From 9e503be0f2a24f4df72a3ccab2db4d3e63511f57 Mon Sep 17 00:00:00 2001 From: pooya parsa Date: Fri, 7 Apr 2023 12:34:35 +0200 Subject: [PATCH] feat(nuxt): experimental option for rich json payloads (#19205) Co-authored-by: Daniel Roe --- docs/3.api/1.composables/use-nuxt-app.md | 22 +++++- packages/nuxt/package.json | 1 + .../nuxt/src/app/composables/asyncData.ts | 2 +- packages/nuxt/src/app/composables/index.ts | 2 +- packages/nuxt/src/app/composables/payload.ts | 78 ++++++++++++++++++- packages/nuxt/src/app/entry.ts | 5 +- packages/nuxt/src/app/nuxt.ts | 29 ++++--- .../src/app/plugins/revive-payload.client.ts | 23 ++++++ .../src/app/plugins/revive-payload.server.ts | 21 +++++ packages/nuxt/src/core/nitro.ts | 1 + packages/nuxt/src/core/nuxt.ts | 7 ++ .../nuxt/src/core/runtime/nitro/renderer.ts | 48 ++++++++++-- packages/nuxt/src/core/templates.ts | 1 + packages/nuxt/src/imports/presets.ts | 4 +- packages/schema/src/config/build.ts | 2 + packages/schema/src/config/experimental.ts | 4 + pnpm-lock.yaml | 7 ++ test/basic.test.ts | 58 +++++++++++--- test/bundle.test.ts | 5 +- test/fixtures/basic/nuxt.config.ts | 7 ++ test/fixtures/basic/pages/json-payload.vue | 26 +++++++ .../plugins/custom-type-assertion.client.ts | 7 ++ .../basic/plugins/custom-type-registration.ts | 7 ++ test/utils.ts | 29 +++++++ 24 files changed, 355 insertions(+), 41 deletions(-) create mode 100644 packages/nuxt/src/app/plugins/revive-payload.client.ts create mode 100644 packages/nuxt/src/app/plugins/revive-payload.server.ts create mode 100644 test/fixtures/basic/pages/json-payload.vue create mode 100644 test/fixtures/basic/plugins/custom-type-assertion.client.ts create mode 100644 test/fixtures/basic/plugins/custom-type-registration.ts diff --git a/docs/3.api/1.composables/use-nuxt-app.md b/docs/3.api/1.composables/use-nuxt-app.md index e986656d06..d962cca4d0 100644 --- a/docs/3.api/1.composables/use-nuxt-app.md +++ b/docs/3.api/1.composables/use-nuxt-app.md @@ -84,9 +84,7 @@ await nuxtApp.callHook('my-plugin:init') ### `payload` -`payload` exposes data and state variables from server side to client side and makes them available in the `window.__NUXT__` object that is accessible from the browser. - -`payload` exposes the following keys on the client side after they are stringified and passed from the server side: +`payload` exposes data and state variables from server side to client side. The following keys will be available on the client after they have been passed from the server side: - **serverRendered** (boolean) - Indicates if response is server-side-rendered. - **data** (object) - When you fetch the data from an API endpoint using either `useFetch` or `useAsyncData`, resulting payload can be accessed from the `payload.data`. This data is cached and helps you prevent fetching the same data in case an identical request is made more than once. @@ -115,6 +113,24 @@ export default defineNuxtPlugin((nuxtApp) => { }) ``` +::alert +Normally `payload` must contain only plain JavaScript objects. But by setting `experimental.renderJsonPayloads`, it is possible to use more advanced types, such as `ref`, `reactive`, `shallowRef`, `shallowReactive` and `NuxtError`. + +You can also add your own types. In future you will be able to add your own types easily with [object-syntax plugins](https://github.com/nuxt/nuxt/issues/14628). For now, you must add your plugin which calls both `definePayloadReducer` and `definePayloadReviver` via a custom module: + +```ts +export default defineNuxtConfig({ + modules: [ + function (_options, nuxt) { + // TODO: support directly via object syntax plugins: https://github.com/nuxt/nuxt/issues/14628 + nuxt.hook('modules:done', () => { + nuxt.options.plugins.unshift('~/plugins/custom-type-plugin') + }) + }, + ] +}) +:: + ### `isHydrating` Use `nuxtApp.isHydrating` (boolean) to check if the Nuxt app is hydrating on the client side. diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index f8a4b8027f..7b00c02c43 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -74,6 +74,7 @@ "cookie-es": "^0.5.0", "defu": "^6.1.2", "destr": "^1.2.2", + "devalue": "^4.3.0", "escape-string-regexp": "^5.0.0", "estree-walker": "^3.0.3", "fs-extra": "^11.1.1", diff --git a/packages/nuxt/src/app/composables/asyncData.ts b/packages/nuxt/src/app/composables/asyncData.ts index f9e33480cb..d2791772d5 100644 --- a/packages/nuxt/src/app/composables/asyncData.ts +++ b/packages/nuxt/src/app/composables/asyncData.ts @@ -118,7 +118,7 @@ export function useAsyncData< nuxt._asyncData[key] = { data: ref(getCachedData() ?? options.default?.() ?? null), pending: ref(!hasCachedData()), - error: ref(nuxt.payload._errors[key] ? createError(nuxt.payload._errors[key]) : null) + error: toRef(nuxt.payload._errors, key) } } // TODO: Else, somehow check for conflicting keys with different defaults or fetcher diff --git a/packages/nuxt/src/app/composables/index.ts b/packages/nuxt/src/app/composables/index.ts index 845894679e..e40cb76ce6 100644 --- a/packages/nuxt/src/app/composables/index.ts +++ b/packages/nuxt/src/app/composables/index.ts @@ -28,6 +28,6 @@ export { onNuxtReady } from './ready' export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, onBeforeRouteLeave, onBeforeRouteUpdate, setPageLayout, navigateTo, useRoute, useRouter } from './router' export type { AddRouteMiddlewareOptions, RouteMiddleware } from './router' export { preloadComponents, prefetchComponents, preloadRouteComponents } from './preload' -export { isPrerendered, loadPayload, preloadPayload } from './payload' +export { isPrerendered, loadPayload, preloadPayload, definePayloadReducer, definePayloadReviver } from './payload' export type { ReloadNuxtAppOptions } from './chunk' export { reloadNuxtApp } from './chunk' diff --git a/packages/nuxt/src/app/composables/payload.ts b/packages/nuxt/src/app/composables/payload.ts index 3ceef7a0eb..f7f8249c32 100644 --- a/packages/nuxt/src/app/composables/payload.ts +++ b/packages/nuxt/src/app/composables/payload.ts @@ -1,7 +1,12 @@ import { joinURL, hasProtocol } from 'ufo' +import { parse } from 'devalue' import { useHead } from '@unhead/vue' +import { getCurrentInstance } from 'vue' import { useNuxtApp, useRuntimeConfig } from '../nuxt' +// @ts-expect-error virtual import +import { renderJsonPayloads } from '#build/nuxt.config.mjs' + interface LoadPayloadOptions { fresh?: boolean hash?: string @@ -36,6 +41,7 @@ export function preloadPayload (url: string, opts: LoadPayloadOptions = {}) { // --- Internal --- +const extension = renderJsonPayloads ? 'json' : 'js' function _getPayloadURL (url: string, opts: LoadPayloadOptions = {}) { const u = new URL(url, 'http://localhost') if (u.search) { @@ -45,15 +51,19 @@ function _getPayloadURL (url: string, opts: LoadPayloadOptions = {}) { throw new Error('Payload URL must not include hostname: ' + url) } const hash = opts.hash || (opts.fresh ? Date.now() : '') - return joinURL(useRuntimeConfig().app.baseURL, u.pathname, hash ? `_payload.${hash}.js` : '_payload.js') + return joinURL(useRuntimeConfig().app.baseURL, u.pathname, hash ? `_payload.${hash}.${extension}` : `_payload.${extension}`) } async function _importPayload (payloadURL: string) { if (process.server) { return null } - const res = await import(/* webpackIgnore: true */ /* @vite-ignore */ payloadURL).catch((err) => { + try { + return renderJsonPayloads + ? parsePayload(await fetch(payloadURL).then(res => res.text())) + : await import(/* webpackIgnore: true */ /* @vite-ignore */ payloadURL).then(r => r.default || r) + } catch (err) { console.warn('[nuxt] Cannot load payload ', payloadURL, err) - }) - return res?.default || null + } + return null } export function isPrerendered () { @@ -61,3 +71,63 @@ export function isPrerendered () { const nuxtApp = useNuxtApp() return !!nuxtApp.payload.prerenderedAt } + +let payloadCache: any = null +export async function getNuxtClientPayload () { + if (process.server) { + return + } + if (payloadCache) { + return payloadCache + } + + const el = document.getElementById('__NUXT_DATA__') + if (!el) { + return {} + } + + const inlineData = parsePayload(el.textContent || '') + + const externalData = el.dataset.src ? await _importPayload(el.dataset.src) : undefined + + payloadCache = { + ...inlineData, + ...externalData, + ...window.__NUXT__ + } + + return payloadCache +} + +export function parsePayload (payload: string) { + return parse(payload, useNuxtApp()._payloadRevivers) +} + +/** + * This is an experimental function for configuring passing rich data from server -> client. + */ +export function definePayloadReducer ( + name: string, + reduce: (data: any) => any +) { + if (process.server) { + useNuxtApp().ssrContext!._payloadReducers[name] = reduce + } +} + +/** + * This is an experimental function for configuring passing rich data from server -> client. + * + * This function _must_ be called in a Nuxt plugin that is `unshift`ed to the beginning of the Nuxt plugins array. + */ +export function definePayloadReviver ( + name: string, + revive: (data: string) => any | undefined +) { + if (process.dev && getCurrentInstance()) { + console.warn('[nuxt] [definePayloadReviver] This function must be called in a Nuxt plugin that is `unshift`ed to the beginning of the Nuxt plugins array.') + } + if (process.client) { + useNuxtApp()._payloadRevivers[name] = revive + } +} diff --git a/packages/nuxt/src/app/entry.ts b/packages/nuxt/src/app/entry.ts index 4424ae4a4d..9d09fd7ddf 100644 --- a/packages/nuxt/src/app/entry.ts +++ b/packages/nuxt/src/app/entry.ts @@ -52,7 +52,10 @@ if (process.client) { } entry = async function initApp () { - const isSSR = Boolean(window.__NUXT__?.serverRendered) + const isSSR = Boolean( + window.__NUXT__?.serverRendered || + document.getElementById('__NUXT_DATA__')?.dataset.ssr === 'true' + ) const vueApp = isSSR ? createSSRApp(RootComponent) : createApp(RootComponent) const nuxt = createNuxtApp({ vueApp }) diff --git a/packages/nuxt/src/app/nuxt.ts b/packages/nuxt/src/app/nuxt.ts index 05d89f8269..62779112d1 100644 --- a/packages/nuxt/src/app/nuxt.ts +++ b/packages/nuxt/src/app/nuxt.ts @@ -1,5 +1,5 @@ /* eslint-disable no-use-before-define */ -import { getCurrentInstance, reactive } from 'vue' +import { getCurrentInstance, shallowReactive, reactive } from 'vue' import type { App, onErrorCaptured, VNode, Ref } from 'vue' import type { RouteLocationNormalizedLoaded } from 'vue-router' import type { Hookable, HookCallback } from 'hookable' @@ -12,6 +12,7 @@ import type { RuntimeConfig, AppConfigInput, AppConfig } from 'nuxt/schema' // eslint-disable-next-line import/no-restricted-paths import type { NuxtIslandContext } from '../core/runtime/nitro/renderer' import type { RouteMiddleware } from '../../app' +import type { NuxtError } from '../app/composables/error' const nuxtAppCtx = /* #__PURE__ */ getContext('nuxt-app') @@ -58,6 +59,8 @@ export interface NuxtSSRContext extends SSRContext { teleports?: Record renderMeta?: () => Promise | NuxtMeta islandContext?: NuxtIslandContext + /** @internal */ + _payloadReducers: Record any> } interface _NuxtApp { @@ -99,6 +102,9 @@ interface _NuxtApp { /** @internal */ _islandPromises?: Record> + /** @internal */ + _payloadRevivers: Record any> + // Nuxt injections $config: RuntimeConfig @@ -111,7 +117,6 @@ interface _NuxtApp { prerenderedAt?: number data: Record state: Record - rendered?: Function error?: Error | { url: string statusCode: number @@ -120,6 +125,7 @@ interface _NuxtApp { description: string data?: any } | null + _errors: Record [key: string]: any } static: { @@ -152,11 +158,11 @@ export function createNuxtApp (options: CreateOptions) { get nuxt () { return __NUXT_VERSION__ }, get vue () { return nuxtApp.vueApp.version } }, - payload: reactive({ - data: {}, - state: {}, - _errors: {}, - ...(process.client ? window.__NUXT__ : { serverRendered: true }) + payload: shallowReactive({ + data: shallowReactive({}), + state: shallowReactive({}), + _errors: shallowReactive({}), + ...(process.client ? window.__NUXT__ ?? {} : { serverRendered: true }) }), static: { data: {} @@ -182,6 +188,7 @@ export function createNuxtApp (options: CreateOptions) { }, _asyncDataPromises: {}, _asyncData: {}, + _payloadRevivers: {}, ...options } as any as NuxtApp @@ -217,7 +224,11 @@ export function createNuxtApp (options: CreateOptions) { if (nuxtApp.ssrContext) { nuxtApp.ssrContext.nuxt = nuxtApp } - // Expose to server renderer to create window.__NUXT__ + // Expose payload types + if (nuxtApp.ssrContext) { + nuxtApp.ssrContext._payloadReducers = {} + } + // Expose to server renderer to create payload nuxtApp.ssrContext = nuxtApp.ssrContext || {} as any if (nuxtApp.ssrContext!.payload) { Object.assign(nuxtApp.payload, nuxtApp.ssrContext!.payload) @@ -225,7 +236,7 @@ export function createNuxtApp (options: CreateOptions) { nuxtApp.ssrContext!.payload = nuxtApp.payload // Expose client runtime-config to the payload - nuxtApp.payload.config = { + nuxtApp.ssrContext!.config = { public: options.ssrContext!.runtimeConfig.public, app: options.ssrContext!.runtimeConfig.app } diff --git a/packages/nuxt/src/app/plugins/revive-payload.client.ts b/packages/nuxt/src/app/plugins/revive-payload.client.ts new file mode 100644 index 0000000000..53c2f73fe5 --- /dev/null +++ b/packages/nuxt/src/app/plugins/revive-payload.client.ts @@ -0,0 +1,23 @@ +import { reactive, ref, shallowRef, shallowReactive } from 'vue' +import { definePayloadReviver, getNuxtClientPayload } from '#app/composables/payload' +import { createError } from '#app/composables/error' +import { callWithNuxt, defineNuxtPlugin } from '#app/nuxt' + +const revivers = { + NuxtError: (data: any) => createError(data), + EmptyShallowRef: (data: any) => shallowRef(JSON.parse(data)), + EmptyRef: (data: any) => ref(JSON.parse(data)), + ShallowRef: (data: any) => shallowRef(data), + ShallowReactive: (data: any) => shallowReactive(data), + Ref: (data: any) => ref(data), + Reactive: (data: any) => reactive(data) +} + +export default defineNuxtPlugin(async (nuxtApp) => { + for (const reviver in revivers) { + definePayloadReviver(reviver, revivers[reviver as keyof typeof revivers]) + } + Object.assign(nuxtApp.payload, await callWithNuxt(nuxtApp, getNuxtClientPayload, [])) + // For backwards compatibility - TODO: remove later + window.__NUXT__ = nuxtApp.payload +}) diff --git a/packages/nuxt/src/app/plugins/revive-payload.server.ts b/packages/nuxt/src/app/plugins/revive-payload.server.ts new file mode 100644 index 0000000000..5573d8ab04 --- /dev/null +++ b/packages/nuxt/src/app/plugins/revive-payload.server.ts @@ -0,0 +1,21 @@ +import { isShallow, isRef, isReactive, toRaw } from 'vue' +import { definePayloadReducer } from '#app/composables/payload' +import { isNuxtError } from '#app/composables/error' +import { defineNuxtPlugin } from '#app/nuxt' +/* Defining a plugin that will be used by the Nuxt framework. */ + +const reducers = { + NuxtError: (data: any) => isNuxtError(data) && data.toJSON(), + EmptyShallowRef: (data: any) => isRef(data) && isShallow(data) && !data.value && JSON.stringify(data.value), + EmptyRef: (data: any) => isRef(data) && !data.value && JSON.stringify(data.value), + ShallowRef: (data: any) => isRef(data) && isShallow(data) && data.value, + ShallowReactive: (data: any) => isReactive(data) && isShallow(data) && toRaw(data), + Ref: (data: any) => isRef(data) && data.value, + Reactive: (data: any) => isReactive(data) && toRaw(data) +} + +export default defineNuxtPlugin(() => { + for (const reducer in reducers) { + definePayloadReducer(reducer, reducers[reducer as keyof typeof reducers]) + } +}) diff --git a/packages/nuxt/src/core/nitro.ts b/packages/nuxt/src/core/nitro.ts index 684ff3758e..9b2526e078 100644 --- a/packages/nuxt/src/core/nitro.ts +++ b/packages/nuxt/src/core/nitro.ts @@ -172,6 +172,7 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { 'process.env.NUXT_NO_SCRIPTS': !!nuxt.options.experimental.noScripts && !nuxt.options.dev, 'process.env.NUXT_INLINE_STYLES': !!nuxt.options.experimental.inlineSSRStyles, 'process.env.NUXT_PAYLOAD_EXTRACTION': !!nuxt.options.experimental.payloadExtraction, + 'process.env.NUXT_JSON_PAYLOADS': !!nuxt.options.experimental.renderJsonPayloads, 'process.env.NUXT_COMPONENT_ISLANDS': !!nuxt.options.experimental.componentIslands, 'process.dev': nuxt.options.dev, __VUE_PROD_DEVTOOLS__: false diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index f820fd1f1a..6d533a1a8e 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -281,6 +281,13 @@ async function initNuxt (nuxt: Nuxt) { addPlugin(resolve(nuxt.options.appDir, 'plugins/restore-state.client')) } + if (nuxt.options.experimental.renderJsonPayloads) { + nuxt.hook('modules:done', () => { + nuxt.options.plugins.unshift(resolve(nuxt.options.appDir, 'plugins/revive-payload.client')) + nuxt.options.plugins.unshift(resolve(nuxt.options.appDir, 'plugins/revive-payload.server')) + }) + } + // Track components used to render for webpack if (nuxt.options.builder === '@nuxt/webpack-builder') { addPlugin(resolve(nuxt.options.appDir, 'plugins/preload.server')) diff --git a/packages/nuxt/src/core/runtime/nitro/renderer.ts b/packages/nuxt/src/core/runtime/nitro/renderer.ts index 9336bdfa5a..cfab7c4924 100644 --- a/packages/nuxt/src/core/runtime/nitro/renderer.ts +++ b/packages/nuxt/src/core/runtime/nitro/renderer.ts @@ -4,6 +4,7 @@ import type { Manifest } from 'vite' import type { H3Event } from 'h3' import { appendHeader, getQuery, writeEarlyHints, readBody, createError } from 'h3' import devalue from '@nuxt/devalue' +import { stringify, uneval } from 'devalue' import destr from 'destr' import { joinURL, withoutTrailingSlash } from 'ufo' import { renderToString as _renderToString } from 'vue/server-renderer' @@ -121,6 +122,7 @@ const getSPARenderer = lazyCachedFunction(async () => { const renderToString = (ssrContext: NuxtSSRContext) => { const config = useRuntimeConfig() ssrContext!.payload = { + _errors: {}, serverRendered: false, config: { public: config.public, @@ -160,7 +162,7 @@ async function getIslandContext (event: H3Event): Promise { const PAYLOAD_CACHE = (process.env.NUXT_PAYLOAD_EXTRACTION && process.env.prerender) ? new Map() : null // TODO: Use LRU cache const ISLAND_CACHE = (process.env.NUXT_COMPONENT_ISLANDS && process.env.prerender) ? new Map() : null // TODO: Use LRU cache -const PAYLOAD_URL_RE = /\/_payload(\.[a-zA-Z0-9]+)?.js(\?.*)?$/ +const PAYLOAD_URL_RE = process.env.NUXT_JSON_PAYLOADS ? /\/_payload(\.[a-zA-Z0-9]+)?.json(\?.*)?$/ : /\/_payload(\.[a-zA-Z0-9]+)?.js(\?.*)?$/ const ROOT_NODE_REGEX = new RegExp(`^<${appRootTag} id="${appRootId}">([\\s\\S]*)$`) const PRERENDER_NO_SSR_ROUTES = new Set(['/index.html', '/200.html', '/404.html']) @@ -219,12 +221,13 @@ export default defineRenderHandler(async (event) => { error: !!ssrError, nuxt: undefined!, /* NuxtApp */ payload: (ssrError ? { error: ssrError } : {}) as NuxtSSRContext['payload'], + _payloadReducers: {}, islandContext } // Whether we are prerendering route const _PAYLOAD_EXTRACTION = process.env.prerender && process.env.NUXT_PAYLOAD_EXTRACTION && !ssrContext.noSSR - const payloadURL = _PAYLOAD_EXTRACTION ? joinURL(useRuntimeConfig().app.baseURL, url, '_payload.js') : undefined + const payloadURL = _PAYLOAD_EXTRACTION ? joinURL(useRuntimeConfig().app.baseURL, url, process.env.NUXT_JSON_PAYLOADS ? '_payload.json' : '_payload.js') : undefined if (process.env.prerender) { ssrContext.payload.prerenderedAt = Date.now() } @@ -260,7 +263,7 @@ export default defineRenderHandler(async (event) => { if (_PAYLOAD_EXTRACTION) { // Hint nitro to prerender payload for this route - appendHeader(event, 'x-nitro-prerender', joinURL(url, '_payload.js')) + appendHeader(event, 'x-nitro-prerender', joinURL(url, process.env.NUXT_JSON_PAYLOADS ? '_payload.json' : '_payload.js')) // Use same ssr context to generate payload for this route PAYLOAD_CACHE!.set(withoutTrailingSlash(url), renderPayloadResponse(ssrContext)) } @@ -279,7 +282,9 @@ export default defineRenderHandler(async (event) => { htmlAttrs: normalizeChunks([renderedMeta.htmlAttrs]), head: normalizeChunks([ renderedMeta.headTags, - _PAYLOAD_EXTRACTION ? `` : null, + process.env.NUXT_JSON_PAYLOADS + ? _PAYLOAD_EXTRACTION ? `` : null + : _PAYLOAD_EXTRACTION ? `` : null, _rendered.renderResourceHints(), _rendered.renderStyles(), inlinedStyles, @@ -295,8 +300,12 @@ export default defineRenderHandler(async (event) => { process.env.NUXT_NO_SCRIPTS ? undefined : (_PAYLOAD_EXTRACTION - ? `` - : `` + ? process.env.NUXT_JSON_PAYLOADS + ? renderPayloadJsonScript({ id: '__NUXT_DATA__', ssrContext, data: splitPayload(ssrContext).initial, src: payloadURL }) + : renderPayloadScript({ ssrContext, data: splitPayload(ssrContext).initial, src: payloadURL }) + : process.env.NUXT_JSON_PAYLOADS + ? renderPayloadJsonScript({ id: '__NUXT_DATA__', ssrContext, data: ssrContext.payload }) + : renderPayloadScript({ ssrContext, data: ssrContext.payload }) ), _rendered.renderScripts(), // Note: bodyScripts may contain tags other than ` + + `` +} + +function renderPayloadScript (opts: { ssrContext: NuxtSSRContext, data?: any, src?: string }) { + opts.data.config = opts.ssrContext.config + const _PAYLOAD_EXTRACTION = process.env.prerender && process.env.NUXT_PAYLOAD_EXTRACTION && !opts.ssrContext.noSSR + if (_PAYLOAD_EXTRACTION) { + return `` + } + return `` +} + function splitPayload (ssrContext: NuxtSSRContext) { const { data, prerenderedAt, ...initial } = ssrContext.payload return { diff --git a/packages/nuxt/src/core/templates.ts b/packages/nuxt/src/core/templates.ts index 55e73f7669..e77d7094e3 100644 --- a/packages/nuxt/src/core/templates.ts +++ b/packages/nuxt/src/core/templates.ts @@ -296,6 +296,7 @@ export const nuxtConfigTemplate = { getContents: (ctx: TemplateContext) => { return [ ...Object.entries(ctx.nuxt.options.app).map(([k, v]) => `export const ${camelCase('app-' + k)} = ${JSON.stringify(v)}`), + `export const renderJsonPayloads = ${!!ctx.nuxt.options.experimental.renderJsonPayloads}`, `export const devPagesDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.dir.pages) : 'null'}` ].join('\n\n') } diff --git a/packages/nuxt/src/imports/presets.ts b/packages/nuxt/src/imports/presets.ts index 5479c26a14..c8fb0c7368 100644 --- a/packages/nuxt/src/imports/presets.ts +++ b/packages/nuxt/src/imports/presets.ts @@ -55,7 +55,9 @@ const appPreset = defineUnimportPreset({ 'prefetchComponents', 'loadPayload', 'preloadPayload', - 'isPrerendered' + 'isPrerendered', + 'definePayloadReducer', + 'definePayloadReviver' ] }) diff --git a/packages/schema/src/config/build.ts b/packages/schema/src/config/build.ts index 8d68e361bf..e4f74a60da 100644 --- a/packages/schema/src/config/build.ts +++ b/packages/schema/src/config/build.ts @@ -170,6 +170,7 @@ export default defineUntypedSchema({ $resolve: async (val, get) => defu(val || {}, await get('dev') ? {} : { vue: ['onBeforeMount', 'onMounted', 'onBeforeUpdate', 'onRenderTracked', 'onRenderTriggered', 'onActivated', 'onDeactivated', 'onBeforeUnmount'], + '#app': ['definePayloadReviver'] } ) }, @@ -177,6 +178,7 @@ export default defineUntypedSchema({ $resolve: async (val, get) => defu(val || {}, await get('dev') ? {} : { vue: ['onServerPrefetch', 'onRenderTracked', 'onRenderTriggered'], + '#app': ['definePayloadReducer'] } ) } diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index 617f943c25..858f8d54e1 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -98,6 +98,10 @@ export default defineUntypedSchema({ */ noScripts: false, + // TODO: enable by default in v3.5 + /** Render JSON payloads with support for revivifying complex types. */ + renderJsonPayloads: false, + /** * Disable vue server renderer endpoint within nitro. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bafd147a20..d64cd22397 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -563,6 +563,9 @@ importers: destr: specifier: ^1.2.2 version: 1.2.2 + devalue: + specifier: ^4.3.0 + version: 4.3.0 escape-string-regexp: specifier: ^5.0.0 version: 5.0.0 @@ -4419,6 +4422,10 @@ packages: resolution: {integrity: sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==} engines: {node: '>=8'} + /devalue@4.3.0: + resolution: {integrity: sha512-n94yQo4LI3w7erwf84mhRUkUJfhLoCZiLyoOZ/QFsDbcWNZePrLwbQpvZBUG2TNxwV3VjCKPxkiiQA6pe3TrTA==} + dev: false + /diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} diff --git a/test/basic.test.ts b/test/basic.test.ts index 15ae710a0e..ed58421013 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -7,7 +7,7 @@ import { setup, fetch, $fetch, startServer, isDev, createPage, url } from '@nuxt import { $fetchComponent } from '@nuxt/test-utils/experimental' import type { NuxtIslandResponse } from '../packages/nuxt/src/core/runtime/nitro/renderer' -import { expectNoClientErrors, expectWithPolling, renderPage, withLogs } from './utils' +import { expectNoClientErrors, expectWithPolling, parseData, parsePayload, renderPage, withLogs } from './utils' const isWebpack = process.env.TEST_BUILDER === 'webpack' @@ -43,7 +43,9 @@ describe('server api', () => { describe('route rules', () => { it('should enable spa mode', async () => { - expect(await $fetch('/route-rules/spa')).toContain('serverRendered:false') + const { script, attrs } = parseData(await $fetch('/route-rules/spa')) + expect(script.serverRendered).toEqual(false) + expect(attrs['data-ssr']).toEqual('false') }) }) @@ -324,6 +326,23 @@ describe('pages', () => { }) }) +describe('rich payloads', () => { + it('correctly serializes and revivifies complex types', async () => { + const html = await $fetch('/json-payload') + for (const test of [ + 'Date: true', + 'Recursive objects: true', + 'Shallow reactive: true', + 'Shallow ref: true', + 'Reactive: true', + 'Ref: true', + 'Error: true' + ]) { + expect(html).toContain(test) + } + }) +}) + describe('nuxt links', () => { it('handles trailing slashes', async () => { const html = await $fetch('/nuxt-link/trailing-slash') @@ -467,7 +486,8 @@ describe('legacy async data', () => { it('should work with defineNuxtComponent', async () => { const html = await $fetch('/legacy/async-data') expect(html).toContain('
Hello API
') - expect(html).toContain('{hello:"Hello API"}') + const { script } = parseData(html) + expect(script.data['options:asyncdata:/legacy/async-data'].hello).toEqual('Hello API') }) }) @@ -1205,10 +1225,28 @@ describe.runIf(isDev() && !isWebpack)('vite plugins', () => { describe.skipIf(isDev() || isWindows)('payload rendering', () => { it('renders a payload', async () => { - const payload = await $fetch('/random/a/_payload.js', { responseType: 'text' }) - expect(payload).toMatch( - /export default \{data:\{hey:\{[^}]*\},rand_a:\[[^\]]*\],".*":\{html:".*server-only component.*",head:\{link:\[\],style:\[\]\}\}\},prerenderedAt:\d*\}/ - ) + const payload = await $fetch('/random/a/_payload.json', { responseType: 'text' }) + const data = parsePayload(payload) + expect(typeof data.prerenderedAt).toEqual('number') + + const [_key, serverData] = Object.entries(data.data).find(([key]) => key.startsWith('ServerOnlyComponent'))! + expect(serverData).toMatchInlineSnapshot(` + { + "head": { + "link": [], + "style": [], + }, + "html": "
server-only component
", + } + `) + + expect(data.data).toMatchObject({ + hey: { + baz: 'qux', + foo: 'bar' + }, + rand_a: expect.arrayContaining([expect.anything()]) + }) }) it('does not fetch a prefetched payload', async () => { @@ -1222,10 +1260,8 @@ describe.skipIf(isDev() || isWindows)('payload rendering', () => { await page.goto(url('/random/a')) await page.waitForLoadState('networkidle') - const importSuffix = isDev() && !isWebpack ? '?import' : '' - // We are manually prefetching other payloads - expect(requests).toContain('/random/c/_payload.js') + expect(requests).toContain('/random/c/_payload.json') // We are not triggering API requests in the payload expect(requests).not.toContain(expect.stringContaining('/api/random')) @@ -1240,7 +1276,7 @@ describe.skipIf(isDev() || isWindows)('payload rendering', () => { expect(requests).not.toContain(expect.stringContaining('/__nuxt_island')) // We are fetching a payload we did not prefetch - expect(requests).toContain('/random/b/_payload.js' + importSuffix) + expect(requests).toContain('/random/b/_payload.json') // We are not refetching payloads we've already prefetched // expect(requests.filter(p => p.includes('_payload')).length).toBe(1) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index e29efc0ab7..9098877aaa 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -40,10 +40,10 @@ describe.skipIf(isWindows || process.env.ECOSYSTEM_CI)('minimal nuxt application it('default server bundle size', async () => { stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) - expect(roundToKilobytes(stats.server.totalBytes)).toMatchInlineSnapshot('"91k"') + expect(roundToKilobytes(stats.server.totalBytes)).toMatchInlineSnapshot('"92k"') const modules = await analyzeSizes('node_modules/**/*', serverDir) - expect(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2632k"') + expect(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2648k"') const packages = modules.files .filter(m => m.endsWith('package.json')) @@ -66,6 +66,7 @@ describe.skipIf(isWindows || process.env.ECOSYSTEM_CI)('minimal nuxt application "cookie-es", "defu", "destr", + "devalue", "estree-walker", "h3", "hookable", diff --git a/test/fixtures/basic/nuxt.config.ts b/test/fixtures/basic/nuxt.config.ts index c6a7ab5b31..6a11e29bba 100644 --- a/test/fixtures/basic/nuxt.config.ts +++ b/test/fixtures/basic/nuxt.config.ts @@ -108,6 +108,12 @@ export default defineNuxtConfig({ addVitePlugin(plugin.vite()) addWebpackPlugin(plugin.webpack()) }, + function (_options, nuxt) { + // TODO: support directly via object syntax plugins: https://github.com/nuxt/nuxt/issues/14628 + nuxt.hook('modules:done', () => { + nuxt.options.plugins.unshift('~/plugins/custom-type-registration') + }) + }, function (_options, nuxt) { const routesToDuplicate = ['/async-parent', '/fixed-keyed-child-parent', '/keyed-child-parent', '/with-layout', '/with-layout2'] const stripLayout = (page: NuxtPage): NuxtPage => ({ @@ -184,6 +190,7 @@ export default defineNuxtConfig({ } }, experimental: { + renderJsonPayloads: true, respectNoSSRHeader: true, clientFallback: true, restoreState: true, diff --git a/test/fixtures/basic/pages/json-payload.vue b/test/fixtures/basic/pages/json-payload.vue new file mode 100644 index 0000000000..97a7fc8dd0 --- /dev/null +++ b/test/fixtures/basic/pages/json-payload.vue @@ -0,0 +1,26 @@ + + + diff --git a/test/fixtures/basic/plugins/custom-type-assertion.client.ts b/test/fixtures/basic/plugins/custom-type-assertion.client.ts new file mode 100644 index 0000000000..a7294f6159 --- /dev/null +++ b/test/fixtures/basic/plugins/custom-type-assertion.client.ts @@ -0,0 +1,7 @@ +export default defineNuxtPlugin((nuxtApp) => { + if (nuxtApp.payload.blinkable !== '') { + throw createError({ + message: 'Custom type in Nuxt payload was not revived correctly' + }) + } +}) diff --git a/test/fixtures/basic/plugins/custom-type-registration.ts b/test/fixtures/basic/plugins/custom-type-registration.ts new file mode 100644 index 0000000000..b7ef9b110f --- /dev/null +++ b/test/fixtures/basic/plugins/custom-type-registration.ts @@ -0,0 +1,7 @@ +export default defineNuxtPlugin((nuxtApp) => { + definePayloadReducer('BlinkingText', data => data === '' && '_') + definePayloadReviver('BlinkingText', () => '') + if (process.server) { + nuxtApp.payload.blinkable = '' + } +}) diff --git a/test/utils.ts b/test/utils.ts index 33b43d3863..5d2be09ba8 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -1,5 +1,8 @@ import { expect } from 'vitest' import type { Page } from 'playwright' +import { parse } from 'devalue' +import { shallowReactive, shallowRef, reactive, ref } from 'vue' +import { createError } from 'h3' import { createPage, getBrowser, url, useTestContext } from '@nuxt/test-utils' export async function renderPage (path = '/') { @@ -89,3 +92,29 @@ export async function withLogs (callback: (page: Page, logs: string[]) => Promis await page.close() } } + +const revivers = { + NuxtError: (data: any) => createError(data), + EmptyShallowRef: (data: any) => shallowRef(JSON.parse(data)), + EmptyRef: (data: any) => ref(JSON.parse(data)), + ShallowRef: (data: any) => shallowRef(data), + ShallowReactive: (data: any) => shallowReactive(data), + Ref: (data: any) => ref(data), + Reactive: (data: any) => reactive(data), + // test fixture reviver only + BlinkingText: () => '' +} +export function parsePayload (payload: string) { + return parse(payload || '', revivers) +} +export function parseData (html: string) { + const { script, attrs } = html.match(/