diff --git a/CHANGELOG.md b/CHANGELOG.md index d490aa19d..2a0b96332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## [3.5.18](https://github.com/vuejs/core/compare/v3.5.17...v3.5.18) (2025-07-23) + + +### Bug Fixes + +* **compiler-core:** avoid cached text vnodes retaining detached DOM nodes ([#13662](https://github.com/vuejs/core/issues/13662)) ([00695a5](https://github.com/vuejs/core/commit/00695a5b41b2d032deaeada83831ff83aa6bfd4e)), closes [#13661](https://github.com/vuejs/core/issues/13661) +* **compiler-core:** avoid self updates of `v-pre` ([#12556](https://github.com/vuejs/core/issues/12556)) ([21b685a](https://github.com/vuejs/core/commit/21b685ad9d9d0e6060fc7d07b719bf35f2d9ae1f)) +* **compiler-core:** identifiers in function parameters should not be inferred as references ([#13548](https://github.com/vuejs/core/issues/13548)) ([9b02923](https://github.com/vuejs/core/commit/9b029239edf88558465b941e1e4c085f92b1ebff)) +* **compiler-core:** recognize empty string as non-identifier ([#12553](https://github.com/vuejs/core/issues/12553)) ([ce93339](https://github.com/vuejs/core/commit/ce933390ad1c72bed258f7ad959a78f0e8acdf57)) +* **compiler-core:** transform empty `v-bind` dynamic argument content correctly ([#12554](https://github.com/vuejs/core/issues/12554)) ([d3af67e](https://github.com/vuejs/core/commit/d3af67e878790892f9d34cfea15d13625aabe733)) +* **compiler-sfc:** transform empty srcset w/ includeAbsolute: true ([#13639](https://github.com/vuejs/core/issues/13639)) ([d8e40ef](https://github.com/vuejs/core/commit/d8e40ef7e1c20ee86b294e7cf78e2de60d12830e)), closes [vitejs/vite-plugin-vue#631](https://github.com/vitejs/vite-plugin-vue/issues/631) +* **css-vars:** nullish v-bind in style should not lead to unexpected inheritance ([#12461](https://github.com/vuejs/core/issues/12461)) ([c85f1b5](https://github.com/vuejs/core/commit/c85f1b5a132eb8ec25f71b250e25e65a5c20964f)), closes [#12434](https://github.com/vuejs/core/issues/12434) [#12439](https://github.com/vuejs/core/issues/12439) [#7474](https://github.com/vuejs/core/issues/7474) [#7475](https://github.com/vuejs/core/issues/7475) +* **custom-element:** ensure exposed methods are accessible from custom elements by making them enumerable ([#13634](https://github.com/vuejs/core/issues/13634)) ([90573b0](https://github.com/vuejs/core/commit/90573b06bf6fb6c14c6bbff6c4e34e0ab108953a)), closes [#13632](https://github.com/vuejs/core/issues/13632) +* **hydration:** prevent lazy hydration for updated components ([#13511](https://github.com/vuejs/core/issues/13511)) ([a9269c6](https://github.com/vuejs/core/commit/a9269c642bf944560bc29adb5dae471c11cd9ee8)), closes [#13510](https://github.com/vuejs/core/issues/13510) +* **runtime-core:** ensure correct anchor el for unresolved async components ([#13560](https://github.com/vuejs/core/issues/13560)) ([7f29943](https://github.com/vuejs/core/commit/7f2994393dcdb82cacbf62e02b5ba5565f32588b)), closes [#13559](https://github.com/vuejs/core/issues/13559) +* **slots:** refine internal key checking to support slot names starting with an underscore ([#13612](https://github.com/vuejs/core/issues/13612)) ([c5f7db1](https://github.com/vuejs/core/commit/c5f7db11542bb2246363aef78c88a8e6cef0ee93)), closes [#13611](https://github.com/vuejs/core/issues/13611) +* **ssr:** ensure empty slots render as a comment node in Transition ([#13396](https://github.com/vuejs/core/issues/13396)) ([8cfc10a](https://github.com/vuejs/core/commit/8cfc10a80b9cbf5d801ab149e49b8506d192e7e1)), closes [#13394](https://github.com/vuejs/core/issues/13394) + + + ## [3.5.17](https://github.com/vuejs/core/compare/v3.5.16...v3.5.17) (2025-06-18) diff --git a/package.json b/package.json index 5b51534bf..e75d5ca1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "private": true, - "version": "3.5.17", + "version": "3.5.18", "packageManager": "pnpm@10.13.1", "type": "module", "scripts": { diff --git a/packages/compiler-core/__tests__/transforms/__snapshots__/cacheStatic.spec.ts.snap b/packages/compiler-core/__tests__/transforms/__snapshots__/cacheStatic.spec.ts.snap index b8bef22c4..91a82db5b 100644 --- a/packages/compiler-core/__tests__/transforms/__snapshots__/cacheStatic.spec.ts.snap +++ b/packages/compiler-core/__tests__/transforms/__snapshots__/cacheStatic.spec.ts.snap @@ -60,7 +60,7 @@ return function render(_ctx, _cache) { return (_openBlock(), _createElementBlock("div", null, _cache[0] || (_cache[0] = [ _createElementVNode("span", null, null, -1 /* CACHED */), - _createTextVNode("foo"), + _createTextVNode("foo", -1 /* CACHED */), _createElementVNode("div", null, null, -1 /* CACHED */) ]))) } diff --git a/packages/compiler-core/__tests__/transforms/vIf.spec.ts b/packages/compiler-core/__tests__/transforms/vIf.spec.ts index 2c2fedab0..73b6e2215 100644 --- a/packages/compiler-core/__tests__/transforms/vIf.spec.ts +++ b/packages/compiler-core/__tests__/transforms/vIf.spec.ts @@ -301,6 +301,25 @@ describe('compiler: v-if', () => { ]) }) + test('error on adjacent v-else', () => { + const onError = vi.fn() + + const { + node: { branches }, + } = parseWithIfTransform( + `
`, + { onError }, + 0, + ) + + expect(onError.mock.calls[0]).toMatchObject([ + { + code: ErrorCodes.X_V_ELSE_NO_ADJACENT_IF, + loc: branches[branches.length - 1].loc, + }, + ]) + }) + test('error on user key', () => { const onError = vi.fn() // dynamic diff --git a/packages/compiler-core/__tests__/utils.spec.ts b/packages/compiler-core/__tests__/utils.spec.ts index 2d377a271..000b10e11 100644 --- a/packages/compiler-core/__tests__/utils.spec.ts +++ b/packages/compiler-core/__tests__/utils.spec.ts @@ -1,4 +1,9 @@ -import type { ExpressionNode, TransformContext } from '../src' +import { babelParse, walkIdentifiers } from '@vue/compiler-sfc' +import { + type ExpressionNode, + type TransformContext, + isReferencedIdentifier, +} from '../src' import { type Position, createSimpleExpression } from '../src/ast' import { advancePositionWithClone, @@ -115,3 +120,18 @@ test('toValidAssetId', () => { '_component_test_2797935797_1', ) }) + +describe('isReferencedIdentifier', () => { + test('identifiers in function parameters should not be inferred as references', () => { + expect.assertions(4) + const ast = babelParse(`(({ title }) => [])`) + walkIdentifiers( + ast.program.body[0], + (node, parent, parentStack, isReference) => { + expect(isReference).toBe(false) + expect(isReferencedIdentifier(node, parent, parentStack)).toBe(false) + }, + true, + ) + }) +}) diff --git a/packages/compiler-core/package.json b/packages/compiler-core/package.json index a3c188a66..a59342aeb 100644 --- a/packages/compiler-core/package.json +++ b/packages/compiler-core/package.json @@ -1,6 +1,6 @@ { "name": "@vue/compiler-core", - "version": "3.5.17", + "version": "3.5.18", "description": "@vue/compiler-core", "main": "index.js", "module": "dist/compiler-core.esm-bundler.js", diff --git a/packages/compiler-core/src/babelUtils.ts b/packages/compiler-core/src/babelUtils.ts index 52fabeea8..51614612b 100644 --- a/packages/compiler-core/src/babelUtils.ts +++ b/packages/compiler-core/src/babelUtils.ts @@ -122,7 +122,7 @@ export function isReferencedIdentifier( return false } - if (isReferenced(id, parent)) { + if (isReferenced(id, parent, parentStack[parentStack.length - 2])) { return true } @@ -132,7 +132,8 @@ export function isReferencedIdentifier( case 'AssignmentExpression': case 'AssignmentPattern': return true - case 'ObjectPattern': + case 'ObjectProperty': + return parent.key !== id && isInDestructureAssignment(parent, parentStack) case 'ArrayPattern': return isInDestructureAssignment(parent, parentStack) } diff --git a/packages/compiler-core/src/transforms/cacheStatic.ts b/packages/compiler-core/src/transforms/cacheStatic.ts index 239ee689a..0f112e19c 100644 --- a/packages/compiler-core/src/transforms/cacheStatic.ts +++ b/packages/compiler-core/src/transforms/cacheStatic.ts @@ -24,7 +24,13 @@ import { getVNodeHelper, } from '../ast' import type { TransformContext } from '../transform' -import { PatchFlags, isArray, isString, isSymbol } from '@vue/shared' +import { + PatchFlagNames, + PatchFlags, + isArray, + isString, + isSymbol, +} from '@vue/shared' import { findDir, isSlotOutlet } from '../utils' import { GUARD_REACTIVE_PROPS, @@ -109,6 +115,15 @@ function walk( ? ConstantTypes.NOT_CONSTANT : getConstantType(child, context) if (constantType >= ConstantTypes.CAN_CACHE) { + if ( + child.codegenNode.type === NodeTypes.JS_CALL_EXPRESSION && + child.codegenNode.arguments.length > 0 + ) { + child.codegenNode.arguments.push( + PatchFlags.CACHED + + (__DEV__ ? ` /* ${PatchFlagNames[PatchFlags.CACHED]} */` : ``), + ) + } toCache.push(child) continue } diff --git a/packages/compiler-core/src/transforms/vIf.ts b/packages/compiler-core/src/transforms/vIf.ts index 54c505407..8bf5c6a32 100644 --- a/packages/compiler-core/src/transforms/vIf.ts +++ b/packages/compiler-core/src/transforms/vIf.ts @@ -141,9 +141,9 @@ export function processIf( } if (sibling && sibling.type === NodeTypes.IF) { - // Check if v-else was followed by v-else-if + // Check if v-else was followed by v-else-if or there are two adjacent v-else if ( - dir.name === 'else-if' && + (dir.name === 'else-if' || dir.name === 'else') && sibling.branches[sibling.branches.length - 1].condition === undefined ) { context.onError( diff --git a/packages/compiler-dom/package.json b/packages/compiler-dom/package.json index 95a56f045..38b62f050 100644 --- a/packages/compiler-dom/package.json +++ b/packages/compiler-dom/package.json @@ -1,6 +1,6 @@ { "name": "@vue/compiler-dom", - "version": "3.5.17", + "version": "3.5.18", "description": "@vue/compiler-dom", "main": "index.js", "module": "dist/compiler-dom.esm-bundler.js", diff --git a/packages/compiler-sfc/__tests__/__snapshots__/templateTransformSrcset.spec.ts.snap b/packages/compiler-sfc/__tests__/__snapshots__/templateTransformSrcset.spec.ts.snap index 0469ffaba..28e0af71f 100644 --- a/packages/compiler-sfc/__tests__/__snapshots__/templateTransformSrcset.spec.ts.snap +++ b/packages/compiler-sfc/__tests__/__snapshots__/templateTransformSrcset.spec.ts.snap @@ -16,6 +16,16 @@ export function render(_ctx, _cache) { }" `; +exports[`compiler sfc: transform srcset > transform empty srcset w/ includeAbsolute: true 1`] = ` +"import { openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" + +const _hoisted_1 = { srcset: " " } + +export function render(_ctx, _cache) { + return (_openBlock(), _createElementBlock("img", _hoisted_1)) +}" +`; + exports[`compiler sfc: transform srcset > transform srcset 1`] = ` "import { createElementVNode as _createElementVNode, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" import _imports_0 from './logo.png' diff --git a/packages/compiler-sfc/__tests__/templateTransformSrcset.spec.ts b/packages/compiler-sfc/__tests__/templateTransformSrcset.spec.ts index 491731f94..68239bbd1 100644 --- a/packages/compiler-sfc/__tests__/templateTransformSrcset.spec.ts +++ b/packages/compiler-sfc/__tests__/templateTransformSrcset.spec.ts @@ -72,6 +72,14 @@ describe('compiler sfc: transform srcset', () => { ).toMatchSnapshot() }) + test('transform empty srcset w/ includeAbsolute: true', () => { + expect( + compileWithSrcset(``, { + includeAbsolute: true, + }).code, + ).toMatchSnapshot() + }) + test('transform srcset w/ stringify', () => { const code = compileWithSrcset( `
${src}
`, diff --git a/packages/compiler-sfc/package.json b/packages/compiler-sfc/package.json index 45468177a..ff1f2e48d 100644 --- a/packages/compiler-sfc/package.json +++ b/packages/compiler-sfc/package.json @@ -1,6 +1,6 @@ { "name": "@vue/compiler-sfc", - "version": "3.5.17", + "version": "3.5.18", "description": "@vue/compiler-sfc", "main": "dist/compiler-sfc.cjs.js", "module": "dist/compiler-sfc.esm-browser.js", diff --git a/packages/compiler-sfc/src/template/transformSrcset.ts b/packages/compiler-sfc/src/template/transformSrcset.ts index 8f00f86e3..40fba4882 100644 --- a/packages/compiler-sfc/src/template/transformSrcset.ts +++ b/packages/compiler-sfc/src/template/transformSrcset.ts @@ -71,6 +71,7 @@ export const transformSrcset: NodeTransform = ( const shouldProcessUrl = (url: string) => { return ( + url && !isExternalUrl(url) && !isDataUrl(url) && (options.includeAbsolute || isRelativeUrl(url)) diff --git a/packages/compiler-ssr/package.json b/packages/compiler-ssr/package.json index 31681468f..d11be67c4 100644 --- a/packages/compiler-ssr/package.json +++ b/packages/compiler-ssr/package.json @@ -1,6 +1,6 @@ { "name": "@vue/compiler-ssr", - "version": "3.5.17", + "version": "3.5.18", "description": "@vue/compiler-ssr", "main": "dist/compiler-ssr.cjs.js", "types": "dist/compiler-ssr.d.ts", diff --git a/packages/reactivity/package.json b/packages/reactivity/package.json index d861a5fb1..a26334ee8 100644 --- a/packages/reactivity/package.json +++ b/packages/reactivity/package.json @@ -1,6 +1,6 @@ { "name": "@vue/reactivity", - "version": "3.5.17", + "version": "3.5.18", "description": "@vue/reactivity", "main": "index.js", "module": "dist/reactivity.esm-bundler.js", diff --git a/packages/runtime-core/__tests__/componentSlots.spec.ts b/packages/runtime-core/__tests__/componentSlots.spec.ts index ad87e5367..765fce33e 100644 --- a/packages/runtime-core/__tests__/componentSlots.spec.ts +++ b/packages/runtime-core/__tests__/componentSlots.spec.ts @@ -6,6 +6,7 @@ import { nodeOps, ref, render, + serializeInner, useSlots, } from '@vue/runtime-test' import { createBlock, normalizeVNode } from '../src/vnode' @@ -74,6 +75,10 @@ describe('component: slots', () => { footer: ['f1', 'f2'], }) + expect( + '[Vue warn]: Non-function value encountered for slot "_inner". Prefer function slots for better performance.', + ).toHaveBeenWarned() + expect( '[Vue warn]: Non-function value encountered for slot "header". Prefer function slots for better performance.', ).toHaveBeenWarned() @@ -82,8 +87,8 @@ describe('component: slots', () => { '[Vue warn]: Non-function value encountered for slot "footer". Prefer function slots for better performance.', ).toHaveBeenWarned() - expect(slots).not.toHaveProperty('_inner') expect(slots).not.toHaveProperty('foo') + expect(slots._inner()).toMatchObject([normalizeVNode('_inner')]) expect(slots.header()).toMatchObject([normalizeVNode('header')]) expect(slots.footer()).toMatchObject([ normalizeVNode('f1'), @@ -442,4 +447,22 @@ describe('component: slots', () => { 'Slot "default" invoked outside of the render function', ).toHaveBeenWarned() }) + + test('slot name starts with underscore', () => { + const Comp = { + setup(_: any, { slots }: any) { + return () => slots._foo() + }, + } + + const App = { + setup() { + return () => h(Comp, null, { _foo: () => 'foo' }) + }, + } + + const root = nodeOps.createElement('div') + createApp(App).mount(root) + expect(serializeInner(root)).toBe('foo') + }) }) diff --git a/packages/runtime-core/__tests__/components/Suspense.spec.ts b/packages/runtime-core/__tests__/components/Suspense.spec.ts index 65e801de2..563c91a17 100644 --- a/packages/runtime-core/__tests__/components/Suspense.spec.ts +++ b/packages/runtime-core/__tests__/components/Suspense.spec.ts @@ -2230,5 +2230,57 @@ describe('Suspense', () => { fallback: [h('div'), h('div')], }) }) + + // #13559 + test('renders multiple async components in Suspense with v-for and updates on items change', async () => { + const CompAsyncSetup = defineAsyncComponent({ + props: ['item'], + render(ctx: any) { + return h('div', ctx.item.name) + }, + }) + + const items = ref([ + { id: 1, name: '111' }, + { id: 2, name: '222' }, + { id: 3, name: '333' }, + ]) + + const Comp = { + setup() { + return () => + h(Suspense, null, { + default: () => + h( + Fragment, + null, + items.value.map(item => + h(CompAsyncSetup, { item, key: item.id }), + ), + ), + }) + }, + } + + const root = nodeOps.createElement('div') + render(h(Comp), root) + await nextTick() + await Promise.all(deps) + + expect(serializeInner(root)).toBe( + `
111
222
333
`, + ) + + items.value = [ + { id: 4, name: '444' }, + { id: 5, name: '555' }, + { id: 6, name: '666' }, + ] + await nextTick() + await Promise.all(deps) + expect(serializeInner(root)).toBe( + `
444
555
666
`, + ) + }) }) }) diff --git a/packages/runtime-core/__tests__/hydration.spec.ts b/packages/runtime-core/__tests__/hydration.spec.ts index 4a9e0fac2..6828e61ec 100644 --- a/packages/runtime-core/__tests__/hydration.spec.ts +++ b/packages/runtime-core/__tests__/hydration.spec.ts @@ -1160,6 +1160,69 @@ describe('SSR hydration', () => { ) }) + // #13510 + test('update async component after parent mount before async component resolve', async () => { + const Comp = { + props: ['toggle'], + render(this: any) { + return h('h1', [ + this.toggle ? 'Async component' : 'Updated async component', + ]) + }, + } + let serverResolve: any + let AsyncComp = defineAsyncComponent( + () => + new Promise(r => { + serverResolve = r + }), + ) + + const toggle = ref(true) + const App = { + setup() { + onMounted(() => { + // change state, after mount and before async component resolve + nextTick(() => (toggle.value = false)) + }) + + return () => { + return h(AsyncComp, { toggle: toggle.value }) + } + }, + } + + // server render + const htmlPromise = renderToString(h(App)) + serverResolve(Comp) + const html = await htmlPromise + expect(html).toMatchInlineSnapshot(`"

Async component

"`) + + // hydration + let clientResolve: any + AsyncComp = defineAsyncComponent( + () => + new Promise(r => { + clientResolve = r + }), + ) + + const container = document.createElement('div') + container.innerHTML = html + createSSRApp(App).mount(container) + + // resolve + clientResolve(Comp) + await new Promise(r => setTimeout(r)) + + // prevent lazy hydration since the component has been patched + expect('Skipping lazy hydration for component').toHaveBeenWarned() + expect(`Hydration node mismatch`).not.toHaveBeenWarned() + expect(container.innerHTML).toMatchInlineSnapshot( + `"

Updated async component

"`, + ) + }) + test('hydrate safely when property used by async setup changed before render', async () => { const toggle = ref(true) @@ -1677,6 +1740,35 @@ describe('SSR hydration', () => { expect(`mismatch`).not.toHaveBeenWarned() }) + // #13394 + test('transition appear work with empty content', async () => { + const show = ref(true) + const { vnode, container } = mountWithHydration( + ``, + function (this: any) { + return h( + Transition, + { appear: true }, + { + default: () => + show.value + ? renderSlot(this.$slots, 'default') + : createTextVNode('foo'), + }, + ) + }, + ) + + // empty slot render as a comment node + expect(container.firstChild!.nodeType).toBe(Node.COMMENT_NODE) + expect(vnode.el).toBe(container.firstChild) + expect(`mismatch`).not.toHaveBeenWarned() + + show.value = false + await nextTick() + expect(container.innerHTML).toBe('foo') + }) + test('transition appear with v-if', () => { const show = false const { vnode, container } = mountWithHydration( diff --git a/packages/runtime-core/package.json b/packages/runtime-core/package.json index 1e60e27cb..5c2541c9c 100644 --- a/packages/runtime-core/package.json +++ b/packages/runtime-core/package.json @@ -1,6 +1,6 @@ { "name": "@vue/runtime-core", - "version": "3.5.17", + "version": "3.5.18", "description": "@vue/runtime-core", "main": "index.js", "module": "dist/runtime-core.esm-bundler.js", diff --git a/packages/runtime-core/src/apiAsyncComponent.ts b/packages/runtime-core/src/apiAsyncComponent.ts index cb675f06e..ab4ab51b6 100644 --- a/packages/runtime-core/src/apiAsyncComponent.ts +++ b/packages/runtime-core/src/apiAsyncComponent.ts @@ -123,28 +123,30 @@ export function defineAsyncComponent< __asyncHydrate(el, instance, hydrate) { let patched = false + ;(instance.bu || (instance.bu = [])).push(() => (patched = true)) + const performHydrate = () => { + // skip hydration if the component has been patched + if (patched) { + if (__DEV__) { + warn( + `Skipping lazy hydration for component '${getComponentName(resolvedComp!) || resolvedComp!.__file}': ` + + `it was updated before lazy hydration performed.`, + ) + } + return + } + hydrate() + } const doHydrate = hydrateStrategy ? () => { - const performHydrate = () => { - // skip hydration if the component has been patched - if (__DEV__ && patched) { - warn( - `Skipping lazy hydration for component '${getComponentName(resolvedComp!)}': ` + - `it was updated before lazy hydration performed.`, - ) - return - } - hydrate() - } const teardown = hydrateStrategy(performHydrate, cb => forEachElement(el, cb), ) if (teardown) { ;(instance.bum || (instance.bum = [])).push(teardown) } - ;(instance.u || (instance.u = [])).push(() => (patched = true)) } - : hydrate + : performHydrate if (resolvedComp) { doHydrate() } else { diff --git a/packages/runtime-core/src/apiSetupHelpers.ts b/packages/runtime-core/src/apiSetupHelpers.ts index 2ddaeb509..209b3364c 100644 --- a/packages/runtime-core/src/apiSetupHelpers.ts +++ b/packages/runtime-core/src/apiSetupHelpers.ts @@ -382,17 +382,17 @@ export function withDefaults< } export function useSlots(): SetupContext['slots'] { - return getContext().slots + return getContext('useSlots').slots } export function useAttrs(): SetupContext['attrs'] { - return getContext().attrs + return getContext('useAttrs').attrs } -function getContext(): SetupContext { +function getContext(calledFunctionName: string): SetupContext { const i = getCurrentInstance()! if (__DEV__ && !i) { - warn(`useContext() called without active instance.`) + warn(`${calledFunctionName}() called without active instance.`) } return i.setupContext || (i.setupContext = createSetupContext(i)) } diff --git a/packages/runtime-core/src/componentOptions.ts b/packages/runtime-core/src/componentOptions.ts index 5db6a0a17..25d21477c 100644 --- a/packages/runtime-core/src/componentOptions.ts +++ b/packages/runtime-core/src/componentOptions.ts @@ -756,6 +756,7 @@ export function applyOptions(instance: ComponentInternalInstance): void { Object.defineProperty(exposed, key, { get: () => publicThis[key], set: val => (publicThis[key] = val), + enumerable: true, }) }) } else if (!instance.exposed) { diff --git a/packages/runtime-core/src/componentSlots.ts b/packages/runtime-core/src/componentSlots.ts index 6114f6c86..380728750 100644 --- a/packages/runtime-core/src/componentSlots.ts +++ b/packages/runtime-core/src/componentSlots.ts @@ -86,7 +86,8 @@ export type RawSlots = { __?: number[] } -const isInternalKey = (key: string) => key[0] === '_' || key === '$stable' +const isInternalKey = (key: string) => + key === '_' || key === '__' || key === '_ctx' || key === '$stable' const normalizeSlotValue = (value: unknown): VNode[] => isArray(value) diff --git a/packages/runtime-core/src/profiling.ts b/packages/runtime-core/src/profiling.ts index 1984f5a21..4e832a7c4 100644 --- a/packages/runtime-core/src/profiling.ts +++ b/packages/runtime-core/src/profiling.ts @@ -28,12 +28,10 @@ export function endMeasure( if (instance.appContext.config.performance && isSupported()) { const startTag = `vue-${type}-${instance.uid}` const endTag = startTag + `:end` + const measureName = `<${formatComponentName(instance, instance.type)}> ${type}` perf.mark(endTag) - perf.measure( - `<${formatComponentName(instance, instance.type)}> ${type}`, - startTag, - endTag, - ) + perf.measure(measureName, startTag, endTag) + perf.clearMeasures(measureName) perf.clearMarks(startTag) perf.clearMarks(endTag) } diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index a57be791a..f046e93ad 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -1226,6 +1226,7 @@ function baseCreateRenderer( if (!initialVNode.el) { const placeholder = (instance.subTree = createVNode(Comment)) processCommentNode(null, placeholder, container!, anchor) + initialVNode.placeholder = placeholder.el } } else { setupRenderEffect( @@ -1979,8 +1980,12 @@ function baseCreateRenderer( for (i = toBePatched - 1; i >= 0; i--) { const nextIndex = s2 + i const nextChild = c2[nextIndex] as VNode + const anchorVNode = c2[nextIndex + 1] as VNode const anchor = - nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor + nextIndex + 1 < l2 + ? // #13559, fallback to el placeholder for unresolved async component + anchorVNode.el || anchorVNode.placeholder + : parentAnchor if (newIndexToOldIndexMap[i] === 0) { // mount new patch( diff --git a/packages/runtime-core/src/rendererTemplateRef.ts b/packages/runtime-core/src/rendererTemplateRef.ts index ca21030dc..cea9e5b84 100644 --- a/packages/runtime-core/src/rendererTemplateRef.ts +++ b/packages/runtime-core/src/rendererTemplateRef.ts @@ -2,6 +2,7 @@ import type { SuspenseBoundary } from './components/Suspense' import type { VNode, VNodeNormalizedRef, VNodeNormalizedRefAtom } from './vnode' import { EMPTY_OBJ, + NO, ShapeFlags, hasOwn, isArray, @@ -77,7 +78,7 @@ export function setRef( const rawSetupState = toRaw(setupState) const canSetSetupRef = setupState === EMPTY_OBJ - ? () => false + ? NO : (key: string) => { if (__DEV__) { if (hasOwn(rawSetupState, key) && !isRef(rawSetupState[key])) { diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index a8c5340cd..cd1ef948d 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -196,6 +196,7 @@ export interface VNode< // DOM el: HostNode | null + placeholder: HostNode | null // async component el placeholder anchor: HostNode | null // fragment anchor target: HostElement | null // teleport target targetStart: HostNode | null // teleport target start anchor @@ -711,6 +712,8 @@ export function cloneVNode( suspense: vnode.suspense, ssContent: vnode.ssContent && cloneVNode(vnode.ssContent), ssFallback: vnode.ssFallback && cloneVNode(vnode.ssFallback), + placeholder: vnode.placeholder, + el: vnode.el, anchor: vnode.anchor, ctx: vnode.ctx, diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts index c44840df5..07ea09148 100644 --- a/packages/runtime-dom/__tests__/customElement.spec.ts +++ b/packages/runtime-dom/__tests__/customElement.spec.ts @@ -1402,6 +1402,34 @@ describe('defineCustomElement', () => { }) describe('expose', () => { + test('expose w/ options api', async () => { + const E = defineCustomElement({ + data() { + return { + value: 0, + } + }, + methods: { + foo() { + ;(this as any).value++ + }, + }, + expose: ['foo'], + render(_ctx: any) { + return h('div', null, _ctx.value) + }, + }) + customElements.define('my-el-expose-options-api', E) + + container.innerHTML = `` + const e = container.childNodes[0] as VueElement & { + foo: () => void + } + expect(e.shadowRoot!.innerHTML).toBe(`
0
`) + e.foo() + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe(`
1
`) + }) test('expose attributes and callback', async () => { type SetValue = (value: string) => void let fn: MockedFunction diff --git a/packages/runtime-dom/package.json b/packages/runtime-dom/package.json index 4d71206e2..729406f7a 100644 --- a/packages/runtime-dom/package.json +++ b/packages/runtime-dom/package.json @@ -1,6 +1,6 @@ { "name": "@vue/runtime-dom", - "version": "3.5.17", + "version": "3.5.18", "description": "@vue/runtime-dom", "main": "index.js", "module": "dist/runtime-dom.esm-bundler.js", diff --git a/packages/server-renderer/__tests__/ssrSlot.spec.ts b/packages/server-renderer/__tests__/ssrSlot.spec.ts index 4cc7fd97e..214e6ee84 100644 --- a/packages/server-renderer/__tests__/ssrSlot.spec.ts +++ b/packages/server-renderer/__tests__/ssrSlot.spec.ts @@ -111,26 +111,106 @@ describe('ssr: slot', () => { }) test('transition slot', async () => { + const ReusableTransition = { + template: ``, + } + + const ReusableTransitionWithAppear = { + template: ``, + } + expect( await renderToString( createApp({ components: { - one: { - template: ``, - }, + one: ReusableTransition, }, template: `
foo
`, }), ), ).toBe(``) + expect(await renderToString(createApp(ReusableTransition))).toBe(``) + + expect(await renderToString(createApp(ReusableTransitionWithAppear))).toBe( + ``, + ) + + expect( + await renderToString( + createApp({ + components: { + one: ReusableTransition, + }, + template: ``, + }), + ), + ).toBe(``) + expect( await renderToString( createApp({ components: { - one: { - template: ``, - }, + one: ReusableTransitionWithAppear, + }, + template: ``, + }), + ), + ).toBe(``) + + expect( + await renderToString( + createApp({ + render() { + return h(ReusableTransition, null, { + default: () => null, + }) + }, + }), + ), + ).toBe(``) + + expect( + await renderToString( + createApp({ + render() { + return h(ReusableTransitionWithAppear, null, { + default: () => null, + }) + }, + }), + ), + ).toBe(``) + + expect( + await renderToString( + createApp({ + render() { + return h(ReusableTransitionWithAppear, null, { + default: () => [], + }) + }, + }), + ), + ).toBe(``) + + expect( + await renderToString( + createApp({ + render() { + return h(ReusableTransition, null, { + default: () => [], + }) + }, + }), + ), + ).toBe(``) + + expect( + await renderToString( + createApp({ + components: { + one: ReusableTransition, }, template: `
foo
`, }), diff --git a/packages/server-renderer/package.json b/packages/server-renderer/package.json index 18ede1625..0fe43c72c 100644 --- a/packages/server-renderer/package.json +++ b/packages/server-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@vue/server-renderer", - "version": "3.5.17", + "version": "3.5.18", "description": "@vue/server-renderer", "main": "index.js", "module": "dist/server-renderer.esm-bundler.js", diff --git a/packages/server-renderer/src/helpers/ssrRenderSlot.ts b/packages/server-renderer/src/helpers/ssrRenderSlot.ts index 19aa4ce63..2f93a12de 100644 --- a/packages/server-renderer/src/helpers/ssrRenderSlot.ts +++ b/packages/server-renderer/src/helpers/ssrRenderSlot.ts @@ -74,6 +74,8 @@ export function ssrRenderSlotInner( ) } else if (fallbackRenderFn) { fallbackRenderFn() + } else if (transition) { + push(``) } } else { // ssr slot. @@ -110,13 +112,19 @@ export function ssrRenderSlotInner( end-- } - for (let i = start; i < end; i++) { - push(slotBuffer[i]) + if (start < end) { + for (let i = start; i < end; i++) { + push(slotBuffer[i]) + } + } else if (transition) { + push(``) } } } } else if (fallbackRenderFn) { fallbackRenderFn() + } else if (transition) { + push(``) } } diff --git a/packages/shared/package.json b/packages/shared/package.json index 648179768..63a10598c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@vue/shared", - "version": "3.5.17", + "version": "3.5.18", "description": "internal utils shared across @vue packages", "main": "index.js", "module": "dist/shared.esm-bundler.js", diff --git a/packages/vue-compat/package.json b/packages/vue-compat/package.json index 091dc1030..dbb356958 100644 --- a/packages/vue-compat/package.json +++ b/packages/vue-compat/package.json @@ -1,6 +1,6 @@ { "name": "@vue/compat", - "version": "3.5.17", + "version": "3.5.18", "description": "Vue 3 compatibility build for Vue 2", "main": "index.js", "module": "dist/vue.runtime.esm-bundler.js", diff --git a/packages/vue/__tests__/e2e/memory-leak.spec.ts b/packages/vue/__tests__/e2e/memory-leak.spec.ts new file mode 100644 index 000000000..2412cea2b --- /dev/null +++ b/packages/vue/__tests__/e2e/memory-leak.spec.ts @@ -0,0 +1,85 @@ +import { E2E_TIMEOUT, setupPuppeteer } from './e2eUtils' +import path from 'node:path' + +const { page, html, click } = setupPuppeteer() + +beforeEach(async () => { + await page().setContent(`
`) + await page().addScriptTag({ + path: path.resolve(__dirname, '../../dist/vue.global.js'), + }) +}) + +describe('not leaking', async () => { + // #13661 + test( + 'cached text vnodes should not retaining detached DOM nodes', + async () => { + const client = await page().createCDPSession() + await page().evaluate(async () => { + const { createApp, ref } = (window as any).Vue + createApp({ + components: { + Comp1: { + template: ` +

+
{{ test.length }}
+ `, + setup() { + const test = ref([...Array(3000)].map((_, i) => ({ i }))) + // @ts-expect-error + window.__REF__ = new WeakRef(test) + + return { test } + }, + }, + Comp2: { + template: `

comp2

`, + }, + }, + template: ` + + +
+ + text node +
+
+ `, + setup() { + const toggle = ref(true) + const click = () => (toggle.value = !toggle.value) + return { toggle, click } + }, + }).mount('#app') + }) + + expect(await html('#app')).toBe( + `` + + `

` + + `
` + + `

comp2

` + + ` text node ` + + `
` + + `

` + + `
3000
`, + ) + + await click('#toggleBtn') + expect(await html('#app')).toBe( + ``, + ) + + const isCollected = async () => + // @ts-expect-error + await page().evaluate(() => window.__REF__.deref() === undefined) + + while ((await isCollected()) === false) { + await client.send('HeapProfiler.collectGarbage') + } + + expect(await isCollected()).toBe(true) + }, + E2E_TIMEOUT, + ) +}) diff --git a/packages/vue/package.json b/packages/vue/package.json index 5d5704cb3..897bdec9e 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "vue", - "version": "3.5.17", + "version": "3.5.18", "description": "The progressive JavaScript framework for building modern web UI.", "main": "index.js", "module": "dist/vue.runtime.esm-bundler.js",