Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(runtime-core): useId() #11404

Merged
merged 3 commits into from
Jul 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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