Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Mar 22, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
397 changes: 397 additions & 0 deletions packages/runtime-vapor/__tests__/apiInject.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
})
40 changes: 38 additions & 2 deletions packages/runtime-vapor/src/apiCreateVaporApp.ts
Original file line number Diff line number Diff line change
@@ -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
84 changes: 84 additions & 0 deletions packages/runtime-vapor/src/apiInject.ts
Original file line number Diff line number Diff line change
@@ -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)
}
19 changes: 15 additions & 4 deletions packages/runtime-vapor/src/component.ts
Original file line number Diff line number Diff line change
@@ -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(),
7 changes: 7 additions & 0 deletions packages/runtime-vapor/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,