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 4 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>
): AsyncData<DataT>
danielroe marked this conversation as resolved.
Show resolved Hide resolved

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 and line number of the instance of `useAsyncData` will be generated for you.
danielroe marked this conversation as resolved.
Show resolved Hide resolved
* **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
20 changes: 12 additions & 8 deletions docs/content/3.api/1.composables/use-fetch.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,23 @@ 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>
): AsyncData<DataT>

type UseFetchOptions = {
method?: string,
params?: SearchParams,
headers?: {key: string, value: string}[],
baseURL?: string,
method?: string
params?: SearchParams
headers?: {key: string, value: string}[]
baseURL?: string
server?: boolean
lazy?: boolean
default?: () => DataT
transform?: (input: DataT) => DataT
pick?: string[]
}

type DataT = {
type AsyncData<DataT> = {
data: Ref<DataT>
pending: Ref<boolean>
refresh: () => Promise<void>
Expand All @@ -45,6 +45,10 @@ type DataT = {
* `pick`: Only pick specified keys in this array from the `handler` function result.
* `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
50 changes: 46 additions & 4 deletions packages/nuxt/src/app/composables/asyncData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,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 @@ -48,8 +56,21 @@ export function useAsyncData<
> (
key: string,
handler: (ctx?: NuxtApp) => Promise<DataT>,
options: AsyncDataOptions<DataT, Transform, PickKeys> = {}
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>
> (
_key: string | ((ctx?: NuxtApp) => Promise<DataT>),
_handler?: ((ctx?: NuxtApp) => Promise<DataT>) | AsyncDataOptions<DataT, Transform, PickKeys> | string,
_options?: AsyncDataOptions<DataT, Transform, PickKeys> | string,
fallback?: string
): AsyncData<PickFrom<ReturnType<Transform>, PickKeys>, DataE | null | true> {
// eslint-disable-next-line prefer-const
let [key, handler, options] = (typeof _key === 'string' ? [_key, _handler, _options] : typeof _options === 'string' ? [_options, _key, {}] : [fallback, _key, _options]) as [string, (ctx?: NuxtApp) => Promise<DataT>, AsyncDataOptions<DataT, Transform, PickKeys>]
Copy link
Member

Choose a reason for hiding this comment

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

Perf: We don't need array creation and shift on normal (string, fn, obj?) usage.

We can use a variable swap for (fn, obj?) (is this even valid and handled when magic-keys pluign is not working ?!)

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, this works even if the plugin isn't working, as long as the user provides a key. If they don't, they will get an error message: 'asyncData key must be a string'.

Copy link
Member Author

Choose a reason for hiding this comment

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

And you're right, we could swap variables around, but we would lose internal typing for those variables by doing it that way.

// Validate arguments
if (typeof key !== 'string') {
throw new TypeError('asyncData key must be a string')
Expand Down Expand Up @@ -171,7 +192,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 @@ -180,8 +209,21 @@ export function useLazyAsyncData<
> (
key: string,
handler: (ctx?: NuxtApp) => Promise<DataT>,
options: Omit<AsyncDataOptions<DataT, Transform, PickKeys>, 'lazy'> = {}
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>
> (
_key: string | ((ctx?: NuxtApp) => Promise<DataT>),
_handler?: ((ctx?: NuxtApp) => Promise<DataT>) | AsyncDataOptions<DataT, Transform, PickKeys> | string,
_options?: Omit<AsyncDataOptions<DataT, Transform, PickKeys>, 'lazy'>,
fallback?: string
): AsyncData<PickFrom<ReturnType<Transform>, PickKeys>, DataE | null | true> {
// eslint-disable-next-line prefer-const
let [key, handler, options] = (typeof _key === 'string' ? [_key, _handler, _options] : typeof _options === 'string' ? [_options, _key, {}] : [fallback, _key, _options]) as [string, (ctx?: NuxtApp) => Promise<DataT>, AsyncDataOptions<DataT, Transform, PickKeys>]
return useAsyncData(key, handler, { ...options, lazy: true })
}

Expand Down
41 changes: 33 additions & 8 deletions packages/nuxt/src/app/composables/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,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 +24,22 @@ 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),
_opts?: string | UseFetchOptions<_ResT, Transform, PickKeys>,
_fallback?: string
) {
if (process.dev && opts.transform && !opts.key) {
console.warn('[nuxt] You should provide a key for `useFetch` when using a custom transform function.')
}
const key = '$f_' + (opts.key || hash([request, { ...opts, transform: null }]))
const [opts, fallback] = (typeof _opts === 'string' ? [{}, _opts] : [_opts, _fallback])
const key = '$f_' + (typeof opts.key === 'string' ? opts.key : typeof request === 'string' && !opts.transform && !opts.default ? hash([request, opts]) : fallback)
const _request = computed(() => {
let r = request as Ref<FetchRequest> | FetchRequest | (() => FetchRequest)
if (typeof r === 'function') {
Expand Down Expand Up @@ -67,10 +77,25 @@ 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),
_opts?: string | Omit<UseFetchOptions<_ResT, Transform, PickKeys>, 'lazy'>,
_fallback?: string
) {
const [opts, fallback] = (typeof _opts === 'string' ? [{}, _opts] : [_opts, _fallback])
return useFetch<ResT, ErrorT, ReqT, _ResT, Transform, PickKeys>(request, {
...opts,
lazy: true
})
},
// @ts-ignore
fallback)
}
5 changes: 4 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,10 @@ 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> (_key?: string | (() => T | Ref<T>), _init?: string | (() => T | Ref<T>)): Ref<T> {
const [key, init] = (typeof _key === 'string' ? [_key, _init] : [_init, _key]) as [string, (() => T | Ref<T>)]
const nuxt = useNuxtApp()
const state = toRef(nuxt.payload.state, key)
if (state.value === undefined && init) {
Expand Down
1 change: 1 addition & 0 deletions packages/vite/build.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default defineBuildConfig({
'vue'
],
externals: [
'acorn',
'@nuxt/schema'
]
})
1 change: 1 addition & 0 deletions packages/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"defu": "^6.0.0",
"esbuild": "^0.14.39",
"escape-string-regexp": "^5.0.0",
"estree-walker": "^3.0.1",
"externality": "^0.2.1",
"fs-extra": "^10.1.0",
"get-port-please": "^2.5.0",
Expand Down
61 changes: 61 additions & 0 deletions packages/vite/src/plugins/magic-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import crypto from 'node:crypto'
danielroe marked this conversation as resolved.
Show resolved Hide resolved
import { pathToFileURL } from 'node:url'
import type { Plugin } from 'vite'
import { parse } from 'acorn'
import { walk } from 'estree-walker'
import MagicString from 'magic-string'
import type { CallExpression } from 'estree'
import { parseURL } from 'ufo'

export interface MagicKeysOptions {
sourcemap?: boolean
useAcorn?: boolean
}

function createKey (source: string) {
const hash = crypto.createHash('md5')
hash.update(source)
return hash.digest('base64').toString()
}
pi0 marked this conversation as resolved.
Show resolved Hide resolved

const keyedFunctions = [
'useState', 'useFetch', 'useAsyncData', 'useLazyAsyncData', 'useLazyFetch'
]

export const magicKeysPlugin = (options: MagicKeysOptions = {}): Plugin => {
return {
name: 'nuxt:magic-keys',
enforce: 'post',
transform (code, id) {
const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href))
if (!pathname.match(/\.([cm][jt]sx?|vue)/)) { return }
danielroe marked this conversation as resolved.
Show resolved Hide resolved
if (!keyedFunctions.some(f => code.includes(f))) { return }
danielroe marked this conversation as resolved.
Show resolved Hide resolved
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
walk(options.useAcorn
? parse(script, {
sourceType: 'module',
ecmaVersion: 'latest'
})
: this.parse(script), {
enter (node: CallExpression) {
if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier') { return }
if (keyedFunctions.includes(node.callee.name)) {
const end = (node as any).end
s.appendLeft(
codeIndex + end - 1,
(node.arguments.length ? ', ' : '') + "'" + createKey(`${id}-${codeIndex + end}`) + "'"
danielroe marked this conversation as resolved.
Show resolved Hide resolved
)
}
}
})
if (s.hasChanged()) {
return {
code: s.toString(),
map: options.sourcemap && s.generateMap({ source: id, includeContent: true })
}
}
}
}
}
2 changes: 2 additions & 0 deletions packages/vite/src/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import virtual from './plugins/virtual'
import { DynamicBasePlugin } from './plugins/dynamic-base'
import { warmupViteServer } from './utils/warmup'
import { resolveCSSOptions } from './css'
import { magicKeysPlugin } from './plugins/magic-keys'

export interface ViteOptions extends InlineConfig {
vue?: Options
Expand Down Expand Up @@ -64,6 +65,7 @@ export async function bundle (nuxt: Nuxt) {
}
},
plugins: [
magicKeysPlugin({ sourcemap: nuxt.options.sourcemap }),
virtual(nuxt.vfs),
DynamicBasePlugin.vite({ sourcemap: nuxt.options.sourcemap })
],
Expand Down
2 changes: 2 additions & 0 deletions packages/webpack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@
"@babel/core": "^7.17.10",
"@nuxt/friendly-errors-webpack-plugin": "^2.5.2",
"@nuxt/kit": "^3.0.0-rc.3",
"acorn": "^8.7.1",
"autoprefixer": "^10.4.7",
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"cssnano": "^5.1.7",
"esbuild-loader": "^2.18.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",
Expand Down
5 changes: 5 additions & 0 deletions packages/webpack/src/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import type { Compiler, Watching } from 'webpack'
import type { Nuxt } from '@nuxt/schema'
import { joinURL } from 'ufo'
import { logger, useNuxt } from '@nuxt/kit'
import { createUnplugin } from 'unplugin'
import { DynamicBasePlugin } from '../../vite/src/plugins/dynamic-base'
import { magicKeysPlugin as _magicKeysPlugin } from '../../vite/src/plugins/magic-keys'
import { createMFS } from './utils/mfs'
import { registerVirtualModules } from './virtual-modules'
import { client, server } from './configs'
Expand All @@ -17,6 +19,8 @@ import { createWebpackConfigContext, applyPresets, getWebpackConfig } from './ut
// TODO: Support plugins
// const plugins: string[] = []

const magicKeysPlugin = createUnplugin(_magicKeysPlugin as any)

export async function bundle (nuxt: Nuxt) {
await registerVirtualModules()

Expand All @@ -37,6 +41,7 @@ export async function bundle (nuxt: Nuxt) {
sourcemap: nuxt.options.sourcemap,
globalPublicPath: '__webpack_public_path__'
}))
config.plugins.push(magicKeysPlugin.webpack({ useAcorn: true, sourcemap: nuxt.options.sourcemap }))

// Create compiler
const compiler = webpack(config)
Expand Down
Loading