diff --git a/packages/runtime-core/__tests__/rendererOptimizedMode.spec.ts b/packages/runtime-core/__tests__/rendererOptimizedMode.spec.ts index 3c653281f..82f6f02fb 100644 --- a/packages/runtime-core/__tests__/rendererOptimizedMode.spec.ts +++ b/packages/runtime-core/__tests__/rendererOptimizedMode.spec.ts @@ -2,6 +2,7 @@ import { h, Fragment, createVNode, + createCommentVNode, openBlock, createBlock, render, @@ -576,4 +577,119 @@ describe('renderer: optimized mode', () => { await nextTick() expect(inner(root)).toBe('
World
') }) + + // #3548 + test('should not track dynamic children when the user calls a compiled slot inside template expression', () => { + const Comp = { + setup(props: any, { slots }: SetupContext) { + return () => { + return ( + openBlock(), + (block = createBlock('section', null, [ + renderSlot(slots, 'default') + ])) + ) + } + } + } + + let dynamicVNode: VNode + const Wrapper = { + setup(props: any, { slots }: SetupContext) { + return () => { + return ( + openBlock(), + createBlock(Comp, null, { + default: withCtx(() => { + return [ + (dynamicVNode = createVNode( + 'div', + { + class: { + foo: !!slots.default!() + } + }, + null, + PatchFlags.CLASS + )) + ] + }), + _: 1 + }) + ) + } + } + } + const app = createApp({ + render() { + return ( + openBlock(), + createBlock(Wrapper, null, { + default: withCtx(() => { + return [createVNode({}) /* component */] + }), + _: 1 + }) + ) + } + }) + + app.mount(root) + expect(inner(root)).toBe('
') + /** + * Block Tree: + * - block(div) + * - block(Fragment): renderSlots() + * - dynamicVNode + */ + expect(block!.dynamicChildren!.length).toBe(1) + expect(block!.dynamicChildren![0].dynamicChildren!.length).toBe(1) + expect(block!.dynamicChildren![0].dynamicChildren![0]).toEqual( + dynamicVNode! + ) + }) + + // 3569 + test('should force bailout when the user manually calls the slot function', async () => { + const index = ref(0) + const Foo = { + setup(props: any, { slots }: SetupContext) { + return () => { + return slots.default!()[index.value] + } + } + } + + const app = createApp({ + setup() { + return () => { + return ( + openBlock(), + createBlock(Foo, null, { + default: withCtx(() => [ + true + ? (openBlock(), createBlock('p', { key: 0 }, '1')) + : createCommentVNode('v-if', true), + true + ? (openBlock(), createBlock('p', { key: 0 }, '2')) + : createCommentVNode('v-if', true) + ]), + _: 1 /* STABLE */ + }) + ) + } + } + }) + + app.mount(root) + expect(inner(root)).toBe('

1

') + + index.value = 1 + await nextTick() + expect(inner(root)).toBe('

2

') + + index.value = 0 + await nextTick() + expect(inner(root)).toBe('

1

') + }) }) diff --git a/packages/runtime-core/src/compat/instance.ts b/packages/runtime-core/src/compat/instance.ts index 5ed4e19f0..966a9a1a6 100644 --- a/packages/runtime-core/src/compat/instance.ts +++ b/packages/runtime-core/src/compat/instance.ts @@ -37,6 +37,7 @@ import { import { resolveFilter } from '../helpers/resolveAssets' import { resolveMergedOptions } from '../componentOptions' import { InternalSlots, Slots } from '../componentSlots' +import { ContextualRenderFn } from '../componentRenderContext' export type LegacyPublicInstance = ComponentPublicInstance & LegacyPublicProperties @@ -106,7 +107,7 @@ export function installCompatInstanceProperties(map: PublicPropertiesMap) { const res: InternalSlots = {} for (const key in i.slots) { const fn = i.slots[key]! - if (!(fn as any)._nonScoped) { + if (!(fn as ContextualRenderFn)._ns /* non-scoped slot */) { res[key] = fn } } diff --git a/packages/runtime-core/src/compat/renderFn.ts b/packages/runtime-core/src/compat/renderFn.ts index c65cc8d79..480bf029a 100644 --- a/packages/runtime-core/src/compat/renderFn.ts +++ b/packages/runtime-core/src/compat/renderFn.ts @@ -281,7 +281,7 @@ function convertLegacySlots(vnode: VNode): VNode { for (const key in slots) { const slotChildren = slots[key] slots[key] = () => slotChildren - slots[key]._nonScoped = true + slots[key]._ns = true /* non-scoped slot */ } } } diff --git a/packages/runtime-core/src/componentRenderContext.ts b/packages/runtime-core/src/componentRenderContext.ts index a25756864..8f49ec251 100644 --- a/packages/runtime-core/src/componentRenderContext.ts +++ b/packages/runtime-core/src/componentRenderContext.ts @@ -1,7 +1,6 @@ import { ComponentInternalInstance } from './component' import { devtoolsComponentUpdated } from './devtools' -import { isRenderingCompiledSlot } from './helpers/renderSlot' -import { closeBlock, openBlock } from './vnode' +import { setBlockTracking } from './vnode' /** * mark the current rendering instance for asset resolution (e.g. @@ -56,6 +55,14 @@ export function popScopeId() { */ export const withScopeId = (_id: string) => withCtx +export type ContextualRenderFn = { + (...args: any[]): any + _n: boolean /* already normalized */ + _c: boolean /* compiled */ + _d: boolean /* disableTracking */ + _ns: boolean /* nonScoped */ +} + /** * Wrap a slot function to memoize current rendering instance * @private compiler helper @@ -66,18 +73,26 @@ export function withCtx( isNonScopedSlot?: boolean // __COMPAT__ only ) { if (!ctx) return fn - const renderFnWithContext = (...args: any[]) => { + + // already normalized + if ((fn as ContextualRenderFn)._n) { + return fn + } + + const renderFnWithContext: ContextualRenderFn = (...args: any[]) => { // If a user calls a compiled slot inside a template expression (#1745), it - // can mess up block tracking, so by default we need to push a null block to - // avoid that. This isn't necessary if rendering a compiled ``. - if (!isRenderingCompiledSlot) { - openBlock(true /* null block that disables tracking */) + // can mess up block tracking, so by default we disable block tracking and + // force bail out when invoking a compiled slot (indicated by the ._d flag). + // This isn't necessary if rendering a compiled ``, so we flip the + // ._d flag off when invoking the wrapped fn inside `renderSlot`. + if (renderFnWithContext._d) { + setBlockTracking(-1) } const prevInstance = setCurrentRenderingInstance(ctx) const res = fn(...args) setCurrentRenderingInstance(prevInstance) - if (!isRenderingCompiledSlot) { - closeBlock() + if (renderFnWithContext._d) { + setBlockTracking(1) } if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { @@ -86,13 +101,18 @@ export function withCtx( return res } - // mark this as a compiled slot function. + + // mark normalized to avoid duplicated wrapping + renderFnWithContext._n = true + // mark this as compiled by default // this is used in vnode.ts -> normalizeChildren() to set the slot // rendering flag. - // also used to cache the normalized results to avoid repeated normalization - renderFnWithContext._c = renderFnWithContext + renderFnWithContext._c = true + // disable block tracking by default + renderFnWithContext._d = true + // compat build only flag to distinguish scoped slots from non-scoped ones if (__COMPAT__ && isNonScopedSlot) { - renderFnWithContext._nonScoped = true + renderFnWithContext._ns = true } return renderFnWithContext } diff --git a/packages/runtime-core/src/componentSlots.ts b/packages/runtime-core/src/componentSlots.ts index 7e0fe3cb6..8c19cec3f 100644 --- a/packages/runtime-core/src/componentSlots.ts +++ b/packages/runtime-core/src/componentSlots.ts @@ -17,7 +17,7 @@ import { } from '@vue/shared' import { warn } from './warning' import { isKeepAlive } from './components/KeepAlive' -import { withCtx } from './componentRenderContext' +import { ContextualRenderFn, withCtx } from './componentRenderContext' import { isHmrUpdating } from './hmr' import { DeprecationTypes, isCompatEnabled } from './compat/compatConfig' import { toRaw } from '@vue/reactivity' @@ -62,9 +62,8 @@ const normalizeSlot = ( key: string, rawSlot: Function, ctx: ComponentInternalInstance | null | undefined -): Slot => - (rawSlot as any)._c || - (withCtx((props: any) => { +): Slot => { + const normalized = withCtx((props: any) => { if (__DEV__ && currentInstance) { warn( `Slot "${key}" invoked outside of the render function: ` + @@ -73,7 +72,11 @@ const normalizeSlot = ( ) } return normalizeSlotValue(rawSlot(props)) - }, ctx) as Slot) + }, ctx) as Slot + // NOT a compiled slot + ;(normalized as ContextualRenderFn)._c = false + return normalized +} const normalizeObjectSlots = ( rawSlots: RawSlots, diff --git a/packages/runtime-core/src/helpers/renderSlot.ts b/packages/runtime-core/src/helpers/renderSlot.ts index 26e7b8250..181d49a54 100644 --- a/packages/runtime-core/src/helpers/renderSlot.ts +++ b/packages/runtime-core/src/helpers/renderSlot.ts @@ -1,5 +1,6 @@ import { Data } from '../component' import { Slots, RawSlots } from '../componentSlots' +import { ContextualRenderFn } from '../componentRenderContext' import { Comment, isVNode } from '../vnode' import { VNodeArrayChildren, @@ -11,10 +12,6 @@ import { import { PatchFlags, SlotFlags } from '@vue/shared' import { warn } from '../warning' -export let isRenderingCompiledSlot = 0 -export const setCompiledSlotRendering = (n: number) => - (isRenderingCompiledSlot += n) - /** * Compiler runtime helper for rendering `` * @private @@ -43,7 +40,9 @@ export function renderSlot( // invocation interfering with template-based block tracking, but in // `renderSlot` we can be sure that it's template-based so we can force // enable it. - isRenderingCompiledSlot++ + if (slot && (slot as ContextualRenderFn)._c) { + ;(slot as ContextualRenderFn)._d = false + } openBlock() const validSlotContent = slot && ensureValidVNode(slot(props)) const rendered = createBlock( @@ -57,7 +56,9 @@ export function renderSlot( if (!noSlotted && rendered.scopeId) { rendered.slotScopeIds = [rendered.scopeId + '-s'] } - isRenderingCompiledSlot-- + if (slot && (slot as ContextualRenderFn)._c) { + ;(slot as ContextualRenderFn)._d = true + } return rendered } diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index 930db7718..7e5976e2f 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -40,7 +40,6 @@ import { import { RendererNode, RendererElement } from './renderer' import { NULL_DYNAMIC_COMPONENT } from './helpers/resolveAssets' import { hmrDirtyComponents } from './hmr' -import { setCompiledSlotRendering } from './helpers/renderSlot' import { convertLegacyComponent } from './compat/component' import { convertLegacyVModelProps } from './compat/componentVModel' import { defineLegacyVNodeProperties } from './compat/renderFn' @@ -218,7 +217,7 @@ export function closeBlock() { // Only tracks when this value is > 0 // We are not using a simple boolean because this value may need to be // incremented/decremented by nested usage of v-once (see below) -let shouldTrack = 1 +let isBlockTreeEnabled = 1 /** * Block tracking sometimes needs to be disabled, for example during the @@ -237,7 +236,7 @@ let shouldTrack = 1 * @private */ export function setBlockTracking(value: number) { - shouldTrack += value + isBlockTreeEnabled += value } /** @@ -263,12 +262,13 @@ export function createBlock( true /* isBlock: prevent a block from tracking itself */ ) // save current block children on the block vnode - vnode.dynamicChildren = currentBlock || (EMPTY_ARR as any) + vnode.dynamicChildren = + isBlockTreeEnabled > 0 ? currentBlock || (EMPTY_ARR as any) : null // close block closeBlock() // a block is always going to be patched, so track it as a child of its // parent block - if (shouldTrack > 0 && currentBlock) { + if (isBlockTreeEnabled > 0 && currentBlock) { currentBlock.push(vnode) } return vnode @@ -458,7 +458,7 @@ function _createVNode( } if ( - shouldTrack > 0 && + isBlockTreeEnabled > 0 && // avoid a block node from tracking itself !isBlockNode && // has current parent block @@ -635,9 +635,9 @@ export function normalizeChildren(vnode: VNode, children: unknown) { const slot = (children as any).default if (slot) { // _c marker is added by withCtx() indicating this is a compiled slot - slot._c && setCompiledSlotRendering(1) + slot._c && (slot._d = false) normalizeChildren(vnode, slot()) - slot._c && setCompiledSlotRendering(-1) + slot._c && (slot._d = true) } return } else {