]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(vapor): set scopeId (#14004)
authoredison <daiwei521@126.com>
Wed, 22 Oct 2025 02:35:04 +0000 (10:35 +0800)
committerGitHub <noreply@github.com>
Wed, 22 Oct 2025 02:35:04 +0000 (10:35 +0800)
12 files changed:
packages/compiler-vapor/__tests__/transforms/__snapshots__/transformSlotOutlet.spec.ts.snap
packages/compiler-vapor/__tests__/transforms/transformSlotOutlet.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-core/src/index.ts
packages/runtime-core/src/renderer.ts
packages/runtime-vapor/__tests__/scopeId.spec.ts [new file with mode: 0644]
packages/runtime-vapor/src/block.ts
packages/runtime-vapor/src/component.ts
packages/runtime-vapor/src/componentSlots.ts
packages/runtime-vapor/src/vdomInterop.ts

index caac138dcefaecdfb072b110d9d0fbc18859d7a0..f53323247dca1a81de1fbf772bc4c01def9d51d6 100644 (file)
@@ -111,6 +111,33 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler: transform <slot> outlets > slot outlet with scopeId and slotted=false should generate noSlotted 1`] = `
+"import { createSlot as _createSlot } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createSlot("default", null, null, undefined, true)
+  return n0
+}"
+`;
+
+exports[`compiler: transform <slot> outlets > slot outlet with scopeId and slotted=true should not generate noSlotted 1`] = `
+"import { createSlot as _createSlot } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createSlot("default", null)
+  return n0
+}"
+`;
+
+exports[`compiler: transform <slot> outlets > slot outlet without scopeId should not generate noSlotted 1`] = `
+"import { createSlot as _createSlot } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createSlot("default", null)
+  return n0
+}"
+`;
+
 exports[`compiler: transform <slot> outlets > statically named slot outlet 1`] = `
 "import { createSlot as _createSlot } from 'vue';
 
index 389c665a12fc4d464f50ea1ef8209eb7ba3d248d..39b2bdcc8c336fdbe2042c7f4a60a36c1dc5b4e1 100644 (file)
@@ -277,4 +277,64 @@ describe('compiler: transform <slot> outlets', () => {
       },
     })
   })
+
+  test('slot outlet with scopeId and slotted=false should generate noSlotted', () => {
+    const { ir, code } = compileWithSlotsOutlet(`<slot />`, {
+      scopeId: 'test-scope',
+      slotted: false,
+    })
+    expect(code).toMatchSnapshot()
+    expect(code).toContain('true')
+    expect(ir.block.dynamic.children[0].operation).toMatchObject({
+      type: IRNodeTypes.SLOT_OUTLET_NODE,
+      id: 0,
+      name: {
+        type: NodeTypes.SIMPLE_EXPRESSION,
+        content: 'default',
+        isStatic: true,
+      },
+      props: [],
+      fallback: undefined,
+      noSlotted: true,
+    })
+  })
+
+  test('slot outlet with scopeId and slotted=true should not generate noSlotted', () => {
+    const { ir, code } = compileWithSlotsOutlet(`<slot />`, {
+      scopeId: 'test-scope',
+      slotted: true,
+    })
+    expect(code).toMatchSnapshot()
+    expect(ir.block.dynamic.children[0].operation).toMatchObject({
+      type: IRNodeTypes.SLOT_OUTLET_NODE,
+      id: 0,
+      name: {
+        type: NodeTypes.SIMPLE_EXPRESSION,
+        content: 'default',
+        isStatic: true,
+      },
+      props: [],
+      fallback: undefined,
+      noSlotted: false,
+    })
+  })
+
+  test('slot outlet without scopeId should not generate noSlotted', () => {
+    const { ir, code } = compileWithSlotsOutlet(`<slot />`, {
+      slotted: false,
+    })
+    expect(code).toMatchSnapshot()
+    expect(ir.block.dynamic.children[0].operation).toMatchObject({
+      type: IRNodeTypes.SLOT_OUTLET_NODE,
+      id: 0,
+      name: {
+        type: NodeTypes.SIMPLE_EXPRESSION,
+        content: 'default',
+        isStatic: true,
+      },
+      props: [],
+      fallback: undefined,
+      noSlotted: false,
+    })
+  })
 })
index dc992ae23347699bb65869bdc5f57dbec9c7e50e..17ed92d946aa2d801d0ba4b6d13f4e63750b6370 100644 (file)
@@ -12,7 +12,7 @@ export function genSlotOutlet(
   context: CodegenContext,
 ): CodeFragment[] {
   const { helper } = context
-  const { id, name, fallback, forwarded } = oper
+  const { id, name, fallback, forwarded, noSlotted } = oper
   const [frag, push] = buildCodeFragment()
 
   const nameExpr = name.isStatic
@@ -32,6 +32,8 @@ export function genSlotOutlet(
       nameExpr,
       genRawProps(oper.props, context) || 'null',
       fallbackArg,
+      noSlotted && 'undefined', // instance
+      noSlotted && 'true', // noSlotted
     ),
   )
 
index 76ef7c53c49786ea1872aea731d6f5f36210a79d..6e6b11aa35f4ac6a2d49a89733bf9dedac62eb3a 100644 (file)
@@ -221,6 +221,7 @@ export interface SlotOutletIRNode extends BaseIRNode {
   props: IRProps[]
   fallback?: BlockIRNode
   forwarded?: boolean
+  noSlotted?: boolean
   parent?: number
   anchor?: number
   append?: boolean
index 75d0c26f4afb2af46c36c3a9b40c4beeabea56bb..2b1b27c631351664b31f1164edf8cafa3b921609 100644 (file)
@@ -108,6 +108,7 @@ export const transformSlotOutlet: NodeTransform = (node, context) => {
       props: irProps,
       fallback,
       forwarded: context.inSlot,
+      noSlotted: !!(context.options.scopeId && !context.options.slotted),
     }
   }
 }
index b15fe1e6960bf0f9c294337a85d5f602604bd0eb..9444c2efddfdedb82c697278a0431ea35407d905 100644 (file)
@@ -516,7 +516,11 @@ export { type VaporInteropInterface } from './apiCreateApp'
 /**
  * @internal
  */
-export { type RendererInternals, MoveType, invalidateMount } from './renderer'
+export {
+  type RendererInternals,
+  MoveType,
+  getInheritedScopeIds,
+} from './renderer'
 /**
  * @internal
  */
index 0cf7c351d0eb106deedcdfa45964f2ce68543527..9cdc571921c2dfffc209615440c23b14b13b8e70 100644 (file)
@@ -777,30 +777,9 @@ function baseCreateRenderer(
         hostSetScopeId(el, slotScopeIds[i])
       }
     }
-    let subTree = parentComponent && parentComponent.subTree
-    if (subTree) {
-      if (
-        __DEV__ &&
-        subTree.patchFlag > 0 &&
-        subTree.patchFlag & PatchFlags.DEV_ROOT_FRAGMENT
-      ) {
-        subTree =
-          filterSingleRoot(subTree.children as VNodeArrayChildren) || subTree
-      }
-      if (
-        vnode === subTree ||
-        (isSuspense(subTree.type) &&
-          (subTree.ssContent === vnode || subTree.ssFallback === vnode))
-      ) {
-        const parentVNode = parentComponent!.vnode!
-        setScopeId(
-          el,
-          parentVNode,
-          parentVNode.scopeId,
-          parentVNode.slotScopeIds,
-          parentComponent!.parent,
-        )
-      }
+    const inheritedScopeIds = getInheritedScopeIds(vnode, parentComponent)
+    for (let i = 0; i < inheritedScopeIds.length; i++) {
+      hostSetScopeId(el, inheritedScopeIds[i])
     }
   }
 
@@ -2792,3 +2771,54 @@ export function getVaporInterface(
   }
   return res!
 }
+
+/**
+ * shared between vdom and vapor
+ */
+export function getInheritedScopeIds(
+  vnode: VNode,
+  parentComponent: GenericComponentInstance | null,
+): string[] {
+  const inheritedScopeIds: string[] = []
+
+  let currentParent = parentComponent
+  let currentVNode = vnode
+
+  while (currentParent) {
+    let subTree = currentParent.subTree
+    if (!subTree) break
+
+    if (
+      __DEV__ &&
+      subTree.patchFlag > 0 &&
+      subTree.patchFlag & PatchFlags.DEV_ROOT_FRAGMENT
+    ) {
+      subTree =
+        filterSingleRoot(subTree.children as VNodeArrayChildren) || subTree
+    }
+
+    if (
+      currentVNode === subTree ||
+      (isSuspense(subTree.type) &&
+        (subTree.ssContent === currentVNode ||
+          subTree.ssFallback === currentVNode))
+    ) {
+      const parentVNode = currentParent.vnode!
+
+      if (parentVNode.scopeId) {
+        inheritedScopeIds.push(parentVNode.scopeId)
+      }
+
+      if (parentVNode.slotScopeIds) {
+        inheritedScopeIds.push(...parentVNode.slotScopeIds)
+      }
+
+      currentVNode = parentVNode
+      currentParent = currentParent.parent
+    } else {
+      break
+    }
+  }
+
+  return inheritedScopeIds
+}
diff --git a/packages/runtime-vapor/__tests__/scopeId.spec.ts b/packages/runtime-vapor/__tests__/scopeId.spec.ts
new file mode 100644 (file)
index 0000000..10bad40
--- /dev/null
@@ -0,0 +1,617 @@
+import { createApp, h } from '@vue/runtime-dom'
+import {
+  createComponent,
+  createDynamicComponent,
+  createSlot,
+  defineVaporComponent,
+  forwardedSlotCreator,
+  setInsertionState,
+  template,
+  vaporInteropPlugin,
+  withVaporCtx,
+} from '../src'
+import { makeRender } from './_utils'
+
+const define = makeRender()
+
+describe('scopeId', () => {
+  test('should attach scopeId to child component', () => {
+    const Child = defineVaporComponent({
+      __scopeId: 'child',
+      setup() {
+        return template('<div child></div>', true)()
+      },
+    })
+
+    const { html } = define({
+      __scopeId: 'parent',
+      setup() {
+        return createComponent(Child)
+      },
+    }).render()
+    expect(html()).toBe(`<div child="" parent=""></div>`)
+  })
+
+  test('should attach scopeId to child component with insertion state', () => {
+    const Child = defineVaporComponent({
+      __scopeId: 'child',
+      setup() {
+        return template('<div child></div>', true)()
+      },
+    })
+
+    const { html } = define({
+      __scopeId: 'parent',
+      setup() {
+        const t0 = template('<div parent></div>', true)
+        const n1 = t0() as any
+        setInsertionState(n1)
+        createComponent(Child)
+        return n1
+      },
+    }).render()
+    expect(html()).toBe(`<div parent=""><div child="" parent=""></div></div>`)
+  })
+
+  test('should attach scopeId to nested child component', () => {
+    const Child = defineVaporComponent({
+      __scopeId: 'child',
+      setup() {
+        return template('<div child></div>', true)()
+      },
+    })
+
+    const Parent = defineVaporComponent({
+      __scopeId: 'parent',
+      setup() {
+        return createComponent(Child)
+      },
+    })
+
+    const { html } = define({
+      __scopeId: 'app',
+      setup() {
+        return createComponent(Parent)
+      },
+    }).render()
+    expect(html()).toBe(`<div child="" parent="" app=""></div>`)
+  })
+
+  test('should not attach scopeId to nested multiple root components', () => {
+    const Child = defineVaporComponent({
+      __scopeId: 'child',
+      setup() {
+        return template('<div child></div>', true)()
+      },
+    })
+
+    const Parent = defineVaporComponent({
+      __scopeId: 'parent',
+      setup() {
+        const n0 = template('<div parent></div>')()
+        const n1 = createComponent(Child)
+        return [n0, n1]
+      },
+    })
+
+    const { html } = define({
+      __scopeId: 'app',
+      setup() {
+        return createComponent(Parent)
+      },
+    }).render()
+    expect(html()).toBe(`<div parent=""></div><div child="" parent=""></div>`)
+  })
+
+  test('should attach scopeId to nested child component with insertion state', () => {
+    const Child = defineVaporComponent({
+      __scopeId: 'child',
+      setup() {
+        return template('<div child></div>', true)()
+      },
+    })
+
+    const Parent = defineVaporComponent({
+      __scopeId: 'parent',
+      setup() {
+        return createComponent(Child)
+      },
+    })
+
+    const { html } = define({
+      __scopeId: 'app',
+      setup() {
+        const t0 = template('<div app></div>', true)
+        const n1 = t0() as any
+        setInsertionState(n1)
+        createComponent(Parent)
+        return n1
+      },
+    }).render()
+    expect(html()).toBe(
+      `<div app=""><div child="" parent="" app=""></div></div>`,
+    )
+  })
+
+  test('should attach scopeId to dynamic component', () => {
+    const { html } = define({
+      __scopeId: 'parent',
+      setup() {
+        return createDynamicComponent(() => 'button')
+      },
+    }).render()
+    expect(html()).toBe(`<button parent=""></button><!--dynamic-component-->`)
+  })
+
+  test('should attach scopeId to dynamic component with insertion state', () => {
+    const { html } = define({
+      __scopeId: 'parent',
+      setup() {
+        const t0 = template('<div parent></div>', true)
+        const n1 = t0() as any
+        setInsertionState(n1)
+        createDynamicComponent(() => 'button')
+        return n1
+      },
+    }).render()
+    expect(html()).toBe(
+      `<div parent=""><button parent=""></button><!--dynamic-component--></div>`,
+    )
+  })
+
+  test('should attach scopeId to nested dynamic component', () => {
+    const Comp = defineVaporComponent({
+      __scopeId: 'child',
+      setup() {
+        return createDynamicComponent(() => 'button', null, null, true)
+      },
+    })
+    const { html } = define({
+      __scopeId: 'parent',
+      setup() {
+        return createComponent(Comp, null, null, true)
+      },
+    }).render()
+    expect(html()).toBe(
+      `<button child="" parent=""></button><!--dynamic-component-->`,
+    )
+  })
+
+  test('should attach scopeId to nested dynamic component with insertion state', () => {
+    const Comp = defineVaporComponent({
+      __scopeId: 'child',
+      setup() {
+        return createDynamicComponent(() => 'button', null, null, true)
+      },
+    })
+    const { html } = define({
+      __scopeId: 'parent',
+      setup() {
+        const t0 = template('<div parent></div>', true)
+        const n1 = t0() as any
+        setInsertionState(n1)
+        createComponent(Comp, null, null, true)
+        return n1
+      },
+    }).render()
+    expect(html()).toBe(
+      `<div parent=""><button child="" parent=""></button><!--dynamic-component--></div>`,
+    )
+  })
+
+  test.todo('should attach scopeId to suspense content', async () => {})
+
+  // :slotted basic
+  test('should work on slots', () => {
+    const Child = defineVaporComponent({
+      __scopeId: 'child',
+      setup() {
+        const n1 = template('<div child></div>', true)() as any
+        setInsertionState(n1)
+        createSlot('default', null)
+        return n1
+      },
+    })
+
+    const Child2 = defineVaporComponent({
+      __scopeId: 'child2',
+      setup() {
+        return template('<span child2></span>', true)()
+      },
+    })
+
+    const { html } = define({
+      __scopeId: 'parent',
+      setup() {
+        const n2 = createComponent(
+          Child,
+          null,
+          {
+            default: withVaporCtx(() => {
+              const n0 = template('<div parent></div>')()
+              const n1 = createComponent(Child2)
+              return [n0, n1]
+            }) as any,
+          },
+          true,
+        )
+        return n2
+      },
+    }).render()
+
+    // slot content should have:
+    // - scopeId from parent
+    // - slotted scopeId (with `-s` postfix) from child (the tree owner)
+    expect(html()).toBe(
+      `<div child="" parent="">` +
+        `<div parent="" child-s=""></div>` +
+        // component inside slot should have:
+        // - scopeId from template context
+        // - slotted scopeId from slot owner
+        // - its own scopeId
+        `<span child2="" child-s="" parent=""></span>` +
+        `<!--slot-->` +
+        `</div>`,
+    )
+  })
+
+  test(':slotted on forwarded slots', async () => {
+    const Wrapper = defineVaporComponent({
+      __scopeId: 'wrapper',
+      setup() {
+        // <div><slot/></div>
+        const n1 = template('<div wrapper></div>', true)() as any
+        setInsertionState(n1)
+        createSlot('default', null, undefined, undefined, true /* noSlotted */)
+        return n1
+      },
+    })
+
+    const Slotted = defineVaporComponent({
+      __scopeId: 'slotted',
+      setup() {
+        // <Wrapper><slot/></Wrapper>
+        const _createForwardedSlot = forwardedSlotCreator()
+        const n1 = createComponent(
+          Wrapper,
+          null,
+          {
+            default: withVaporCtx(() => {
+              const n0 = _createForwardedSlot('default', null)
+              return n0
+            }) as any,
+          },
+          true,
+        )
+        return n1
+      },
+    })
+
+    const { html } = define({
+      __scopeId: 'root',
+      setup() {
+        // <Slotted><div></div></Slotted>
+        const n2 = createComponent(
+          Slotted,
+          null,
+          {
+            default: withVaporCtx(() => {
+              return template('<div root></div>')()
+            }) as any,
+          },
+          true,
+        )
+        return n2
+      },
+    }).render()
+
+    expect(html()).toBe(
+      `<div wrapper="" slotted="" root="">` +
+        `<div root="" slotted-s=""></div>` +
+        `<!--slot--><!--slot-->` +
+        `</div>`,
+    )
+  })
+})
+
+describe('vdom interop', () => {
+  test('vdom parent > vapor child', () => {
+    const VaporChild = defineVaporComponent({
+      __scopeId: 'vapor-child',
+      setup() {
+        return template('<button vapor-child></button>', true)()
+      },
+    })
+
+    const VdomParent = {
+      __scopeId: 'vdom-parent',
+      setup() {
+        return () => h(VaporChild as any)
+      },
+    }
+
+    const App = {
+      setup() {
+        return () => h(VdomParent)
+      },
+    }
+
+    const root = document.createElement('div')
+    createApp(App).use(vaporInteropPlugin).mount(root)
+
+    expect(root.innerHTML).toBe(
+      `<button vapor-child="" vdom-parent=""></button>`,
+    )
+  })
+
+  test('vdom parent > vapor child > vdom child', () => {
+    const VdomChild = {
+      __scopeId: 'vdom-child',
+      setup() {
+        return () => h('button')
+      },
+    }
+
+    const VaporChild = defineVaporComponent({
+      __scopeId: 'vapor-child',
+      setup() {
+        return createComponent(VdomChild as any, null, null, true)
+      },
+    })
+
+    const VdomParent = {
+      __scopeId: 'vdom-parent',
+      setup() {
+        return () => h(VaporChild as any)
+      },
+    }
+
+    const App = {
+      setup() {
+        return () => h(VdomParent)
+      },
+    }
+
+    const root = document.createElement('div')
+    createApp(App).use(vaporInteropPlugin).mount(root)
+
+    expect(root.innerHTML).toBe(
+      `<button vdom-child="" vapor-child="" vdom-parent=""></button>`,
+    )
+  })
+
+  test('vdom parent > vapor child > vapor child > vdom child', () => {
+    const VdomChild = {
+      __scopeId: 'vdom-child',
+      setup() {
+        return () => h('button')
+      },
+    }
+
+    const NestedVaporChild = defineVaporComponent({
+      __scopeId: 'nested-vapor-child',
+      setup() {
+        return createComponent(VdomChild as any, null, null, true)
+      },
+    })
+
+    const VaporChild = defineVaporComponent({
+      __scopeId: 'vapor-child',
+      setup() {
+        return createComponent(NestedVaporChild as any, null, null, true)
+      },
+    })
+
+    const VdomParent = {
+      __scopeId: 'vdom-parent',
+      setup() {
+        return () => h(VaporChild as any)
+      },
+    }
+
+    const App = {
+      setup() {
+        return () => h(VdomParent)
+      },
+    }
+
+    const root = document.createElement('div')
+    createApp(App).use(vaporInteropPlugin).mount(root)
+
+    expect(root.innerHTML).toBe(
+      `<button vdom-child="" nested-vapor-child="" vapor-child="" vdom-parent=""></button>`,
+    )
+  })
+
+  test('vdom parent > vapor dynamic child', () => {
+    const VaporChild = defineVaporComponent({
+      __scopeId: 'vapor-child',
+      setup() {
+        return createDynamicComponent(() => 'button', null, null, true)
+      },
+    })
+
+    const VdomParent = {
+      __scopeId: 'vdom-parent',
+      setup() {
+        return () => h(VaporChild as any)
+      },
+    }
+
+    const App = {
+      setup() {
+        return () => h(VdomParent)
+      },
+    }
+
+    const root = document.createElement('div')
+    createApp(App).use(vaporInteropPlugin).mount(root)
+
+    expect(root.innerHTML).toBe(
+      `<button vapor-child="" vdom-parent=""></button><!--dynamic-component-->`,
+    )
+  })
+
+  test('vapor parent > vdom child', () => {
+    const VdomChild = {
+      __scopeId: 'vdom-child',
+      setup() {
+        return () => h('button')
+      },
+    }
+
+    const VaporParent = defineVaporComponent({
+      __scopeId: 'vapor-parent',
+      setup() {
+        return createComponent(VdomChild as any, null, null, true)
+      },
+    })
+
+    const App = {
+      setup() {
+        return () => h(VaporParent as any)
+      },
+    }
+
+    const root = document.createElement('div')
+    createApp(App).use(vaporInteropPlugin).mount(root)
+
+    expect(root.innerHTML).toBe(
+      `<button vdom-child="" vapor-parent=""></button>`,
+    )
+  })
+
+  test('vapor parent > vdom child > vapor child', () => {
+    const VaporChild = defineVaporComponent({
+      __scopeId: 'vapor-child',
+      setup() {
+        return template('<button vapor-child></button>', true)()
+      },
+    })
+
+    const VdomChild = {
+      __scopeId: 'vdom-child',
+      setup() {
+        return () => h(VaporChild as any)
+      },
+    }
+
+    const VaporParent = defineVaporComponent({
+      __scopeId: 'vapor-parent',
+      setup() {
+        return createComponent(VdomChild as any, null, null, true)
+      },
+    })
+
+    const App = {
+      setup() {
+        return () => h(VaporParent as any)
+      },
+    }
+
+    const root = document.createElement('div')
+    createApp(App).use(vaporInteropPlugin).mount(root)
+
+    expect(root.innerHTML).toBe(
+      `<button vapor-child="" vdom-child="" vapor-parent=""></button>`,
+    )
+  })
+
+  test('vapor parent > vdom child > vdom child > vapor child', () => {
+    const VaporChild = defineVaporComponent({
+      __scopeId: 'vapor-child',
+      setup() {
+        return template('<button vapor-child></button>', true)()
+      },
+    })
+
+    const VdomChild = {
+      __scopeId: 'vdom-child',
+      setup() {
+        return () => h(VaporChild as any)
+      },
+    }
+
+    const VdomParent = {
+      __scopeId: 'vdom-parent',
+      setup() {
+        return () => h(VdomChild as any)
+      },
+    }
+
+    const VaporParent = defineVaporComponent({
+      __scopeId: 'vapor-parent',
+      setup() {
+        return createComponent(VdomParent as any, null, null, true)
+      },
+    })
+
+    const App = {
+      setup() {
+        return () => h(VaporParent as any)
+      },
+    }
+
+    const root = document.createElement('div')
+    createApp(App).use(vaporInteropPlugin).mount(root)
+
+    expect(root.innerHTML).toBe(
+      `<button vapor-child="" vdom-child="" vdom-parent="" vapor-parent=""></button>`,
+    )
+  })
+
+  test('vapor parent > vapor slot > vdom child', () => {
+    const VaporSlot = defineVaporComponent({
+      __scopeId: 'vapor-slot',
+      setup() {
+        const n1 = template('<div vapor-slot></div>', true)() as any
+        setInsertionState(n1)
+        createSlot('default', null)
+        return n1
+      },
+    })
+
+    const VdomChild = {
+      __scopeId: 'vdom-child',
+      setup() {
+        return () => h('span')
+      },
+    }
+
+    const VaporParent = defineVaporComponent({
+      __scopeId: 'vapor-parent',
+      setup() {
+        const n2 = createComponent(
+          VaporSlot,
+          null,
+          {
+            default: withVaporCtx(() => {
+              const n0 = template('<div vapor-parent></div>')()
+              const n1 = createComponent(VdomChild)
+              return [n0, n1]
+            }) as any,
+          },
+          true,
+        )
+        return n2
+      },
+    })
+
+    const App = {
+      setup() {
+        return () => h(VaporParent as any)
+      },
+    }
+
+    const root = document.createElement('div')
+    createApp(App).use(vaporInteropPlugin).mount(root)
+
+    expect(root.innerHTML).toBe(
+      `<div vapor-slot="" vapor-parent="">` +
+        `<div vapor-parent="" vapor-slot-s=""></div>` +
+        `<span vdom-child="" vapor-parent="" vapor-slot-s=""></span>` +
+        `<!--slot-->` +
+        `</div>`,
+    )
+  })
+})
index c4c2f0e188a2f6693cedf78a48523f290aa138c3..729ebee16fcecf54cf83ac289b531356de3b30a4 100644 (file)
@@ -11,6 +11,7 @@ import {
   type TransitionHooks,
   type TransitionProps,
   type TransitionState,
+  getInheritedScopeIds,
   performTransitionEnter,
   performTransitionLeave,
 } from '@vue/runtime-dom'
@@ -220,3 +221,48 @@ export function isFragmentBlock(block: Block): boolean {
   }
   return false
 }
+
+export function setScopeId(block: Block, scopeIds: string[]): void {
+  if (block instanceof Element) {
+    for (const id of scopeIds) {
+      block.setAttribute(id, '')
+    }
+  } else if (isVaporComponent(block)) {
+    setScopeId(block.block, scopeIds)
+  } else if (isArray(block)) {
+    for (const b of block) {
+      setScopeId(b, scopeIds)
+    }
+  } else if (isFragment(block)) {
+    setScopeId(block.nodes, scopeIds)
+  }
+}
+
+export function setComponentScopeId(instance: VaporComponentInstance): void {
+  const parent = instance.parent
+  if (!parent) 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)
+  }
+
+  // inherit scopeId from vdom parent
+  if (
+    parent.subTree &&
+    (parent.subTree.component as any) === instance &&
+    parent.vnode!.scopeId
+  ) {
+    scopeIds.push(parent.vnode!.scopeId)
+    const inheritedScopeIds = getInheritedScopeIds(parent.vnode!, parent.parent)
+    scopeIds.push(...inheritedScopeIds)
+  }
+
+  if (scopeIds.length > 0) {
+    setScopeId(instance.block, scopeIds)
+  }
+}
index 1952b31048d729bd44822de37a89f394179095dc..e9e288031f4e7217ad495977255684b7576a1c48 100644 (file)
@@ -28,7 +28,14 @@ import {
   unregisterHMR,
   warn,
 } from '@vue/runtime-dom'
-import { type Block, insert, isBlock, remove } from './block'
+import {
+  type Block,
+  insert,
+  isBlock,
+  remove,
+  setComponentScopeId,
+  setScopeId,
+} from './block'
 import {
   type ShallowRef,
   markRaw,
@@ -382,8 +389,6 @@ export function setupComponent(
     }
   }
 
-  // TODO: scopeid
-
   setActiveSub(prevSub)
   setCurrentInstance(...prevInstance)
 
@@ -640,6 +645,11 @@ export function createComponentWithFallback(
   // mark single root
   ;(el as any).$root = isSingleRoot
 
+  if (!isHydrating) {
+    const scopeId = currentInstance!.type.__scopeId
+    if (scopeId) setScopeId(el, [scopeId])
+  }
+
   if (rawProps) {
     const setFn = () =>
       setDynamicProps(el, [resolveDynamicProps(rawProps as RawProps)])
@@ -690,6 +700,7 @@ export function mountComponent(
   if (instance.bm) invokeArrayFns(instance.bm)
   if (!isHydrating) {
     insert(instance.block, parent, anchor)
+    setComponentScopeId(instance)
   }
   if (instance.m) queuePostFlushCb(instance.m!)
   if (
index 346c272b6d59c080a6faf702ba2f725270264e2f..6b29ad1ae0df355fe4c5497f9f21f099e3fbfae0 100644 (file)
@@ -1,5 +1,5 @@
 import { EMPTY_OBJ, NO, hasOwn, isArray, isFunction } from '@vue/shared'
-import { type Block, type BlockFn, insert } from './block'
+import { type Block, type BlockFn, insert, setScopeId } from './block'
 import { rawPropsProxyHandlers } from './componentProps'
 import { currentInstance, isRef, setCurrentInstance } from '@vue/runtime-dom'
 import type { LooseRawProps, VaporComponentInstance } from './component'
@@ -17,6 +17,23 @@ import {
 } from './dom/hydration'
 import { DynamicFragment, type VaporFragment } from './fragment'
 
+/**
+ * Current slot scopeIds for vdom interop
+ * @internal
+ */
+export let currentSlotScopeIds: string[] | null = null
+
+/**
+ * @internal
+ */
+export function setCurrentSlotScopeIds(
+  scopeIds: string[] | null,
+): string[] | null {
+  const prev = currentSlotScopeIds
+  currentSlotScopeIds = scopeIds
+  return prev
+}
+
 export type RawSlots = Record<string, VaporSlot> & {
   $?: DynamicSlotSource[]
 }
@@ -116,7 +133,7 @@ export function forwardedSlotCreator(): (
 ) => Block {
   const instance = currentInstance as VaporComponentInstance
   return (name, rawProps, fallback) =>
-    createSlot(name, rawProps, fallback, instance)
+    createSlot(name, rawProps, fallback, instance, false /* noSlotted */)
 }
 
 export function createSlot(
@@ -124,6 +141,7 @@ export function createSlot(
   rawProps?: LooseRawProps | null,
   fallback?: VaporSlot,
   i?: VaporComponentInstance,
+  noSlotted?: boolean,
 ): Block {
   const _insertionParent = insertionParent
   const _insertionAnchor = insertionAnchor
@@ -152,13 +170,36 @@ export function createSlot(
         ? new DynamicFragment('slot')
         : new DynamicFragment()
     const isDynamicName = isFunction(name)
+
+    // Calculate slotScopeIds once (for vdom interop)
+    const slotScopeIds: string[] = []
+    if (!noSlotted) {
+      const scopeId = instance!.type.__scopeId
+      if (scopeId) {
+        slotScopeIds.push(`${scopeId}-s`)
+      }
+    }
+
     const renderSlot = () => {
       const slot = getSlot(rawSlots, isFunction(name) ? name() : name)
       if (slot) {
         fragment.fallback = fallback
-        // create and cache bound version of the slot to make it stable
+        // Create and cache bound version of the slot to make it stable
         // so that we avoid unnecessary updates if it resolves to the same slot
-        fragment.update(slot._bound || (slot._bound = () => slot(slotProps)))
+
+        fragment.update(
+          slot._bound ||
+            (slot._bound = () => {
+              const prevSlotScopeIds = setCurrentSlotScopeIds(
+                slotScopeIds.length > 0 ? slotScopeIds : null,
+              )
+              try {
+                return slot(slotProps)
+              } finally {
+                setCurrentSlotScopeIds(prevSlotScopeIds)
+              }
+            }),
+        )
       } else {
         fragment.update(fallback)
       }
@@ -173,6 +214,13 @@ export function createSlot(
   }
 
   if (!isHydrating) {
+    if (!noSlotted) {
+      const scopeId = instance.type.__scopeId
+      if (scopeId) {
+        setScopeId(fragment, [`${scopeId}-s`])
+      }
+    }
+
     if (_insertionParent) insert(fragment, _insertionParent, _insertionAnchor)
   } else {
     if (fragment.insert) {
index d3cb8d243a81105e80db9db73df39654e5dbe619..a44c078e6aacab6d768f86ec1bd2e397f1b14474 100644 (file)
@@ -59,6 +59,7 @@ import {
 } from '@vue/shared'
 import { type RawProps, rawPropsProxyHandlers } from './componentProps'
 import type { RawSlots, VaporSlot } from './componentSlots'
+import { currentSlotScopeIds } from './componentSlots'
 import { renderEffect } from './renderEffect'
 import { _next, createTextNode } from './dom/node'
 import { optimizePropertyLookup } from './dom/prop'
@@ -331,6 +332,9 @@ function createVDOMComponent(
     frag.nodes = vnode.el as any
   }
 
+  vnode.scopeId = parentInstance && parentInstance.type.__scopeId!
+  vnode.slotScopeIds = currentSlotScopeIds
+
   frag.insert = (parentNode, anchor, transition) => {
     if (isHydrating) return
     if (vnode.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
@@ -485,6 +489,9 @@ function renderVDOMSlot(
             parentNode!,
             anchor,
             parentComponent as any,
+            null, // parentSuspense
+            undefined, // namespace
+            vnode!.slotScopeIds, // pass slotScopeIds for :slotted styles
           )
           oldVNode = vnode!
           frag.nodes = vnode!.el as any