"format-check": "prettier --check --cache .",
"test": "vitest",
"test-unit": "vitest --project unit --project unit-jsdom",
- "test-e2e": "node scripts/build.js vue -f global -d && vitest --project e2e",
+ "test-e2e": "node scripts/build.js vue -f global+esm-browser-vapor -d && vitest --project e2e",
"test-e2e-vapor": "pnpm run prepare-e2e-vapor && vitest --project e2e-vapor",
"prepare-e2e-vapor": "node scripts/build.js -f cjs+esm-bundler+esm-bundler-runtime && pnpm run -C packages-private/vapor-e2e-test build",
"test-dts": "run-s build-dts test-dts-only",
type ComponentInternalInstance,
type ComponentOptions,
type ConcreteComponent,
+ type GenericComponent,
type GenericComponentInstance,
currentInstance,
getComponentName,
__asyncLoader: load,
__asyncHydrate(el, instance, hydrate) {
- let patched = false
- ;(instance.bu || (instance.bu = [])).push(() => (patched = true))
- const performHydrate = () => {
- // skip hydration if the component has been patched
- if (patched) {
- if (__DEV__) {
- const resolvedComp = getResolvedComp()
- warn(
- `Skipping lazy hydration for component '${getComponentName(resolvedComp!) || resolvedComp!.__file}': ` +
- `it was updated before lazy hydration performed.`,
- )
- }
- return
- }
- hydrate()
- }
- const doHydrate = hydrateStrategy
- ? () => {
- const teardown = hydrateStrategy(performHydrate, cb =>
- forEachElement(el, cb),
- )
- if (teardown) {
- ;(instance.bum || (instance.bum = [])).push(teardown)
- }
- }
- : performHydrate
- if (getResolvedComp()) {
- doHydrate()
- } else {
- load().then(() => !instance.isUnmounted && doHydrate())
- }
+ performAsyncHydrate(
+ el,
+ instance,
+ hydrate,
+ getResolvedComp,
+ load,
+ hydrateStrategy,
+ )
},
get __asyncResolved() {
(__FEATURE_SUSPENSE__ && suspensible && instance.suspense) ||
(__SSR__ && isInSSRComponentSetup)
) {
- return load()
- .then(comp => {
- return () => createInnerComp(comp, instance)
- })
- .catch(err => {
- onError(err)
- return () =>
- errorComponent
- ? createVNode(errorComponent as ConcreteComponent, {
- error: err,
- })
- : null
- })
+ return loadInnerComponent(instance, load, onError, errorComponent)
}
const { loaded, error, delayed } = useAsyncComponentState(
}) as T
}
-function createInnerComp(
+export function createInnerComp(
comp: ConcreteComponent,
parent: ComponentInternalInstance,
-) {
+): VNode {
const { ref, props, children, ce } = parent.vnode
const vnode = createVNode(comp, props, children)
// ensure inner component inherits the async wrapper's ref owner
return { loaded, error, delayed }
}
+
+/**
+ * shared between core and vapor
+ * @internal
+ */
+export function loadInnerComponent(
+ instance: ComponentInternalInstance,
+ load: () => Promise<any>,
+ onError: (err: Error) => void,
+ errorComponent: ConcreteComponent | undefined,
+): Promise<() => VNode | null> {
+ return load()
+ .then(comp => {
+ return () => createInnerComp(comp, instance)
+ })
+ .catch(err => {
+ onError(err)
+ return () =>
+ errorComponent
+ ? createVNode(errorComponent as ConcreteComponent, {
+ error: err,
+ })
+ : null
+ })
+}
+
+/**
+ * shared between core and vapor
+ * @internal
+ */
+export function performAsyncHydrate(
+ el: Element,
+ instance: GenericComponentInstance,
+ hydrate: () => void,
+ getResolvedComp: () => GenericComponent | undefined,
+ load: () => Promise<GenericComponent>,
+ hydrateStrategy: HydrationStrategy | undefined,
+): void {
+ let patched = false
+ ;(instance.bu || (instance.bu = [])).push(() => (patched = true))
+ const performHydrate = () => {
+ // skip hydration if the component has been patched
+ if (patched) {
+ if (__DEV__) {
+ const resolvedComp = getResolvedComp()! as GenericComponent
+ warn(
+ `Skipping lazy hydration for component '${getComponentName(resolvedComp) || resolvedComp.__file}': ` +
+ `it was updated before lazy hydration performed.`,
+ )
+ }
+ return
+ }
+ hydrate()
+ }
+ const doHydrate = hydrateStrategy
+ ? () => {
+ const teardown = hydrateStrategy(performHydrate, cb =>
+ forEachElement(el, cb),
+ )
+ if (teardown) {
+ ;(instance.bum || (instance.bum = [])).push(teardown)
+ }
+ }
+ : performHydrate
+ if (getResolvedComp()) {
+ doHydrate()
+ } else {
+ load().then(() => !instance.isUnmounted && doHydrate())
+ }
+}
__name?: string
}
+export interface AsyncComponentInternalOptions<
+ R = ConcreteComponent,
+ I = ComponentInternalInstance,
+> {
+ /**
+ * marker for AsyncComponentWrapper
+ * @internal
+ */
+ __asyncLoader?: () => Promise<R>
+ /**
+ * the inner component resolved by the AsyncComponentWrapper
+ * @internal
+ */
+ __asyncResolved?: R
+ /**
+ * Exposed for lazy hydration
+ * @internal
+ */
+ __asyncHydrate?: (el: Element, instance: I, hydrate: () => void) => void
+}
+
export interface FunctionalComponent<
P = {},
E extends EmitsOptions | Record<string, any[]> = {},
import {
+ type AsyncComponentInternalOptions,
type Component,
type ComponentInternalInstance,
type ComponentInternalOptions,
- type ConcreteComponent,
type Data,
type InternalRenderFunction,
type SetupContext,
Provide extends ComponentProvideOptions = ComponentProvideOptions,
> extends LegacyOptions<Props, D, C, M, Mixin, Extends, I, II, Provide>,
ComponentInternalOptions,
+ AsyncComponentInternalOptions,
ComponentCustomOptions {
setup?: (
this: void,
*/
__ssrInlineRender?: boolean
- /**
- * marker for AsyncComponentWrapper
- * @internal
- */
- __asyncLoader?: () => Promise<ConcreteComponent>
- /**
- * the inner component resolved by the AsyncComponentWrapper
- * @internal
- */
- __asyncResolved?: ConcreteComponent
- /**
- * Exposed for lazy hydration
- * @internal
- */
- __asyncHydrate?: (
- el: Element,
- instance: ComponentInternalInstance,
- hydrate: () => void,
- ) => void
-
// Type differentiators ------------------------------------------------------
// Note these are internal but need to be exposed in d.ts for type inference
hasHydrated = true
teardown()
hydrate()
- // replay event
- e.target!.dispatchEvent(new (e.constructor as any)(e.type, e))
+ // replay event if the event is not delegated
+ if (!(`$evt${e.type}` in e.target!)) {
+ e.target!.dispatchEvent(new (e.constructor as any)(e.type, e))
+ }
}
}
const teardown = () => {
GlobalDirectives,
ComponentInstance,
ComponentCustomElementInterface,
+ AsyncComponentInternalOptions,
} from './component'
export type {
DefineComponent,
/**
* @internal
*/
-export { expose, nextUid, validateComponentName } from './component'
+export {
+ expose,
+ nextUid,
+ validateComponentName,
+ isInSSRComponentSetup,
+} from './component'
/**
* @internal
*/
createAsyncComponentContext,
useAsyncComponentState,
isAsyncWrapper,
+ performAsyncHydrate,
+ loadInnerComponent,
+ createInnerComp,
} from './apiAsyncComponent'
/**
* @internal
-import { createVaporSSRApp, delegateEvents } from '../src'
+import {
+ createVaporSSRApp,
+ defineVaporAsyncComponent,
+ delegateEvents,
+} from '../src'
import { nextTick, reactive, ref } from '@vue/runtime-dom'
import { compileScript, parse } from '@vue/compiler-sfc'
import * as runtimeVapor from '../src'
components?: Record<string, any>,
ssr = false,
) {
- return compile(`<template>${code}</template>`, data, components, {
+ if (!code.includes(`<script`)) {
+ code = `<template>${code}</template>`
+ }
+ return compile(code, data, components, {
vapor: true,
ssr,
})
})
})
- describe.todo('async component', async () => {
- test('async component', async () => {})
+ describe('async component', async () => {
+ test('async component', async () => {
+ const data = ref({
+ spy: vi.fn(),
+ })
+
+ const compCode = `<button @click="data.spy">hello!</button>`
+ const SSRComp = compileVaporComponent(compCode, data, undefined, true)
+ let serverResolve: any
+ let AsyncComp = defineVaporAsyncComponent(
+ () =>
+ new Promise(r => {
+ serverResolve = r
+ }),
+ )
+ const appCode = `hello<components.AsyncComp/>world`
+ const SSRApp = compileVaporComponent(appCode, data, { AsyncComp }, true)
+
+ // server render
+ const htmlPromise = VueServerRenderer.renderToString(
+ runtimeDom.createSSRApp(SSRApp),
+ )
+ serverResolve(SSRComp)
+ const html = await htmlPromise
+ expect(html).toMatchInlineSnapshot(
+ `"<!--[-->hello<button>hello!</button>world<!--]-->"`,
+ )
+
+ // hydration
+ let clientResolve: any
+ AsyncComp = defineVaporAsyncComponent(
+ () =>
+ new Promise(r => {
+ clientResolve = r
+ }),
+ )
+
+ const Comp = compileVaporComponent(compCode, data)
+ const App = compileVaporComponent(appCode, data, { AsyncComp })
+
+ const container = document.createElement('div')
+ container.innerHTML = html
+ document.body.appendChild(container)
+ createVaporSSRApp(App).mount(container)
+
+ // hydration not complete yet
+ triggerEvent('click', container.querySelector('button')!)
+ expect(data.value.spy).not.toHaveBeenCalled()
+
+ // resolve
+ clientResolve(Comp)
+ await new Promise(r => setTimeout(r))
+
+ // should be hydrated now
+ triggerEvent('click', container.querySelector('button')!)
+ expect(data.value.spy).toHaveBeenCalled()
+ })
+
+ // No longer needed, parent component updates in vapor mode no longer
+ // cause child components to re-render
+ // test.todo('update async wrapper before resolve', async () => {})
+
+ test('update async component after parent mount before async component resolve', async () => {
+ const data = ref({
+ toggle: true,
+ })
+ const compCode = `
+ <script vapor>
+ defineProps(['toggle'])
+ </script>
+ <template>
+ <h1>{{ toggle ? 'Async component' : 'Updated async component' }}</h1>
+ </template>
+ `
+ const SSRComp = compileVaporComponent(
+ compCode,
+ undefined,
+ undefined,
+ true,
+ )
+ let serverResolve: any
+ let AsyncComp = defineVaporAsyncComponent(
+ () =>
+ new Promise(r => {
+ serverResolve = r
+ }),
+ )
+ const appCode = `<components.AsyncComp :toggle="data.toggle"/>`
+ const SSRApp = compileVaporComponent(appCode, data, { AsyncComp }, true)
+
+ // server render
+ const htmlPromise = VueServerRenderer.renderToString(
+ runtimeDom.createSSRApp(SSRApp),
+ )
+ serverResolve(SSRComp)
+ const html = await htmlPromise
+ expect(html).toMatchInlineSnapshot(`"<h1>Async component</h1>"`)
+
+ // hydration
+ let clientResolve: any
+ AsyncComp = defineVaporAsyncComponent(
+ () =>
+ new Promise(r => {
+ clientResolve = r
+ }),
+ )
+
+ const Comp = compileVaporComponent(compCode)
+ const App = compileVaporComponent(appCode, data, { AsyncComp })
+
+ const container = document.createElement('div')
+ container.innerHTML = html
+ document.body.appendChild(container)
+ createVaporSSRApp(App).mount(container)
+
+ // update before resolve
+ data.value.toggle = false
+ await nextTick()
+
+ // resolve
+ clientResolve(Comp)
+ await new Promise(r => setTimeout(r))
+
+ // prevent lazy hydration since the component has been patched
+ expect('Skipping lazy hydration for component').toHaveBeenWarned()
+ expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+ expect(container.innerHTML).toMatchInlineSnapshot(
+ `"<h1>Updated async component</h1><!--async component-->"`,
+ )
+ })
+
+ test('update async component (fragment root) after parent mount before async component resolve', async () => {
+ const data = ref({
+ toggle: true,
+ })
+ const compCode = `
+ <script vapor>
+ defineProps(['toggle'])
+ </script>
+ <template>
+ <h1>{{ toggle ? 'Async component' : 'Updated async component' }}</h1>
+ <h2>fragment root</h2>
+ </template>
+ `
+ const SSRComp = compileVaporComponent(
+ compCode,
+ undefined,
+ undefined,
+ true,
+ )
+ let serverResolve: any
+ let AsyncComp = defineVaporAsyncComponent(
+ () =>
+ new Promise(r => {
+ serverResolve = r
+ }),
+ )
+ const appCode = `<components.AsyncComp :toggle="data.toggle"/>`
+ const SSRApp = compileVaporComponent(appCode, data, { AsyncComp }, true)
+
+ // server render
+ const htmlPromise = VueServerRenderer.renderToString(
+ runtimeDom.createSSRApp(SSRApp),
+ )
+ serverResolve(SSRComp)
+ const html = await htmlPromise
+ expect(html).toMatchInlineSnapshot(
+ `"<!--[--><h1>Async component</h1><h2>fragment root</h2><!--]-->"`,
+ )
+
+ // hydration
+ let clientResolve: any
+ AsyncComp = defineVaporAsyncComponent(
+ () =>
+ new Promise(r => {
+ clientResolve = r
+ }),
+ )
+
+ const Comp = compileVaporComponent(compCode)
+ const App = compileVaporComponent(appCode, data, { AsyncComp })
- test('update async wrapper before resolve', async () => {})
+ const container = document.createElement('div')
+ container.innerHTML = html
+ document.body.appendChild(container)
+ createVaporSSRApp(App).mount(container)
- test('hydrate safely when property used by async setup changed before render', async () => {})
+ // update before resolve
+ data.value.toggle = false
+ await nextTick()
+
+ // resolve
+ clientResolve(Comp)
+ await new Promise(r => setTimeout(r))
+
+ // prevent lazy hydration since the component has been patched
+ expect('Skipping lazy hydration for component').toHaveBeenWarned()
+ expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+ expect(container.innerHTML).toMatchInlineSnapshot(
+ `"<!--[--><h1>Updated async component</h1><h2>fragment root</h2><!--async component--><!--]-->"`,
+ )
+ })
- test('unmount async wrapper before load', async () => {})
+ // required vapor Suspense
+ test.todo(
+ 'hydrate safely when property used by async setup changed before render',
+ async () => {},
+ )
- test('nested async wrapper', async () => {})
+ // required vapor Suspense
+ test.todo(
+ 'hydrate safely when property used by deep nested async setup changed before render',
+ async () => {},
+ )
- test('unmount async wrapper before load (fragment)', async () => {})
+ test('unmount async wrapper before load', async () => {
+ const data = ref({
+ toggle: true,
+ })
+ const compCode = `<div>async</div>`
+ const appCode = `
+ <div>
+ <components.AsyncComp v-if="data.toggle"/>
+ <div v-else>hi</div>
+ </div>
+ `
+
+ // hydration
+ let clientResolve: any
+ const AsyncComp = defineVaporAsyncComponent(
+ () =>
+ new Promise(r => {
+ clientResolve = r
+ }),
+ )
+
+ const Comp = compileVaporComponent(compCode)
+ const App = compileVaporComponent(appCode, data, {
+ AsyncComp,
+ })
+
+ const container = document.createElement('div')
+ container.innerHTML = '<div><div>async</div></div>'
+ createVaporSSRApp(App).mount(container)
+
+ // unmount before resolve
+ data.value.toggle = false
+ await nextTick()
+ expect(container.innerHTML).toBe(`<div><div>hi</div><!--if--></div>`)
+
+ // resolve
+ clientResolve(Comp)
+ await new Promise(r => setTimeout(r))
+ // should remain unmounted
+ expect(container.innerHTML).toBe(`<div><div>hi</div><!--if--></div>`)
+ })
+
+ test('unmount async wrapper before load (fragment)', async () => {
+ const data = ref({
+ toggle: true,
+ })
+ const compCode = `<div>async</div><div>fragment</div>`
+ const appCode = `
+ <div>
+ <components.AsyncComp v-if="data.toggle"/>
+ <div v-else>hi</div>
+ </div>
+ `
+
+ // hydration
+ let clientResolve: any
+ const AsyncComp = defineVaporAsyncComponent(
+ () =>
+ new Promise(r => {
+ clientResolve = r
+ }),
+ )
+
+ const Comp = compileVaporComponent(compCode)
+ const App = compileVaporComponent(appCode, data, {
+ AsyncComp,
+ })
+
+ const container = document.createElement('div')
+ container.innerHTML =
+ '<div><!--[--><div>async</div><div>fragment</div><!--]--></div>'
+ createVaporSSRApp(App).mount(container)
+
+ // unmount before resolve
+ data.value.toggle = false
+ await nextTick()
+ expect(container.innerHTML).toBe(`<div><div>hi</div><!--if--></div>`)
+
+ // resolve
+ clientResolve(Comp)
+ await new Promise(r => setTimeout(r))
+ // should remain unmounted
+ expect(container.innerHTML).toBe(`<div><div>hi</div><!--if--></div>`)
+ })
+
+ test('nested async wrapper', async () => {
+ const toggleCode = `
+ <script vapor>
+ import { onMounted, ref, nextTick } from 'vue'
+ const show = ref(false)
+ onMounted(() => {
+ nextTick(() => {
+ show.value = true
+ })
+ })
+ </script>
+ <template>
+ <div v-show="show">
+ <slot />
+ </div>
+ </template>
+ `
+
+ const SSRToggle = compileVaporComponent(
+ toggleCode,
+ undefined,
+ undefined,
+ true,
+ )
+
+ const wrapperCode = `<slot/>`
+ const SSRWrapper = compileVaporComponent(
+ wrapperCode,
+ undefined,
+ undefined,
+ true,
+ )
+
+ const data = ref({
+ count: 0,
+ fn: vi.fn(),
+ })
+
+ const childCode = `
+ <script vapor>
+ import { onMounted } from 'vue'
+ const data = _data; const components = _components;
+ onMounted(() => {
+ data.value.fn()
+ data.value.count++
+ })
+ </script>
+ <template>
+ <div>{{data.count}}</div>
+ </template>
+ `
+
+ const SSRChild = compileVaporComponent(childCode, data, undefined, true)
+
+ const appCode = `
+ <components.Toggle>
+ <components.Wrapper>
+ <components.Wrapper>
+ <components.Child/>
+ </components.Wrapper>
+ </components.Wrapper>
+ </components.Toggle>
+ `
+
+ const SSRApp = compileVaporComponent(
+ appCode,
+ undefined,
+ {
+ Toggle: SSRToggle,
+ Wrapper: SSRWrapper,
+ Child: SSRChild,
+ },
+ true,
+ )
+
+ const root = document.createElement('div')
+
+ // server render
+ root.innerHTML = await VueServerRenderer.renderToString(
+ runtimeDom.createSSRApp(SSRApp),
+ )
+ expect(root.innerHTML).toMatchInlineSnapshot(
+ `"<div style="display:none;"><!--[--><!--[--><!--[--><div>0</div><!--]--><!--]--><!--]--></div>"`,
+ )
+
+ const Toggle = compileVaporComponent(toggleCode)
+ const Wrapper = compileVaporComponent(wrapperCode)
+ const Child = compileVaporComponent(childCode, data)
+
+ const App = compileVaporComponent(appCode, undefined, {
+ Toggle,
+ Wrapper,
+ Child,
+ })
+
+ // hydration
+ createVaporSSRApp(App).mount(root)
+ await nextTick()
+ await nextTick()
+ expect(root.innerHTML).toMatchInlineSnapshot(
+ `"<div style=""><!--[--><!--[--><!--[--><div>1</div><!--]--><!--]--><!--]--></div>"`,
+ )
+ expect(data.value.fn).toBeCalledTimes(1)
+ })
})
describe('force hydrate prop', async () => {
type AsyncComponentOptions,
ErrorCodes,
createAsyncComponentContext,
+ createInnerComp as createSSRInnerComp,
currentInstance,
handleError,
+ isInSSRComponentSetup,
+ loadInnerComponent as loadSSRInnerComponent,
markAsyncBoundary,
+ performAsyncHydrate,
useAsyncComponentState,
+ watch,
} from '@vue/runtime-dom'
import { defineVaporComponent } from './apiDefineComponent'
import {
} from './component'
import { renderEffect } from './renderEffect'
import { DynamicFragment } from './fragment'
-
-/*! #__NO_SIDE_EFFECTS__ */
+import {
+ hydrateNode,
+ isComment,
+ isHydrating,
+ locateEndAnchor,
+ removeFragmentNodes,
+} from './dom/hydration'
+import { invokeArrayFns } from '@vue/shared'
+import { insert, remove } from './block'
+import { parentNode } from './dom/node'
+
+/*@ __NO_SIDE_EFFECTS__ */
export function defineVaporAsyncComponent<T extends VaporComponent>(
source: AsyncComponentLoader<T> | AsyncComponentOptions<T>,
): T {
loadingComponent,
errorComponent,
delay,
- // hydrate: hydrateStrategy,
+ hydrate: hydrateStrategy,
timeout,
- // suspensible = true,
+ suspensible = true,
},
} = createAsyncComponentContext<T, VaporComponent>(source)
__asyncLoader: load,
- // __asyncHydrate(el, instance, hydrate) {
- // // TODO async hydrate
- // },
+ __asyncHydrate(
+ el: Element,
+ instance: VaporComponentInstance,
+ // Note: this hydrate function essentially calls the setup method of the component
+ // not the actual hydrate function
+ hydrate: () => void,
+ ) {
+ // if async component needs to be updated before hydration, hydration is no longer needed.
+ let isHydrated = false
+ watch(
+ () => instance.attrs,
+ () => {
+ // early return if already hydrated
+ if (isHydrated) return
+
+ // call the beforeUpdate hook to avoid calling hydrate in performAsyncHydrate
+ instance.bu && invokeArrayFns(instance.bu)
+
+ // mount the inner component and remove the placeholder
+ const parent = parentNode(el)!
+ load().then(() => {
+ if (instance.isUnmounted) return
+ hydrate()
+ if (isComment(el, '[')) {
+ const endAnchor = locateEndAnchor(el)!
+ removeFragmentNodes(el, endAnchor)
+ insert(instance.block, parent, endAnchor)
+ } else {
+ insert(instance.block, parent, el)
+ remove(el, parent)
+ }
+ })
+ },
+ { deep: true, once: true },
+ )
+
+ performAsyncHydrate(
+ el,
+ instance,
+ () => {
+ hydrateNode(el, () => {
+ hydrate()
+ insert(instance.block, parentNode(el)!, el)
+ isHydrated = true
+ })
+ },
+ getResolvedComp,
+ load,
+ hydrateStrategy,
+ )
+ },
get __asyncResolved() {
return getResolvedComp()
const instance = currentInstance as VaporComponentInstance
markAsyncBoundary(instance)
- const frag = __DEV__
- ? new DynamicFragment('async component')
- : new DynamicFragment()
+ const frag =
+ __DEV__ || isHydrating
+ ? new DynamicFragment('async component')
+ : new DynamicFragment()
// already resolved
let resolvedComp = getResolvedComp()
if (resolvedComp) {
- frag.update(() => createInnerComp(resolvedComp!, instance))
+ // SSR
+ if (__SSR__ && isInSSRComponentSetup) {
+ return () => createSSRInnerComp(resolvedComp! as any, instance as any)
+ }
+
+ frag!.update(() => createInnerComp(resolvedComp!, instance))
return frag
}
)
}
- // TODO suspense-controlled or SSR.
+ // TODO suspense-controlled
+ if (__FEATURE_SUSPENSE__ && suspensible && instance.suspense) {
+ }
+
+ // SSR
+ if (__SSR__ && isInSSRComponentSetup) {
+ return loadSSRInnerComponent(
+ instance as any,
+ load,
+ onError,
+ errorComponent,
+ )
+ }
const { loaded, error, delayed } = useAsyncComponentState(
delay,
} else if (loadingComponent && !delayed.value) {
render = () => createComponent(loadingComponent)
}
- frag.update(render)
+ frag!.update(render)
})
return frag
import {
+ type AsyncComponentInternalOptions,
type ComponentInternalOptions,
type ComponentPropsOptions,
EffectScope,
currentInstance,
endMeasure,
expose,
+ isAsyncWrapper,
nextUid,
popWarningContext,
pushWarningContext,
setActiveSub,
unref,
} from '@vue/reactivity'
-import {
- EMPTY_OBJ,
- invokeArrayFns,
- isArray,
- isFunction,
- isString,
-} from '@vue/shared'
+import { EMPTY_OBJ, invokeArrayFns, isFunction, isString } from '@vue/shared'
import {
type DynamicPropsSource,
type RawProps,
getSlot,
} from './componentSlots'
import { hmrReload, hmrRerender } from './hmr'
-import { createElement } from './dom/node'
+import { _next, createElement } from './dom/node'
import {
adoptTemplate,
advanceHydrationNode,
currentHydrationNode,
+ isComment,
isHydrating,
+ locateEndAnchor,
locateHydrationNode,
setCurrentHydrationNode,
} from './dom/hydration'
export interface ObjectVaporComponent
extends ComponentInternalOptions,
+ AsyncComponentInternalOptions<ObjectVaporComponent, VaporComponentInstance>,
SharedInternalOptions {
setup?: VaporSetupFn
inheritAttrs?: boolean
name?: string
vapor?: boolean
- __asyncLoader?: () => Promise<VaporComponent>
- __asyncResolved?: VaporComponent
}
interface SharedInternalOptions {
instance.emitsOptions = normalizeEmitsOptions(component)
}
+ // hydrating async component
+ if (
+ isHydrating &&
+ isAsyncWrapper(instance) &&
+ component.__asyncHydrate &&
+ !component.__asyncResolved
+ ) {
+ // it may get unmounted before its inner component is loaded,
+ // so we need to give it a placeholder block that matches its
+ // adopted DOM
+ const el = currentHydrationNode!
+ if (isComment(el, '[')) {
+ const end = _next(locateEndAnchor(el)!)
+ const block = (instance.block = [el as Node])
+ let cur = el as Node
+ while (true) {
+ let n = _next(cur)
+ if (n && n !== end) {
+ block.push((cur = n))
+ } else {
+ break
+ }
+ }
+ } else {
+ instance.block = el
+ }
+ // also mark it as mounted to ensure it can be unmounted before
+ // its inner component is resolved
+ instance.isMounted = true
+
+ // advance current hydration node to the nextSibling
+ setCurrentHydrationNode(
+ isComment(el, '[') ? locateEndAnchor(el)! : el.nextSibling,
+ )
+ component.__asyncHydrate(el as Element, instance, () =>
+ setupComponent(instance, component, scopeId),
+ )
+ } else {
+ setupComponent(instance, component, scopeId)
+ }
+
+ onScopeDispose(() => unmountComponent(instance), true)
+
+ if (_insertionParent || isHydrating) {
+ mountComponent(instance, _insertionParent!, _insertionAnchor)
+ }
+
+ if (isHydrating && _insertionAnchor !== undefined) {
+ advanceHydrationNode(_insertionParent!)
+ }
+ return instance
+}
+
+export function setupComponent(
+ instance: VaporComponentInstance,
+ component: VaporComponent,
+ scopeId: string | undefined,
+): void {
const prevInstance = setCurrentInstance(instance)
const prevSub = setActiveSub()
}
}
+ if (scopeId) setScopeId(instance.block, scopeId)
+
setActiveSub(prevSub)
setCurrentInstance(...prevInstance)
popWarningContext()
endMeasure(instance, 'init')
}
-
- onScopeDispose(() => unmountComponent(instance), true)
-
- if (scopeId) setScopeId(instance.block, scopeId)
-
- if (_insertionParent) {
- mountComponent(instance, _insertionParent, _insertionAnchor)
- }
-
- if (isHydrating && _insertionAnchor !== undefined) {
- advanceHydrationNode(_insertionParent!)
- }
- return instance
}
export let isApplyingFallthroughProps = false
startMeasure(instance, `mount`)
}
if (instance.bm) invokeArrayFns(instance.bm)
- const block = instance.block
- if (isHydrating) {
- if (
- !(block instanceof Node) ||
- (isArray(block) && block.some(b => !(b instanceof Node)))
- ) {
- insert(block, parent, anchor)
- }
- } else {
- insert(block, parent, anchor)
+ if (!isHydrating) {
+ insert(instance.block, parent, anchor)
setComponentScopeId(instance)
}
-
if (instance.m) queuePostFlushCb(() => invokeArrayFns(instance.m!))
instance.isMounted = true
if (__DEV__) {
export let isHydrating = false
export let currentHydrationNode: Node | null = null
+function pushIsHydrating(value: boolean): void {
+ isHydratingStack.push((isHydrating = value))
+}
+
+function popIsHydrating(): void {
+ isHydratingStack.pop()
+ isHydrating = isHydratingStack[isHydratingStack.length - 1] || false
+}
+
export function runWithoutHydration(fn: () => any): any {
try {
- isHydrating = false
+ pushIsHydrating(false)
return fn()
} finally {
- isHydrating = true
+ popIsHydrating()
}
}
isOptimized = true
}
enableHydrationNodeLookup()
- isHydratingStack.push((isHydrating = true))
+ pushIsHydrating(true)
setup()
const res = fn()
cleanup()
currentHydrationNode = null
- isHydratingStack.pop()
- isHydrating = isHydratingStack[isHydratingStack.length - 1] || false
+ popIsHydrating()
if (!isHydrating) disableHydrationNodeLookup()
return res
}
// fragment
if (isComment(node, '[')) {
- const end = locateEndAnchor(node as Anchor)
- while (true) {
- const next = _next(node)
- if (next && next !== end) {
- remove(next, parentNode(node)!)
- } else {
- break
- }
- }
+ removeFragmentNodes(node)
}
const next = _next(node)
console.error('Hydration completed but contains mismatches.')
hasLoggedMismatchError = true
}
+
+export function removeFragmentNodes(node: Node, endAnchor?: Node): void {
+ const end = endAnchor || locateEndAnchor(node as Anchor)
+ while (true) {
+ const next = _next(node)
+ if (next && next !== end) {
+ remove(next, parentNode(node)!)
+ } else {
+ break
+ }
+ }
+}
if (this.anchor) return
// reuse the empty comment node as the anchor for empty if
+ // e.g. `<div v-if="false"></div>` -> `<!---->`
if (this.anchorLabel === 'if' && isEmpty) {
this.anchor = currentHydrationNode!
if (!this.anchor) {
--- /dev/null
+<div><span id="custom-trigger">click here to hydrate</span></div>
+<div id="app"><button>0</button></div>
+
+<script type="module">
+ import {
+ createVaporSSRApp,
+ defineVaporAsyncComponent,
+ ref,
+ onMounted,
+ delegateEvents,
+ template,
+ createIf,
+ createComponent,
+ child,
+ renderEffect,
+ setText,
+ } from '../../dist/vue.runtime-with-vapor.esm-browser.js'
+
+ delegateEvents('click')
+
+ window.isHydrated = false
+ const Comp = {
+ setup() {
+ const count = ref(0)
+ onMounted(() => {
+ console.log('hydrated')
+ window.isHydrated = true
+ })
+
+ const n0 = template('<button> </button>', true)()
+ const x0 = child(n0)
+ n0.$evtclick = () => count.value++
+ renderEffect(() => setText(x0, count.value))
+ return n0
+ },
+ }
+
+ const AsyncComp = defineVaporAsyncComponent({
+ loader: () => Promise.resolve(Comp),
+ hydrate: (hydrate, el) => {
+ const triggerEl = document.getElementById('custom-trigger')
+ triggerEl.addEventListener('click', hydrate, { once: true })
+ return () => {
+ window.teardownCalled = true
+ triggerEl.removeEventListener('click', hydrate)
+ }
+ },
+ })
+
+ const show = (window.show = ref(true))
+ createVaporSSRApp({
+ setup() {
+ onMounted(() => {
+ window.isRootMounted = true
+ })
+
+ const n0 = createIf(
+ () => show.value,
+ () => {
+ return createComponent(AsyncComp)
+ },
+ () => {
+ return template('off')()
+ },
+ )
+ return n0
+ },
+ }).mount('#app')
+</script>
--- /dev/null
+<div id="app"><button>0</button></div>
+
+<script type="module">
+ import {
+ createVaporSSRApp,
+ defineVaporAsyncComponent,
+ ref,
+ onMounted,
+ hydrateOnIdle,
+ delegateEvents,
+ template,
+ createComponent,
+ child,
+ renderEffect,
+ setText,
+ } from '../../dist/vue.runtime-with-vapor.esm-browser.js'
+
+ delegateEvents('click')
+
+ window.isHydrated = false
+ const Comp = {
+ setup() {
+ const count = ref(0)
+ onMounted(() => {
+ console.log('hydrated')
+ window.isHydrated = true
+ })
+
+ const n0 = template('<button> </button>', true)()
+ const x0 = child(n0)
+ n0.$evtclick = () => count.value++
+ renderEffect(() => setText(x0, count.value))
+ return n0
+ },
+ }
+
+ const AsyncComp = defineVaporAsyncComponent({
+ loader: () =>
+ new Promise(resolve => {
+ setTimeout(() => {
+ console.log('resolve')
+ resolve(Comp)
+ requestIdleCallback(() => {
+ console.log('busy')
+ })
+ }, 10)
+ }),
+ hydrate: hydrateOnIdle(),
+ })
+
+ createVaporSSRApp({
+ setup() {
+ return createComponent(AsyncComp)
+ },
+ }).mount('#app')
+</script>
--- /dev/null
+<div>click to hydrate</div>
+<div id="app"><button>0</button></div>
+<style>
+ body {
+ margin: 0;
+ }
+</style>
+
+<script type="module">
+ import {
+ createVaporSSRApp,
+ defineVaporAsyncComponent,
+ ref,
+ onMounted,
+ hydrateOnInteraction,
+ delegateEvents,
+ template,
+ createComponent,
+ child,
+ renderEffect,
+ setText,
+ } from '../../dist/vue.runtime-with-vapor.esm-browser.js'
+
+ delegateEvents('click')
+
+ const isFragment = location.search.includes('?fragment')
+ if (isFragment) {
+ document.getElementById('app').innerHTML =
+ `<!--[--><!--[--><span>one</span><!--]--><button>0</button><span>two</span><!--]-->`
+ }
+
+ window.isHydrated = false
+
+ const Comp = {
+ setup() {
+ const count = ref(0)
+ onMounted(() => {
+ console.log('hydrated')
+ window.isHydrated = true
+ })
+
+ if (isFragment) {
+ const n1 = template('<span>one</span>')()
+ const n0 = template('<button> </button>', true)()
+ const x0 = child(n0)
+ n0.$evtclick = () => count.value++
+ renderEffect(() => setText(x0, count.value))
+ const n2 = template('<span>two</span>')()
+ return [n1, n0, n2]
+ } else {
+ const n0 = template('<button> </button>', true)()
+ const x0 = child(n0)
+ n0.$evtclick = () => count.value++
+ renderEffect(() => setText(x0, count.value))
+ return n0
+ }
+ },
+ }
+
+ const AsyncComp = defineVaporAsyncComponent({
+ loader: () => Promise.resolve(Comp),
+ hydrate: hydrateOnInteraction(['click', 'wheel']),
+ })
+
+ createVaporSSRApp({
+ setup() {
+ onMounted(() => {
+ window.isRootMounted = true
+ })
+ return createComponent(AsyncComp)
+ },
+ }).mount('#app')
+</script>
--- /dev/null
+<div>resize the window width to < 500px to hydrate</div>
+<div id="app"><button>0</button></div>
+
+<script type="module">
+ import {
+ createVaporSSRApp,
+ defineVaporAsyncComponent,
+ ref,
+ onMounted,
+ hydrateOnMediaQuery,
+ delegateEvents,
+ template,
+ createComponent,
+ child,
+ renderEffect,
+ setText,
+ } from '../../dist/vue.runtime-with-vapor.esm-browser.js'
+
+ delegateEvents('click')
+
+ window.isHydrated = false
+ const Comp = {
+ props: {
+ value: Boolean,
+ },
+ setup(props) {
+ const count = ref(0)
+ onMounted(() => {
+ console.log('hydrated')
+ window.isHydrated = true
+ })
+
+ props.value
+ const n0 = template('<button> </button>', true)()
+ const x0 = child(n0)
+ n0.$evtclick = () => count.value++
+ renderEffect(() => setText(x0, count.value))
+ return n0
+ },
+ }
+
+ const AsyncComp = defineVaporAsyncComponent({
+ loader: () => Promise.resolve(Comp),
+ hydrate: hydrateOnMediaQuery('(max-width:500px)'),
+ })
+
+ createVaporSSRApp({
+ setup() {
+ onMounted(() => {
+ window.isRootMounted = true
+ })
+
+ const show = (window.show = ref(true))
+ return createComponent(AsyncComp, { value: () => show.value })
+ },
+ }).mount('#app')
+</script>
--- /dev/null
+<script src="../../dist/vue.global.js"></script>
+
+<div style="height: 1000px">scroll to the bottom to hydrate</div>
+<div id="app"><button>0</button></div>
+<style>
+ body {
+ margin: 0;
+ }
+</style>
+
+<script type="module">
+ import {
+ createVaporSSRApp,
+ defineVaporAsyncComponent,
+ ref,
+ onMounted,
+ hydrateOnVisible,
+ delegateEvents,
+ template,
+ createComponent,
+ child,
+ renderEffect,
+ setText,
+ } from '../../dist/vue.runtime-with-vapor.esm-browser.js'
+
+ delegateEvents('click')
+
+ const rootMargin = location.search.match(/rootMargin=(\d+)/)?.[1] ?? 0
+ const isFragment = location.search.includes('?fragment')
+ const isVIf = location.search.includes('?v-if')
+ if (isFragment) {
+ document.getElementById('app').innerHTML =
+ `<!--[--><!--[--><span>one</span><!--]--><button>0</button><span>two</span><!--]-->`
+ } else if (isVIf) {
+ document.getElementById('app').innerHTML = `<!---->`
+ }
+
+ window.isHydrated = false
+
+ const Comp = {
+ setup() {
+ const count = ref(0)
+ onMounted(() => {
+ console.log('hydrated')
+ window.isHydrated = true
+ })
+
+ if (isVIf) {
+ return template('<!--v-if-->')()
+ } else if (isFragment) {
+ const n1 = template('<span>one</span>')()
+ const n0 = template('<button> </button>', true)()
+ const x0 = child(n0)
+ n0.$evtclick = () => count.value++
+ renderEffect(() => setText(x0, count.value))
+ const n2 = template('<span>two</span>')()
+ return [n1, n0, n2]
+ } else {
+ const n0 = template('<button> </button>', true)()
+ const x0 = child(n0)
+ n0.$evtclick = () => count.value++
+ renderEffect(() => setText(x0, count.value))
+ return n0
+ }
+ },
+ }
+
+ const AsyncComp = defineVaporAsyncComponent({
+ loader: () => Promise.resolve(Comp),
+ hydrate: hydrateOnVisible({ rootMargin: rootMargin + 'px' }),
+ })
+
+ createVaporSSRApp({
+ setup() {
+ onMounted(() => {
+ window.isRootMounted = true
+ })
+ return createComponent(AsyncComp)
+ },
+ }).mount('#app')
+</script>
}
describe('async component hydration strategies', () => {
- const { page, click, text, count } = setupPuppeteer(['--window-size=800,600'])
+ const { page, click, text, count } = setupPuppeteer([
+ '--window-size=800,600',
+ '--disable-web-security',
+ ])
- async function goToCase(name: string, query = '') {
- const file = `file://${path.resolve(__dirname, `./hydration-strat-${name}.html${query}`)}`
+ async function goToCase(name: string, query = '', vapor = false) {
+ const file = `file://${path.resolve(__dirname, `./hydration-strat-${name}${vapor ? '-vapor' : ''}.html${query}`)}`
await page().goto(file)
}
expect(await text('button')).toBe(n)
}
- test('idle', async () => {
- const messages: string[] = []
- page().on('console', e => messages.push(e.text()))
-
- await goToCase('idle')
- // not hydrated yet
- expect(await page().evaluate(() => window.isHydrated)).toBe(false)
- // wait for hydration
- await page().waitForFunction(() => window.isHydrated)
- // assert message order: hyration should happen after already queued main thread work
- expect(messages.slice(1)).toMatchObject(['resolve', 'busy', 'hydrated'])
- await assertHydrationSuccess()
+ describe('vdom', () => {
+ runSharedTests(false)
})
- test('visible', async () => {
- await goToCase('visible')
- await page().waitForFunction(() => window.isRootMounted)
- expect(await page().evaluate(() => window.isHydrated)).toBe(false)
- // scroll down
- await page().evaluate(() => window.scrollTo({ top: 1000 }))
- await page().waitForFunction(() => window.isHydrated)
- await assertHydrationSuccess()
+ describe('vapor', () => {
+ runSharedTests(true)
})
- test('visible (with rootMargin)', async () => {
- await goToCase('visible', '?rootMargin=1000')
- await page().waitForFunction(() => window.isRootMounted)
- // should hydrate without needing to scroll
- await page().waitForFunction(() => window.isHydrated)
- await assertHydrationSuccess()
- })
-
- test('visible (fragment)', async () => {
- await goToCase('visible', '?fragment')
- await page().waitForFunction(() => window.isRootMounted)
- expect(await page().evaluate(() => window.isHydrated)).toBe(false)
- expect(await count('span')).toBe(2)
- // scroll down
- await page().evaluate(() => window.scrollTo({ top: 1000 }))
- await page().waitForFunction(() => window.isHydrated)
- await assertHydrationSuccess()
- })
-
- test('visible (root v-if) should not throw error', async () => {
- const spy = vi.fn()
- const currentPage = page()
- currentPage.on('pageerror', spy)
- await goToCase('visible', '?v-if')
- await page().waitForFunction(() => window.isRootMounted)
- expect(await page().evaluate(() => window.isHydrated)).toBe(false)
- expect(spy).toBeCalledTimes(0)
- currentPage.off('pageerror', spy)
- })
-
- test('media query', async () => {
- await goToCase('media')
- await page().waitForFunction(() => window.isRootMounted)
- expect(await page().evaluate(() => window.isHydrated)).toBe(false)
- // resize
- await page().setViewport({ width: 400, height: 600 })
- await page().waitForFunction(() => window.isHydrated)
- await assertHydrationSuccess()
- })
-
- // #13255
- test('media query (patched before hydration)', async () => {
- const spy = vi.fn()
- const currentPage = page()
- currentPage.on('pageerror', spy)
-
- const warn: any[] = []
- currentPage.on('console', e => warn.push(e.text()))
-
- await goToCase('media')
- await page().waitForFunction(() => window.isRootMounted)
- expect(await page().evaluate(() => window.isHydrated)).toBe(false)
-
- // patch
- await page().evaluate(() => (window.show.value = false))
- await click('button')
- expect(await text('button')).toBe('1')
-
- // resize
- await page().setViewport({ width: 400, height: 600 })
- await page().waitForFunction(() => window.isHydrated)
- await assertHydrationSuccess('2')
-
- expect(spy).toBeCalledTimes(0)
- currentPage.off('pageerror', spy)
- expect(
- warn.some(w => w.includes('Skipping lazy hydration for component')),
- ).toBe(true)
- })
-
- test('interaction', async () => {
- await goToCase('interaction')
- await page().waitForFunction(() => window.isRootMounted)
- expect(await page().evaluate(() => window.isHydrated)).toBe(false)
- await click('button')
- await page().waitForFunction(() => window.isHydrated)
- // should replay event
- expect(await text('button')).toBe('1')
- await assertHydrationSuccess('2')
- })
-
- test('interaction (fragment)', async () => {
- await goToCase('interaction', '?fragment')
- await page().waitForFunction(() => window.isRootMounted)
- expect(await page().evaluate(() => window.isHydrated)).toBe(false)
- await click('button')
- await page().waitForFunction(() => window.isHydrated)
- // should replay event
- expect(await text('button')).toBe('1')
- await assertHydrationSuccess('2')
- })
-
- test('custom', async () => {
- await goToCase('custom')
- await page().waitForFunction(() => window.isRootMounted)
- expect(await page().evaluate(() => window.isHydrated)).toBe(false)
- await click('#custom-trigger')
- await page().waitForFunction(() => window.isHydrated)
- await assertHydrationSuccess()
- })
-
- test('custom teardown', async () => {
- await goToCase('custom')
- await page().waitForFunction(() => window.isRootMounted)
- expect(await page().evaluate(() => window.isHydrated)).toBe(false)
- await page().evaluate(() => (window.show.value = false))
- expect(await text('#app')).toBe('off')
- expect(await page().evaluate(() => window.isHydrated)).toBe(false)
- expect(await page().evaluate(() => window.teardownCalled)).toBe(true)
- })
+ function runSharedTests(vapor: boolean) {
+ test('idle', async () => {
+ const messages: string[] = []
+ page().on('console', e => messages.push(e.text()))
+
+ await goToCase('idle', '', vapor)
+ // not hydrated yet
+ expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+ // wait for hydration
+ await page().waitForFunction(() => window.isHydrated)
+ // assert message order: hyration should happen after already queued main thread work
+ expect(messages.slice(1)).toMatchObject(['resolve', 'busy', 'hydrated'])
+ await assertHydrationSuccess()
+ })
+
+ test('visible', async () => {
+ await goToCase('visible', '', vapor)
+ await page().waitForFunction(() => window.isRootMounted)
+ expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+ // scroll down
+ await page().evaluate(() => window.scrollTo({ top: 1000 }))
+ await page().waitForFunction(() => window.isHydrated)
+ await assertHydrationSuccess()
+ })
+
+ test('visible (with rootMargin)', async () => {
+ await goToCase('visible', '?rootMargin=1000', vapor)
+ await page().waitForFunction(() => window.isRootMounted)
+ // should hydrate without needing to scroll
+ await page().waitForFunction(() => window.isHydrated)
+ await assertHydrationSuccess()
+ })
+
+ test('visible (fragment)', async () => {
+ await goToCase('visible', '?fragment', vapor)
+ await page().waitForFunction(() => window.isRootMounted)
+ expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+ expect(await count('span')).toBe(2)
+ // scroll down
+ await page().evaluate(() => window.scrollTo({ top: 1000 }))
+ await page().waitForFunction(() => window.isHydrated)
+ await assertHydrationSuccess()
+ })
+
+ test('visible (root v-if) should not throw error', async () => {
+ const spy = vi.fn()
+ const currentPage = page()
+ currentPage.on('pageerror', spy)
+ await goToCase('visible', '?v-if', vapor)
+ await page().waitForFunction(() => window.isRootMounted)
+ expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+ expect(spy).toBeCalledTimes(0)
+ currentPage.off('pageerror', spy)
+ })
+
+ test('media query', async () => {
+ await goToCase('media', '', vapor)
+ await page().waitForFunction(() => window.isRootMounted)
+ expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+ // resize
+ await page().setViewport({ width: 400, height: 600 })
+ await page().waitForFunction(() => window.isHydrated)
+ await assertHydrationSuccess()
+ })
+
+ // #13255
+ test('media query (patched before hydration)', async () => {
+ const spy = vi.fn()
+ const currentPage = page()
+ currentPage.on('pageerror', spy)
+
+ const warn: any[] = []
+ currentPage.on('console', e => warn.push(e.text()))
+
+ await goToCase('media', '', vapor)
+ await page().waitForFunction(() => window.isRootMounted)
+ expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+
+ // patch
+ await page().evaluate(() => (window.show.value = false))
+ await click('button')
+ expect(await text('button')).toBe('1')
+
+ // resize
+ await page().setViewport({ width: 400, height: 600 })
+ await page().waitForFunction(() => window.isHydrated)
+ await assertHydrationSuccess('2')
+
+ expect(spy).toBeCalledTimes(0)
+ currentPage.off('pageerror', spy)
+ expect(
+ warn.some(w => w.includes('Skipping lazy hydration for component')),
+ ).toBe(true)
+ })
+
+ test('interaction', async () => {
+ await goToCase('interaction', '', vapor)
+ await page().waitForFunction(() => window.isRootMounted)
+ expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+ await click('button')
+ await page().waitForFunction(() => window.isHydrated)
+ // should replay event
+ expect(await text('button')).toBe('1')
+ await assertHydrationSuccess('2')
+ })
+
+ test('interaction (fragment)', async () => {
+ await goToCase('interaction', '?fragment', vapor)
+ await page().waitForFunction(() => window.isRootMounted)
+ expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+ await click('button')
+ await page().waitForFunction(() => window.isHydrated)
+ // should replay event
+ expect(await text('button')).toBe('1')
+ await assertHydrationSuccess('2')
+ })
+
+ test('custom', async () => {
+ await goToCase('custom', '', vapor)
+ await page().waitForFunction(() => window.isRootMounted)
+ expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+ await click('#custom-trigger')
+ await page().waitForFunction(() => window.isHydrated)
+ await assertHydrationSuccess()
+ })
+
+ test('custom teardown', async () => {
+ await goToCase('custom', '', vapor)
+ await page().waitForFunction(() => window.isRootMounted)
+ expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+ await page().evaluate(() => (window.show.value = false))
+ expect(await text('#app')).toBe('off')
+ expect(await page().evaluate(() => window.isHydrated)).toBe(false)
+ expect(await page().evaluate(() => window.teardownCalled)).toBe(true)
+ })
+ }
})