Просмотр исходного кода

chore: Merge branch 'main' into minor

daiwei 1 месяц назад
Родитель
Сommit
7bf898b23d
32 измененных файлов с 933 добавлено и 418 удалено
  1. 1 1
      .github/workflows/size-data.yml
  2. 19 0
      changelogs/CHANGELOG-3.5.md
  3. 2 2
      package.json
  4. 88 11
      packages-private/dts-test/defineComponent.test-d.tsx
  5. 16 1
      packages-private/dts-test/tsx.test-d.tsx
  6. 0 1
      packages-private/sfc-playground/src/Header.vue
  7. 1 0
      packages-private/sfc-playground/src/VersionSelect.vue
  8. 1 1
      packages/compiler-core/package.json
  9. 2 2
      packages/compiler-sfc/package.json
  10. 36 1
      packages/reactivity/__tests__/collections/Set.spec.ts
  11. 13 0
      packages/reactivity/__tests__/reactive.spec.ts
  12. 35 1
      packages/reactivity/__tests__/reactiveArray.spec.ts
  13. 10 2
      packages/reactivity/src/arrayInstrumentations.ts
  14. 13 6
      packages/reactivity/src/collectionHandlers.ts
  15. 20 4
      packages/runtime-core/__tests__/helpers/renderList.spec.ts
  16. 1 1
      packages/runtime-core/src/apiDefineComponent.ts
  17. 37 3
      packages/runtime-core/src/apiSetupHelpers.ts
  18. 12 5
      packages/runtime-core/src/component.ts
  19. 2 1
      packages/runtime-core/src/componentPublicInstance.ts
  20. 10 6
      packages/runtime-core/src/helpers/renderList.ts
  21. 2 0
      packages/runtime-core/src/index.ts
  22. 4 1
      packages/runtime-core/src/renderer.ts
  23. 266 0
      packages/runtime-dom/__tests__/customElement.spec.ts
  24. 54 4
      packages/runtime-dom/src/apiCustomElement.ts
  25. 6 0
      packages/runtime-dom/src/index.ts
  26. 18 1
      packages/runtime-dom/src/patchProp.ts
  27. 20 1
      packages/server-renderer/__tests__/ssrRenderList.spec.ts
  28. 84 0
      packages/server-renderer/__tests__/ssrWatch.spec.ts
  29. 4 2
      packages/server-renderer/src/helpers/ssrRenderList.ts
  30. 1 0
      packages/vue-compat/package.json
  31. 154 360
      pnpm-lock.yaml
  32. 1 0
      pnpm-workspace.yaml

+ 1 - 1
.github/workflows/size-data.yml

@@ -39,7 +39,7 @@ jobs:
           echo ${{ github.base_ref }} > ./temp/size/base.txt
 
       - name: Upload Size Data
-        uses: actions/upload-artifact@v6
+        uses: actions/upload-artifact@v7
         with:
           name: size-data
           path: temp/size

+ 19 - 0
changelogs/CHANGELOG-3.5.md

@@ -1,3 +1,22 @@
+## [3.5.30](https://github.com/vuejs/core/compare/v3.5.29...v3.5.30) (2026-03-09)
+
+
+### Bug Fixes
+
+* **compat:** add `entities` to @vue/compat deps to fix CJS edge cases ([#12514](https://github.com/vuejs/core/issues/12514)) ([e725a67](https://github.com/vuejs/core/commit/e725a679e434a688c3493fc9af496501a8d1eeec)), closes [#10609](https://github.com/vuejs/core/issues/10609)
+* **custom-element:** ensure child component styles are injected in correct order before parent styles ([#13374](https://github.com/vuejs/core/issues/13374)) ([1398bf8](https://github.com/vuejs/core/commit/1398bf8dfbfef6b1bca154fc98d37044011a51be)), closes [#13029](https://github.com/vuejs/core/issues/13029)
+* **custom-element:** properly locate parent when slotted in shadow dom ([#12480](https://github.com/vuejs/core/issues/12480)) ([f06c81a](https://github.com/vuejs/core/commit/f06c81aa3dddbeff8bc2e2e63c0b6b6debcbdc13)), closes [#12479](https://github.com/vuejs/core/issues/12479)
+* **custom-element:** should properly patch as props for vue custom elements ([#12409](https://github.com/vuejs/core/issues/12409)) ([740983e](https://github.com/vuejs/core/commit/740983e6736255b183ee27a3f8b40e89ad7e3ba3)), closes [#12408](https://github.com/vuejs/core/issues/12408)
+* **reactivity:** avoid duplicate raw/proxy entries in Set.add ([#14545](https://github.com/vuejs/core/issues/14545)) ([d943612](https://github.com/vuejs/core/commit/d943612e59feb656e16568dea77b97856923c58c))
+* **reactivity:** fix reduce on reactive arrays to preserve reactivity ([#12737](https://github.com/vuejs/core/issues/12737)) ([16ef165](https://github.com/vuejs/core/commit/16ef165415224def18ec8247dabb84d5a1338c17)), closes [#12735](https://github.com/vuejs/core/issues/12735)
+* **reactivity:** handle `Set` with initial reactive values edge case ([#12393](https://github.com/vuejs/core/issues/12393)) ([5dc27ca](https://github.com/vuejs/core/commit/5dc27ca68fdbab95b37af15870d91515fc2412b2)), closes [#8647](https://github.com/vuejs/core/issues/8647)
+* **runtime-core:** warn about negative number in v-for ([#12308](https://github.com/vuejs/core/issues/12308)) ([9438cc5](https://github.com/vuejs/core/commit/9438cc54506a38038a1bf4b6698025f9a4cafb48))
+* **ssr:** prevent watch from firing after async setup await ([#14547](https://github.com/vuejs/core/issues/14547)) ([6cda71d](https://github.com/vuejs/core/commit/6cda71d48bd45c5e0ed2822866b83c4fafff1be9)), closes [#14546](https://github.com/vuejs/core/issues/14546)
+* **types:** make generics with runtime props in defineComponent work (fix [#11374](https://github.com/vuejs/core/issues/11374)) ([#13119](https://github.com/vuejs/core/issues/13119)) ([cea3cf7](https://github.com/vuejs/core/commit/cea3cf758645c9683db51822590b073ce3069dda)), closes [#13763](https://github.com/vuejs/core/issues/13763)
+* **types:** narrow useAttrs class/style typing for TSX ([#14492](https://github.com/vuejs/core/issues/14492)) ([bbb8977](https://github.com/vuejs/core/commit/bbb89775b137eac12b92ae4eb49999a7fd6b52b0)), closes [#14489](https://github.com/vuejs/core/issues/14489)
+
+
+
 ## [3.5.29](https://github.com/vuejs/core/compare/v3.5.28...v3.5.29) (2026-02-24)
 
 

+ 2 - 2
package.json

@@ -53,7 +53,7 @@
     "@babel/types": "catalog:",
     "@rolldown/plugin-node-polyfills": "^1.0.3",
     "@types/hash-sum": "^1.0.2",
-    "@types/node": "^24.10.13",
+    "@types/node": "^24.12.0",
     "@types/semver": "^7.7.1",
     "@types/serve-handler": "^6.1.4",
     "@vitest/coverage-v8": "^4.0.18",
@@ -75,7 +75,7 @@
     "picocolors": "^1.1.1",
     "pretty-bytes": "^7.1.0",
     "pug": "^3.0.3",
-    "puppeteer": "~24.37.5",
+    "puppeteer": "~24.38.0",
     "rimraf": "^6.1.3",
     "rolldown": "^1.0.0-rc.6",
     "rolldown-plugin-dts": "^0.22.3",

+ 88 - 11
packages-private/dts-test/defineComponent.test-d.tsx

@@ -1402,7 +1402,7 @@ describe('function syntax w/ emits', () => {
 describe('function syntax w/ runtime props', () => {
   // with runtime props, the runtime props must match
   // manual type declaration
-  defineComponent(
+  const Comp1 = defineComponent(
     (_props: { msg: string }) => {
       return () => {}
     },
@@ -1411,7 +1411,34 @@ describe('function syntax w/ runtime props', () => {
     },
   )
 
+  // @ts-expect-error bar isn't specified in props definition
   defineComponent(
+    (_props: { msg: string }) => {
+      return () => {}
+    },
+    {
+      props: ['msg', 'bar'],
+    },
+  )
+
+  defineComponent(
+    (_props: { msg: string; bar: string }) => {
+      return () => {}
+    },
+    {
+      props: ['msg'],
+    },
+  )
+
+  expectType<JSX.Element>(<Comp1 msg="1" />)
+  // @ts-expect-error msg type is incorrect
+  expectType<JSX.Element>(<Comp1 msg={1} />)
+  // @ts-expect-error msg is missing
+  expectType<JSX.Element>(<Comp1 />)
+  // @ts-expect-error bar doesn't exist
+  expectType<JSX.Element>(<Comp1 msg="1" bar="2" />)
+
+  const Comp2 = defineComponent(
     <T extends string>(_props: { msg: T }) => {
       return () => {}
     },
@@ -1420,7 +1447,36 @@ describe('function syntax w/ runtime props', () => {
     },
   )
 
+  // @ts-expect-error bar isn't specified in props definition
   defineComponent(
+    <T extends string>(_props: { msg: T }) => {
+      return () => {}
+    },
+    {
+      props: ['msg', 'bar'],
+    },
+  )
+
+  defineComponent(
+    <T extends string>(_props: { msg: T; bar: T }) => {
+      return () => {}
+    },
+    {
+      props: ['msg'],
+    },
+  )
+
+  expectType<JSX.Element>(<Comp2 msg="1" />)
+  expectType<JSX.Element>(<Comp2<string> msg="1" />)
+  // @ts-expect-error msg type is incorrect
+  expectType<JSX.Element>(<Comp2 msg={1} />)
+  // @ts-expect-error msg is missing
+  expectType<JSX.Element>(<Comp2 />)
+  // @ts-expect-error bar doesn't exist
+  expectType<JSX.Element>(<Comp2 msg="1" bar="2" />)
+
+  // Note: generics aren't supported with object runtime props
+  const Comp3 = defineComponent(
     <T extends string>(_props: { msg: T }) => {
       return () => {}
     },
@@ -1431,37 +1487,58 @@ describe('function syntax w/ runtime props', () => {
     },
   )
 
-  // @ts-expect-error string prop names don't match
   defineComponent(
-    (_props: { msg: string }) => {
+    // @ts-expect-error bar isn't specified in props definition
+    <T extends string>(_props: { msg: T }) => {
       return () => {}
     },
     {
-      props: ['bar'],
+      props: {
+        bar: String,
+      },
     },
   )
 
   defineComponent(
-    (_props: { msg: string }) => {
+    // @ts-expect-error generics aren't supported with object runtime props
+    <T extends string>(_props: { msg: T; bar: T }) => {
       return () => {}
     },
     {
       props: {
-        // @ts-expect-error prop type mismatch
-        msg: Number,
+        msg: String,
       },
     },
   )
 
-  // @ts-expect-error prop keys don't match
+  expectType<JSX.Element>(<Comp3 msg="1" />)
+  // @ts-expect-error generics aren't supported with object runtime props
+  expectType<JSX.Element>(<Comp3<string> msg="1" />)
+  // @ts-expect-error msg type is incorrect
+  expectType<JSX.Element>(<Comp3 msg={1} />)
+  // @ts-expect-error msg is missing
+  expectType<JSX.Element>(<Comp3 />)
+  // @ts-expect-error bar doesn't exist
+  expectType<JSX.Element>(<Comp3 msg="1" bar="2" />)
+
+  // @ts-expect-error string prop names don't match
   defineComponent(
-    (_props: { msg: string }, ctx) => {
+    (_props: { msg: string }) => {
+      return () => {}
+    },
+    {
+      props: ['bar'],
+    },
+  )
+
+  defineComponent(
+    (_props: { msg: string }) => {
       return () => {}
     },
     {
       props: {
-        msg: String,
-        bar: String,
+        // @ts-expect-error prop type mismatch
+        msg: Number,
       },
     },
   )

+ 16 - 1
packages-private/dts-test/tsx.test-d.tsx

@@ -1,5 +1,12 @@
 // TSX w/ defineComponent is tested in defineComponent.test-d.tsx
-import { Fragment, KeepAlive, Suspense, Teleport, type VNode } from 'vue'
+import {
+  Fragment,
+  KeepAlive,
+  Suspense,
+  Teleport,
+  type VNode,
+  useAttrs,
+} from 'vue'
 import { expectType } from './utils'
 
 expectType<VNode>(<div />)
@@ -54,6 +61,14 @@ expectType<JSX.Element>(
   />,
 )
 
+// allow class/style passthrough from attrs
+const attrs = useAttrs()
+expectType<JSX.Element>(<div class={attrs.class} />)
+expectType<JSX.Element>(<div style={attrs.style} />)
+
+// @ts-expect-error invalid class value
+;<div class={0} />
+
 // #7955
 expectType<JSX.Element>(<div style={[undefined, '', null, false]} />)
 

+ 0 - 1
packages-private/sfc-playground/src/Header.vue

@@ -171,7 +171,6 @@ nav {
   background-color: var(--bg);
   box-shadow: 0 0 4px rgba(0, 0, 0, 0.33);
   position: relative;
-  z-index: 999;
   display: flex;
   justify-content: space-between;
 }

+ 1 - 0
packages-private/sfc-playground/src/VersionSelect.vue

@@ -106,6 +106,7 @@ onMounted(() => {
 
 <style>
 .version {
+  z-index: 1;
   margin-right: 12px;
   position: relative;
 }

+ 1 - 1
packages/compiler-core/package.json

@@ -48,7 +48,7 @@
   "dependencies": {
     "@babel/parser": "catalog:",
     "@vue/shared": "workspace:*",
-    "entities": "^7.0.1",
+    "entities": "catalog:",
     "estree-walker": "catalog:",
     "source-map-js": "catalog:"
   },

+ 2 - 2
packages/compiler-sfc/package.json

@@ -50,7 +50,7 @@
     "@vue/shared": "workspace:*",
     "estree-walker": "catalog:",
     "magic-string": "catalog:",
-    "postcss": "^8.5.6",
+    "postcss": "^8.5.8",
     "source-map-js": "catalog:"
   },
   "devDependencies": {
@@ -59,7 +59,7 @@
     "hash-sum": "^2.0.0",
     "lru-cache": "10.1.0",
     "merge-source-map": "^1.1.0",
-    "minimatch": "~10.2.0",
+    "minimatch": "~10.2.4",
     "postcss-modules": "^6.0.1",
     "postcss-selector-parser": "^7.1.1",
     "pug": "^3.0.3",

+ 36 - 1
packages/reactivity/__tests__/collections/Set.spec.ts

@@ -1,4 +1,11 @@
-import { effect, isReactive, reactive, toRaw } from '../../src'
+import {
+  effect,
+  isReactive,
+  reactive,
+  readonly,
+  shallowReactive,
+  toRaw,
+} from '../../src'
 
 describe('reactivity/collections', () => {
   function coverCollectionFn(collection: Set<any>, fnName: string) {
@@ -403,6 +410,34 @@ describe('reactivity/collections', () => {
       expect(dummy).toBe(false)
     })
 
+    it('should not add readonly versions of existing raw values', () => {
+      const rawValue = {}
+      const wrappedValue = readonly(rawValue)
+      const set = reactive(new Set([rawValue]))
+
+      expect(set.has(wrappedValue)).toBe(true)
+
+      set.add(wrappedValue)
+
+      expect(set.size).toBe(1)
+      expect(toRaw(set).has(rawValue)).toBe(true)
+      expect(toRaw(set).has(wrappedValue)).toBe(false)
+    })
+
+    it('should not add proxy versions of existing raw values to shallow sets', () => {
+      const rawValue = {}
+      const wrappedValue = reactive(rawValue)
+      const set = shallowReactive(new Set([rawValue]))
+
+      expect(set.has(wrappedValue)).toBe(true)
+
+      set.add(wrappedValue)
+
+      expect(set.size).toBe(1)
+      expect(toRaw(set).has(rawValue)).toBe(true)
+      expect(toRaw(set).has(wrappedValue)).toBe(false)
+    })
+
     it('should warn when set contains both raw and reactive versions of the same object', () => {
       const raw = new Set()
       const rawKey = {}

+ 13 - 0
packages/reactivity/__tests__/reactive.spec.ts

@@ -112,6 +112,19 @@ describe('reactivity/reactive', () => {
     expect(dummy).toBe(false)
   })
 
+  // #8647
+  test('observing Set with reactive initial value', () => {
+    const observed = reactive({})
+    const observedSet = reactive(new Set([observed]))
+
+    expect(observedSet.has(observed)).toBe(true)
+    expect(observedSet.size).toBe(1)
+
+    // expect nothing happens
+    observedSet.add(observed)
+    expect(observedSet.size).toBe(1)
+  })
+
   test('observed value should proxy mutations to original (Object)', () => {
     const original: any = { foo: 1 }
     const observed = reactive(original)

+ 35 - 1
packages/reactivity/__tests__/reactiveArray.spec.ts

@@ -627,7 +627,7 @@ describe('reactivity/reactive/Array', () => {
       expect(left.value).toBe(shallow[0])
       expect(right.value).toBe(shallow[0])
 
-      const deep = reactive([{ val: 1 }, { val: 2 }])
+      let deep = reactive([{ val: 1 }, { val: 2 }])
       left = computed(() => deep.reduce((acc, x) => acc + x.val, '0'))
       right = computed(() => deep.reduceRight((acc, x) => acc + x.val, '3'))
       expect(left.value).toBe('012')
@@ -636,6 +636,40 @@ describe('reactivity/reactive/Array', () => {
       deep[1].val = 23
       expect(left.value).toBe('0123')
       expect(right.value).toBe('3231')
+
+      deep = reactive([{ val: 1 }, { val: 2 }])
+      const maxBy = (prev: any, cur: any) => {
+        expect(isReactive(prev)).toBe(true)
+        expect(isReactive(cur)).toBe(true)
+        return prev.val > cur.val ? prev : cur
+      }
+      left = computed(() => deep.reduce(maxBy))
+      right = computed(() => deep.reduceRight(maxBy))
+      expect(left.value).toMatchObject({ val: 2 })
+      expect(right.value).toMatchObject({ val: 2 })
+
+      deep[0].val = 23
+      expect(left.value).toMatchObject({ val: 23 })
+      expect(right.value).toMatchObject({ val: 23 })
+
+      deep[1].val = 24
+      expect(left.value).toMatchObject({ val: 24 })
+      expect(right.value).toMatchObject({ val: 24 })
+    })
+
+    test('reduce left and right with single deep reactive element and no initial value', () => {
+      const deep = reactive([{ val: 1 }])
+      const left = computed(() => deep.reduce(prev => prev))
+      const right = computed(() => deep.reduceRight(prev => prev))
+
+      expect(isReactive(left.value)).toBe(true)
+      expect(isReactive(right.value)).toBe(true)
+      expect(left.value).toMatchObject({ val: 1 })
+      expect(right.value).toMatchObject({ val: 1 })
+
+      deep[0].val = 2
+      expect(left.value).toMatchObject({ val: 2 })
+      expect(right.value).toMatchObject({ val: 2 })
     })
 
     test('some', () => {

+ 10 - 2
packages/reactivity/src/arrayInstrumentations.ts

@@ -313,10 +313,17 @@ function reduce(
   args: unknown[],
 ) {
   const arr = shallowReadArray(self)
+  const needsWrap = arr !== self && !isShallow(self)
   let wrappedFn = fn
+  let wrapInitialAccumulator = false
   if (arr !== self) {
-    if (!isShallow(self)) {
+    if (needsWrap) {
+      wrapInitialAccumulator = args.length === 0
       wrappedFn = function (this: unknown, acc, item, index) {
+        if (wrapInitialAccumulator) {
+          wrapInitialAccumulator = false
+          acc = toWrapped(self, acc)
+        }
         return fn.call(this, acc, toWrapped(self, item), index, self)
       }
     } else if (fn.length > 3) {
@@ -325,7 +332,8 @@ function reduce(
       }
     }
   }
-  return (arr[method] as any)(wrappedFn, ...args)
+  const result = (arr[method] as any)(wrappedFn, ...args)
+  return wrapInitialAccumulator ? toWrapped(self, result) : result
 }
 
 // instrument identity-sensitive methods to account for reactive proxies

+ 13 - 6
packages/reactivity/src/collectionHandlers.ts

@@ -167,15 +167,22 @@ function createInstrumentations(
         }
       : {
           add(this: SetTypes, value: unknown) {
-            if (!shallow && !isShallow(value) && !isReadonly(value)) {
-              value = toRaw(value)
-            }
             const target = toRaw(this)
             const proto = getProto(target)
-            const hadKey = proto.has.call(target, value)
+            const rawValue = toRaw(value)
+            const valueToAdd =
+              !shallow && !isShallow(value) && !isReadonly(value)
+                ? rawValue
+                : value
+            const hadKey =
+              proto.has.call(target, valueToAdd) ||
+              (hasChanged(value, valueToAdd) &&
+                proto.has.call(target, value)) ||
+              (hasChanged(rawValue, valueToAdd) &&
+                proto.has.call(target, rawValue))
             if (!hadKey) {
-              target.add(value)
-              trigger(target, TriggerOpTypes.ADD, value, value)
+              target.add(valueToAdd)
+              trigger(target, TriggerOpTypes.ADD, valueToAdd, valueToAdd)
             }
             return this
           },

+ 20 - 4
packages/runtime-core/__tests__/helpers/renderList.spec.ts

@@ -29,14 +29,26 @@ describe('renderList', () => {
   })
 
   it('should warn when given a non-integer N', () => {
-    try {
-      renderList(3.1, () => {})
-    } catch (e) {}
+    expect(renderList(3.1, () => {})).toEqual([])
     expect(
-      `The v-for range expect an integer value but got 3.1.`,
+      `The v-for range expects a positive integer value but got 3.1.`,
     ).toHaveBeenWarned()
   })
 
+  it('should warn when given a negative N', () => {
+    expect(renderList(-1, () => {})).toEqual([])
+    expect(
+      `The v-for range expects a positive integer value but got -1.`,
+    ).toHaveBeenWarned()
+  })
+
+  it('should not warn when given 0', () => {
+    renderList(0, () => {})
+    expect(
+      `The v-for range expects a positive integer value but got 0.`,
+    ).not.toHaveBeenWarned()
+  })
+
   it('should render properties in an object', () => {
     expect(
       renderList(
@@ -58,6 +70,10 @@ describe('renderList', () => {
     ).toEqual(['node 0: 1', 'node 1: 2', 'node 2: 3'])
   })
 
+  it('should return empty array when source is 0', () => {
+    expect(renderList(0, (item, index) => `node ${index}: ${item}`)).toEqual([])
+  })
+
   it('should return empty array when source is undefined', () => {
     expect(
       renderList(undefined, (item, index) => `node ${index}: ${item}`),

+ 1 - 1
packages/runtime-core/src/apiDefineComponent.ts

@@ -157,7 +157,7 @@ export function defineComponent<
     ctx: SetupContext<E, S>,
   ) => RenderFunction | Promise<RenderFunction>,
   options?: Pick<ComponentOptions, 'name' | 'inheritAttrs'> & {
-    props?: (keyof Props)[]
+    props?: (keyof NoInfer<Props>)[]
     emits?: E | EE[]
     slots?: S
   },

+ 37 - 3
packages/runtime-core/src/apiSetupHelpers.ts

@@ -13,7 +13,9 @@ import {
   type SetupContext,
   createSetupContext,
   getCurrentGenericInstance,
+  isInSSRComponentSetup,
   setCurrentInstance,
+  setInSSRSetupState,
 } from './component'
 import type { EmitFn, EmitsOptions, ObjectEmitsOptions } from './componentEmits'
 import type {
@@ -231,6 +233,22 @@ export function defineOptions<
   }
 }
 
+/**
+ * Vue `<script setup>` compiler macro for providing type hints to IDEs for
+ * slot name and slot props type checking.
+ *
+ * Example usage:
+ * ```ts
+ * const slots = defineSlots<{
+ *   default(props: { msg: string }): any
+ * }>()
+ * ```
+ *
+ * This is only usable inside `<script setup>`, is compiled away in the
+ * output and should **not** be actually called at runtime.
+ *
+ * @see {@link https://vuejs.org/api/sfc-script-setup.html#defineslots}
+ */
 export function defineSlots<
   S extends Record<string, any> = Record<string, any>,
 >(): StrictUnwrapSlotsType<SlotsType<S>> {
@@ -512,6 +530,7 @@ export function createPropsRestProxy(
  */
 export function withAsyncContext(getAwaitable: () => any): [any, () => void] {
   const ctx = getCurrentGenericInstance()!
+  const inSSRSetup = isInSSRComponentSetup
   if (__DEV__ && !ctx) {
     warn(
       `withAsyncContext called without active current instance. ` +
@@ -520,15 +539,30 @@ export function withAsyncContext(getAwaitable: () => any): [any, () => void] {
   }
   let awaitable = getAwaitable()
   setCurrentInstance(null, undefined)
+  if (inSSRSetup) {
+    setInSSRSetupState(false)
+  }
+
+  const restore = () => {
+    setCurrentInstance(ctx)
+    if (inSSRSetup) {
+      setInSSRSetupState(true)
+    }
+  }
 
   // Never restore a captured "prev" instance here: in concurrent async setup
   // continuations it may belong to a sibling component and cause leaks.
   // clear global currentInstance for user microtasks.
-  const cleanup = () => setCurrentInstance(null, undefined)
+  const cleanup = () => {
+    setCurrentInstance(null, undefined)
+    if (inSSRSetup) {
+      setInSSRSetupState(false)
+    }
+  }
 
   if (isPromise(awaitable)) {
     awaitable = awaitable.catch(e => {
-      setCurrentInstance(ctx)
+      restore()
       // Defer cleanup so the async function's catch continuation
       // still runs with the restored instance.
       Promise.resolve().then(() => Promise.resolve().then(cleanup))
@@ -538,7 +572,7 @@ export function withAsyncContext(getAwaitable: () => any): [any, () => void] {
   return [
     awaitable,
     () => {
-      setCurrentInstance(ctx)
+      restore()
       // Keep instance for the current continuation, then cleanup.
       Promise.resolve().then(cleanup)
     },

+ 12 - 5
packages/runtime-core/src/component.ts

@@ -102,6 +102,13 @@ export * from './componentCurrentInstance'
 
 export type Data = Record<string, unknown>
 
+/**
+ * For extending allowed non-declared attrs on components in TSX
+ */
+export interface AllowedAttrs {}
+
+export type Attrs = Data & AllowedAttrs
+
 /**
  * Public utility type for extracting the instance type of a component.
  * Works with all valid component definition types. This is intended to replace
@@ -331,7 +338,7 @@ export type SetupContext<
   S extends SlotsType = {},
 > = E extends any
   ? {
-      attrs: Data
+      attrs: Attrs
       slots: UnwrapSlotsType<S>
       emit: EmitFn<E>
       expose: <Exposed extends Record<string, any> = Record<string, any>>(
@@ -1199,13 +1206,13 @@ export function createSetupContext(
   if (__DEV__) {
     // We use getters in dev in case libs like test-utils overwrite instance
     // properties (overwrites should not be done in prod)
-    let attrsProxy: Data
+    let attrsProxy: Attrs
     let slotsProxy: Slots
     return Object.freeze({
       get attrs() {
         return (
           attrsProxy ||
-          (attrsProxy = new Proxy(instance.attrs, attrsProxyHandlers))
+          (attrsProxy = new Proxy(instance.attrs, attrsProxyHandlers) as Attrs)
         )
       },
       get slots() {
@@ -1218,7 +1225,7 @@ export function createSetupContext(
     })
   } else {
     return {
-      attrs: new Proxy(instance.attrs, attrsProxyHandlers),
+      attrs: new Proxy(instance.attrs, attrsProxyHandlers) as Attrs,
       slots: instance.slots,
       emit: instance.emit,
       expose: exposed => expose(instance, exposed as any),
@@ -1347,7 +1354,7 @@ export interface ComponentCustomElementInterface {
   /**
    * @internal
    */
-  _injectChildStyle(type: ConcreteComponent): void
+  _injectChildStyle(type: ConcreteComponent, parent?: ConcreteComponent): void
   /**
    * @internal
    */

+ 2 - 1
packages/runtime-core/src/componentPublicInstance.ts

@@ -1,4 +1,5 @@
 import {
+  type Attrs,
   type Component,
   type ComponentInternalInstance,
   type Data,
@@ -307,7 +308,7 @@ export type ComponentPublicInstance<
   $props: MakeDefaultsOptional extends true
     ? Partial<Defaults> & Omit<Prettify<P> & PublicProps, keyof Defaults>
     : Prettify<P> & PublicProps
-  $attrs: Data
+  $attrs: Attrs
   $refs: Data & TypeRefs
   $slots: UnwrapSlotsType<S>
   $root: ComponentPublicInstance | null

+ 10 - 6
packages/runtime-core/src/helpers/renderList.ts

@@ -91,12 +91,16 @@ export function renderList(
       )
     }
   } else if (typeof source === 'number') {
-    if (__DEV__ && !Number.isInteger(source)) {
-      warn(`The v-for range expect an integer value but got ${source}.`)
-    }
-    ret = new Array(source)
-    for (let i = 0; i < source; i++) {
-      ret[i] = renderItem(i + 1, i, undefined, cached && cached[i])
+    if (__DEV__ && (!Number.isInteger(source) || source < 0)) {
+      warn(
+        `The v-for range expects a positive integer value but got ${source}.`,
+      )
+      ret = []
+    } else {
+      ret = new Array(source)
+      for (let i = 0; i < source; i++) {
+        ret[i] = renderItem(i + 1, i, undefined, cached && cached[i])
+      }
     }
   } else if (isObject(source)) {
     if (source[Symbol.iterator as any]) {

+ 2 - 0
packages/runtime-core/src/index.ts

@@ -271,7 +271,9 @@ export type {
   ConcreteComponent,
   FunctionalComponent,
   ComponentInternalInstance,
+  Attrs,
   SetupContext,
+  AllowedAttrs,
   ComponentCustomProps,
   AllowedComponentProps,
   GlobalComponents,

+ 4 - 1
packages/runtime-core/src/renderer.ts

@@ -1527,7 +1527,10 @@ function baseCreateRenderer(
             (root as ComponentInternalInstance).ce &&
             (root as ComponentInternalInstance).ce!._hasShadowRoot()
           ) {
-            ;(root as ComponentInternalInstance).ce!._injectChildStyle(type)
+            ;(root as ComponentInternalInstance).ce!._injectChildStyle(
+              type,
+              instance.parent ? instance.parent.type : undefined,
+            )
           }
 
           if (__DEV__) {

+ 266 - 0
packages/runtime-dom/__tests__/customElement.spec.ts

@@ -711,6 +711,27 @@ describe('defineCustomElement', () => {
       expect(e.shadowRoot!.innerHTML).toBe('<div></div>')
     })
 
+    // #12408
+    test('should set number tabindex as attribute', () => {
+      render(h('my-el-attrs', { tabindex: 1, 'data-test': true }), container)
+      const el = container.children[0] as HTMLElement
+      expect(el.getAttribute('tabindex')).toBe('1')
+      expect(el.getAttribute('data-test')).toBe('true')
+    })
+
+    test('should keep undeclared native attrs as attrs', () => {
+      const root = document.createElement('div')
+      document.body.appendChild(root)
+
+      render(h('my-el-attrs', { translate: 'no' }), root)
+      const el = root.children[0] as HTMLElement
+      expect(el.getAttribute('translate')).toBe('no')
+      expect(el.translate).toBe(false)
+
+      render(null, root)
+      root.remove()
+    })
+
     // https://github.com/vuejs/core/issues/12964
     // Disabled because of missing support for `delegatesFocus` in jsdom
     // https://github.com/jsdom/jsdom/issues/3418
@@ -995,6 +1016,31 @@ describe('defineCustomElement', () => {
       )
     })
 
+    test('should resolve correct parent when element is slotted in shadow DOM', async () => {
+      const GrandParent = defineCustomElement({
+        provide: {
+          foo: ref('GrandParent'),
+        },
+        render() {
+          return h('my-parent-in-shadow', h('slot'))
+        },
+      })
+      const Parent = defineCustomElement({
+        provide: {
+          foo: ref('Parent'),
+        },
+        render() {
+          return h('slot')
+        },
+      })
+      customElements.define('my-grand-parent', GrandParent)
+      customElements.define('my-parent-in-shadow', Parent)
+      container.innerHTML = `<my-grand-parent><my-consumer></my-consumer></my-grand-parent>`
+      const grandParent = container.childNodes[0] as VueElement,
+        consumer = grandParent.firstElementChild as VueElement
+      expect(consumer.shadowRoot!.textContent).toBe('Parent')
+    })
+
     // #13212
     test('inherited from app context within nested elements', async () => {
       const outerValues: (string | undefined)[] = []
@@ -1172,6 +1218,190 @@ describe('defineCustomElement', () => {
       assertStyles(el, [`div { color: blue; }`, `div { color: red; }`])
     })
 
+    test('root custom element HMR should preserve child-first style order', async () => {
+      const Child = defineComponent({
+        styles: [`div { color: green; }`],
+        render() {
+          return 'child'
+        },
+      })
+      const def = defineComponent({
+        __hmrId: 'root-child-style-order',
+        styles: [`div { color: red; }`],
+        render() {
+          return h(Child)
+        },
+      })
+      const Foo = defineCustomElement(def)
+      customElements.define('my-el-root-hmr-style-order', Foo)
+      container.innerHTML = `<my-el-root-hmr-style-order></my-el-root-hmr-style-order>`
+      const el = container.childNodes[0] as VueElement
+
+      assertStyles(el, [`div { color: green; }`, `div { color: red; }`])
+
+      __VUE_HMR_RUNTIME__.reload(def.__hmrId!, {
+        ...def,
+        styles: [`div { color: blue; }`, `div { color: yellow; }`],
+      } as any)
+
+      await nextTick()
+      assertStyles(el, [
+        `div { color: green; }`,
+        `div { color: blue; }`,
+        `div { color: yellow; }`,
+      ])
+    })
+
+    test('inject child component styles before parent styles', async () => {
+      const Baz = () => h(Bar)
+      const Bar = defineComponent({
+        styles: [`div { color: green; }`],
+        render() {
+          return 'bar'
+        },
+      })
+      const WrapperBar = defineComponent({
+        styles: [`div { color: blue; }`],
+        render() {
+          return h(Baz)
+        },
+      })
+      const WBaz = () => h(WrapperBar)
+      const Foo = defineCustomElement({
+        styles: [`div { color: red; }`],
+        render() {
+          return [h(Baz), h(WBaz)]
+        },
+      })
+      customElements.define('my-el-with-wrapper-child-styles', Foo)
+      container.innerHTML = `<my-el-with-wrapper-child-styles></my-el-with-wrapper-child-styles>`
+      const el = container.childNodes[0] as VueElement
+
+      // inject order should be child -> parent
+      assertStyles(el, [
+        `div { color: green; }`,
+        `div { color: blue; }`,
+        `div { color: red; }`,
+      ])
+    })
+
+    test('inject nested child component styles after HMR removes parent styles', async () => {
+      const Bar = defineComponent({
+        __hmrId: 'nested-child-style-hmr-bar',
+        styles: [`div { color: green; }`],
+        render() {
+          return 'bar'
+        },
+      })
+      const WrapperBar = defineComponent({
+        __hmrId: 'nested-child-style-hmr-wrapper',
+        styles: [`div { color: blue; }`],
+        render() {
+          return h(Bar)
+        },
+      })
+      const Foo = defineCustomElement({
+        styles: [`div { color: red; }`],
+        render() {
+          return h(WrapperBar)
+        },
+      })
+      customElements.define('my-el-with-hmr-nested-child-styles', Foo)
+      container.innerHTML = `<my-el-with-hmr-nested-child-styles></my-el-with-hmr-nested-child-styles>`
+      const el = container.childNodes[0] as VueElement
+
+      assertStyles(el, [
+        `div { color: green; }`,
+        `div { color: blue; }`,
+        `div { color: red; }`,
+      ])
+
+      __VUE_HMR_RUNTIME__.reload(WrapperBar.__hmrId!, {
+        ...WrapperBar,
+        styles: undefined,
+      } as any)
+      await nextTick()
+      assertStyles(el, [`div { color: green; }`, `div { color: red; }`])
+
+      __VUE_HMR_RUNTIME__.reload(Bar.__hmrId!, {
+        ...Bar,
+        styles: [`div { color: yellow; }`],
+      } as any)
+      await nextTick()
+      assertStyles(el, [`div { color: yellow; }`, `div { color: red; }`])
+    })
+
+    test('inject child component styles when parent has no styles', async () => {
+      const Baz = () => h(Bar)
+      const Bar = defineComponent({
+        styles: [`div { color: green; }`],
+        render() {
+          return 'bar'
+        },
+      })
+      const WrapperBar = defineComponent({
+        styles: [`div { color: blue; }`],
+        render() {
+          return h(Baz)
+        },
+      })
+      const WBaz = () => h(WrapperBar)
+      // without styles
+      const Foo = defineCustomElement({
+        render() {
+          return [h(Baz), h(WBaz)]
+        },
+      })
+      customElements.define('my-el-with-inject-child-styles', Foo)
+      container.innerHTML = `<my-el-with-inject-child-styles></my-el-with-inject-child-styles>`
+      const el = container.childNodes[0] as VueElement
+
+      assertStyles(el, [`div { color: green; }`, `div { color: blue; }`])
+    })
+
+    test('inject nested child component styles', async () => {
+      const Baz = defineComponent({
+        styles: [`div { color: yellow; }`],
+        render() {
+          return h(Bar)
+        },
+      })
+      const Bar = defineComponent({
+        styles: [`div { color: green; }`],
+        render() {
+          return 'bar'
+        },
+      })
+      const WrapperBar = defineComponent({
+        styles: [`div { color: blue; }`],
+        render() {
+          return h(Baz)
+        },
+      })
+      const WBaz = defineComponent({
+        styles: [`div { color: black; }`],
+        render() {
+          return h(WrapperBar)
+        },
+      })
+      const Foo = defineCustomElement({
+        styles: [`div { color: red; }`],
+        render() {
+          return [h(Baz), h(WBaz)]
+        },
+      })
+      customElements.define('my-el-with-inject-nested-child-styles', Foo)
+      container.innerHTML = `<my-el-with-inject-nested-child-styles></my-el-with-inject-nested-child-styles>`
+      const el = container.childNodes[0] as VueElement
+      assertStyles(el, [
+        `div { color: green; }`,
+        `div { color: yellow; }`,
+        `div { color: blue; }`,
+        `div { color: black; }`,
+        `div { color: red; }`,
+      ])
+    })
+
     test("child components should not inject styles to root element's shadow root w/ shadowRoot false", async () => {
       const Bar = defineComponent({
         styles: [`div { color: green; }`],
@@ -1308,6 +1538,42 @@ describe('defineCustomElement', () => {
       expect(e2.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
     })
 
+    test('render object prop before resolve', async () => {
+      const AsyncComp = defineComponent({
+        props: { value: Object },
+        render(this: any) {
+          return h('div', this.value.x)
+        },
+      })
+      let resolve!: (comp: typeof AsyncComp) => void
+      const p = new Promise<typeof AsyncComp>(res => {
+        resolve = res
+      })
+      const E = defineCustomElement(defineAsyncComponent(() => p))
+      customElements.define('my-el-async-object-prop', E)
+
+      const root = document.createElement('div')
+      document.body.appendChild(root)
+      const value = { x: 1 }
+
+      render(h('my-el-async-object-prop', { value }), root)
+
+      const el = root.children[0] as VueElement & { value: typeof value }
+      expect(el.value).toBe(value)
+      expect(el.getAttribute('value')).toBe(null)
+
+      resolve(AsyncComp)
+
+      await new Promise(r => setTimeout(r))
+
+      expect(el.value).toBe(value)
+      expect(el.getAttribute('value')).toBe(null)
+      expect(el.shadowRoot!.innerHTML).toBe(`<div>1</div>`)
+
+      render(null, root)
+      root.remove()
+    })
+
     test('Number prop casting before resolve', async () => {
       const E = defineCustomElement(
         defineAsyncComponent(() => {

+ 54 - 4
packages/runtime-dom/src/apiCustomElement.ts

@@ -234,6 +234,8 @@ export abstract class VueElementBase<
   protected _resolved = false
   protected _numberProps: Record<string, true> | null = null
   protected _styleChildren: WeakSet<object> = new WeakSet()
+  protected _styleAnchors: WeakMap<ConcreteComponent, HTMLStyleElement> =
+    new WeakMap()
   protected _pendingResolve: Promise<void> | undefined
   protected _parent: VueElementBase | undefined
   protected _patching = false
@@ -309,7 +311,12 @@ export abstract class VueElementBase<
     // locate nearest Vue custom element parent for provide/inject
     let parent: Node | null = this
     while (
-      (parent = parent && (parent.parentNode || (parent as ShadowRoot).host))
+      (parent =
+        parent &&
+        // #12479 should check assignedSlot first to get correct parent
+        ((parent as Element).assignedSlot ||
+          parent.parentNode ||
+          (parent as ShadowRoot).host))
     ) {
       if (parent instanceof VueElementBase) {
         this._parent = parent
@@ -479,6 +486,7 @@ export abstract class VueElementBase<
           this._styles.forEach(s => this._root.removeChild(s))
           this._styles.length = 0
         }
+        this._styleAnchors.delete(this._def)
         this._applyStyles(newStyles)
         if (!this._instance!.vapor) {
           this._instance = null
@@ -595,6 +603,7 @@ export abstract class VueElementBase<
   protected _applyStyles(
     styles: string[] | undefined,
     owner?: ConcreteComponent,
+    parentComp?: ConcreteComponent,
   ): void {
     if (!styles) return
     if (owner) {
@@ -603,12 +612,25 @@ export abstract class VueElementBase<
       }
       this._styleChildren.add(owner)
     }
+
     const nonce = this._nonce
+    const root = this.shadowRoot!
+    const insertionAnchor = parentComp
+      ? this._getStyleAnchor(parentComp) || this._getStyleAnchor(this._def)
+      : this._getRootStyleInsertionAnchor(root)
+    let last: HTMLStyleElement | null = null
     for (let i = styles.length - 1; i >= 0; i--) {
       const s = document.createElement('style')
       if (nonce) s.setAttribute('nonce', nonce)
       s.textContent = styles[i]
-      this.shadowRoot!.prepend(s)
+
+      root.insertBefore(s, last || insertionAnchor)
+      last = s
+      if (i === 0) {
+        if (!parentComp) this._styleAnchors.set(this._def, s)
+        if (owner) this._styleAnchors.set(owner, s)
+      }
+
       // record for HMR
       if (__DEV__) {
         if (owner) {
@@ -627,6 +649,30 @@ export abstract class VueElementBase<
     }
   }
 
+  private _getStyleAnchor(comp?: ConcreteComponent): HTMLStyleElement | null {
+    if (!comp) {
+      return null
+    }
+    const anchor = this._styleAnchors.get(comp)
+    if (anchor && anchor.parentNode === this.shadowRoot) {
+      return anchor
+    }
+    if (anchor) {
+      this._styleAnchors.delete(comp)
+    }
+    return null
+  }
+
+  private _getRootStyleInsertionAnchor(root: ShadowRoot): ChildNode | null {
+    for (let i = 0; i < root.childNodes.length; i++) {
+      const node = root.childNodes[i]
+      if (!(node instanceof HTMLStyleElement)) {
+        return node
+      }
+    }
+    return null
+  }
+
   /**
    * Only called when shadowRoot is false
    */
@@ -708,8 +754,11 @@ export abstract class VueElementBase<
   /**
    * @internal
    */
-  _injectChildStyle(comp: ConcreteComponent & CustomElementOptions): void {
-    this._applyStyles(comp.styles, comp)
+  _injectChildStyle(
+    comp: ConcreteComponent & CustomElementOptions,
+    parentComp?: ConcreteComponent,
+  ): void {
+    this._applyStyles(comp.styles, comp, parentComp)
   }
 
   /**
@@ -743,6 +792,7 @@ export abstract class VueElementBase<
   _removeChildStyle(comp: ConcreteComponent): void {
     if (__DEV__) {
       this._styleChildren.delete(comp)
+      this._styleAnchors.delete(comp)
       if (this._childStyles && comp.__hmrId) {
         // clear old styles
         const oldStyles = this._childStyles.get(comp.__hmrId)

+ 6 - 0
packages/runtime-dom/src/index.ts

@@ -36,6 +36,7 @@ import type { TransitionGroupProps } from './components/TransitionGroup'
 import type { vShow } from './directives/vShow'
 import type { VOnDirective } from './directives/vOn'
 import type { VModelDirective } from './directives/vModel'
+import type { ClassValue, StyleValue } from './jsx'
 
 /**
  * This is a stub implementation to prevent the need to use dom types.
@@ -51,6 +52,11 @@ declare module '@vue/reactivity' {
 }
 
 declare module '@vue/runtime-core' {
+  interface AllowedAttrs {
+    class?: ClassValue
+    style?: StyleValue
+  }
+
   interface GlobalComponents {
     Transition: DefineComponent<TransitionProps>
     TransitionGroup: DefineComponent<TransitionGroupProps>

+ 18 - 1
packages/runtime-dom/src/patchProp.ts

@@ -55,7 +55,11 @@ export const patchProp: DOMRendererOptions['patchProp'] = (
   } else if (
     // #11081 force set props for possible async custom element
     (el as VueElement)._isVueCE &&
-    (/[A-Z]/.test(key) || !isString(nextValue))
+    // #12408 check if it's declared prop or it's async custom element
+    (shouldSetAsPropForVueCE(el as VueElement, key) ||
+      // @ts-expect-error _def is private
+      ((el as VueElement)._def.__asyncLoader &&
+        (/[A-Z]/.test(key) || !isString(nextValue))))
   ) {
     patchDOMProp(el, camelize(key), nextValue, parentComponent, key)
   } else {
@@ -102,3 +106,16 @@ export function shouldSetAsProp(
 
   return key in el
 }
+
+function shouldSetAsPropForVueCE(el: VueElement, key: string) {
+  const props = // @ts-expect-error _def is private
+    el._def.props as Record<string, unknown> | string[] | undefined
+  if (!props) {
+    return false
+  }
+
+  const camelKey = camelize(key)
+  return Array.isArray(props)
+    ? props.some(prop => camelize(prop) === camelKey)
+    : Object.keys(props).some(prop => camelize(prop) === camelKey)
+}

+ 20 - 1
packages/server-renderer/__tests__/ssrRenderList.spec.ts

@@ -27,10 +27,24 @@ describe('ssr: renderList', () => {
   it('should warn when given a non-integer N', () => {
     ssrRenderList(3.1, () => {})
     expect(
-      `The v-for range expect an integer value but got 3.1.`,
+      `The v-for range expects a positive integer value but got 3.1.`,
     ).toHaveBeenWarned()
   })
 
+  it('should warn when given a negative N', () => {
+    ssrRenderList(-1, () => {})
+    expect(
+      `The v-for range expects a positive integer value but got -1.`,
+    ).toHaveBeenWarned()
+  })
+
+  it('should not warn when given 0', () => {
+    ssrRenderList(0, () => {})
+    expect(
+      `The v-for range expects a positive integer value but got 0.`,
+    ).not.toHaveBeenWarned()
+  })
+
   it('should render properties in an object', () => {
     ssrRenderList({ a: 1, b: 2, c: 3 }, (item, key, index) =>
       stack.push(`node ${index}/${key}: ${item}`),
@@ -51,6 +65,11 @@ describe('ssr: renderList', () => {
     expect(stack).toEqual(['node 0: 1', 'node 1: 2', 'node 2: 3'])
   })
 
+  it('should not render items when source is 0', () => {
+    ssrRenderList(0, (item, index) => stack.push(`node ${index}: ${item}`))
+    expect(stack).toEqual([])
+  })
+
   it('should not render items when source is undefined', () => {
     ssrRenderList(undefined, (item, index) =>
       stack.push(`node ${index}: ${item}`),

+ 84 - 0
packages/server-renderer/__tests__/ssrWatch.spec.ts

@@ -6,6 +6,7 @@ import {
   ref,
   watch,
   watchEffect,
+  withAsyncContext,
 } from 'vue'
 import { type SSRContext, renderToString } from '../src'
 
@@ -119,6 +120,89 @@ describe('ssr: watch', () => {
     await nextTick()
     expect(msg).toBe('start')
   })
+
+  test('should not run non-immediate watchers registered after async context restore', async () => {
+    const text = ref('start')
+    let beforeAwaitTriggered = false
+    let afterAwaitTriggered = false
+
+    const App = defineComponent({
+      async setup() {
+        let __temp: any, __restore: any
+
+        watch(text, () => {
+          beforeAwaitTriggered = true
+        })
+        ;[__temp, __restore] = withAsyncContext(() => Promise.resolve())
+        __temp = await __temp
+        __restore()
+
+        watch(text, () => {
+          afterAwaitTriggered = true
+        })
+
+        text.value = 'changed'
+        expect(beforeAwaitTriggered).toBe(false)
+        expect(afterAwaitTriggered).toBe(false)
+
+        return () => h('div', null, text.value)
+      },
+    })
+
+    const app = createSSRApp(App)
+    const ctx: SSRContext = {}
+    const html = await renderToString(app, ctx)
+
+    expect(ctx.__watcherHandles).toBeUndefined()
+    expect(html).toMatch('changed')
+    await nextTick()
+    expect(beforeAwaitTriggered).toBe(false)
+    expect(afterAwaitTriggered).toBe(false)
+  })
+
+  test('should not run non-immediate watchers registered after async context restore on rejection', async () => {
+    const text = ref('start')
+    let beforeAwaitTriggered = false
+    let afterAwaitTriggered = false
+
+    const App = defineComponent({
+      async setup() {
+        let __temp: any, __restore: any
+
+        watch(text, () => {
+          beforeAwaitTriggered = true
+        })
+
+        try {
+          ;[__temp, __restore] = withAsyncContext(() =>
+            Promise.reject(new Error('failed')),
+          )
+          __temp = await __temp
+          __restore()
+        } catch {}
+
+        watch(text, () => {
+          afterAwaitTriggered = true
+        })
+
+        text.value = 'changed'
+        expect(beforeAwaitTriggered).toBe(false)
+        expect(afterAwaitTriggered).toBe(false)
+
+        return () => h('div', null, text.value)
+      },
+    })
+
+    const app = createSSRApp(App)
+    const ctx: SSRContext = {}
+    const html = await renderToString(app, ctx)
+
+    expect(ctx.__watcherHandles).toBeUndefined()
+    expect(html).toMatch('changed')
+    await nextTick()
+    expect(beforeAwaitTriggered).toBe(false)
+    expect(afterAwaitTriggered).toBe(false)
+  })
 })
 
 describe('ssr: watchEffect', () => {

+ 4 - 2
packages/server-renderer/src/helpers/ssrRenderList.ts

@@ -10,8 +10,10 @@ export function ssrRenderList(
       renderItem(source[i], i)
     }
   } else if (typeof source === 'number') {
-    if (__DEV__ && !Number.isInteger(source)) {
-      warn(`The v-for range expect an integer value but got ${source}.`)
+    if (__DEV__ && (!Number.isInteger(source) || source < 0)) {
+      warn(
+        `The v-for range expects a positive integer value but got ${source}.`,
+      )
       return
     }
     for (let i = 0; i < source; i++) {

+ 1 - 0
packages/vue-compat/package.json

@@ -53,6 +53,7 @@
   "homepage": "https://github.com/vuejs/core/tree/main/packages/vue-compat#readme",
   "dependencies": {
     "@babel/parser": "catalog:",
+    "entities": "catalog:",
     "estree-walker": "catalog:",
     "source-map-js": "catalog:"
   },

Разница между файлами не показана из-за своего большого размера
+ 154 - 360
pnpm-lock.yaml


+ 1 - 0
pnpm-workspace.yaml

@@ -5,6 +5,7 @@ packages:
 catalog:
   '@babel/parser': ^7.29.0
   '@babel/types': ^7.29.0
+  'entities': '^7.0.1'
   'estree-walker': ^2.0.2
   'vite': npm:@voidzero-dev/vite-plus-core@latest
   '@vitejs/plugin-vue': ^6.0.4

Некоторые файлы не были показаны из-за большого количества измененных файлов