From d14a11c1cdcee88452f17ce97758743c863958f4 Mon Sep 17 00:00:00 2001 From: Evan You Date: Wed, 31 Jul 2024 12:14:51 +0800 Subject: [PATCH] feat: lazy hydration strategies for async components (#11458) --- .../runtime-core/src/apiAsyncComponent.ts | 21 ++++ packages/runtime-core/src/componentOptions.ts | 9 ++ packages/runtime-core/src/hydration.ts | 4 +- .../runtime-core/src/hydrationStrategies.ts | 111 ++++++++++++++++ packages/runtime-core/src/index.ts | 10 ++ packages/runtime-core/src/renderer.ts | 15 +-- packages/vue/__tests__/e2e/e2eUtils.ts | 11 +- .../__tests__/e2e/hydration-strat-custom.html | 44 +++++++ .../__tests__/e2e/hydration-strat-idle.html | 36 ++++++ .../e2e/hydration-strat-interaction.html | 48 +++++++ .../__tests__/e2e/hydration-strat-media.html | 36 ++++++ .../e2e/hydration-strat-visible.html | 49 ++++++++ .../__tests__/e2e/hydrationStrategies.spec.ts | 118 ++++++++++++++++++ 13 files changed, 498 insertions(+), 14 deletions(-) create mode 100644 packages/runtime-core/src/hydrationStrategies.ts create mode 100644 packages/vue/__tests__/e2e/hydration-strat-custom.html create mode 100644 packages/vue/__tests__/e2e/hydration-strat-idle.html create mode 100644 packages/vue/__tests__/e2e/hydration-strat-interaction.html create mode 100644 packages/vue/__tests__/e2e/hydration-strat-media.html create mode 100644 packages/vue/__tests__/e2e/hydration-strat-visible.html create mode 100644 packages/vue/__tests__/e2e/hydrationStrategies.spec.ts diff --git a/packages/runtime-core/src/apiAsyncComponent.ts b/packages/runtime-core/src/apiAsyncComponent.ts index dc1d3ae1141..e1c9a0ce06f 100644 --- a/packages/runtime-core/src/apiAsyncComponent.ts +++ b/packages/runtime-core/src/apiAsyncComponent.ts @@ -16,6 +16,7 @@ import { ErrorCodes, handleError } from './errorHandling' import { isKeepAlive } from './components/KeepAlive' import { queueJob } from './scheduler' import { markAsyncBoundary } from './helpers/useId' +import { type HydrationStrategy, forEachElement } from './hydrationStrategies' export type AsyncComponentResolveResult = T | { default: T } // es modules @@ -30,6 +31,7 @@ export interface AsyncComponentOptions { delay?: number timeout?: number suspensible?: boolean + hydrate?: HydrationStrategy onError?: ( error: Error, retry: () => void, @@ -54,6 +56,7 @@ export function defineAsyncComponent< loadingComponent, errorComponent, delay = 200, + hydrate: hydrateStrategy, timeout, // undefined = never times out suspensible = true, onError: userOnError, @@ -118,6 +121,24 @@ export function defineAsyncComponent< __asyncLoader: load, + __asyncHydrate(el, instance, hydrate) { + const doHydrate = hydrateStrategy + ? () => { + const teardown = hydrateStrategy(hydrate, cb => + forEachElement(el, cb), + ) + if (teardown) { + ;(instance.bum || (instance.bum = [])).push(teardown) + } + } + : hydrate + if (resolvedComp) { + doHydrate() + } else { + load().then(() => !instance.isUnmounted && doHydrate()) + } + }, + get __asyncResolved() { return resolvedComp }, diff --git a/packages/runtime-core/src/componentOptions.ts b/packages/runtime-core/src/componentOptions.ts index 888024a2703..f426429f2eb 100644 --- a/packages/runtime-core/src/componentOptions.ts +++ b/packages/runtime-core/src/componentOptions.ts @@ -199,6 +199,15 @@ export interface ComponentOptionsBase< * @internal */ __asyncResolved?: ConcreteComponent + /** + * Exposed for lazy hydration + * @internal + */ + __asyncHydrate?: ( + el: Element, + instance: ComponentInternalInstance, + hydrate: () => void, + ) => void // Type differentiators ------------------------------------------------------ diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts index e79a9cede3d..27a9a7d58a1 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -46,7 +46,7 @@ export type RootHydrateFunction = ( container: (Element | ShadowRoot) & { _vnode?: VNode }, ) => void -enum DOMNodeTypes { +export enum DOMNodeTypes { ELEMENT = 1, TEXT = 3, COMMENT = 8, @@ -75,7 +75,7 @@ const getContainerType = (container: Element): 'svg' | 'mathml' | undefined => { return undefined } -const isComment = (node: Node): node is Comment => +export const isComment = (node: Node): node is Comment => node.nodeType === DOMNodeTypes.COMMENT // Note: hydration is DOM-specific diff --git a/packages/runtime-core/src/hydrationStrategies.ts b/packages/runtime-core/src/hydrationStrategies.ts new file mode 100644 index 00000000000..4f0a2d23e1a --- /dev/null +++ b/packages/runtime-core/src/hydrationStrategies.ts @@ -0,0 +1,111 @@ +import { isString } from '@vue/shared' +import { DOMNodeTypes, isComment } from './hydration' + +/** + * A lazy hydration strategy for async components. + * @param hydrate - call this to perform the actual hydration. + * @param forEachElement - iterate through the root elements of the component's + * non-hydrated DOM, accounting for possible fragments. + * @returns a teardown function to be called if the async component is unmounted + * before it is hydrated. This can be used to e.g. remove DOM event + * listeners. + */ +export type HydrationStrategy = ( + hydrate: () => void, + forEachElement: (cb: (el: Element) => any) => void, +) => (() => void) | void + +export type HydrationStrategyFactory = ( + options?: Options, +) => HydrationStrategy + +export const hydrateOnIdle: HydrationStrategyFactory = () => hydrate => { + const id = requestIdleCallback(hydrate) + return () => cancelIdleCallback(id) +} + +export const hydrateOnVisible: HydrationStrategyFactory = + (margin = 0) => + (hydrate, forEach) => { + const ob = new IntersectionObserver( + entries => { + for (const e of entries) { + if (!e.isIntersecting) continue + ob.disconnect() + hydrate() + break + } + }, + { + rootMargin: isString(margin) ? margin : margin + 'px', + }, + ) + forEach(el => ob.observe(el)) + return () => ob.disconnect() + } + +export const hydrateOnMediaQuery: HydrationStrategyFactory = + query => hydrate => { + if (query) { + const mql = matchMedia(query) + if (mql.matches) { + hydrate() + } else { + mql.addEventListener('change', hydrate, { once: true }) + return () => mql.removeEventListener('change', hydrate) + } + } + } + +export const hydrateOnInteraction: HydrationStrategyFactory< + string | string[] +> = + (interactions = []) => + (hydrate, forEach) => { + if (isString(interactions)) interactions = [interactions] + let hasHydrated = false + const doHydrate = (e: Event) => { + if (!hasHydrated) { + hasHydrated = true + teardown() + hydrate() + // replay event + e.target!.dispatchEvent(new (e.constructor as any)(e.type, e)) + } + } + const teardown = () => { + forEach(el => { + for (const i of interactions) { + el.removeEventListener(i, doHydrate) + } + }) + } + forEach(el => { + for (const i of interactions) { + el.addEventListener(i, doHydrate, { once: true }) + } + }) + return teardown + } + +export function forEachElement(node: Node, cb: (el: Element) => void) { + // fragment + if (isComment(node) && node.data === '[') { + let depth = 1 + let next = node.nextSibling + while (next) { + if (next.nodeType === DOMNodeTypes.ELEMENT) { + cb(next as Element) + } else if (isComment(next)) { + if (next.data === ']') { + if (--depth === 0) break + } else if (next.data === '[') { + depth++ + } + } + next = next.nextSibling + } + } else { + cb(node as Element) + } +} diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index e4b1c55200c..b8dc513689e 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -64,6 +64,12 @@ export { useAttrs, useSlots } from './apiSetupHelpers' export { useModel } from './helpers/useModel' export { useTemplateRef } from './helpers/useTemplateRef' export { useId } from './helpers/useId' +export { + hydrateOnIdle, + hydrateOnVisible, + hydrateOnMediaQuery, + hydrateOnInteraction, +} from './hydrationStrategies' // + +
click here to hydrate
+
+ + diff --git a/packages/vue/__tests__/e2e/hydration-strat-idle.html b/packages/vue/__tests__/e2e/hydration-strat-idle.html new file mode 100644 index 00000000000..56017131257 --- /dev/null +++ b/packages/vue/__tests__/e2e/hydration-strat-idle.html @@ -0,0 +1,36 @@ + + +
+ + diff --git a/packages/vue/__tests__/e2e/hydration-strat-interaction.html b/packages/vue/__tests__/e2e/hydration-strat-interaction.html new file mode 100644 index 00000000000..9f4f44d99c8 --- /dev/null +++ b/packages/vue/__tests__/e2e/hydration-strat-interaction.html @@ -0,0 +1,48 @@ + + +
click to hydrate
+
+ + + diff --git a/packages/vue/__tests__/e2e/hydration-strat-media.html b/packages/vue/__tests__/e2e/hydration-strat-media.html new file mode 100644 index 00000000000..f8d30a09ba3 --- /dev/null +++ b/packages/vue/__tests__/e2e/hydration-strat-media.html @@ -0,0 +1,36 @@ + + +
resize the window width to < 500px to hydrate
+
+ + diff --git a/packages/vue/__tests__/e2e/hydration-strat-visible.html b/packages/vue/__tests__/e2e/hydration-strat-visible.html new file mode 100644 index 00000000000..863455c8450 --- /dev/null +++ b/packages/vue/__tests__/e2e/hydration-strat-visible.html @@ -0,0 +1,49 @@ + + +
scroll to the bottom to hydrate
+
+ + + diff --git a/packages/vue/__tests__/e2e/hydrationStrategies.spec.ts b/packages/vue/__tests__/e2e/hydrationStrategies.spec.ts new file mode 100644 index 00000000000..58e3784ba7f --- /dev/null +++ b/packages/vue/__tests__/e2e/hydrationStrategies.spec.ts @@ -0,0 +1,118 @@ +import path from 'node:path' +import { setupPuppeteer } from './e2eUtils' +import type { Ref } from '../../src/runtime' + +declare const window: Window & { + isHydrated: boolean + isRootMounted: boolean + teardownCalled?: boolean + show: Ref +} + +describe('async component hydration strategies', () => { + const { page, click, text, count } = setupPuppeteer(['--window-size=800,600']) + + async function goToCase(name: string, query = '') { + const file = `file://${path.resolve(__dirname, `./hydration-strat-${name}.html${query}`)}` + await page().goto(file) + } + + async function assertHydrationSuccess(n = '1') { + await click('button') + 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() + }) + + 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() + }) + + 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('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() + }) + + 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) + }) +})