diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index 8fb727849..4230a3b1a 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -67,10 +67,10 @@ import { extend, getGlobalThis, isArray, + isBuiltInTag, isFunction, isObject, isPromise, - makeMap, } from '@vue/shared' import type { Data } from '@vue/runtime-shared' import type { SuspenseBoundary } from './components/Suspense' @@ -761,8 +761,6 @@ export const unsetCurrentInstance = () => { internalSetCurrentInstance(null) } -const isBuiltInTag = /*#__PURE__*/ makeMap('slot,component') - export function validateComponentName( name: string, { isNativeTag }: AppConfig, diff --git a/packages/runtime-vapor/__tests__/helpers/resolveAssets.spec.ts b/packages/runtime-vapor/__tests__/helpers/resolveAssets.spec.ts new file mode 100644 index 000000000..366edc83d --- /dev/null +++ b/packages/runtime-vapor/__tests__/helpers/resolveAssets.spec.ts @@ -0,0 +1,104 @@ +import { + type Component, + type Directive, + createVaporApp, + resolveComponent, + resolveDirective, +} from '@vue/runtime-vapor' +import { makeRender } from '../_utils' + +const define = makeRender() + +describe('resolveAssets', () => { + test('todo', () => { + expect(true).toBeTruthy() + }) + test('should work', () => { + const FooBar = () => [] + const BarBaz = { mounted: () => null } + let component1: Component | string + let component2: Component | string + let component3: Component | string + let component4: Component | string + let directive1: Directive + let directive2: Directive + let directive3: Directive + let directive4: Directive + const Root = define({ + render() { + component1 = resolveComponent('FooBar')! + directive1 = resolveDirective('BarBaz')! + // camelize + component2 = resolveComponent('Foo-bar')! + directive2 = resolveDirective('Bar-baz')! + // capitalize + component3 = resolveComponent('fooBar')! + directive3 = resolveDirective('barBaz')! + // camelize and capitalize + component4 = resolveComponent('foo-bar')! + directive4 = resolveDirective('bar-baz')! + return [] + }, + }) + const app = createVaporApp(Root.component) + app.component('FooBar', FooBar) + app.directive('BarBaz', BarBaz) + const root = document.createElement('div') + app.mount(root) + expect(component1!).toBe(FooBar) + expect(component2!).toBe(FooBar) + expect(component3!).toBe(FooBar) + expect(component4!).toBe(FooBar) + expect(directive1!).toBe(BarBaz) + expect(directive2!).toBe(BarBaz) + expect(directive3!).toBe(BarBaz) + expect(directive4!).toBe(BarBaz) + }) + test('maybeSelfReference', async () => { + let component1: Component | string + let component2: Component | string + let component3: Component | string + const Foo = () => [] + const Root = define({ + name: 'Root', + render() { + component1 = resolveComponent('Root', true) + component2 = resolveComponent('Foo', true) + component3 = resolveComponent('Bar', true) + return [] + }, + }) + const app = createVaporApp(Root.component) + app.component('Foo', Foo) + const root = document.createElement('div') + app.mount(root) + expect(component1!).toMatchObject(Root.component) // explicit self name reference + expect(component2!).toBe(Foo) // successful resolve take higher priority + expect(component3!).toMatchObject(Root.component) // fallback when resolve fails + }) + describe('warning', () => { + test('used outside render() or setup()', () => { + resolveComponent('foo') + expect( + '[Vue warn]: resolveComponent can only be used in render() or setup().', + ).toHaveBeenWarned() + resolveDirective('foo') + expect( + '[Vue warn]: resolveDirective can only be used in render() or setup().', + ).toHaveBeenWarned() + }) + test('not exist', () => { + const Root = define({ + setup() { + resolveComponent('foo') + resolveDirective('bar') + }, + }) + const app = createVaporApp(Root.component) + const root = document.createElement('div') + app.mount(root) + expect('Failed to resolve component: foo').toHaveBeenWarned() + expect('Failed to resolve directive: bar').toHaveBeenWarned() + }) + }) +}) diff --git a/packages/runtime-vapor/src/apiCreateVaporApp.ts b/packages/runtime-vapor/src/apiCreateVaporApp.ts index 0f1afef78..e61481b2a 100644 --- a/packages/runtime-vapor/src/apiCreateVaporApp.ts +++ b/packages/runtime-vapor/src/apiCreateVaporApp.ts @@ -1,14 +1,16 @@ -import { isFunction, isObject } from '@vue/shared' +import { NO, isFunction, isObject } from '@vue/shared' import { type Component, type ComponentInternalInstance, createComponentInstance, + validateComponentName, } from './component' import { warn } from './warning' -import { version } from '.' +import { type Directive, version } from '.' import { render, setupComponent, unmountComponent } from './apiRender' import type { InjectionKey } from './apiInject' import type { RawProps } from './componentProps' +import { validateDirectiveName } from './directives' export function createVaporApp( rootComponent: Component, @@ -60,6 +62,35 @@ export function createVaporApp( return app }, + component(name: string, component?: Component): any { + if (__DEV__) { + validateComponentName(name, context.config) + } + if (!component) { + return context.components[name] + } + if (__DEV__ && context.components[name]) { + warn(`Component "${name}" has already been registered in target app.`) + } + context.components[name] = component + return app + }, + + directive(name: string, directive?: Directive) { + if (__DEV__) { + validateDirectiveName(name) + } + + if (!directive) { + return context.directives[name] as any + } + if (__DEV__ && context.directives[name]) { + warn(`Directive "${name}" has already been registered in target app.`) + } + context.directives[name] = directive + return app + }, + mount(rootContainer): any { if (!instance) { instance = createComponentInstance( @@ -119,11 +150,14 @@ export function createAppContext(): AppContext { return { app: null as any, config: { + isNativeTag: NO, errorHandler: undefined, warnHandler: undefined, globalProperties: {}, }, provides: Object.create(null), + components: {}, + directives: {}, } } @@ -151,6 +185,11 @@ export interface App { ): this use(plugin: Plugin, options: Options): this + component(name: string): Component | undefined + component(name: string, component: T): this + directive(name: string): Directive | undefined + directive(name: string, directive: Directive): this + mount( rootContainer: ParentNode | string, isHydrate?: boolean, @@ -163,6 +202,9 @@ export interface App { } export interface AppConfig { + // @private + readonly isNativeTag: (tag: string) => boolean + errorHandler?: ( err: unknown, instance: ComponentInternalInstance | null, @@ -180,6 +222,17 @@ export interface AppContext { app: App // for devtools config: AppConfig provides: Record + + /** + * Resolved component registry, only for components with mixins or extends + * @internal + */ + components: Record + /** + * Resolved directive registry, only for components with mixins or extends + * @internal + */ + directives: Record } /** diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 28ca6c2ab..34a1dd210 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -1,5 +1,11 @@ import { isRef } from '@vue/reactivity' -import { EMPTY_OBJ, hasOwn, isArray, isFunction } from '@vue/shared' +import { + EMPTY_OBJ, + hasOwn, + isArray, + isBuiltInTag, + isFunction, +} from '@vue/shared' import type { Block } from './apiRender' import { type ComponentPropsOptions, @@ -24,7 +30,11 @@ import { } from './componentSlots' import { VaporLifecycleHooks } from './apiLifecycle' import { warn } from './warning' -import { type AppContext, createAppContext } from './apiCreateVaporApp' +import { + type AppConfig, + type AppContext, + createAppContext, +} from './apiCreateVaporApp' import type { Data } from '@vue/runtime-shared' import { BlockEffectScope } from './blockEffectScope' @@ -233,7 +243,6 @@ export interface ComponentInternalInstance { // [VaporLifecycleHooks.SERVER_PREFETCH]: LifecycleHook<() => Promise> } -// TODO export let currentInstance: ComponentInternalInstance | null = null export const getCurrentInstance: () => ComponentInternalInstance | null = () => @@ -256,7 +265,7 @@ const emptyAppContext = createAppContext() let uid = 0 export function createComponentInstance( - component: ObjectComponent | FunctionalComponent, + component: Component, rawProps: RawProps | null, slots: Slots | null, dynamicSlots: DynamicSlots | null, @@ -367,6 +376,17 @@ export function isVaporComponent( return !!val && hasOwn(val, componentKey) } +export function validateComponentName( + name: string, + { isNativeTag }: AppConfig, +) { + if (isBuiltInTag(name) || isNativeTag(name)) { + warn( + 'Do not use built-in or reserved HTML elements as component id: ' + name, + ) + } +} + function getAttrsProxy(instance: ComponentInternalInstance): Data { return ( instance.attrsProxy || diff --git a/packages/runtime-vapor/src/directives.ts b/packages/runtime-vapor/src/directives.ts index 3ec374623..30ea1012f 100644 --- a/packages/runtime-vapor/src/directives.ts +++ b/packages/runtime-vapor/src/directives.ts @@ -1,4 +1,4 @@ -import { invokeArrayFns, isFunction } from '@vue/shared' +import { invokeArrayFns, isBuiltInDirective, isFunction } from '@vue/shared' import { type ComponentInternalInstance, currentInstance, @@ -72,6 +72,12 @@ export type Directive = | ObjectDirective | FunctionDirective +export function validateDirectiveName(name: string) { + if (isBuiltInDirective(name)) { + warn('Do not use built-in directive ids as custom directive id: ' + name) + } +} + export type DirectiveArguments = Array< | [Directive | undefined] | [Directive | undefined, () => any] diff --git a/packages/runtime-vapor/src/helpers/resolveAssets.ts b/packages/runtime-vapor/src/helpers/resolveAssets.ts index 35ae0bb9a..18eba78cb 100644 --- a/packages/runtime-vapor/src/helpers/resolveAssets.ts +++ b/packages/runtime-vapor/src/helpers/resolveAssets.ts @@ -1,7 +1,95 @@ -export function resolveComponent() { - // TODO +import { camelize, capitalize } from '@vue/shared' +import { type Directive, warn } from '..' +import { type Component, currentInstance } from '../component' +import { getComponentName } from '../warning' + +export const COMPONENTS = 'components' +export const DIRECTIVES = 'directives' + +export type AssetTypes = typeof COMPONENTS | typeof DIRECTIVES + +export function resolveComponent(name: string, maybeSelfReference?: boolean) { + return resolveAsset(COMPONENTS, name, true, maybeSelfReference) || name } -export function resolveDirective() { - // TODO +export function resolveDirective(name: string) { + return resolveAsset(DIRECTIVES, name) +} + +/** + * @private + * overload 1: components + */ +function resolveAsset( + type: typeof COMPONENTS, + name: string, + warnMissing?: boolean, + maybeSelfReference?: boolean, +): Component | undefined +// overload 2: directives +function resolveAsset( + type: typeof DIRECTIVES, + name: string, +): Directive | undefined +// implementation +function resolveAsset( + type: AssetTypes, + name: string, + warnMissing = true, + maybeSelfReference = false, +) { + const instance = currentInstance + if (instance) { + const Component = instance.component + + // explicit self name has highest priority + if (type === COMPONENTS) { + const selfName = getComponentName( + Component, + false /* do not include inferred name to avoid breaking existing code */, + ) + if ( + selfName && + (selfName === name || + selfName === camelize(name) || + selfName === capitalize(camelize(name))) + ) { + return Component + } + } + + const res = + // global registration + resolve(instance.appContext[type], name) + + if (!res && maybeSelfReference) { + // fallback to implicit self-reference + return Component + } + + if (__DEV__ && warnMissing && !res) { + const extra = + type === COMPONENTS + ? `\nIf this is a native custom element, make sure to exclude it from ` + + `component resolution via compilerOptions.isCustomElement.` + : `` + warn(`Failed to resolve ${type.slice(0, -1)}: ${name}${extra}`) + } + + return res + } else if (__DEV__) { + warn( + `resolve${capitalize(type.slice(0, -1))} ` + + `can only be used in render() or setup().`, + ) + } +} + +function resolve(registry: Record | undefined, name: string) { + return ( + registry && + (registry[name] || + registry[camelize(name)] || + registry[capitalize(camelize(name))]) + ) } diff --git a/packages/shared/src/general.ts b/packages/shared/src/general.ts index 93e99b4b5..028fa596a 100644 --- a/packages/shared/src/general.ts +++ b/packages/shared/src/general.ts @@ -93,6 +93,8 @@ export const isReservedProp = /*#__PURE__*/ makeMap( 'onVnodeBeforeUnmount,onVnodeUnmounted', ) +export const isBuiltInTag = /*#__PURE__*/ makeMap('slot,component') + export const isBuiltInDirective = /*#__PURE__*/ makeMap( 'bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo', )