diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vSlot.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vSlot.spec.ts.snap index 4ecd8c76a7e..d1d80d4d620 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vSlot.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vSlot.spec.ts.snap @@ -103,6 +103,97 @@ export function render(_ctx) { }" `; +exports[`compiler: transform slot > forwarded slots > 1`] = ` +"import { forwardedSlotCreator as _forwardedSlotCreator, resolveComponent as _resolveComponent, createComponentWithFallback as _createComponentWithFallback } from 'vue'; + +export function render(_ctx) { + const _createForwardedSlot = _forwardedSlotCreator() + const _component_Comp = _resolveComponent("Comp") + const n2 = _createComponentWithFallback(_component_Comp, null, { + "default": () => { + const n1 = _createComponentWithFallback(_component_Comp, null, { + "default": () => { + const n0 = _createForwardedSlot("default", null) + return n0 + } + }) + return n1 + } + }, true) + return n2 +}" +`; + +exports[`compiler: transform slot > forwarded slots > tag only 1`] = ` +"import { forwardedSlotCreator as _forwardedSlotCreator, resolveComponent as _resolveComponent, createComponentWithFallback as _createComponentWithFallback } from 'vue'; + +export function render(_ctx) { + const _createForwardedSlot = _forwardedSlotCreator() + const _component_Comp = _resolveComponent("Comp") + const n1 = _createComponentWithFallback(_component_Comp, null, { + "default": () => { + const n0 = _createForwardedSlot("default", null) + return n0 + } + }, true) + return n1 +}" +`; + +exports[`compiler: transform slot > forwarded slots > tag w/ template 1`] = ` +"import { forwardedSlotCreator as _forwardedSlotCreator, resolveComponent as _resolveComponent, createComponentWithFallback as _createComponentWithFallback } from 'vue'; + +export function render(_ctx) { + const _createForwardedSlot = _forwardedSlotCreator() + const _component_Comp = _resolveComponent("Comp") + const n2 = _createComponentWithFallback(_component_Comp, null, { + "default": () => { + const n0 = _createForwardedSlot("default", null) + return n0 + } + }, true) + return n2 +}" +`; + +exports[`compiler: transform slot > forwarded slots > tag w/ v-for 1`] = ` +"import { forwardedSlotCreator as _forwardedSlotCreator, resolveComponent as _resolveComponent, createFor as _createFor, createComponentWithFallback as _createComponentWithFallback } from 'vue'; + +export function render(_ctx) { + const _createForwardedSlot = _forwardedSlotCreator() + const _component_Comp = _resolveComponent("Comp") + const n3 = _createComponentWithFallback(_component_Comp, null, { + "default": () => { + const n0 = _createFor(() => (_ctx.b), (_for_item0) => { + const n2 = _createForwardedSlot("default", null) + return n2 + }) + return n0 + } + }, true) + return n3 +}" +`; + +exports[`compiler: transform slot > forwarded slots > tag w/ v-if 1`] = ` +"import { forwardedSlotCreator as _forwardedSlotCreator, resolveComponent as _resolveComponent, createIf as _createIf, createComponentWithFallback as _createComponentWithFallback } from 'vue'; + +export function render(_ctx) { + const _createForwardedSlot = _forwardedSlotCreator() + const _component_Comp = _resolveComponent("Comp") + const n3 = _createComponentWithFallback(_component_Comp, null, { + "default": () => { + const n0 = _createIf(() => (_ctx.ok), () => { + const n2 = _createForwardedSlot("default", null) + return n2 + }) + return n0 + } + }, true) + return n3 +}" +`; + exports[`compiler: transform slot > implicit default slot 1`] = ` "import { resolveComponent as _resolveComponent, createComponentWithFallback as _createComponentWithFallback, template as _template } from 'vue'; const t0 = _template("
") diff --git a/packages/compiler-vapor/__tests__/transforms/vSlot.spec.ts b/packages/compiler-vapor/__tests__/transforms/vSlot.spec.ts index 84ddb2e5d04..a7da3d542f7 100644 --- a/packages/compiler-vapor/__tests__/transforms/vSlot.spec.ts +++ b/packages/compiler-vapor/__tests__/transforms/vSlot.spec.ts @@ -409,6 +409,35 @@ describe('compiler: transform slot', () => { }) }) + describe('forwarded slots', () => { + test(' tag only', () => { + const { code } = compileWithSlots(``) + expect(code).toMatchSnapshot() + }) + + test(' tag w/ v-if', () => { + const { code } = compileWithSlots(``) + expect(code).toMatchSnapshot() + }) + + test(' tag w/ v-for', () => { + const { code } = compileWithSlots(``) + expect(code).toMatchSnapshot() + }) + + test(' tag w/ template', () => { + const { code } = compileWithSlots( + ``, + ) + expect(code).toMatchSnapshot() + }) + + test('', () => { + const { code } = compileWithSlots(``) + expect(code).toMatchSnapshot() + }) + }) + describe('errors', () => { test('error on extraneous children w/ named default slot', () => { const onError = vi.fn() diff --git a/packages/compiler-vapor/src/generate.ts b/packages/compiler-vapor/src/generate.ts index 193a0f5da77..ff3806611ad 100644 --- a/packages/compiler-vapor/src/generate.ts +++ b/packages/compiler-vapor/src/generate.ts @@ -18,6 +18,7 @@ import { genCall, } from './generators/utils' import { setTemplateRefIdent } from './generators/templateRef' +import { createForwardedSlotIdent } from './generators/slotOutlet' export type CodegenOptions = Omit @@ -129,6 +130,12 @@ export function generate( `const ${setTemplateRefIdent} = ${context.helper('createTemplateRefSetter')}()`, ) } + if (ir.hasForwardedSlot) { + push( + NEWLINE, + `const ${createForwardedSlotIdent} = ${context.helper('forwardedSlotCreator')}()`, + ) + } push(...genBlockContent(ir.block, context, true)) push(INDENT_END, NEWLINE) diff --git a/packages/compiler-vapor/src/generators/component.ts b/packages/compiler-vapor/src/generators/component.ts index 7c232db754b..05b16077b08 100644 --- a/packages/compiler-vapor/src/generators/component.ts +++ b/packages/compiler-vapor/src/generators/component.ts @@ -47,7 +47,7 @@ export function genCreateComponent( const { helper } = context const tag = genTag() - const { root, props, slots, once } = operation + const { root, props, slots, once, scopeId } = operation const rawSlots = genRawSlots(slots, context) const [ids, handlers] = processInlineHandlers(props, context) const rawProps = context.withId(() => genRawProps(props, context), ids) @@ -75,6 +75,7 @@ export function genCreateComponent( rawSlots, root ? 'true' : false, once && 'true', + scopeId && JSON.stringify(scopeId), ), ...genDirectivesForElement(operation.id, context), ] diff --git a/packages/compiler-vapor/src/generators/slotOutlet.ts b/packages/compiler-vapor/src/generators/slotOutlet.ts index 3221cbbd2c7..dc992ae2334 100644 --- a/packages/compiler-vapor/src/generators/slotOutlet.ts +++ b/packages/compiler-vapor/src/generators/slotOutlet.ts @@ -5,12 +5,14 @@ import { genExpression } from './expression' import { type CodeFragment, NEWLINE, buildCodeFragment, genCall } from './utils' import { genRawProps } from './component' +export const createForwardedSlotIdent = `_createForwardedSlot` + export function genSlotOutlet( oper: SlotOutletIRNode, context: CodegenContext, ): CodeFragment[] { const { helper } = context - const { id, name, fallback } = oper + const { id, name, fallback, forwarded } = oper const [frag, push] = buildCodeFragment() const nameExpr = name.isStatic @@ -26,7 +28,7 @@ export function genSlotOutlet( NEWLINE, `const n${id} = `, ...genCall( - helper('createSlot'), + forwarded ? createForwardedSlotIdent : helper('createSlot'), nameExpr, genRawProps(oper.props, context) || 'null', fallbackArg, diff --git a/packages/compiler-vapor/src/ir/index.ts b/packages/compiler-vapor/src/ir/index.ts index da636113224..e6394ad4661 100644 --- a/packages/compiler-vapor/src/ir/index.ts +++ b/packages/compiler-vapor/src/ir/index.ts @@ -66,6 +66,7 @@ export interface RootIRNode { directive: Set block: BlockIRNode hasTemplateRef: boolean + hasForwardedSlot: boolean } export interface IfIRNode extends BaseIRNode { @@ -196,6 +197,7 @@ export interface CreateComponentIRNode extends BaseIRNode { dynamic?: SimpleExpressionNode parent?: number anchor?: number + scopeId?: string | null } export interface DeclareOldRefIRNode extends BaseIRNode { @@ -209,6 +211,7 @@ export interface SlotOutletIRNode extends BaseIRNode { name: SimpleExpressionNode props: IRProps[] fallback?: BlockIRNode + forwarded?: boolean parent?: number anchor?: number } diff --git a/packages/compiler-vapor/src/transform.ts b/packages/compiler-vapor/src/transform.ts index 76563899d2b..763e9612cdc 100644 --- a/packages/compiler-vapor/src/transform.ts +++ b/packages/compiler-vapor/src/transform.ts @@ -76,6 +76,7 @@ export class TransformContext { inVOnce: boolean = false inVFor: number = 0 + inSlot: boolean = false comment: CommentNode[] = [] component: Set = this.ir.component @@ -230,6 +231,7 @@ export function transform( directive: new Set(), block: newBlock(node), hasTemplateRef: false, + hasForwardedSlot: false, } const context = new TransformContext(ir, node, options) diff --git a/packages/compiler-vapor/src/transforms/transformElement.ts b/packages/compiler-vapor/src/transforms/transformElement.ts index dceb3fd6121..fbb48d82018 100644 --- a/packages/compiler-vapor/src/transforms/transformElement.ts +++ b/packages/compiler-vapor/src/transforms/transformElement.ts @@ -133,6 +133,7 @@ function transformComponentElement( root: singleRoot, slots: [...context.slots], once: context.inVOnce, + scopeId: context.inSlot ? context.options.scopeId : undefined, dynamic: dynamicComponent, } context.slots = [] diff --git a/packages/compiler-vapor/src/transforms/transformSlotOutlet.ts b/packages/compiler-vapor/src/transforms/transformSlotOutlet.ts index 83b4aa2d2e4..dc2b620ddb2 100644 --- a/packages/compiler-vapor/src/transforms/transformSlotOutlet.ts +++ b/packages/compiler-vapor/src/transforms/transformSlotOutlet.ts @@ -99,6 +99,7 @@ export const transformSlotOutlet: NodeTransform = (node, context) => { } return () => { + if (context.inSlot) context.ir.hasForwardedSlot = true exitBlock && exitBlock() context.dynamic.operation = { type: IRNodeTypes.SLOT_OUTLET_NODE, @@ -106,6 +107,7 @@ export const transformSlotOutlet: NodeTransform = (node, context) => { name: slotName, props: irProps, fallback, + forwarded: context.inSlot, } } } diff --git a/packages/compiler-vapor/src/transforms/vSlot.ts b/packages/compiler-vapor/src/transforms/vSlot.ts index d1bf1c6b05f..525fa323d3a 100644 --- a/packages/compiler-vapor/src/transforms/vSlot.ts +++ b/packages/compiler-vapor/src/transforms/vSlot.ts @@ -237,7 +237,14 @@ function createSlotBlock( const block: SlotBlockIRNode = newBlock(slotNode) block.props = dir && dir.exp const exitBlock = context.enterBlock(block) - return [block, exitBlock] + context.inSlot = true + return [ + block, + () => { + context.inSlot = false + exitBlock() + }, + ] } function isNonWhitespaceContent(node: TemplateChildNode): boolean { diff --git a/packages/runtime-core/src/apiCreateApp.ts b/packages/runtime-core/src/apiCreateApp.ts index 5bdd204cfad..531496fb743 100644 --- a/packages/runtime-core/src/apiCreateApp.ts +++ b/packages/runtime-core/src/apiCreateApp.ts @@ -188,7 +188,12 @@ export interface VaporInteropInterface { move(vnode: VNode, container: any, anchor: any): void slot(n1: VNode | null, n2: VNode, container: any, anchor: any): void - vdomMount: (component: ConcreteComponent, props?: any, slots?: any) => any + vdomMount: ( + component: ConcreteComponent, + props?: any, + slots?: any, + scopeId?: string, + ) => any vdomUnmount: UnmountComponentFn vdomSlot: ( slots: any, diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index e309554f2f6..527bead3ecc 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -505,7 +505,11 @@ export { type VaporInteropInterface } from './apiCreateApp' /** * @internal */ -export { type RendererInternals, MoveType } from './renderer' +export { + type RendererInternals, + MoveType, + getInheritedScopeIds, +} from './renderer' /** * @internal */ diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 5a18d62a8e1..cdf7462449d 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -764,30 +764,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]) } } @@ -2656,3 +2635,54 @@ 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__/componentSlots.spec.ts b/packages/runtime-vapor/__tests__/componentSlots.spec.ts index 58076fff9ee..bdbd960363d 100644 --- a/packages/runtime-vapor/__tests__/componentSlots.spec.ts +++ b/packages/runtime-vapor/__tests__/componentSlots.spec.ts @@ -7,6 +7,7 @@ import { createSlot, createVaporApp, defineVaporComponent, + forwardedSlotCreator, insert, prepend, renderEffect, @@ -15,7 +16,7 @@ import { import { currentInstance, nextTick, ref } from '@vue/runtime-dom' import { makeRender } from './_utils' import type { DynamicSlot } from '../src/componentSlots' -import { setElementText } from '../src/dom/prop' +import { setElementText, setText } from '../src/dom/prop' const define = makeRender() @@ -503,4 +504,106 @@ describe('component: slots', () => { expect(host.innerHTML).toBe('

') }) }) + + describe('forwarded slot', () => { + test('should work', async () => { + const Child = defineVaporComponent({ + setup() { + return createSlot('foo', null) + }, + }) + const Parent = defineVaporComponent({ + setup() { + const createForwardedSlot = forwardedSlotCreator() + const n2 = createComponent( + Child, + null, + { + foo: () => { + return createForwardedSlot('foo', null) + }, + }, + true, + ) + return n2 + }, + }) + + const foo = ref('foo') + const { host } = define({ + setup() { + const n2 = createComponent( + Parent, + null, + { + foo: () => { + const n0 = template(' ')() as any + renderEffect(() => setText(n0, foo.value)) + return n0 + }, + }, + true, + ) + return n2 + }, + }).render() + + expect(host.innerHTML).toBe('foo') + + foo.value = 'bar' + await nextTick() + expect(host.innerHTML).toBe('bar') + }) + + test('mixed with non-forwarded slot', async () => { + const Child = defineVaporComponent({ + setup() { + return [createSlot('foo', null)] + }, + }) + const Parent = defineVaporComponent({ + setup() { + const createForwardedSlot = forwardedSlotCreator() + const n2 = createComponent(Child, null, { + foo: () => { + const n0 = createForwardedSlot('foo', null) + return n0 + }, + }) + const n3 = createSlot('default', null) + return [n2, n3] + }, + }) + + const foo = ref('foo') + const { host } = define({ + setup() { + const n2 = createComponent( + Parent, + null, + { + foo: () => { + const n0 = template(' ')() as any + renderEffect(() => setText(n0, foo.value)) + return n0 + }, + default: () => { + const n3 = template(' ')() as any + renderEffect(() => setText(n3, foo.value)) + return n3 + }, + }, + true, + ) + return n2 + }, + }).render() + + expect(host.innerHTML).toBe('foofoo') + + foo.value = 'bar' + await nextTick() + expect(host.innerHTML).toBe('barbar') + }) + }) }) diff --git a/packages/runtime-vapor/__tests__/scopeId.spec.ts b/packages/runtime-vapor/__tests__/scopeId.spec.ts new file mode 100644 index 00000000000..48051b1497f --- /dev/null +++ b/packages/runtime-vapor/__tests__/scopeId.spec.ts @@ -0,0 +1,627 @@ +import { createApp, h } from '@vue/runtime-dom' +import { + createComponent, + createDynamicComponent, + createSlot, + defineVaporComponent, + forwardedSlotCreator, + setInsertionState, + template, + vaporInteropPlugin, +} 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('
', true)() + }, + }) + + const { html } = define({ + __scopeId: 'parent', + setup() { + return createComponent(Child) + }, + }).render() + expect(html()).toBe(`
`) + }) + + test('should attach scopeId to child component with insertion state', () => { + const Child = defineVaporComponent({ + __scopeId: 'child', + setup() { + return template('
', true)() + }, + }) + + const { html } = define({ + __scopeId: 'parent', + setup() { + const t0 = template('
', true) + const n1 = t0() as any + setInsertionState(n1) + createComponent(Child) + return n1 + }, + }).render() + expect(html()).toBe(`
`) + }) + + test('should attach scopeId to nested child component', () => { + const Child = defineVaporComponent({ + __scopeId: 'child', + setup() { + return template('
', true)() + }, + }) + + const Parent = defineVaporComponent({ + __scopeId: 'parent', + setup() { + return createComponent(Child) + }, + }) + + const { html } = define({ + __scopeId: 'app', + setup() { + return createComponent(Parent) + }, + }).render() + expect(html()).toBe(`
`) + }) + + test('should not attach scopeId to nested multiple root components', () => { + const Child = defineVaporComponent({ + __scopeId: 'child', + setup() { + return template('
', true)() + }, + }) + + const Parent = defineVaporComponent({ + __scopeId: 'parent', + setup() { + const n0 = template('
')() + const n1 = createComponent(Child) + return [n0, n1] + }, + }) + + const { html } = define({ + __scopeId: 'app', + setup() { + return createComponent(Parent) + }, + }).render() + expect(html()).toBe(`
`) + }) + + test('should attach scopeId to nested child component with insertion state', () => { + const Child = defineVaporComponent({ + __scopeId: 'child', + setup() { + return template('
', true)() + }, + }) + + const Parent = defineVaporComponent({ + __scopeId: 'parent', + setup() { + return createComponent(Child) + }, + }) + + const { html } = define({ + __scopeId: 'app', + setup() { + const t0 = template('
', true) + const n1 = t0() as any + setInsertionState(n1) + createComponent(Parent) + return n1 + }, + }).render() + expect(html()).toBe( + `
`, + ) + }) + + test('should attach scopeId to dynamic component', () => { + const { html } = define({ + __scopeId: 'parent', + setup() { + return createDynamicComponent(() => 'button') + }, + }).render() + expect(html()).toBe(``) + }) + + test('should attach scopeId to dynamic component with insertion state', () => { + const { html } = define({ + __scopeId: 'parent', + setup() { + const t0 = template('
', true) + const n1 = t0() as any + setInsertionState(n1) + createDynamicComponent(() => 'button') + return n1 + }, + }).render() + expect(html()).toBe( + `
`, + ) + }) + + 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( + ``, + ) + }) + + 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('
', true) + const n1 = t0() as any + setInsertionState(n1) + createComponent(Comp, null, null, true) + return n1 + }, + }).render() + expect(html()).toBe( + `
`, + ) + }) + + 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('
', true)() as any + setInsertionState(n1) + createSlot('default', null) + return n1 + }, + }) + + const Child2 = defineVaporComponent({ + __scopeId: 'child2', + setup() { + return template('', true)() + }, + }) + + const { html } = define({ + __scopeId: 'parent', + setup() { + const n2 = createComponent( + Child, + null, + { + default: () => { + const n0 = template('
')() + const n1 = createComponent( + Child2, + null, + null, + undefined, + undefined, + 'parent', + ) + return [n0, n1] + }, + }, + true, + ) + return n2 + }, + }).render() + + expect(html()).toBe( + `
` + + `
` + + // component inside slot should have: + // - scopeId from template context + // - slotted scopeId from slot owner + // - its own scopeId + `` + + `` + + `
`, + ) + }) + + test(':slotted on forwarded slots', async () => { + const Wrapper = defineVaporComponent({ + __scopeId: 'wrapper', + setup() { + //
+ const n1 = template('
', true)() as any + setInsertionState(n1) + createSlot('default', null) + return n1 + }, + }) + + const Slotted = defineVaporComponent({ + __scopeId: 'slotted', + setup() { + // + const _createForwardedSlot = forwardedSlotCreator() + const n1 = createComponent( + Wrapper, + null, + { + default: () => { + const n0 = _createForwardedSlot('default', null) + return n0 + }, + }, + true, + ) + return n1 + }, + }) + + const { html } = define({ + __scopeId: 'root', + setup() { + //
+ const n2 = createComponent( + Slotted, + null, + { + default: () => { + return template('
')() + }, + }, + true, + ) + return n2 + }, + }).render() + + expect(html()).toBe( + `
` + + `
` + + `` + + `
`, + ) + }) +}) + +describe('vdom interop', () => { + test('vdom parent > vapor child', () => { + const VaporChild = defineVaporComponent({ + __scopeId: 'vapor-child', + setup() { + return template('', 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( + ``, + ) + }) + + test('vdom parent > vapor > 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( + ``, + ) + }) + + test('vdom parent > vapor > vapor > 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( + ``, + ) + }) + + 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( + ``, + ) + }) + + 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( + ``, + ) + }) + + test('vapor parent > vdom > vapor child', () => { + const VaporChild = defineVaporComponent({ + __scopeId: 'vapor-child', + setup() { + return template('', 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( + ``, + ) + }) + + test('vapor parent > vdom > vdom > vapor child', () => { + const VaporChild = defineVaporComponent({ + __scopeId: 'vapor-child', + setup() { + return template('', 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( + ``, + ) + }) + + test('vapor parent > vapor slot > vdom child', () => { + const VaporSlot = defineVaporComponent({ + __scopeId: 'vapor-slot', + setup() { + const n1 = template('
', 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: () => { + const n0 = template('
')() + const n1 = createComponent( + VdomChild, + undefined, + undefined, + undefined, + undefined, + 'vapor-parent', + ) + return [n0, n1] + }, + }, + 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( + `
` + + `
` + + `` + + `` + + `
`, + ) + }) +}) diff --git a/packages/runtime-vapor/src/apiCreateApp.ts b/packages/runtime-vapor/src/apiCreateApp.ts index 834437ee350..1a9ed06a735 100644 --- a/packages/runtime-vapor/src/apiCreateApp.ts +++ b/packages/runtime-vapor/src/apiCreateApp.ts @@ -41,6 +41,8 @@ const mountApp: AppMountFn = (app, container) => { app._props as RawProps, null, false, + false, + undefined, app._context, ) mountComponent(instance, container) @@ -61,6 +63,8 @@ const hydrateApp: AppMountFn = (app, container) => { app._props as RawProps, null, false, + false, + undefined, app._context, ) mountComponent(instance, container) diff --git a/packages/runtime-vapor/src/apiCreateDynamicComponent.ts b/packages/runtime-vapor/src/apiCreateDynamicComponent.ts index 2126611d718..e436f77e291 100644 --- a/packages/runtime-vapor/src/apiCreateDynamicComponent.ts +++ b/packages/runtime-vapor/src/apiCreateDynamicComponent.ts @@ -1,16 +1,23 @@ import { resolveDynamicComponent } from '@vue/runtime-dom' -import { DynamicFragment, type VaporFragment } from './block' +import { DynamicFragment, type VaporFragment, insert } from './block' import { createComponentWithFallback } from './component' import { renderEffect } from './renderEffect' import type { RawProps } from './componentProps' import type { RawSlots } from './componentSlots' +import { isHydrating } from './dom/hydration' +import { insertionAnchor, insertionParent } from './insertionState' export function createDynamicComponent( getter: () => any, rawProps?: RawProps | null, rawSlots?: RawSlots | null, isSingleRoot?: boolean, + once?: boolean, + scopeId?: string, ): VaporFragment { + const _insertionParent = insertionParent + const _insertionAnchor = insertionAnchor + const frag = __DEV__ ? new DynamicFragment('dynamic-component') : new DynamicFragment() @@ -23,9 +30,15 @@ export function createDynamicComponent( rawProps, rawSlots, isSingleRoot, + once, + scopeId, ), value, ) }) + + if (!isHydrating && _insertionParent) { + insert(frag, _insertionParent, _insertionAnchor) + } return frag } diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index b782afd38d3..a5a63807ee1 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -8,6 +8,7 @@ import { import { createComment, createTextNode } from './dom/node' import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity' import { isHydrating } from './dom/hydration' +import { getInheritedScopeIds } from '@vue/runtime-dom' export type Block = | Node @@ -34,6 +35,11 @@ export class DynamicFragment extends VaporFragment { scope: EffectScope | undefined current?: BlockFn fallback?: BlockFn + /** + * slot only + * indicates forwarded slot + */ + forwarded?: boolean constructor(anchorLabel?: string) { super([]) @@ -187,3 +193,41 @@ export function normalizeBlock(block: Block): Node[] { } return nodes } + +export function setScopeId(block: Block, scopeId: string): void { + if (block instanceof Element) { + block.setAttribute(scopeId, '') + } else if (isVaporComponent(block)) { + setScopeId(block.block, scopeId) + } else if (isArray(block)) { + for (const b of block) { + setScopeId(b, scopeId) + } + } else if (isFragment(block)) { + setScopeId(block.nodes, scopeId) + } +} + +export function setComponentScopeId(instance: VaporComponentInstance): void { + const parent = instance.parent + if (!parent) return + if (isArray(instance.block) && instance.block.length > 1) return + + const scopeId = parent.type.__scopeId + if (scopeId) { + setScopeId(instance.block, scopeId) + } + + // inherit scopeId from vdom parent + if ( + parent.subTree && + (parent.subTree.component as any) === instance && + parent.vnode!.scopeId + ) { + setScopeId(instance.block, parent.vnode!.scopeId) + const scopeIds = getInheritedScopeIds(parent.vnode!, parent.parent) + for (const id of scopeIds) { + setScopeId(instance.block, id) + } + } +} diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 548babebf8b..4708db61885 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -25,7 +25,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, @@ -59,7 +66,11 @@ import { } from './componentSlots' import { hmrReload, hmrRerender } from './hmr' import { isHydrating, locateHydrationNode } from './dom/hydration' -import { insertionAnchor, insertionParent } from './insertionState' +import { + insertionAnchor, + insertionParent, + resetInsertionState, +} from './insertionState' export { currentInstance } from '@vue/runtime-dom' @@ -134,6 +145,8 @@ export function createComponent( rawProps?: LooseRawProps | null, rawSlots?: LooseRawSlots | null, isSingleRoot?: boolean, + once?: boolean, // TODO once support + scopeId?: string, appContext: GenericAppContext = (currentInstance && currentInstance.appContext) || emptyContext, @@ -142,6 +155,8 @@ export function createComponent( const _insertionAnchor = insertionAnchor if (isHydrating) { locateHydrationNode() + } else { + resetInsertionState() } // vdom interop enabled and component is not an explicit vapor component @@ -150,6 +165,7 @@ export function createComponent( component as any, rawProps, rawSlots, + scopeId, ) if (!isHydrating && _insertionParent) { insert(frag, _insertionParent, _insertionAnchor) @@ -269,10 +285,11 @@ export function createComponent( onScopeDispose(() => unmountComponent(instance), true) + if (scopeId) setScopeId(instance.block, scopeId) + if (!isHydrating && _insertionParent) { - insert(instance.block, _insertionParent, _insertionAnchor) + mountComponent(instance, _insertionParent, _insertionAnchor) } - return instance } @@ -465,15 +482,27 @@ export function createComponentWithFallback( rawProps?: LooseRawProps | null, rawSlots?: LooseRawSlots | null, isSingleRoot?: boolean, + once?: boolean, + scopeId?: string, ): HTMLElement | VaporComponentInstance { if (!isString(comp)) { - return createComponent(comp, rawProps, rawSlots, isSingleRoot) + return createComponent( + comp, + rawProps, + rawSlots, + isSingleRoot, + once, + scopeId, + ) } const el = document.createElement(comp) // mark single root ;(el as any).$root = isSingleRoot + scopeId = scopeId || currentInstance!.type.__scopeId + if (scopeId) setScopeId(el, scopeId) + if (rawProps) { renderEffect(() => { setDynamicProps(el, [resolveDynamicProps(rawProps as RawProps)]) @@ -501,6 +530,7 @@ export function mountComponent( } if (instance.bm) invokeArrayFns(instance.bm) insert(instance.block, parent, anchor) + setComponentScopeId(instance) if (instance.m) queuePostFlushCb(() => invokeArrayFns(instance.m!)) instance.isMounted = true if (__DEV__) { diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index a5e9daad229..7a0e9ed9286 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -210,7 +210,8 @@ export function hasAttrFromRawProps(rawProps: RawProps, key: string): boolean { if (dynamicSources) { let i = dynamicSources.length while (i--) { - if (hasOwn(resolveSource(dynamicSources[i]), key)) { + const source = resolveSource(dynamicSources[i]) + if (source && hasOwn(source, key)) { return true } } diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index 74296e09466..32dd235a0e7 100644 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@ -1,5 +1,11 @@ import { EMPTY_OBJ, NO, hasOwn, isArray, isFunction } from '@vue/shared' -import { type Block, type BlockFn, DynamicFragment, insert } from './block' +import { + type Block, + type BlockFn, + DynamicFragment, + insert, + setScopeId, +} from './block' import { rawPropsProxyHandlers } from './componentProps' import { currentInstance, isRef } from '@vue/runtime-dom' import type { LooseRawProps, VaporComponentInstance } from './component' @@ -87,10 +93,21 @@ export function getSlot( } } +export function forwardedSlotCreator(): ( + name: string | (() => string), + rawProps?: LooseRawProps | null, + fallback?: VaporSlot, +) => Block { + const instance = currentInstance as VaporComponentInstance + return (name, rawProps, fallback) => + createSlot(name, rawProps, fallback, instance) +} + export function createSlot( name: string | (() => string), rawProps?: LooseRawProps | null, fallback?: VaporSlot, + i?: VaporComponentInstance, ): Block { const _insertionParent = insertionParent const _insertionAnchor = insertionAnchor @@ -98,7 +115,7 @@ export function createSlot( locateHydrationNode() } - const instance = currentInstance as VaporComponentInstance + const instance = i || (currentInstance as VaporComponentInstance) const rawSlots = instance.rawSlots const slotProps = rawProps ? new Proxy(rawProps, rawPropsProxyHandlers) @@ -145,9 +162,27 @@ export function createSlot( } } + if (i) fragment.forwarded = true + if (i || !hasForwardedSlot(fragment.nodes)) { + const scopeId = instance!.type.__scopeId + if (scopeId) setScopeId(fragment, `${scopeId}-s`) + } + if (!isHydrating && _insertionParent) { insert(fragment, _insertionParent, _insertionAnchor) } return fragment } + +function isForwardedSlot(block: Block): block is DynamicFragment { + return block instanceof DynamicFragment && !!block.forwarded +} + +function hasForwardedSlot(block: Block): block is DynamicFragment { + if (isArray(block)) { + return block.some(isForwardedSlot) + } else { + return isForwardedSlot(block) + } +} diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index 682532fa4d8..10d0aa63384 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -9,7 +9,7 @@ export { insert, prepend, remove, isFragment, VaporFragment } from './block' export { setInsertionState } from './insertionState' export { createComponent, createComponentWithFallback } from './component' export { renderEffect } from './renderEffect' -export { createSlot } from './componentSlots' +export { createSlot, forwardedSlotCreator } from './componentSlots' export { template } from './dom/template' export { createTextNode, child, nthChild, next } from './dom/node' export { diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 77228fd72a0..af86b291b27 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -26,7 +26,14 @@ import { mountComponent, unmountComponent, } from './component' -import { type Block, VaporFragment, insert, remove } from './block' +import { + type Block, + VaporFragment, + insert, + isFragment, + isValidBlock, + remove, +} from './block' import { EMPTY_OBJ, extend, isFunction } from '@vue/shared' import { type RawProps, rawPropsProxyHandlers } from './componentProps' import type { RawSlots, VaporSlot } from './componentSlots' @@ -61,6 +68,7 @@ const vaporInteropImpl: Omit< instance.rawPropsRef = propsRef instance.rawSlotsRef = slotsRef mountComponent(instance, container, selfAnchor) + vnode.el = instance.block simpleSetCurrentInstance(prev) return instance }, @@ -147,6 +155,7 @@ function createVDOMComponent( component: ConcreteComponent, rawProps?: LooseRawProps | null, rawSlots?: LooseRawSlots | null, + scopeId?: string, ): VaporFragment { const frag = new VaporFragment([]) const vnode = createVNode( @@ -175,6 +184,8 @@ function createVDOMComponent( internals.umt(vnode.component!, null, !!parentNode) } + vnode.scopeId = scopeId || parentInstance.type.__scopeId! + frag.insert = (parentNode, anchor) => { if (!isMounted) { internals.mt( @@ -198,6 +209,9 @@ function createVDOMComponent( parentInstance as any, ) } + + // update the fragment nodes + frag.nodes = vnode.el as Block } frag.remove = unmount @@ -230,7 +244,24 @@ function renderVDOMSlot( isFunction(name) ? name() : name, props, ) - if ((vnode.children as any[]).length) { + let isValidSlotContent + let children = vnode.children as any[] + + // TODO add tests + // handle forwarded vapor slot + let vaporSlot + if (children.length === 1 && (vaporSlot = children[0].vs)) { + const block = vaporSlot.slot(props) + isValidSlotContent = + isValidBlock(block) || + // if block is a vapor fragment with insert, it indicates a forwarded VDOM slot + (isFragment(block) && block.insert) + } + // vnode children + else { + isValidSlotContent = children.length > 0 + } + if (isValidSlotContent) { if (fallbackNodes) { remove(fallbackNodes, parentNode) fallbackNodes = undefined