feat: implement inheritAttrs (#153)

Co-authored-by: 三咲智子 Kevin Deng <sxzz@sxzz.moe>
This commit is contained in:
Doctor Wu 2024-03-19 00:24:58 +08:00 committed by GitHub
parent 6fc5cfbc65
commit 38e167ceb8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 323 additions and 22 deletions

View File

@ -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("<div></div>")
export function render(_ctx) {
const n1 = t0()
const n0 = _createComponent(_resolveComponent("Comp"))
_insert(n0, n1)
return n1
}"
`;

View File

@ -0,0 +1,23 @@
import { compile } from '@vue/compiler-vapor'
describe('generate component', () => {
test('generate single root component (without props)', () => {
const { code } = compile(`<Comp/>`)
expect(code).toMatchSnapshot()
})
test('generate single root component (with props)', () => {
const { code } = compile(`<Comp :foo="foo"/>`)
expect(code).toMatchSnapshot()
})
test('generate multi root component', () => {
const { code } = compile(`<Comp/>123`)
expect(code).toMatchSnapshot()
})
test('should not generate withAttrs if component is not the root of the template', () => {
const { code } = compile(`<div><Comp/></div>`)
expect(code).toMatchSnapshot()
})
})

View File

@ -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() {

View File

@ -182,6 +182,7 @@ export interface CreateComponentIRNode extends BaseIRNode {
// TODO slots
resolve: boolean
root: boolean
}
export type IRNode = OperationNode | RootIRNode

View File

@ -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,
})
}

View File

@ -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<any>()
describe('attribute fallthrough', () => {
it('should allow attrs to fallthrough', async () => {
const t0 = template('<div>')
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<string, any>) {
return createComponent(
Child,
[
{
foo: () => _ctx.foo,
id: () => _ctx.id,
},
],
true,
)
},
}).render()
const reset = setCurrentInstance(instance)
expect(host.innerHTML).toBe('<div id="a">1</div>')
foo.value++
await nextTick()
expect(host.innerHTML).toBe('<div id="a">2</div>')
id.value = 'b'
await nextTick()
expect(host.innerHTML).toBe('<div id="b">2</div>')
reset()
})
it('should not fallthrough if explicitly pass inheritAttrs: false', async () => {
const t0 = template('<div>')
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<string, any>) {
return createComponent(
Child,
[
{
foo: () => _ctx.foo,
id: () => _ctx.id,
},
],
true,
)
},
}).render()
const reset = setCurrentInstance(instance)
expect(host.innerHTML).toBe('<div>1</div>')
foo.value++
await nextTick()
expect(host.innerHTML).toBe('<div>2</div>')
id.value = 'b'
await nextTick()
expect(host.innerHTML).toBe('<div>2</div>')
reset()
})
it('should pass through attrs in nested single root components', async () => {
const t0 = template('<div>')
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<string, any>) {
return createComponent(
Child,
[
{
foo: () => _ctx.foo,
id: () => _ctx.id,
},
],
true,
)
},
}).render()
const reset = setCurrentInstance(instance)
expect(host.innerHTML).toBe('<div foo="1" id="a">1</div>')
foo.value++
await nextTick()
expect(host.innerHTML).toBe('<div foo="2" id="a">2</div>')
id.value = 'b'
await nextTick()
expect(host.innerHTML).toBe('<div foo="2" id="b">2</div>')
reset()
})
})

View File

@ -238,24 +238,26 @@ describe('component: props', () => {
return { foo, id }
},
render(_ctx: Record<string, any>) {
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('<div id="a">1</div>') // TODO: Fallthrough Attributes
expect(host.innerHTML).toBe('<div>1</div>')
expect(host.innerHTML).toBe('<div id="a">1</div>')
foo.value++
await nextTick()
// expect(host.innerHTML).toBe('<div id="a">2</div>') // TODO: Fallthrough Attributes
expect(host.innerHTML).toBe('<div>2</div>')
expect(host.innerHTML).toBe('<div id="a">2</div>')
id.value = 'b'
await nextTick()
// expect(host.innerHTML).toBe('<div id="b">2</div>') // TODO: Fallthrough Attributes
expect(host.innerHTML).toBe('<div id="b">2</div>')
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('<div></div>')

View File

@ -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
}

View File

@ -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!)
}

View File

@ -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()
}

View File

@ -28,6 +28,7 @@ export type FunctionalComponent = SetupFn & Omit<ObjectComponent, 'setup'>
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 = Function> = 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,

View File

@ -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))
}
}

View File

@ -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)