From ad9da6141f19a75ff455aeb09a2749080ced8d33 Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 26 Nov 2025 11:12:35 +0800 Subject: [PATCH 1/2] feat(vapor): implement `v-once` support for slot outlets --- .../transforms/__snapshots__/vOnce.spec.ts.snap | 12 ++++++++++++ .../__tests__/transforms/vOnce.spec.ts | 8 +++++++- packages/compiler-vapor/src/generators/slotOutlet.ts | 3 ++- packages/compiler-vapor/src/ir/index.ts | 1 + .../src/transforms/transformSlotOutlet.ts | 1 + packages/runtime-vapor/src/componentSlots.ts | 12 +++++++++++- packages/runtime-vapor/src/renderEffect.ts | 4 ++++ 7 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vOnce.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vOnce.spec.ts.snap index 7f76c3cd7c1..eb0abd04676 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vOnce.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vOnce.spec.ts.snap @@ -60,6 +60,18 @@ export function render(_ctx) { }" `; +exports[`compiler: v-once > on slot outlet 1`] = ` +"import { setInsertionState as _setInsertionState, createSlot as _createSlot, template as _template } from 'vue'; +const t0 = _template("
", true) + +export function render(_ctx) { + const n1 = t0() + _setInsertionState(n1, null, true) + const n0 = _createSlot("default", null, null, null, true) + return n1 +}" +`; + exports[`compiler: v-once > with v-for 1`] = ` "import { createFor as _createFor, template as _template } from 'vue'; const t0 = _template("
") diff --git a/packages/compiler-vapor/__tests__/transforms/vOnce.spec.ts b/packages/compiler-vapor/__tests__/transforms/vOnce.spec.ts index 97f6ee62a11..4a42901a1c2 100644 --- a/packages/compiler-vapor/__tests__/transforms/vOnce.spec.ts +++ b/packages/compiler-vapor/__tests__/transforms/vOnce.spec.ts @@ -135,7 +135,13 @@ describe('compiler: v-once', () => { }) }) - test.todo('on slot outlet') + test('on slot outlet', () => { + const { ir, code } = compileWithOnce(`
`) + expect(code).toMatchSnapshot() + + expect(ir.block.effect).lengthOf(0) + expect(ir.block.operation).lengthOf(0) + }) test('inside v-once', () => { const { ir, code } = compileWithOnce(`
`) diff --git a/packages/compiler-vapor/src/generators/slotOutlet.ts b/packages/compiler-vapor/src/generators/slotOutlet.ts index afacb644888..68428b2331b 100644 --- a/packages/compiler-vapor/src/generators/slotOutlet.ts +++ b/packages/compiler-vapor/src/generators/slotOutlet.ts @@ -10,7 +10,7 @@ export function genSlotOutlet( context: CodegenContext, ): CodeFragment[] { const { helper } = context - const { id, name, fallback, noSlotted } = oper + const { id, name, fallback, noSlotted, once } = oper const [frag, push] = buildCodeFragment() const nameExpr = name.isStatic @@ -31,6 +31,7 @@ export function genSlotOutlet( genRawProps(oper.props, context) || 'null', fallbackArg, noSlotted && 'true', // noSlotted + once && 'true', // v-once ), ) diff --git a/packages/compiler-vapor/src/ir/index.ts b/packages/compiler-vapor/src/ir/index.ts index 13fd5e1e696..115310bbda3 100644 --- a/packages/compiler-vapor/src/ir/index.ts +++ b/packages/compiler-vapor/src/ir/index.ts @@ -221,6 +221,7 @@ export interface SlotOutletIRNode extends BaseIRNode { props: IRProps[] fallback?: BlockIRNode noSlotted?: boolean + once?: boolean parent?: number anchor?: number append?: boolean diff --git a/packages/compiler-vapor/src/transforms/transformSlotOutlet.ts b/packages/compiler-vapor/src/transforms/transformSlotOutlet.ts index 88edc0510e3..72dc7875da8 100644 --- a/packages/compiler-vapor/src/transforms/transformSlotOutlet.ts +++ b/packages/compiler-vapor/src/transforms/transformSlotOutlet.ts @@ -107,6 +107,7 @@ export const transformSlotOutlet: NodeTransform = (node, context) => { props: irProps, fallback, noSlotted: !!(context.options.scopeId && !context.options.slotted), + once: context.inVOnce, } } } diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index 2f83b9f8647..b450e077a14 100644 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@ -25,6 +25,12 @@ import { DynamicFragment, type VaporFragment } from './fragment' import { createElement } from './dom/node' import { setDynamicProps } from './dom/prop' +/** + * Flag to indicate if we are executing a once slot. + * When true, renderEffect should skip creating reactive effect. + */ +export let inOnceSlot = false + /** * Current slot scopeIds for vdom interop */ @@ -163,6 +169,7 @@ export function createSlot( rawProps?: LooseRawProps | null, fallback?: VaporSlot, noSlotted?: boolean, + once?: boolean, ): Block { const _insertionParent = insertionParent const _insertionAnchor = insertionAnchor @@ -236,9 +243,12 @@ export function createSlot( const prevSlotScopeIds = setCurrentSlotScopeIds( slotScopeIds.length > 0 ? slotScopeIds : null, ) + const prev = inOnceSlot try { + if (once) inOnceSlot = true return slot(slotProps) } finally { + inOnceSlot = prev setCurrentSlotScopeIds(prevSlotScopeIds) } }), @@ -249,7 +259,7 @@ export function createSlot( } // dynamic slot name or has dynamicSlots - if (isDynamicName || rawSlots.$) { + if (!once && (isDynamicName || rawSlots.$)) { renderEffect(renderSlot) } else { renderSlot() diff --git a/packages/runtime-vapor/src/renderEffect.ts b/packages/runtime-vapor/src/renderEffect.ts index e36ac4ba458..5fc2d541424 100644 --- a/packages/runtime-vapor/src/renderEffect.ts +++ b/packages/runtime-vapor/src/renderEffect.ts @@ -9,6 +9,7 @@ import { warn, } from '@vue/runtime-dom' import { type VaporComponentInstance, isVaporComponent } from './component' +import { inOnceSlot } from './componentSlots' import { invokeArrayFns } from '@vue/shared' export class RenderEffect extends ReactiveEffect { @@ -88,6 +89,9 @@ export class RenderEffect extends ReactiveEffect { } export function renderEffect(fn: () => void, noLifecycle = false): void { + // in once slot, just run the function directly + if (inOnceSlot) return fn() + const effect = new RenderEffect(fn) if (noLifecycle) { effect.fn = fn From 256ef3648483cae8d34da892fac12b5ee3a01af1 Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 26 Nov 2025 11:19:24 +0800 Subject: [PATCH 2/2] test(v-once): add test for v-once in slot outlet --- .../__tests__/componentSlots.spec.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/runtime-vapor/__tests__/componentSlots.spec.ts b/packages/runtime-vapor/__tests__/componentSlots.spec.ts index 69756b02339..74b6a9b3b9a 100644 --- a/packages/runtime-vapor/__tests__/componentSlots.spec.ts +++ b/packages/runtime-vapor/__tests__/componentSlots.spec.ts @@ -14,6 +14,7 @@ import { renderEffect, setInsertionState, template, + txt, vaporInteropPlugin, withVaporCtx, } from '../src' @@ -774,6 +775,42 @@ describe('component: slots', () => { await nextTick() expect(html()).toBe('2') }) + + test('work with v-once', async () => { + const Child = defineVaporComponent({ + setup() { + return createSlot( + 'default', + null, + undefined, + undefined, + true /* once */, + ) + }, + }) + + const count = ref(0) + + const { html } = define({ + setup() { + return createComponent(Child, null, { + default: withVaporCtx(() => { + const n3 = template('
')() as any + const x3 = txt(n3) as any + renderEffect(() => setText(x3, toDisplayString(count.value))) + return n3 + }), + }) + }, + }).render() + + expect(html()).toBe('
0
') + + // expect no changes due to v-once + count.value++ + await nextTick() + expect(html()).toBe('
0
') + }) }) describe('forwarded slot', () => {