feat(nuxt): automatically generate unique keys for keyed composables (#4955)

Co-authored-by: Pooya Parsa <pyapar@gmail.com>
This commit is contained in:
Daniel Roe 2022-07-07 17:26:04 +01:00 committed by GitHub
parent 4d607080f5
commit 23546a270c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 255 additions and 39 deletions

View File

@ -22,6 +22,7 @@
"jsdoc/require-param": "off",
"jsdoc/require-returns": "off",
"jsdoc/require-param-type": "off",
"no-redeclare": "off",
"import/no-restricted-paths": [
"error",
{

View File

@ -5,13 +5,17 @@ Within your pages, components, and plugins you can use useAsyncData to get acces
## Type
```ts [Signature]
function useAsyncData(
handler: (nuxtApp?: NuxtApp) => Promise<DataT>,
options?: AsyncDataOptions<DataT>
): AsyncData<DataT>
function useAsyncData(
key: string,
handler: (nuxtApp?: NuxtApp) => Promise<DataT>,
options?: AsyncDataOptions
): Promise<DataT>
options?: AsyncDataOptions<DataT>
): Promise<AsyncData<DataT>>
type AsyncDataOptions = {
type AsyncDataOptions<DataT> = {
server?: boolean
lazy?: boolean
default?: () => DataT | Ref<DataT>
@ -21,7 +25,7 @@ type AsyncDataOptions = {
initialCache?: boolean
}
type DataT = {
type AsyncData<DataT> = {
data: Ref<DataT>
pending: Ref<boolean>
refresh: () => Promise<void>
@ -31,7 +35,7 @@ type DataT = {
## Params
* **key**: a unique key to ensure that data fetching can be properly de-duplicated across requests
* **key**: a unique key to ensure that data fetching can be properly de-duplicated across requests. If you do not provide a key, then a key that is unique to the file name and line number of the instance of `useAsyncData` will be generated for you.
* **handler**: an asynchronous function that returns a value
* **options**:
* _lazy_: whether to resolve the async function after loading the route, instead of blocking navigation (defaults to `false`)

View File

@ -6,9 +6,9 @@ This composable provides a convenient wrapper around [`useAsyncData`](/api/compo
```ts [Signature]
function useFetch(
url: string | Request,
options?: UseFetchOptions
): Promise<DataT>
url: string | Request | Ref<string | Request> | () => string | Request,
options?: UseFetchOptions<DataT>
): Promise<AsyncData<DataT>>
type UseFetchOptions = {
key?: string,
@ -25,7 +25,7 @@ type UseFetchOptions = {
watch?: WatchSource[]
}
type DataT = {
type AsyncData<DataT> = {
data: Ref<DataT>
pending: Ref<boolean>
refresh: () => Promise<void>
@ -51,6 +51,10 @@ type DataT = {
* `watch`: watch reactive sources to auto-refresh
* `transform`: A function that can be used to alter `handler` function result after resolving.
::alert{type=warning}
If you provide a function or ref as the `url` parameter, or if you provide functions as arguments to the `options` parameter, then the `useFetch` call will not match other `useFetch` calls elsewhere in your codebase, even if the options seem to be identical. If you wish to force a match, you may provide your own key in `options`.
::
## Return values
* **data**: the result of the asynchronous function that is passed in

View File

@ -1,10 +1,11 @@
# `useState`
```ts
useState<T>(init?: () => T | Ref<T>): Ref<T>
useState<T>(key: string, init?: () => T | Ref<T>): Ref<T>
```
* **key**: A unique key ensuring that data fetching is properly de-duplicated across requests
* **key**: A unique key ensuring that data fetching is properly de-duplicated across requests. If you do not provide a key, then a key that is unique to the file and line number of the instance of `useState` will be generated for you.
* **init**: A function that provides initial value for the state when not initiated. This function can also return a `Ref`.
* **T**: (typescript only) Specify the type of state

View File

@ -1,6 +1,6 @@
<script setup>
const ctr = ref(0)
const { data, pending, refresh } = await useAsyncData('/api/hello', () => $fetch(`/api/hello/${ctr.value}`), { watch: [ctr] })
const { data, pending, refresh } = await useAsyncData(() => $fetch(`/api/hello/${ctr.value}`), { watch: [ctr] })
</script>

View File

@ -20,8 +20,8 @@
"lint": "eslint --ext .vue,.ts,.js,.mjs .",
"lint:docs": "markdownlint ./docs/content && case-police 'docs/content**/*.md'",
"lint:docs:fix": "markdownlint ./docs/content --fix && case-police 'docs/content**/*.md' --fix",
"nuxi": "NUXT_TELEMETRY_DISABLED=1 node ./packages/nuxi/bin/nuxi.mjs",
"nuxt": "NUXT_TELEMETRY_DISABLED=1 node ./packages/nuxi/bin/nuxi.mjs",
"nuxi": "NUXT_TELEMETRY_DISABLED=1 JITI_ESM_RESOLVE=1 node ./packages/nuxi/bin/nuxi.mjs",
"nuxt": "NUXT_TELEMETRY_DISABLED=1 JITI_ESM_RESOLVE=1 node ./packages/nuxi/bin/nuxi.mjs",
"play": "echo use yarn dev && exit 1",
"release": "yarn && yarn lint && FORCE_COLOR=1 lerna publish -m \"chore: release\" && yarn stub",
"stub": "lerna run prepack -- --stub",

View File

@ -46,7 +46,15 @@ export interface _AsyncData<DataT, ErrorT> {
export type AsyncData<Data, Error> = _AsyncData<Data, Error> & Promise<_AsyncData<Data, Error>>
const getDefault = () => null
export function useAsyncData<
DataT,
DataE = Error,
Transform extends _Transform<DataT> = _Transform<DataT, DataT>,
PickKeys extends KeyOfRes<Transform> = KeyOfRes<Transform>
> (
handler: (ctx?: NuxtApp) => Promise<DataT>,
options?: AsyncDataOptions<DataT, Transform, PickKeys>
): AsyncData<PickFrom<ReturnType<Transform>, PickKeys>, DataE | null | true>
export function useAsyncData<
DataT,
DataE = Error,
@ -55,14 +63,26 @@ export function useAsyncData<
> (
key: string,
handler: (ctx?: NuxtApp) => Promise<DataT>,
options: AsyncDataOptions<DataT, Transform, PickKeys> = {}
): AsyncData<PickFrom<ReturnType<Transform>, PickKeys>, DataE | null | true> {
options?: AsyncDataOptions<DataT, Transform, PickKeys>
): AsyncData<PickFrom<ReturnType<Transform>, PickKeys>, DataE | null | true>
export function useAsyncData<
DataT,
DataE = Error,
Transform extends _Transform<DataT> = _Transform<DataT, DataT>,
PickKeys extends KeyOfRes<Transform> = KeyOfRes<Transform>
> (...args): AsyncData<PickFrom<ReturnType<Transform>, PickKeys>, DataE | null | true> {
const autoKey = typeof args[args.length - 1] === 'string' ? args.pop() : undefined
if (typeof args[0] !== 'string') { args.unshift(autoKey) }
// eslint-disable-next-line prefer-const
let [key, handler, options = {}] = args as [string, (ctx?: NuxtApp) => Promise<DataT>, AsyncDataOptions<DataT, Transform, PickKeys>]
// Validate arguments
if (typeof key !== 'string') {
throw new TypeError('asyncData key must be a string')
throw new TypeError('[nuxt] [asyncData] key must be a string.')
}
if (typeof handler !== 'function') {
throw new TypeError('asyncData handler must be a function')
throw new TypeError('[nuxt] [asyncData] handler must be a function.')
}
// Apply defaults
@ -180,7 +200,15 @@ export function useAsyncData<
return asyncDataPromise as AsyncData<PickFrom<ReturnType<Transform>, PickKeys>, DataE>
}
export function useLazyAsyncData<
DataT,
DataE = Error,
Transform extends _Transform<DataT> = _Transform<DataT, DataT>,
PickKeys extends KeyOfRes<Transform> = KeyOfRes<Transform>
> (
handler: (ctx?: NuxtApp) => Promise<DataT>,
options?: Omit<AsyncDataOptions<DataT, Transform, PickKeys>, 'lazy'>
): AsyncData<PickFrom<ReturnType<Transform>, PickKeys>, DataE | null | true>
export function useLazyAsyncData<
DataT,
DataE = Error,
@ -189,9 +217,19 @@ export function useLazyAsyncData<
> (
key: string,
handler: (ctx?: NuxtApp) => Promise<DataT>,
options: Omit<AsyncDataOptions<DataT, Transform, PickKeys>, 'lazy'> = {}
): AsyncData<PickFrom<ReturnType<Transform>, PickKeys>, DataE | null | true> {
return useAsyncData(key, handler, { ...options, lazy: true })
options?: Omit<AsyncDataOptions<DataT, Transform, PickKeys>, 'lazy'>
): AsyncData<PickFrom<ReturnType<Transform>, PickKeys>, DataE | null | true>
export function useLazyAsyncData<
DataT,
DataE = Error,
Transform extends _Transform<DataT> = _Transform<DataT, DataT>,
PickKeys extends KeyOfRes<Transform> = KeyOfRes<Transform>
> (...args): AsyncData<PickFrom<ReturnType<Transform>, PickKeys>, DataE | null | true> {
const autoKey = typeof args[args.length - 1] === 'string' ? args.pop() : undefined
if (typeof args[0] !== 'string') { args.unshift(autoKey) }
const [key, handler, options] = args as [string, (ctx?: NuxtApp) => Promise<DataT>, AsyncDataOptions<DataT, Transform, PickKeys>]
// @ts-ignore
return useAsyncData(key, handler, { ...options, lazy: true }, null)
}
export function refreshNuxtData (keys?: string | string[]): Promise<void> {

View File

@ -1,8 +1,7 @@
import type { FetchOptions, FetchRequest } from 'ohmyfetch'
import type { TypedInternalResponse, NitroFetchRequest } from 'nitropack'
import { hash } from 'ohash'
import { computed, isRef, Ref } from 'vue'
import type { AsyncDataOptions, _Transform, KeyOfRes } from './asyncData'
import type { AsyncDataOptions, _Transform, KeyOfRes, AsyncData, PickFrom } from './asyncData'
import { useAsyncData } from './asyncData'
export type FetchResult<ReqT extends NitroFetchRequest> = TypedInternalResponse<ReqT, unknown>
@ -24,12 +23,30 @@ export function useFetch<
PickKeys extends KeyOfRes<Transform> = KeyOfRes<Transform>
> (
request: Ref<ReqT> | ReqT | (() => ReqT),
opts: UseFetchOptions<_ResT, Transform, PickKeys> = {}
opts?: UseFetchOptions<_ResT, Transform, PickKeys>
): AsyncData<PickFrom<ReturnType<Transform>, PickKeys>, ErrorT | null | true>
export function useFetch<
ResT = void,
ErrorT = Error,
ReqT extends NitroFetchRequest = NitroFetchRequest,
_ResT = ResT extends void ? FetchResult<ReqT> : ResT,
Transform extends (res: _ResT) => any = (res: _ResT) => _ResT,
PickKeys extends KeyOfRes<Transform> = KeyOfRes<Transform>
> (
request: Ref<ReqT> | ReqT | (() => ReqT),
arg1?: string | UseFetchOptions<_ResT, Transform, PickKeys>,
arg2?: string
) {
if (process.dev && !opts.key && Object.values(opts).some(v => typeof v === 'function' || v instanceof Blob)) {
console.warn('[nuxt] [useFetch] You should provide a key when passing options that are not serializable to JSON:', opts)
const [opts, autoKey] = typeof arg1 === 'string' ? [{}, arg1] : [arg1, arg2]
const _key = opts.key || autoKey
if (!_key || typeof _key !== 'string') {
throw new TypeError('[nuxt] [useFetch] key must be a string: ' + _key)
}
const key = '$f_' + (opts.key || hash([request, { ...opts, transform: null }]))
if (!request) {
throw new Error('[nuxt] [useFetch] request is missing.')
}
const key = '$f' + _key
const _request = computed(() => {
let r = request as Ref<FetchRequest> | FetchRequest | (() => FetchRequest)
if (typeof r === 'function') {
@ -83,10 +100,26 @@ export function useLazyFetch<
PickKeys extends KeyOfRes<Transform> = KeyOfRes<Transform>
> (
request: Ref<ReqT> | ReqT | (() => ReqT),
opts: Omit<UseFetchOptions<_ResT, Transform, PickKeys>, 'lazy'> = {}
opts?: Omit<UseFetchOptions<_ResT, Transform, PickKeys>, 'lazy'>
): AsyncData<PickFrom<ReturnType<Transform>, PickKeys>, ErrorT | null | true>
export function useLazyFetch<
ResT = void,
ErrorT = Error,
ReqT extends NitroFetchRequest = NitroFetchRequest,
_ResT = ResT extends void ? FetchResult<ReqT> : ResT,
Transform extends (res: _ResT) => any = (res: _ResT) => _ResT,
PickKeys extends KeyOfRes<Transform> = KeyOfRes<Transform>
> (
request: Ref<ReqT> | ReqT | (() => ReqT),
arg1?: string | Omit<UseFetchOptions<_ResT, Transform, PickKeys>, 'lazy'>,
arg2?: string
) {
const [opts, autoKey] = typeof arg1 === 'string' ? [{}, arg1] : [arg1, arg2]
return useFetch<ResT, ErrorT, ReqT, _ResT, Transform, PickKeys>(request, {
...opts,
lazy: true
})
},
// @ts-ignore
autoKey)
}

View File

@ -8,7 +8,20 @@ import { useNuxtApp } from '#app'
* @param key a unique key ensuring that data fetching can be properly de-duplicated across requests
* @param init a function that provides initial value for the state when it's not initiated
*/
export const useState = <T> (key: string, init?: (() => T | Ref<T>)): Ref<T> => {
export function useState <T> (key?: string, init?: (() => T | Ref<T>)): Ref<T>
export function useState <T> (init?: (() => T | Ref<T>)): Ref<T>
export function useState <T> (...args): Ref<T> {
const autoKey = typeof args[args.length - 1] === 'string' ? args.pop() : undefined
if (typeof args[0] !== 'string') { args.unshift(autoKey) }
const [_key, init] = args as [string, (() => T | Ref<T>)]
if (!_key || typeof _key !== 'string') {
throw new TypeError('[nuxt] [useState] key must be a string: ' + _key)
}
if (init !== undefined && typeof init !== 'function') {
throw new Error('[nuxt] [useState] init must be a function: ' + init)
}
const key = '$s' + _key
const nuxt = useNuxtApp()
const state = toRef(nuxt.payload.state, key)
if (state.value === undefined && init) {

View File

@ -29,6 +29,7 @@
"defu": "^6.0.0",
"esbuild": "^0.14.48",
"escape-string-regexp": "^5.0.0",
"estree-walker": "^3.0.1",
"externality": "^0.2.2",
"fs-extra": "^10.1.0",
"get-port-please": "^2.5.0",
@ -36,6 +37,7 @@
"knitwork": "^0.1.2",
"magic-string": "^0.26.2",
"mlly": "^0.5.4",
"ohash": "^0.1.0",
"pathe": "^0.3.2",
"perfect-debounce": "^0.1.3",
"postcss": "^8.4.14",

View File

@ -0,0 +1,55 @@
import { pathToFileURL } from 'node:url'
import { createUnplugin } from 'unplugin'
import { isAbsolute, relative } from 'pathe'
import { walk } from 'estree-walker'
import MagicString from 'magic-string'
import { hash } from 'ohash'
import type { CallExpression } from 'estree'
import { parseURL } from 'ufo'
export interface ComposableKeysOptions {
sourcemap?: boolean
rootDir?: string
}
const keyedFunctions = [
'useState', 'useFetch', 'useAsyncData', 'useLazyAsyncData', 'useLazyFetch'
]
const KEYED_FUNCTIONS_RE = new RegExp(`(${keyedFunctions.join('|')})`)
export const composableKeysPlugin = createUnplugin((options: ComposableKeysOptions = {}) => {
return {
name: 'nuxt:composable-keys',
enforce: 'post',
transform (code, id) {
const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href))
if (!pathname.match(/\.(m?[jt]sx?|vue)/)) { return }
if (!KEYED_FUNCTIONS_RE.test(code)) { return }
const { 0: script = code, index: codeIndex = 0 } = code.match(/(?<=<script[^>]*>)[\S\s.]*?(?=<\/script>)/) || []
const s = new MagicString(code)
// https://github.com/unjs/unplugin/issues/90
const relativeID = isAbsolute(id) ? relative(options.rootDir, id) : id
walk(this.parse(script, {
sourceType: 'module',
ecmaVersion: 'latest'
}), {
enter (node: CallExpression) {
if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier') { return }
if (keyedFunctions.includes(node.callee.name) && node.arguments.length < 4) {
const end = (node as any).end
s.appendLeft(
codeIndex + end - 1,
(node.arguments.length ? ', ' : '') + "'$" + hash(`${relativeID}-${codeIndex + end}`) + "'"
)
}
}
})
if (s.hasChanged()) {
return {
code: s.toString(),
map: options.sourcemap && s.generateMap({ source: id, includeContent: true })
}
}
}
}
})

View File

@ -1,5 +1,5 @@
import { createHash } from 'node:crypto'
import { promises as fsp, readdirSync, statSync } from 'node:fs'
import { hash } from 'ohash'
import { join } from 'pathe'
export function uniq<T> (arr: T[]): T[] {
@ -28,13 +28,6 @@ export function hashId (id: string) {
return '$id_' + hash(id)
}
export function hash (input: string, length = 8) {
return createHash('sha256')
.update(input)
.digest('hex')
.slice(0, length)
}
export function readDirRecursively (dir: string) {
return readdirSync(dir).reduce((files, file) => {
const name = join(dir, file)

View File

@ -13,6 +13,7 @@ import virtual from './plugins/virtual'
import { DynamicBasePlugin } from './plugins/dynamic-base'
import { warmupViteServer } from './utils/warmup'
import { resolveCSSOptions } from './css'
import { composableKeysPlugin } from './plugins/composable-keys'
export interface ViteOptions extends InlineConfig {
vue?: Options
@ -65,6 +66,7 @@ export async function bundle (nuxt: Nuxt) {
}
},
plugins: [
composableKeysPlugin.vite({ sourcemap: nuxt.options.sourcemap, rootDir: nuxt.options.rootDir }),
replace({
...Object.fromEntries([';', '(', '{', '}', ' ', '\t', '\n'].map(d => [`${d}global.`, `${d}globalThis.`])),
preventAssignment: true

View File

@ -25,6 +25,7 @@
"cssnano": "^5.1.12",
"esbuild-loader": "^2.19.0",
"escape-string-regexp": "^5.0.0",
"estree-walker": "^3.0.1",
"file-loader": "^6.2.0",
"fork-ts-checker-webpack-plugin": "^7.2.11",
"fs-extra": "^10.1.0",

View File

@ -9,6 +9,7 @@ import type { Nuxt } from '@nuxt/schema'
import { joinURL } from 'ufo'
import { logger, useNuxt } from '@nuxt/kit'
import { DynamicBasePlugin } from '../../vite/src/plugins/dynamic-base'
import { composableKeysPlugin } from '../../vite/src/plugins/composable-keys'
import { createMFS } from './utils/mfs'
import { registerVirtualModules } from './virtual-modules'
import { client, server } from './configs'
@ -37,6 +38,10 @@ export async function bundle (nuxt: Nuxt) {
sourcemap: nuxt.options.sourcemap,
globalPublicPath: '__webpack_public_path__'
}))
config.plugins.push(composableKeysPlugin.webpack({
sourcemap: nuxt.options.sourcemap,
rootDir: nuxt.options.rootDir
}))
// Create compiler
const compiler = webpack(config)

View File

@ -326,6 +326,14 @@ describe('extends support', () => {
})
})
describe('automatically keyed composables', () => {
it('should automatically generate keys', async () => {
const html = await $fetch('/keyed-composables')
expect(html).toContain('true')
expect(html).not.toContain('false')
})
})
describe('dynamic paths', () => {
if (process.env.NUXT_TEST_DEV) {
// TODO:

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
const useLocalState = () => useState(() => ({ foo: Math.random() }))
const useStateTest1 = useLocalState()
const useStateTest2 = useLocalState()
const useLocalAsyncData = () => useAsyncData(() => Promise.resolve({ foo: Math.random() }), { transform: data => data.foo })
const { data: useAsyncDataTest1 } = await useLocalAsyncData()
const { data: useAsyncDataTest2 } = await useLocalAsyncData()
const useLocalLazyAsyncData = () => useLazyAsyncData(() => Promise.resolve({ foo: Math.random() }), { transform: data => data.foo })
const { data: useLazyAsyncDataTest1 } = await useLocalLazyAsyncData()
const { data: useLazyAsyncDataTest2 } = await useLocalLazyAsyncData()
const useLocalFetch = () => useFetch('/api/counter', { transform: data => data.count })
const { data: useFetchTest1 } = await useLocalFetch()
const { data: useFetchTest2 } = await useLocalFetch()
const useLocalLazyFetch = () => useLazyFetch(() => '/api/counter')
const { data: useLazyFetchTest1 } = await useLocalLazyFetch()
const { data: useLazyFetchTest2 } = await useLocalLazyFetch()
</script>
<template>
<pre>
{{ useStateTest1 === useStateTest2 }}
{{ useAsyncDataTest1 === useAsyncDataTest2 }}
{{ useLazyAsyncDataTest1 === useLazyAsyncDataTest2 }}
{{ useFetchTest1 === useFetchTest2 }}
{{ useLazyFetchTest1 === useLazyFetchTest2 }}
</pre>
</template>

View File

@ -134,4 +134,19 @@ describe('composables', () => {
expectTypeOf(useFetch('/test', { default: () => ref(500) }).data).toMatchTypeOf<Ref<number>>()
expectTypeOf(useFetch('/test', { default: () => 500 }).data).toMatchTypeOf<Ref<number>>()
})
it('provides proper type support when using overloads', () => {
expectTypeOf(useState('test')).toMatchTypeOf(useState())
expectTypeOf(useState('test', () => ({ foo: Math.random() }))).toMatchTypeOf(useState(() => ({ foo: Math.random() })))
expectTypeOf(useAsyncData('test', () => Promise.resolve({ foo: Math.random() })))
.toMatchTypeOf(useAsyncData(() => Promise.resolve({ foo: Math.random() })))
expectTypeOf(useAsyncData('test', () => Promise.resolve({ foo: Math.random() }), { transform: data => data.foo }))
.toMatchTypeOf(useAsyncData(() => Promise.resolve({ foo: Math.random() }), { transform: data => data.foo }))
expectTypeOf(useLazyAsyncData('test', () => Promise.resolve({ foo: Math.random() })))
.toMatchTypeOf(useLazyAsyncData(() => Promise.resolve({ foo: Math.random() })))
expectTypeOf(useLazyAsyncData('test', () => Promise.resolve({ foo: Math.random() }), { transform: data => data.foo }))
.toMatchTypeOf(useLazyAsyncData(() => Promise.resolve({ foo: Math.random() }), { transform: data => data.foo }))
})
})

View File

@ -1775,6 +1775,7 @@ __metadata:
defu: ^6.0.0
esbuild: ^0.14.48
escape-string-regexp: ^5.0.0
estree-walker: ^3.0.1
externality: ^0.2.2
fs-extra: ^10.1.0
get-port-please: ^2.5.0
@ -1782,6 +1783,7 @@ __metadata:
knitwork: ^0.1.2
magic-string: ^0.26.2
mlly: ^0.5.4
ohash: ^0.1.0
pathe: ^0.3.2
perfect-debounce: ^0.1.3
postcss: ^8.4.14
@ -1820,6 +1822,7 @@ __metadata:
cssnano: ^5.1.12
esbuild-loader: ^2.19.0
escape-string-regexp: ^5.0.0
estree-walker: ^3.0.1
file-loader: ^6.2.0
fork-ts-checker-webpack-plugin: ^7.2.11
fs-extra: ^10.1.0
@ -6278,6 +6281,13 @@ __metadata:
languageName: node
linkType: hard
"estree-walker@npm:^3.0.1":
version: 3.0.1
resolution: "estree-walker@npm:3.0.1"
checksum: 674096950819041f1ee471e63f7aa987f2ed3a3a441cc41a5176e9ed01ea5cfd6487822c3b9c2cddd0e2c8f9d7ef52d32d06147a19b5a9ca9f8ab0c094bd43b9
languageName: node
linkType: hard
"esutils@npm:^2.0.2":
version: 2.0.3
resolution: "esutils@npm:2.0.3"