From b3649f7ec37f4ffc55eb99f013a82e0d6d49f5c7 Mon Sep 17 00:00:00 2001 From: Evan You Date: Fri, 19 Jul 2024 16:34:23 +0800 Subject: [PATCH 1/3] feat(runtime-core): useId() --- .../__tests__/helpers/useId.spec.ts | 188 ++++++++++++++++++ .../runtime-core/src/apiAsyncComponent.ts | 3 + packages/runtime-core/src/apiCreateApp.ts | 5 + packages/runtime-core/src/component.ts | 12 ++ packages/runtime-core/src/componentOptions.ts | 5 + packages/runtime-core/src/helpers/useId.ts | 27 +++ packages/runtime-core/src/index.ts | 1 + 7 files changed, 241 insertions(+) create mode 100644 packages/runtime-core/__tests__/helpers/useId.spec.ts create mode 100644 packages/runtime-core/src/helpers/useId.ts diff --git a/packages/runtime-core/__tests__/helpers/useId.spec.ts b/packages/runtime-core/__tests__/helpers/useId.spec.ts new file mode 100644 index 00000000000..74211892ef1 --- /dev/null +++ b/packages/runtime-core/__tests__/helpers/useId.spec.ts @@ -0,0 +1,188 @@ +/** + * @vitest-environment jsdom + */ +import { + type App, + Suspense, + createApp, + defineAsyncComponent, + defineComponent, + h, + useId, +} from 'vue' +import { renderToString } from '@vue/server-renderer' + +type TestCaseFactory = () => [App, Promise[]] + +async function runOnClient(factory: TestCaseFactory) { + const [app, deps] = factory() + const root = document.createElement('div') + app.mount(root) + await Promise.all(deps) + await promiseWithDelay(null, 0) + return root.innerHTML +} + +async function runOnServer(factory: TestCaseFactory) { + const [app, _] = factory() + return (await renderToString(app)) + .replace(//g, '') // remove fragment wrappers + .trim() +} + +async function getOutput(factory: TestCaseFactory) { + const clientResult = await runOnClient(factory) + const serverResult = await runOnServer(factory) + expect(serverResult).toBe(clientResult) + return clientResult +} + +function promiseWithDelay(res: any, delay: number) { + return new Promise(r => { + setTimeout(() => r(res), delay) + }) +} + +const BasicComponentWithUseId = defineComponent({ + setup() { + const id1 = useId() + const id2 = useId() + return () => [id1, ' ', id2] + }, +}) + +describe('useId', () => { + test('basic', async () => { + expect( + await getOutput(() => { + const app = createApp(BasicComponentWithUseId) + return [app, []] + }), + ).toBe('v0:0 v0:1') + }) + + test('async component', async () => { + const factory = ( + delay1: number, + delay2: number, + ): ReturnType => { + const p1 = promiseWithDelay(BasicComponentWithUseId, delay1) + const p2 = promiseWithDelay(BasicComponentWithUseId, delay2) + const AsyncOne = defineAsyncComponent(() => p1) + const AsyncTwo = defineAsyncComponent(() => p2) + const app = createApp({ + setup() { + const id1 = useId() + const id2 = useId() + return () => [id1, ' ', id2, ' ', h(AsyncOne), ' ', h(AsyncTwo)] + }, + }) + return [app, [p1, p2]] + } + + const expected = + 'v0:0 v0:1 ' + // root + 'v1:0 v1:1 ' + // inside first async subtree + 'v2:0 v2:1' // inside second async subtree + // assert different async resolution order does not affect id stable-ness + expect(await getOutput(() => factory(10, 20))).toBe(expected) + expect(await getOutput(() => factory(20, 10))).toBe(expected) + }) + + test('serverPrefetch', async () => { + const factory = ( + delay1: number, + delay2: number, + ): ReturnType => { + const p1 = promiseWithDelay(null, delay1) + const p2 = promiseWithDelay(null, delay2) + + const SPOne = defineComponent({ + async serverPrefetch() { + await p1 + }, + render() { + return h(BasicComponentWithUseId) + }, + }) + + const SPTwo = defineComponent({ + async serverPrefetch() { + await p2 + }, + render() { + return h(BasicComponentWithUseId) + }, + }) + + const app = createApp({ + setup() { + const id1 = useId() + const id2 = useId() + return () => [id1, ' ', id2, ' ', h(SPOne), ' ', h(SPTwo)] + }, + }) + return [app, [p1, p2]] + } + + const expected = + 'v0:0 v0:1 ' + // root + 'v1:0 v1:1 ' + // inside first async subtree + 'v2:0 v2:1' // inside second async subtree + // assert different async resolution order does not affect id stable-ness + expect(await getOutput(() => factory(10, 20))).toBe(expected) + expect(await getOutput(() => factory(20, 10))).toBe(expected) + }) + + test('async setup()', async () => { + const factory = ( + delay1: number, + delay2: number, + ): ReturnType => { + const p1 = promiseWithDelay(null, delay1) + const p2 = promiseWithDelay(null, delay2) + + const ASOne = defineComponent({ + async setup() { + await p1 + return {} + }, + render() { + return h(BasicComponentWithUseId) + }, + }) + + const ASTwo = defineComponent({ + async setup() { + await p2 + return {} + }, + render() { + return h(BasicComponentWithUseId) + }, + }) + + const app = createApp({ + setup() { + const id1 = useId() + const id2 = useId() + return () => + h(Suspense, null, { + default: h('div', [id1, ' ', id2, ' ', h(ASOne), ' ', h(ASTwo)]), + }) + }, + }) + return [app, [p1, p2]] + } + + const expected = + '
' + + 'v0:0 v0:1 ' + // root + 'v1:0 v1:1 ' + // inside first async subtree + 'v2:0 v2:1' + // inside second async subtree + '
' + // assert different async resolution order does not affect id stable-ness + expect(await getOutput(() => factory(10, 20))).toBe(expected) + expect(await getOutput(() => factory(20, 10))).toBe(expected) + }) +}) diff --git a/packages/runtime-core/src/apiAsyncComponent.ts b/packages/runtime-core/src/apiAsyncComponent.ts index 452426c1324..256673ff7de 100644 --- a/packages/runtime-core/src/apiAsyncComponent.ts +++ b/packages/runtime-core/src/apiAsyncComponent.ts @@ -15,6 +15,7 @@ import { ref } from '@vue/reactivity' import { ErrorCodes, handleError } from './errorHandling' import { isKeepAlive } from './components/KeepAlive' import { queueJob } from './scheduler' +import { markAsyncBoundary } from './helpers/useId' export type AsyncComponentResolveResult = T | { default: T } // es modules @@ -157,6 +158,8 @@ export function defineAsyncComponent< }) : null }) + } else { + markAsyncBoundary(instance) } const loaded = ref(false) diff --git a/packages/runtime-core/src/apiCreateApp.ts b/packages/runtime-core/src/apiCreateApp.ts index ddf5888e845..44bb0390c99 100644 --- a/packages/runtime-core/src/apiCreateApp.ts +++ b/packages/runtime-core/src/apiCreateApp.ts @@ -131,6 +131,11 @@ export interface AppConfig { * But in some cases, e.g. SSR, throwing might be more desirable. */ throwUnhandledErrorInProduction?: boolean + + /** + * Prefix for all useId() calls within this app + */ + idPrefix?: string } export interface AppContext { diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index c3b943eeaa5..767b09ed364 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -92,6 +92,7 @@ import type { SuspenseProps } from './components/Suspense' import type { KeepAliveProps } from './components/KeepAlive' import type { BaseTransitionProps } from './components/BaseTransition' import type { DefineComponent } from './apiDefineComponent' +import { markAsyncBoundary } from './helpers/useId' export type Data = Record @@ -356,6 +357,14 @@ export interface ComponentInternalInstance { * @internal */ provides: Data + /** + * for tracking useId() + * first number is the index of the current async boundrary + * second number is the index of the useId call within that boundary + * third number is the count of child async boundaries + * @internal + */ + ids: [number, number, number] /** * Tracking reactive effects (e.g. watchers) associated with this component * so that they can be automatically stopped on component unmount @@ -619,6 +628,7 @@ export function createComponentInstance( withProxy: null, provides: parent ? parent.provides : Object.create(appContext.provides), + ids: parent ? parent.ids : [0, 0, 0], accessCache: null!, renderCache: [], @@ -862,6 +872,8 @@ function setupStatefulComponent( reset() if (isPromise(setupResult)) { + // async setup, mark as async boundary for useId() + markAsyncBoundary(instance) setupResult.then(unsetCurrentInstance, unsetCurrentInstance) if (isSSR) { // return the promise so server-renderer can wait on it diff --git a/packages/runtime-core/src/componentOptions.ts b/packages/runtime-core/src/componentOptions.ts index 2ede4404266..888024a2703 100644 --- a/packages/runtime-core/src/componentOptions.ts +++ b/packages/runtime-core/src/componentOptions.ts @@ -84,6 +84,7 @@ import { type ComponentTypeEmits, normalizePropsOrEmits, } from './apiSetupHelpers' +import { markAsyncBoundary } from './helpers/useId' /** * Interface for declaring custom options. @@ -771,6 +772,10 @@ export function applyOptions(instance: ComponentInternalInstance) { ) { instance.filters = filters } + + if (__SSR__ && serverPrefetch) { + markAsyncBoundary(instance) + } } export function resolveInjections( diff --git a/packages/runtime-core/src/helpers/useId.ts b/packages/runtime-core/src/helpers/useId.ts new file mode 100644 index 00000000000..f307da0e932 --- /dev/null +++ b/packages/runtime-core/src/helpers/useId.ts @@ -0,0 +1,27 @@ +import { + type ComponentInternalInstance, + getCurrentInstance, +} from '../component' +import { warn } from '../warning' + +export function useId() { + const i = getCurrentInstance() + if (i) { + return (i.appContext.config.idPrefix || 'v') + i.ids[0] + ':' + i.ids[1]++ + } else if (__DEV__) { + warn( + `useId() is called when there is no active component ` + + `instance to be associated with.`, + ) + } +} + +/** + * There are 3 types of async boundaries: + * - async components + * - components with async setup() + * - components with serverPrefetch + */ +export function markAsyncBoundary(instance: ComponentInternalInstance) { + instance.ids = [++instance.ids[2], 0, 0] +} diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index e13565736fa..e4b1c55200c 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -63,6 +63,7 @@ export { defineAsyncComponent } from './apiAsyncComponent' export { useAttrs, useSlots } from './apiSetupHelpers' export { useModel } from './helpers/useModel' export { useTemplateRef } from './helpers/useTemplateRef' +export { useId } from './helpers/useId' //