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

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

Merged
merged 28 commits into from
Jul 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
435a331
feat(nuxt): automatically generate unique keys for keyed composables
danielroe May 12, 2022
fcbb164
docs: update and fix typings
danielroe May 12, 2022
ca20190
fix: update lazy fetch types
danielroe May 12, 2022
8f709e3
fix: add acorn and estree-walker to dependencies
danielroe May 12, 2022
be98e92
refactor: rename magic keys -> composable keys
danielroe May 13, 2022
cf0e2da
perf: use regexp for early return
danielroe May 13, 2022
1c1bac4
fix: normalise id into relative path
danielroe May 13, 2022
52d531d
fix: improve readability
danielroe May 13, 2022
ae61515
perf: use hash map for generating short keys
danielroe May 13, 2022
231c5a5
fix: remove cjs from transformed extensions
danielroe May 13, 2022
bf02262
docs: apply suggestions
danielroe May 13, 2022
f818c8c
Merge remote-tracking branch 'origin/main' into feat/magic-keys
danielroe May 13, 2022
4b20d10
fix: remove changed files
danielroe May 13, 2022
88cf9f0
docs: update counter example
danielroe May 13, 2022
da9cf27
Merge remote-tracking branch 'origin/main' into feat/magic-keys
danielroe May 20, 2022
040acb2
refactor: simplify handling of fallback
danielroe May 20, 2022
bf63061
refactor: use `ohash` for hashing
danielroe Jun 8, 2022
92bd6dc
Merge remote-tracking branch 'origin/main' into feat/magic-keys
danielroe Jun 8, 2022
585c284
Merge branch 'main' into feat/magic-keys
danielroe Jun 9, 2022
4bb6cfa
Merge remote-tracking branch 'origin/main' into feat/magic-keys
danielroe Jun 17, 2022
9ced5f2
Merge remote-tracking branch 'origin/main' into feat/magic-keys
danielroe Jul 4, 2022
b3bde78
Merge remote-tracking branch 'origin/main' into feat/magic-keys
danielroe Jul 6, 2022
eab5ec3
fix playground,, asyncData with key, improve runtime checks and small…
pi0 Jul 6, 2022
502cc11
fix lint issue
pi0 Jul 6, 2022
f116ff6
fix: useState with explicit key
pi0 Jul 7, 2022
faab8b1
refactor: use unplugin's built-in parser
danielroe Jul 7, 2022
5964216
chore: update lockfile
danielroe Jul 7, 2022
4c42bc6
add workaround for autogenerated key
pi0 Jul 7, 2022
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
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",
danielroe marked this conversation as resolved.
Show resolved Hide resolved
"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