Skip to content

Commit

Permalink
feat(runtime-core): useId() (#11404)
Browse files Browse the repository at this point in the history
  • Loading branch information
yyx990803 authored Jul 19, 2024
1 parent 3f8cbb2 commit 73ef156
Show file tree
Hide file tree
Showing 7 changed files with 294 additions and 0 deletions.
242 changes: 242 additions & 0 deletions packages/runtime-core/__tests__/helpers/useId.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/**
* @vitest-environment jsdom
*/
import {
type App,
Suspense,
createApp,
defineAsyncComponent,
defineComponent,
h,
useId,
} from 'vue'
import { renderToString } from '@vue/server-renderer'

type TestCaseFactory = () => [App, Promise<any>[]]

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<any>(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('v:0 v:1')
})

test('with config.idPrefix', async () => {
expect(
await getOutput(() => {
const app = createApp(BasicComponentWithUseId)
app.config.idPrefix = 'foo'
return [app, []]
}),
).toBe('foo:0 foo:1')
})

test('async component', async () => {
const factory = (
delay1: number,
delay2: number,
): ReturnType<TestCaseFactory> => {
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 =
'v:0 v:1 ' + // root
'v:0-0 v:0-1 ' + // inside first async subtree
'v:1-0 v:1-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<TestCaseFactory> => {
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 =
'v:0 v:1 ' + // root
'v:0-0 v:0-1 ' + // inside first async subtree
'v:1-0 v:1-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<TestCaseFactory> => {
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 =
'<div>' +
'v:0 v:1 ' + // root
'v:0-0 v:0-1 ' + // inside first async subtree
'v:1-0 v:1-1' + // inside second async subtree
'</div>'
// 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('deep nested', async () => {
const factory = (): ReturnType<TestCaseFactory> => {
const p = Promise.resolve()
const One = {
async setup() {
const id = useId()
await p
return () => [id, ' ', h(Two), ' ', h(Three)]
},
}
const Two = {
async setup() {
const id = useId()
await p
return () => [id, ' ', h(Three), ' ', h(Three)]
},
}
const Three = {
async setup() {
const id = useId()
return () => id
},
}
const app = createApp({
setup() {
return () =>
h(Suspense, null, {
default: h(One),
})
},
})
return [app, [p]]
}

const expected =
'v:0 ' + // One
'v:0-0 ' + // Two
'v:0-0-0 v:0-0-1 ' + // Three + Three nested in Two
'v:0-1' // Three after Two
// assert different async resolution order does not affect id stable-ness
expect(await getOutput(() => factory())).toBe(expected)
expect(await getOutput(() => factory())).toBe(expected)
})
})
3 changes: 3 additions & 0 deletions packages/runtime-core/src/apiAsyncComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = Component> = T | { default: T } // es modules

Expand Down Expand Up @@ -157,6 +158,8 @@ export function defineAsyncComponent<
})
: null
})
} else {
markAsyncBoundary(instance)
}

const loaded = ref(false)
Expand Down
5 changes: 5 additions & 0 deletions packages/runtime-core/src/apiCreateApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions packages/runtime-core/src/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>

Expand Down Expand Up @@ -356,6 +357,13 @@ export interface ComponentInternalInstance {
* @internal
*/
provides: Data
/**
* for tracking useId()
* first element is the current boundary prefix
* second number is the index of the useId call within that boundary
* @internal
*/
ids: [string, number, number]
/**
* Tracking reactive effects (e.g. watchers) associated with this component
* so that they can be automatically stopped on component unmount
Expand Down Expand Up @@ -619,6 +627,7 @@ export function createComponentInstance(
withProxy: null,

provides: parent ? parent.provides : Object.create(appContext.provides),
ids: parent ? parent.ids : ['', 0, 0],
accessCache: null!,
renderCache: [],

Expand Down Expand Up @@ -862,6 +871,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
Expand Down
5 changes: 5 additions & 0 deletions packages/runtime-core/src/componentOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import {
type ComponentTypeEmits,
normalizePropsOrEmits,
} from './apiSetupHelpers'
import { markAsyncBoundary } from './helpers/useId'

/**
* Interface for declaring custom options.
Expand Down Expand Up @@ -771,6 +772,10 @@ export function applyOptions(instance: ComponentInternalInstance) {
) {
instance.filters = filters
}

if (__SSR__ && serverPrefetch) {
markAsyncBoundary(instance)
}
}

export function resolveInjections(
Expand Down
27 changes: 27 additions & 0 deletions packages/runtime-core/src/helpers/useId.ts
Original file line number Diff line number Diff line change
@@ -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[0] + instance.ids[2]++ + '-', 0, 0]
}
1 change: 1 addition & 0 deletions packages/runtime-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

// <script setup> API ----------------------------------------------------------

Expand Down

0 comments on commit 73ef156

Please sign in to comment.