From 3169c5cec718c8c8306c463d0fde297bff11ac21 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 7 May 2024 15:04:21 +0100 Subject: [PATCH] feat(nuxt): add `onPrehydrate` lifecycle hook (#27037) --- docs/3.api/2.composables/on-prehydrate.md | 61 +++++++++++++++++ packages/nuxt/src/app/composables/index.ts | 2 +- packages/nuxt/src/app/composables/ssr.ts | 47 +++++++++++++ packages/nuxt/src/core/nuxt.ts | 4 ++ packages/nuxt/src/core/plugins/prehydrate.ts | 67 +++++++++++++++++++ packages/nuxt/src/imports/presets.ts | 2 +- packages/schema/src/config/build.ts | 2 +- test/basic.test.ts | 27 ++++++++ .../basic/pages/composables/on-prehydrate.vue | 22 ++++++ test/nuxt/composables.test.ts | 1 + 10 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 docs/3.api/2.composables/on-prehydrate.md create mode 100644 packages/nuxt/src/core/plugins/prehydrate.ts create mode 100644 test/fixtures/basic/pages/composables/on-prehydrate.vue diff --git a/docs/3.api/2.composables/on-prehydrate.md b/docs/3.api/2.composables/on-prehydrate.md new file mode 100644 index 0000000000..79073123a4 --- /dev/null +++ b/docs/3.api/2.composables/on-prehydrate.md @@ -0,0 +1,61 @@ +--- +title: "onPrehydrate" +description: "Use onPrehydrate to run a callback on the client immediately before +Nuxt hydrates the page." +links: + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/composables/ssr.ts + size: xs +--- + +::important +This composable will be available in Nuxt v3.12+ or in [the nightly release channel](/docs/guide/going-further/nightly-release-channel). +:: + +`onPrehydrate` is a composable lifecycle hook that allows you to run a callback on the client immediately before +Nuxt hydrates the page. + +::note +This is an advanced utility and should be used with care. For example, [`nuxt-time`](https://github.com/danielroe/nuxt-time/pull/251) and [`@nuxtjs/color-mode`](https://github.com/nuxt-modules/color-mode/blob/main/src/script.js) manipulate the DOM to avoid hydration mismatches. +:: + +## Usage + +`onPrehydrate` can be called directly in the setup function of a Vue component (for example, in ` + + +``` diff --git a/packages/nuxt/src/app/composables/index.ts b/packages/nuxt/src/app/composables/index.ts index d5058ecb92..dbb5e96079 100644 --- a/packages/nuxt/src/app/composables/index.ts +++ b/packages/nuxt/src/app/composables/index.ts @@ -24,7 +24,7 @@ export { useFetch, useLazyFetch } from './fetch' export type { FetchResult, UseFetchOptions } from './fetch' export { useCookie, refreshCookie } from './cookie' export type { CookieOptions, CookieRef } from './cookie' -export { prerenderRoutes, useRequestHeaders, useRequestEvent, useRequestFetch, setResponseStatus } from './ssr' +export { onPrehydrate, prerenderRoutes, useRequestHeaders, useRequestEvent, useRequestFetch, setResponseStatus } from './ssr' export { onNuxtReady } from './ready' export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, onBeforeRouteLeave, onBeforeRouteUpdate, setPageLayout, navigateTo, useRoute, useRouter } from './router' export type { AddRouteMiddlewareOptions, RouteMiddleware } from './router' diff --git a/packages/nuxt/src/app/composables/ssr.ts b/packages/nuxt/src/app/composables/ssr.ts index 8509e968bc..56f3383109 100644 --- a/packages/nuxt/src/app/composables/ssr.ts +++ b/packages/nuxt/src/app/composables/ssr.ts @@ -1,5 +1,8 @@ import type { H3Event } from 'h3' import { setResponseStatus as _setResponseStatus, appendHeader, getRequestHeader, getRequestHeaders } from 'h3' +import { getCurrentInstance } from 'vue' +import { useServerHead } from '@unhead/vue' + import type { NuxtApp } from '../nuxt' import { useNuxtApp } from '../nuxt' import { toArray } from '../utils' @@ -65,3 +68,47 @@ export function prerenderRoutes (path: string | string[]) { const paths = toArray(path) appendHeader(useRequestEvent()!, 'x-nitro-prerender', paths.map(p => encodeURIComponent(p)).join(', ')) } + +const PREHYDRATE_ATTR_KEY = 'data-prehydrate-id' + +/** + * `onPrehydrate` is a composable lifecycle hook that allows you to run a callback on the client immediately before + * Nuxt hydrates the page. This is an advanced feature. + * + * The callback will be stringified and inlined in the HTML so it should not have any external + * dependencies (such as auto-imports) or refer to variables defined outside the callback. + * + * The callback will run before Nuxt runtime initializes so it should not rely on the Nuxt or Vue context. + * @since 3.12.0 + */ +export function onPrehydrate (callback: (el: HTMLElement) => void): void +export function onPrehydrate (callback: string | ((el: HTMLElement) => void), key?: string): undefined | string { + if (import.meta.client) { return } + + if (typeof callback !== 'string') { + throw new TypeError('[nuxt] To transform a callback into a string, `onPrehydrate` must be processed by the Nuxt build pipeline. If it is called in a third-party library, make sure to add the library to `build.transpile`.') + } + + const vm = getCurrentInstance() + if (vm && key) { + vm.attrs[PREHYDRATE_ATTR_KEY] ||= '' + key = ':' + key + ':' + if (!(vm.attrs[PREHYDRATE_ATTR_KEY] as string).includes(key)) { + vm.attrs[PREHYDRATE_ATTR_KEY] += key + } + } + const code = vm && key + ? `document.querySelectorAll('[${PREHYDRATE_ATTR_KEY}*=${JSON.stringify(key)}]').forEach` + callback + : (callback + '()') + + useServerHead({ + script: [{ + key: vm && key ? key : code, + tagPosition: 'bodyClose', + tagPriority: 'critical', + innerHTML: code, + }], + }) + + return vm && key ? vm.attrs[PREHYDRATE_ATTR_KEY] as string : undefined +} diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index 31ead96d88..fe2c189081 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -34,6 +34,7 @@ import schemaModule from './schema' import { RemovePluginMetadataPlugin } from './plugins/plugin-metadata' import { AsyncContextInjectionPlugin } from './plugins/async-context' import { resolveDeepImportsPlugin } from './plugins/resolve-deep-imports' +import { prehydrateTransformPlugin } from './plugins/prehydrate' export function createNuxt (options: NuxtOptions): Nuxt { const hooks = createHooks() @@ -150,6 +151,9 @@ async function initNuxt (nuxt: Nuxt) { // add resolver for modules used in virtual files addVitePlugin(() => resolveDeepImportsPlugin(nuxt)) + // Add transform for `onPrehydrate` lifecycle hook + addBuildPlugin(prehydrateTransformPlugin(nuxt)) + if (nuxt.options.experimental.localLayerAliases) { // Add layer aliasing support for ~, ~~, @ and @@ aliases addVitePlugin(() => LayerAliasingPlugin.vite({ diff --git a/packages/nuxt/src/core/plugins/prehydrate.ts b/packages/nuxt/src/core/plugins/prehydrate.ts new file mode 100644 index 0000000000..e47fa3883e --- /dev/null +++ b/packages/nuxt/src/core/plugins/prehydrate.ts @@ -0,0 +1,67 @@ +import { transform } from 'esbuild' +import { parse } from 'acorn' +import { walk } from 'estree-walker' +import type { Node } from 'estree-walker' +import type { Nuxt } from '@nuxt/schema' +import { createUnplugin } from 'unplugin' +import type { SimpleCallExpression } from 'estree' +import MagicString from 'magic-string' + +import { hash } from 'ohash' +import { isJS, isVue } from '../utils' + +export function prehydrateTransformPlugin (nuxt: Nuxt) { + return createUnplugin(() => ({ + name: 'nuxt:prehydrate-transform', + transformInclude (id) { + return isJS(id) || isVue(id, { type: ['script'] }) + }, + async transform (code, id) { + if (!code.includes('onPrehydrate(')) { return } + + const s = new MagicString(code) + const promises: Array> = [] + + walk(parse(code, { + sourceType: 'module', + ecmaVersion: 'latest', + ranges: true, + }) as Node, { + enter (_node) { + if (_node.type !== 'CallExpression' || _node.callee.type !== 'Identifier') { return } + const node = _node as SimpleCallExpression & { start: number, end: number } + const name = 'name' in node.callee && node.callee.name + if (name === 'onPrehydrate') { + if (node.arguments[0].type !== 'ArrowFunctionExpression' && node.arguments[0].type !== 'FunctionExpression') { return } + + const needsAttr = node.arguments[0].params.length > 0 + const { start, end } = node.arguments[0] as Node & { start: number, end: number } + + const p = transform(`forEach(${code.slice(start, end)})`, { loader: 'ts', minify: true }) + promises.push(p.then(({ code: result }) => { + const cleaned = result.slice('forEach'.length).replace(/;\s+$/, '') + const args = [JSON.stringify(cleaned)] + if (needsAttr) { + args.push(JSON.stringify(hash(result))) + } + s.overwrite(start, end, args.join(', ')) + })) + } + }, + }) + + await Promise.all(promises).catch((e) => { + console.error(`[nuxt] Could not transform onPrehydrate in \`${id}\`:`, e) + }) + + if (s.hasChanged()) { + return { + code: s.toString(), + map: nuxt.options.sourcemap.server || nuxt.options.sourcemap.client + ? s.generateMap({ hires: true }) + : undefined, + } + } + }, + })) +} diff --git a/packages/nuxt/src/imports/presets.ts b/packages/nuxt/src/imports/presets.ts index 8387737409..aeb0a7bf62 100644 --- a/packages/nuxt/src/imports/presets.ts +++ b/packages/nuxt/src/imports/presets.ts @@ -66,7 +66,7 @@ const granularAppPresets: InlinePreset[] = [ from: '#app/composables/cookie', }, { - imports: ['prerenderRoutes', 'useRequestHeader', 'useRequestHeaders', 'useRequestEvent', 'useRequestFetch', 'setResponseStatus'], + imports: ['onPrehydrate', 'prerenderRoutes', 'useRequestHeader', 'useRequestHeaders', 'useRequestEvent', 'useRequestFetch', 'setResponseStatus'], from: '#app/composables/ssr', }, { diff --git a/packages/schema/src/config/build.ts b/packages/schema/src/config/build.ts index cd51c29fde..5be66f821c 100644 --- a/packages/schema/src/config/build.ts +++ b/packages/schema/src/config/build.ts @@ -176,7 +176,7 @@ export default defineUntypedSchema({ ? {} : { 'vue': ['onRenderTracked', 'onRenderTriggered', 'onServerPrefetch'], - '#app': ['definePayloadReducer', 'definePageMeta'], + '#app': ['definePayloadReducer', 'definePageMeta', 'onPrehydrate'], }, ), }, diff --git a/test/basic.test.ts b/test/basic.test.ts index f35940d51e..81f8ff916b 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -577,6 +577,33 @@ describe('nuxt composables', () => { await page.close() }) + it('supports onPrehydrate', async () => { + const html = await $fetch('/composables/on-prehydrate') as string + /** + * Should look something like this: + * + * ```html + *
onPrehydrate testing
+ * + * + * + * ``` + */ + const { id1, id2 } = html.match(/]* data-prehydrate-id=":(?[^:]+)::(?[^:]+):"> onPrehydrate testing <\/div>/)?.groups || {} + expect(id1).toBeTruthy() + const matches = [ + html.match(/]*>\(\(\)=>{console.log\(window\)}\)\(\)<\/script>/), + html.match(new RegExp(`]*>document.querySelectorAll\\('\\[data-prehydrate-id\\*=":${id1}:"]'\\).forEach\\(o=>{console.log\\(o.outerHTML\\)}\\)`)), + html.match(new RegExp(`]*>document.querySelectorAll\\('\\[data-prehydrate-id\\*=":${id2}:"]'\\).forEach\\(o=>{console.log\\("other",o.outerHTML\\)}\\)`)), + ] + + // This tests we inject all scripts correctly, and only have one occurrence of multiple calls of a composable + expect(matches.every(s => s?.length === 1)).toBeTruthy() + + // Check for hydration/syntax errors on client side + await expectNoClientErrors('/composables/on-prehydrate') + }) + it('respects preview mode with a token', async () => { const token = 'hehe' const page = await createPage(`/preview?preview=true&token=${token}`) diff --git a/test/fixtures/basic/pages/composables/on-prehydrate.vue b/test/fixtures/basic/pages/composables/on-prehydrate.vue new file mode 100644 index 0000000000..ceaf066aa7 --- /dev/null +++ b/test/fixtures/basic/pages/composables/on-prehydrate.vue @@ -0,0 +1,22 @@ + + + diff --git a/test/nuxt/composables.test.ts b/test/nuxt/composables.test.ts index ecd504236a..5c20e9a038 100644 --- a/test/nuxt/composables.test.ts +++ b/test/nuxt/composables.test.ts @@ -100,6 +100,7 @@ describe('composables', () => { 'preloadRouteComponents', 'reloadNuxtApp', 'refreshCookie', + 'onPrehydrate', 'useFetch', 'useHead', 'useLazyFetch',