Просмотр исходного кода

fix(runtime-vapor): separate slot owner and consumer contexts

track slot owners and consumers to keep `instance.parent` accurate in slot context
Alex Snezhko 5 месяцев назад
Родитель
Сommit
457b41aeab

+ 6 - 11
packages/compiler-vapor/src/generators/component.ts

@@ -40,12 +40,7 @@ import { genEventHandler } from './event'
 import { genDirectiveModifiers, genDirectivesForElement } from './directive'
 import { genBlock } from './block'
 import { genModelHandler } from './vModel'
-import {
-  isBuiltInComponent,
-  isKeepAliveTag,
-  isTeleportTag,
-  isTransitionGroupTag,
-} from '../utils'
+import { isBuiltInComponent, isTransitionGroupTag } from '../utils'
 
 export function genCreateComponent(
   operation: CreateComponentIRNode,
@@ -467,11 +462,11 @@ function genSlotBlockWithProps(oper: SlotBlockIRNode, context: CodegenContext) {
 
   if (
     node.type === NodeTypes.ELEMENT &&
-    // Not a real component
-    !isTeleportTag(node.tag) &&
-    // Needs to determine whether to activate/deactivate based on instance.parent being KeepAlive
-    !isKeepAliveTag(node.tag) &&
-    // Slot updates need to trigger TransitionGroup's onBeforeUpdate/onUpdated hook
+    // // Not a real component
+    // !isTeleportTag(node.tag) &&
+    // // Needs to determine whether to activate/deactivate based on instance.parent being KeepAlive
+    // !isKeepAliveTag(node.tag) &&
+    // // Slot updates need to trigger TransitionGroup's onBeforeUpdate/onUpdated hook
     !isTransitionGroupTag(node.tag)
   ) {
     // wrap with withVaporCtx to ensure correct currentInstance inside slot

+ 1 - 1
packages/runtime-core/src/componentCurrentInstance.ts

@@ -92,7 +92,7 @@ export const setCurrentInstance = (
   }
 }
 
-const internalOptions = ['ce'] as const
+const internalOptions = ['ce', 'type'] as const
 
 /**
  * @internal

+ 4 - 4
packages/runtime-dom/src/helpers/useCssModule.ts

@@ -1,14 +1,14 @@
-import { getCurrentInstance, warn } from '@vue/runtime-core'
+import { useInstanceOption, warn } from '@vue/runtime-core'
 import { EMPTY_OBJ } from '@vue/shared'
 
 export function useCssModule(name = '$style'): Record<string, string> {
   if (!__GLOBAL__) {
-    const instance = getCurrentInstance()!
-    if (!instance) {
+    const { hasInstance, value: type } = useInstanceOption('type', true)
+    if (!hasInstance) {
       __DEV__ && warn(`useCssModule must be called inside setup()`)
       return EMPTY_OBJ
     }
-    const modules = instance.type.__cssModules
+    const modules = type!.__cssModules
     if (!modules) {
       __DEV__ && warn(`Current instance does not have CSS modules injected.`)
       return EMPTY_OBJ

+ 28 - 0
packages/runtime-vapor/__tests__/apiInject.spec.ts

@@ -12,8 +12,10 @@ import {
 } from '@vue/runtime-dom'
 import {
   createComponent,
+  createSlot,
   createTextNode,
   createVaporApp,
+  defineVaporComponent,
   renderEffect,
 } from '../src'
 import { makeRender } from './_utils'
@@ -368,6 +370,32 @@ describe('api: provide/inject', () => {
     expect(host.innerHTML).toBe('')
   })
 
+  it('should work with slots', () => {
+    const Parent = defineVaporComponent({
+      setup() {
+        provide('test', 'hello')
+        return createSlot('default', null)
+      },
+    })
+
+    const Child = defineVaporComponent({
+      setup() {
+        const test = inject('test')
+        return createTextNode(toDisplayString(test))
+      },
+    })
+
+    const { host } = define({
+      setup() {
+        return createComponent(Parent, null, {
+          default: () => createComponent(Child),
+        })
+      },
+    }).render()
+
+    expect(host.innerHTML).toBe('hello<!--slot-->')
+  })
+
   describe('hasInjectionContext', () => {
     it('should be false outside of setup', () => {
       expect(hasInjectionContext()).toBe(false)

+ 55 - 0
packages/runtime-vapor/__tests__/helpers/useCssModule.spec.ts

@@ -0,0 +1,55 @@
+import { useCssModule } from '@vue/runtime-dom'
+import { makeRender } from '../_utils'
+import { defineVaporComponent, template } from '@vue/runtime-vapor'
+
+const define = makeRender<any>()
+
+describe('useCssModule', () => {
+  function mountWithModule(modules: any, name?: string) {
+    let res
+    define(
+      defineVaporComponent({
+        __cssModules: modules,
+        setup() {
+          res = useCssModule(name)
+          const n0 = template('<div></div>')()
+          return n0
+        },
+      }),
+    ).render()
+    return res
+  }
+
+  test('basic usage', () => {
+    const modules = {
+      $style: {
+        red: 'red',
+      },
+    }
+    expect(mountWithModule(modules)).toMatchObject(modules.$style)
+  })
+
+  test('basic usage', () => {
+    const modules = {
+      foo: {
+        red: 'red',
+      },
+    }
+    expect(mountWithModule(modules, 'foo')).toMatchObject(modules.foo)
+  })
+
+  test('warn out of setup usage', () => {
+    useCssModule()
+    expect('must be called inside setup').toHaveBeenWarned()
+  })
+
+  test('warn missing injection', () => {
+    mountWithModule(undefined)
+    expect('instance does not have CSS modules').toHaveBeenWarned()
+  })
+
+  test('warn missing injection', () => {
+    mountWithModule({ $style: { red: 'red' } }, 'foo')
+    expect('instance does not have CSS module named "foo"').toHaveBeenWarned()
+  })
+})

+ 10 - 4
packages/runtime-vapor/src/block.ts

@@ -253,19 +253,25 @@ export function setScopeId(block: Block, scopeIds: string[]): void {
 
 export function setComponentScopeId(instance: VaporComponentInstance): void {
   const parent = instance.parent
-  if (!parent) return
+  const slotScopeOwner = instance.slotScopeOwner
+  if (!parent && !slotScopeOwner) return
   // prevent setting scopeId on multi-root fragments
   if (isArray(instance.block) && instance.block.length > 1) return
 
   const scopeIds: string[] = []
 
-  const scopeId = parent.type.__scopeId
-  if (scopeId) {
-    scopeIds.push(scopeId)
+  const slotScopeId = slotScopeOwner && slotScopeOwner.type.__scopeId
+  const parentScopeId = parent && parent.type.__scopeId
+
+  if (slotScopeId) {
+    scopeIds.push(slotScopeId)
+  } else if (parentScopeId) {
+    scopeIds.push(parentScopeId)
   }
 
   // inherit scopeId from vdom parent
   if (
+    parent &&
     parent.subTree &&
     (parent.subTree.component as any) === instance &&
     parent.vnode!.scopeId

+ 30 - 18
packages/runtime-vapor/src/component.ts

@@ -71,6 +71,8 @@ import {
   type VaporSlot,
   dynamicSlotsProxyHandlers,
   getSlot,
+  getSlotConsumer,
+  getSlotScopeOwner,
 } from './componentSlots'
 import { hmrReload, hmrRerender } from './hmr'
 import {
@@ -178,8 +180,10 @@ export function createComponent(
   rawSlots?: LooseRawSlots | null,
   isSingleRoot?: boolean,
   once?: boolean,
-  appContext: GenericAppContext = (currentInstance &&
-    currentInstance.appContext) ||
+  appContext: GenericAppContext = (((getSlotScopeOwner() as VaporComponentInstance | null) ||
+    (currentInstance as VaporComponentInstance | null)) &&
+    ((getSlotScopeOwner() as VaporComponentInstance | null) ||
+      (currentInstance as VaporComponentInstance | null))!.appContext) ||
     emptyContext,
 ): VaporComponentInstance {
   const _insertionParent = insertionParent
@@ -191,15 +195,19 @@ export function createComponent(
     resetInsertionState()
   }
 
+  const parentInstance =
+    (getSlotConsumer() as VaporComponentInstance | null) ||
+    (currentInstance as VaporComponentInstance | null)
+
   if (
     isSingleRoot &&
     component.inheritAttrs !== false &&
-    isVaporComponent(currentInstance) &&
-    currentInstance.hasFallthrough
+    isVaporComponent(parentInstance) &&
+    parentInstance.hasFallthrough
   ) {
     // check if we are the single root of the parent
     // if yes, inject parent attrs as dynamic props source
-    const attrs = currentInstance.attrs
+    const attrs = parentInstance.attrs
     if (rawProps) {
       ;((rawProps as RawProps).$ || ((rawProps as RawProps).$ = [])).push(
         () => attrs,
@@ -210,12 +218,8 @@ export function createComponent(
   }
 
   // keep-alive
-  if (
-    currentInstance &&
-    currentInstance.vapor &&
-    isKeepAlive(currentInstance)
-  ) {
-    const cached = (currentInstance as KeepAliveInstance).getCachedComponent(
+  if (parentInstance && parentInstance.vapor && isKeepAlive(parentInstance)) {
+    const cached = (parentInstance as KeepAliveInstance).getCachedComponent(
       component,
     )
     // @ts-expect-error
@@ -262,6 +266,7 @@ export function createComponent(
     rawSlots as RawSlots,
     appContext,
     once,
+    parentInstance,
   )
 
   // HMR
@@ -475,6 +480,9 @@ export class VaporComponentInstance implements GenericComponentInstance {
 
   slots: StaticSlots
 
+  // slot template owner for scope inheritance
+  slotScopeOwner: VaporComponentInstance | null
+
   // to hold vnode props / slots in vdom interop mode
   rawPropsRef?: ShallowRef<any>
   rawSlotsRef?: ShallowRef<any>
@@ -541,17 +549,18 @@ export class VaporComponentInstance implements GenericComponentInstance {
     rawSlots?: RawSlots | null,
     appContext?: GenericAppContext,
     once?: boolean,
+    parent: GenericComponentInstance | null = currentInstance,
   ) {
     this.vapor = true
     this.uid = nextUid()
     this.type = comp
-    this.parent = currentInstance
-    this.root = currentInstance ? currentInstance.root : this
+    this.parent = parent
+    this.root = parent ? parent.root : this
 
-    if (currentInstance) {
-      this.appContext = currentInstance.appContext
-      this.provides = currentInstance.provides
-      this.ids = currentInstance.ids
+    if (parent) {
+      this.appContext = parent.appContext
+      this.provides = parent.provides
+      this.ids = parent.ids
     } else {
       this.appContext = appContext || emptyContext
       this.provides = Object.create(this.appContext.provides)
@@ -600,6 +609,8 @@ export class VaporComponentInstance implements GenericComponentInstance {
         : rawSlots
       : EMPTY_OBJ
 
+    this.slotScopeOwner = getSlotScopeOwner() as VaporComponentInstance | null
+
     // apply custom element special handling
     if (comp.ce) {
       comp.ce(this)
@@ -672,7 +683,8 @@ export function createPlainElement(
   ;(el as any).$root = isSingleRoot
 
   if (!isHydrating) {
-    const scopeId = currentInstance!.type.__scopeId
+    const scopeOwner = getSlotScopeOwner() || currentInstance
+    const scopeId = scopeOwner && scopeOwner.type.__scopeId
     if (scopeId) setScopeId(el, [scopeId])
   }
 

+ 42 - 26
packages/runtime-vapor/src/componentSlots.ts

@@ -122,27 +122,38 @@ export function getSlot(
   }
 }
 
-/**
- * Wraps a slot function to execute in the parent component's context.
- *
- * This ensures that:
- * 1. Reactive effects created inside the slot (e.g., `renderEffect`) bind to the
- *    parent's instance, so the parent's lifecycle hooks fire when the slot's
- *    reactive dependencies change.
- * 2. Elements created in the slot inherit the parent's scopeId for proper style
- *    scoping in scoped CSS.
- *
- * **Rationale**: Slots are defined in the parent's template, so the parent should
- * own the rendering context and be aware of updates.
- *
- */
+// Tracks slot execution context: the owner that defined the slot, and the
+// consumer that is currently rendering it.
+const slotOwnerStack: (VaporComponentInstance | null)[] = []
+const slotConsumerStack: (GenericComponentInstance | null)[] = []
+
+export function getSlotScopeOwner(): GenericComponentInstance | null {
+  return slotOwnerStack.length > 0
+    ? slotOwnerStack[slotOwnerStack.length - 1]
+    : null
+}
+
+export function getSlotConsumer(): GenericComponentInstance | null {
+  return slotConsumerStack.length > 0
+    ? slotConsumerStack[slotConsumerStack.length - 1]
+    : null
+}
+
 export function withVaporCtx(fn: Function): BlockFn {
-  const instance = currentInstance as VaporComponentInstance
+  const owner =
+    (currentInstance as VaporComponentInstance | null) ||
+    (getSlotScopeOwner() as VaporComponentInstance | null)
+
+  const ownerInstance = owner!
   return (...args: any[]) => {
-    const prev = setCurrentInstance(instance)
+    const prev = setCurrentInstance(ownerInstance)
+    slotOwnerStack.push(ownerInstance)
+    slotConsumerStack.push(prev[0])
     try {
       return fn(...args)
     } finally {
+      slotConsumerStack.pop()
+      slotOwnerStack.pop()
       setCurrentInstance(...prev)
     }
   }
@@ -159,20 +170,25 @@ export function createSlot(
   const _isLastInsertion = isLastInsertion
   if (!isHydrating) resetInsertionState()
 
-  const instance = currentInstance as VaporComponentInstance
-  const rawSlots = instance.rawSlots
+  const slotContext =
+    (currentInstance as VaporComponentInstance | null) ||
+    (getSlotScopeOwner() as VaporComponentInstance | null)
+  const owner =
+    (getSlotScopeOwner() as VaporComponentInstance | null) || slotContext!
+  const rawSlots = slotContext!.rawSlots
   const slotProps = rawProps
     ? new Proxy(rawProps, rawPropsProxyHandlers)
     : EMPTY_OBJ
 
   let fragment: DynamicFragment
+  const contextInstance = owner
   if (isRef(rawSlots._)) {
     if (isHydrating) locateHydrationNode()
-    fragment = instance.appContext.vapor!.vdomSlot(
+    fragment = contextInstance.appContext.vapor!.vdomSlot(
       rawSlots._,
       name,
       slotProps,
-      instance,
+      contextInstance,
       fallback,
     )
   } else {
@@ -185,7 +201,7 @@ export function createSlot(
     // Calculate slotScopeIds once (for vdom interop)
     const slotScopeIds: string[] = []
     if (!noSlotted) {
-      const scopeId = instance!.type.__scopeId
+      const scopeId = contextInstance.type.__scopeId
       if (scopeId) {
         slotScopeIds.push(`${scopeId}-s`)
       }
@@ -198,10 +214,10 @@ export function createSlot(
       // because in shadowRoot: false mode the slot element gets
       // replaced by injected content
       if (
-        (instance as GenericComponentInstance).ce ||
-        (instance.parent &&
-          isAsyncWrapper(instance.parent) &&
-          instance.parent.ce)
+        (slotContext as GenericComponentInstance).ce ||
+        (slotContext!.parent &&
+          isAsyncWrapper(slotContext!.parent) &&
+          slotContext!.parent.ce)
       ) {
         const el = createElement('slot')
         renderEffect(() => {
@@ -249,7 +265,7 @@ export function createSlot(
 
   if (!isHydrating) {
     if (!noSlotted) {
-      const scopeId = instance.type.__scopeId
+      const scopeId = owner.type.__scopeId
       if (scopeId) {
         setScopeId(fragment, [`${scopeId}-s`])
       }

+ 10 - 2
packages/runtime-vapor/src/vdomInterop.ts

@@ -59,7 +59,7 @@ import {
 } from '@vue/shared'
 import { type RawProps, rawPropsProxyHandlers } from './componentProps'
 import type { RawSlots, VaporSlot } from './componentSlots'
-import { currentSlotScopeIds } from './componentSlots'
+import { currentSlotScopeIds, getSlotScopeOwner } from './componentSlots'
 import { renderEffect } from './renderEffect'
 import { _next, createTextNode } from './dom/node'
 import { optimizePropertyLookup } from './dom/prop'
@@ -286,6 +286,9 @@ function createVDOMComponent(
     { props: component.props },
     rawProps as RawProps,
     rawSlots as RawSlots,
+    parentInstance ? parentInstance.appContext : undefined,
+    undefined,
+    parentInstance,
   )
 
   // overwrite how the vdom instance handles props
@@ -332,7 +335,12 @@ function createVDOMComponent(
     frag.nodes = vnode.el as any
   }
 
-  vnode.scopeId = parentInstance && parentInstance.type.__scopeId!
+  const slotScopeOwner = getSlotScopeOwner()
+  const scopeOwner =
+    (isVaporComponent(slotScopeOwner) ? slotScopeOwner : null) ||
+    (parentInstance && parentInstance.slotScopeOwner) ||
+    parentInstance
+  vnode.scopeId = scopeOwner && scopeOwner.type.__scopeId!
   vnode.slotScopeIds = currentSlotScopeIds
 
   frag.insert = (parentNode, anchor, transition) => {