diff --git a/packages/compiler-vapor/__tests__/__snapshots__/compile.spec.ts.snap b/packages/compiler-vapor/__tests__/__snapshots__/compile.spec.ts.snap index 8eb9a09ea..f5058d890 100644 --- a/packages/compiler-vapor/__tests__/__snapshots__/compile.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/__snapshots__/compile.spec.ts.snap @@ -120,18 +120,19 @@ export function render(_ctx) { `; exports[`compile > directives > v-pre > should not affect siblings after it 1`] = ` -"import { createTextNode as _createTextNode, insert as _insert, renderEffect as _renderEffect, setText as _setText, setDynamicProp as _setDynamicProp, template as _template } from 'vue/vapor'; +"import { resolveComponent as _resolveComponent, createComponent as _createComponent, createTextNode as _createTextNode, insert as _insert, renderEffect as _renderEffect, setText as _setText, setDynamicProp as _setDynamicProp, template as _template } from 'vue/vapor'; const t0 = _template("
{{ bar }}
") -const t1 = _template("
") +const t1 = _template("
") export function render(_ctx) { const n0 = t0() - const n2 = t1() - const n1 = _createTextNode() - _insert(n1, n2) - _renderEffect(() => _setText(n1, _ctx.bar)) - _renderEffect(() => _setDynamicProp(n2, "id", _ctx.foo)) - return [n0, n2] + const n3 = t1() + const n1 = _createComponent(_resolveComponent("Comp")) + const n2 = _createTextNode() + _insert([n1, n2], n3) + _renderEffect(() => _setText(n2, _ctx.bar)) + _renderEffect(() => _setDynamicProp(n3, "id", _ctx.foo)) + return [n0, n3] }" `; diff --git a/packages/compiler-vapor/__tests__/compile.spec.ts b/packages/compiler-vapor/__tests__/compile.spec.ts index da6d96210..b406d9e95 100644 --- a/packages/compiler-vapor/__tests__/compile.spec.ts +++ b/packages/compiler-vapor/__tests__/compile.spec.ts @@ -72,7 +72,6 @@ describe('compile', () => { expect(code).not.contains('effect') }) - // TODO: support multiple root nodes and components test('should not affect siblings after it', () => { const code = compile( `
{{ bar }}
\n` + diff --git a/packages/compiler-vapor/src/generators/component.ts b/packages/compiler-vapor/src/generators/component.ts new file mode 100644 index 000000000..ad54d884f --- /dev/null +++ b/packages/compiler-vapor/src/generators/component.ts @@ -0,0 +1,64 @@ +import { isArray } from '@vue/shared' +import type { CodegenContext } from '../generate' +import type { CreateComponentIRNode, IRProp } from '../ir' +import { + type CodeFragment, + INDENT_END, + INDENT_START, + NEWLINE, + genCall, + genMulti, +} from './utils' +import { genExpression } from './expression' +import { genPropKey } from './prop' + +export function genCreateComponent( + oper: CreateComponentIRNode, + context: CodegenContext, +): CodeFragment[] { + const { vaporHelper } = context + + const tag = oper.resolve + ? genCall(vaporHelper('resolveComponent'), JSON.stringify(oper.tag)) + : [oper.tag] + + return [ + NEWLINE, + `const n${oper.id} = `, + ...genCall(vaporHelper('createComponent'), tag, genProps()), + ] + + function genProps() { + const props = oper.props + .map(props => { + if (isArray(props)) { + if (!props.length) return undefined + return genStaticProps(props) + } else { + return ['() => (', ...genExpression(props, context), ')'] + } + }) + .filter(Boolean) + if (props.length) { + return genMulti(['[', ']', ', '], ...props) + } + } + + function genStaticProps(props: IRProp[]) { + return genMulti( + [ + ['{', INDENT_START, NEWLINE], + [INDENT_END, NEWLINE, '}'], + [', ', NEWLINE], + ], + ...props.map(prop => { + return [ + ...genPropKey(prop, context), + ': () => (', + ...genExpression(prop.values[0], context), + ')', + ] + }), + ) + } +} diff --git a/packages/compiler-vapor/src/generators/operation.ts b/packages/compiler-vapor/src/generators/operation.ts index e08022a7d..59b0816d4 100644 --- a/packages/compiler-vapor/src/generators/operation.ts +++ b/packages/compiler-vapor/src/generators/operation.ts @@ -16,6 +16,7 @@ import { NEWLINE, buildCodeFragment, } from './utils' +import { genCreateComponent } from './component' export function genOperations(opers: OperationNode[], context: CodegenContext) { const [frag, push] = buildCodeFragment() @@ -56,6 +57,8 @@ export function genOperation( return genIf(oper, context) case IRNodeTypes.FOR: return genFor(oper, context) + case IRNodeTypes.CREATE_COMPONENT_NODE: + return genCreateComponent(oper, context) } return [] diff --git a/packages/compiler-vapor/src/generators/prop.ts b/packages/compiler-vapor/src/generators/prop.ts index 7ef11811e..8fcaf28bc 100644 --- a/packages/compiler-vapor/src/generators/prop.ts +++ b/packages/compiler-vapor/src/generators/prop.ts @@ -78,14 +78,14 @@ function genLiteralObjectProps( return genMulti( ['{ ', ' }', ', '], ...props.map(prop => [ - ...genPropertyKey(prop, context), + ...genPropKey(prop, context), `: `, ...genPropValue(prop.values, context), ]), ) } -function genPropertyKey( +export function genPropKey( { key: node, runtimeCamelize, modifier }: IRProp, context: CodegenContext, ): CodeFragment[] { diff --git a/packages/compiler-vapor/src/ir.ts b/packages/compiler-vapor/src/ir.ts index 6b6de7026..848e98014 100644 --- a/packages/compiler-vapor/src/ir.ts +++ b/packages/compiler-vapor/src/ir.ts @@ -29,6 +29,7 @@ export enum IRNodeTypes { INSERT_NODE, PREPEND_NODE, CREATE_TEXT_NODE, + CREATE_COMPONENT_NODE, WITH_DIRECTIVE, @@ -173,6 +174,16 @@ export interface WithDirectiveIRNode extends BaseIRNode { builtin?: VaporHelper } +export interface CreateComponentIRNode extends BaseIRNode { + type: IRNodeTypes.CREATE_COMPONENT_NODE + id: number + tag: string + props: IRProps[] + // TODO slots + + resolve: boolean +} + export type IRNode = OperationNode | RootIRNode export type OperationNode = | SetPropIRNode @@ -189,6 +200,7 @@ export type OperationNode = | WithDirectiveIRNode | IfIRNode | ForIRNode + | CreateComponentIRNode export enum DynamicFlag { NONE = 0, diff --git a/packages/compiler-vapor/src/transforms/transformElement.ts b/packages/compiler-vapor/src/transforms/transformElement.ts index a0e6146c6..5e80749e0 100644 --- a/packages/compiler-vapor/src/transforms/transformElement.ts +++ b/packages/compiler-vapor/src/transforms/transformElement.ts @@ -15,6 +15,7 @@ import type { TransformContext, } from '../transform' import { + DynamicFlag, IRNodeTypes, type IRProp, type IRProps, @@ -29,8 +30,7 @@ export const isReservedProp = /*#__PURE__*/ makeMap( export const transformElement: NodeTransform = (node, context) => { return function postTransformElement() { - node = context.node - + ;({ node } = context) if ( !( node.type === NodeTypes.ELEMENT && @@ -41,37 +41,94 @@ export const transformElement: NodeTransform = (node, context) => { return } - const { tag, props } = node - const isComponent = node.tagType === ElementTypes.COMPONENT + const { tag, tagType } = node + const isComponent = tagType === ElementTypes.COMPONENT + const propsResult = buildProps( + node, + context as TransformContext, + ) + + ;(isComponent ? transformComponentElement : transformNativeElement)( + tag, + propsResult, + context, + ) + } +} - context.template += `<${tag}` - if (props.length) { - buildProps( - node, - context as TransformContext, - undefined, - isComponent, - ) - } - const { scopeId } = context.options - if (scopeId) { - context.template += ` ${scopeId}` - } - context.template += `>` + context.childrenTemplate.join('') +function transformComponentElement( + tag: string, + propsResult: PropsResult, + context: TransformContext, +) { + const { bindingMetadata } = context.options + const resolve = !bindingMetadata[tag] + context.dynamic.flags |= DynamicFlag.NON_TEMPLATE | DynamicFlag.INSERT + + context.registerOperation({ + type: IRNodeTypes.CREATE_COMPONENT_NODE, + id: context.reference(), + tag, + props: propsResult[0] ? propsResult[1] : [propsResult[1]], + resolve, + }) +} - // TODO remove unnecessary close tag, e.g. if it's the last element of the template - if (!isVoidTag(tag)) { - context.template += `` +function transformNativeElement( + tag: string, + propsResult: ReturnType, + context: TransformContext, +) { + const { scopeId } = context.options + + context.template += `<${tag}` + if (scopeId) context.template += ` ${scopeId}` + + if (propsResult[0] /* dynamic props */) { + const [, dynamicArgs, expressions] = propsResult + context.registerEffect(expressions, [ + { + type: IRNodeTypes.SET_DYNAMIC_PROPS, + element: context.reference(), + props: dynamicArgs, + }, + ]) + } else { + for (const prop of propsResult[1]) { + const { key, values } = prop + if (key.isStatic && values.length === 1 && values[0].isStatic) { + context.template += ` ${key.content}` + if (values[0].content) context.template += `="${values[0].content}"` + } else { + context.registerEffect(values, [ + { + type: IRNodeTypes.SET_PROP, + element: context.reference(), + prop, + }, + ]) + } } } + + context.template += `>` + context.childrenTemplate.join('') + // TODO remove unnecessary close tag, e.g. if it's the last element of the template + if (!isVoidTag(tag)) { + context.template += `` + } } +export type PropsResult = + | [dynamic: true, props: IRProps[], expressions: SimpleExpressionNode[]] + | [dynamic: false, props: IRProp[]] + function buildProps( node: ElementNode, context: TransformContext, - props: (VaporDirectiveNode | AttributeNode)[] = node.props as any, - isComponent: boolean, -) { +): PropsResult { + const props = node.props as (VaporDirectiveNode | AttributeNode)[] + if (props.length === 0) return [false, []] + const dynamicArgs: IRProps[] = [] const dynamicExpr: SimpleExpressionNode[] = [] let results: DirectiveTransformResult[] = [] @@ -112,31 +169,11 @@ function buildProps( if (dynamicArgs.length || results.some(({ key }) => !key.isStatic)) { // take rest of props as dynamic props pushMergeArg() - context.registerEffect(dynamicExpr, [ - { - type: IRNodeTypes.SET_DYNAMIC_PROPS, - element: context.reference(), - props: dynamicArgs, - }, - ]) - } else { - const irProps = dedupeProperties(results) - for (const prop of irProps) { - const { key, values } = prop - if (key.isStatic && values.length === 1 && values[0].isStatic) { - context.template += ` ${key.content}` - if (values[0].content) context.template += `="${values[0].content}"` - } else { - context.registerEffect(values, [ - { - type: IRNodeTypes.SET_PROP, - element: context.reference(), - prop, - }, - ]) - } - } + return [true, dynamicArgs, dynamicExpr] } + + const irProps = dedupeProperties(results) + return [false, irProps] } function transformProp( diff --git a/packages/runtime-vapor/__tests__/_utils.ts b/packages/runtime-vapor/__tests__/_utils.ts index 9117cc677..d9169061d 100644 --- a/packages/runtime-vapor/__tests__/_utils.ts +++ b/packages/runtime-vapor/__tests__/_utils.ts @@ -1,12 +1,12 @@ -import type { Data } from '@vue/shared' import { + type App, type ComponentInternalInstance, type ObjectComponent, type SetupFn, - render as _render, - createComponentInstance, + createVaporApp, defineComponent, } from '../src' +import type { RawProps } from '../src/componentProps' export function makeRender( initHost = () => { @@ -27,18 +27,20 @@ export function makeRender( const define = (comp: Component) => { const component = defineComponent(comp as any) let instance: ComponentInternalInstance + let app: App const render = ( - props: Data = {}, + props: RawProps = {}, container: string | ParentNode = '#host', ) => { - instance = createComponentInstance(component, props) - _render(instance, container) + app = createVaporApp(component, props) + instance = app.mount(container) return res() } const res = () => ({ component, host, instance, + app, render, }) diff --git a/packages/runtime-vapor/__tests__/component.spec.ts b/packages/runtime-vapor/__tests__/component.spec.ts index 7a2a68a78..c166a8c73 100644 --- a/packages/runtime-vapor/__tests__/component.spec.ts +++ b/packages/runtime-vapor/__tests__/component.spec.ts @@ -1,4 +1,4 @@ -import { ref, setText, template, unmountComponent, watchEffect } from '../src' +import { ref, setText, template, watchEffect } from '../src' import { describe, expect } from 'vitest' import { makeRender } from './_utils' @@ -6,7 +6,7 @@ const define = makeRender() describe('component', () => { test('unmountComponent', async () => { - const { host, instance } = define(() => { + const { host, app } = define(() => { const count = ref(0) const t0 = template('
') const n0 = t0() @@ -16,7 +16,7 @@ describe('component', () => { return n0 }).render() expect(host.innerHTML).toBe('
0
') - unmountComponent(instance) + app.unmount() expect(host.innerHTML).toBe('') }) }) diff --git a/packages/runtime-vapor/__tests__/componentEmits.spec.ts b/packages/runtime-vapor/__tests__/componentEmits.spec.ts index b2e34d572..6094c5120 100644 --- a/packages/runtime-vapor/__tests__/componentEmits.spec.ts +++ b/packages/runtime-vapor/__tests__/componentEmits.spec.ts @@ -3,13 +3,13 @@ // Note: emits and listener fallthrough is tested in // ./rendererAttrsFallthrough.spec.ts. -import { nextTick, onBeforeUnmount, unmountComponent } from '../src' +import { nextTick, onBeforeUnmount } from '../src' import { isEmitListener } from '../src/componentEmits' import { makeRender } from './_utils' const define = makeRender() -describe('component: emit', () => { +describe.todo('component: emit', () => { test('trigger handlers', () => { const { render } = define({ render() {}, @@ -137,9 +137,7 @@ describe('component: emit', () => { const fn2 = vi.fn() render({ - get onFoo() { - return [fn1, fn2] - }, + onFoo: () => [fn1, fn2], }) expect(fn1).toHaveBeenCalledTimes(1) expect(fn1).toHaveBeenCalledWith(1) @@ -246,22 +244,22 @@ describe('component: emit', () => { const fn1 = vi.fn() const fn2 = vi.fn() render({ - get modelValue() { + modelValue() { return null }, - get modelModifiers() { + modelModifiers() { return { number: true } }, - get ['onUpdate:modelValue']() { + ['onUpdate:modelValue']() { return fn1 }, - get foo() { + foo() { return null }, - get fooModifiers() { + fooModifiers() { return { number: true } }, - get ['onUpdate:foo']() { + ['onUpdate:foo']() { return fn2 }, }) @@ -282,22 +280,22 @@ describe('component: emit', () => { const fn1 = vi.fn() const fn2 = vi.fn() render({ - get modelValue() { + modelValue() { return null }, - get modelModifiers() { + modelModifiers() { return { trim: true } }, - get ['onUpdate:modelValue']() { + ['onUpdate:modelValue']() { return fn1 }, - get foo() { + foo() { return null }, - get fooModifiers() { + fooModifiers() { return { trim: true } }, - get 'onUpdate:foo'() { + 'onUpdate:foo'() { return fn2 }, }) @@ -318,22 +316,22 @@ describe('component: emit', () => { const fn1 = vi.fn() const fn2 = vi.fn() render({ - get modelValue() { + modelValue() { return null }, - get modelModifiers() { + modelModifiers() { return { trim: true, number: true } }, - get ['onUpdate:modelValue']() { + ['onUpdate:modelValue']() { return fn1 }, - get foo() { + foo() { return null }, - get fooModifiers() { + fooModifiers() { return { trim: true, number: true } }, - get ['onUpdate:foo']() { + ['onUpdate:foo']() { return fn2 }, }) @@ -352,13 +350,13 @@ describe('component: emit', () => { }) const fn = vi.fn() render({ - get modelValue() { + modelValue() { return null }, - get modelModifiers() { + modelModifiers() { return { trim: true } }, - get ['onUpdate:modelValue']() { + ['onUpdate:modelValue']() { return fn }, }) @@ -397,7 +395,7 @@ describe('component: emit', () => { test('does not emit after unmount', async () => { const fn = vi.fn() - const { instance } = define({ + const { app } = define({ emits: ['closing'], setup(_: any, { emit }: any) { onBeforeUnmount(async () => { @@ -412,7 +410,7 @@ describe('component: emit', () => { }, }) await nextTick() - unmountComponent(instance) + app.unmount() await nextTick() expect(fn).not.toHaveBeenCalled() }) diff --git a/packages/runtime-vapor/__tests__/componentProps.spec.ts b/packages/runtime-vapor/__tests__/componentProps.spec.ts index df493d71d..12f97274a 100644 --- a/packages/runtime-vapor/__tests__/componentProps.spec.ts +++ b/packages/runtime-vapor/__tests__/componentProps.spec.ts @@ -1,24 +1,24 @@ // NOTE: This test is implemented based on the case of `runtime-core/__test__/componentProps.spec.ts`. -// NOTE: not supported -// mixins -// caching - import { setCurrentInstance } from '../src/component' import { + createComponent, defineComponent, getCurrentInstance, nextTick, ref, setText, template, + toRefs, + watch, watchEffect, } from '../src' import { makeRender } from './_utils' const define = makeRender() -describe('component props (vapor)', () => { +describe('component: props', () => { + // NOTE: no proxy test('stateful', () => { let props: any let attrs: any @@ -32,65 +32,51 @@ describe('component props (vapor)', () => { }, }) - render({ - get fooBar() { - return 1 - }, - get bar() { - return 2 - }, - }) - expect(props.fooBar).toEqual(1) - expect(attrs.bar).toEqual(2) + render({ fooBar: () => 1, bar: () => 2 }) + expect(props).toEqual({ fooBar: 1 }) + expect(attrs).toEqual({ bar: 2 }) // test passing kebab-case and resolving to camelCase - render({ - get ['foo-bar']() { - return 2 - }, - get bar() { - return 3 - }, - get baz() { - return 4 - }, - }) - expect(props.fooBar).toEqual(2) - expect(attrs.bar).toEqual(3) - expect(attrs.baz).toEqual(4) + render({ 'foo-bar': () => 2, bar: () => 3, baz: () => 4 }) + expect(props).toEqual({ fooBar: 2 }) + expect(attrs).toEqual({ bar: 3, baz: 4 }) // test updating kebab-case should not delete it (#955) - render({ - get ['foo-bar']() { - return 3 - }, - get bar() { - return 3 - }, - get baz() { - return 4 - }, - get barBaz() { - return 5 - }, - }) - expect(props.fooBar).toEqual(3) - expect(props.barBaz).toEqual(5) - expect(attrs.bar).toEqual(3) - expect(attrs.baz).toEqual(4) + render({ 'foo-bar': () => 3, bar: () => 3, baz: () => 4, barBaz: () => 5 }) + expect(props).toEqual({ fooBar: 3, barBaz: 5 }) + expect(attrs).toEqual({ bar: 3, baz: 4 }) + + // remove the props with camelCase key (#1412) + render({ qux: () => 5 }) + expect(props).toEqual({}) + expect(attrs).toEqual({ qux: 5 }) + }) - render({ - get qux() { - return 5 + test.fails('stateful with setup', () => { + let props: any + let attrs: any + + const { render } = define({ + props: ['foo'], + setup(_props: any, { attrs: _attrs }: any) { + return () => { + props = _props + attrs = _attrs + } }, }) - expect(props.fooBar).toBeUndefined() - expect(props.barBaz).toBeUndefined() - expect(attrs.qux).toEqual(5) - }) - test.todo('stateful with setup', () => { - // TODO: + render({ foo: () => 1, bar: () => 2 }) + expect(props).toEqual({ foo: 1 }) + expect(attrs).toEqual({ bar: 2 }) + + render({ foo: () => 2, bar: () => 3, baz: () => 4 }) + expect(props).toEqual({ foo: 2 }) + expect(attrs).toEqual({ bar: 3, baz: 4 }) + + render({ qux: () => 5 }) + expect(props).toEqual({}) + expect(attrs).toEqual({ qux: 5 }) }) test('functional with declaration', () => { @@ -105,42 +91,19 @@ describe('component props (vapor)', () => { }) Comp.props = ['foo'] - render({ - get foo() { - return 1 - }, - get bar() { - return 2 - }, - }) - expect(props.foo).toEqual(1) - expect(attrs.bar).toEqual(2) + render({ foo: () => 1, bar: () => 2 }) + expect(props).toEqual({ foo: 1 }) + expect(attrs).toEqual({ bar: 2 }) - render({ - get foo() { - return 2 - }, - get bar() { - return 3 - }, - get baz() { - return 4 - }, - }) - expect(props.foo).toEqual(2) - expect(attrs.bar).toEqual(3) - expect(attrs.baz).toEqual(4) + render({ foo: () => 2, bar: () => 3, baz: () => 4 }) + expect(props).toEqual({ foo: 2 }) + expect(attrs).toEqual({ bar: 3, baz: 4 }) - render({ - get qux() { - return 5 - }, - }) - expect(props.foo).toBeUndefined() - expect(attrs.qux).toEqual(5) + render({ qux: () => 5 }) + expect(props).toEqual({}) + expect(attrs).toEqual({ qux: 5 }) }) - // FIXME: test('functional without declaration', () => { let props: any let attrs: any @@ -152,21 +115,15 @@ describe('component props (vapor)', () => { return {} }) - render({ - get foo() { - return 1 - }, - }) - expect(props.foo).toEqual(1) - expect(attrs.foo).toEqual(1) + render({ foo: () => 1 }) + expect(props).toEqual({ foo: 1 }) + expect(attrs).toEqual({ foo: 1 }) + expect(props).toBe(attrs) - render({ - get foo() { - return 2 - }, - }) - expect(props.foo).toEqual(2) - expect(attrs.foo).toEqual(2) + render({ bar: () => 2 }) + expect(props).toEqual({ bar: 2 }) + expect(attrs).toEqual({ bar: 2 }) + expect(props).toBe(attrs) }) test('boolean casting', () => { @@ -186,15 +143,16 @@ describe('component props (vapor)', () => { render({ // absent should cast to false - bar: '', // empty string should cast to true - baz: 'baz', // same string should cast to true - qux: 'ok', // other values should be left in-tact (but raise warning) + bar: () => '', // empty string should cast to true + baz: () => 'baz', // same string should cast to true + qux: () => 'ok', // other values should be left in-tact (but raise warning) }) expect(props.foo).toBe(false) expect(props.bar).toBe(true) expect(props.baz).toBe(true) expect(props.qux).toBe('ok') + // expect('type check failed for prop "qux"').toHaveBeenWarned() }) test('default value', () => { @@ -221,82 +179,57 @@ describe('component props (vapor)', () => { }, }) - render({ - get foo() { - return 2 - }, - }) + render({ foo: () => 2 }) expect(props.foo).toBe(2) // const prevBar = props.bar - props.bar expect(props.bar).toEqual({ a: 1 }) expect(props.baz).toEqual(defaultBaz) - // expect(defaultFn).toHaveBeenCalledTimes(1) // failed: (caching is not supported) - expect(defaultFn).toHaveBeenCalledTimes(3) + expect(defaultFn).toHaveBeenCalledTimes(1) expect(defaultBaz).toHaveBeenCalledTimes(0) // #999: updates should not cause default factory of unchanged prop to be // called again - render({ - get foo() { - return 3 - }, - }) + render({ foo: () => 3 }) + expect(props.foo).toBe(3) expect(props.bar).toEqual({ a: 1 }) // expect(props.bar).toBe(prevBar) // failed: (caching is not supported) - // expect(defaultFn).toHaveBeenCalledTimes(1) // failed: caching is not supported (called 3 times) + // expect(defaultFn).toHaveBeenCalledTimes(1) // failed: caching is not supported (called 2 times) - render({ - get bar() { - return { b: 2 } - }, - }) + render({ bar: () => ({ b: 2 }) }) expect(props.foo).toBe(1) expect(props.bar).toEqual({ b: 2 }) - // expect(defaultFn).toHaveBeenCalledTimes(1) // failed: caching is not supported (called 3 times) + // expect(defaultFn).toHaveBeenCalledTimes(1) // failed: caching is not supported (called 2 times) render({ - get foo() { - return 3 - }, - get bar() { - return { b: 3 } - }, + foo: () => 3, + bar: () => ({ b: 3 }), }) expect(props.foo).toBe(3) expect(props.bar).toEqual({ b: 3 }) - // expect(defaultFn).toHaveBeenCalledTimes(1) // failed: caching is not supported (called 3 times) + // expect(defaultFn).toHaveBeenCalledTimes(1) // failed: caching is not supported (called 2 times) - render({ - get bar() { - return { b: 4 } - }, - }) + render({ bar: () => ({ b: 4 }) }) expect(props.foo).toBe(1) expect(props.bar).toEqual({ b: 4 }) - // expect(defaultFn).toHaveBeenCalledTimes(1) // failed: caching is not supported (called 3 times) + // expect(defaultFn).toHaveBeenCalledTimes(1) // failed: caching is not supported (called 2 times) }) test.todo('using inject in default value factory', () => { // TODO: impl inject }) - // NOTE: maybe it's unnecessary - // https://github.com/vuejs/core-vapor/pull/99#discussion_r1472647377 test('optimized props updates', async () => { - const renderChild = define({ + const t0 = template('
') + const { component: Child } = define({ props: ['foo'], render() { const instance = getCurrentInstance()! - const t0 = template('
') const n0 = t0() - watchEffect(() => { - setText(n0, instance.props.foo) - }) + watchEffect(() => setText(n0, instance.props.foo)) return n0 }, - }).render + }) const foo = ref(1) const id = ref('a') @@ -305,54 +238,36 @@ describe('component props (vapor)', () => { return { foo, id } }, render(_ctx: Record) { - const t0 = template('
') - const n0 = t0() - renderChild( - { - get foo() { - return _ctx.foo - }, - get id() { - return _ctx.id - }, - }, - n0 as HTMLDivElement, - ) - return n0 + return createComponent(Child, { + foo: () => _ctx.foo, + id: () => _ctx.id, + }) }, }).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() + id.value = 'b' + await nextTick() // expect(host.innerHTML).toBe('
2
') // TODO: Fallthrough Attributes reset() }) describe('validator', () => { test('validator should be called with two arguments', () => { - let args: any - const mockFn = vi.fn((..._args: any[]) => { - args = _args - return true - }) - + const mockFn = vi.fn((...args: any[]) => true) const props = { - get foo() { - return 1 - }, - get bar() { - return 2 - }, + foo: () => 1, + bar: () => 2, } + const t0 = template('
') define({ props: { foo: { @@ -364,19 +279,11 @@ describe('component props (vapor)', () => { }, }, render() { - const t0 = template('
') - const n0 = t0() - return n0 + return t0() }, }).render(props) - expect(mockFn).toHaveBeenCalled() - // NOTE: Vapor Component props defined by getter. So, `props` not Equal to `{ foo: 1, bar: 2 }` - // expect(mockFn).toHaveBeenCalledWith(1, { foo: 1, bar: 2 }) - expect(args.length).toBe(2) - expect(args[0]).toBe(1) - expect(args[1].foo).toEqual(1) - expect(args[1].bar).toEqual(2) + expect(mockFn).toHaveBeenCalledWith(1, { foo: 1, bar: 2 }) }) // TODO: impl setter and warnner @@ -401,16 +308,16 @@ describe('component props (vapor)', () => { return n0 }, }).render!({ - get foo() { + foo() { return 1 }, - get bar() { + bar() { return 2 }, }) expect( - `Set operation on key "bar" failed: target is readonly.`, + `Set operation on key "bar" failed: taris readonly.`, ).toHaveBeenWarnedLast() expect(mockFn).toHaveBeenCalledWith(2) }, @@ -450,70 +357,71 @@ describe('component props (vapor)', () => { return () => null }, }).render({ - get ['foo-bar']() { - return 'hello' - }, + ['foo-bar']: () => 'hello', }) expect(`Missing required prop: "fooBar"`).not.toHaveBeenWarned() }) test('props type support BigInt', () => { + const t0 = template('
') const { host } = define({ props: { foo: BigInt, }, render() { const instance = getCurrentInstance()! - const t0 = template('
') const n0 = t0() - watchEffect(() => { - setText(n0, instance.props.foo) - }) + watchEffect(() => setText(n0, instance.props.foo)) return n0 }, }).render({ - get foo() { - return BigInt(BigInt(100000111)) + BigInt(2000000000) * BigInt(30000000) - }, + foo: () => + BigInt(BigInt(100000111)) + BigInt(2000000000) * BigInt(30000000), }) expect(host.innerHTML).toBe('
60000000100000111
') }) - // #3288 + // #3474 test.todo( - 'declared prop key should be present even if not passed', - async () => { - // let initialKeys: string[] = [] - // const changeSpy = vi.fn() - // const passFoo = ref(false) - // const Comp = { - // props: ['foo'], - // setup() { - // const instance = getCurrentInstance()! - // initialKeys = Object.keys(instance.props) - // watchEffect(changeSpy) - // return {} - // }, - // render() { - // return {} - // }, - // } - // const Parent = createIf( - // () => passFoo.value, - // () => { - // return render(Comp , { foo: 1 }, host) // TODO: createComponent fn - // }, - // ) - // // expect(changeSpy).toHaveBeenCalledTimes(1) - }, + 'should cache the value returned from the default factory to avoid unnecessary watcher trigger', + () => {}, ) + // #3288 + test('declared prop key should be present even if not passed', async () => { + let initialKeys: string[] = [] + const changeSpy = vi.fn() + const passFoo = ref(false) + + const Comp: any = { + render() {}, + props: { + foo: String, + }, + setup(props: any) { + initialKeys = Object.keys(props) + const { foo } = toRefs(props) + watch(foo, changeSpy) + }, + } + + define(() => + createComponent(Comp, [() => (passFoo.value ? { foo: () => 'ok' } : {})]), + ).render() + + expect(initialKeys).toMatchObject(['foo']) + passFoo.value = true + await nextTick() + expect(changeSpy).toHaveBeenCalledTimes(1) + }) + // #3371 test.todo(`avoid double-setting props when casting`, async () => { // TODO: proide, slots }) - test('support null in required + multiple-type declarations', () => { + // NOTE: type check is not supported + test.todo('support null in required + multiple-type declarations', () => { const { render } = define({ props: { foo: { type: [Function, null], required: true }, @@ -522,11 +430,11 @@ describe('component props (vapor)', () => { }) expect(() => { - render({ foo: () => {} }) + render({ foo: () => () => {} }) }).not.toThrow() expect(() => { - render({ foo: null }) + render({ foo: () => null }) }).not.toThrow() }) @@ -537,22 +445,17 @@ describe('component props (vapor)', () => { const instance = getCurrentInstance()! const t0 = template('
') const n0 = t0() - watchEffect(() => { + watchEffect(() => setText( n0, JSON.stringify(instance.attrs) + Object.keys(instance.attrs), - ) - }) + ), + ) return n0 }, }) - let attrs: any = { - get foo() { - return undefined - }, - } - + const attrs: any = { foo: () => undefined } render(attrs) expect(host.innerHTML).toBe( @@ -567,8 +470,20 @@ describe('component props (vapor)', () => { type: String, }, } - define({ props, render() {} }).render({ msg: 'test' }) + define({ props, render() {} }).render({ msg: () => 'test' }) expect(Object.keys(props.msg).length).toBe(1) }) + + test('should warn against reserved prop names', () => { + const { render } = define({ + props: { + $foo: String, + }, + render() {}, + }) + + render({ msg: () => 'test' }) + expect(`Invalid prop name: "$foo"`).toHaveBeenWarned() + }) }) diff --git a/packages/runtime-vapor/__tests__/dom/element.spec.ts b/packages/runtime-vapor/__tests__/dom/element.spec.ts index 43b9ab02b..603f43491 100644 --- a/packages/runtime-vapor/__tests__/dom/element.spec.ts +++ b/packages/runtime-vapor/__tests__/dom/element.spec.ts @@ -1,5 +1,5 @@ import { insert, normalizeBlock, prepend, remove } from '../../src/dom/element' -import { fragmentKey } from '../../src/render' +import { fragmentKey } from '../../src/apiRender' const node1 = document.createTextNode('node1') const node2 = document.createTextNode('node2') diff --git a/packages/runtime-vapor/src/apiCreateComponent.ts b/packages/runtime-vapor/src/apiCreateComponent.ts new file mode 100644 index 000000000..c74783b32 --- /dev/null +++ b/packages/runtime-vapor/src/apiCreateComponent.ts @@ -0,0 +1,18 @@ +import { + type Component, + createComponentInstance, + currentInstance, +} from './component' +import { setupComponent } from './apiRender' +import type { RawProps } from './componentProps' + +export function createComponent(comp: Component, rawProps: RawProps = null) { + const current = currentInstance! + const instance = createComponentInstance(comp, rawProps) + setupComponent(instance) + + // register sub-component with current component for lifecycle management + current.comps.add(instance) + + return instance.block +} diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts index 20401c2a1..1d31aa41b 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -2,7 +2,7 @@ import { type EffectScope, effectScope, isReactive } from '@vue/reactivity' import { isArray, isObject, isString } from '@vue/shared' import { createComment, createTextNode, insert, remove } from './dom/element' import { renderEffect } from './renderWatch' -import { type Block, type Fragment, fragmentKey } from './render' +import { type Block, type Fragment, fragmentKey } from './apiRender' import { warn } from './warning' interface ForBlock extends Fragment { diff --git a/packages/runtime-vapor/src/apiCreateIf.ts b/packages/runtime-vapor/src/apiCreateIf.ts index 084544eb9..33270ef0c 100644 --- a/packages/runtime-vapor/src/apiCreateIf.ts +++ b/packages/runtime-vapor/src/apiCreateIf.ts @@ -1,5 +1,5 @@ import { renderWatch } from './renderWatch' -import { type Block, type Fragment, fragmentKey } from './render' +import { type Block, type Fragment, fragmentKey } from './apiRender' import { type EffectScope, effectScope } from '@vue/reactivity' import { createComment, createTextNode, insert, remove } from './dom/element' diff --git a/packages/runtime-vapor/src/apiCreateVaporApp.ts b/packages/runtime-vapor/src/apiCreateVaporApp.ts new file mode 100644 index 000000000..af8e480fd --- /dev/null +++ b/packages/runtime-vapor/src/apiCreateVaporApp.ts @@ -0,0 +1,103 @@ +import { isObject } from '@vue/shared' +import { + type Component, + type ComponentInternalInstance, + createComponentInstance, +} from './component' +import { warn } from './warning' +import { version } from '.' +import { render, setupComponent, unmountComponent } from './apiRender' +import type { RawProps } from './componentProps' + +export function createVaporApp( + rootComponent: Component, + rootProps: RawProps | null = null, +): App { + if (rootProps != null && !isObject(rootProps)) { + __DEV__ && warn(`root props passed to app.mount() must be an object.`) + rootProps = null + } + + const context = createAppContext() + let instance: ComponentInternalInstance + + const app: App = { + version, + + get config() { + return context.config + }, + + set config(v) { + if (__DEV__) { + warn( + `app.config cannot be replaced. Modify individual options instead.`, + ) + } + }, + + mount(rootContainer): any { + if (!instance) { + instance = createComponentInstance(rootComponent, rootProps) + setupComponent(instance) + render(instance, rootContainer) + return instance + } else if (__DEV__) { + warn( + `App has already been mounted.\n` + + `If you want to remount the same app, move your app creation logic ` + + `into a factory function and create fresh app instances for each ` + + `mount - e.g. \`const createMyApp = () => createApp(App)\``, + ) + } + }, + unmount() { + if (instance) { + unmountComponent(instance) + } else if (__DEV__) { + warn(`Cannot unmount an app that is not mounted.`) + } + }, + } + + return app +} + +function createAppContext(): AppContext { + return { + app: null as any, + config: { + errorHandler: undefined, + warnHandler: undefined, + }, + } +} + +export interface App { + version: string + config: AppConfig + + mount( + rootContainer: ParentNode | string, + isHydrate?: boolean, + ): ComponentInternalInstance + unmount(): void +} + +export interface AppConfig { + errorHandler?: ( + err: unknown, + instance: ComponentInternalInstance | null, + info: string, + ) => void + warnHandler?: ( + msg: string, + instance: ComponentInternalInstance | null, + trace: string, + ) => void +} + +export interface AppContext { + app: App // for devtools + config: AppConfig +} diff --git a/packages/runtime-vapor/src/render.ts b/packages/runtime-vapor/src/apiRender.ts similarity index 71% rename from packages/runtime-vapor/src/render.ts rename to packages/runtime-vapor/src/apiRender.ts index 893b046f4..94efce891 100644 --- a/packages/runtime-vapor/src/render.ts +++ b/packages/runtime-vapor/src/apiRender.ts @@ -1,13 +1,10 @@ -import { proxyRefs } from '@vue/reactivity' -import { invokeArrayFns, isArray, isFunction, isObject } from '@vue/shared' -import { - type ComponentInternalInstance, - setCurrentInstance, - unsetCurrentInstance, -} from './component' -import { invokeDirectiveHook } from './directives' +import { isArray, isFunction, isObject } from '@vue/shared' +import { type ComponentInternalInstance, 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' export const fragmentKey = Symbol(__DEV__ ? `fragmentKey` : ``) @@ -18,28 +15,9 @@ export type Fragment = { [fragmentKey]: true } -export function render( - instance: ComponentInternalInstance, - container: string | ParentNode, -): void { - mountComponent(instance, (container = normalizeContainer(container))) - flushPostFlushCbs() -} - -function normalizeContainer(container: string | ParentNode): ParentNode { - return typeof container === 'string' - ? (querySelector(container) as ParentNode) - : container -} - -function mountComponent( - instance: ComponentInternalInstance, - container: ParentNode, -) { - instance.container = container - +export function setupComponent(instance: ComponentInternalInstance): void { const reset = setCurrentInstance(instance) - const block = instance.scope.run(() => { + instance.scope.run(() => { const { component, props, emit, attrs } = instance const ctx = { expose: () => {}, emit, attrs } @@ -70,40 +48,52 @@ function mountComponent( block = [] } return (instance.block = block) - })! - const { bm, m } = instance + }) + reset() +} + +export function render( + instance: ComponentInternalInstance, + container: string | ParentNode, +): void { + mountComponent(instance, (container = normalizeContainer(container))) + flushPostFlushCbs() +} + +function normalizeContainer(container: string | ParentNode): ParentNode { + return typeof container === 'string' + ? (querySelector(container) as ParentNode) + : container +} + +function mountComponent( + instance: ComponentInternalInstance, + container: ParentNode, +) { + instance.container = container // hook: beforeMount - bm && invokeArrayFns(bm) - invokeDirectiveHook(instance, 'beforeMount') + invokeLifecycle(instance, VaporLifecycleHooks.BEFORE_MOUNT, 'beforeMount') - insert(block, instance.container) + insert(instance.block!, instance.container) instance.isMounted = true // hook: mounted - queuePostRenderEffect(() => { - invokeDirectiveHook(instance, 'mounted') - m && invokeArrayFns(m) - }) - reset() + invokeLifecycle(instance, VaporLifecycleHooks.MOUNTED, 'mounted', true) return instance } export function unmountComponent(instance: ComponentInternalInstance) { - const { container, block, scope, um, bum } = instance + const { container, block, scope } = instance // hook: beforeUnmount - bum && invokeArrayFns(bum) - invokeDirectiveHook(instance, 'beforeUnmount') + invokeLifecycle(instance, VaporLifecycleHooks.BEFORE_UNMOUNT, 'beforeUnmount') scope.stop() block && remove(block, container) - instance.isMounted = false - instance.isUnmounted = true // hook: unmounted - invokeDirectiveHook(instance, 'unmounted') - um && invokeArrayFns(um) - unsetCurrentInstance() + invokeLifecycle(instance, VaporLifecycleHooks.UNMOUNTED, 'unmounted', true) + queuePostRenderEffect(() => (instance.isUnmounted = true)) } diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 60f9fd298..b980cc7ec 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -1,11 +1,12 @@ import { EffectScope } from '@vue/reactivity' - import { EMPTY_OBJ, isFunction } from '@vue/shared' -import type { Block } from './render' +import type { Block } from './apiRender' import type { DirectiveBinding } from './directives' import { type ComponentPropsOptions, type NormalizedPropsOptions, + type NormalizedRawProps, + type RawProps, initProps, normalizePropsOptions, } from './componentProps' @@ -37,33 +38,29 @@ type LifecycleHook = TFn[] | null export interface ComponentInternalInstance { uid: number - container: ParentNode + vapor: true + block: Block | null + container: ParentNode + parent: ComponentInternalInstance | null + scope: EffectScope component: FunctionalComponent | ObjectComponent + comps: Set + dirs: Map - // TODO: ExtraProps: key, ref, ... - rawProps: { [key: string]: any } - - // normalized options + rawProps: NormalizedRawProps propsOptions: NormalizedPropsOptions emitsOptions: ObjectEmitsOptions | null - parent: ComponentInternalInstance | null - // state - props: Data - attrs: Data setupState: Data + props: Data emit: EmitFn emitted: Record | null + attrs: Data refs: Data - vapor: true - - /** directives */ - dirs: Map - // lifecycle isMounted: boolean isUnmounted: boolean @@ -141,37 +138,37 @@ export const unsetCurrentInstance = () => { } let uid = 0 -export const createComponentInstance = ( +export function createComponentInstance( component: ObjectComponent | FunctionalComponent, - rawProps: Data, -): ComponentInternalInstance => { + rawProps: RawProps | null, +): ComponentInternalInstance { const instance: ComponentInternalInstance = { uid: uid++, + vapor: true, + block: null, - container: null!, // set on mountComponent - scope: new EffectScope(true /* detached */)!, - component, - rawProps, + container: null!, - // TODO: registory of parent + // TODO parent: null, + scope: new EffectScope(true /* detached */)!, + component, + comps: new Set(), + dirs: new Map(), + // resolved props and emits options + rawProps: null!, // set later propsOptions: normalizePropsOptions(component), emitsOptions: normalizeEmitsOptions(component), - // emit - emit: null!, // to be set immediately - emitted: null, - // state + setupState: EMPTY_OBJ, props: EMPTY_OBJ, + emit: null!, + emitted: null, attrs: EMPTY_OBJ, - setupState: EMPTY_OBJ, refs: EMPTY_OBJ, - vapor: true, - - dirs: new Map(), // lifecycle isMounted: false, @@ -227,8 +224,6 @@ export const createComponentInstance = ( */ // [VaporLifecycleHooks.SERVER_PREFETCH]: null, } - - // TODO init first initProps(instance, rawProps, !isFunction(component)) instance.emit = emit.bind(null, instance) diff --git a/packages/runtime-vapor/src/componentAttrs.ts b/packages/runtime-vapor/src/componentAttrs.ts new file mode 100644 index 000000000..8eabb0449 --- /dev/null +++ b/packages/runtime-vapor/src/componentAttrs.ts @@ -0,0 +1,44 @@ +import { camelize, isFunction } from '@vue/shared' +import type { ComponentInternalInstance } from './component' +import { isEmitListener } from './componentEmits' + +export function patchAttrs(instance: ComponentInternalInstance) { + const attrs = instance.attrs + const options = instance.propsOptions[0] + + const keys = new Set() + if (instance.rawProps.length) + for (const props of Array.from(instance.rawProps).reverse()) { + if (isFunction(props)) { + const resolved = props() + for (const rawKey in resolved) { + registerAttr(rawKey, () => resolved[rawKey]) + } + } else { + for (const rawKey in props) { + registerAttr(rawKey, props[rawKey]) + } + } + } + + for (const key in attrs) { + if (!keys.has(key)) { + delete attrs[key] + } + } + + function registerAttr(key: string, getter: () => unknown) { + if ( + (!options || !(camelize(key) in options)) && + !isEmitListener(instance.emitsOptions, key) + ) { + keys.add(key) + if (key in attrs) return + Object.defineProperty(attrs, key, { + get: getter, + enumerable: true, + configurable: true, + }) + } + } +} diff --git a/packages/runtime-vapor/src/componentEmits.ts b/packages/runtime-vapor/src/componentEmits.ts index 138097c81..bbca2044c 100644 --- a/packages/runtime-vapor/src/componentEmits.ts +++ b/packages/runtime-vapor/src/componentEmits.ts @@ -1,5 +1,8 @@ // NOTE: runtime-core/src/componentEmits.ts +// TODO WIP +// @ts-nocheck + import { EMPTY_OBJ, type UnionToIntersection, @@ -45,6 +48,8 @@ export function emit( ...rawArgs: any[] ) { if (instance.isUnmounted) return + // TODO + // @ts-expect-error const { rawProps } = instance let args = rawArgs diff --git a/packages/runtime-vapor/src/componentLifecycle.ts b/packages/runtime-vapor/src/componentLifecycle.ts new file mode 100644 index 000000000..2dd94b1d5 --- /dev/null +++ b/packages/runtime-vapor/src/componentLifecycle.ts @@ -0,0 +1,34 @@ +import { invokeArrayFns } from '@vue/shared' +import type { VaporLifecycleHooks } from './apiLifecycle' +import { type ComponentInternalInstance, setCurrentInstance } from './component' +import { queuePostRenderEffect } from './scheduler' +import { type DirectiveHookName, invokeDirectiveHook } from './directives' + +export function invokeLifecycle( + instance: ComponentInternalInstance, + lifecycle: VaporLifecycleHooks, + directive: DirectiveHookName, + post?: boolean, +) { + invokeArrayFns(post ? [invokeSub, invokeCurrent] : [invokeCurrent, invokeSub]) + + function invokeCurrent() { + const hooks = instance[lifecycle] + if (hooks) { + const fn = () => { + const reset = setCurrentInstance(instance) + invokeArrayFns(hooks) + reset() + } + post ? queuePostRenderEffect(fn) : fn() + } + + invokeDirectiveHook(instance, directive) + } + + function invokeSub() { + instance.comps.forEach(comp => + invokeLifecycle(comp, lifecycle, directive, post), + ) + } +} diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index 5950a57a1..c1ebdf9ef 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -1,5 +1,3 @@ -// NOTE: runtime-core/src/componentProps.ts - import { type Data, EMPTY_ARR, @@ -11,14 +9,15 @@ import { isArray, isFunction, } from '@vue/shared' -import { shallowReactive, shallowReadonly, toRaw } from '@vue/reactivity' +import { baseWatch, shallowReactive } from '@vue/reactivity' import { warn } from './warning' import { type Component, type ComponentInternalInstance, setCurrentInstance, } from './component' -import { isEmitListener } from './componentEmits' +import { patchAttrs } from './componentAttrs' +import { createVaporPreScheduler } from './scheduler' export type ComponentPropsOptions

= | ComponentObjectPropsOptions

@@ -69,108 +68,129 @@ type NormalizedProp = }) export type NormalizedProps = Record -export type NormalizedPropsOptions = [NormalizedProps, string[]] | [] +export type NormalizedPropsOptions = + | [props: NormalizedProps, needCastKeys: string[]] + | [] + +type StaticProps = Record unknown> +type DynamicProps = () => Data +export type NormalizedRawProps = Array +export type RawProps = NormalizedRawProps | StaticProps | null export function initProps( instance: ComponentInternalInstance, - rawProps: Data | null, + rawProps: RawProps, isStateful: boolean, ) { const props: Data = {} - const attrs: Data = {} + const attrs = (instance.attrs = shallowReactive({})) - const [options, needCastKeys] = instance.propsOptions - let hasAttrsChanged = false - let rawCastValues: Data | undefined - if (rawProps) { - for (let key in rawProps) { - const valueGetter = () => rawProps[key] - let camelKey - if (options && hasOwn(options, (camelKey = camelize(key)))) { - if (!needCastKeys || !needCastKeys.includes(camelKey)) { - // NOTE: must getter - // props[camelKey] = value - Object.defineProperty(props, camelKey, { - get() { - return valueGetter() - }, - enumerable: true, - }) - } else { - // NOTE: must getter - // ;(rawCastValues || (rawCastValues = {}))[camelKey] = value - rawCastValues || (rawCastValues = {}) - Object.defineProperty(rawCastValues, camelKey, { - get() { - return valueGetter() - }, - enumerable: true, - }) - } - } else if (!isEmitListener(instance.emitsOptions, key)) { - // if (!(key in attrs) || value !== attrs[key]) { - if (!(key in attrs)) { - // NOTE: must getter - // attrs[key] = value - Object.defineProperty(attrs, key, { - get() { - return valueGetter() - }, - enumerable: true, - }) - hasAttrsChanged = true - } - } - } - } + if (!rawProps) rawProps = [] + else if (!isArray(rawProps)) rawProps = [rawProps] + instance.rawProps = rawProps - if (needCastKeys) { - const rawCurrentProps = toRaw(props) - const castValues = rawCastValues || EMPTY_OBJ - for (let i = 0; i < needCastKeys.length; i++) { - const key = needCastKeys[i] - - // NOTE: must getter - // props[key] = resolvePropValue( - // options!, - // rawCurrentProps, - // key, - // castValues[key], - // instance, - // !hasOwn(castValues, key), - // ) - Object.defineProperty(props, key, { - get() { - return resolvePropValue( - options!, - rawCurrentProps, - key, - castValues[key], + const [options] = instance.propsOptions + + const hasDynamicProps = rawProps.some(isFunction) + if (options) { + if (hasDynamicProps) { + for (const key in options) { + const getter = () => + getDynamicPropValue(rawProps as NormalizedRawProps, key) + registerProp(instance, props, key, getter, true) + } + } else { + for (const key in options) { + const rawKey = rawProps[0] && getRawKey(rawProps[0] as StaticProps, key) + if (rawKey) { + registerProp( instance, - !hasOwn(castValues, key), + props, + key, + (rawProps[0] as StaticProps)[rawKey], ) - }, - }) + } else { + registerProp(instance, props, key, undefined, false, true) + } + } } } // validation if (__DEV__) { - validateProps(rawProps || {}, props, instance) + validateProps(rawProps, props, options || {}) + } + + if (hasDynamicProps) { + baseWatch(() => patchAttrs(instance), undefined, { + scheduler: createVaporPreScheduler(instance), + }) + } else { + patchAttrs(instance) } if (isStateful) { - instance.props = shallowReactive(props) + instance.props = /* isSSR ? props : */ shallowReactive(props) } else { - if (instance.propsOptions === EMPTY_ARR) { - instance.props = attrs + // functional w/ optional props, props === attrs + instance.props = instance.propsOptions === EMPTY_ARR ? attrs : props + } +} + +function registerProp( + instance: ComponentInternalInstance, + props: Data, + rawKey: string, + getter?: (() => unknown) | (() => DynamicPropResult), + isDynamic?: boolean, + isAbsent?: boolean, +) { + const key = camelize(rawKey) + if (key in props) return + + const [options, needCastKeys] = instance.propsOptions + const needCast = needCastKeys && needCastKeys.includes(key) + const withCast = (value: unknown, absent?: boolean) => + resolvePropValue(options!, props, key, value, instance, absent) + + if (isAbsent) { + props[key] = needCast ? withCast(undefined, true) : undefined + } else { + const get: () => unknown = isDynamic + ? needCast + ? () => withCast(...(getter!() as DynamicPropResult)) + : () => (getter!() as DynamicPropResult)[0] + : needCast + ? () => withCast(getter!()) + : getter! + + Object.defineProperty(props, key, { + get, + enumerable: true, + }) + } +} + +function getRawKey(obj: Data, key: string) { + return Object.keys(obj).find(k => camelize(k) === key) +} + +type DynamicPropResult = [value: unknown, absent: boolean] +function getDynamicPropValue( + rawProps: NormalizedRawProps, + key: string, +): DynamicPropResult { + for (const props of Array.from(rawProps).reverse()) { + if (isFunction(props)) { + const resolved = props() + const rawKey = getRawKey(resolved, key) + if (rawKey) return [resolved[rawKey], false] } else { - instance.props = props + const rawKey = getRawKey(props, key) + if (rawKey) return [props[rawKey](), false] } } - instance.attrs = attrs - - return hasAttrsChanged + return [undefined, true] } function resolvePropValue( @@ -179,7 +199,7 @@ function resolvePropValue( key: string, value: unknown, instance: ComponentInternalInstance, - isAbsent: boolean, + isAbsent?: boolean, ) { const opt = options[key] if (opt != null) { @@ -223,12 +243,12 @@ function resolvePropValue( export function normalizePropsOptions(comp: Component): NormalizedPropsOptions { // TODO: cahching? - const raw = comp.props as any - const normalized: NormalizedPropsOptions[0] = {} + const raw = comp.props + const normalized: NormalizedProps | undefined = {} const needCastKeys: NormalizedPropsOptions[1] = [] if (!raw) { - return EMPTY_ARR as any + return EMPTY_ARR as [] } if (isArray(raw)) { @@ -238,7 +258,7 @@ export function normalizePropsOptions(comp: Component): NormalizedPropsOptions { normalized[normalizedKey] = EMPTY_OBJ } } - } else if (raw) { + } else { for (const key in raw) { const normalizedKey = camelize(key) if (validatePropName(normalizedKey)) { @@ -267,6 +287,8 @@ export function normalizePropsOptions(comp: Component): NormalizedPropsOptions { function validatePropName(key: string) { if (key[0] !== '$') { return true + } else if (__DEV__) { + warn(`Invalid prop name: "${key}" is a reserved property.`) } return false } @@ -296,22 +318,25 @@ function getTypeIndex( * dev only */ function validateProps( - rawProps: Data, + rawProps: NormalizedRawProps, props: Data, - instance: ComponentInternalInstance, + options: NormalizedProps, ) { - const resolvedValues = toRaw(props) - const options = instance.propsOptions[0] + const presentKeys: string[] = [] + for (const props of rawProps) { + presentKeys.push(...Object.keys(isFunction(props) ? props() : props)) + } + for (const key in options) { - let opt = options[key] - if (opt == null) continue - validateProp( - key, - resolvedValues[key], - opt, - __DEV__ ? shallowReadonly(resolvedValues) : resolvedValues, - !hasOwn(rawProps, key) && !hasOwn(rawProps, hyphenate(key)), - ) + const opt = options[key] + if (opt != null) + validateProp( + key, + props[key], + opt, + props, + !presentKeys.some(k => camelize(k) === key), + ) } } @@ -321,11 +346,11 @@ function validateProps( function validateProp( name: string, value: unknown, - prop: PropOptions, + option: PropOptions, props: Data, isAbsent: boolean, ) { - const { required, validator } = prop + const { required, validator } = option // required! if (required && isAbsent) { warn('Missing required prop: "' + name + '"') diff --git a/packages/runtime-vapor/src/directives.ts b/packages/runtime-vapor/src/directives.ts index 9bff0a8eb..15d2119c4 100644 --- a/packages/runtime-vapor/src/directives.ts +++ b/packages/runtime-vapor/src/directives.ts @@ -145,7 +145,3 @@ function callDirectiveHook( ]) resetTracking() } - -export function resolveDirective() { - // TODO -} diff --git a/packages/runtime-vapor/src/dom/element.ts b/packages/runtime-vapor/src/dom/element.ts index d6d0dfa58..63c515d4a 100644 --- a/packages/runtime-vapor/src/dom/element.ts +++ b/packages/runtime-vapor/src/dom/element.ts @@ -1,5 +1,5 @@ import { isArray, toDisplayString } from '@vue/shared' -import type { Block } from '../render' +import type { Block } from '../apiRender' /*! #__NO_SIDE_EFFECTS__ */ export function normalizeBlock(block: Block): Node[] { diff --git a/packages/runtime-vapor/src/helpers/resolveAssets.ts b/packages/runtime-vapor/src/helpers/resolveAssets.ts new file mode 100644 index 000000000..35ae0bb9a --- /dev/null +++ b/packages/runtime-vapor/src/helpers/resolveAssets.ts @@ -0,0 +1,7 @@ +export function resolveComponent() { + // TODO +} + +export function resolveDirective() { + // TODO +} diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index f3891c4a4..2ce9a8360 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -46,7 +46,6 @@ export { type FunctionalComponent, type SetupFn, } from './component' -export { render, unmountComponent } from './render' export { renderEffect, renderWatch } from './renderWatch' export { watch, @@ -62,7 +61,6 @@ export { } from './apiWatch' export { withDirectives, - resolveDirective, type Directive, type DirectiveBinding, type DirectiveHook, @@ -88,7 +86,6 @@ export { on, delegate, delegateEvents, setDynamicEvents } from './dom/event' export { setRef } from './dom/templateRef' export { defineComponent } from './apiDefineComponent' -export { createComponentInstance } from './component' export { onBeforeMount, onMounted, @@ -103,8 +100,17 @@ export { onErrorCaptured, // onServerPrefetch, } from './apiLifecycle' +export { + createVaporApp, + type App, + type AppConfig, + type AppContext, +} from './apiCreateVaporApp' export { createIf } from './apiCreateIf' export { createFor } from './apiCreateFor' +export { createComponent } from './apiCreateComponent' + +export { resolveComponent, resolveDirective } from './helpers/resolveAssets' // **Internal** DOM-only runtime directive helpers export { diff --git a/packages/sfc-playground/src/App.vue b/packages/sfc-playground/src/App.vue index a3525036c..1f0f3e62e 100644 --- a/packages/sfc-playground/src/App.vue +++ b/packages/sfc-playground/src/App.vue @@ -120,9 +120,9 @@ watch( files.value['src/index.html'] = new File( 'src/index.html', ` + + diff --git a/playground/src/main.ts b/playground/src/main.ts index e8f26d40f..58677200c 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -1,4 +1,4 @@ -import { createComponentInstance, render, unmountComponent } from 'vue/vapor' +import { createVaporApp } from 'vue/vapor' import { createApp } from 'vue' import './style.css' @@ -6,14 +6,11 @@ const modules = import.meta.glob('./**/*.(vue|js)') const mod = (modules['.' + location.pathname] || modules['./App.vue'])() mod.then(({ default: mod }) => { - if (mod.vapor) { - const instance = createComponentInstance(mod, {}) - render(instance, '#app') - // @ts-expect-error - globalThis.unmount = () => { - unmountComponent(instance) - } - } else { - createApp(mod).mount('#app') + const app = (mod.vapor ? createVaporApp : createApp)(mod) + app.mount('#app') + + // @ts-expect-error + globalThis.unmount = () => { + app.unmount() } }) diff --git a/playground/src/props.js b/playground/src/props.js index 8da476cc8..8127cc3a0 100644 --- a/playground/src/props.js +++ b/playground/src/props.js @@ -1,69 +1,53 @@ // @ts-check import { - children, + createComponent, defineComponent, on, + reactive, ref, - render as renderComponent, + renderEffect, setText, template, - watch, - watchEffect, } from '@vue/vapor' const t0 = template('') export default defineComponent({ vapor: true, - props: undefined, - - setup(_, {}) { + setup() { const count = ref(1) + const props = reactive({ + a: 'b', + 'foo-bar': 100, + }) const handleClick = () => { count.value++ + props['foo-bar']++ + // @ts-expect-error + props.boolean = true + console.log(count) } - const __returned__ = { count, handleClick } - - Object.defineProperty(__returned__, '__isScriptSetup', { - enumerable: false, - value: true, - }) - - return __returned__ - }, - - render(_ctx) { - const n0 = t0() - const n1 = /** @type {HTMLButtonElement} */ (children(n0, 0)) - on(n1, 'click', () => _ctx.handleClick) - watchEffect(() => { - setText(n1, _ctx.count) - }) - - // TODO: create component fn? - // const c0 = createComponent(...) - // insert(n0, c0) - renderComponent( - /** @type {any} */ (child), - - // TODO: proxy?? - { - /* */ - get count() { - return _ctx.count - }, - - /* */ - get inlineDouble() { - return _ctx.count * 2 + return (() => { + const n0 = /** @type {HTMLButtonElement} */ (t0()) + on(n0, 'click', () => handleClick) + renderEffect(() => setText(n0, count.value)) + /** @type {any} */ + const n1 = createComponent(child, [ + { + /* */ + count: () => { + // console.trace('access') + return count.value + }, + /* */ + inlineDouble: () => count.value * 2, + id: () => 'hello', }, - }, - // @ts-expect-error TODO - n0[0], - ) - - return n0 + () => props, + ]) + return [n0, n1] + })() }, }) @@ -74,25 +58,22 @@ const child = defineComponent({ props: { count: { type: Number, default: 1 }, inlineDouble: { type: Number, default: 2 }, + fooBar: { type: Number, required: true }, + boolean: { type: Boolean }, }, - setup(props) { - watch( - () => props.count, - v => console.log('count changed', v), - ) - watch( - () => props.inlineDouble, - v => console.log('inlineDouble changed', v), - ) - }, + setup(props, { attrs }) { + console.log(props, { ...props }) + console.log(attrs, { ...attrs }) - render(_ctx) { - const n0 = t1() - const n1 = children(n0, 0) - watchEffect(() => { - setText(n1, void 0, _ctx.count + ' * 2 = ' + _ctx.inlineDouble) - }) - return n0 + return (() => { + const n0 = /** @type {HTMLParagraphElement} */ (t1()) + renderEffect(() => + setText(n0, props.count + ' * 2 = ' + props.inlineDouble), + ) + const n1 = /** @type {HTMLParagraphElement} */ (t1()) + renderEffect(() => setText(n1, props.fooBar, ', ', props.boolean)) + return [n0, n1] + })() }, }) diff --git a/playground/src/sub-comp.vue b/playground/src/sub-comp.vue new file mode 100644 index 000000000..33e7ebd64 --- /dev/null +++ b/playground/src/sub-comp.vue @@ -0,0 +1,36 @@ + + +