Ver código fonte

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

Co-authored-by: 三咲智子 Kevin Deng <sxzz@sxzz.moe>
ubugeeei 2 anos atrás
pai
commit
5c9a15140d

+ 397 - 0
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<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)
+      })
+    })
+  })
+})

+ 38 - 2
packages/runtime-vapor/src/apiCreateVaporApp.ts

@@ -7,6 +7,7 @@ import {
 import { warn } from './warning'
 import { warn } from './warning'
 import { version } from '.'
 import { version } from '.'
 import { render, setupComponent, unmountComponent } from './apiRender'
 import { render, setupComponent, unmountComponent } from './apiRender'
+import type { InjectionKey } from './apiInject'
 import type { RawProps } from './componentProps'
 import type { RawProps } from './componentProps'
 
 
 export function createVaporApp(
 export function createVaporApp(
@@ -22,6 +23,8 @@ export function createVaporApp(
   let instance: ComponentInternalInstance
   let instance: ComponentInternalInstance
 
 
   const app: App = {
   const app: App = {
+    _context: context,
+
     version,
     version,
 
 
     get config() {
     get config() {
@@ -38,7 +41,7 @@ export function createVaporApp(
 
 
     mount(rootContainer): any {
     mount(rootContainer): any {
       if (!instance) {
       if (!instance) {
-        instance = createComponentInstance(rootComponent, rootProps)
+        instance = createComponentInstance(rootComponent, rootProps, context)
         setupComponent(instance)
         setupComponent(instance)
         render(instance, rootContainer)
         render(instance, rootContainer)
         return instance
         return instance
@@ -58,18 +61,40 @@ export function createVaporApp(
         warn(`Cannot unmount an app that is not mounted.`)
         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
   return app
 }
 }
 
 
-function createAppContext(): AppContext {
+export function createAppContext(): AppContext {
   return {
   return {
     app: null as any,
     app: null as any,
     config: {
     config: {
       errorHandler: undefined,
       errorHandler: undefined,
       warnHandler: undefined,
       warnHandler: undefined,
     },
     },
+    provides: Object.create(null),
   }
   }
 }
 }
 
 
@@ -82,6 +107,10 @@ export interface App {
     isHydrate?: boolean,
     isHydrate?: boolean,
   ): ComponentInternalInstance
   ): ComponentInternalInstance
   unmount(): void
   unmount(): void
+  provide<T>(key: string | InjectionKey<T>, value: T): App
+  runWithContext<T>(fn: () => T): T
+
+  _context: AppContext
 }
 }
 
 
 export interface AppConfig {
 export interface AppConfig {
@@ -100,4 +129,11 @@ export interface AppConfig {
 export interface AppContext {
 export interface AppContext {
   app: App // for devtools
   app: App // for devtools
   config: AppConfig
   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 - 0
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<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)
+}

+ 15 - 4
packages/runtime-vapor/src/component.ts

@@ -18,9 +18,9 @@ import {
   normalizeEmitsOptions,
   normalizeEmitsOptions,
 } from './componentEmits'
 } from './componentEmits'
 import { VaporLifecycleHooks } from './apiLifecycle'
 import { VaporLifecycleHooks } from './apiLifecycle'
-
-import type { Data } from '@vue/shared'
 import { warn } from './warning'
 import { warn } from './warning'
+import { type AppContext, createAppContext } from './apiCreateVaporApp'
+import type { Data } from '@vue/shared'
 
 
 export type Component = FunctionalComponent | ObjectComponent
 export type Component = FunctionalComponent | ObjectComponent
 
 
@@ -79,11 +79,13 @@ export interface ComponentInternalInstance {
   [componentKey]: true
   [componentKey]: true
   uid: number
   uid: number
   vapor: true
   vapor: true
+  appContext: AppContext
 
 
   block: Block | null
   block: Block | null
   container: ParentNode
   container: ParentNode
   parent: ComponentInternalInstance | null
   parent: ComponentInternalInstance | null
 
 
+  provides: Data
   scope: EffectScope
   scope: EffectScope
   component: FunctionalComponent | ObjectComponent
   component: FunctionalComponent | ObjectComponent
   comps: Set<ComponentInternalInstance>
   comps: Set<ComponentInternalInstance>
@@ -180,23 +182,32 @@ export const unsetCurrentInstance = () => {
   currentInstance = null
   currentInstance = null
 }
 }
 
 
+const emptyAppContext = createAppContext()
+
 let uid = 0
 let uid = 0
 export function createComponentInstance(
 export function createComponentInstance(
   component: ObjectComponent | FunctionalComponent,
   component: ObjectComponent | FunctionalComponent,
   rawProps: RawProps | null,
   rawProps: RawProps | null,
+  // application root node only
+  appContext: AppContext | null = null,
 ): ComponentInternalInstance {
 ): ComponentInternalInstance {
+  const parent = getCurrentInstance()
+  const _appContext =
+    (parent ? parent.appContext : appContext) || emptyAppContext
+
   const instance: ComponentInternalInstance = {
   const instance: ComponentInternalInstance = {
     [componentKey]: true,
     [componentKey]: true,
     uid: uid++,
     uid: uid++,
     vapor: true,
     vapor: true,
+    appContext: _appContext,
 
 
     block: null,
     block: null,
     container: null!,
     container: null!,
 
 
-    // TODO
-    parent: null,
+    parent,
 
 
     scope: new EffectScope(true /* detached */)!,
     scope: new EffectScope(true /* detached */)!,
+    provides: parent ? parent.provides : Object.create(_appContext.provides),
     component,
     component,
     comps: new Set(),
     comps: new Set(),
     dirs: new Map(),
     dirs: new Map(),

+ 7 - 0
packages/runtime-vapor/src/index.ts

@@ -3,6 +3,7 @@
 export const version = __VERSION__
 export const version = __VERSION__
 export {
 export {
   // core
   // core
+  type Ref,
   reactive,
   reactive,
   ref,
   ref,
   readonly,
   readonly,
@@ -89,6 +90,12 @@ export { on, delegate, delegateEvents, setDynamicEvents } from './dom/event'
 export { setRef } from './dom/templateRef'
 export { setRef } from './dom/templateRef'
 
 
 export { defineComponent } from './apiDefineComponent'
 export { defineComponent } from './apiDefineComponent'
+export {
+  type InjectionKey,
+  inject,
+  provide,
+  hasInjectionContext,
+} from './apiInject'
 export {
 export {
   onBeforeMount,
   onBeforeMount,
   onMounted,
   onMounted,