mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-25 07:05:11 +00:00
feat(nuxt): add onPrehydrate
lifecycle hook (#27037)
This commit is contained in:
parent
233b6a717f
commit
3169c5cec7
61
docs/3.api/2.composables/on-prehydrate.md
Normal file
61
docs/3.api/2.composables/on-prehydrate.md
Normal 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>
|
||||
```
|
@ -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'
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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({
|
||||
|
67
packages/nuxt/src/core/plugins/prehydrate.ts
Normal file
67
packages/nuxt/src/core/plugins/prehydrate.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
@ -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',
|
||||
},
|
||||
{
|
||||
|
@ -176,7 +176,7 @@ export default defineUntypedSchema({
|
||||
? {}
|
||||
: {
|
||||
'vue': ['onRenderTracked', 'onRenderTriggered', 'onServerPrefetch'],
|
||||
'#app': ['definePayloadReducer', 'definePageMeta'],
|
||||
'#app': ['definePayloadReducer', 'definePageMeta', 'onPrehydrate'],
|
||||
},
|
||||
),
|
||||
},
|
||||
|
@ -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}`)
|
||||
|
22
test/fixtures/basic/pages/composables/on-prehydrate.vue
vendored
Normal file
22
test/fixtures/basic/pages/composables/on-prehydrate.vue
vendored
Normal 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>
|
@ -100,6 +100,7 @@ describe('composables', () => {
|
||||
'preloadRouteComponents',
|
||||
'reloadNuxtApp',
|
||||
'refreshCookie',
|
||||
'onPrehydrate',
|
||||
'useFetch',
|
||||
'useHead',
|
||||
'useLazyFetch',
|
||||
|
Loading…
Reference in New Issue
Block a user