Skip to content

Commit

Permalink
feat(nuxt): experimental option for rich json payloads (#19205)
Browse files Browse the repository at this point in the history
Co-authored-by: Daniel Roe <daniel@roe.dev>
  • Loading branch information
pi0 and danielroe committed Apr 7, 2023
1 parent d4f718d commit 9e503be
Show file tree
Hide file tree
Showing 24 changed files with 355 additions and 41 deletions.
22 changes: 19 additions & 3 deletions docs/3.api/1.composables/use-nuxt-app.md
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/nuxt/package.json
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/nuxt/src/app/composables/asyncData.ts
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/nuxt/src/app/composables/index.ts
Expand Up @@ -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'
78 changes: 74 additions & 4 deletions 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
Expand Down Expand Up @@ -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) {
Expand All @@ -45,19 +51,83 @@ 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 () {
// Note: Alternative for server is checking x-nitro-prerender header
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
}
}
5 changes: 4 additions & 1 deletion packages/nuxt/src/app/entry.ts
Expand Up @@ -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 })
Expand Down
29 changes: 20 additions & 9 deletions 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'
Expand All @@ -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')

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -99,6 +102,9 @@ interface _NuxtApp {
/** @internal */
_islandPromises?: Record<string, Promise<any>>

/** @internal */
_payloadRevivers: Record<string, (data: any) => any>

// Nuxt injections
$config: RuntimeConfig

Expand All @@ -111,7 +117,6 @@ interface _NuxtApp {
prerenderedAt?: number
data: Record<string, any>
state: Record<string, any>
rendered?: Function
error?: Error | {
url: string
statusCode: number
Expand All @@ -120,6 +125,7 @@ interface _NuxtApp {
description: string
data?: any
} | null
_errors: Record<string, NuxtError | undefined>
[key: string]: any
}
static: {
Expand Down Expand Up @@ -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: {}
Expand All @@ -182,6 +188,7 @@ export function createNuxtApp (options: CreateOptions) {
},
_asyncDataPromises: {},
_asyncData: {},
_payloadRevivers: {},
...options
} as any as NuxtApp

Expand Down Expand Up @@ -217,15 +224,19 @@ 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)
}
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
}
Expand Down
23 changes: 23 additions & 0 deletions 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
})
21 changes: 21 additions & 0 deletions 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(),

This comment has been minimized.

Copy link
@joffreyBerrier

joffreyBerrier May 17, 2023

Hi @pi0 is it possible to desactivate this line ?
I'm using constructor: { __h3_error__: true } because I need to have a response api error and not a custom error manage in h3.

Example: My Api send an array of object error to show in FrontEnd the error, so this line data.toJSON() doesn't work because my data is an object and not an Event

Do you have a solution for me ?

This comment has been minimized.

Copy link
@pi0

pi0 May 17, 2023

Author Member

You can create a special class to wrap your Array of errors or directly assign .toJSON() to them. Would you please elaborate the multi-error example into an issue in Nuxt repo and ping me? We might follow-it up with some upstream ideas for h3 or in nuxt to improve 👍🏼

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])
}
})
1 change: 1 addition & 0 deletions packages/nuxt/src/core/nitro.ts
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/nuxt/src/core/nuxt.ts
Expand Up @@ -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'))
Expand Down

0 comments on commit 9e503be

Please sign in to comment.