diff --git a/packages/runtime-vapor/__tests__/apiInject.spec.ts b/packages/runtime-vapor/__tests__/apiInject.spec.ts new file mode 100644 index 000000000..1b0b35cd3 --- /dev/null +++ b/packages/runtime-vapor/__tests__/apiInject.spec.ts @@ -0,0 +1,397 @@ +// NOTE: This test is implemented based on the case of `runtime-core/__test__/apiInject.spec.ts`. + +import { + type InjectionKey, + type Ref, + createComponent, + createTextNode, + createVaporApp, + getCurrentInstance, + hasInjectionContext, + inject, + nextTick, + provide, + reactive, + readonly, + ref, + renderEffect, + setText, +} from '../src' +import { makeRender } from './_utils' + +const define = makeRender() + +// reference: https://vue-composition-api-rfc.netlify.com/api.html#provide-inject +describe('api: provide/inject', () => { + it('string keys', () => { + const Provider = define({ + setup() { + provide('foo', 1) + return createComponent(Middle) + }, + }) + + const Middle = { + render() { + return createComponent(Consumer) + }, + } + + const Consumer = { + setup() { + const foo = inject('foo') + return (() => { + const n0 = createTextNode() + setText(n0, foo) + return n0 + })() + }, + } + + Provider.render() + expect(Provider.host.innerHTML).toBe('1') + }) + + it('symbol keys', () => { + // also verifies InjectionKey type sync + const key: InjectionKey = Symbol() + + const Provider = define({ + setup() { + provide(key, 1) + return createComponent(Middle) + }, + }) + + const Middle = { + render: () => createComponent(Consumer), + } + + const Consumer = { + setup() { + const foo = inject(key) + return (() => { + const n0 = createTextNode() + setText(n0, foo) + return n0 + })() + }, + } + + Provider.render() + expect(Provider.host.innerHTML).toBe('1') + }) + + it('default values', () => { + const Provider = define({ + setup() { + provide('foo', 'foo') + return createComponent(Middle) + }, + }) + + const Middle = { + render: () => createComponent(Consumer), + } + + const Consumer = { + setup() { + // default value should be ignored if value is provided + const foo = inject('foo', 'fooDefault') + // default value should be used if value is not provided + const bar = inject('bar', 'bar') + return (() => { + const n0 = createTextNode() + setText(n0, foo + bar) + return n0 + })() + }, + } + + Provider.render() + expect(Provider.host.innerHTML).toBe('foobar') + }) + + // NOTE: Options API is not supported + // it('bound to instance', () => {}) + + it('nested providers', () => { + const ProviderOne = define({ + setup() { + provide('foo', 'foo') + provide('bar', 'bar') + return createComponent(ProviderTwo) + }, + }) + + const ProviderTwo = { + setup() { + // override parent value + provide('foo', 'fooOverride') + provide('baz', 'baz') + return createComponent(Consumer) + }, + } + + const Consumer = { + setup() { + const foo = inject('foo') + const bar = inject('bar') + const baz = inject('baz') + return (() => { + const n0 = createTextNode() + setText(n0, [foo, bar, baz].join(',')) + return n0 + })() + }, + } + + ProviderOne.render() + expect(ProviderOne.host.innerHTML).toBe('fooOverride,bar,baz') + }) + + it('reactivity with refs', async () => { + const count = ref(1) + + const Provider = define({ + setup() { + provide('count', count) + return createComponent(Middle) + }, + }) + + const Middle = { + render: () => createComponent(Consumer), + } + + const Consumer = { + setup() { + const count = inject>('count')! + return (() => { + const n0 = createTextNode() + renderEffect(() => { + setText(n0, count.value) + }) + return n0 + })() + }, + } + + Provider.render() + expect(Provider.host.innerHTML).toBe('1') + + count.value++ + await nextTick() + expect(Provider.host.innerHTML).toBe('2') + }) + + it('reactivity with readonly refs', async () => { + const count = ref(1) + + const Provider = define({ + setup() { + provide('count', readonly(count)) + return createComponent(Middle) + }, + }) + + const Middle = { + render: () => createComponent(Consumer), + } + + const Consumer = { + setup() { + const count = inject>('count')! + // should not work + count.value++ + return (() => { + const n0 = createTextNode() + renderEffect(() => { + setText(n0, count.value) + }) + return n0 + })() + }, + } + + Provider.render() + expect(Provider.host.innerHTML).toBe('1') + + expect( + `Set operation on key "value" failed: target is readonly`, + ).toHaveBeenWarned() + + count.value++ + await nextTick() + expect(Provider.host.innerHTML).toBe('2') + }) + + it('reactivity with objects', async () => { + const rootState = reactive({ count: 1 }) + + const Provider = define({ + setup() { + provide('state', rootState) + return createComponent(Middle) + }, + }) + + const Middle = { + render: () => createComponent(Consumer), + } + + const Consumer = { + setup() { + const state = inject('state')! + return (() => { + const n0 = createTextNode() + renderEffect(() => { + setText(n0, state.count) + }) + return n0 + })() + }, + } + + Provider.render() + expect(Provider.host.innerHTML).toBe('1') + + rootState.count++ + await nextTick() + expect(Provider.host.innerHTML).toBe('2') + }) + + it('reactivity with readonly objects', async () => { + const rootState = reactive({ count: 1 }) + + const Provider = define({ + setup() { + provide('state', readonly(rootState)) + return createComponent(Middle) + }, + }) + + const Middle = { + render: () => createComponent(Consumer), + } + + const Consumer = { + setup() { + const state = inject('state')! + // should not work + state.count++ + return (() => { + const n0 = createTextNode() + renderEffect(() => { + setText(n0, state.count) + }) + return n0 + })() + }, + } + + Provider.render() + expect(Provider.host.innerHTML).toBe('1') + + expect( + `Set operation on key "count" failed: target is readonly`, + ).toHaveBeenWarned() + + rootState.count++ + await nextTick() + expect(Provider.host.innerHTML).toBe('2') + }) + + it('should warn unfound', () => { + const Provider = define({ + setup() { + return createComponent(Middle) + }, + }) + + const Middle = { + render: () => createComponent(Consumer), + } + + const Consumer = { + setup() { + const foo = inject('foo') + expect(foo).toBeUndefined() + return (() => { + const n0 = createTextNode() + setText(n0, foo) + return n0 + })() + }, + } + + Provider.render() + expect(Provider.host.innerHTML).toBe('') + expect(`injection "foo" not found.`).toHaveBeenWarned() + }) + + it('should not warn when default value is undefined', () => { + const Provider = define({ + setup() { + return createComponent(Middle) + }, + }) + + const Middle = { + render: () => createComponent(Consumer), + } + + const Consumer = { + setup() { + const foo = inject('foo', undefined) + return (() => { + const n0 = createTextNode() + setText(n0, foo) + return n0 + })() + }, + } + + Provider.render() + expect(`injection "foo" not found.`).not.toHaveBeenWarned() + }) + + // #2400 + it.todo('should not self-inject', () => { + const Comp = define({ + setup() { + provide('foo', 'foo') + const injection = inject('foo', null) + return () => injection + }, + }) + + Comp.render() + expect(Comp.host.innerHTML).toBe('') + }) + + describe('hasInjectionContext', () => { + it('should be false outside of setup', () => { + expect(hasInjectionContext()).toBe(false) + }) + + it('should be true within setup', () => { + expect.assertions(1) + const Comp = define({ + setup() { + expect(hasInjectionContext()).toBe(true) + return () => null + }, + }) + + Comp.render() + }) + + it('should be true within app.runWithContext()', () => { + expect.assertions(1) + createVaporApp({}).runWithContext(() => { + expect(hasInjectionContext()).toBe(true) + }) + }) + }) +}) diff --git a/packages/runtime-vapor/src/apiCreateVaporApp.ts b/packages/runtime-vapor/src/apiCreateVaporApp.ts index af8e480fd..10f98b472 100644 --- a/packages/runtime-vapor/src/apiCreateVaporApp.ts +++ b/packages/runtime-vapor/src/apiCreateVaporApp.ts @@ -7,6 +7,7 @@ import { import { warn } from './warning' import { version } from '.' import { render, setupComponent, unmountComponent } from './apiRender' +import type { InjectionKey } from './apiInject' import type { RawProps } from './componentProps' export function createVaporApp( @@ -22,6 +23,8 @@ export function createVaporApp( let instance: ComponentInternalInstance const app: App = { + _context: context, + version, get config() { @@ -38,7 +41,7 @@ export function createVaporApp( mount(rootContainer): any { if (!instance) { - instance = createComponentInstance(rootComponent, rootProps) + instance = createComponentInstance(rootComponent, rootProps, context) setupComponent(instance) render(instance, rootContainer) return instance @@ -58,18 +61,40 @@ export function createVaporApp( warn(`Cannot unmount an app that is not mounted.`) } }, + provide(key, value) { + if (__DEV__ && (key as string | symbol) in context.provides) { + warn( + `App already provides property with key "${String(key)}". ` + + `It will be overwritten with the new value.`, + ) + } + + context.provides[key as string | symbol] = value + + return app + }, + runWithContext(fn) { + const lastApp = currentApp + currentApp = app + try { + return fn() + } finally { + currentApp = lastApp + } + }, } return app } -function createAppContext(): AppContext { +export function createAppContext(): AppContext { return { app: null as any, config: { errorHandler: undefined, warnHandler: undefined, }, + provides: Object.create(null), } } @@ -82,6 +107,10 @@ export interface App { isHydrate?: boolean, ): ComponentInternalInstance unmount(): void + provide(key: string | InjectionKey, value: T): App + runWithContext(fn: () => T): T + + _context: AppContext } export interface AppConfig { @@ -100,4 +129,11 @@ export interface AppConfig { export interface AppContext { app: App // for devtools config: AppConfig + provides: Record } + +/** + * @internal Used to identify the current app when using `inject()` within + * `app.runWithContext()`. + */ +export let currentApp: App | null = null diff --git a/packages/runtime-vapor/src/apiInject.ts b/packages/runtime-vapor/src/apiInject.ts new file mode 100644 index 000000000..bed8465a4 --- /dev/null +++ b/packages/runtime-vapor/src/apiInject.ts @@ -0,0 +1,84 @@ +import { isFunction } from '@vue/shared' +import { currentInstance } from './component' +import { currentApp } from './apiCreateVaporApp' +import { warn } from './warning' + +export interface InjectionKey extends Symbol {} + +export function provide | string | number>( + key: K, + value: K extends InjectionKey ? V : T, +) { + if (!currentInstance) { + if (__DEV__) { + warn(`provide() can only be used inside setup().`) + } + } else { + let provides = currentInstance.provides + // by default an instance inherits its parent's provides object + // but when it needs to provide values of its own, it creates its + // own provides object using parent provides object as prototype. + // this way in `inject` we can simply look up injections from direct + // parent and let the prototype chain do the work. + const parentProvides = + currentInstance.parent && currentInstance.parent.provides + if (parentProvides === provides) { + provides = currentInstance.provides = Object.create(parentProvides) + } + // TS doesn't allow symbol as index type + provides[key as string] = value + } +} + +export function inject(key: InjectionKey | string): T | undefined +export function inject( + key: InjectionKey | string, + defaultValue: T, + treatDefaultAsFactory?: false, +): T +export function inject( + key: InjectionKey | string, + defaultValue: T | (() => T), + treatDefaultAsFactory: true, +): T +export function inject( + key: InjectionKey | string, + defaultValue?: unknown, + treatDefaultAsFactory = false, +) { + const instance = currentInstance + + // also support looking up from app-level provides w/ `app.runWithContext()` + if (instance || currentApp) { + // #2400 + // to support `app.use` plugins, + // fallback to appContext's `provides` if the instance is at root + const provides = instance + ? instance.parent == null + ? instance.appContext && instance.appContext.provides + : instance.parent.provides + : currentApp!._context.provides + + if (provides && (key as string | symbol) in provides) { + // TS doesn't allow symbol as index type + return provides[key as string] + } else if (arguments.length > 1) { + return treatDefaultAsFactory && isFunction(defaultValue) + ? defaultValue.call(instance && instance) + : defaultValue + } else if (__DEV__) { + warn(`injection "${String(key)}" not found.`) + } + } else if (__DEV__) { + warn(`inject() can only be used inside setup() or functional components.`) + } +} + +/** + * Returns true if `inject()` can be used without warning about being called in the wrong place (e.g. outside of + * setup()). This is used by libraries that want to use `inject()` internally without triggering a warning to the end + * user. One example is `useRoute()` in `vue-router`. + */ +export function hasInjectionContext(): boolean { + return !!(currentInstance || currentApp) +} diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 1bbdb9d2c..883c36d94 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -18,9 +18,9 @@ import { normalizeEmitsOptions, } from './componentEmits' import { VaporLifecycleHooks } from './apiLifecycle' - -import type { Data } from '@vue/shared' import { warn } from './warning' +import { type AppContext, createAppContext } from './apiCreateVaporApp' +import type { Data } from '@vue/shared' export type Component = FunctionalComponent | ObjectComponent @@ -79,11 +79,13 @@ export interface ComponentInternalInstance { [componentKey]: true uid: number vapor: true + appContext: AppContext block: Block | null container: ParentNode parent: ComponentInternalInstance | null + provides: Data scope: EffectScope component: FunctionalComponent | ObjectComponent comps: Set @@ -180,23 +182,32 @@ export const unsetCurrentInstance = () => { currentInstance = null } +const emptyAppContext = createAppContext() + let uid = 0 export function createComponentInstance( component: ObjectComponent | FunctionalComponent, rawProps: RawProps | null, + // application root node only + appContext: AppContext | null = null, ): ComponentInternalInstance { + const parent = getCurrentInstance() + const _appContext = + (parent ? parent.appContext : appContext) || emptyAppContext + const instance: ComponentInternalInstance = { [componentKey]: true, uid: uid++, vapor: true, + appContext: _appContext, block: null, container: null!, - // TODO - parent: null, + parent, scope: new EffectScope(true /* detached */)!, + provides: parent ? parent.provides : Object.create(_appContext.provides), component, comps: new Set(), dirs: new Map(), diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index dd3d7fa7a..cb8b28daf 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -3,6 +3,7 @@ export const version = __VERSION__ export { // core + type Ref, reactive, ref, readonly, @@ -89,6 +90,12 @@ export { on, delegate, delegateEvents, setDynamicEvents } from './dom/event' export { setRef } from './dom/templateRef' export { defineComponent } from './apiDefineComponent' +export { + type InjectionKey, + inject, + provide, + hasInjectionContext, +} from './apiInject' export { onBeforeMount, onMounted,