feat(runtime-vapor): provide and inject (#158)

Co-authored-by: 三咲智子 Kevin Deng <sxzz@sxzz.moe>
This commit is contained in:
ubugeeei 2024-03-23 00:41:16 +09:00 committed by GitHub
parent ed6b1718d2
commit 5c9a15140d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 541 additions and 6 deletions

View File

@ -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<any>()
// 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<number> = 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<Ref<number>>('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<Ref<number>>('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<typeof rootState>('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<typeof rootState>('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)
})
})
})
})

View File

@ -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<T>(key: string | InjectionKey<T>, value: T): App
runWithContext<T>(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<string | symbol, any>
}
/**
* @internal Used to identify the current app when using `inject()` within
* `app.runWithContext()`.
*/
export let currentApp: App | null = null

View File

@ -0,0 +1,84 @@
import { isFunction } from '@vue/shared'
import { currentInstance } from './component'
import { currentApp } from './apiCreateVaporApp'
import { warn } from './warning'
export interface InjectionKey<T> extends Symbol {}
export function provide<T, K = InjectionKey<T> | string | number>(
key: K,
value: K extends InjectionKey<infer V> ? 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<T>(key: InjectionKey<T> | string): T | undefined
export function inject<T>(
key: InjectionKey<T> | string,
defaultValue: T,
treatDefaultAsFactory?: false,
): T
export function inject<T>(
key: InjectionKey<T> | string,
defaultValue: T | (() => T),
treatDefaultAsFactory: true,
): T
export function inject(
key: InjectionKey<any> | 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)
}

View File

@ -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<ComponentInternalInstance>
@ -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(),

View File

@ -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,