diff --git a/.eslintrc b/.eslintrc index 4b6b21596a8..5b5d001fa7d 100644 --- a/.eslintrc +++ b/.eslintrc @@ -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", { diff --git a/docs/content/3.api/1.composables/use-async-data.md b/docs/content/3.api/1.composables/use-async-data.md index 17806001ea0..a1632812a07 100644 --- a/docs/content/3.api/1.composables/use-async-data.md +++ b/docs/content/3.api/1.composables/use-async-data.md @@ -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, + options?: AsyncDataOptions +): AsyncData function useAsyncData( key: string, handler: (nuxtApp?: NuxtApp) => Promise, - options?: AsyncDataOptions -): Promise + options?: AsyncDataOptions +): Promise> -type AsyncDataOptions = { +type AsyncDataOptions = { server?: boolean lazy?: boolean default?: () => DataT | Ref @@ -21,7 +25,7 @@ type AsyncDataOptions = { initialCache?: boolean } -type DataT = { +type AsyncData = { data: Ref pending: Ref refresh: () => Promise @@ -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`) diff --git a/docs/content/3.api/1.composables/use-fetch.md b/docs/content/3.api/1.composables/use-fetch.md index ff286de4a64..5ee5fbb70c3 100644 --- a/docs/content/3.api/1.composables/use-fetch.md +++ b/docs/content/3.api/1.composables/use-fetch.md @@ -6,9 +6,9 @@ This composable provides a convenient wrapper around [`useAsyncData`](/api/compo ```ts [Signature] function useFetch( - url: string | Request, - options?: UseFetchOptions -): Promise + url: string | Request | Ref | () => string | Request, + options?: UseFetchOptions +): Promise> type UseFetchOptions = { key?: string, @@ -25,7 +25,7 @@ type UseFetchOptions = { watch?: WatchSource[] } -type DataT = { +type AsyncData = { data: Ref pending: Ref refresh: () => Promise @@ -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 diff --git a/docs/content/3.api/1.composables/use-state.md b/docs/content/3.api/1.composables/use-state.md index b6c46e48f32..30ac7d5c4e2 100644 --- a/docs/content/3.api/1.composables/use-state.md +++ b/docs/content/3.api/1.composables/use-state.md @@ -1,10 +1,11 @@ # `useState` ```ts +useState(init?: () => T | Ref): Ref useState(key: string, init?: () => T | Ref): Ref ``` -* **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 diff --git a/examples/composables/use-async-data/components/CounterExample.vue b/examples/composables/use-async-data/components/CounterExample.vue index 7b4756d630f..14ea1d01a5a 100644 --- a/examples/composables/use-async-data/components/CounterExample.vue +++ b/examples/composables/use-async-data/components/CounterExample.vue @@ -1,6 +1,6 @@ diff --git a/package.json b/package.json index 94b4d2b9c7c..2fb707c8cbb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/nuxt/src/app/composables/asyncData.ts b/packages/nuxt/src/app/composables/asyncData.ts index 98f809429bf..a644d78f977 100644 --- a/packages/nuxt/src/app/composables/asyncData.ts +++ b/packages/nuxt/src/app/composables/asyncData.ts @@ -46,7 +46,15 @@ export interface _AsyncData { export type AsyncData = _AsyncData & Promise<_AsyncData> const getDefault = () => null - +export function useAsyncData< + DataT, + DataE = Error, + Transform extends _Transform = _Transform, + PickKeys extends KeyOfRes = KeyOfRes +> ( + handler: (ctx?: NuxtApp) => Promise, + options?: AsyncDataOptions +): AsyncData, PickKeys>, DataE | null | true> export function useAsyncData< DataT, DataE = Error, @@ -55,14 +63,26 @@ export function useAsyncData< > ( key: string, handler: (ctx?: NuxtApp) => Promise, - options: AsyncDataOptions = {} -): AsyncData, PickKeys>, DataE | null | true> { + options?: AsyncDataOptions +): AsyncData, PickKeys>, DataE | null | true> +export function useAsyncData< + DataT, + DataE = Error, + Transform extends _Transform = _Transform, + PickKeys extends KeyOfRes = KeyOfRes +> (...args): AsyncData, 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, AsyncDataOptions] + // 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, PickKeys>, DataE> } - +export function useLazyAsyncData< + DataT, + DataE = Error, + Transform extends _Transform = _Transform, + PickKeys extends KeyOfRes = KeyOfRes +> ( + handler: (ctx?: NuxtApp) => Promise, + options?: Omit, 'lazy'> +): AsyncData, PickKeys>, DataE | null | true> export function useLazyAsyncData< DataT, DataE = Error, @@ -189,9 +217,19 @@ export function useLazyAsyncData< > ( key: string, handler: (ctx?: NuxtApp) => Promise, - options: Omit, 'lazy'> = {} -): AsyncData, PickKeys>, DataE | null | true> { - return useAsyncData(key, handler, { ...options, lazy: true }) + options?: Omit, 'lazy'> +): AsyncData, PickKeys>, DataE | null | true> +export function useLazyAsyncData< + DataT, + DataE = Error, + Transform extends _Transform = _Transform, + PickKeys extends KeyOfRes = KeyOfRes +> (...args): AsyncData, 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, AsyncDataOptions] + // @ts-ignore + return useAsyncData(key, handler, { ...options, lazy: true }, null) } export function refreshNuxtData (keys?: string | string[]): Promise { diff --git a/packages/nuxt/src/app/composables/fetch.ts b/packages/nuxt/src/app/composables/fetch.ts index 4d94093e5c2..93ad83e5fdf 100644 --- a/packages/nuxt/src/app/composables/fetch.ts +++ b/packages/nuxt/src/app/composables/fetch.ts @@ -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 = TypedInternalResponse @@ -24,12 +23,30 @@ export function useFetch< PickKeys extends KeyOfRes = KeyOfRes > ( request: Ref | ReqT | (() => ReqT), - opts: UseFetchOptions<_ResT, Transform, PickKeys> = {} + opts?: UseFetchOptions<_ResT, Transform, PickKeys> +): AsyncData, PickKeys>, ErrorT | null | true> +export function useFetch< + ResT = void, + ErrorT = Error, + ReqT extends NitroFetchRequest = NitroFetchRequest, + _ResT = ResT extends void ? FetchResult : ResT, + Transform extends (res: _ResT) => any = (res: _ResT) => _ResT, + PickKeys extends KeyOfRes = KeyOfRes +> ( + request: Ref | 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) if (typeof r === 'function') { @@ -83,10 +100,26 @@ export function useLazyFetch< PickKeys extends KeyOfRes = KeyOfRes > ( request: Ref | ReqT | (() => ReqT), - opts: Omit, 'lazy'> = {} + opts?: Omit, 'lazy'> +): AsyncData, PickKeys>, ErrorT | null | true> +export function useLazyFetch< + ResT = void, + ErrorT = Error, + ReqT extends NitroFetchRequest = NitroFetchRequest, + _ResT = ResT extends void ? FetchResult : ResT, + Transform extends (res: _ResT) => any = (res: _ResT) => _ResT, + PickKeys extends KeyOfRes = KeyOfRes +> ( + request: Ref | ReqT | (() => ReqT), + arg1?: string | Omit, 'lazy'>, + arg2?: string ) { + const [opts, autoKey] = typeof arg1 === 'string' ? [{}, arg1] : [arg1, arg2] + return useFetch(request, { ...opts, lazy: true - }) + }, + // @ts-ignore + autoKey) } diff --git a/packages/nuxt/src/app/composables/state.ts b/packages/nuxt/src/app/composables/state.ts index 07ac7ac80a2..451e6987b97 100644 --- a/packages/nuxt/src/app/composables/state.ts +++ b/packages/nuxt/src/app/composables/state.ts @@ -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 = (key: string, init?: (() => T | Ref)): Ref => { +export function useState (key?: string, init?: (() => T | Ref)): Ref +export function useState (init?: (() => T | Ref)): Ref +export function useState (...args): Ref { + 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)] + 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) { diff --git a/packages/vite/package.json b/packages/vite/package.json index 5ad0fe93d3f..9f83b60def4 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -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", diff --git a/packages/vite/src/plugins/composable-keys.ts b/packages/vite/src/plugins/composable-keys.ts new file mode 100644 index 00000000000..9b4e40b6360 --- /dev/null +++ b/packages/vite/src/plugins/composable-keys.ts @@ -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(/(?<=]*>)[\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 }) + } + } + } + } +}) diff --git a/packages/vite/src/utils/index.ts b/packages/vite/src/utils/index.ts index 79792452ba2..94b2bd5b13b 100644 --- a/packages/vite/src/utils/index.ts +++ b/packages/vite/src/utils/index.ts @@ -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 (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) diff --git a/packages/vite/src/vite.ts b/packages/vite/src/vite.ts index 6d57060c262..29187cd8579 100644 --- a/packages/vite/src/vite.ts +++ b/packages/vite/src/vite.ts @@ -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 diff --git a/packages/webpack/package.json b/packages/webpack/package.json index 445d247bcb2..dff12c230ec 100644 --- a/packages/webpack/package.json +++ b/packages/webpack/package.json @@ -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", diff --git a/packages/webpack/src/webpack.ts b/packages/webpack/src/webpack.ts index 07db13be6c4..4696a289012 100644 --- a/packages/webpack/src/webpack.ts +++ b/packages/webpack/src/webpack.ts @@ -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) diff --git a/test/basic.test.ts b/test/basic.test.ts index ea4ce3a0ca0..223a71d1532 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -318,6 +318,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: diff --git a/test/fixtures/basic/pages/keyed-composables.vue b/test/fixtures/basic/pages/keyed-composables.vue new file mode 100644 index 00000000000..2879009aefb --- /dev/null +++ b/test/fixtures/basic/pages/keyed-composables.vue @@ -0,0 +1,31 @@ + + + diff --git a/test/fixtures/basic/types.ts b/test/fixtures/basic/types.ts index 819bb9d7d10..5b6afad4acb 100644 --- a/test/fixtures/basic/types.ts +++ b/test/fixtures/basic/types.ts @@ -134,4 +134,19 @@ describe('composables', () => { expectTypeOf(useFetch('/test', { default: () => ref(500) }).data).toMatchTypeOf>() expectTypeOf(useFetch('/test', { default: () => 500 }).data).toMatchTypeOf>() }) + + 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 })) + }) }) diff --git a/yarn.lock b/yarn.lock index 7e78f68e784..8129e98d012 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"