Przeglądaj źródła

chore: Merge branch 'main' into minor

daiwei 1 miesiąc temu
rodzic
commit
73317aeae0
40 zmienionych plików z 1477 dodań i 380 usunięć
  1. 3 0
      .github/renovate.json5
  2. 2 2
      .github/workflows/size-report.yml
  3. 21 0
      changelogs/CHANGELOG-3.5.md
  4. 8 8
      package.json
  5. 10 0
      packages-private/dts-test/reactivity.test-d.ts
  6. 8 0
      packages-private/dts-test/ref.test-d.ts
  7. 3 3
      packages/compiler-core/__tests__/transforms/__snapshots__/vMemo.spec.ts.snap
  8. 1 1
      packages/compiler-core/src/transforms/vFor.ts
  9. 44 0
      packages/compiler-sfc/__tests__/compileScript/__snapshots__/defineModel.spec.ts.snap
  10. 37 0
      packages/compiler-sfc/__tests__/compileScript/defineModel.spec.ts
  11. 36 0
      packages/compiler-sfc/__tests__/templateTransformAssetUrl.spec.ts
  12. 33 0
      packages/compiler-sfc/__tests__/templateTransformSrcset.spec.ts
  13. 2 2
      packages/compiler-sfc/package.json
  14. 6 2
      packages/compiler-sfc/src/script/defineModel.ts
  15. 14 1
      packages/compiler-sfc/src/template/templateUtils.ts
  16. 125 64
      packages/compiler-sfc/src/template/transformAssetUrl.ts
  17. 10 4
      packages/compiler-sfc/src/template/transformSrcset.ts
  18. 31 0
      packages/reactivity/__tests__/ref.spec.ts
  19. 1 1
      packages/reactivity/src/reactive.ts
  20. 37 16
      packages/reactivity/src/ref.ts
  21. 312 1
      packages/runtime-core/__tests__/components/Suspense.spec.ts
  22. 65 0
      packages/runtime-core/__tests__/helpers/withMemo.spec.ts
  23. 11 0
      packages/runtime-core/__tests__/vnode.spec.ts
  24. 9 2
      packages/runtime-core/src/componentRenderUtils.ts
  25. 2 1
      packages/runtime-core/src/components/BaseTransition.ts
  26. 13 1
      packages/runtime-core/src/components/Suspense.ts
  27. 16 8
      packages/runtime-core/src/components/Teleport.ts
  28. 8 0
      packages/runtime-core/src/hmr.ts
  29. 21 3
      packages/runtime-core/src/renderer.ts
  30. 9 0
      packages/runtime-core/src/vnode.ts
  31. 46 0
      packages/runtime-dom/__tests__/directives/vModel.spec.ts
  32. 6 1
      packages/runtime-dom/src/directives/vModel.ts
  33. 80 0
      packages/server-renderer/__tests__/render.spec.ts
  34. 3 3
      packages/server-renderer/__tests__/ssrWatch.spec.ts
  35. 40 0
      packages/server-renderer/src/render.ts
  36. 23 9
      packages/server-renderer/src/renderToStream.ts
  37. 13 10
      packages/server-renderer/src/renderToString.ts
  38. 137 0
      packages/vue/__tests__/e2e/Transition.spec.ts
  39. 229 235
      pnpm-lock.yaml
  40. 2 2
      pnpm-workspace.yaml

+ 3 - 0
.github/renovate.json5

@@ -52,6 +52,9 @@
     // ESM only
     'estree-walker',
 
+    // v8 is ESM-only and breaks compiler-core's CJS path
+    'entities',
+
     // pinned
     // https://github.com/vuejs/core/issues/10300#issuecomment-1940855364
     'lru-cache',

+ 2 - 2
.github/workflows/size-report.yml

@@ -31,7 +31,7 @@ jobs:
           cache: true
 
       - name: Download Size Data
-        uses: dawidd6/action-download-artifact@v14
+        uses: dawidd6/action-download-artifact@v19
         with:
           name: size-data
           run_id: ${{ github.event.workflow_run.id }}
@@ -50,7 +50,7 @@ jobs:
           path: temp/size/base.txt
 
       - name: Download Previous Size Data
-        uses: dawidd6/action-download-artifact@v14
+        uses: dawidd6/action-download-artifact@v19
         with:
           branch: ${{ steps.pr-base.outputs.content }}
           workflow: size-data.yml

+ 21 - 0
changelogs/CHANGELOG-3.5.md

@@ -1,3 +1,24 @@
+## [3.5.31](https://github.com/vuejs/core/compare/v3.5.30...v3.5.31) (2026-03-25)
+
+
+### Bug Fixes
+
+* **compiler-sfc:** allow Node.js subpath imports patterns in asset urls ([#13045](https://github.com/vuejs/core/issues/13045)) ([95c3356](https://github.com/vuejs/core/commit/95c33560c9af369d44a7670d0c3b93fb62323be2)), closes [#9919](https://github.com/vuejs/core/issues/9919)
+* **compiler-sfc:** support template literal as defineModel name ([#14622](https://github.com/vuejs/core/issues/14622)) ([bd7eef0](https://github.com/vuejs/core/commit/bd7eef0161d69bccd48ac303bc6a56ba8d718e2d)), closes [#14621](https://github.com/vuejs/core/issues/14621)
+* **reactivity:** normalize toRef property keys before dep lookup + improve types ([#14625](https://github.com/vuejs/core/issues/14625)) ([1bb28d0](https://github.com/vuejs/core/commit/1bb28d011b31bc75c80d2860bc6484cceec1ff20)), closes [#12427](https://github.com/vuejs/core/issues/12427) [#12431](https://github.com/vuejs/core/issues/12431)
+* **runtime-core:** invalidate detached v-for memo vnodes after unmount ([#14624](https://github.com/vuejs/core/issues/14624)) ([560def4](https://github.com/vuejs/core/commit/560def426fc38c1710fda7ddd1ac634d67897960)), closes [#12708](https://github.com/vuejs/core/issues/12708) [#12710](https://github.com/vuejs/core/issues/12710)
+* **runtime-core:** preserve nullish event handlers in mergeProps ([#14550](https://github.com/vuejs/core/issues/14550)) ([5725222](https://github.com/vuejs/core/commit/5725222a6bc5d1dd032318349ce0c540b1d63a49))
+* **runtime-core:** prevent merging model listener when value is null or undefined ([#14629](https://github.com/vuejs/core/issues/14629)) ([b39e032](https://github.com/vuejs/core/commit/b39e0329f67354702f4e417e55c15c61d2439657))
+* **runtime-dom:** defer teleport mount/update until suspense resolves ([#8619](https://github.com/vuejs/core/issues/8619)) ([88ed045](https://github.com/vuejs/core/commit/88ed04501555b9257df8d7ad86d844c2c2136e50)), closes [#8603](https://github.com/vuejs/core/issues/8603)
+* **runtime-dom:** handle activeElement check in Shadow DOM for v-model ([#14196](https://github.com/vuejs/core/issues/14196)) ([959ded2](https://github.com/vuejs/core/commit/959ded22ab7ea1453f607e0964e1fb6748ece6c7))
+* **server-renderer:** cleanup component effect scopes after SSR render ([#14548](https://github.com/vuejs/core/issues/14548)) ([862f11e](https://github.com/vuejs/core/commit/862f11ee017d51cb9573a8c0642055b3b17cace8))
+* **suspense:** avoid unmount activeBranch twice if wrapped in transition ([#9392](https://github.com/vuejs/core/issues/9392)) ([908c6ad](https://github.com/vuejs/core/commit/908c6ad05e1c76ae690d2e50f3bd28278af07e22)), closes [#7966](https://github.com/vuejs/core/issues/7966)
+* **suspense:** update suspense vnode's el during branch self-update ([#12922](https://github.com/vuejs/core/issues/12922)) ([a2c1700](https://github.com/vuejs/core/commit/a2c17004c84e5ce3c4e82e35b806ba381144eed3)), closes [#12920](https://github.com/vuejs/core/issues/12920)
+* **transition:** skip enter guard while hmr updating ([#14611](https://github.com/vuejs/core/issues/14611)) ([be0a2f1](https://github.com/vuejs/core/commit/be0a2f1a7fc3d81d05638798cc628848cfa62cef)), closes [#14608](https://github.com/vuejs/core/issues/14608)
+* **types:** prevent shallowReactive marker from leaking into value unions ([#14493](https://github.com/vuejs/core/issues/14493)) ([3b561db](https://github.com/vuejs/core/commit/3b561db4ab42d06166b002f13c0e97cb2bd4a061)), closes [#14490](https://github.com/vuejs/core/issues/14490)
+
+
+
 ## [3.5.30](https://github.com/vuejs/core/compare/v3.5.29...v3.5.30) (2026-03-09)
 
 

+ 8 - 8
package.json

@@ -56,14 +56,14 @@
     "@types/node": "^24.12.0",
     "@types/semver": "^7.7.1",
     "@types/serve-handler": "^6.1.4",
-    "@vitest/coverage-v8": "^4.0.18",
-    "@vitest/ui": "^4.0.18",
+    "@vitest/coverage-v8": "^4.1.1",
+    "@vitest/ui": "^4.1.1",
     "@vue/consolidate": "1.0.0",
     "conventional-changelog-cli": "^5.0.0",
     "enquirer": "^2.4.1",
     "estree-walker": "catalog:",
     "fast-glob": "^3.3.2",
-    "jsdom": "^27.3.0",
+    "jsdom": "^29.0.1",
     "lodash": "^4.17.23",
     "magic-string": "^0.30.21",
     "markdown-table": "^3.0.4",
@@ -75,14 +75,14 @@
     "picocolors": "^1.1.1",
     "playwright": "^1.56.1",
     "pretty-bytes": "^7.1.0",
-    "pug": "^3.0.3",
-    "puppeteer": "~24.38.0",
+    "pug": "^3.0.4",
+    "puppeteer": "~24.40.0",
     "rimraf": "^6.1.3",
     "rolldown": "^1.0.0-rc.6",
     "rolldown-plugin-dts": "^0.22.3",
-    "semver": "^7.7.3",
-    "serve": "^14.2.5",
-    "serve-handler": "^6.1.6",
+    "semver": "^7.7.4",
+    "serve": "^14.2.6",
+    "serve-handler": "^6.1.7",
     "todomvc-app-css": "^2.4.3",
     "tslib": "^2.8.1",
     "typescript": "~5.6.2",

+ 10 - 0
packages-private/dts-test/reactivity.test-d.ts

@@ -4,6 +4,7 @@ import {
   reactive,
   readonly,
   ref,
+  shallowReactive,
   shallowReadonly,
 } from 'vue'
 import { describe, expectType } from './utils'
@@ -130,3 +131,12 @@ describe('should not error when assignment', () => {
   record2 = arr
   expectType<string>(record2[0])
 })
+
+describe('shallowReactive marker should not leak into value unions', () => {
+  const state = shallowReactive({
+    a: { title: 'A' },
+    b: { title: 'B' },
+  })
+  const value = {} as (typeof state)[keyof typeof state]
+  expectType<string>(value.title)
+})

+ 8 - 0
packages-private/dts-test/ref.test-d.ts

@@ -338,6 +338,14 @@ expectType<{ name: string } | null>(p2.union)
   // Should not distribute Refs over union
   expectType<Ref<number | string>>(toRef(obj, 'c'))
 
+  const array = reactive(['a', 'b'])
+  expectType<Ref<string>>(toRef(array, '1'))
+  expectType<Ref<string>>(toRef(array, '1', 'fallback'))
+
+  const tuple: [string, number] = ['a', 1]
+  expectType<Ref<string>>(toRef(tuple, '0'))
+  expectType<Ref<number>>(toRef(tuple, '1'))
+
   expectType<Ref<number>>(toRef(() => 123))
   expectType<Ref<number | string>>(toRef(() => obj.c))
 

+ 3 - 3
packages/compiler-core/__tests__/transforms/__snapshots__/vMemo.spec.ts.snap

@@ -7,7 +7,7 @@ export function render(_ctx, _cache) {
   return (_openBlock(), _createElementBlock("div", null, [
     (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.tableData, (data, __, ___, _cached) => {
       const _memo = (_ctx.getLetter(data))
-      if (_cached && _cached.key === _ctx.getId(data) && _isMemoSame(_cached, _memo)) return _cached
+      if (_cached && _cached.el && _cached.key === _ctx.getId(data) && _isMemoSame(_cached, _memo)) return _cached
       const _item = (_openBlock(), _createElementBlock("span", {
         key: _ctx.getId(data)
       }))
@@ -55,7 +55,7 @@ export function render(_ctx, _cache) {
   return (_openBlock(), _createElementBlock("div", null, [
     (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, ({ x, y }, __, ___, _cached) => {
       const _memo = ([x, y === _ctx.z])
-      if (_cached && _cached.key === x && _isMemoSame(_cached, _memo)) return _cached
+      if (_cached && _cached.el && _cached.key === x && _isMemoSame(_cached, _memo)) return _cached
       const _item = (_openBlock(), _createElementBlock("span", { key: x }, "foobar"))
       _item.memo = _memo
       return _item
@@ -71,7 +71,7 @@ export function render(_ctx, _cache) {
   return (_openBlock(), _createElementBlock("div", null, [
     (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, ({ x, y }, __, ___, _cached) => {
       const _memo = ([x, y === _ctx.z])
-      if (_cached && _cached.key === x && _isMemoSame(_cached, _memo)) return _cached
+      if (_cached && _cached.el && _cached.key === x && _isMemoSame(_cached, _memo)) return _cached
       const _item = (_openBlock(), _createElementBlock("div", { key: x }, [
         _createElementVNode("span", null, "foobar")
       ]))

+ 1 - 1
packages/compiler-core/src/transforms/vFor.ts

@@ -221,7 +221,7 @@ export const transformFor: NodeTransform = createStructuralDirectiveTransform(
           loop.body = createBlockStatement([
             createCompoundExpression([`const _memo = (`, memo.exp!, `)`]),
             createCompoundExpression([
-              `if (_cached`,
+              `if (_cached && _cached.el`,
               ...(keyExp ? [` && _cached.key === `, keyExp] : []),
               ` && ${context.helperString(
                 IS_MEMO_SAME,

+ 44 - 0
packages/compiler-sfc/__tests__/compileScript/__snapshots__/defineModel.spec.ts.snap

@@ -166,6 +166,50 @@ return { count }
 }"
 `;
 
+exports[`defineModel() > w/ template literal name 1`] = `
+"import { useModel as _useModel } from 'vue'
+
+export default {
+  props: {
+    "x": { default: 100 },
+    "xModifiers": {},
+    "y": { default: 200 },
+    "yModifiers": {},
+  },
+  emits: ["update:x", "update:y"],
+  setup(__props, { expose: __expose }) {
+  __expose();
+
+      const x = _useModel(__props, \`x\`)
+      const y = _useModel(__props, \`y\`)
+      
+return { x, y }
+}
+
+}"
+`;
+
+exports[`defineModel() > w/ template literal name with expressions falls back to modelValue 1`] = `
+"import { useModel as _useModel } from 'vue'
+const name = 'x'
+      
+export default {
+  props: {
+    "modelValue": \`\${name}\`,
+    "modelModifiers": {},
+  },
+  emits: ["update:modelValue"],
+  setup(__props, { expose: __expose }) {
+  __expose();
+
+      const m = _useModel(__props, "modelValue", \`\${name}\`)
+      
+return { name, m }
+}
+
+}"
+`;
+
 exports[`defineModel() > w/ types, basic usage 1`] = `
 "import { useModel as _useModel, defineComponent as _defineComponent } from 'vue'
 

+ 37 - 0
packages/compiler-sfc/__tests__/compileScript/defineModel.spec.ts

@@ -36,6 +36,43 @@ describe('defineModel()', () => {
     })
   })
 
+  test('w/ template literal name', () => {
+    const { content, bindings } = compile(
+      `
+      <script setup>
+      const x = defineModel(\`x\`, { default: 100 })
+      const y = defineModel(\`y\`, { default: 200 })
+      </script>
+      `,
+    )
+    assertCode(content)
+    expect(content).toMatch('"x": { default: 100 },')
+    expect(content).toMatch('"y": { default: 200 },')
+    expect(content).toMatch('emits: ["update:x", "update:y"],')
+    expect(content).toMatch('const x = _useModel(__props, `x`)')
+    expect(content).toMatch('const y = _useModel(__props, `y`)')
+    expect(content).not.toMatch('defineModel')
+
+    expect(bindings).toStrictEqual({
+      x: BindingTypes.SETUP_REF,
+      y: BindingTypes.SETUP_REF,
+    })
+  })
+
+  test('w/ template literal name with expressions falls back to modelValue', () => {
+    const { content } = compile(
+      `
+      <script setup>
+      const name = 'x'
+      const m = defineModel(\`\${name}\`)
+      </script>
+      `,
+    )
+    assertCode(content)
+    expect(content).toMatch('"modelValue":')
+    expect(content).toMatch('_useModel(__props, "modelValue",')
+  })
+
   test('w/ defineProps and defineEmits', () => {
     const { content, bindings } = compile(
       `

+ 36 - 0
packages/compiler-sfc/__tests__/templateTransformAssetUrl.spec.ts

@@ -114,6 +114,42 @@ describe('compiler sfc: transform asset url', () => {
     expect(code).toMatch(`"xlink:href": "#myCircle"`)
   })
 
+  // #9919
+  test('should transform subpath import paths', () => {
+    const { code } = compileWithAssetUrls(
+      `<img src="#src/assets/vue.svg" />` +
+        `<img src="#/src/assets/vue.svg" />`,
+    )
+    expect(code).toContain(`_imports_0 from '#src/assets/vue.svg'`)
+    expect(code).toContain(`_imports_1 from '#/src/assets/vue.svg'`)
+  })
+
+  test('should not transform pure hash values for custom asset URL tags', () => {
+    const { code } = compileWithAssetUrls(
+      `<foo bar="#fragment" />` +
+        `<foo bar="#src/assets/vue.svg" />` +
+        `<foo bar="#/src/assets/vue.svg" />`,
+      {
+        tags: {
+          foo: ['bar'],
+        },
+      },
+    )
+
+    expect(code).toContain(`bar: "#fragment"`)
+    expect(code).toContain(`bar: "#src/assets/vue.svg"`)
+    expect(code).toContain(`bar: "#/src/assets/vue.svg"`)
+    expect(code).not.toContain(`from '#fragment'`)
+    expect(code).not.toContain(`from '#src/assets/vue.svg'`)
+    expect(code).not.toContain(`from '#/src/assets/vue.svg'`)
+  })
+
+  test('should not throw for malformed percent-encoding in asset paths', () => {
+    const { code } = compileWithAssetUrls(`<img src="./foo%.png" />`)
+
+    expect(code).toContain(`import _imports_0 from './foo%.png'`)
+  })
+
   test('should allow for full base URLs, with paths', () => {
     const { code } = compileWithAssetUrls(`<img src="./logo.png" />`, {
       base: 'http://localhost:3000/src/',

+ 33 - 0
packages/compiler-sfc/__tests__/templateTransformSrcset.spec.ts

@@ -106,4 +106,37 @@ describe('compiler sfc: transform srcset', () => {
     ).code
     expect(code).toMatchSnapshot()
   })
+
+  test('should transform subpath import paths starting with #', () => {
+    const code = compileWithSrcset(
+      `<img srcset="#src/assets/vue.svg" />` +
+        `<img srcset="#/src/assets/vue.svg 2x" />`,
+    ).code
+
+    expect(code).toContain(`_imports_0 from '#src/assets/vue.svg'`)
+    expect(code).toContain(`_imports_1 from '#/src/assets/vue.svg'`)
+    expect(code).toContain(`const _hoisted_1 = _imports_0`)
+    expect(code).toContain(`const _hoisted_2 = _imports_1 + ' 2x'`)
+  })
+
+  test('should preserve svg fragments in srcset URLs', () => {
+    const code = compileWithSrcset(
+      `<img srcset="./icons.svg#icon-heart" />` +
+        `<img srcset="./icons.svg#icon-star 2x" />`,
+    ).code
+
+    expect(code).toContain(`_imports_0 from './icons.svg'`)
+    expect(code.match(/from '\.\/icons\.svg'/g)).toHaveLength(1)
+    expect(code).toContain(`const _hoisted_1 = _imports_0 + '#icon-heart'`)
+    expect(code).toContain(
+      `const _hoisted_2 = _imports_0 + '#icon-star' + ' 2x'`,
+    )
+  })
+
+  test('should not throw for malformed percent-encoding in srcset paths', () => {
+    const code = compileWithSrcset(`<img srcset="./foo%.png 2x" />`).code
+
+    expect(code).toContain(`import _imports_0 from './foo%.png'`)
+    expect(code).toContain(`const _hoisted_1 = _imports_0 + ' 2x'`)
+  })
 })

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

@@ -62,7 +62,7 @@
     "minimatch": "~10.2.4",
     "postcss-modules": "^6.0.1",
     "postcss-selector-parser": "^7.1.1",
-    "pug": "^3.0.3",
-    "sass": "^1.97.3"
+    "pug": "^3.0.4",
+    "sass": "^1.98.0"
   }
 }

+ 6 - 2
packages/compiler-sfc/src/script/defineModel.ts

@@ -30,9 +30,13 @@ export function processDefineModel(
   let modelName: string
   let options: Node | undefined
   const arg0 = node.arguments[0] && unwrapTSNode(node.arguments[0])
-  const hasName = arg0 && arg0.type === 'StringLiteral'
+  const hasName =
+    arg0 &&
+    (arg0.type === 'StringLiteral' ||
+      (arg0.type === 'TemplateLiteral' && arg0.expressions.length === 0))
   if (hasName) {
-    modelName = arg0.value
+    modelName =
+      arg0.type === 'StringLiteral' ? arg0.value : arg0.quasis[0].value.cooked!
     options = node.arguments[1]
   } else {
     modelName = 'modelValue'

+ 14 - 1
packages/compiler-sfc/src/template/templateUtils.ts

@@ -13,7 +13,12 @@ type RootChildNode = ParentNode['children'][number]
 
 export function isRelativeUrl(url: string): boolean {
   const firstChar = url.charAt(0)
-  return firstChar === '.' || firstChar === '~' || firstChar === '@'
+  return (
+    firstChar === '.' ||
+    firstChar === '~' ||
+    firstChar === '@' ||
+    firstChar === '#'
+  )
 }
 
 const externalRE = /^(?:https?:)?\/\//
@@ -26,6 +31,14 @@ export function isDataUrl(url: string): boolean {
   return dataUrlRE.test(url)
 }
 
+export function normalizeDecodedImportPath(source: string): string {
+  try {
+    return decodeURIComponent(source)
+  } catch {
+    return source
+  }
+}
+
 /**
  * Parses string url into URL object.
  */

+ 125 - 64
packages/compiler-sfc/src/template/transformAssetUrl.ts

@@ -13,6 +13,7 @@ import {
   isDataUrl,
   isExternalUrl,
   isRelativeUrl,
+  normalizeDecodedImportPath,
   parseUrl,
 } from './templateUtils'
 import { isArray } from '@vue/shared'
@@ -34,14 +35,20 @@ export interface AssetURLOptions {
   tags?: AssetURLTagConfig
 }
 
+// Built-in attrs that always represent resource URLs. `use` is intentionally
+// omitted because its hash-only values may still be fragment references.
+const resourceUrlTagConfig: AssetURLTagConfig = {
+  video: ['src', 'poster'],
+  source: ['src'],
+  img: ['src'],
+  image: ['xlink:href', 'href'],
+}
+
 export const defaultAssetUrlOptions: Required<AssetURLOptions> = {
   base: null,
   includeAbsolute: false,
   tags: {
-    video: ['src', 'poster'],
-    source: ['src'],
-    img: ['src'],
-    image: ['xlink:href', 'href'],
+    ...resourceUrlTagConfig,
     use: ['xlink:href', 'href'],
   },
 }
@@ -69,6 +76,10 @@ export const createAssetUrlTransformWithOptions = (
     (transformAssetUrl as Function)(node, context, options)
 }
 
+function canTransformHashImport(tag: string, attrName: string): boolean {
+  return !!resourceUrlTagConfig[tag]?.includes(attrName)
+}
+
 /**
  * A `@vue/compiler-core` plugin that transforms relative asset urls into
  * either imports or absolute urls.
@@ -104,17 +115,24 @@ export const transformAssetUrl: NodeTransform = (
       if (
         attr.type !== NodeTypes.ATTRIBUTE ||
         !assetAttrs.includes(attr.name) ||
-        !attr.value ||
-        isExternalUrl(attr.value.content) ||
-        isDataUrl(attr.value.content) ||
-        attr.value.content[0] === '#' ||
-        (!options.includeAbsolute && !isRelativeUrl(attr.value.content))
+        !attr.value
       ) {
         return
       }
 
-      const url = parseUrl(attr.value.content)
-      if (options.base && attr.value.content[0] === '.') {
+      const urlValue = attr.value.content
+      const isHashOnlyValue = urlValue[0] === '#'
+      if (
+        isExternalUrl(urlValue) ||
+        isDataUrl(urlValue) ||
+        (isHashOnlyValue && !canTransformHashImport(node.tag, attr.name)) ||
+        (!options.includeAbsolute && !isRelativeUrl(urlValue))
+      ) {
+        return
+      }
+
+      const url = parseUrl(urlValue)
+      if (options.base && urlValue[0] === '.') {
         // explicit base - directly rewrite relative urls into absolute url
         // to avoid generating extra imports
         // Allow for full hostnames provided in options.base
@@ -147,70 +165,113 @@ export const transformAssetUrl: NodeTransform = (
   }
 }
 
+/**
+ * Resolves or registers an import for the given source path
+ * @param source - Path to resolve import for
+ * @param loc - Source location
+ * @param context - Transform context
+ * @returns Object containing import name and expression
+ */
+function resolveOrRegisterImport(
+  source: string,
+  loc: SourceLocation,
+  context: TransformContext,
+): {
+  name: string
+  exp: SimpleExpressionNode
+} {
+  const normalizedSource = normalizeDecodedImportPath(source)
+  const existingIndex = context.imports.findIndex(
+    i => i.path === normalizedSource,
+  )
+  if (existingIndex > -1) {
+    return {
+      name: `_imports_${existingIndex}`,
+      exp: context.imports[existingIndex].exp as SimpleExpressionNode,
+    }
+  }
+
+  const name = `_imports_${context.imports.length}`
+  const exp = createSimpleExpression(
+    name,
+    false,
+    loc,
+    ConstantTypes.CAN_STRINGIFY,
+  )
+
+  // We need to ensure the path is not encoded (to %2F),
+  // so we decode it back in case it is encoded
+  context.imports.push({
+    exp,
+    path: normalizedSource,
+  })
+
+  return { name, exp }
+}
+
+/**
+ * Transforms asset URLs into import expressions or string literals
+ */
 function getImportsExpressionExp(
   path: string | null,
   hash: string | null,
   loc: SourceLocation,
   context: TransformContext,
 ): ExpressionNode {
-  if (path) {
-    let name: string
-    let exp: SimpleExpressionNode
-    const existingIndex = context.imports.findIndex(i => i.path === path)
-    if (existingIndex > -1) {
-      name = `_imports_${existingIndex}`
-      exp = context.imports[existingIndex].exp as SimpleExpressionNode
-    } else {
-      name = `_imports_${context.imports.length}`
-      exp = createSimpleExpression(
-        name,
-        false,
-        loc,
-        ConstantTypes.CAN_STRINGIFY,
-      )
-
-      // We need to ensure the path is not encoded (to %2F),
-      // so we decode it back in case it is encoded
-      context.imports.push({
-        exp,
-        path: decodeURIComponent(path),
-      })
-    }
+  // Neither path nor hash - return empty string
+  if (!path && !hash) {
+    return createSimpleExpression(`''`, false, loc, ConstantTypes.CAN_STRINGIFY)
+  }
 
-    if (!hash) {
-      return exp
-    }
+  // Only hash without path - treat hash as the import source (likely a subpath import)
+  if (!path && hash) {
+    const { exp } = resolveOrRegisterImport(hash, loc, context)
+    return exp
+  }
+
+  // Only path without hash - straightforward import
+  if (path && !hash) {
+    const { exp } = resolveOrRegisterImport(path, loc, context)
+    return exp
+  }
+
+  // At this point, we know we have both path and hash components
+  const { name } = resolveOrRegisterImport(path!, loc, context)
+
+  // Combine path import with hash
+  const hashExp = `${name} + '${hash}'`
+  const finalExp = createSimpleExpression(
+    hashExp,
+    false,
+    loc,
+    ConstantTypes.CAN_STRINGIFY,
+  )
 
-    const hashExp = `${name} + '${hash}'`
-    const finalExp = createSimpleExpression(
-      hashExp,
+  // No hoisting needed
+  if (!context.hoistStatic) {
+    return finalExp
+  }
+
+  // Check for existing hoisted expression
+  const existingHoistIndex = context.hoists.findIndex(h => {
+    return (
+      h &&
+      h.type === NodeTypes.SIMPLE_EXPRESSION &&
+      !h.isStatic &&
+      h.content === hashExp
+    )
+  })
+
+  // Return existing hoisted expression if found
+  if (existingHoistIndex > -1) {
+    return createSimpleExpression(
+      `_hoisted_${existingHoistIndex + 1}`,
       false,
       loc,
       ConstantTypes.CAN_STRINGIFY,
     )
-
-    if (!context.hoistStatic) {
-      return finalExp
-    }
-
-    const existingHoistIndex = context.hoists.findIndex(h => {
-      return (
-        h &&
-        h.type === NodeTypes.SIMPLE_EXPRESSION &&
-        !h.isStatic &&
-        h.content === hashExp
-      )
-    })
-    if (existingHoistIndex > -1) {
-      return createSimpleExpression(
-        `_hoisted_${existingHoistIndex + 1}`,
-        false,
-        loc,
-        ConstantTypes.CAN_STRINGIFY,
-      )
-    }
-    return context.hoist(finalExp)
-  } else {
-    return createSimpleExpression(`''`, false, loc, ConstantTypes.CAN_STRINGIFY)
   }
+
+  // Hoist the expression and return the hoisted expression
+  return context.hoist(finalExp)
 }

+ 10 - 4
packages/compiler-sfc/src/template/transformSrcset.ts

@@ -9,6 +9,7 @@ import {
   isDataUrl,
   isExternalUrl,
   isRelativeUrl,
+  normalizeDecodedImportPath,
   parseUrl,
 } from './templateUtils'
 import {
@@ -106,11 +107,13 @@ export const transformSrcset: NodeTransform = (
           let content = ''
           imageCandidates.forEach(({ url, descriptor }, index) => {
             if (shouldProcessUrl(url)) {
-              const { path } = parseUrl(url)
-              if (path) {
+              const { path, hash } = parseUrl(url)
+              const source = path ? path : hash
+              if (source) {
                 let exp = ''
+                const normalizedSource = normalizeDecodedImportPath(source)
                 const existingImportsIndex = context.imports.findIndex(
-                  i => i.path === path,
+                  i => i.path === normalizedSource,
                 )
                 if (existingImportsIndex > -1) {
                   exp = `_imports_${existingImportsIndex}`
@@ -123,9 +126,12 @@ export const transformSrcset: NodeTransform = (
                       attr.loc,
                       ConstantTypes.CAN_STRINGIFY,
                     ),
-                    path,
+                    path: normalizedSource,
                   })
                 }
+                if (path && hash) {
+                  exp = `${exp} + '${hash}'`
+                }
                 content += exp
               }
             } else {

+ 31 - 0
packages/reactivity/__tests__/ref.spec.ts

@@ -388,6 +388,37 @@ describe('reactivity/ref', () => {
     expect(bar.value).toBe(6)
   })
 
+  test('triggerRef on toRef created from array coerces property keys', () => {
+    const assertTriggerRef = (key: unknown) => {
+      const array = reactive(['a'])
+      const first = toRef(array as any, key as any)
+      const fn = vi.fn()
+
+      effect(() => fn(first.value))
+      expect(fn).toHaveBeenCalledTimes(1)
+
+      triggerRef(first)
+      expect(fn).toHaveBeenCalledTimes(2)
+    }
+
+    assertTriggerRef(0)
+    // JS coerces non-symbol property keys like [0] to the string "0".
+    assertTriggerRef([0])
+  })
+
+  test('triggerRef on toRef created from symbol key preserves the symbol', () => {
+    const key = Symbol()
+    const object = reactive({ [key]: 'a' })
+    const value = toRef(object, key)
+    const fn = vi.fn()
+
+    effect(() => fn(value.value))
+    expect(fn).toHaveBeenCalledTimes(1)
+
+    triggerRef(value)
+    expect(fn).toHaveBeenCalledTimes(2)
+  })
+
   test('toRef default value', () => {
     const a: { x: number | undefined } = { x: undefined }
     const x = toRef(a, 'x', 1)

+ 1 - 1
packages/reactivity/src/reactive.ts

@@ -106,7 +106,7 @@ export function reactive(target: object) {
 
 export declare const ShallowReactiveMarker: unique symbol
 
-export type ShallowReactive<T> = T & { [ShallowReactiveMarker]?: true }
+export type ShallowReactive<T> = T & { [ShallowReactiveMarker]: never }
 
 /**
  * Shallow version of {@link reactive}.

+ 37 - 16
packages/reactivity/src/ref.ts

@@ -5,6 +5,7 @@ import {
   isFunction,
   isIntegerKey,
   isObject,
+  isSymbol,
 } from '@vue/shared'
 import type { ComputedRef, WritableComputedRef } from './computed'
 import { ReactiveFlags, TrackOpTypes, TriggerOpTypes } from './constants'
@@ -391,6 +392,22 @@ export type ToRefs<T = any> = {
   [K in keyof T]: ToRef<T[K]>
 }
 
+type ArrayStringKey<T> = T extends readonly any[]
+  ? number extends T['length']
+    ? `${number}`
+    : never
+  : never
+
+type ToRefKey<T> = keyof T | ArrayStringKey<T>
+
+type ToRefValue<T extends object, K extends ToRefKey<T>> = K extends keyof T
+  ? T[K]
+  : T extends readonly (infer V)[]
+    ? K extends ArrayStringKey<T>
+      ? V
+      : never
+    : never
+
 /**
  * Converts a reactive object to a plain object where each property of the
  * resulting object is a ref pointing to the corresponding property of the
@@ -413,20 +430,22 @@ class ObjectRefImpl<T extends object, K extends keyof T> {
   public _value: T[K] = undefined!
 
   private readonly _raw: T
+  private readonly _key: K
   private readonly _shallow: boolean
 
   constructor(
     private readonly _object: T,
-    private readonly _key: K,
+    key: K,
     private readonly _defaultValue?: T[K],
   ) {
+    this._key = (isSymbol(key) ? key : String(key)) as K
     this._raw = toRaw(_object)
 
     let shallow = true
     let obj = _object
 
     // For an array with integer key, refs are not unwrapped
-    if (!isArray(_object) || !isIntegerKey(String(_key))) {
+    if (!isArray(_object) || isSymbol(this._key) || !isIntegerKey(this._key)) {
       // Otherwise, check each proxy layer for unwrapping
       do {
         shallow = !isProxy(obj) || isShallow(obj)
@@ -524,19 +543,19 @@ export function toRef<T>(
   : T extends Ref
     ? T
     : Ref<UnwrapRef<T>>
-export function toRef<T extends object, K extends keyof T>(
+export function toRef<T extends object, K extends ToRefKey<T>>(
   object: T,
   key: K,
-): ToRef<T[K]>
-export function toRef<T extends object, K extends keyof T>(
+): ToRef<ToRefValue<T, K>>
+export function toRef<T extends object, K extends ToRefKey<T>>(
   object: T,
   key: K,
-  defaultValue: T[K],
-): ToRef<Exclude<T[K], undefined>>
+  defaultValue: ToRefValue<T, K>,
+): ToRef<Exclude<ToRefValue<T, K>, undefined>>
 /*@__NO_SIDE_EFFECTS__*/
 export function toRef(
-  source: Record<string, any> | MaybeRef,
-  key?: string,
+  source: Record<PropertyKey, any> | MaybeRef,
+  key?: string | number | symbol,
   defaultValue?: unknown,
 ): Ref {
   if (isRef(source)) {
@@ -551,8 +570,8 @@ export function toRef(
 }
 
 function propertyToRef(
-  source: Record<string, any>,
-  key: string,
+  source: Record<PropertyKey, any>,
+  key: string | number | symbol,
   defaultValue?: unknown,
 ) {
   return new ObjectRefImpl(source, key, defaultValue) as any
@@ -603,8 +622,10 @@ export type UnwrapRefSimple<T> = T extends
           ? WeakSet<UnwrapRefSimple<V>> & UnwrapRef<Omit<T, keyof WeakSet<any>>>
           : T extends ReadonlyArray<any>
             ? { [K in keyof T]: UnwrapRefSimple<T[K]> }
-            : T extends object & { [ShallowReactiveMarker]?: never }
-              ? {
-                  [P in keyof T]: P extends symbol ? T[P] : UnwrapRef<T[P]>
-                }
-              : T
+            : T extends object & { [ShallowReactiveMarker]: never }
+              ? T
+              : T extends object
+                ? {
+                    [P in keyof T]: P extends symbol ? T[P] : UnwrapRef<T[P]>
+                  }
+                : T

+ 312 - 1
packages/runtime-core/__tests__/components/Suspense.spec.ts

@@ -8,7 +8,10 @@ import {
   KeepAlive,
   Suspense,
   type SuspenseProps,
+  Teleport,
+  createBlock,
   createCommentVNode,
+  createElementBlock,
   h,
   nextTick,
   nodeOps,
@@ -16,6 +19,7 @@ import {
   onMounted,
   onUnmounted,
   onUpdated,
+  openBlock,
   ref,
   render,
   renderList,
@@ -27,9 +31,17 @@ import {
   watchEffect,
   withDirectives,
 } from '@vue/runtime-test'
-import { computed, createApp, defineComponent, inject, provide } from 'vue'
+import {
+  computed,
+  createApp,
+  defineAsyncComponent as defineAsyncComp,
+  defineComponent,
+  inject,
+  provide,
+} from 'vue'
 import type { RawSlots } from 'packages/runtime-core/src/componentSlots'
 import { resetSuspenseId } from '../../src/components/Suspense'
+import { PatchFlags } from '@vue/shared'
 
 describe('Suspense', () => {
   const deps: Promise<any>[] = []
@@ -2165,6 +2177,305 @@ describe('Suspense', () => {
     await Promise.all(deps)
   })
 
+  // #12920
+  test('unmount Suspense after async child (with defineAsyncComponent) self-triggered update', async () => {
+    const Comp = defineComponent({
+      setup() {
+        const show = ref(true)
+        onMounted(() => {
+          // trigger update
+          show.value = !show.value
+        })
+        return () =>
+          show.value
+            ? (openBlock(), createElementBlock('div', { key: 0 }, 'show'))
+            : (openBlock(), createElementBlock('div', { key: 1 }, 'hidden'))
+      },
+    })
+
+    const AsyncComp = defineAsyncComp(() => {
+      const p = new Promise(resolve => {
+        resolve(Comp)
+      })
+      deps.push(p.then(() => Promise.resolve()))
+      return p as any
+    })
+
+    const toggle = ref(true)
+    const root = nodeOps.createElement('div')
+    const App = {
+      render() {
+        return (
+          openBlock(),
+          createElementBlock(
+            Fragment,
+            null,
+            [
+              h('h1', null, toggle.value),
+              toggle.value
+                ? (openBlock(),
+                  createBlock(
+                    Suspense,
+                    { key: 0 },
+                    {
+                      default: h(AsyncComp),
+                    },
+                  ))
+                : createCommentVNode('v-if', true),
+            ],
+            PatchFlags.STABLE_FRAGMENT,
+          )
+        )
+      },
+    }
+    render(h(App), root)
+    expect(serializeInner(root)).toBe(`<h1>true</h1><!---->`)
+
+    await Promise.all(deps)
+    await nextTick()
+    await nextTick()
+    expect(serializeInner(root)).toBe(`<h1>true</h1><div>show</div>`)
+
+    await nextTick()
+    expect(serializeInner(root)).toBe(`<h1>true</h1><div>hidden</div>`)
+
+    // unmount suspense
+    toggle.value = false
+    await Promise.all(deps)
+    await nextTick()
+    expect(serializeInner(root)).toBe(`<h1>true</h1><!--v-if-->`)
+  })
+
+  test('unmount Suspense after async child (with async setup) self-triggered update', async () => {
+    const AsyncComp = defineComponent({
+      async setup() {
+        const show = ref(true)
+        onMounted(() => {
+          // trigger update
+          show.value = !show.value
+        })
+        const p = new Promise(r => setTimeout(r, 1))
+        // extra tick needed for Node 12+
+        deps.push(p.then(() => Promise.resolve()))
+        return () =>
+          show.value
+            ? (openBlock(), createElementBlock('div', { key: 0 }, 'show'))
+            : (openBlock(), createElementBlock('div', { key: 1 }, 'hidden'))
+      },
+    })
+
+    const toggle = ref(true)
+    const root = nodeOps.createElement('div')
+    const App = {
+      render() {
+        return (
+          openBlock(),
+          createElementBlock(
+            Fragment,
+            null,
+            [
+              h('h1', null, toggle.value),
+              toggle.value
+                ? (openBlock(),
+                  createBlock(
+                    Suspense,
+                    { key: 0 },
+                    {
+                      default: h(AsyncComp),
+                    },
+                  ))
+                : createCommentVNode('v-if', true),
+            ],
+            PatchFlags.STABLE_FRAGMENT,
+          )
+        )
+      },
+    }
+    render(h(App), root)
+    expect(serializeInner(root)).toBe(`<h1>true</h1><!---->`)
+
+    await Promise.all(deps)
+    await nextTick()
+    expect(serializeInner(root)).toBe(`<h1>true</h1><div>hidden</div>`)
+
+    // unmount suspense
+    toggle.value = false
+    await Promise.all(deps)
+    await nextTick()
+    expect(serializeInner(root)).toBe(`<h1>true</h1><!--v-if-->`)
+  })
+
+  test('propagates host el through wrapper components above Suspense after async child self-triggered update', async () => {
+    const AsyncComp = defineComponent({
+      async setup() {
+        const show = ref(true)
+        onMounted(() => {
+          show.value = false
+        })
+        const p = new Promise(r => setTimeout(r, 1))
+        deps.push(p.then(() => Promise.resolve()))
+        return () =>
+          h(
+            'div',
+            { key: show.value ? 'show' : 'hidden' },
+            show.value ? 'show' : 'hidden',
+          )
+      },
+    })
+
+    const Inner = defineComponent({
+      render() {
+        return h(Suspense, null, {
+          default: () => h(AsyncComp),
+        })
+      },
+    })
+
+    const Outer = defineComponent({
+      render() {
+        return h(Inner)
+      },
+    })
+
+    const root = nodeOps.createElement('div')
+    const vnode = h(Outer)
+    render(vnode, root)
+    expect(serializeInner(root)).toBe(`<!---->`)
+
+    await Promise.all(deps)
+    await nextTick()
+    expect(serializeInner(root)).toBe(`<div>hidden</div>`)
+
+    const renderedEl = root.children[0]
+    const innerVNode = vnode.component!.subTree
+    const suspenseVNode = innerVNode.component!.subTree
+
+    expect(suspenseVNode.el).toBe(renderedEl)
+    expect(innerVNode.el).toBe(renderedEl)
+    expect(vnode.el).toBe(renderedEl)
+  })
+
+  test('should mount after suspense is resolved', async () => {
+    const target = nodeOps.createElement('div')
+
+    const Async = defineAsyncComponent({
+      render() {
+        return h('div', 'async')
+      },
+    })
+
+    const Comp = {
+      setup() {
+        return () =>
+          h(Suspense, null, {
+            default: h('div', null, [
+              h(Async),
+              h(Teleport, { to: target }, h('div', 'teleported')),
+            ]),
+            fallback: h('div', 'fallback'),
+          })
+      },
+    }
+
+    const root = nodeOps.createElement('div')
+    render(h(Comp), root)
+    expect(serializeInner(root)).toBe(`<div>fallback</div>`)
+    expect(serializeInner(target)).toBe(``)
+
+    await Promise.all(deps)
+    await nextTick()
+    expect(serializeInner(root)).toBe(
+      `<div><div>async</div><!--teleport start--><!--teleport end--></div>`,
+    )
+    expect(serializeInner(target)).toBe(`<div>teleported</div>`)
+  })
+
+  test('should patch teleport before suspense is resolved', async () => {
+    const target = nodeOps.createElement('div')
+    const text = ref('one')
+
+    const Async = defineAsyncComponent({
+      render() {
+        return h('div', 'async')
+      },
+    })
+
+    const Comp = {
+      setup() {
+        return () =>
+          h(Suspense, null, {
+            default: h('div', null, [
+              h(Async),
+              h(Teleport, { to: target }, h('div', text.value)),
+            ]),
+            fallback: h('div', 'fallback'),
+          })
+      },
+    }
+
+    const root = nodeOps.createElement('div')
+    render(h(Comp), root)
+    expect(serializeInner(root)).toBe(`<div>fallback</div>`)
+    expect(serializeInner(target)).toBe(``)
+
+    text.value = 'two'
+    await nextTick()
+    expect(serializeInner(root)).toBe(`<div>fallback</div>`)
+    expect(serializeInner(target)).toBe(``)
+
+    await Promise.all(deps)
+    await nextTick()
+    expect(serializeInner(root)).toBe(
+      `<div><div>async</div><!--teleport start--><!--teleport end--></div>`,
+    )
+    expect(serializeInner(target)).toBe(`<div>two</div>`)
+  })
+
+  test('should handle disabled teleport updates before suspense is resolved', async () => {
+    const target = nodeOps.createElement('div')
+    const disabled = ref(false)
+
+    const Async = defineAsyncComponent({
+      render() {
+        return h('div', 'async')
+      },
+    })
+
+    const Comp = {
+      setup() {
+        return () =>
+          h(Suspense, null, {
+            default: h('div', null, [
+              h(Async),
+              h(
+                Teleport,
+                { to: target, disabled: disabled.value },
+                h('div', 'teleported'),
+              ),
+            ]),
+            fallback: h('div', 'fallback'),
+          })
+      },
+    }
+
+    const root = nodeOps.createElement('div')
+    render(h(Comp), root)
+    expect(serializeInner(root)).toBe(`<div>fallback</div>`)
+    expect(serializeInner(target)).toBe(``)
+
+    disabled.value = true
+    await nextTick()
+    expect(serializeInner(root)).toBe(`<div>fallback</div>`)
+    expect(serializeInner(target)).toBe(``)
+
+    await Promise.all(deps)
+    await nextTick()
+    expect(serializeInner(root)).toBe(
+      `<div><div>async</div><!--teleport start--><div>teleported</div><!--teleport end--></div>`,
+    )
+    expect(serializeInner(target)).toBe(``)
+  })
+
   //#11617
   test('update async component before resolve then update again', async () => {
     const arr: boolean[] = []

+ 65 - 0
packages/runtime-core/__tests__/helpers/withMemo.spec.ts

@@ -204,6 +204,71 @@ describe('v-memo', () => {
     )
   })
 
+  test('on v-if + v-for', async () => {
+    const [el, vm] = mount({
+      template: `<span v-if="show">
+          <span v-for="elem in [1]" :key="elem" v-memo="[count]">{{ count }}</span>
+        </span>`,
+      data: () => ({
+        show: true,
+        count: 0,
+      }),
+    })
+
+    expect(el.innerHTML).toBe(`<span><span>0</span></span>`)
+
+    vm.show = false
+    await nextTick()
+    expect(el.innerHTML).toBe(`<!--v-if-->`)
+
+    vm.show = true
+    await nextTick()
+    expect(el.innerHTML).toBe(`<span><span>0</span></span>`)
+
+    vm.count++
+    await nextTick()
+    expect(el.innerHTML).toBe(`<span><span>1</span></span>`)
+
+    vm.count++
+    await nextTick()
+    expect(el.innerHTML).toBe(`<span><span>2</span></span>`)
+  })
+
+  test('on v-if + v-for in production mode', async () => {
+    __DEV__ = false
+    try {
+      const [el, vm] = mount({
+        template: `<span v-if="show">
+            <span v-for="elem in [1]" :key="elem" v-memo="[count]">{{ count }}</span>
+          </span>`,
+        data: () => ({
+          show: true,
+          count: 0,
+        }),
+      })
+
+      expect(el.innerHTML).toBe(`<span><span>0</span></span>`)
+
+      vm.show = false
+      await nextTick()
+      expect(el.innerHTML).toBe(`<!---->`)
+
+      vm.show = true
+      await nextTick()
+      expect(el.innerHTML).toBe(`<span><span>0</span></span>`)
+
+      vm.count++
+      await nextTick()
+      expect(el.innerHTML).toBe(`<span><span>1</span></span>`)
+
+      vm.count++
+      await nextTick()
+      expect(el.innerHTML).toBe(`<span><span>2</span></span>`)
+    } finally {
+      __DEV__ = true
+    }
+  })
+
   test('on v-for /w constant expression ', async () => {
     const [el, vm] = mount({
       template: `<div v-for="item in 3"  v-memo="[count < 2 ? true : count]">

+ 11 - 0
packages/runtime-core/__tests__/vnode.spec.ts

@@ -472,6 +472,17 @@ describe('vnode', () => {
       expect(mergeProps(props1, props3)).toMatchObject({
         onClick: clickHandler1,
       })
+      const props4: Data = { onClick: undefined }
+      expect(mergeProps(props4)).toHaveProperty('onClick', undefined)
+      expect(mergeProps({ onClick: null })).toMatchObject({
+        onClick: null,
+      })
+      expect(
+        mergeProps({ 'onUpdate:modelValue': undefined }),
+      ).not.toHaveProperty('onUpdate:modelValue')
+      expect(mergeProps({ 'onUpdate:modelValue': null })).not.toHaveProperty(
+        'onUpdate:modelValue',
+      )
     })
 
     test('default', () => {

+ 9 - 2
packages/runtime-core/src/componentRenderUtils.ts

@@ -478,13 +478,16 @@ function hasPropValueChanged(
 }
 
 export function updateHOCHostEl(
-  { vnode, parent }: ComponentInternalInstance,
+  { vnode, parent, suspense }: ComponentInternalInstance,
   el: typeof vnode.el, // HostNode
 ): void {
   while (parent && !parent.vapor) {
     const root = parent.subTree!
     if (root.suspense && root.suspense.activeBranch === vnode) {
-      root.el = vnode.el
+      // Suspense proxies its active branch host node, so keep propagating from
+      // the boundary vnode to any wrapper components above it.
+      root.suspense.vnode.el = root.el = el
+      vnode = root
     }
     if (root === vnode) {
       ;(vnode = parent.vnode!).el = el
@@ -493,4 +496,8 @@ export function updateHOCHostEl(
       break
     }
   }
+  // also update suspense vnode el
+  if (suspense && suspense.activeBranch === vnode) {
+    suspense.vnode.el = el
+  }
 }

+ 2 - 1
packages/runtime-core/src/components/BaseTransition.ts

@@ -27,6 +27,7 @@ import {
   isVaporComponent,
 } from '../renderer'
 import { SchedulerJobFlags } from '../scheduler'
+import { isHmrUpdating } from '../hmr'
 
 type Hook<T = () => void> = T | T[]
 
@@ -453,7 +454,7 @@ export function baseResolveTransitionHooks(
 
     enter(el) {
       // prevent enter if leave is in progress
-      if (isLeaving()) return
+      if (!isHmrUpdating && isLeaving()) return
       let hook = onEnter
       let afterHook = onAfterEnter
       let cancelHook = onEnterCancelled

+ 13 - 1
packages/runtime-core/src/components/Suspense.ts

@@ -34,6 +34,10 @@ export interface SuspenseProps {
   onResolve?: () => void
   onPending?: () => void
   onFallback?: () => void
+  /**
+   * Switch to fallback content if it takes longer than `timeout` milliseconds to render the new default content.
+   * A `timeout` value of `0` will cause the fallback content to be displayed immediately when default content is replaced.
+   */
   timeout?: string | number
   /**
    * Allow suspense to be captured by parent suspense
@@ -416,6 +420,7 @@ export interface SuspenseBoundary {
   container: RendererElement
   hiddenContainer: RendererElement
   activeBranch: VNode | null
+  isFallbackMountPending: boolean
   pendingBranch: VNode | null
   deps: number
   pendingId: number
@@ -500,6 +505,7 @@ function createSuspenseBoundary(
     pendingId: suspenseId++,
     timeout: typeof timeout === 'number' ? timeout : -1,
     activeBranch: null,
+    isFallbackMountPending: false,
     pendingBranch: null,
     isInFallback: !isHydrating,
     isHydrating,
@@ -558,7 +564,10 @@ function createSuspenseBoundary(
           }
         }
         // unmount current active tree
-        if (activeBranch) {
+        // #7966 when Suspense is wrapped in Transition, fallback may wait for
+        // afterLeave before mounting. In that window, activeBranch is still the
+        // leaving content, so avoid unmounting it again during resolve.
+        if (activeBranch && !suspense.isFallbackMountPending) {
           // if the fallback tree was mounted, it may have been moved
           // as part of a parent suspense. get the latest anchor for insertion
           // #8105 if `delayEnter` is true, it means that the mounting of
@@ -594,6 +603,7 @@ function createSuspenseBoundary(
         }
       }
 
+      suspense.isFallbackMountPending = false
       setActiveBranch(suspense, pendingBranch!)
       suspense.pendingBranch = null
       suspense.isInFallback = false
@@ -649,6 +659,7 @@ function createSuspenseBoundary(
 
       const anchor = next(activeBranch!)
       const mountFallback = () => {
+        suspense.isFallbackMountPending = false
         if (!suspense.isInFallback) {
           return
         }
@@ -670,6 +681,7 @@ function createSuspenseBoundary(
       const delayEnter =
         fallbackVNode.transition && fallbackVNode.transition.mode === 'out-in'
       if (delayEnter) {
+        suspense.isFallbackMountPending = true
         activeBranch!.transition!.afterLeave = mountFallback
       }
       suspense.isInFallback = true

+ 16 - 8
packages/runtime-core/src/components/Teleport.ts

@@ -169,10 +169,14 @@ export const TeleportImpl = {
         updateCssVars(n2, true)
       }
 
-      if (isTeleportDeferred(n2.props)) {
+      if (
+        isTeleportDeferred(n2.props) ||
+        (parentSuspense && parentSuspense.pendingBranch)
+      ) {
         n2.el!.__isMounted = false
         queuePostRenderEffect(
           () => {
+            if (n2.el!.__isMounted !== false) return
             mountToTarget()
             delete n2.el!.__isMounted
           },
@@ -183,7 +187,17 @@ export const TeleportImpl = {
         mountToTarget()
       }
     } else {
-      if (isTeleportDeferred(n2.props) && n1.el!.__isMounted === false) {
+      // update content
+      n2.el = n1.el
+      n2.targetStart = n1.targetStart
+      const mainAnchor = (n2.anchor = n1.anchor)!
+      const target = (n2.target = n1.target)!
+      const targetAnchor = (n2.targetAnchor = n1.targetAnchor)!
+
+      // Target mounting may still be pending because of deferred teleport or a
+      // parent suspense buffering post-render effects. In that case, defer the
+      // teleport patch itself until the pending mount effect has run.
+      if (n1.el!.__isMounted === false) {
         queuePostRenderEffect(
           () => {
             TeleportImpl.process(
@@ -204,12 +218,6 @@ export const TeleportImpl = {
         )
         return
       }
-      // update content
-      n2.el = n1.el
-      n2.targetStart = n1.targetStart
-      const mainAnchor = (n2.anchor = n1.anchor)!
-      const target = (n2.target = n1.target)!
-      const targetAnchor = (n2.targetAnchor = n1.targetAnchor)!
       const wasDisabled = isTeleportDisabled(n1.props)
       const currentContainer = wasDisabled ? container : target
       const currentAnchor = wasDisabled ? mainAnchor : targetAnchor

+ 8 - 0
packages/runtime-core/src/hmr.ts

@@ -15,6 +15,14 @@ type HMRComponent = ComponentOptions | ClassComponent
 
 export let isHmrUpdating = false
 
+export const setHmrUpdating = (v: boolean): boolean => {
+  try {
+    return isHmrUpdating
+  } finally {
+    isHmrUpdating = v
+  }
+}
+
 export const hmrDirtyComponents: Map<
   ConcreteComponent,
   Set<GenericComponentInstance>

+ 21 - 3
packages/runtime-core/src/renderer.ts

@@ -87,6 +87,7 @@ import {
   hmrDirtyComponentsMode,
   isHmrUpdating,
   registerHMR,
+  setHmrUpdating,
   unregisterHMR,
 } from './hmr'
 import { type RootHydrateFunction, createHydrationFunctions } from './hydration'
@@ -777,10 +778,17 @@ function baseCreateRenderer(
     }
 
     if ((vnodeHook = props && props.onVnodeMounted) || dirs) {
+      const isHmr = __DEV__ && isHmrUpdating
       queuePostRenderEffect(
         () => {
-          vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode)
-          dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
+          let prev
+          if (__DEV__) prev = setHmrUpdating(isHmr)
+          try {
+            vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode)
+            dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
+          } finally {
+            if (__DEV__) setHmrUpdating(prev!)
+          }
         },
         undefined,
         parentSuspense,
@@ -2336,6 +2344,7 @@ function baseCreateRenderer(
       patchFlag,
       dirs,
       cacheIndex,
+      memo,
     } = vnode
 
     if (patchFlag === PatchFlags.BAIL) {
@@ -2453,16 +2462,25 @@ function baseCreateRenderer(
       }
     }
 
+    // v-for + v-memo stores cached vnodes inside renderList's array cache rather
+    // than component renderCache. Invalidate detached cached vnodes after
+    // unmount so a later v-if remount won't reuse a vnode whose DOM is gone.
+    const shouldInvalidateMemo = memo != null && cacheIndex == null
+
     if (
       (shouldInvokeVnodeHook &&
         (vnodeHook = props && props.onVnodeUnmounted)) ||
-      shouldInvokeDirs
+      shouldInvokeDirs ||
+      shouldInvalidateMemo
     ) {
       queuePostRenderEffect(
         () => {
           vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode)
           shouldInvokeDirs &&
             invokeDirectiveHook(vnode, null, parentComponent, 'unmounted')
+          if (shouldInvalidateMemo) {
+            vnode.el = null
+          }
         },
         undefined,
         parentSuspense,

+ 9 - 0
packages/runtime-core/src/vnode.ts

@@ -6,6 +6,7 @@ import {
   extend,
   isArray,
   isFunction,
+  isModelListener,
   isObject,
   isOn,
   isString,
@@ -910,6 +911,14 @@ export function mergeProps(...args: (Data & VNodeProps)[]): Data {
           ret[key] = existing
             ? [].concat(existing as any, incoming as any)
             : incoming
+        } else if (
+          incoming == null &&
+          existing == null &&
+          // mergeProps({ 'onUpdate:modelValue': undefined }) should not retain
+          // the model listener.
+          !isModelListener(key)
+        ) {
+          ret[key] = incoming
         }
       } else if (key !== '') {
         ret[key] = toMerge[key]

+ 46 - 0
packages/runtime-dom/__tests__/directives/vModel.spec.ts

@@ -375,6 +375,52 @@ describe('vModel', () => {
     expect(data.lazy).toEqual('foo')
   })
 
+  it('should preserve unresolved trimmed text while focused in nested shadow roots', async () => {
+    const model = ref('')
+    const component = defineComponent({
+      render() {
+        return withVModel(
+          h('input', {
+            'onUpdate:modelValue': (value: string) => {
+              model.value = value
+            },
+          }),
+          model.value,
+          {
+            trim: true,
+          },
+        )
+      },
+    })
+
+    document.body.appendChild(root)
+    const outerShadowRoot = root.attachShadow({ mode: 'open' })
+    const innerHost = document.createElement('div')
+    outerShadowRoot.appendChild(innerHost)
+    const innerShadowRoot = innerHost.attachShadow({ mode: 'open' })
+
+    try {
+      render(h(component), innerShadowRoot)
+
+      const input = innerShadowRoot.querySelector('input') as HTMLInputElement
+      input.focus()
+
+      expect(document.activeElement).toBe(root)
+      expect(outerShadowRoot.activeElement).toBe(innerHost)
+      expect(innerShadowRoot.activeElement).toBe(input)
+
+      input.value = '    hello, world    '
+      triggerEvent('input', input)
+      await nextTick()
+
+      expect(model.value).toEqual('hello, world')
+      expect(input.value).toEqual('    hello, world    ')
+    } finally {
+      render(null, innerShadowRoot)
+      root.remove()
+    }
+  })
+
   it('should work with range', async () => {
     const component = defineComponent({
       data() {

+ 6 - 1
packages/runtime-dom/src/directives/vModel.ts

@@ -135,7 +135,12 @@ export const vModelTextUpdate = (
     return
   }
 
-  if (document.activeElement === el && el.type !== 'range') {
+  const rootNode = el.getRootNode()
+  if (
+    (rootNode instanceof Document || rootNode instanceof ShadowRoot) &&
+    rootNode.activeElement === el &&
+    el.type !== 'range'
+  ) {
     // #8546
     if (lazy && value === oldValue) {
       return

+ 80 - 0
packages/server-renderer/__tests__/render.spec.ts

@@ -10,9 +10,11 @@ import {
   createTextVNode,
   createVNode,
   defineComponent,
+  effectScope,
   getCurrentInstance,
   h,
   onErrorCaptured,
+  onScopeDispose,
   onServerPrefetch,
   reactive,
   ref,
@@ -1002,6 +1004,84 @@ function testRender(type: string, render: typeof renderToString) {
       expect(html).toBe(`<div>hello</div>`)
     })
 
+    test('cleans up component effect scopes after each render', async () => {
+      const cleanups: number[] = []
+      const app = createApp({
+        setup() {
+          onScopeDispose(() => {
+            cleanups.push(1)
+          })
+          return () => h('div', 'ok')
+        },
+      })
+
+      expect(cleanups).toEqual([])
+      expect(await render(app)).toBe(`<div>ok</div>`)
+      expect(cleanups).toEqual([1])
+    })
+
+    test('concurrent renders isolate scope cleanup ownership', async () => {
+      const cleaned: string[] = []
+
+      const deferred = () => {
+        let resolve!: () => void
+        const promise = new Promise<void>(r => {
+          resolve = r
+        })
+        return { promise, resolve }
+      }
+
+      const gateA = deferred()
+      const gateB = deferred()
+
+      const makeApp = (id: string, gate: ReturnType<typeof deferred>) =>
+        createApp({
+          async setup() {
+            onScopeDispose(() => {
+              cleaned.push(id)
+            })
+            await gate.promise
+            return () => h('div', id)
+          },
+        })
+
+      const pA = render(makeApp('A', gateA))
+      const pB = render(makeApp('B', gateB))
+
+      gateB.resolve()
+      expect(await pB).toBe(`<div>B</div>`)
+      expect(cleaned).toEqual(['B'])
+
+      gateA.resolve()
+      expect(await pA).toBe(`<div>A</div>`)
+      expect(cleaned.sort()).toEqual(['A', 'B'])
+    })
+
+    test('detached scopes created during SSR are not auto-stopped', async () => {
+      let detachedStopped = false
+      let detached: any
+
+      const app = createApp({
+        setup() {
+          detached = effectScope(true)
+          detached.run(() => {
+            onScopeDispose(() => {
+              detachedStopped = true
+            })
+          })
+          return () => h('div', 'detached')
+        },
+      })
+
+      expect(await render(app)).toBe(`<div>detached</div>`)
+      expect(detached.active).toBe(true)
+      expect(detachedStopped).toBe(false)
+
+      detached.stop()
+      expect(detached.active).toBe(false)
+      expect(detachedStopped).toBe(true)
+    })
+
     test('multiple onServerPrefetch', async () => {
       const msg = Promise.resolve('hello')
       const msg2 = Promise.resolve('hi')

+ 3 - 3
packages/server-renderer/__tests__/ssrWatch.spec.ts

@@ -32,7 +32,7 @@ describe('ssr: watch', () => {
     const ctx: SSRContext = {}
     const html = await renderToString(app, ctx)
 
-    expect(ctx.__watcherHandles!.length).toBe(1)
+    expect(ctx.__watcherHandles!.length).toBe(0)
 
     expect(html).toMatch('hello world')
   })
@@ -61,7 +61,7 @@ describe('ssr: watch', () => {
     const ctx: SSRContext = {}
     const html = await renderToString(app, ctx)
 
-    expect(ctx.__watcherHandles!.length).toBe(1)
+    expect(ctx.__watcherHandles!.length).toBe(0)
     expect(html).toMatch('changed again')
     await nextTick()
     expect(msg).toBe('changed again')
@@ -229,7 +229,7 @@ describe('ssr: watchEffect', () => {
     const ctx: SSRContext = {}
     const html = await renderToString(app, ctx)
 
-    expect(ctx.__watcherHandles!.length).toBe(1)
+    expect(ctx.__watcherHandles!.length).toBe(0)
     expect(html).toMatch('changed again')
     await nextTick()
     expect(msg).toBe('changed again')

+ 40 - 0
packages/server-renderer/src/render.ts

@@ -11,6 +11,7 @@ import {
   type VNodeArrayChildren,
   type VNodeProps,
   mergeProps,
+  ssrContextKey,
   ssrUtils,
   warn,
 } from 'vue'
@@ -55,6 +56,37 @@ export type SSRContext = {
    * @internal
    */
   __watcherHandles?: (() => void)[]
+  /**
+   * @internal
+   */
+  __instanceScopes?: { stop: () => void }[]
+}
+
+export function cleanupContext(context: SSRContext): void {
+  let firstError: unknown
+  if (context.__watcherHandles) {
+    for (const unwatch of context.__watcherHandles) {
+      try {
+        unwatch()
+      } catch (err) {
+        if (firstError === undefined) firstError = err
+      }
+    }
+    context.__watcherHandles.length = 0
+  }
+  if (context.__instanceScopes) {
+    for (const scope of context.__instanceScopes) {
+      try {
+        scope.stop()
+      } catch (err) {
+        if (firstError === undefined) firstError = err
+      }
+    }
+    context.__instanceScopes.length = 0
+  }
+  if (firstError !== undefined) {
+    throw firstError
+  }
 }
 
 // Each component has a buffer array.
@@ -98,6 +130,14 @@ export function renderComponentVNode(
     parentComponent,
     null,
   ))
+  const context = instance.appContext.provides[ssrContextKey as any] as
+    | SSRContext
+    | undefined
+  if (context) {
+    ;(context.__instanceScopes || (context.__instanceScopes = [])).push(
+      instance.scope,
+    )
+  }
   if (__DEV__) pushWarningContext(vnode)
   const res = setupComponent(instance, true /* isSSR */)
   if (__DEV__) popWarningContext()

+ 23 - 9
packages/server-renderer/src/renderToStream.ts

@@ -7,7 +7,12 @@ import {
   ssrUtils,
 } from 'vue'
 import { isPromise, isString } from '@vue/shared'
-import { type SSRBuffer, type SSRContext, renderComponentVNode } from './render'
+import {
+  type SSRBuffer,
+  type SSRContext,
+  cleanupContext,
+  renderComponentVNode,
+} from './render'
 import type { Readable, Writable } from 'node:stream'
 import { resolveTeleports } from './renderToString'
 
@@ -43,7 +48,7 @@ async function unrollBuffer(
 
 function unrollBufferSync(buffer: SSRBuffer, stream: SimpleReadable) {
   for (let i = 0; i < buffer.length; i++) {
-    let item = buffer[i]
+    const item = buffer[i]
     if (isString(item)) {
       stream.push(item)
     } else {
@@ -73,18 +78,27 @@ export function renderToSimpleStream<T extends SimpleReadable>(
   // provide the ssr context to the tree
   input.provide(ssrContextKey, context)
 
-  Promise.resolve(renderComponentVNode(vnode))
+  let cleaned = false
+  const finalize = () => {
+    if (cleaned) return
+    cleaned = true
+    cleanupContext(context)
+  }
+
+  Promise.resolve()
+    .then(() => renderComponentVNode(vnode))
     .then(buffer => unrollBuffer(buffer, stream))
     .then(() => resolveTeleports(context))
     .then(() => {
-      if (context.__watcherHandles) {
-        for (const unwatch of context.__watcherHandles) {
-          unwatch()
-        }
-      }
+      finalize()
+      return stream.push(null)
     })
-    .then(() => stream.push(null))
     .catch(error => {
+      try {
+        finalize()
+      } catch {
+        // preserve original render error as the stream failure reason
+      }
       stream.destroy(error)
     })
 

+ 13 - 10
packages/server-renderer/src/renderToString.ts

@@ -7,7 +7,12 @@ import {
   ssrUtils,
 } from 'vue'
 import { isPromise, isString } from '@vue/shared'
-import { type SSRBuffer, type SSRContext, renderComponentVNode } from './render'
+import {
+  type SSRBuffer,
+  type SSRContext,
+  cleanupContext,
+  renderComponentVNode,
+} from './render'
 
 const { isVNode } = ssrUtils
 
@@ -81,19 +86,17 @@ export async function renderToString(
   vnode.appContext = input._context
   // provide the ssr context to the tree
   input.provide(ssrContextKey, context)
-  const buffer = await renderComponentVNode(vnode)
+  try {
+    const buffer = await renderComponentVNode(vnode)
 
-  const result = await unrollBuffer(buffer as SSRBuffer)
+    const result = await unrollBuffer(buffer as SSRBuffer)
 
-  await resolveTeleports(context)
+    await resolveTeleports(context)
 
-  if (context.__watcherHandles) {
-    for (const unwatch of context.__watcherHandles) {
-      unwatch()
-    }
+    return result
+  } finally {
+    cleanupContext(context)
   }
-
-  return result
 }
 
 export async function resolveTeleports(context: SSRContext): Promise<void> {

+ 137 - 0
packages/vue/__tests__/e2e/Transition.spec.ts

@@ -1657,6 +1657,75 @@ describe('e2e: Transition', () => {
       E2E_TIMEOUT,
     )
 
+    // #14608
+    test(
+      'hmr reload child wrapped in KeepAlive (out-in mode)',
+      async () => {
+        await page().evaluate(
+          async ({ duration, childId }) => {
+            const { createApp } = (window as any).Vue
+            const { createRecord } = (window as any).__VUE_HMR_RUNTIME__
+
+            const Child = {
+              __hmrId: childId,
+              name: 'OriginalChild',
+              data() {
+                return { count: 0 }
+              },
+              template: `<div class="test">{{ count }}</div>`,
+            }
+
+            createRecord(childId, Child)
+
+            createApp({
+              components: { Child },
+              data() {
+                return { toggle: true }
+              },
+              template: `
+                <div id="container">
+                  <transition name="test" mode="out-in" :duration="${duration}">
+                    <KeepAlive>
+                      <Child v-if="toggle" />
+                    </KeepAlive>
+                  </transition>
+                </div>
+              `,
+            }).mount('#app')
+
+            await (window as any).Vue.nextTick()
+          },
+          { duration, childId: 'transition-keepalive-out-in-hmr' },
+        )
+
+        expect(await html('#container')).toBe('<div class="test">0</div>')
+
+        await page().evaluate(async childId => {
+          const { reload } = (window as any).__VUE_HMR_RUNTIME__
+          reload(childId, {
+            __hmrId: childId,
+            name: 'UpdatedChild',
+            data() {
+              return { count: 1 }
+            },
+            template: `<div class="test">{{ count }}</div>`,
+          })
+
+          await (window as any).Vue.nextTick()
+        }, 'transition-keepalive-out-in-hmr')
+
+        await nextFrame()
+        expect(await html('#container')).toBe(
+          '<div class="test test-leave-active test-leave-to">0</div>' +
+            '<div class="test test-enter-active test-enter-to">1</div>',
+        )
+
+        await transitionFinish()
+        expect(await html('#container')).toBe('<div class="test">1</div>')
+      },
+      E2E_TIMEOUT,
+    )
+
     // #12860
     test(
       'unmount children',
@@ -2110,6 +2179,74 @@ describe('e2e: Transition', () => {
       E2E_TIMEOUT,
     )
 
+    test(
+      'avoid unmount activeBranch twice with Suspense (out-in mode + timeout="0")',
+      async () => {
+        const unmountSpy = vi.fn()
+        await page().exposeFunction('unmountSpy', unmountSpy)
+        await page().evaluate(() => {
+          const { createApp, shallowRef, h } = (window as any).Vue
+          const One = {
+            setup() {
+              return () =>
+                h(
+                  'div',
+                  {
+                    onVnodeBeforeUnmount: () => unmountSpy(),
+                  },
+                  'one',
+                )
+            },
+          }
+          const Two = {
+            async setup() {
+              return () => h('div', null, 'two')
+            },
+          }
+          createApp({
+            template: `
+            <div id="container">
+              <transition mode="out-in">
+                <suspense timeout="0">
+                  <template #default>
+                    <component :is="view"></component>
+                  </template>
+                  <template #fallback>
+                    <div>Loading...</div>
+                  </template>
+                </suspense>
+              </transition>
+            </div>
+            <button id="toggleBtn" @click="click">button</button>
+            `,
+            setup: () => {
+              const view = shallowRef(One)
+              const click = () => {
+                view.value = view.value === One ? Two : One
+              }
+              return { view, click }
+            },
+          }).mount('#app')
+        })
+
+        expect(await html('#container')).toBe('<div>one</div>')
+
+        // leave
+        await classWhenTransitionStart()
+        await nextFrame()
+        expect(await html('#container')).toBe(
+          '<div class="v-enter-from v-enter-active">two</div>',
+        )
+
+        await transitionFinish()
+        expect(await html('#container')).toBe('<div class="">two</div>')
+
+        // should only call unmount once
+        expect(unmountSpy).toBeCalledTimes(1)
+      },
+      E2E_TIMEOUT,
+    )
+
     // #5844
     test('children mount should be called after html changes', async () => {
       const fooMountSpy = vi.fn()

Plik diff jest za duży
+ 229 - 235
pnpm-lock.yaml


+ 2 - 2
pnpm-workspace.yaml

@@ -3,12 +3,12 @@ packages:
   - 'packages-private/*'
 
 catalog:
-  '@babel/parser': ^7.29.0
+  '@babel/parser': ^7.29.2
   '@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
+  '@vitejs/plugin-vue': ^6.0.5
   'magic-string': ^0.30.21
   'source-map-js': ^1.2.1
 

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików