Skip to content
This repository has been archived by the owner on Apr 6, 2023. It is now read-only.

Commit

Permalink
feat(nuxt): automatically generate unique keys for keyed composables (#…
Browse files Browse the repository at this point in the history
…4955)

Co-authored-by: Pooya Parsa <pyapar@gmail.com>
  • Loading branch information
danielroe and pi0 authored Jul 7, 2022
1 parent 4d60708 commit 23546a2
Show file tree
Hide file tree
Showing 19 changed files with 255 additions and 39 deletions.
1 change: 1 addition & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
{
Expand Down
14 changes: 9 additions & 5 deletions docs/content/3.api/1.composables/use-async-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -21,7 +25,7 @@ type AsyncDataOptions = {
initialCache?: boolean
}

type DataT = {
type AsyncData<DataT> = {
data: Ref<DataT>
pending: Ref<boolean>
refresh: () => Promise<void>
Expand All @@ -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`)
Expand Down
12 changes: 8 additions & 4 deletions docs/content/3.api/1.composables/use-fetch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,7 +25,7 @@ type UseFetchOptions = {
watch?: WatchSource[]
}

type DataT = {
type AsyncData<DataT> = {
data: Ref<DataT>
pending: Ref<boolean>
refresh: () => Promise<void>
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/content/3.api/1.composables/use-state.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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>

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 47 additions & 9 deletions packages/nuxt/src/app/composables/asyncData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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> {
Expand Down
49 changes: 41 additions & 8 deletions packages/nuxt/src/app/composables/fetch.ts
Original file line number Diff line number Diff line change
@@ -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>
Expand All @@ -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') {
Expand Down Expand Up @@ -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)
}
15 changes: 14 additions & 1 deletion packages/nuxt/src/app/composables/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions packages/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@
"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",
"h3": "^0.7.10",
"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",
Expand Down
55 changes: 55 additions & 0 deletions packages/vite/src/plugins/composable-keys.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
}
}
})
9 changes: 1 addition & 8 deletions packages/vite/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -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[] {
Expand Down Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 23546a2

Please sign in to comment.