]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(vapor): implement `v-once` support for slot outlets (#14141)
authoredison <daiwei521@126.com>
Wed, 26 Nov 2025 05:39:53 +0000 (13:39 +0800)
committerGitHub <noreply@github.com>
Wed, 26 Nov 2025 05:39:53 +0000 (13:39 +0800)
packages/compiler-vapor/__tests__/transforms/__snapshots__/vOnce.spec.ts.snap
packages/compiler-vapor/__tests__/transforms/vOnce.spec.ts
packages/compiler-vapor/src/generators/slotOutlet.ts
packages/compiler-vapor/src/ir/index.ts
packages/compiler-vapor/src/transforms/transformSlotOutlet.ts
packages/runtime-vapor/__tests__/componentSlots.spec.ts
packages/runtime-vapor/src/componentSlots.ts
packages/runtime-vapor/src/renderEffect.ts

index 7f76c3cd7c11c0d15388e326ad4e06029b6cc682..eb0abd0467666c480d7a5bc12ec2bf00a5c5d102 100644 (file)
@@ -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("<div></div>", 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("<div></div>")
index 97f6ee62a119d3587e452125bcd15f00fbb5db64..4a42901a1c2f99366b4ce9cdde802d38fc794d80 100644 (file)
@@ -135,7 +135,13 @@ describe('compiler: v-once', () => {
     })
   })
 
-  test.todo('on slot outlet')
+  test('on slot outlet', () => {
+    const { ir, code } = compileWithOnce(`<div><slot v-once /></div>`)
+    expect(code).toMatchSnapshot()
+
+    expect(ir.block.effect).lengthOf(0)
+    expect(ir.block.operation).lengthOf(0)
+  })
 
   test('inside v-once', () => {
     const { ir, code } = compileWithOnce(`<div v-once><div v-once/></div>`)
index afacb644888bce9052372767a6ed4e1b0ccbdfbc..68428b2331b6ddc9d947dbbdfe466417e0babc93 100644 (file)
@@ -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
     ),
   )
 
index 13fd5e1e6960f2e3ff22a648f65bf7f4a2b24a4f..115310bbda39d1a985604abe02a0008c14aa3d88 100644 (file)
@@ -221,6 +221,7 @@ export interface SlotOutletIRNode extends BaseIRNode {
   props: IRProps[]
   fallback?: BlockIRNode
   noSlotted?: boolean
+  once?: boolean
   parent?: number
   anchor?: number
   append?: boolean
index 88edc0510e3bb93505cd76c01999fa99df0d2be5..72dc7875da880f7d48330f3a0fb076181c5d649b 100644 (file)
@@ -107,6 +107,7 @@ export const transformSlotOutlet: NodeTransform = (node, context) => {
       props: irProps,
       fallback,
       noSlotted: !!(context.options.scopeId && !context.options.slotted),
+      once: context.inVOnce,
     }
   }
 }
index 69756b02339ef5c8cb6fec6ed518bbec47f1adc7..74b6a9b3b9a33d382580dc8175dbda8e83bebd86 100644 (file)
@@ -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('<span>2</span><!--for--><!--slot-->')
     })
+
+    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('<div> </div>')() as any
+              const x3 = txt(n3) as any
+              renderEffect(() => setText(x3, toDisplayString(count.value)))
+              return n3
+            }),
+          })
+        },
+      }).render()
+
+      expect(html()).toBe('<div>0</div><!--slot-->')
+
+      // expect no changes due to v-once
+      count.value++
+      await nextTick()
+      expect(html()).toBe('<div>0</div><!--slot-->')
+    })
   })
 
   describe('forwarded slot', () => {
index 2f83b9f8647032bd65f88318a5b77a7b641f176c..b450e077a1409236fc43ae058d8c270c8dfda42d 100644 (file)
@@ -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()
index e36ac4ba4586606a6858b4c6d40f8bff1e3c30d0..5fc2d5414244f976bf56ead812cc168dc7b67ee5 100644 (file)
@@ -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