From 732507b41f359513ff8aa14526314c729def2253 Mon Sep 17 00:00:00 2001 From: Alex Liu Date: Mon, 16 Oct 2023 19:56:37 +0800 Subject: [PATCH 1/7] fix(nuxt): resolve type error in options of `useFetch` (#23693) --- packages/nuxt/src/app/composables/fetch.ts | 2 +- test/fixtures/basic-types/types.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/nuxt/src/app/composables/fetch.ts b/packages/nuxt/src/app/composables/fetch.ts index c4c1d2b083..a3cb38fc51 100644 --- a/packages/nuxt/src/app/composables/fetch.ts +++ b/packages/nuxt/src/app/composables/fetch.ts @@ -13,7 +13,7 @@ type AvailableRouterMethod = _AvailableRouterMethod export type FetchResult> = TypedInternalResponse> type ComputedOptions> = { - [K in keyof T]: T[K] extends Function ? T[K] : T[K] extends Record ? ComputedOptions | Ref | T[K] : Ref | T[K] + [K in keyof T]: T[K] extends Function ? T[K] : ComputedOptions | Ref | T[K] } interface NitroFetchOptions = AvailableRouterMethod> extends FetchOptions { diff --git a/test/fixtures/basic-types/types.ts b/test/fixtures/basic-types/types.ts index 11baf9366f..3f692400c1 100644 --- a/test/fixtures/basic-types/types.ts +++ b/test/fixtures/basic-types/types.ts @@ -406,6 +406,17 @@ describe('composables', () => { expectTypeOf(test).toEqualTypeOf() }) + it('allows passing reactive values in useFetch', () => { + useFetch('/api/hey', { + headers: { + key: ref('test') + }, + query: { + param: computed(() => 'thing') + } + }) + }) + it('correctly types returns with key signatures', () => { interface TestType { id: string From 6b3d0163b6cc48df76a89165dd89a99c9254ca51 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 16 Oct 2023 13:54:03 +0100 Subject: [PATCH 2/7] fix(webpack): remove `lodash-es` + simplify postcss resolution (#23692) --- packages/webpack/package.json | 3 +- packages/webpack/src/plugins/vue/client.ts | 5 ++- packages/webpack/src/utils/config.ts | 6 +-- packages/webpack/src/utils/postcss.ts | 52 ++++++++-------------- pnpm-lock.yaml | 11 +++-- 5 files changed, 31 insertions(+), 46 deletions(-) diff --git a/packages/webpack/package.json b/packages/webpack/package.json index 1e71a1c8e7..7c822bb3cc 100644 --- a/packages/webpack/package.json +++ b/packages/webpack/package.json @@ -34,7 +34,7 @@ "fs-extra": "^11.1.1", "h3": "^1.8.2", "hash-sum": "^2.0.0", - "lodash-es": "^4.17.21", + "klona": "^2.0.6", "magic-string": "^0.30.4", "memfs": "^4.6.0", "mini-css-extract-plugin": "^2.7.6", @@ -65,7 +65,6 @@ "@nuxt/schema": "workspace:*", "@types/fs-extra": "11.0.2", "@types/hash-sum": "1.0.0", - "@types/lodash-es": "4.17.9", "@types/pify": "5.0.2", "@types/webpack-bundle-analyzer": "4.6.1", "@types/webpack-hot-middleware": "2.25.7", diff --git a/packages/webpack/src/plugins/vue/client.ts b/packages/webpack/src/plugins/vue/client.ts index f9c5319012..5c51fce6d9 100644 --- a/packages/webpack/src/plugins/vue/client.ts +++ b/packages/webpack/src/plugins/vue/client.ts @@ -6,7 +6,6 @@ import { normalizeWebpackManifest } from 'vue-bundle-renderer' import { dirname } from 'pathe' import hash from 'hash-sum' -import { uniq } from 'lodash-es' import fse from 'fs-extra' import type { Nuxt } from '@nuxt/schema' @@ -18,6 +17,10 @@ interface PluginOptions { nuxt: Nuxt } +function uniq (items: T[]) { + return [...new Set(items)] +} + export default class VueSSRClientPlugin { options: PluginOptions diff --git a/packages/webpack/src/utils/config.ts b/packages/webpack/src/utils/config.ts index a9ac4b4acb..38fb29a51a 100644 --- a/packages/webpack/src/utils/config.ts +++ b/packages/webpack/src/utils/config.ts @@ -1,7 +1,7 @@ -import { cloneDeep } from 'lodash-es' import type { Configuration } from 'webpack' import type { Nuxt, NuxtOptions } from '@nuxt/schema' import { logger } from '@nuxt/kit' +import { klona } from 'klona' export interface WebpackConfigContext { nuxt: Nuxt @@ -67,6 +67,6 @@ export function fileName (ctx: WebpackConfigContext, key: string) { } export function getWebpackConfig (ctx: WebpackConfigContext): Configuration { - // Clone deep avoid leaking config between Client and Server - return cloneDeep(ctx.config) + // Clone to avoid leaking config between Client and Server + return klona(ctx.config) } diff --git a/packages/webpack/src/utils/postcss.ts b/packages/webpack/src/utils/postcss.ts index c11f32ff5d..270341a1d4 100644 --- a/packages/webpack/src/utils/postcss.ts +++ b/packages/webpack/src/utils/postcss.ts @@ -1,7 +1,7 @@ import { createCommonJS } from 'mlly' -import { cloneDeep, defaults, merge } from 'lodash-es' import { requireModule } from '@nuxt/kit' import type { Nuxt } from '@nuxt/schema' +import { defu } from 'defu' const isPureObject = (obj: unknown): obj is Object => obj !== null && !Array.isArray(obj) && typeof obj === 'object' @@ -26,15 +26,6 @@ const orderPresets = { } export const getPostcssConfig = (nuxt: Nuxt) => { - function defaultConfig () { - return { - sourceMap: nuxt.options.webpack.cssSourceMap, - plugins: nuxt.options.postcss.plugins, - // Array, String or Function - order: 'autoprefixerAndCssnanoLast' - } - } - function sortPlugins ({ plugins, order }: any) { const names = Object.keys(plugins) if (typeof order === 'string') { @@ -43,38 +34,31 @@ export const getPostcssConfig = (nuxt: Nuxt) => { return typeof order === 'function' ? order(names, orderPresets) : (order || names) } - function loadPlugins (config: any) { - if (!isPureObject(config.plugins)) { return } + if (!nuxt.options.webpack.postcss || !nuxt.options.postcss) { + return false + } + const postcssOptions = defu({}, nuxt.options.postcss, { + sourceMap: nuxt.options.webpack.cssSourceMap, + // Array, String or Function + order: 'autoprefixerAndCssnanoLast' + }) + + // Keep the order of default plugins + if (!Array.isArray(postcssOptions.plugins) && isPureObject(postcssOptions.plugins)) { // Map postcss plugins into instances on object mode once const cjs = createCommonJS(import.meta.url) - config.plugins = sortPlugins(config).map((pluginName: string) => { + postcssOptions.plugins = sortPlugins(postcssOptions).map((pluginName: string) => { const pluginFn = requireModule(pluginName, { paths: [cjs.__dirname] }) - const pluginOptions = config.plugins[pluginName] + const pluginOptions = postcssOptions.plugins[pluginName] if (!pluginOptions || typeof pluginFn !== 'function') { return null } return pluginFn(pluginOptions) }).filter(Boolean) } - if (!nuxt.options.webpack.postcss || !nuxt.options.postcss) { - return false - } - - let postcssOptions = cloneDeep(nuxt.options.postcss) - // Apply default plugins - if (isPureObject(postcssOptions)) { - if (Array.isArray(postcssOptions.plugins)) { - defaults(postcssOptions, defaultConfig()) - } else { - // Keep the order of default plugins - postcssOptions = merge({}, defaultConfig(), postcssOptions) - loadPlugins(postcssOptions) - } - - return { - sourceMap: nuxt.options.webpack.cssSourceMap, - ...nuxt.options.webpack.postcss, - postcssOptions - } + return { + sourceMap: nuxt.options.webpack.cssSourceMap, + ...nuxt.options.webpack.postcss, + postcssOptions } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43de3db5ba..2fb6e87e81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -744,9 +744,9 @@ importers: hash-sum: specifier: ^2.0.0 version: 2.0.0 - lodash-es: - specifier: ^4.17.21 - version: 4.17.21 + klona: + specifier: ^2.0.6 + version: 2.0.6 magic-string: specifier: ^0.30.4 version: 0.30.4 @@ -832,9 +832,6 @@ importers: '@types/hash-sum': specifier: 1.0.0 version: 1.0.0 - '@types/lodash-es': - specifier: 4.17.9 - version: 4.17.9 '@types/pify': specifier: 5.0.2 version: 5.0.2 @@ -1272,6 +1269,7 @@ packages: /@babel/highlight@7.22.20: resolution: {integrity: sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==} engines: {node: '>=6.9.0'} + requiresBuild: true dependencies: '@babel/helper-validator-identifier': 7.22.20 chalk: 2.4.2 @@ -7994,6 +7992,7 @@ packages: /lodash-es@4.17.21: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + dev: true /lodash._reinterpolate@3.0.0: resolution: {integrity: sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA==} From 830f4f4aa88e639de6b04ccdb393628f629d909b Mon Sep 17 00:00:00 2001 From: Sacha Stafyniak Date: Mon, 16 Oct 2023 14:56:23 +0200 Subject: [PATCH 3/7] feat(nuxt): support `deep: false` for data composables (#23600) --- docs/3.api/1.composables/use-async-data.md | 2 ++ docs/3.api/1.composables/use-fetch.md | 2 ++ docs/3.api/1.composables/use-state.md | 9 +++++++++ packages/nuxt/src/app/composables/asyncData.ts | 7 +++++-- packages/nuxt/src/app/composables/fetch.ts | 2 ++ 5 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/3.api/1.composables/use-async-data.md b/docs/3.api/1.composables/use-async-data.md index 866705e69a..ff318a552f 100644 --- a/docs/3.api/1.composables/use-async-data.md +++ b/docs/3.api/1.composables/use-async-data.md @@ -26,6 +26,7 @@ type AsyncDataOptions = { server?: boolean lazy?: boolean immediate?: boolean + deep?: boolean default?: () => DataT | Ref | null transform?: (input: DataT) => DataT pick?: string[] @@ -60,6 +61,7 @@ type AsyncDataRequestStatus = 'idle' | 'pending' | 'success' | 'error' * _transform_: a function that can be used to alter `handler` function result after resolving * _pick_: only pick specified keys in this array from the `handler` function result * _watch_: watch reactive sources to auto-refresh + * _deep_: return data in a deep ref object (it is `true` by default). It can be set to `false` to return data in a shallow ref object, which can improve performance if your data does not need to be deeply reactive. Under the hood, `lazy: false` uses `` to block the loading of the route before the data has been fetched. Consider using `lazy: true` and implementing a loading state instead for a snappier user experience. diff --git a/docs/3.api/1.composables/use-fetch.md b/docs/3.api/1.composables/use-fetch.md index b3bdc3cd11..765bee2c53 100644 --- a/docs/3.api/1.composables/use-fetch.md +++ b/docs/3.api/1.composables/use-fetch.md @@ -26,6 +26,7 @@ type UseFetchOptions = { server?: boolean lazy?: boolean immediate?: boolean + deep?: boolean default?: () => DataT transform?: (input: DataT) => DataT pick?: string[] @@ -72,6 +73,7 @@ All fetch options can be given a `computed` or `ref` value. These will be watche * `transform`: a function that can be used to alter `handler` function result after resolving * `pick`: only pick specified keys in this array from the `handler` function result * `watch`: watch an array of reactive sources and auto-refresh the fetch result when they change. Fetch options and URL are watched by default. You can completely ignore reactive sources by using `watch: false`. Together with `immediate: false`, this allows for a fully-manual `useFetch`. + * `deep`: return data in a deep ref object (it is `true` by default). It can be set to `false` to return data in a shallow ref object, which can improve performance if your data does not need to be deeply reactive. ::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`](/docs/api/composables/use-fetch) call will not match other [`useFetch`](/docs/api/composables/use-fetch) 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`. diff --git a/docs/3.api/1.composables/use-state.md b/docs/3.api/1.composables/use-state.md index e33472ca05..9c82f7af60 100644 --- a/docs/3.api/1.composables/use-state.md +++ b/docs/3.api/1.composables/use-state.md @@ -24,3 +24,12 @@ Because the data inside [`useState`](/docs/api/composables/use-state) will be se ::ReadMore{link="/docs/getting-started/state-management"} :: + +## Using `shallowRef` + +If you don't need your state to be deeply reactive, you can combine `useState` with [`shallowRef`](https://vuejs.org/api/reactivity-advanced.html#shallowref). This can improve performance when your state contains large objects and arrays. + +```ts +const state = useState('my-shallow-state', () => shallowRef({ deep: 'not reactive' })) +// isShallow(state) === true +``` diff --git a/packages/nuxt/src/app/composables/asyncData.ts b/packages/nuxt/src/app/composables/asyncData.ts index 62cae6ecd9..25d39dc49d 100644 --- a/packages/nuxt/src/app/composables/asyncData.ts +++ b/packages/nuxt/src/app/composables/asyncData.ts @@ -1,4 +1,4 @@ -import { getCurrentInstance, onBeforeMount, onServerPrefetch, onUnmounted, ref, toRef, unref, watch } from 'vue' +import { getCurrentInstance, onBeforeMount, onServerPrefetch, onUnmounted, ref, shallowRef, toRef, unref, watch } from 'vue' import type { Ref, WatchSource } from 'vue' import type { NuxtApp } from '../nuxt' import { useNuxtApp } from '../nuxt' @@ -44,6 +44,7 @@ export interface AsyncDataOptions< pick?: PickKeys watch?: MultiWatchSources immediate?: boolean + deep?: boolean } export interface AsyncDataExecuteOptions { @@ -148,8 +149,10 @@ export function useAsyncData< if (!nuxt._asyncData[key] || !options.immediate) { nuxt.payload._errors[key] ??= null + const _ref = options.deep !== true ? shallowRef : ref + nuxt._asyncData[key] = { - data: ref(getCachedData() ?? options.default!()), + data: _ref(getCachedData() ?? options.default!()), pending: ref(!hasCachedData()), error: toRef(nuxt.payload._errors, key), status: ref('idle') diff --git a/packages/nuxt/src/app/composables/fetch.ts b/packages/nuxt/src/app/composables/fetch.ts index a3cb38fc51..ed97a9af2e 100644 --- a/packages/nuxt/src/app/composables/fetch.ts +++ b/packages/nuxt/src/app/composables/fetch.ts @@ -107,6 +107,7 @@ export function useFetch< pick, watch, immediate, + deep, ...fetchOptions } = opts @@ -122,6 +123,7 @@ export function useFetch< transform, pick, immediate, + deep, watch: watch === false ? [] : [_fetchOptions, _request, ...(watch || [])] } From 24b629e82e321a1c66484673e6803ed34ec120d1 Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Mon, 16 Oct 2023 15:09:54 +0200 Subject: [PATCH 4/7] fix(nuxt): skip hydration mismatches with client components (#19231) --- .../2.directory-structure/1.components.md | 4 --- .../nuxt/src/app/components/client-only.ts | 32 ++++++++++++++++--- packages/nuxt/src/app/components/utils.ts | 2 +- test/basic.test.ts | 18 +++++++++++ .../client/FragmentServer.client.vue | 8 +++++ .../client/FragmentServer.server.vue | 5 +++ .../client/FragmentServerFragment.client.vue | 8 +++++ .../client/FragmentServerFragment.server.vue | 8 +++++ .../basic/components/client/Server.client.vue | 5 +++ .../basic/components/client/Server.server.vue | 5 +++ .../client/ServerFragment.client.vue | 5 +++ .../client/ServerFragment.server.vue | 8 +++++ test/fixtures/basic/pages/client-server.vue | 28 ++++++++++++++++ 13 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 test/fixtures/basic/components/client/FragmentServer.client.vue create mode 100644 test/fixtures/basic/components/client/FragmentServer.server.vue create mode 100644 test/fixtures/basic/components/client/FragmentServerFragment.client.vue create mode 100644 test/fixtures/basic/components/client/FragmentServerFragment.server.vue create mode 100644 test/fixtures/basic/components/client/Server.client.vue create mode 100644 test/fixtures/basic/components/client/Server.server.vue create mode 100644 test/fixtures/basic/components/client/ServerFragment.client.vue create mode 100644 test/fixtures/basic/components/client/ServerFragment.server.vue create mode 100644 test/fixtures/basic/pages/client-server.vue diff --git a/docs/2.guide/2.directory-structure/1.components.md b/docs/2.guide/2.directory-structure/1.components.md index a6fb461af3..6e594c77a6 100644 --- a/docs/2.guide/2.directory-structure/1.components.md +++ b/docs/2.guide/2.directory-structure/1.components.md @@ -350,10 +350,6 @@ In this case, the `.server` + `.client` components are two 'halves' of a compone ``` -::alert{type=warning} -It is essential that the client half of the component can 'hydrate' the server-rendered HTML. That is, it should render the same HTML on initial load, or you will experience a hydration mismatch. -:: - ## `` Component Nuxt provides the `` component to render a component only during development. diff --git a/packages/nuxt/src/app/components/client-only.ts b/packages/nuxt/src/app/components/client-only.ts index f6739e118a..894decea0c 100644 --- a/packages/nuxt/src/app/components/client-only.ts +++ b/packages/nuxt/src/app/components/client-only.ts @@ -1,5 +1,6 @@ -import { createElementBlock, createElementVNode, defineComponent, h, mergeProps, onMounted, ref } from 'vue' -import type { ComponentOptions } from 'vue' +import { createElementBlock, createElementVNode, createStaticVNode, defineComponent, getCurrentInstance, h, onMounted, ref } from 'vue' +import type { ComponentInternalInstance, ComponentOptions } from 'vue' +import { getFragmentHTML } from './utils' export default defineComponent({ name: 'ClientOnly', @@ -39,7 +40,8 @@ export function createClientOnly (component: T) { ? createElementVNode(res.type, res.props, res.children, res.patchFlag, res.dynamicProps, res.shapeFlag) : h(res) } else { - return h('div', mergeProps(ctx.$attrs ?? ctx._.attrs, { key: 'placeholder-key' })) + const fragment = getFragmentHTML(ctx._.vnode.el ?? null) + return process.client ? createStaticVNode(fragment.join(''), fragment.length) : h('div', ctx.$attrs ?? ctx._.attrs) } } } else if (clone.template) { @@ -51,8 +53,20 @@ export function createClientOnly (component: T) { } clone.setup = (props, ctx) => { + const instance = getCurrentInstance()! + + const attrs = instance.attrs + // remove existing directives during hydration + const directives = extractDirectives(instance) + // prevent attrs inheritance since a staticVNode is rendered before hydration + instance.attrs = {} const mounted$ = ref(false) - onMounted(() => { mounted$.value = true }) + + onMounted(() => { + instance.attrs = attrs + instance.vnode.dirs = directives + mounted$.value = true + }) return Promise.resolve(component.setup?.(props, ctx) || {}) .then((setupState) => { @@ -65,7 +79,8 @@ export function createClientOnly (component: T) { ? createElementVNode(res.type, res.props, res.children, res.patchFlag, res.dynamicProps, res.shapeFlag) : h(res) } else { - return h('div', mergeProps(ctx.attrs, { key: 'placeholder-key' })) + const fragment = getFragmentHTML(instance?.vnode.el ?? null) + return process.client ? createStaticVNode(fragment.join(''), fragment.length) : h('div', ctx.attrs) } } }) @@ -75,3 +90,10 @@ export function createClientOnly (component: T) { return clone } + +function extractDirectives (instance: ComponentInternalInstance | null) { + if (!instance || !instance.vnode.dirs) { return null } + const directives = instance.vnode.dirs + instance.vnode.dirs = null + return directives +} diff --git a/packages/nuxt/src/app/components/utils.ts b/packages/nuxt/src/app/components/utils.ts index c98d8efaac..3fac26c7bb 100644 --- a/packages/nuxt/src/app/components/utils.ts +++ b/packages/nuxt/src/app/components/utils.ts @@ -104,7 +104,7 @@ export function vforToArray (source: any): any[] { * @param withoutSlots purge all slots from the HTML string retrieved * @returns {string[]} An array of string which represent the content of each element. Use `.join('')` to retrieve a component vnode.el HTML */ -export function getFragmentHTML (element: RendererNode | null, withoutSlots = false) { +export function getFragmentHTML (element: RendererNode | null, withoutSlots = false): string[] { if (element) { if (element.nodeName === '#comment' && element.nodeValue === '[') { return getFragmentChildren(element, [], withoutSlots) diff --git a/test/basic.test.ts b/test/basic.test.ts index 04f54eef72..e3e27c933d 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -271,6 +271,24 @@ describe('pages', () => { await expectNoClientErrors('/another-parent') }) + it('/client-server', async () => { + // expect no hydration issues + await expectNoClientErrors('/client-server') + const page = await createPage('/client-server') + await page.waitForLoadState('networkidle') + const bodyHTML = await page.innerHTML('body') + expect(await page.locator('.placeholder-to-ensure-no-override').all()).toHaveLength(5) + expect(await page.locator('.server').all()).toHaveLength(0) + expect(await page.locator('.client-fragment-server.client').all()).toHaveLength(2) + expect(await page.locator('.client-fragment-server-fragment.client').all()).toHaveLength(2) + expect(await page.locator('.client-server.client').all()).toHaveLength(1) + expect(await page.locator('.client-server-fragment.client').all()).toHaveLength(1) + expect(await page.locator('.client-server-fragment.client').all()).toHaveLength(1) + + expect(bodyHTML).not.toContain('hello') + expect(bodyHTML).toContain('world') + }) + it('/client-only-components', async () => { const html = await $fetch('/client-only-components') // ensure fallbacks with classes and arbitrary attributes are rendered diff --git a/test/fixtures/basic/components/client/FragmentServer.client.vue b/test/fixtures/basic/components/client/FragmentServer.client.vue new file mode 100644 index 0000000000..281136bff6 --- /dev/null +++ b/test/fixtures/basic/components/client/FragmentServer.client.vue @@ -0,0 +1,8 @@ + diff --git a/test/fixtures/basic/components/client/FragmentServer.server.vue b/test/fixtures/basic/components/client/FragmentServer.server.vue new file mode 100644 index 0000000000..28bcc947f5 --- /dev/null +++ b/test/fixtures/basic/components/client/FragmentServer.server.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic/components/client/FragmentServerFragment.client.vue b/test/fixtures/basic/components/client/FragmentServerFragment.client.vue new file mode 100644 index 0000000000..398f0f0a56 --- /dev/null +++ b/test/fixtures/basic/components/client/FragmentServerFragment.client.vue @@ -0,0 +1,8 @@ + diff --git a/test/fixtures/basic/components/client/FragmentServerFragment.server.vue b/test/fixtures/basic/components/client/FragmentServerFragment.server.vue new file mode 100644 index 0000000000..ca5c6c67a2 --- /dev/null +++ b/test/fixtures/basic/components/client/FragmentServerFragment.server.vue @@ -0,0 +1,8 @@ + diff --git a/test/fixtures/basic/components/client/Server.client.vue b/test/fixtures/basic/components/client/Server.client.vue new file mode 100644 index 0000000000..4a948b67cd --- /dev/null +++ b/test/fixtures/basic/components/client/Server.client.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic/components/client/Server.server.vue b/test/fixtures/basic/components/client/Server.server.vue new file mode 100644 index 0000000000..2944736ca6 --- /dev/null +++ b/test/fixtures/basic/components/client/Server.server.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic/components/client/ServerFragment.client.vue b/test/fixtures/basic/components/client/ServerFragment.client.vue new file mode 100644 index 0000000000..9aa8067a42 --- /dev/null +++ b/test/fixtures/basic/components/client/ServerFragment.client.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic/components/client/ServerFragment.server.vue b/test/fixtures/basic/components/client/ServerFragment.server.vue new file mode 100644 index 0000000000..96e7f42586 --- /dev/null +++ b/test/fixtures/basic/components/client/ServerFragment.server.vue @@ -0,0 +1,8 @@ + diff --git a/test/fixtures/basic/pages/client-server.vue b/test/fixtures/basic/pages/client-server.vue new file mode 100644 index 0000000000..70bf772567 --- /dev/null +++ b/test/fixtures/basic/pages/client-server.vue @@ -0,0 +1,28 @@ + From 34adac661de1935eda63dab516e103d8196e3a85 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 16 Oct 2023 14:36:30 +0100 Subject: [PATCH 5/7] fix(nuxt): write cookie values before navigating away (#23697) --- packages/nuxt/src/app/composables/cookie.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/nuxt/src/app/composables/cookie.ts b/packages/nuxt/src/app/composables/cookie.ts index 16d703b52a..be545fd861 100644 --- a/packages/nuxt/src/app/composables/cookie.ts +++ b/packages/nuxt/src/app/composables/cookie.ts @@ -35,8 +35,6 @@ export function useCookie (name: string, _opts?: if (import.meta.client) { const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(`nuxt:cookies:${name}`) - if (getCurrentScope()) { onScopeDispose(() => { channel?.close() }) } - const callback = () => { writeClientCookie(name, cookie.value, opts as CookieSerializeOptions) channel?.postMessage(opts.encode(cookie.value as T)) @@ -44,6 +42,14 @@ export function useCookie (name: string, _opts?: let watchPaused = false + if (getCurrentScope()) { + onScopeDispose(() => { + watchPaused = true + callback() + channel?.close() + }) + } + if (channel) { channel.onmessage = (event) => { watchPaused = true From f4d67a9bcdb7e9dd26b4aecc470a702546b5f44a Mon Sep 17 00:00:00 2001 From: warflash Date: Mon, 16 Oct 2023 21:20:02 +0200 Subject: [PATCH 6/7] fix(nuxt): refetch both undefined/null values in `useAsyncData` (#23351) --- packages/nuxt/src/app/composables/asyncData.ts | 6 ++++-- test/basic.test.ts | 6 ++++++ .../basic/pages/useAsyncData/nuxt-data.vue | 15 +++++++++++++++ test/nuxt/composables.test.ts | 14 ++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/basic/pages/useAsyncData/nuxt-data.vue diff --git a/packages/nuxt/src/app/composables/asyncData.ts b/packages/nuxt/src/app/composables/asyncData.ts index 25d39dc49d..eff2d725b9 100644 --- a/packages/nuxt/src/app/composables/asyncData.ts +++ b/packages/nuxt/src/app/composables/asyncData.ts @@ -143,7 +143,9 @@ export function useAsyncData< const nuxt = useNuxtApp() const getCachedData = () => nuxt.isHydrating ? nuxt.payload.data[key] : nuxt.static.data[key] - const hasCachedData = () => getCachedData() !== undefined + const hasCachedData = () => ![null, undefined].includes( + nuxt.isHydrating ? nuxt.payload.data[key] : nuxt.static.data[key] + ) // Create or use a shared asyncData entity if (!nuxt._asyncData[key] || !options.immediate) { @@ -253,7 +255,7 @@ export function useAsyncData< } } - if (fetchOnServer && nuxt.isHydrating && hasCachedData()) { + if (asyncData.error.value || (fetchOnServer && nuxt.isHydrating && hasCachedData())) { // 1. Hydration (server: true): no fetch asyncData.pending.value = false asyncData.status.value = asyncData.error.value ? 'error' : 'success' diff --git a/test/basic.test.ts b/test/basic.test.ts index e3e27c933d..b15c84b74d 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -1925,6 +1925,12 @@ describe.skipIf(process.env.TEST_CONTEXT !== 'async')('Async context', () => { }) describe.skipIf(isWindows)('useAsyncData', () => { + it('works after useNuxtData call', async () => { + const page = await createPage('/useAsyncData/nuxt-data') + expect(await page.locator('body').getByText('resolved:true').textContent()).toContain('resolved:true') + await page.close() + }) + it('single request resolves', async () => { await expectNoClientErrors('/useAsyncData/single') }) diff --git a/test/fixtures/basic/pages/useAsyncData/nuxt-data.vue b/test/fixtures/basic/pages/useAsyncData/nuxt-data.vue new file mode 100644 index 0000000000..f661a6b5d9 --- /dev/null +++ b/test/fixtures/basic/pages/useAsyncData/nuxt-data.vue @@ -0,0 +1,15 @@ + + + diff --git a/test/nuxt/composables.test.ts b/test/nuxt/composables.test.ts index ef219e372d..abdba10556 100644 --- a/test/nuxt/composables.test.ts +++ b/test/nuxt/composables.test.ts @@ -143,6 +143,20 @@ describe('useAsyncData', () => { expect(useNuxtData('key').data.value).toBeUndefined() }) + it('should be usable _after_ a useNuxtData call', async () => { + useNuxtApp().payload.data.call = null + const { data: cachedData } = useNuxtData('call') + expect(cachedData.value).toMatchInlineSnapshot('null') + const { data } = await useAsyncData('call', () => Promise.resolve({ resolved: true }), { server: false }) + expect(cachedData.value).toMatchInlineSnapshot(` + { + "resolved": true, + } + `) + expect(data.value).toEqual(cachedData.value) + clearNuxtData('call') + }) + it('should be refreshable', async () => { await useAsyncData('key', () => Promise.resolve('test')) clearNuxtData('key') From b52548d915426629a493b3e9a28a0a606e8e3843 Mon Sep 17 00:00:00 2001 From: Dario Ferderber Date: Mon, 16 Oct 2023 21:54:39 +0200 Subject: [PATCH 7/7] feat(nuxt): custom cache support for data fetching composables (#20747) --- docs/3.api/1.composables/use-async-data.md | 2 ++ docs/3.api/1.composables/use-fetch.md | 2 ++ .../nuxt/src/app/composables/asyncData.ts | 36 ++++++++++--------- packages/nuxt/src/app/composables/fetch.ts | 2 ++ test/nuxt/composables.test.ts | 9 +++++ 5 files changed, 34 insertions(+), 17 deletions(-) diff --git a/docs/3.api/1.composables/use-async-data.md b/docs/3.api/1.composables/use-async-data.md index ff318a552f..91d00c5702 100644 --- a/docs/3.api/1.composables/use-async-data.md +++ b/docs/3.api/1.composables/use-async-data.md @@ -31,6 +31,7 @@ type AsyncDataOptions = { transform?: (input: DataT) => DataT pick?: string[] watch?: WatchSource[] + getCachedData?: (key: string) => any } type AsyncData = { @@ -61,6 +62,7 @@ type AsyncDataRequestStatus = 'idle' | 'pending' | 'success' | 'error' * _transform_: a function that can be used to alter `handler` function result after resolving * _pick_: only pick specified keys in this array from the `handler` function result * _watch_: watch reactive sources to auto-refresh + * _getCachedData_: a function that receives a cache key and can return cached data if it exists (by default it returns `nuxtApp.payload.data[key]` when hydrating and `nuxtApp.static.data[key]` after the app is hydrated). You can use this to build your own custom cache for `useAsyncData`. * _deep_: return data in a deep ref object (it is `true` by default). It can be set to `false` to return data in a shallow ref object, which can improve performance if your data does not need to be deeply reactive. Under the hood, `lazy: false` uses `` to block the loading of the route before the data has been fetched. Consider using `lazy: true` and implementing a loading state instead for a snappier user experience. diff --git a/docs/3.api/1.composables/use-fetch.md b/docs/3.api/1.composables/use-fetch.md index 765bee2c53..ff9bbb909d 100644 --- a/docs/3.api/1.composables/use-fetch.md +++ b/docs/3.api/1.composables/use-fetch.md @@ -26,6 +26,7 @@ type UseFetchOptions = { server?: boolean lazy?: boolean immediate?: boolean + getCachedData?: (key: string) => any deep?: boolean default?: () => DataT transform?: (input: DataT) => DataT @@ -73,6 +74,7 @@ All fetch options can be given a `computed` or `ref` value. These will be watche * `transform`: a function that can be used to alter `handler` function result after resolving * `pick`: only pick specified keys in this array from the `handler` function result * `watch`: watch an array of reactive sources and auto-refresh the fetch result when they change. Fetch options and URL are watched by default. You can completely ignore reactive sources by using `watch: false`. Together with `immediate: false`, this allows for a fully-manual `useFetch`. + * `getCachedData`: a function that receives a cache key and can return cached data if it exists (by default it returns `nuxtApp.payload.data[key]` when hydrating and `nuxtApp.static.data[key]` after the app is hydrated). You can use this to build your own custom cache for `useFetch`. * `deep`: return data in a deep ref object (it is `true` by default). It can be set to `false` to return data in a shallow ref object, which can improve performance if your data does not need to be deeply reactive. ::alert{type=warning} diff --git a/packages/nuxt/src/app/composables/asyncData.ts b/packages/nuxt/src/app/composables/asyncData.ts index eff2d725b9..ac83951f3d 100644 --- a/packages/nuxt/src/app/composables/asyncData.ts +++ b/packages/nuxt/src/app/composables/asyncData.ts @@ -40,6 +40,7 @@ export interface AsyncDataOptions< server?: boolean lazy?: boolean default?: () => DefaultT | Ref + getCachedData?: (key: string) => DataT transform?: _Transform pick?: PickKeys watch?: MultiWatchSources @@ -60,15 +61,14 @@ export interface AsyncDataExecuteOptions { export interface _AsyncData { data: Ref pending: Ref - refresh: (opts?: AsyncDataExecuteOptions) => Promise - execute: (opts?: AsyncDataExecuteOptions) => Promise + refresh: (opts?: AsyncDataExecuteOptions) => Promise + execute: (opts?: AsyncDataExecuteOptions) => Promise error: Ref status: Ref } export type AsyncData = _AsyncData & Promise<_AsyncData> -const getDefault = () => null export function useAsyncData< ResT, DataE = Error, @@ -132,20 +132,22 @@ export function useAsyncData< throw new TypeError('[nuxt] [asyncData] handler must be a function.') } + // Setup nuxt instance payload + const nuxt = useNuxtApp() + + // Used to get default values + const getDefault = () => null + const getDefaultCachedData = () => nuxt.isHydrating ? nuxt.payload.data[key] : nuxt.static.data[key] + // Apply defaults options.server = options.server ?? true options.default = options.default ?? (getDefault as () => DefaultT) + options.getCachedData = options.getCachedData ?? getDefaultCachedData options.lazy = options.lazy ?? false options.immediate = options.immediate ?? true - // Setup nuxt instance payload - const nuxt = useNuxtApp() - - const getCachedData = () => nuxt.isHydrating ? nuxt.payload.data[key] : nuxt.static.data[key] - const hasCachedData = () => ![null, undefined].includes( - nuxt.isHydrating ? nuxt.payload.data[key] : nuxt.static.data[key] - ) + const hasCachedData = () => ![null, undefined].includes(options.getCachedData!(key) as any) // Create or use a shared asyncData entity if (!nuxt._asyncData[key] || !options.immediate) { @@ -154,7 +156,7 @@ export function useAsyncData< const _ref = options.deep !== true ? shallowRef : ref nuxt._asyncData[key] = { - data: _ref(getCachedData() ?? options.default!()), + data: _ref(options.getCachedData!(key) ?? options.default!()), pending: ref(!hasCachedData()), error: toRef(nuxt.payload._errors, key), status: ref('idle') @@ -168,13 +170,13 @@ export function useAsyncData< if (nuxt._asyncDataPromises[key]) { if (opts.dedupe === false) { // Avoid fetching same key more than once at a time - return nuxt._asyncDataPromises[key] + return nuxt._asyncDataPromises[key]! } (nuxt._asyncDataPromises[key] as any).cancelled = true } // Avoid fetching same key that is already fetched if ((opts._initial || (nuxt.isHydrating && opts._initial !== false)) && hasCachedData()) { - return getCachedData() + return Promise.resolve(options.getCachedData!(key)) } asyncData.pending.value = true asyncData.status.value = 'pending' @@ -222,7 +224,7 @@ export function useAsyncData< delete nuxt._asyncDataPromises[key] }) nuxt._asyncDataPromises[key] = promise - return nuxt._asyncDataPromises[key] + return nuxt._asyncDataPromises[key]! } const initialFetch = () => asyncData.refresh({ _initial: true }) @@ -235,7 +237,7 @@ export function useAsyncData< if (getCurrentInstance()) { onServerPrefetch(() => promise) } else { - nuxt.hook('app:created', () => promise) + nuxt.hook('app:created', async () => { await promise }) } } @@ -270,9 +272,9 @@ export function useAsyncData< if (options.watch) { watch(options.watch, () => asyncData.refresh()) } - const off = nuxt.hook('app:data:refresh', (keys) => { + const off = nuxt.hook('app:data:refresh', async (keys) => { if (!keys || keys.includes(key)) { - return asyncData.refresh() + await asyncData.refresh() } }) if (instance) { diff --git a/packages/nuxt/src/app/composables/fetch.ts b/packages/nuxt/src/app/composables/fetch.ts index ed97a9af2e..f969cece81 100644 --- a/packages/nuxt/src/app/composables/fetch.ts +++ b/packages/nuxt/src/app/composables/fetch.ts @@ -107,6 +107,7 @@ export function useFetch< pick, watch, immediate, + getCachedData, deep, ...fetchOptions } = opts @@ -123,6 +124,7 @@ export function useFetch< transform, pick, immediate, + getCachedData, deep, watch: watch === false ? [] : [_fetchOptions, _request, ...(watch || [])] } diff --git a/test/nuxt/composables.test.ts b/test/nuxt/composables.test.ts index abdba10556..9935776ddf 100644 --- a/test/nuxt/composables.test.ts +++ b/test/nuxt/composables.test.ts @@ -165,6 +165,15 @@ describe('useAsyncData', () => { await refreshNuxtData('key') expect(data.data.value).toMatchInlineSnapshot('"test"') }) + + it('allows custom access to a cache', async () => { + const { data } = await useAsyncData(() => ({ val: true }), { getCachedData: () => ({ val: false }) }) + expect(data.value).toMatchInlineSnapshot(` + { + "val": false, + } + `) + }) }) describe('errors', () => {