]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(runtime-vapor): add support for v-once (#13459)
authoredison <daiwei521@126.com>
Tue, 21 Oct 2025 03:03:29 +0000 (11:03 +0800)
committerGitHub <noreply@github.com>
Tue, 21 Oct 2025 03:03:29 +0000 (11:03 +0800)
packages/runtime-vapor/__tests__/apiCreateDynamicComponent.spec.ts
packages/runtime-vapor/__tests__/component.spec.ts
packages/runtime-vapor/src/apiCreateApp.ts
packages/runtime-vapor/src/apiCreateDynamicComponent.ts
packages/runtime-vapor/src/apiDefineAsyncComponent.ts
packages/runtime-vapor/src/component.ts
packages/runtime-vapor/src/componentProps.ts
packages/runtime-vapor/src/vdomInterop.ts

index e912af2851ab4719e0a198a51289a2f950f67626..89514e17701c7bf196b0f6957ceca59bbc3c3068 100644 (file)
@@ -63,6 +63,44 @@ describe('api: createDynamicComponent', () => {
     expect(html()).toBe('<baz></baz><!--dynamic-component-->')
   })
 
+  test('with v-once', async () => {
+    const val = shallowRef<any>(A)
+
+    const { html } = define({
+      setup() {
+        return createDynamicComponent(() => val.value, null, null, true, true)
+      },
+    }).render()
+
+    expect(html()).toBe('AAA<!--dynamic-component-->')
+
+    val.value = B
+    await nextTick()
+    expect(html()).toBe('AAA<!--dynamic-component-->') // still AAA
+  })
+
+  test('fallback with v-once', async () => {
+    const val = shallowRef<any>('button')
+    const id = ref(0)
+    const { html } = define({
+      setup() {
+        return createDynamicComponent(
+          () => val.value,
+          { id: () => id.value },
+          null,
+          true,
+          true,
+        )
+      },
+    }).render()
+
+    expect(html()).toBe('<button id="0"></button><!--dynamic-component-->')
+
+    id.value++
+    await nextTick()
+    expect(html()).toBe('<button id="0"></button><!--dynamic-component-->')
+  })
+
   test('render fallback with insertionState', async () => {
     const { html, mount } = define({
       setup() {
index b96a932a2f38913e1abef9b846de5a37dfe36565..ce901e19931b8ac7ebf81515dee81ca250f4ba74 100644 (file)
@@ -8,6 +8,7 @@ import {
   onUpdated,
   provide,
   ref,
+  useAttrs,
   watch,
   watchEffect,
 } from '@vue/runtime-dom'
@@ -15,6 +16,7 @@ import {
   createComponent,
   createIf,
   createTextNode,
+  defineVaporComponent,
   renderEffect,
   setInsertionState,
   template,
@@ -315,6 +317,66 @@ describe('component', () => {
     expect(getEffectsCount(i.scope)).toBe(0)
   })
 
+  it('work with v-once + props', () => {
+    const Child = defineVaporComponent({
+      props: {
+        count: Number,
+      },
+      setup(props) {
+        const n0 = template(' ')() as any
+        renderEffect(() => setText(n0, props.count))
+        return n0
+      },
+    })
+
+    const count = ref(0)
+    const { html } = define({
+      setup() {
+        return createComponent(
+          Child,
+          { count: () => count.value },
+          null,
+          true,
+          true, // v-once
+        )
+      },
+    }).render()
+
+    expect(html()).toBe('0')
+
+    count.value++
+    expect(html()).toBe('0')
+  })
+
+  it('work with v-once + attrs', () => {
+    const Child = defineVaporComponent({
+      setup() {
+        const attrs = useAttrs()
+        const n0 = template(' ')() as any
+        renderEffect(() => setText(n0, attrs.count as string))
+        return n0
+      },
+    })
+
+    const count = ref(0)
+    const { html } = define({
+      setup() {
+        return createComponent(
+          Child,
+          { count: () => count.value },
+          null,
+          true,
+          true, // v-once
+        )
+      },
+    }).render()
+
+    expect(html()).toBe('0')
+
+    count.value++
+    expect(html()).toBe('0')
+  })
+
   test('should mount component only with template in production mode', () => {
     __DEV__ = false
     const { component: Child } = define({
index bcc680eae42f5076974f6fea28f2b0205341c2a8..89fc6179ee04a96ce20d1f5dae172ea7b4d1a341 100644 (file)
@@ -41,6 +41,7 @@ const mountApp: AppMountFn<ParentNode> = (app, container) => {
     app._props as RawProps,
     null,
     false,
+    false,
     app._context,
   )
   mountComponent(instance, container)
@@ -61,6 +62,7 @@ const hydrateApp: AppMountFn<ParentNode> = (app, container) => {
       app._props as RawProps,
       null,
       false,
+      false,
       app._context,
     )
     mountComponent(instance, container)
index daef0479d814d42f8bb3a77a07e5df392380ef00..8f43e86529090826995c4512088231dee7f07a03 100644 (file)
@@ -18,6 +18,7 @@ export function createDynamicComponent(
   rawProps?: RawProps | null,
   rawSlots?: RawSlots | null,
   isSingleRoot?: boolean,
+  once?: boolean,
 ): VaporFragment {
   const _insertionParent = insertionParent
   const _insertionAnchor = insertionAnchor
@@ -29,7 +30,7 @@ export function createDynamicComponent(
       ? new DynamicFragment('dynamic-component')
       : new DynamicFragment()
 
-  renderEffect(() => {
+  const renderFn = () => {
     const value = getter()
     const appContext =
       (currentInstance && currentInstance.appContext) || emptyContext
@@ -40,11 +41,15 @@ export function createDynamicComponent(
           rawProps,
           rawSlots,
           isSingleRoot,
+          once,
           appContext,
         ),
       value,
     )
-  })
+  }
+
+  if (once) renderFn()
+  else renderEffect(renderFn)
 
   if (!isHydrating) {
     if (_insertionParent) insert(frag, _insertionParent, _insertionAnchor)
index 7fe1cfc2ac1747925b66002c8471542036a0e981..dd6143950e349daf74e3d91e86829752bf9c8083 100644 (file)
@@ -185,6 +185,7 @@ function createInnerComp(
     rawProps,
     rawSlots,
     isSingleRoot,
+    undefined,
     appContext,
   )
 
index 1a3acf5c4d18bebbeafecedcbf330c0de359c19b..1952b31048d729bd44822de37a89f394179095dc 100644 (file)
@@ -51,6 +51,7 @@ import {
   getPropsProxyHandlers,
   hasFallthroughAttrs,
   normalizePropsOptions,
+  resolveDynamicProps,
   setupPropsValidation,
 } from './componentProps'
 import { type RenderEffect, renderEffect } from './renderEffect'
@@ -163,6 +164,7 @@ export function createComponent(
   rawProps?: LooseRawProps | null,
   rawSlots?: LooseRawSlots | null,
   isSingleRoot?: boolean,
+  once?: boolean,
   appContext: GenericAppContext = (currentInstance &&
     currentInstance.appContext) ||
     emptyContext,
@@ -246,6 +248,7 @@ export function createComponent(
     rawProps as RawProps,
     rawSlots as RawSlots,
     appContext,
+    once,
   )
 
   // HMR
@@ -521,6 +524,7 @@ export class VaporComponentInstance implements GenericComponentInstance {
     rawProps?: RawProps | null,
     rawSlots?: RawSlots | null,
     appContext?: GenericAppContext,
+    once?: boolean,
   ) {
     this.vapor = true
     this.uid = nextUid()
@@ -561,7 +565,7 @@ export class VaporComponentInstance implements GenericComponentInstance {
     this.rawProps = rawProps || EMPTY_OBJ
     this.hasFallthrough = hasFallthroughAttrs(comp, rawProps)
     if (rawProps || comp.props) {
-      const [propsHandlers, attrsHandlers] = getPropsProxyHandlers(comp)
+      const [propsHandlers, attrsHandlers] = getPropsProxyHandlers(comp, once)
       this.attrs = new Proxy(this, attrsHandlers)
       this.props = comp.props
         ? new Proxy(this, propsHandlers!)
@@ -606,10 +610,18 @@ export function createComponentWithFallback(
   rawProps?: LooseRawProps | null,
   rawSlots?: LooseRawSlots | null,
   isSingleRoot?: boolean,
+  once?: boolean,
   appContext?: GenericAppContext,
 ): HTMLElement | VaporComponentInstance {
   if (!isString(comp)) {
-    return createComponent(comp, rawProps, rawSlots, isSingleRoot, appContext)
+    return createComponent(
+      comp,
+      rawProps,
+      rawSlots,
+      isSingleRoot,
+      once,
+      appContext,
+    )
   }
 
   const _insertionParent = insertionParent
@@ -628,6 +640,13 @@ export function createComponentWithFallback(
   // mark single root
   ;(el as any).$root = isSingleRoot
 
+  if (rawProps) {
+    const setFn = () =>
+      setDynamicProps(el, [resolveDynamicProps(rawProps as RawProps)])
+    if (once) setFn()
+    else renderEffect(setFn)
+  }
+
   if (rawSlots) {
     let nextNode: Node | null = null
     if (isHydrating) {
index 55eadb980468083789a5871efeee7e20220d2dc0..6832bd9103c6348021933c83ff9abe079d8f7bd8 100644 (file)
@@ -23,6 +23,7 @@ import {
 import { ReactiveFlags } from '@vue/reactivity'
 import { normalizeEmitsOptions } from './componentEmits'
 import { renderEffect } from './renderEffect'
+import { pauseTracking, resetTracking } from '@vue/reactivity'
 import type { interopKey } from './vdomInterop'
 
 export type RawProps = Record<string, () => unknown> & {
@@ -43,6 +44,7 @@ export function resolveSource(
 
 export function getPropsProxyHandlers(
   comp: VaporComponent,
+  once?: boolean,
 ): [
   ProxyHandler<VaporComponentInstance> | null,
   ProxyHandler<VaporComponentInstance>,
@@ -111,9 +113,18 @@ export function getPropsProxyHandlers(
     )
   }
 
+  const getPropValue = once
+    ? (...args: Parameters<typeof getProp>) => {
+        pauseTracking()
+        const value = getProp(...args)
+        resetTracking()
+        return value
+      }
+    : getProp
+
   const propsHandlers = propsOptions
     ? ({
-        get: (target, key) => getProp(target, key),
+        get: (target, key) => getPropValue(target, key),
         has: (_, key) => isProp(key),
         ownKeys: () => Object.keys(propsOptions),
         getOwnPropertyDescriptor(target, key) {
@@ -121,7 +132,7 @@ export function getPropsProxyHandlers(
             return {
               configurable: true,
               enumerable: true,
-              get: () => getProp(target, key),
+              get: () => getPropValue(target, key),
             }
           }
         },
@@ -149,8 +160,17 @@ export function getPropsProxyHandlers(
     }
   }
 
+  const getAttrValue = once
+    ? (...args: Parameters<typeof getAttr>) => {
+        pauseTracking()
+        const value = getAttr(...args)
+        resetTracking()
+        return value
+      }
+    : getAttr
+
   const attrsHandlers = {
-    get: (target, key: string) => getAttr(target.rawProps, key),
+    get: (target, key: string) => getAttrValue(target.rawProps, key),
     has: (target, key: string) => hasAttr(target.rawProps, key),
     ownKeys: target => getKeysFromRawProps(target.rawProps).filter(isAttr),
     getOwnPropertyDescriptor(target, key: string) {
@@ -158,7 +178,7 @@ export function getPropsProxyHandlers(
         return {
           configurable: true,
           enumerable: true,
-          get: () => getAttr(target.rawProps, key),
+          get: () => getAttrValue(target.rawProps, key),
         }
       }
     },
index 85a7bc6ebb97eaa4aff3d3d281bb4b048c78e26b..d3cb8d243a81105e80db9db73df39654e5dbe619 100644 (file)
@@ -124,6 +124,7 @@ const vaporInteropImpl: Omit<
         _: slotsRef, // pass the slots ref
       } as any as RawSlots,
       undefined,
+      undefined,
       (parentComponent ? parentComponent.appContext : vnode.appContext) as any,
     ))
     instance.rawPropsRef = propsRef