]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(runtime-core): useId() (#11404)
authorEvan You <evan@vuejs.org>
Fri, 19 Jul 2024 10:06:02 +0000 (18:06 +0800)
committerGitHub <noreply@github.com>
Fri, 19 Jul 2024 10:06:02 +0000 (18:06 +0800)
packages/runtime-core/__tests__/helpers/useId.spec.ts [new file with mode: 0644]
packages/runtime-core/src/apiAsyncComponent.ts
packages/runtime-core/src/apiCreateApp.ts
packages/runtime-core/src/component.ts
packages/runtime-core/src/componentOptions.ts
packages/runtime-core/src/helpers/useId.ts [new file with mode: 0644]
packages/runtime-core/src/index.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 (file)
index 0000000..8277be1
--- /dev/null
@@ -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)
+  })
+})
index 452426c132419d51a958a5f9addd17648611e557..256673ff7de1b7f31e75bce1e8c56ea2443d68c9 100644 (file)
@@ -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
 
@@ -157,6 +158,8 @@ export function defineAsyncComponent<
                   })
                 : null
           })
+      } else {
+        markAsyncBoundary(instance)
       }
 
       const loaded = ref(false)
index ddf5888e845d7253ef07284334af4eb04e69bd68..44bb0390c9942c7ba9c425da16f45a5c031319f4 100644 (file)
@@ -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 {
index c3b943eeaa559cff57a947726383a7be9ec56c36..6c49f3bcd5057532f132344b10e8e6a6bd03b375 100644 (file)
@@ -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>
 
@@ -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
@@ -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: [],
 
@@ -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
index 2ede44042662e98b513ca54eaf4fa4f40f278a4c..888024a270383967b58dc3cf7e1260171b05671f 100644 (file)
@@ -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 (file)
index 0000000..dbf79e5
--- /dev/null
@@ -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]
+}
index e13565736faeeb1cbf7fe4883e5397570ddacacb..e4b1c55200ca8a418f7946d62a355ecc3ebafd4c 100644 (file)
@@ -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 ----------------------------------------------------------