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 1 commit
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
188 changes: 188 additions & 0 deletions packages/runtime-core/__tests__/helpers/useId.spec.ts
Original file line number Diff line number Diff line change
@@ -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<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('v0:0 v0: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 =
'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<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 =
'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<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>' +
'v0:0 v0:1 ' + // root
'v1:0 v1:1 ' + // inside first async subtree
'v2:0 v2: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)
})
})
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
12 changes: 12 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,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
Expand Down Expand Up @@ -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: [],

Expand Down Expand Up @@ -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
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.`,
)
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yyx990803 is there any good reason to not throw an error in case i == false ?
The code as it is right now results in return type of string | undefined.

Sure, a user might call useId outside a component, but I feel like this should just produce an error.
I am having the same problem as @dr46ul describes here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of the APIs that require an active currentInstance throws. This avoids breaking the entire user experience in unexpected cases and leaves the option to throw to the developer.

Copy link

@NiklasBeierl NiklasBeierl Sep 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd argue that not throwing in a situation where an active currentInstance is required constitutes "silent failure". But then again console warnings are issued and I might have spent too much time in python-land recently to appreciate avoiding a throw. 😂

Thanks for explaining! 👍

Copy link

@adamdehaven adamdehaven Sep 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason then it cannot return an empty string, (or some type of string) rather than undefined?

The string | undefined return type causes downstream apps (including Nuxt) to have to do something like useId()! or YOLO cast const myId = useId() as string to avoid having to account for undefined even when the dev knows they are calling it properly.

Copy link

@NiklasBeierl NiklasBeierl Sep 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess yolo-casting ist he way to go if you know you are calling it properly? (i.e. inside a component).

I now actually sort of appreciate this Situation since I was calling useId inside a composable and handling this situation explicitly (with a throw) prevents missunderstandings like trying to use it in a store. (But there are other composables that could be used in a store).


/**
* 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]
}
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