feat(nuxt): add onPrehydrate lifecycle hook (#27037)

This commit is contained in:
Daniel Roe 2024-05-07 15:04:21 +01:00 committed by GitHub
parent 233b6a717f
commit 3169c5cec7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 232 additions and 3 deletions

View File

@ -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 `<script setup>`), or in a plugin.
It will only have an effect when it is called on the server, and it will not be included in your client build.
## Parameters
- `callback`: A function that will be stringified and inlined in the HTML. 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.
## Example
```vue twoslash [app.vue]
<script setup lang="ts">
declare const window: Window
// ---cut---
// onPrehydrate is guaranteed to run before Nuxt hydrates
onPrehydrate(() => {
console.log(window)
})
// As long as it only has one root node, you can access the element
onPrehydrate((el) => {
console.log(el.outerHTML)
// <div data-v-inspector="app.vue:15:3" data-prehydrate-id=":b3qlvSiBeH:"> Hi there </div>
})
// For _very_ advanced use cases (such as not having a single root node) you
// can access/set `data-prehydrate-id` yourself
const prehydrateId = onPrehydrate((el) => {})
</script>
<template>
<div>
Hi there
</div>
</template>
```

View File

@ -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'

View File

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

View File

@ -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<NuxtHooks>()
@ -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({

View File

@ -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<Promise<any>> = []
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,
}
}
},
}))
}

View File

@ -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',
},
{

View File

@ -176,7 +176,7 @@ export default defineUntypedSchema({
? {}
: {
'vue': ['onRenderTracked', 'onRenderTriggered', 'onServerPrefetch'],
'#app': ['definePayloadReducer', 'definePageMeta'],
'#app': ['definePayloadReducer', 'definePageMeta', 'onPrehydrate'],
},
),
},

View File

@ -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
* <div data-prehydrate-id=":b3qlvSiBeH::df1mQEC9xH:"> onPrehydrate testing </div>
* <script>(()=>{console.log(window)})()</script>
* <script>document.querySelectorAll('[data-prehydrate-id*=":b3qlvSiBeH:"]').forEach(o=>{console.log(o.outerHTML)})</script>
* <script>document.querySelectorAll('[data-prehydrate-id*=":df1mQEC9xH:"]').forEach(o=>{console.log("other",o.outerHTML)})</script>
* ```
*/
const { id1, id2 } = html.match(/<div[^>]* data-prehydrate-id=":(?<id1>[^:]+)::(?<id2>[^:]+):"> onPrehydrate testing <\/div>/)?.groups || {}
expect(id1).toBeTruthy()
const matches = [
html.match(/<script[^>]*>\(\(\)=>{console.log\(window\)}\)\(\)<\/script>/),
html.match(new RegExp(`<script[^>]*>document.querySelectorAll\\('\\[data-prehydrate-id\\*=":${id1}:"]'\\).forEach\\(o=>{console.log\\(o.outerHTML\\)}\\)</script>`)),
html.match(new RegExp(`<script[^>]*>document.querySelectorAll\\('\\[data-prehydrate-id\\*=":${id2}:"]'\\).forEach\\(o=>{console.log\\("other",o.outerHTML\\)}\\)</script>`)),
]
// 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}`)

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
onPrehydrate(() => {
// This is guaranteed to run before Nuxt hydrates
console.log(window)
})
onPrehydrate((el) => {
console.log(el.outerHTML)
})
onPrehydrate((el) => {
console.log(el.outerHTML)
})
onPrehydrate((el) => {
console.log('other', el.outerHTML)
})
</script>
<template>
<div>
onPrehydrate testing
</div>
</template>

View File

@ -100,6 +100,7 @@ describe('composables', () => {
'preloadRouteComponents',
'reloadNuxtApp',
'refreshCookie',
'onPrehydrate',
'useFetch',
'useHead',
'useLazyFetch',