Skip to content

Commit 73ef156

Browse files
authoredJul 19, 2024··
feat(runtime-core): useId() (#11404)
1 parent 3f8cbb2 commit 73ef156

File tree

7 files changed

+294
-0
lines changed

7 files changed

+294
-0
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import {
5+
type App,
6+
Suspense,
7+
createApp,
8+
defineAsyncComponent,
9+
defineComponent,
10+
h,
11+
useId,
12+
} from 'vue'
13+
import { renderToString } from '@vue/server-renderer'
14+
15+
type TestCaseFactory = () => [App, Promise<any>[]]
16+
17+
async function runOnClient(factory: TestCaseFactory) {
18+
const [app, deps] = factory()
19+
const root = document.createElement('div')
20+
app.mount(root)
21+
await Promise.all(deps)
22+
await promiseWithDelay(null, 0)
23+
return root.innerHTML
24+
}
25+
26+
async function runOnServer(factory: TestCaseFactory) {
27+
const [app, _] = factory()
28+
return (await renderToString(app))
29+
.replace(/<!--[\[\]]-->/g, '') // remove fragment wrappers
30+
.trim()
31+
}
32+
33+
async function getOutput(factory: TestCaseFactory) {
34+
const clientResult = await runOnClient(factory)
35+
const serverResult = await runOnServer(factory)
36+
expect(serverResult).toBe(clientResult)
37+
return clientResult
38+
}
39+
40+
function promiseWithDelay(res: any, delay: number) {
41+
return new Promise<any>(r => {
42+
setTimeout(() => r(res), delay)
43+
})
44+
}
45+
46+
const BasicComponentWithUseId = defineComponent({
47+
setup() {
48+
const id1 = useId()
49+
const id2 = useId()
50+
return () => [id1, ' ', id2]
51+
},
52+
})
53+
54+
describe('useId', () => {
55+
test('basic', async () => {
56+
expect(
57+
await getOutput(() => {
58+
const app = createApp(BasicComponentWithUseId)
59+
return [app, []]
60+
}),
61+
).toBe('v:0 v:1')
62+
})
63+
64+
test('with config.idPrefix', async () => {
65+
expect(
66+
await getOutput(() => {
67+
const app = createApp(BasicComponentWithUseId)
68+
app.config.idPrefix = 'foo'
69+
return [app, []]
70+
}),
71+
).toBe('foo:0 foo:1')
72+
})
73+
74+
test('async component', async () => {
75+
const factory = (
76+
delay1: number,
77+
delay2: number,
78+
): ReturnType<TestCaseFactory> => {
79+
const p1 = promiseWithDelay(BasicComponentWithUseId, delay1)
80+
const p2 = promiseWithDelay(BasicComponentWithUseId, delay2)
81+
const AsyncOne = defineAsyncComponent(() => p1)
82+
const AsyncTwo = defineAsyncComponent(() => p2)
83+
const app = createApp({
84+
setup() {
85+
const id1 = useId()
86+
const id2 = useId()
87+
return () => [id1, ' ', id2, ' ', h(AsyncOne), ' ', h(AsyncTwo)]
88+
},
89+
})
90+
return [app, [p1, p2]]
91+
}
92+
93+
const expected =
94+
'v:0 v:1 ' + // root
95+
'v:0-0 v:0-1 ' + // inside first async subtree
96+
'v:1-0 v:1-1' // inside second async subtree
97+
// assert different async resolution order does not affect id stable-ness
98+
expect(await getOutput(() => factory(10, 20))).toBe(expected)
99+
expect(await getOutput(() => factory(20, 10))).toBe(expected)
100+
})
101+
102+
test('serverPrefetch', async () => {
103+
const factory = (
104+
delay1: number,
105+
delay2: number,
106+
): ReturnType<TestCaseFactory> => {
107+
const p1 = promiseWithDelay(null, delay1)
108+
const p2 = promiseWithDelay(null, delay2)
109+
110+
const SPOne = defineComponent({
111+
async serverPrefetch() {
112+
await p1
113+
},
114+
render() {
115+
return h(BasicComponentWithUseId)
116+
},
117+
})
118+
119+
const SPTwo = defineComponent({
120+
async serverPrefetch() {
121+
await p2
122+
},
123+
render() {
124+
return h(BasicComponentWithUseId)
125+
},
126+
})
127+
128+
const app = createApp({
129+
setup() {
130+
const id1 = useId()
131+
const id2 = useId()
132+
return () => [id1, ' ', id2, ' ', h(SPOne), ' ', h(SPTwo)]
133+
},
134+
})
135+
return [app, [p1, p2]]
136+
}
137+
138+
const expected =
139+
'v:0 v:1 ' + // root
140+
'v:0-0 v:0-1 ' + // inside first async subtree
141+
'v:1-0 v:1-1' // inside second async subtree
142+
// assert different async resolution order does not affect id stable-ness
143+
expect(await getOutput(() => factory(10, 20))).toBe(expected)
144+
expect(await getOutput(() => factory(20, 10))).toBe(expected)
145+
})
146+
147+
test('async setup()', async () => {
148+
const factory = (
149+
delay1: number,
150+
delay2: number,
151+
): ReturnType<TestCaseFactory> => {
152+
const p1 = promiseWithDelay(null, delay1)
153+
const p2 = promiseWithDelay(null, delay2)
154+
155+
const ASOne = defineComponent({
156+
async setup() {
157+
await p1
158+
return {}
159+
},
160+
render() {
161+
return h(BasicComponentWithUseId)
162+
},
163+
})
164+
165+
const ASTwo = defineComponent({
166+
async setup() {
167+
await p2
168+
return {}
169+
},
170+
render() {
171+
return h(BasicComponentWithUseId)
172+
},
173+
})
174+
175+
const app = createApp({
176+
setup() {
177+
const id1 = useId()
178+
const id2 = useId()
179+
return () =>
180+
h(Suspense, null, {
181+
default: h('div', [id1, ' ', id2, ' ', h(ASOne), ' ', h(ASTwo)]),
182+
})
183+
},
184+
})
185+
return [app, [p1, p2]]
186+
}
187+
188+
const expected =
189+
'<div>' +
190+
'v:0 v:1 ' + // root
191+
'v:0-0 v:0-1 ' + // inside first async subtree
192+
'v:1-0 v:1-1' + // inside second async subtree
193+
'</div>'
194+
// assert different async resolution order does not affect id stable-ness
195+
expect(await getOutput(() => factory(10, 20))).toBe(expected)
196+
expect(await getOutput(() => factory(20, 10))).toBe(expected)
197+
})
198+
199+
test('deep nested', async () => {
200+
const factory = (): ReturnType<TestCaseFactory> => {
201+
const p = Promise.resolve()
202+
const One = {
203+
async setup() {
204+
const id = useId()
205+
await p
206+
return () => [id, ' ', h(Two), ' ', h(Three)]
207+
},
208+
}
209+
const Two = {
210+
async setup() {
211+
const id = useId()
212+
await p
213+
return () => [id, ' ', h(Three), ' ', h(Three)]
214+
},
215+
}
216+
const Three = {
217+
async setup() {
218+
const id = useId()
219+
return () => id
220+
},
221+
}
222+
const app = createApp({
223+
setup() {
224+
return () =>
225+
h(Suspense, null, {
226+
default: h(One),
227+
})
228+
},
229+
})
230+
return [app, [p]]
231+
}
232+
233+
const expected =
234+
'v:0 ' + // One
235+
'v:0-0 ' + // Two
236+
'v:0-0-0 v:0-0-1 ' + // Three + Three nested in Two
237+
'v:0-1' // Three after Two
238+
// assert different async resolution order does not affect id stable-ness
239+
expect(await getOutput(() => factory())).toBe(expected)
240+
expect(await getOutput(() => factory())).toBe(expected)
241+
})
242+
})

‎packages/runtime-core/src/apiAsyncComponent.ts

+3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { ref } from '@vue/reactivity'
1515
import { ErrorCodes, handleError } from './errorHandling'
1616
import { isKeepAlive } from './components/KeepAlive'
1717
import { queueJob } from './scheduler'
18+
import { markAsyncBoundary } from './helpers/useId'
1819

1920
export type AsyncComponentResolveResult<T = Component> = T | { default: T } // es modules
2021

@@ -157,6 +158,8 @@ export function defineAsyncComponent<
157158
})
158159
: null
159160
})
161+
} else {
162+
markAsyncBoundary(instance)
160163
}
161164

162165
const loaded = ref(false)

‎packages/runtime-core/src/apiCreateApp.ts

+5
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,11 @@ export interface AppConfig {
131131
* But in some cases, e.g. SSR, throwing might be more desirable.
132132
*/
133133
throwUnhandledErrorInProduction?: boolean
134+
135+
/**
136+
* Prefix for all useId() calls within this app
137+
*/
138+
idPrefix?: string
134139
}
135140

136141
export interface AppContext {

‎packages/runtime-core/src/component.ts

+11
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ import type { SuspenseProps } from './components/Suspense'
9292
import type { KeepAliveProps } from './components/KeepAlive'
9393
import type { BaseTransitionProps } from './components/BaseTransition'
9494
import type { DefineComponent } from './apiDefineComponent'
95+
import { markAsyncBoundary } from './helpers/useId'
9596

9697
export type Data = Record<string, unknown>
9798

@@ -356,6 +357,13 @@ export interface ComponentInternalInstance {
356357
* @internal
357358
*/
358359
provides: Data
360+
/**
361+
* for tracking useId()
362+
* first element is the current boundary prefix
363+
* second number is the index of the useId call within that boundary
364+
* @internal
365+
*/
366+
ids: [string, number, number]
359367
/**
360368
* Tracking reactive effects (e.g. watchers) associated with this component
361369
* so that they can be automatically stopped on component unmount
@@ -619,6 +627,7 @@ export function createComponentInstance(
619627
withProxy: null,
620628

621629
provides: parent ? parent.provides : Object.create(appContext.provides),
630+
ids: parent ? parent.ids : ['', 0, 0],
622631
accessCache: null!,
623632
renderCache: [],
624633

@@ -862,6 +871,8 @@ function setupStatefulComponent(
862871
reset()
863872

864873
if (isPromise(setupResult)) {
874+
// async setup, mark as async boundary for useId()
875+
markAsyncBoundary(instance)
865876
setupResult.then(unsetCurrentInstance, unsetCurrentInstance)
866877
if (isSSR) {
867878
// return the promise so server-renderer can wait on it

‎packages/runtime-core/src/componentOptions.ts

+5
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import {
8484
type ComponentTypeEmits,
8585
normalizePropsOrEmits,
8686
} from './apiSetupHelpers'
87+
import { markAsyncBoundary } from './helpers/useId'
8788

8889
/**
8990
* Interface for declaring custom options.
@@ -771,6 +772,10 @@ export function applyOptions(instance: ComponentInternalInstance) {
771772
) {
772773
instance.filters = filters
773774
}
775+
776+
if (__SSR__ && serverPrefetch) {
777+
markAsyncBoundary(instance)
778+
}
774779
}
775780

776781
export function resolveInjections(
+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {
2+
type ComponentInternalInstance,
3+
getCurrentInstance,
4+
} from '../component'
5+
import { warn } from '../warning'
6+
7+
export function useId() {
8+
const i = getCurrentInstance()
9+
if (i) {
10+
return (i.appContext.config.idPrefix || 'v') + ':' + i.ids[0] + i.ids[1]++
11+
} else if (__DEV__) {
12+
warn(
13+
`useId() is called when there is no active component ` +
14+
`instance to be associated with.`,
15+
)
16+
}
17+
}
18+
19+
/**
20+
* There are 3 types of async boundaries:
21+
* - async components
22+
* - components with async setup()
23+
* - components with serverPrefetch
24+
*/
25+
export function markAsyncBoundary(instance: ComponentInternalInstance) {
26+
instance.ids = [instance.ids[0] + instance.ids[2]++ + '-', 0, 0]
27+
}

‎packages/runtime-core/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export { defineAsyncComponent } from './apiAsyncComponent'
6363
export { useAttrs, useSlots } from './apiSetupHelpers'
6464
export { useModel } from './helpers/useModel'
6565
export { useTemplateRef } from './helpers/useTemplateRef'
66+
export { useId } from './helpers/useId'
6667

6768
// <script setup> API ----------------------------------------------------------
6869

0 commit comments

Comments
 (0)
Please sign in to comment.