feat(nuxt): experimental option for rich json payloads (#19205)

Co-authored-by: Daniel Roe <daniel@roe.dev>
This commit is contained in:
pooya parsa 2023-04-07 12:34:35 +02:00 committed by GitHub
parent d4f718d8cd
commit 9e503be0f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 355 additions and 41 deletions

View File

@ -84,9 +84,7 @@ await nuxtApp.callHook('my-plugin:init')
### `payload` ### `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 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:
`payload` exposes the following keys on the client side after they are stringified and passed from the server side:
- **serverRendered** (boolean) - Indicates if response is server-side-rendered. - **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. - **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` ### `isHydrating`
Use `nuxtApp.isHydrating` (boolean) to check if the Nuxt app is hydrating on the client side. Use `nuxtApp.isHydrating` (boolean) to check if the Nuxt app is hydrating on the client side.

View File

@ -74,6 +74,7 @@
"cookie-es": "^0.5.0", "cookie-es": "^0.5.0",
"defu": "^6.1.2", "defu": "^6.1.2",
"destr": "^1.2.2", "destr": "^1.2.2",
"devalue": "^4.3.0",
"escape-string-regexp": "^5.0.0", "escape-string-regexp": "^5.0.0",
"estree-walker": "^3.0.3", "estree-walker": "^3.0.3",
"fs-extra": "^11.1.1", "fs-extra": "^11.1.1",

View File

@ -118,7 +118,7 @@ export function useAsyncData<
nuxt._asyncData[key] = { nuxt._asyncData[key] = {
data: ref(getCachedData() ?? options.default?.() ?? null), data: ref(getCachedData() ?? options.default?.() ?? null),
pending: ref(!hasCachedData()), 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 // TODO: Else, somehow check for conflicting keys with different defaults or fetcher

View File

@ -28,6 +28,6 @@ export { onNuxtReady } from './ready'
export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, onBeforeRouteLeave, onBeforeRouteUpdate, setPageLayout, navigateTo, useRoute, useRouter } from './router' export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, onBeforeRouteLeave, onBeforeRouteUpdate, setPageLayout, navigateTo, useRoute, useRouter } from './router'
export type { AddRouteMiddlewareOptions, RouteMiddleware } from './router' export type { AddRouteMiddlewareOptions, RouteMiddleware } from './router'
export { preloadComponents, prefetchComponents, preloadRouteComponents } from './preload' 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 type { ReloadNuxtAppOptions } from './chunk'
export { reloadNuxtApp } from './chunk' export { reloadNuxtApp } from './chunk'

View File

@ -1,7 +1,12 @@
import { joinURL, hasProtocol } from 'ufo' import { joinURL, hasProtocol } from 'ufo'
import { parse } from 'devalue'
import { useHead } from '@unhead/vue' import { useHead } from '@unhead/vue'
import { getCurrentInstance } from 'vue'
import { useNuxtApp, useRuntimeConfig } from '../nuxt' import { useNuxtApp, useRuntimeConfig } from '../nuxt'
// @ts-expect-error virtual import
import { renderJsonPayloads } from '#build/nuxt.config.mjs'
interface LoadPayloadOptions { interface LoadPayloadOptions {
fresh?: boolean fresh?: boolean
hash?: string hash?: string
@ -36,6 +41,7 @@ export function preloadPayload (url: string, opts: LoadPayloadOptions = {}) {
// --- Internal --- // --- Internal ---
const extension = renderJsonPayloads ? 'json' : 'js'
function _getPayloadURL (url: string, opts: LoadPayloadOptions = {}) { function _getPayloadURL (url: string, opts: LoadPayloadOptions = {}) {
const u = new URL(url, 'http://localhost') const u = new URL(url, 'http://localhost')
if (u.search) { if (u.search) {
@ -45,15 +51,19 @@ function _getPayloadURL (url: string, opts: LoadPayloadOptions = {}) {
throw new Error('Payload URL must not include hostname: ' + url) throw new Error('Payload URL must not include hostname: ' + url)
} }
const hash = opts.hash || (opts.fresh ? Date.now() : '') 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) { async function _importPayload (payloadURL: string) {
if (process.server) { return null } 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) console.warn('[nuxt] Cannot load payload ', payloadURL, err)
}) }
return res?.default || null return null
} }
export function isPrerendered () { export function isPrerendered () {
@ -61,3 +71,63 @@ export function isPrerendered () {
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
return !!nuxtApp.payload.prerenderedAt 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
}
}

View File

@ -52,7 +52,10 @@ if (process.client) {
} }
entry = async function initApp () { 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 vueApp = isSSR ? createSSRApp(RootComponent) : createApp(RootComponent)
const nuxt = createNuxtApp({ vueApp }) const nuxt = createNuxtApp({ vueApp })

View File

@ -1,5 +1,5 @@
/* eslint-disable no-use-before-define */ /* 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 { App, onErrorCaptured, VNode, Ref } from 'vue'
import type { RouteLocationNormalizedLoaded } from 'vue-router' import type { RouteLocationNormalizedLoaded } from 'vue-router'
import type { Hookable, HookCallback } from 'hookable' 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 // eslint-disable-next-line import/no-restricted-paths
import type { NuxtIslandContext } from '../core/runtime/nitro/renderer' import type { NuxtIslandContext } from '../core/runtime/nitro/renderer'
import type { RouteMiddleware } from '../../app' import type { RouteMiddleware } from '../../app'
import type { NuxtError } from '../app/composables/error'
const nuxtAppCtx = /* #__PURE__ */ getContext<NuxtApp>('nuxt-app') const nuxtAppCtx = /* #__PURE__ */ getContext<NuxtApp>('nuxt-app')
@ -58,6 +59,8 @@ export interface NuxtSSRContext extends SSRContext {
teleports?: Record<string, string> teleports?: Record<string, string>
renderMeta?: () => Promise<NuxtMeta> | NuxtMeta renderMeta?: () => Promise<NuxtMeta> | NuxtMeta
islandContext?: NuxtIslandContext islandContext?: NuxtIslandContext
/** @internal */
_payloadReducers: Record<string, (data: any) => any>
} }
interface _NuxtApp { interface _NuxtApp {
@ -99,6 +102,9 @@ interface _NuxtApp {
/** @internal */ /** @internal */
_islandPromises?: Record<string, Promise<any>> _islandPromises?: Record<string, Promise<any>>
/** @internal */
_payloadRevivers: Record<string, (data: any) => any>
// Nuxt injections // Nuxt injections
$config: RuntimeConfig $config: RuntimeConfig
@ -111,7 +117,6 @@ interface _NuxtApp {
prerenderedAt?: number prerenderedAt?: number
data: Record<string, any> data: Record<string, any>
state: Record<string, any> state: Record<string, any>
rendered?: Function
error?: Error | { error?: Error | {
url: string url: string
statusCode: number statusCode: number
@ -120,6 +125,7 @@ interface _NuxtApp {
description: string description: string
data?: any data?: any
} | null } | null
_errors: Record<string, NuxtError | undefined>
[key: string]: any [key: string]: any
} }
static: { static: {
@ -152,11 +158,11 @@ export function createNuxtApp (options: CreateOptions) {
get nuxt () { return __NUXT_VERSION__ }, get nuxt () { return __NUXT_VERSION__ },
get vue () { return nuxtApp.vueApp.version } get vue () { return nuxtApp.vueApp.version }
}, },
payload: reactive({ payload: shallowReactive({
data: {}, data: shallowReactive({}),
state: {}, state: shallowReactive({}),
_errors: {}, _errors: shallowReactive({}),
...(process.client ? window.__NUXT__ : { serverRendered: true }) ...(process.client ? window.__NUXT__ ?? {} : { serverRendered: true })
}), }),
static: { static: {
data: {} data: {}
@ -182,6 +188,7 @@ export function createNuxtApp (options: CreateOptions) {
}, },
_asyncDataPromises: {}, _asyncDataPromises: {},
_asyncData: {}, _asyncData: {},
_payloadRevivers: {},
...options ...options
} as any as NuxtApp } as any as NuxtApp
@ -217,7 +224,11 @@ export function createNuxtApp (options: CreateOptions) {
if (nuxtApp.ssrContext) { if (nuxtApp.ssrContext) {
nuxtApp.ssrContext.nuxt = nuxtApp 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 nuxtApp.ssrContext = nuxtApp.ssrContext || {} as any
if (nuxtApp.ssrContext!.payload) { if (nuxtApp.ssrContext!.payload) {
Object.assign(nuxtApp.payload, nuxtApp.ssrContext!.payload) Object.assign(nuxtApp.payload, nuxtApp.ssrContext!.payload)
@ -225,7 +236,7 @@ export function createNuxtApp (options: CreateOptions) {
nuxtApp.ssrContext!.payload = nuxtApp.payload nuxtApp.ssrContext!.payload = nuxtApp.payload
// Expose client runtime-config to the payload // Expose client runtime-config to the payload
nuxtApp.payload.config = { nuxtApp.ssrContext!.config = {
public: options.ssrContext!.runtimeConfig.public, public: options.ssrContext!.runtimeConfig.public,
app: options.ssrContext!.runtimeConfig.app app: options.ssrContext!.runtimeConfig.app
} }

View File

@ -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
})

View File

@ -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])
}
})

View File

@ -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_NO_SCRIPTS': !!nuxt.options.experimental.noScripts && !nuxt.options.dev,
'process.env.NUXT_INLINE_STYLES': !!nuxt.options.experimental.inlineSSRStyles, 'process.env.NUXT_INLINE_STYLES': !!nuxt.options.experimental.inlineSSRStyles,
'process.env.NUXT_PAYLOAD_EXTRACTION': !!nuxt.options.experimental.payloadExtraction, '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.env.NUXT_COMPONENT_ISLANDS': !!nuxt.options.experimental.componentIslands,
'process.dev': nuxt.options.dev, 'process.dev': nuxt.options.dev,
__VUE_PROD_DEVTOOLS__: false __VUE_PROD_DEVTOOLS__: false

View File

@ -281,6 +281,13 @@ async function initNuxt (nuxt: Nuxt) {
addPlugin(resolve(nuxt.options.appDir, 'plugins/restore-state.client')) 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 // Track components used to render for webpack
if (nuxt.options.builder === '@nuxt/webpack-builder') { if (nuxt.options.builder === '@nuxt/webpack-builder') {
addPlugin(resolve(nuxt.options.appDir, 'plugins/preload.server')) addPlugin(resolve(nuxt.options.appDir, 'plugins/preload.server'))

View File

@ -4,6 +4,7 @@ import type { Manifest } from 'vite'
import type { H3Event } from 'h3' import type { H3Event } from 'h3'
import { appendHeader, getQuery, writeEarlyHints, readBody, createError } from 'h3' import { appendHeader, getQuery, writeEarlyHints, readBody, createError } from 'h3'
import devalue from '@nuxt/devalue' import devalue from '@nuxt/devalue'
import { stringify, uneval } from 'devalue'
import destr from 'destr' import destr from 'destr'
import { joinURL, withoutTrailingSlash } from 'ufo' import { joinURL, withoutTrailingSlash } from 'ufo'
import { renderToString as _renderToString } from 'vue/server-renderer' import { renderToString as _renderToString } from 'vue/server-renderer'
@ -121,6 +122,7 @@ const getSPARenderer = lazyCachedFunction(async () => {
const renderToString = (ssrContext: NuxtSSRContext) => { const renderToString = (ssrContext: NuxtSSRContext) => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
ssrContext!.payload = { ssrContext!.payload = {
_errors: {},
serverRendered: false, serverRendered: false,
config: { config: {
public: config.public, public: config.public,
@ -160,7 +162,7 @@ async function getIslandContext (event: H3Event): Promise<NuxtIslandContext> {
const PAYLOAD_CACHE = (process.env.NUXT_PAYLOAD_EXTRACTION && process.env.prerender) ? new Map() : null // TODO: Use LRU cache 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 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]*)</${appRootTag}>$`) const ROOT_NODE_REGEX = new RegExp(`^<${appRootTag} id="${appRootId}">([\\s\\S]*)</${appRootTag}>$`)
const PRERENDER_NO_SSR_ROUTES = new Set(['/index.html', '/200.html', '/404.html']) const PRERENDER_NO_SSR_ROUTES = new Set(['/index.html', '/200.html', '/404.html'])
@ -219,12 +221,13 @@ export default defineRenderHandler(async (event) => {
error: !!ssrError, error: !!ssrError,
nuxt: undefined!, /* NuxtApp */ nuxt: undefined!, /* NuxtApp */
payload: (ssrError ? { error: ssrError } : {}) as NuxtSSRContext['payload'], payload: (ssrError ? { error: ssrError } : {}) as NuxtSSRContext['payload'],
_payloadReducers: {},
islandContext islandContext
} }
// Whether we are prerendering route // Whether we are prerendering route
const _PAYLOAD_EXTRACTION = process.env.prerender && process.env.NUXT_PAYLOAD_EXTRACTION && !ssrContext.noSSR 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) { if (process.env.prerender) {
ssrContext.payload.prerenderedAt = Date.now() ssrContext.payload.prerenderedAt = Date.now()
} }
@ -260,7 +263,7 @@ export default defineRenderHandler(async (event) => {
if (_PAYLOAD_EXTRACTION) { if (_PAYLOAD_EXTRACTION) {
// Hint nitro to prerender payload for this route // 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 // Use same ssr context to generate payload for this route
PAYLOAD_CACHE!.set(withoutTrailingSlash(url), renderPayloadResponse(ssrContext)) PAYLOAD_CACHE!.set(withoutTrailingSlash(url), renderPayloadResponse(ssrContext))
} }
@ -279,7 +282,9 @@ export default defineRenderHandler(async (event) => {
htmlAttrs: normalizeChunks([renderedMeta.htmlAttrs]), htmlAttrs: normalizeChunks([renderedMeta.htmlAttrs]),
head: normalizeChunks([ head: normalizeChunks([
renderedMeta.headTags, renderedMeta.headTags,
_PAYLOAD_EXTRACTION ? `<link rel="modulepreload" href="${payloadURL}">` : null, process.env.NUXT_JSON_PAYLOADS
? _PAYLOAD_EXTRACTION ? `<link rel="modulepreload" href="${payloadURL}">` : null
: _PAYLOAD_EXTRACTION ? `<link rel="preload" as="fetch" crossorigin="anonymous" href="${payloadURL}">` : null,
_rendered.renderResourceHints(), _rendered.renderResourceHints(),
_rendered.renderStyles(), _rendered.renderStyles(),
inlinedStyles, inlinedStyles,
@ -295,8 +300,12 @@ export default defineRenderHandler(async (event) => {
process.env.NUXT_NO_SCRIPTS process.env.NUXT_NO_SCRIPTS
? undefined ? undefined
: (_PAYLOAD_EXTRACTION : (_PAYLOAD_EXTRACTION
? `<script type="module">import p from "${payloadURL}";window.__NUXT__={...p,...(${devalue(splitPayload(ssrContext).initial)})}</script>` ? process.env.NUXT_JSON_PAYLOADS
: `<script>window.__NUXT__=${devalue(ssrContext.payload)}</script>` ? 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(), _rendered.renderScripts(),
// Note: bodyScripts may contain tags other than <script> // Note: bodyScripts may contain tags other than <script>
@ -420,16 +429,39 @@ async function renderInlineStyles (usedModules: Set<string> | string[]) {
function renderPayloadResponse (ssrContext: NuxtSSRContext) { function renderPayloadResponse (ssrContext: NuxtSSRContext) {
return <RenderResponse> { return <RenderResponse> {
body: `export default ${devalue(splitPayload(ssrContext).payload)}`, body: process.env.NUXT_JSON_PAYLOADS
? stringify(splitPayload(ssrContext).payload, ssrContext._payloadReducers)
: `export default ${devalue(splitPayload(ssrContext).payload)}`,
statusCode: ssrContext.event.node.res.statusCode, statusCode: ssrContext.event.node.res.statusCode,
statusMessage: ssrContext.event.node.res.statusMessage, statusMessage: ssrContext.event.node.res.statusMessage,
headers: { headers: {
'content-type': 'text/javascript;charset=UTF-8', 'content-type': process.env.NUXT_JSON_PAYLOADS ? 'application/json;charset=utf-8' : 'text/javascript;charset=utf-8',
'x-powered-by': 'Nuxt' 'x-powered-by': 'Nuxt'
} }
} }
} }
function renderPayloadJsonScript (opts: { id: string, ssrContext: NuxtSSRContext, data?: any, src?: string }) {
const attrs = [
'type="application/json"',
`id="${opts.id}"`,
`data-ssr="${!(process.env.NUXT_NO_SSR || opts.ssrContext.noSSR)}"`,
opts.src ? `data-src="${opts.src}"` : ''
].filter(Boolean)
const contents = opts.data ? stringify(opts.data, opts.ssrContext._payloadReducers) : ''
return `<script ${attrs.join(' ')}>${contents}</script>` +
`<script>window.__NUXT__={};window.__NUXT__.config=${uneval(opts.ssrContext.config)}</script>`
}
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 `<script type="module">import p from "${opts.src}";window.__NUXT__={...p,...(${devalue(opts.data)})}</script>`
}
return `<script>window.__NUXT__=${devalue(opts.data)}</script>`
}
function splitPayload (ssrContext: NuxtSSRContext) { function splitPayload (ssrContext: NuxtSSRContext) {
const { data, prerenderedAt, ...initial } = ssrContext.payload const { data, prerenderedAt, ...initial } = ssrContext.payload
return { return {

View File

@ -296,6 +296,7 @@ export const nuxtConfigTemplate = {
getContents: (ctx: TemplateContext) => { getContents: (ctx: TemplateContext) => {
return [ return [
...Object.entries(ctx.nuxt.options.app).map(([k, v]) => `export const ${camelCase('app-' + k)} = ${JSON.stringify(v)}`), ...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'}` `export const devPagesDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.dir.pages) : 'null'}`
].join('\n\n') ].join('\n\n')
} }

View File

@ -55,7 +55,9 @@ const appPreset = defineUnimportPreset({
'prefetchComponents', 'prefetchComponents',
'loadPayload', 'loadPayload',
'preloadPayload', 'preloadPayload',
'isPrerendered' 'isPrerendered',
'definePayloadReducer',
'definePayloadReviver'
] ]
}) })

View File

@ -170,6 +170,7 @@ export default defineUntypedSchema({
$resolve: async (val, get) => defu(val || {}, $resolve: async (val, get) => defu(val || {},
await get('dev') ? {} : { await get('dev') ? {} : {
vue: ['onBeforeMount', 'onMounted', 'onBeforeUpdate', 'onRenderTracked', 'onRenderTriggered', 'onActivated', 'onDeactivated', 'onBeforeUnmount'], vue: ['onBeforeMount', 'onMounted', 'onBeforeUpdate', 'onRenderTracked', 'onRenderTriggered', 'onActivated', 'onDeactivated', 'onBeforeUnmount'],
'#app': ['definePayloadReviver']
} }
) )
}, },
@ -177,6 +178,7 @@ export default defineUntypedSchema({
$resolve: async (val, get) => defu(val || {}, $resolve: async (val, get) => defu(val || {},
await get('dev') ? {} : { await get('dev') ? {} : {
vue: ['onServerPrefetch', 'onRenderTracked', 'onRenderTriggered'], vue: ['onServerPrefetch', 'onRenderTracked', 'onRenderTriggered'],
'#app': ['definePayloadReducer']
} }
) )
} }

View File

@ -98,6 +98,10 @@ export default defineUntypedSchema({
*/ */
noScripts: false, 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. * Disable vue server renderer endpoint within nitro.
*/ */

View File

@ -563,6 +563,9 @@ importers:
destr: destr:
specifier: ^1.2.2 specifier: ^1.2.2
version: 1.2.2 version: 1.2.2
devalue:
specifier: ^4.3.0
version: 4.3.0
escape-string-regexp: escape-string-regexp:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.0 version: 5.0.0
@ -4419,6 +4422,10 @@ packages:
resolution: {integrity: sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==} resolution: {integrity: sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==}
engines: {node: '>=8'} engines: {node: '>=8'}
/devalue@4.3.0:
resolution: {integrity: sha512-n94yQo4LI3w7erwf84mhRUkUJfhLoCZiLyoOZ/QFsDbcWNZePrLwbQpvZBUG2TNxwV3VjCKPxkiiQA6pe3TrTA==}
dev: false
/diff@4.0.2: /diff@4.0.2:
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
engines: {node: '>=0.3.1'} engines: {node: '>=0.3.1'}

View File

@ -7,7 +7,7 @@ import { setup, fetch, $fetch, startServer, isDev, createPage, url } from '@nuxt
import { $fetchComponent } from '@nuxt/test-utils/experimental' import { $fetchComponent } from '@nuxt/test-utils/experimental'
import type { NuxtIslandResponse } from '../packages/nuxt/src/core/runtime/nitro/renderer' 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' const isWebpack = process.env.TEST_BUILDER === 'webpack'
@ -43,7 +43,9 @@ describe('server api', () => {
describe('route rules', () => { describe('route rules', () => {
it('should enable spa mode', async () => { 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', () => { describe('nuxt links', () => {
it('handles trailing slashes', async () => { it('handles trailing slashes', async () => {
const html = await $fetch('/nuxt-link/trailing-slash') const html = await $fetch('/nuxt-link/trailing-slash')
@ -467,7 +486,8 @@ describe('legacy async data', () => {
it('should work with defineNuxtComponent', async () => { it('should work with defineNuxtComponent', async () => {
const html = await $fetch('/legacy/async-data') const html = await $fetch('/legacy/async-data')
expect(html).toContain('<div>Hello API</div>') expect(html).toContain('<div>Hello API</div>')
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', () => { describe.skipIf(isDev() || isWindows)('payload rendering', () => {
it('renders a payload', async () => { it('renders a payload', async () => {
const payload = await $fetch('/random/a/_payload.js', { responseType: 'text' }) const payload = await $fetch('/random/a/_payload.json', { responseType: 'text' })
expect(payload).toMatch( const data = parsePayload(payload)
/export default \{data:\{hey:\{[^}]*\},rand_a:\[[^\]]*\],".*":\{html:".*server-only component.*",head:\{link:\[\],style:\[\]\}\}\},prerenderedAt:\d*\}/ 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": "<div> server-only component </div>",
}
`)
expect(data.data).toMatchObject({
hey: {
baz: 'qux',
foo: 'bar'
},
rand_a: expect.arrayContaining([expect.anything()])
})
}) })
it('does not fetch a prefetched payload', async () => { 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.goto(url('/random/a'))
await page.waitForLoadState('networkidle') await page.waitForLoadState('networkidle')
const importSuffix = isDev() && !isWebpack ? '?import' : ''
// We are manually prefetching other payloads // 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 // We are not triggering API requests in the payload
expect(requests).not.toContain(expect.stringContaining('/api/random')) 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')) expect(requests).not.toContain(expect.stringContaining('/__nuxt_island'))
// We are fetching a payload we did not prefetch // 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 // We are not refetching payloads we've already prefetched
// expect(requests.filter(p => p.includes('_payload')).length).toBe(1) // expect(requests.filter(p => p.includes('_payload')).length).toBe(1)

View File

@ -40,10 +40,10 @@ describe.skipIf(isWindows || process.env.ECOSYSTEM_CI)('minimal nuxt application
it('default server bundle size', async () => { it('default server bundle size', async () => {
stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) 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) const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2632k"') expect(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2648k"')
const packages = modules.files const packages = modules.files
.filter(m => m.endsWith('package.json')) .filter(m => m.endsWith('package.json'))
@ -66,6 +66,7 @@ describe.skipIf(isWindows || process.env.ECOSYSTEM_CI)('minimal nuxt application
"cookie-es", "cookie-es",
"defu", "defu",
"destr", "destr",
"devalue",
"estree-walker", "estree-walker",
"h3", "h3",
"hookable", "hookable",

View File

@ -108,6 +108,12 @@ export default defineNuxtConfig({
addVitePlugin(plugin.vite()) addVitePlugin(plugin.vite())
addWebpackPlugin(plugin.webpack()) 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) { function (_options, nuxt) {
const routesToDuplicate = ['/async-parent', '/fixed-keyed-child-parent', '/keyed-child-parent', '/with-layout', '/with-layout2'] const routesToDuplicate = ['/async-parent', '/fixed-keyed-child-parent', '/keyed-child-parent', '/with-layout', '/with-layout2']
const stripLayout = (page: NuxtPage): NuxtPage => ({ const stripLayout = (page: NuxtPage): NuxtPage => ({
@ -184,6 +190,7 @@ export default defineNuxtConfig({
} }
}, },
experimental: { experimental: {
renderJsonPayloads: true,
respectNoSSRHeader: true, respectNoSSRHeader: true,
clientFallback: true, clientFallback: true,
restoreState: true, restoreState: true,

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
const state = useState(() => shallowRef({} as Record<string, any>))
if (process.server) {
const r = ref('')
state.value.ref = r
state.value.shallowReactive = shallowReactive({ nested: { ref: r } })
state.value.shallowRef = shallowRef(false)
state.value.reactive = reactive({ ref: r })
state.value.error = createError({ message: 'error' })
state.value.date = new Date()
}
</script>
<template>
<div>
<pre>{{ state }}</pre>
Date: {{ state.date instanceof Date }} <br>
Error: {{ isNuxtError(state.error) }} <hr>
Shallow reactive: {{ isReactive(state.shallowReactive) && isShallow(state.shallowReactive) }} <br>
Shallow ref: {{ isShallow(state.shallowRef) }} <br>
Reactive: {{ isReactive(state.reactive) }} <br>
Ref: {{ isRef(state.ref) }} <hr>
Recursive objects: {{ state.ref === state.shallowReactive.nested.ref }} <br>
</div>
</template>

View File

@ -0,0 +1,7 @@
export default defineNuxtPlugin((nuxtApp) => {
if (nuxtApp.payload.blinkable !== '<revivified-blink>') {
throw createError({
message: 'Custom type in Nuxt payload was not revived correctly'
})
}
})

View File

@ -0,0 +1,7 @@
export default defineNuxtPlugin((nuxtApp) => {
definePayloadReducer('BlinkingText', data => data === '<original-blink>' && '_')
definePayloadReviver('BlinkingText', () => '<revivified-blink>')
if (process.server) {
nuxtApp.payload.blinkable = '<original-blink>'
}
})

View File

@ -1,5 +1,8 @@
import { expect } from 'vitest' import { expect } from 'vitest'
import type { Page } from 'playwright' 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' import { createPage, getBrowser, url, useTestContext } from '@nuxt/test-utils'
export async function renderPage (path = '/') { export async function renderPage (path = '/') {
@ -89,3 +92,29 @@ export async function withLogs (callback: (page: Page, logs: string[]) => Promis
await page.close() 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: () => '<revivified-blink>'
}
export function parsePayload (payload: string) {
return parse(payload || '', revivers)
}
export function parseData (html: string) {
const { script, attrs } = html.match(/<script type="application\/json" id="__NUXT_DATA__"(?<attrs>[^>]+)>(?<script>.*?)<\/script>/)?.groups || {}
const _attrs: Record<string, string> = {}
for (const attr of attrs.matchAll(/( |^)(?<key>[\w-]+)+="(?<value>[^"]+)"/g)) {
_attrs[attr!.groups!.key] = attr!.groups!.value
}
return {
script: parsePayload(script || ''),
attrs: _attrs
}
}