feat(compiler-vapor): slot outlet (#182)

Co-authored-by: 三咲智子 Kevin Deng <sxzz@sxzz.moe>
This commit is contained in:
Rizumu Ayaka 2024-05-02 22:26:52 +08:00 committed by GitHub
parent bfb52502f8
commit 2b0def3ba5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 585 additions and 6 deletions

View File

@ -0,0 +1,136 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`compiler: transform <slot> outlets > default slot outlet 1`] = `
"import { createSlot as _createSlot } from 'vue/vapor';
export function render(_ctx) {
const n0 = _createSlot("default", null)
return n0
}"
`;
exports[`compiler: transform <slot> outlets > default slot outlet with fallback 1`] = `
"import { createSlot as _createSlot, template as _template } from 'vue/vapor';
const t0 = _template("<div></div>")
export function render(_ctx) {
const n0 = _createSlot("default", null, () => {
const n2 = t0()
return n2
})
return n0
}"
`;
exports[`compiler: transform <slot> outlets > default slot outlet with props 1`] = `
"import { createSlot as _createSlot } from 'vue/vapor';
export function render(_ctx) {
const n0 = _createSlot("default", [
{
foo: () => ("bar"),
baz: () => (_ctx.qux),
fooBar: () => (_ctx.foo-_ctx.bar)
}
])
return n0
}"
`;
exports[`compiler: transform <slot> outlets > dynamically named slot outlet 1`] = `
"import { createSlot as _createSlot } from 'vue/vapor';
export function render(_ctx) {
const n0 = _createSlot(() => (_ctx.foo + _ctx.bar), null)
return n0
}"
`;
exports[`compiler: transform <slot> outlets > dynamically named slot outlet with v-bind shorthand 1`] = `
"import { createSlot as _createSlot } from 'vue/vapor';
export function render(_ctx) {
const n0 = _createSlot(() => (_ctx.name), null)
return n0
}"
`;
exports[`compiler: transform <slot> outlets > error on unexpected custom directive on <slot> 1`] = `
"import { createSlot as _createSlot } from 'vue/vapor';
export function render(_ctx) {
const n0 = _createSlot("default", null)
return n0
}"
`;
exports[`compiler: transform <slot> outlets > error on unexpected custom directive with v-show on <slot> 1`] = `
"import { createSlot as _createSlot } from 'vue/vapor';
export function render(_ctx) {
const n0 = _createSlot("default", null)
return n0
}"
`;
exports[`compiler: transform <slot> outlets > named slot outlet with fallback 1`] = `
"import { createSlot as _createSlot, template as _template } from 'vue/vapor';
const t0 = _template("<div></div>")
export function render(_ctx) {
const n0 = _createSlot("foo", null, () => {
const n2 = t0()
return n2
})
return n0
}"
`;
exports[`compiler: transform <slot> outlets > statically named slot outlet 1`] = `
"import { createSlot as _createSlot } from 'vue/vapor';
export function render(_ctx) {
const n0 = _createSlot("foo", null)
return n0
}"
`;
exports[`compiler: transform <slot> outlets > statically named slot outlet with props 1`] = `
"import { createSlot as _createSlot } from 'vue/vapor';
export function render(_ctx) {
const n0 = _createSlot("foo", [
{
foo: () => ("bar"),
baz: () => (_ctx.qux)
}
])
return n0
}"
`;
exports[`compiler: transform <slot> outlets > statically named slot outlet with v-bind="obj" 1`] = `
"import { createSlot as _createSlot } from 'vue/vapor';
export function render(_ctx) {
const n0 = _createSlot("foo", [
{ foo: () => ("bar") },
() => (_ctx.obj),
{ baz: () => (_ctx.qux) }
])
return n0
}"
`;
exports[`compiler: transform <slot> outlets > statically named slot outlet with v-on 1`] = `
"import { createSlot as _createSlot, toHandlers as _toHandlers } from 'vue/vapor';
export function render(_ctx) {
const n0 = _createSlot("default", [
{ onClick: () => _ctx.foo },
() => (_toHandlers(_ctx.bar)),
{ baz: () => (_ctx.qux) }
])
return n0
}"
`;

View File

@ -0,0 +1,258 @@
import { ErrorCodes, NodeTypes } from '@vue/compiler-core'
import {
IRNodeTypes,
transformChildren,
transformElement,
transformSlotOutlet,
transformText,
transformVBind,
transformVOn,
transformVShow,
} from '../../src'
import { makeCompile } from './_utils'
const compileWithSlotsOutlet = makeCompile({
nodeTransforms: [
transformText,
transformSlotOutlet,
transformElement,
transformChildren,
],
directiveTransforms: {
bind: transformVBind,
on: transformVOn,
show: transformVShow,
},
})
describe('compiler: transform <slot> outlets', () => {
test('default slot outlet', () => {
const { ir, code, vaporHelpers } = compileWithSlotsOutlet(`<slot />`)
expect(code).toMatchSnapshot()
expect(vaporHelpers).toContain('createSlot')
expect(ir.block.effect).toEqual([])
expect(ir.block.operation).toMatchObject([
{
type: IRNodeTypes.SLOT_OUTLET_NODE,
id: 0,
name: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'default',
isStatic: true,
},
props: [],
fallback: undefined,
},
])
})
test('statically named slot outlet', () => {
const { ir, code } = compileWithSlotsOutlet(`<slot name="foo" />`)
expect(code).toMatchSnapshot()
expect(ir.block.operation).toMatchObject([
{
type: IRNodeTypes.SLOT_OUTLET_NODE,
id: 0,
name: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'foo',
isStatic: true,
},
},
])
})
test('dynamically named slot outlet', () => {
const { ir, code } = compileWithSlotsOutlet(`<slot :name="foo + bar" />`)
expect(code).toMatchSnapshot()
expect(ir.block.operation).toMatchObject([
{
type: IRNodeTypes.SLOT_OUTLET_NODE,
id: 0,
name: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'foo + bar',
isStatic: false,
},
},
])
})
test('dynamically named slot outlet with v-bind shorthand', () => {
const { ir, code } = compileWithSlotsOutlet(`<slot :name />`)
expect(code).toMatchSnapshot()
expect(ir.block.operation).toMatchObject([
{
type: IRNodeTypes.SLOT_OUTLET_NODE,
id: 0,
name: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'name',
isStatic: false,
},
},
])
})
test('default slot outlet with props', () => {
const { ir, code } = compileWithSlotsOutlet(
`<slot foo="bar" :baz="qux" :foo-bar="foo-bar" />`,
)
expect(code).toMatchSnapshot()
expect(ir.block.operation).toMatchObject([
{
type: IRNodeTypes.SLOT_OUTLET_NODE,
name: { content: 'default' },
props: [
[
{ key: { content: 'foo' }, values: [{ content: 'bar' }] },
{ key: { content: 'baz' }, values: [{ content: 'qux' }] },
{ key: { content: 'fooBar' }, values: [{ content: 'foo-bar' }] },
],
],
},
])
})
test('statically named slot outlet with props', () => {
const { ir, code } = compileWithSlotsOutlet(
`<slot name="foo" foo="bar" :baz="qux" />`,
)
expect(code).toMatchSnapshot()
expect(ir.block.operation).toMatchObject([
{
type: IRNodeTypes.SLOT_OUTLET_NODE,
name: { content: 'foo' },
props: [
[
{ key: { content: 'foo' }, values: [{ content: 'bar' }] },
{ key: { content: 'baz' }, values: [{ content: 'qux' }] },
],
],
},
])
})
test('statically named slot outlet with v-bind="obj"', () => {
const { ir, code } = compileWithSlotsOutlet(
`<slot name="foo" foo="bar" v-bind="obj" :baz="qux" />`,
)
expect(code).toMatchSnapshot()
expect(ir.block.operation).toMatchObject([
{
type: IRNodeTypes.SLOT_OUTLET_NODE,
name: { content: 'foo' },
props: [
[{ key: { content: 'foo' }, values: [{ content: 'bar' }] }],
{ value: { content: 'obj', isStatic: false } },
[{ key: { content: 'baz' }, values: [{ content: 'qux' }] }],
],
},
])
})
test('statically named slot outlet with v-on', () => {
const { ir, code } = compileWithSlotsOutlet(
`<slot @click="foo" v-on="bar" :baz="qux" />`,
)
expect(code).toMatchSnapshot()
expect(ir.block.operation).toMatchObject([
{
type: IRNodeTypes.SLOT_OUTLET_NODE,
props: [
[{ key: { content: 'click' }, values: [{ content: 'foo' }] }],
{ value: { content: 'bar' }, handler: true },
[{ key: { content: 'baz' }, values: [{ content: 'qux' }] }],
],
},
])
})
test('default slot outlet with fallback', () => {
const { ir, code } = compileWithSlotsOutlet(`<slot><div/></slot>`)
expect(code).toMatchSnapshot()
expect(ir.template[0]).toMatchObject('<div></div>')
expect(ir.block.operation).toMatchObject([
{
type: IRNodeTypes.SLOT_OUTLET_NODE,
id: 0,
name: { content: 'default' },
fallback: {
type: IRNodeTypes.BLOCK,
dynamic: {
children: [{ template: 0, id: 2 }],
},
returns: [2],
},
},
])
})
test('named slot outlet with fallback', () => {
const { ir, code } = compileWithSlotsOutlet(
`<slot name="foo"><div/></slot>`,
)
expect(code).toMatchSnapshot()
expect(ir.template[0]).toMatchObject('<div></div>')
expect(ir.block.operation).toMatchObject([
{
type: IRNodeTypes.SLOT_OUTLET_NODE,
id: 0,
name: { content: 'foo' },
fallback: {
type: IRNodeTypes.BLOCK,
dynamic: {
children: [{ template: 0, id: 2 }],
},
returns: [2],
},
},
])
})
test('error on unexpected custom directive on <slot>', () => {
const onError = vi.fn()
const source = `<slot v-foo />`
const index = source.indexOf('v-foo')
const { code } = compileWithSlotsOutlet(source, { onError })
expect(code).toMatchSnapshot()
expect(onError.mock.calls[0][0]).toMatchObject({
code: ErrorCodes.X_V_SLOT_UNEXPECTED_DIRECTIVE_ON_SLOT_OUTLET,
loc: {
start: {
offset: index,
line: 1,
column: index + 1,
},
end: {
offset: index + 5,
line: 1,
column: index + 6,
},
},
})
})
test('error on unexpected custom directive with v-show on <slot>', () => {
const onError = vi.fn()
const source = `<slot v-show="ok" />`
const index = source.indexOf('v-show="ok"')
const { code } = compileWithSlotsOutlet(source, { onError })
expect(code).toMatchSnapshot()
expect(onError.mock.calls[0][0]).toMatchObject({
code: ErrorCodes.X_V_SLOT_UNEXPECTED_DIRECTIVE_ON_SLOT_OUTLET,
loc: {
start: {
offset: index,
line: 1,
column: index + 1,
},
end: {
offset: index + 11,
line: 1,
column: index + 12,
},
},
})
})
})

View File

@ -27,6 +27,7 @@ import { transformVModel } from './transforms/vModel'
import { transformVIf } from './transforms/vIf' import { transformVIf } from './transforms/vIf'
import { transformVFor } from './transforms/vFor' import { transformVFor } from './transforms/vFor'
import { transformComment } from './transforms/transformComment' import { transformComment } from './transforms/transformComment'
import { transformSlotOutlet } from './transforms/transformSlotOutlet'
import type { HackOptions } from './ir' import type { HackOptions } from './ir'
export { wrapTemplate } from './transforms/utils' export { wrapTemplate } from './transforms/utils'
@ -103,6 +104,7 @@ export function getBaseTransformPreset(
transformOnce, transformOnce,
transformVIf, transformVIf,
transformVFor, transformVFor,
transformSlotOutlet,
transformTemplateRef, transformTemplateRef,
transformText, transformText,
transformElement, transformElement,

View File

@ -17,6 +17,7 @@ import {
buildCodeFragment, buildCodeFragment,
} from './utils' } from './utils'
import { genCreateComponent } from './component' import { genCreateComponent } from './component'
import { genSlotOutlet } from './slotOutlet'
export function genOperations(opers: OperationNode[], context: CodegenContext) { export function genOperations(opers: OperationNode[], context: CodegenContext) {
const [frag, push] = buildCodeFragment() const [frag, push] = buildCodeFragment()
@ -61,6 +62,8 @@ export function genOperation(
return genCreateComponent(oper, context) return genCreateComponent(oper, context)
case IRNodeTypes.DECLARE_OLD_REF: case IRNodeTypes.DECLARE_OLD_REF:
return genDeclareOldRef(oper) return genDeclareOldRef(oper)
case IRNodeTypes.SLOT_OUTLET_NODE:
return genSlotOutlet(oper, context)
} }
return [] return []

View File

@ -0,0 +1,34 @@
import type { CodegenContext } from '../generate'
import type { SlotOutletIRNode } from '../ir'
import { genBlock } from './block'
import { genExpression } from './expression'
import { type CodeFragment, NEWLINE, buildCodeFragment, genCall } from './utils'
import { genRawProps } from './component'
export function genSlotOutlet(oper: SlotOutletIRNode, context: CodegenContext) {
const { vaporHelper } = context
const { id, name, fallback } = oper
const [frag, push] = buildCodeFragment()
const nameExpr = name.isStatic
? genExpression(name, context)
: ['() => (', ...genExpression(name, context), ')']
let fallbackArg: CodeFragment[] | undefined
if (fallback) {
fallbackArg = genBlock(fallback, context)
}
push(
NEWLINE,
`const n${id} = `,
...genCall(
vaporHelper('createSlot'),
nameExpr,
genRawProps(oper.props, context) || 'null',
fallbackArg,
),
)
return frag
}

View File

@ -48,3 +48,4 @@ export { transformVIf } from './transforms/vIf'
export { transformVFor } from './transforms/vFor' export { transformVFor } from './transforms/vFor'
export { transformVModel } from './transforms/vModel' export { transformVModel } from './transforms/vModel'
export { transformComment } from './transforms/transformComment' export { transformComment } from './transforms/transformComment'
export { transformSlotOutlet } from './transforms/transformSlotOutlet'

View File

@ -30,6 +30,7 @@ export enum IRNodeTypes {
PREPEND_NODE, PREPEND_NODE,
CREATE_TEXT_NODE, CREATE_TEXT_NODE,
CREATE_COMPONENT_NODE, CREATE_COMPONENT_NODE,
SLOT_OUTLET_NODE,
WITH_DIRECTIVE, WITH_DIRECTIVE,
DECLARE_OLD_REF, // consider make it more general DECLARE_OLD_REF, // consider make it more general
@ -214,6 +215,14 @@ export interface DeclareOldRefIRNode extends BaseIRNode {
id: number id: number
} }
export interface SlotOutletIRNode extends BaseIRNode {
type: IRNodeTypes.SLOT_OUTLET_NODE
id: number
name: SimpleExpressionNode
props: IRProps[]
fallback?: BlockIRNode
}
export type IRNode = OperationNode | RootIRNode export type IRNode = OperationNode | RootIRNode
export type OperationNode = export type OperationNode =
| SetPropIRNode | SetPropIRNode
@ -232,6 +241,7 @@ export type OperationNode =
| ForIRNode | ForIRNode
| CreateComponentIRNode | CreateComponentIRNode
| DeclareOldRefIRNode | DeclareOldRefIRNode
| SlotOutletIRNode
export enum DynamicFlag { export enum DynamicFlag {
NONE = 0, NONE = 0,

View File

@ -29,6 +29,7 @@ import {
type IRProp, type IRProp,
type IRProps, type IRProps,
type IRPropsDynamicAttribute, type IRPropsDynamicAttribute,
type IRPropsStatic,
type VaporDirectiveNode, type VaporDirectiveNode,
} from '../ir' } from '../ir'
import { EMPTY_EXPRESSION } from './utils' import { EMPTY_EXPRESSION } from './utils'
@ -125,7 +126,7 @@ function resolveSetupReference(name: string, context: TransformContext) {
function transformNativeElement( function transformNativeElement(
tag: string, tag: string,
propsResult: ReturnType<typeof buildProps>, propsResult: PropsResult,
context: TransformContext<ElementNode>, context: TransformContext<ElementNode>,
) { ) {
const { scopeId } = context.options const { scopeId } = context.options
@ -179,9 +180,9 @@ function transformNativeElement(
export type PropsResult = export type PropsResult =
| [dynamic: true, props: IRProps[], expressions: SimpleExpressionNode[]] | [dynamic: true, props: IRProps[], expressions: SimpleExpressionNode[]]
| [dynamic: false, props: IRProp[]] | [dynamic: false, props: IRPropsStatic]
function buildProps( export function buildProps(
node: ElementNode, node: ElementNode,
context: TransformContext<ElementNode>, context: TransformContext<ElementNode>,
isComponent: boolean, isComponent: boolean,

View File

@ -0,0 +1,133 @@
import {
type AttributeNode,
type ElementNode,
ElementTypes,
ErrorCodes,
NodeTypes,
type SimpleExpressionNode,
createCompilerError,
createSimpleExpression,
isStaticArgOf,
isStaticExp,
} from '@vue/compiler-core'
import type { NodeTransform, TransformContext } from '../transform'
import {
type BlockIRNode,
DynamicFlag,
IRNodeTypes,
type IRProps,
type VaporDirectiveNode,
type WithDirectiveIRNode,
} from '../ir'
import { camelize, extend } from '@vue/shared'
import { newBlock } from './utils'
import { buildProps } from './transformElement'
export const transformSlotOutlet: NodeTransform = (node, context) => {
if (node.type !== NodeTypes.ELEMENT || node.tag !== 'slot') {
return
}
const id = context.reference()
context.dynamic.flags |= DynamicFlag.INSERT | DynamicFlag.NON_TEMPLATE
const [fallback, exitBlock] = createFallback(
node,
context as TransformContext<ElementNode>,
)
let slotName: SimpleExpressionNode | undefined
const slotProps: (AttributeNode | VaporDirectiveNode)[] = []
for (const prop of node.props as (AttributeNode | VaporDirectiveNode)[]) {
if (prop.type === NodeTypes.ATTRIBUTE) {
if (prop.value) {
if (prop.name === 'name') {
slotName = createSimpleExpression(prop.value.content, true, prop.loc)
} else {
slotProps.push(extend({}, prop, { name: camelize(prop.name) }))
}
}
} else if (prop.name === 'bind' && isStaticArgOf(prop.arg, 'name')) {
if (prop.exp) {
slotName = prop.exp!
} else {
// v-bind shorthand syntax
slotName = createSimpleExpression(
camelize(prop.arg!.content),
false,
prop.arg!.loc,
)
slotName.ast = null
}
} else {
let slotProp = prop
if (
slotProp.name === 'bind' &&
slotProp.arg &&
isStaticExp(slotProp.arg)
) {
slotProp = extend({}, prop, {
arg: extend({}, slotProp.arg, {
content: camelize(slotProp.arg!.content),
}),
})
}
slotProps.push(slotProp)
}
}
slotName ||= createSimpleExpression('default', true)
let irProps: IRProps[] = []
if (slotProps.length) {
const [isDynamic, props] = buildProps(
extend({}, node, { props: slotProps }),
context as TransformContext<ElementNode>,
true,
)
irProps = isDynamic ? props : [props]
const runtimeDirective = context.block.operation.find(
(oper): oper is WithDirectiveIRNode =>
oper.type === IRNodeTypes.WITH_DIRECTIVE && oper.element === id,
)
if (runtimeDirective) {
context.options.onError(
createCompilerError(
ErrorCodes.X_V_SLOT_UNEXPECTED_DIRECTIVE_ON_SLOT_OUTLET,
runtimeDirective.dir.loc,
),
)
}
}
return () => {
exitBlock && exitBlock()
context.registerOperation({
type: IRNodeTypes.SLOT_OUTLET_NODE,
id,
name: slotName,
props: irProps,
fallback,
})
}
}
function createFallback(
node: ElementNode,
context: TransformContext<ElementNode>,
): [block?: BlockIRNode, exit?: () => void] {
if (!node.children.length) {
return []
}
context.node = node = extend({}, node, {
type: NodeTypes.ELEMENT,
tag: 'template',
props: [],
tagType: ElementTypes.TEMPLATE,
children: node.children,
})
const fallback = newBlock(node)
const exitBlock = context.enterBlock(fallback)
context.reference()
return [fallback, exitBlock]
}

View File

@ -5,7 +5,7 @@ import {
createCompilerError, createCompilerError,
createSimpleExpression, createSimpleExpression,
} from '@vue/compiler-dom' } from '@vue/compiler-dom'
import { camelize } from '@vue/shared' import { camelize, extend } from '@vue/shared'
import type { DirectiveTransform, TransformContext } from '../transform' import type { DirectiveTransform, TransformContext } from '../transform'
import { resolveExpression } from '../utils' import { resolveExpression } from '../utils'
import { isReservedProp } from './transformElement' import { isReservedProp } from './transformElement'
@ -58,7 +58,7 @@ export const transformVBind: DirectiveTransform = (dir, node, context) => {
let camel = false let camel = false
if (modifiers.includes('camel')) { if (modifiers.includes('camel')) {
if (arg.isStatic) { if (arg.isStatic) {
arg.content = camelize(arg.content) arg = extend({}, arg, { content: camelize(arg.content) })
} else { } else {
camel = true camel = true
} }

View File

@ -20,6 +20,7 @@ const delegatedEvents = /*#__PURE__*/ makeMap(
export const transformVOn: DirectiveTransform = (dir, node, context) => { export const transformVOn: DirectiveTransform = (dir, node, context) => {
let { arg, exp, loc, modifiers } = dir let { arg, exp, loc, modifiers } = dir
const isComponent = node.tagType === ElementTypes.COMPONENT const isComponent = node.tagType === ElementTypes.COMPONENT
const isSlotOutlet = node.tag === 'slot'
if (!exp && !modifiers.length) { if (!exp && !modifiers.length) {
context.options.onError( context.options.onError(
@ -60,7 +61,7 @@ export const transformVOn: DirectiveTransform = (dir, node, context) => {
} }
} }
if (isComponent) { if (isComponent || isSlotOutlet) {
const handler = exp || EMPTY_EXPRESSION const handler = exp || EMPTY_EXPRESSION
return { return {
key: arg, key: arg,