Prechádzať zdrojové kódy

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

Co-authored-by: 三咲智子 Kevin Deng <sxzz@sxzz.moe>
Rizumu Ayaka 2 rokov pred
rodič
commit
2b0def3ba5

+ 136 - 0
packages/compiler-vapor/__tests__/transforms/__snapshots__/transformSlotOutlet.spec.ts.snap

@@ -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
+}"
+`;

+ 258 - 0
packages/compiler-vapor/__tests__/transforms/transformSlotOutlet.spec.ts

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

+ 2 - 0
packages/compiler-vapor/src/compile.ts

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

+ 3 - 0
packages/compiler-vapor/src/generators/operation.ts

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

+ 34 - 0
packages/compiler-vapor/src/generators/slotOutlet.ts

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

+ 1 - 0
packages/compiler-vapor/src/index.ts

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

+ 10 - 0
packages/compiler-vapor/src/ir.ts

@@ -30,6 +30,7 @@ export enum IRNodeTypes {
   PREPEND_NODE,
   CREATE_TEXT_NODE,
   CREATE_COMPONENT_NODE,
+  SLOT_OUTLET_NODE,
 
   WITH_DIRECTIVE,
   DECLARE_OLD_REF, // consider make it more general
@@ -214,6 +215,14 @@ export interface DeclareOldRefIRNode extends BaseIRNode {
   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 OperationNode =
   | SetPropIRNode
@@ -232,6 +241,7 @@ export type OperationNode =
   | ForIRNode
   | CreateComponentIRNode
   | DeclareOldRefIRNode
+  | SlotOutletIRNode
 
 export enum DynamicFlag {
   NONE = 0,

+ 4 - 3
packages/compiler-vapor/src/transforms/transformElement.ts

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

+ 133 - 0
packages/compiler-vapor/src/transforms/transformSlotOutlet.ts

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

+ 2 - 2
packages/compiler-vapor/src/transforms/vBind.ts

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

+ 2 - 1
packages/compiler-vapor/src/transforms/vOn.ts

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