Просмотр исходного кода

test(runtime-vapor): finish createVaporApp unit tests

三咲智子 Kevin Deng 2 лет назад
Родитель
Сommit
bbd1944ce5

+ 25 - 6
packages/runtime-vapor/__tests__/_utils.ts

@@ -17,31 +17,50 @@ export function makeRender<Component = ObjectComponent | SetupFn>(
   },
 ) {
   let host: HTMLElement
+  function resetHost() {
+    return (host = initHost())
+  }
+
   beforeEach(() => {
-    host = initHost()
+    resetHost()
   })
   afterEach(() => {
     host.remove()
   })
 
-  const define = (comp: Component) => {
+  function define(comp: Component) {
     const component = defineComponent(comp as any)
-    let instance: ComponentInternalInstance
+    let instance: ComponentInternalInstance | undefined
     let app: App
-    const render = (
+
+    function render(
       props: RawProps = {},
-      container: string | ParentNode = '#host',
-    ) => {
+      container: string | ParentNode = host,
+    ) {
+      create(props)
+      return mount(container)
+    }
+
+    function create(props: RawProps = {}) {
+      app?.unmount()
       app = createVaporApp(component, props)
+      return res()
+    }
+
+    function mount(container: string | ParentNode = host) {
       instance = app.mount(container)
       return res()
     }
+
     const res = () => ({
       component,
       host,
       instance,
       app,
+      create,
+      mount,
       render,
+      resetHost,
     })
 
     return res()

+ 277 - 9
packages/runtime-vapor/__tests__/apiCreateVaporApp.spec.ts

@@ -1,6 +1,193 @@
-import { type Component, type Plugin, createVaporApp, inject } from '../src'
-;``
-describe('api: createApp', () => {
+import {
+  type ComponentInternalInstance,
+  type Plugin,
+  createComponent,
+  createTextNode,
+  createVaporApp,
+  defineComponent,
+  getCurrentInstance,
+  inject,
+  provide,
+  resolveComponent,
+  resolveDirective,
+  withDirectives,
+} from '../src'
+import { warn } from '../src/warning'
+import { makeRender } from './_utils'
+
+const define = makeRender()
+
+describe('api: createVaporApp', () => {
+  test('mount', () => {
+    const Comp = defineComponent({
+      props: {
+        count: { default: 0 },
+      },
+      setup(props) {
+        return createTextNode(() => [props.count])
+      },
+    })
+
+    const root1 = document.createElement('div')
+    createVaporApp(Comp).mount(root1)
+    expect(root1.innerHTML).toBe(`0`)
+    //#5571 mount multiple apps to the same host element
+    createVaporApp(Comp).mount(root1)
+    expect(
+      `There is already an app instance mounted on the host container`,
+    ).toHaveBeenWarned()
+
+    // mount with props
+    const root2 = document.createElement('div')
+    const app2 = createVaporApp(Comp, { count: () => 1 })
+    app2.mount(root2)
+    expect(root2.innerHTML).toBe(`1`)
+
+    // remount warning
+    const root3 = document.createElement('div')
+    app2.mount(root3)
+    expect(root3.innerHTML).toBe(``)
+    expect(`already been mounted`).toHaveBeenWarned()
+  })
+
+  test('unmount', () => {
+    const Comp = defineComponent({
+      props: {
+        count: { default: 0 },
+      },
+      setup(props) {
+        return createTextNode(() => [props.count])
+      },
+    })
+
+    const root = document.createElement('div')
+    const app = createVaporApp(Comp)
+
+    // warning
+    app.unmount()
+    expect(`that is not mounted`).toHaveBeenWarned()
+
+    app.mount(root)
+
+    app.unmount()
+    expect(root.innerHTML).toBe(``)
+  })
+
+  test('provide', () => {
+    const Root = define({
+      setup() {
+        // test override
+        provide('foo', 3)
+        return createComponent(Child)
+      },
+    })
+
+    const Child = defineComponent({
+      setup() {
+        const foo = inject('foo')
+        const bar = inject('bar')
+        try {
+          inject('__proto__')
+        } catch (e: any) {}
+        return createTextNode(() => [`${foo},${bar}`])
+      },
+    })
+
+    const { app, mount, create, host } = Root.create(null)
+    app.provide('foo', 1)
+    app.provide('bar', 2)
+    mount()
+    expect(host.innerHTML).toBe(`3,2`)
+    expect('[Vue warn]: injection "__proto__" not found.').toHaveBeenWarned()
+
+    const { app: app2 } = create()
+    app2.provide('bar', 1)
+    app2.provide('bar', 2)
+    expect(`App already provides property with key "bar".`).toHaveBeenWarned()
+  })
+
+  test('runWithContext', () => {
+    const { app } = define({
+      setup() {
+        provide('foo', 'should not be seen')
+        return document.createElement('div')
+      },
+    }).create()
+    app.provide('foo', 1)
+
+    expect(app.runWithContext(() => inject('foo'))).toBe(1)
+
+    expect(
+      app.runWithContext(() => {
+        app.runWithContext(() => {})
+        return inject('foo')
+      }),
+    ).toBe(1)
+
+    // ensure the context is restored
+    inject('foo')
+    expect('inject() can only be used inside setup').toHaveBeenWarned()
+  })
+
+  test('component', () => {
+    const { app, mount, host } = define({
+      setup() {
+        const FooBar = resolveComponent('foo-bar')
+        const BarBaz = resolveComponent('bar-baz')
+        // @ts-expect-error TODO support string
+        return [createComponent(FooBar), createComponent(BarBaz)]
+      },
+    }).create()
+
+    const FooBar = () => createTextNode(['foobar!'])
+    app.component('FooBar', FooBar)
+    expect(app.component('FooBar')).toBe(FooBar)
+
+    app.component('BarBaz', () => createTextNode(['barbaz!']))
+    app.component('BarBaz', () => createTextNode(['barbaz!']))
+    expect(
+      'Component "BarBaz" has already been registered in target app.',
+    ).toHaveBeenWarnedTimes(1)
+
+    mount()
+    expect(host.innerHTML).toBe(`foobar!barbaz!`)
+  })
+
+  test('directive', () => {
+    const spy1 = vi.fn()
+    const spy2 = vi.fn()
+
+    const { app, mount } = define({
+      setup() {
+        const FooBar = resolveDirective('foo-bar')
+        const BarBaz = resolveDirective('bar-baz')
+        return withDirectives(document.createElement('div'), [
+          [FooBar],
+          [BarBaz],
+        ])
+      },
+    }).create()
+
+    const FooBar = { mounted: spy1 }
+    app.directive('FooBar', FooBar)
+    expect(app.directive('FooBar')).toBe(FooBar)
+
+    app.directive('BarBaz', { mounted: spy2 })
+    app.directive('BarBaz', { mounted: spy2 })
+    expect(
+      'Directive "BarBaz" has already been registered in target app.',
+    ).toHaveBeenWarnedTimes(1)
+
+    mount()
+    expect(spy1).toHaveBeenCalled()
+    expect(spy2).toHaveBeenCalled()
+
+    app.directive('bind', FooBar)
+    expect(
+      `Do not use built-in directive ids as custom directive id: bind`,
+    ).toHaveBeenWarned()
+  })
+
   test('use', () => {
     const PluginA: Plugin = app => app.provide('foo', 1)
     const PluginB: Plugin = {
@@ -14,22 +201,20 @@ describe('api: createApp', () => {
     }
     const PluginD: any = undefined
 
-    const Root: Component = {
+    const { app, host, mount } = define({
       setup() {
         const foo = inject('foo')
         const bar = inject('bar')
         return document.createTextNode(`${foo},${bar}`)
       },
-    }
+    }).create()
 
-    const app = createVaporApp(Root)
     app.use(PluginA)
     app.use(PluginB, 1, 1)
     app.use(PluginC)
 
-    const root = document.createElement('div')
-    app.mount(root)
-    expect(root.innerHTML).toBe(`1,2`)
+    mount()
+    expect(host.innerHTML).toBe(`1,2`)
 
     app.use(PluginA)
     expect(
@@ -42,4 +227,87 @@ describe('api: createApp', () => {
         `function.`,
     ).toHaveBeenWarnedTimes(1)
   })
+
+  test('config.errorHandler', () => {
+    const error = new Error()
+    let instance: ComponentInternalInstance
+
+    const handler = vi.fn((err, _instance, info) => {
+      expect(err).toBe(error)
+      expect(_instance).toBe(instance)
+      expect(info).toBe(`render function`)
+    })
+
+    const { app, mount } = define({
+      setup() {
+        instance = getCurrentInstance()!
+      },
+      render() {
+        throw error
+      },
+    }).create()
+    app.config.errorHandler = handler
+    mount()
+    expect(handler).toHaveBeenCalled()
+  })
+
+  test('config.warnHandler', () => {
+    let instance: ComponentInternalInstance
+
+    const handler = vi.fn((msg, _instance, trace) => {
+      expect(msg).toMatch(`warn message`)
+      expect(_instance).toBe(instance)
+      expect(trace).toMatch(`Hello`)
+    })
+
+    const { app, mount } = define({
+      name: 'Hello',
+      setup() {
+        instance = getCurrentInstance()!
+        warn('warn message')
+      },
+    }).create()
+
+    app.config.warnHandler = handler
+    mount()
+    expect(handler).toHaveBeenCalledTimes(1)
+  })
+
+  describe('config.isNativeTag', () => {
+    const isNativeTag = vi.fn(tag => tag === 'div')
+
+    test('Component.name', () => {
+      const { app, mount } = define({
+        name: 'div',
+        render(): any {},
+      }).create()
+
+      Object.defineProperty(app.config, 'isNativeTag', {
+        value: isNativeTag,
+        writable: false,
+      })
+
+      mount()
+      expect(
+        `Do not use built-in or reserved HTML elements as component id: div`,
+      ).toHaveBeenWarned()
+    })
+
+    test('register using app.component', () => {
+      const { app, mount } = define({
+        render(): any {},
+      }).create()
+
+      Object.defineProperty(app.config, 'isNativeTag', {
+        value: isNativeTag,
+        writable: false,
+      })
+
+      app.component('div', () => createTextNode(['div']))
+      mount()
+      expect(
+        `Do not use built-in or reserved HTML elements as component id: div`,
+      ).toHaveBeenWarned()
+    })
+  })
 })

+ 23 - 1
packages/runtime-vapor/src/apiCreateVaporApp.ts

@@ -7,7 +7,12 @@ import {
 } from './component'
 import { warn } from './warning'
 import { type Directive, version } from '.'
-import { render, setupComponent, unmountComponent } from './apiRender'
+import {
+  normalizeContainer,
+  render,
+  setupComponent,
+  unmountComponent,
+} from './apiRender'
 import type { InjectionKey } from './apiInject'
 import type { RawProps } from './componentProps'
 import { validateDirectiveName } from './directives'
@@ -29,6 +34,7 @@ export function createVaporApp(
 
   const app: App = {
     _context: context,
+    _container: null,
 
     version,
 
@@ -93,6 +99,15 @@ export function createVaporApp(
 
     mount(rootContainer): any {
       if (!instance) {
+        rootContainer = normalizeContainer(rootContainer)
+        // #5571
+        if (__DEV__ && (rootContainer as any).__vue_app__) {
+          warn(
+            `There is already an app instance mounted on the host container.\n` +
+              ` If you want to mount another app on the same host container,` +
+              ` you need to unmount the previous app by calling \`app.unmount()\` first.`,
+          )
+        }
         instance = createComponentInstance(
           rootComponent,
           rootProps,
@@ -103,6 +118,11 @@ export function createVaporApp(
         )
         setupComponent(instance)
         render(instance, rootContainer)
+
+        app._container = rootContainer
+        // for devtools and telemetry
+        ;(rootContainer as any).__vue_app__ = app
+
         return instance
       } else if (__DEV__) {
         warn(
@@ -116,6 +136,7 @@ export function createVaporApp(
     unmount() {
       if (instance) {
         unmountComponent(instance)
+        delete (app._container as any).__vue_app__
       } else if (__DEV__) {
         warn(`Cannot unmount an app that is not mounted.`)
       }
@@ -199,6 +220,7 @@ export interface App {
   runWithContext<T>(fn: () => T): T
 
   _context: AppContext
+  _container: ParentNode | null
 }
 
 export interface AppConfig {

+ 14 - 2
packages/runtime-vapor/src/apiRender.ts

@@ -3,6 +3,7 @@ import {
   componentKey,
   createSetupContext,
   setCurrentInstance,
+  validateComponentName,
 } from './component'
 import { insert, querySelector, remove } from './dom/element'
 import { flushPostFlushCbs, queuePostFlushCb } from './scheduler'
@@ -35,6 +36,12 @@ export function setupComponent(
   instance.scope.run(() => {
     const { component, props } = instance
 
+    if (__DEV__) {
+      if (component.name) {
+        validateComponentName(component.name, instance.appContext.config)
+      }
+    }
+
     const setupFn = isFunction(component) ? component : component.setup
     let stateOrNode: Block | undefined
     if (setupFn) {
@@ -65,7 +72,12 @@ export function setupComponent(
     }
     if (!block && component.render) {
       pauseTracking()
-      block = component.render(instance.setupState)
+      block = callWithErrorHandling(
+        component.render,
+        instance,
+        VaporErrorCodes.RENDER_FUNCTION,
+        [instance.setupState],
+      )
       resetTracking()
     }
 
@@ -91,7 +103,7 @@ export function render(
   flushPostFlushCbs()
 }
 
-function normalizeContainer(container: string | ParentNode): ParentNode {
+export function normalizeContainer(container: string | ParentNode): ParentNode {
   return typeof container === 'string'
     ? (querySelector(container) as ParentNode)
     : container