mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-25 15:15:19 +00:00
feat(nuxt): experimental option for rich json payloads (#19205)
Co-authored-by: Daniel Roe <daniel@roe.dev>
This commit is contained in:
parent
d4f718d8cd
commit
9e503be0f2
@ -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.
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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 })
|
||||
|
@ -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<NuxtApp>('nuxt-app')
|
||||
|
||||
@ -58,6 +59,8 @@ export interface NuxtSSRContext extends SSRContext {
|
||||
teleports?: Record<string, string>
|
||||
renderMeta?: () => Promise<NuxtMeta> | NuxtMeta
|
||||
islandContext?: NuxtIslandContext
|
||||
/** @internal */
|
||||
_payloadReducers: Record<string, (data: any) => any>
|
||||
}
|
||||
|
||||
interface _NuxtApp {
|
||||
@ -99,6 +102,9 @@ interface _NuxtApp {
|
||||
/** @internal */
|
||||
_islandPromises?: Record<string, Promise<any>>
|
||||
|
||||
/** @internal */
|
||||
_payloadRevivers: Record<string, (data: any) => any>
|
||||
|
||||
// Nuxt injections
|
||||
$config: RuntimeConfig
|
||||
|
||||
@ -111,7 +117,6 @@ interface _NuxtApp {
|
||||
prerenderedAt?: number
|
||||
data: Record<string, any>
|
||||
state: Record<string, any>
|
||||
rendered?: Function
|
||||
error?: Error | {
|
||||
url: string
|
||||
statusCode: number
|
||||
@ -120,6 +125,7 @@ interface _NuxtApp {
|
||||
description: string
|
||||
data?: any
|
||||
} | null
|
||||
_errors: Record<string, NuxtError | undefined>
|
||||
[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
|
||||
}
|
||||
|
23
packages/nuxt/src/app/plugins/revive-payload.client.ts
Normal file
23
packages/nuxt/src/app/plugins/revive-payload.client.ts
Normal 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
|
||||
})
|
21
packages/nuxt/src/app/plugins/revive-payload.server.ts
Normal file
21
packages/nuxt/src/app/plugins/revive-payload.server.ts
Normal 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])
|
||||
}
|
||||
})
|
@ -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
|
||||
|
@ -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'))
|
||||
|
@ -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<NuxtIslandContext> {
|
||||
|
||||
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]*)</${appRootTag}>$`)
|
||||
|
||||
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 ? `<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.renderStyles(),
|
||||
inlinedStyles,
|
||||
@ -295,8 +300,12 @@ export default defineRenderHandler(async (event) => {
|
||||
process.env.NUXT_NO_SCRIPTS
|
||||
? undefined
|
||||
: (_PAYLOAD_EXTRACTION
|
||||
? `<script type="module">import p from "${payloadURL}";window.__NUXT__={...p,...(${devalue(splitPayload(ssrContext).initial)})}</script>`
|
||||
: `<script>window.__NUXT__=${devalue(ssrContext.payload)}</script>`
|
||||
? 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 <script>
|
||||
@ -420,16 +429,39 @@ async function renderInlineStyles (usedModules: Set<string> | string[]) {
|
||||
|
||||
function renderPayloadResponse (ssrContext: NuxtSSRContext) {
|
||||
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,
|
||||
statusMessage: ssrContext.event.node.res.statusMessage,
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
const { data, prerenderedAt, ...initial } = ssrContext.payload
|
||||
return {
|
||||
|
@ -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')
|
||||
}
|
||||
|
@ -55,7 +55,9 @@ const appPreset = defineUnimportPreset({
|
||||
'prefetchComponents',
|
||||
'loadPayload',
|
||||
'preloadPayload',
|
||||
'isPrerendered'
|
||||
'isPrerendered',
|
||||
'definePayloadReducer',
|
||||
'definePayloadReviver'
|
||||
]
|
||||
})
|
||||
|
||||
|
@ -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']
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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'}
|
||||
|
@ -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('<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', () => {
|
||||
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": "<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 () => {
|
||||
@ -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)
|
||||
|
@ -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",
|
||||
|
7
test/fixtures/basic/nuxt.config.ts
vendored
7
test/fixtures/basic/nuxt.config.ts
vendored
@ -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,
|
||||
|
26
test/fixtures/basic/pages/json-payload.vue
vendored
Normal file
26
test/fixtures/basic/pages/json-payload.vue
vendored
Normal 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>
|
7
test/fixtures/basic/plugins/custom-type-assertion.client.ts
vendored
Normal file
7
test/fixtures/basic/plugins/custom-type-assertion.client.ts
vendored
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
7
test/fixtures/basic/plugins/custom-type-registration.ts
vendored
Normal file
7
test/fixtures/basic/plugins/custom-type-registration.ts
vendored
Normal 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>'
|
||||
}
|
||||
})
|
@ -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: () => '<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
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user