diff --git a/packages/compiler-vapor/__tests__/generators/__snapshots__/component.spec.ts.snap b/packages/compiler-vapor/__tests__/generators/__snapshots__/component.spec.ts.snap new file mode 100644 index 000000000..999526e70 --- /dev/null +++ b/packages/compiler-vapor/__tests__/generators/__snapshots__/component.spec.ts.snap @@ -0,0 +1,44 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`generate component > generate multi root component 1`] = ` +"import { resolveComponent as _resolveComponent, createComponent as _createComponent, template as _template } from 'vue/vapor'; +const t0 = _template("123") + +export function render(_ctx) { + const n1 = t0() + const n0 = _createComponent(_resolveComponent("Comp")) + return [n0, n1] +}" +`; + +exports[`generate component > generate single root component (with props) 1`] = ` +"import { resolveComponent as _resolveComponent, createComponent as _createComponent } from 'vue/vapor'; + +export function render(_ctx) { + const n0 = _createComponent(_resolveComponent("Comp"), [{ + foo: () => (foo) + }], true) + return n0 +}" +`; + +exports[`generate component > generate single root component (without props) 1`] = ` +"import { resolveComponent as _resolveComponent, createComponent as _createComponent } from 'vue/vapor'; + +export function render(_ctx) { + const n0 = _createComponent(_resolveComponent("Comp"), null, true) + return n0 +}" +`; + +exports[`generate component > should not generate withAttrs if component is not the root of the template 1`] = ` +"import { resolveComponent as _resolveComponent, createComponent as _createComponent, insert as _insert, template as _template } from 'vue/vapor'; +const t0 = _template("
") + +export function render(_ctx) { + const n1 = t0() + const n0 = _createComponent(_resolveComponent("Comp")) + _insert(n0, n1) + return n1 +}" +`; diff --git a/packages/compiler-vapor/__tests__/generators/component.spec.ts b/packages/compiler-vapor/__tests__/generators/component.spec.ts new file mode 100644 index 000000000..9d6ded902 --- /dev/null +++ b/packages/compiler-vapor/__tests__/generators/component.spec.ts @@ -0,0 +1,23 @@ +import { compile } from '@vue/compiler-vapor' + +describe('generate component', () => { + test('generate single root component (without props)', () => { + const { code } = compile(``) + expect(code).toMatchSnapshot() + }) + + test('generate single root component (with props)', () => { + const { code } = compile(``) + expect(code).toMatchSnapshot() + }) + + test('generate multi root component', () => { + const { code } = compile(`123`) + expect(code).toMatchSnapshot() + }) + + test('should not generate withAttrs if component is not the root of the template', () => { + const { code } = compile(`
`) + expect(code).toMatchSnapshot() + }) +}) diff --git a/packages/compiler-vapor/src/generators/component.ts b/packages/compiler-vapor/src/generators/component.ts index ad54d884f..80550bf6f 100644 --- a/packages/compiler-vapor/src/generators/component.ts +++ b/packages/compiler-vapor/src/generators/component.ts @@ -22,10 +22,18 @@ export function genCreateComponent( ? genCall(vaporHelper('resolveComponent'), JSON.stringify(oper.tag)) : [oper.tag] + const isRoot = oper.root + const props = genProps() + return [ NEWLINE, `const n${oper.id} = `, - ...genCall(vaporHelper('createComponent'), tag, genProps()), + ...genCall( + vaporHelper('createComponent'), + tag, + props || (isRoot ? 'null' : false), + isRoot && 'true', + ), ] function genProps() { diff --git a/packages/compiler-vapor/src/ir.ts b/packages/compiler-vapor/src/ir.ts index 848e98014..db0ff7e3e 100644 --- a/packages/compiler-vapor/src/ir.ts +++ b/packages/compiler-vapor/src/ir.ts @@ -182,6 +182,7 @@ export interface CreateComponentIRNode extends BaseIRNode { // TODO slots resolve: boolean + root: boolean } export type IRNode = OperationNode | RootIRNode diff --git a/packages/compiler-vapor/src/transforms/transformElement.ts b/packages/compiler-vapor/src/transforms/transformElement.ts index 5e80749e0..677ac85aa 100644 --- a/packages/compiler-vapor/src/transforms/transformElement.ts +++ b/packages/compiler-vapor/src/transforms/transformElement.ts @@ -64,6 +64,8 @@ function transformComponentElement( const { bindingMetadata } = context.options const resolve = !bindingMetadata[tag] context.dynamic.flags |= DynamicFlag.NON_TEMPLATE | DynamicFlag.INSERT + const root = + context.root === context.parent && context.parent.node.children.length === 1 context.registerOperation({ type: IRNodeTypes.CREATE_COMPONENT_NODE, @@ -71,6 +73,7 @@ function transformComponentElement( tag, props: propsResult[0] ? propsResult[1] : [propsResult[1]], resolve, + root, }) } diff --git a/packages/runtime-vapor/__tests__/componentAttrs.spec.ts b/packages/runtime-vapor/__tests__/componentAttrs.spec.ts new file mode 100644 index 000000000..8a1970eec --- /dev/null +++ b/packages/runtime-vapor/__tests__/componentAttrs.spec.ts @@ -0,0 +1,163 @@ +import { + createComponent, + getCurrentInstance, + nextTick, + ref, + setText, + template, + watchEffect, +} from '../src' +import { setCurrentInstance } from '../src/component' +import { makeRender } from './_utils' + +const define = makeRender() + +describe('attribute fallthrough', () => { + it('should allow attrs to fallthrough', async () => { + const t0 = template('
') + const { component: Child } = define({ + props: ['foo'], + render() { + const instance = getCurrentInstance()! + const n0 = t0() + watchEffect(() => setText(n0, instance.props.foo)) + return n0 + }, + }) + + const foo = ref(1) + const id = ref('a') + const { instance, host } = define({ + setup() { + return { foo, id } + }, + render(_ctx: Record) { + return createComponent( + Child, + [ + { + foo: () => _ctx.foo, + id: () => _ctx.id, + }, + ], + true, + ) + }, + }).render() + const reset = setCurrentInstance(instance) + expect(host.innerHTML).toBe('
1
') + + foo.value++ + await nextTick() + expect(host.innerHTML).toBe('
2
') + + id.value = 'b' + await nextTick() + expect(host.innerHTML).toBe('
2
') + reset() + }) + + it('should not fallthrough if explicitly pass inheritAttrs: false', async () => { + const t0 = template('
') + const { component: Child } = define({ + props: ['foo'], + inheritAttrs: false, + render() { + const instance = getCurrentInstance()! + const n0 = t0() + watchEffect(() => setText(n0, instance.props.foo)) + return n0 + }, + }) + + const foo = ref(1) + const id = ref('a') + const { instance, host } = define({ + setup() { + return { foo, id } + }, + render(_ctx: Record) { + return createComponent( + Child, + [ + { + foo: () => _ctx.foo, + id: () => _ctx.id, + }, + ], + true, + ) + }, + }).render() + const reset = setCurrentInstance(instance) + expect(host.innerHTML).toBe('
1
') + + foo.value++ + await nextTick() + expect(host.innerHTML).toBe('
2
') + + id.value = 'b' + await nextTick() + expect(host.innerHTML).toBe('
2
') + reset() + }) + + it('should pass through attrs in nested single root components', async () => { + const t0 = template('
') + const { component: Grandson } = define({ + props: ['custom-attr'], + render() { + const instance = getCurrentInstance()! + const n0 = t0() + watchEffect(() => setText(n0, instance.attrs.foo)) + return n0 + }, + }) + + const { component: Child } = define({ + render() { + const n0 = createComponent( + Grandson, + [ + { + 'custom-attr': () => 'custom-attr', + }, + ], + true, + ) + return n0 + }, + }) + + const foo = ref(1) + const id = ref('a') + const { instance, host } = define({ + setup() { + return { foo, id } + }, + render(_ctx: Record) { + return createComponent( + Child, + [ + { + foo: () => _ctx.foo, + id: () => _ctx.id, + }, + ], + true, + ) + }, + }).render() + const reset = setCurrentInstance(instance) + expect(host.innerHTML).toBe('
1
') + + foo.value++ + await nextTick() + expect(host.innerHTML).toBe('
2
') + + id.value = 'b' + await nextTick() + expect(host.innerHTML).toBe('
2
') + reset() + }) +}) diff --git a/packages/runtime-vapor/__tests__/componentProps.spec.ts b/packages/runtime-vapor/__tests__/componentProps.spec.ts index 12f97274a..ba85905c6 100644 --- a/packages/runtime-vapor/__tests__/componentProps.spec.ts +++ b/packages/runtime-vapor/__tests__/componentProps.spec.ts @@ -238,24 +238,26 @@ describe('component: props', () => { return { foo, id } }, render(_ctx: Record) { - return createComponent(Child, { - foo: () => _ctx.foo, - id: () => _ctx.id, - }) + return createComponent( + Child, + { + foo: () => _ctx.foo, + id: () => _ctx.id, + }, + true, + ) }, }).render() const reset = setCurrentInstance(instance) - // expect(host.innerHTML).toBe('
1
') // TODO: Fallthrough Attributes - expect(host.innerHTML).toBe('
1
') + expect(host.innerHTML).toBe('
1
') foo.value++ await nextTick() - // expect(host.innerHTML).toBe('
2
') // TODO: Fallthrough Attributes - expect(host.innerHTML).toBe('
2
') + expect(host.innerHTML).toBe('
2
') id.value = 'b' await nextTick() - // expect(host.innerHTML).toBe('
2
') // TODO: Fallthrough Attributes + expect(host.innerHTML).toBe('
2
') reset() }) @@ -441,6 +443,7 @@ describe('component: props', () => { // #5016 test('handling attr with undefined value', () => { const { render, host } = define({ + inheritAttrs: false, render() { const instance = getCurrentInstance()! const t0 = template('
') diff --git a/packages/runtime-vapor/src/apiCreateComponent.ts b/packages/runtime-vapor/src/apiCreateComponent.ts index c74783b32..133b40fc5 100644 --- a/packages/runtime-vapor/src/apiCreateComponent.ts +++ b/packages/runtime-vapor/src/apiCreateComponent.ts @@ -5,14 +5,22 @@ import { } from './component' import { setupComponent } from './apiRender' import type { RawProps } from './componentProps' +import { withAttrs } from './componentAttrs' -export function createComponent(comp: Component, rawProps: RawProps = null) { +export function createComponent( + comp: Component, + rawProps: RawProps | null = null, + singleRoot: boolean = false, +) { const current = currentInstance! - const instance = createComponentInstance(comp, rawProps) - setupComponent(instance) + const instance = createComponentInstance( + comp, + singleRoot ? withAttrs(rawProps) : rawProps, + ) + setupComponent(instance, singleRoot) // register sub-component with current component for lifecycle management current.comps.add(instance) - return instance.block + return instance } diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts index cefcba1a8..362ac762c 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -4,6 +4,7 @@ import { createComment, createTextNode, insert, remove } from './dom/element' import { renderEffect } from './renderEffect' import { type Block, type Fragment, fragmentKey } from './apiRender' import { warn } from './warning' +import { componentKey } from './component' interface ForBlock extends Fragment { scope: EffectScope @@ -343,6 +344,8 @@ function normalizeAnchor(node: Block): Node { return node } else if (isArray(node)) { return normalizeAnchor(node[0]) + } else if (componentKey in node) { + return normalizeAnchor(node.block!) } else { return normalizeAnchor(node.nodes!) } diff --git a/packages/runtime-vapor/src/apiRender.ts b/packages/runtime-vapor/src/apiRender.ts index 94efce891..3ddaeb5cc 100644 --- a/packages/runtime-vapor/src/apiRender.ts +++ b/packages/runtime-vapor/src/apiRender.ts @@ -1,21 +1,29 @@ import { isArray, isFunction, isObject } from '@vue/shared' -import { type ComponentInternalInstance, setCurrentInstance } from './component' +import { + type ComponentInternalInstance, + componentKey, + setCurrentInstance, +} from './component' import { insert, querySelector, remove } from './dom/element' import { flushPostFlushCbs, queuePostRenderEffect } from './scheduler' import { proxyRefs } from '@vue/reactivity' import { invokeLifecycle } from './componentLifecycle' import { VaporLifecycleHooks } from './apiLifecycle' +import { fallThroughAttrs } from './componentAttrs' export const fragmentKey = Symbol(__DEV__ ? `fragmentKey` : ``) -export type Block = Node | Fragment | Block[] +export type Block = Node | Fragment | ComponentInternalInstance | Block[] export type Fragment = { nodes: Block anchor?: Node [fragmentKey]: true } -export function setupComponent(instance: ComponentInternalInstance): void { +export function setupComponent( + instance: ComponentInternalInstance, + singleRoot: boolean = false, +): void { const reset = setCurrentInstance(instance) instance.scope.run(() => { const { component, props, emit, attrs } = instance @@ -30,9 +38,10 @@ export function setupComponent(instance: ComponentInternalInstance): void { stateOrNode && (stateOrNode instanceof Node || isArray(stateOrNode) || - (stateOrNode as any)[fragmentKey]) + fragmentKey in stateOrNode || + componentKey in stateOrNode) ) { - block = stateOrNode as Block + block = stateOrNode } else if (isObject(stateOrNode)) { instance.setupState = proxyRefs(stateOrNode) } @@ -47,7 +56,9 @@ export function setupComponent(instance: ComponentInternalInstance): void { // TODO: warn no template block = [] } - return (instance.block = block) + instance.block = block + if (singleRoot) fallThroughAttrs(instance) + return block }) reset() } diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index b980cc7ec..79891c744 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -28,6 +28,7 @@ export type FunctionalComponent = SetupFn & Omit export interface ObjectComponent { props?: ComponentPropsOptions + inheritAttrs?: boolean emits?: EmitsOptions setup?: SetupFn render?(ctx: any): Block @@ -36,7 +37,10 @@ export interface ObjectComponent { type LifecycleHook = TFn[] | null +export const componentKey = Symbol(__DEV__ ? `componentKey` : ``) + export interface ComponentInternalInstance { + [componentKey]: true uid: number vapor: true @@ -143,6 +147,7 @@ export function createComponentInstance( rawProps: RawProps | null, ): ComponentInternalInstance { const instance: ComponentInternalInstance = { + [componentKey]: true, uid: uid++, vapor: true, diff --git a/packages/runtime-vapor/src/componentAttrs.ts b/packages/runtime-vapor/src/componentAttrs.ts index 8eabb0449..74c4aadf8 100644 --- a/packages/runtime-vapor/src/componentAttrs.ts +++ b/packages/runtime-vapor/src/componentAttrs.ts @@ -1,6 +1,9 @@ -import { camelize, isFunction } from '@vue/shared' -import type { ComponentInternalInstance } from './component' +import { camelize, isArray, isFunction } from '@vue/shared' +import { type ComponentInternalInstance, currentInstance } from './component' import { isEmitListener } from './componentEmits' +import { setDynamicProps } from './dom/prop' +import type { RawProps } from './componentProps' +import { renderEffect } from './renderEffect' export function patchAttrs(instance: ComponentInternalInstance) { const attrs = instance.attrs @@ -42,3 +45,26 @@ export function patchAttrs(instance: ComponentInternalInstance) { } } } + +export function withAttrs(props: RawProps): RawProps { + const instance = currentInstance! + if (instance.component.inheritAttrs === false) return props + const attrsGetter = () => instance.attrs + if (!props) return [attrsGetter] + if (isArray(props)) { + return [attrsGetter, ...props] + } + return [attrsGetter, props] +} + +export function fallThroughAttrs(instance: ComponentInternalInstance) { + const { + block, + component: { inheritAttrs }, + } = instance + if (inheritAttrs === false) return + + if (block instanceof Element) { + renderEffect(() => setDynamicProps(block, instance.attrs)) + } +} diff --git a/packages/runtime-vapor/src/dom/element.ts b/packages/runtime-vapor/src/dom/element.ts index 63c515d4a..d7e450871 100644 --- a/packages/runtime-vapor/src/dom/element.ts +++ b/packages/runtime-vapor/src/dom/element.ts @@ -1,5 +1,6 @@ import { isArray, toDisplayString } from '@vue/shared' import type { Block } from '../apiRender' +import { componentKey } from '../component' /*! #__NO_SIDE_EFFECTS__ */ export function normalizeBlock(block: Block): Node[] { @@ -8,6 +9,8 @@ export function normalizeBlock(block: Block): Node[] { nodes.push(block) } else if (isArray(block)) { block.forEach(child => nodes.push(...normalizeBlock(child))) + } else if (componentKey in block) { + nodes.push(...normalizeBlock(block.block!)) } else if (block) { nodes.push(...normalizeBlock(block.nodes)) block.anchor && nodes.push(block.anchor)