mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 13:45:18 +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 type { FetchResult, UseFetchOptions } from './fetch'
|
||||||
export { useCookie, refreshCookie } from './cookie'
|
export { useCookie, refreshCookie } from './cookie'
|
||||||
export type { CookieOptions, CookieRef } 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 { onNuxtReady } from './ready'
|
||||||
export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, onBeforeRouteLeave, onBeforeRouteUpdate, setPageLayout, navigateTo, useRoute, useRouter } from './router'
|
export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, onBeforeRouteLeave, onBeforeRouteUpdate, setPageLayout, navigateTo, useRoute, useRouter } from './router'
|
||||||
export type { AddRouteMiddlewareOptions, RouteMiddleware } from './router'
|
export type { AddRouteMiddlewareOptions, RouteMiddleware } from './router'
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
import type { H3Event } from 'h3'
|
import type { H3Event } from 'h3'
|
||||||
import { setResponseStatus as _setResponseStatus, appendHeader, getRequestHeader, getRequestHeaders } 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 type { NuxtApp } from '../nuxt'
|
||||||
import { useNuxtApp } from '../nuxt'
|
import { useNuxtApp } from '../nuxt'
|
||||||
import { toArray } from '../utils'
|
import { toArray } from '../utils'
|
||||||
@ -65,3 +68,47 @@ export function prerenderRoutes (path: string | string[]) {
|
|||||||
const paths = toArray(path)
|
const paths = toArray(path)
|
||||||
appendHeader(useRequestEvent()!, 'x-nitro-prerender', paths.map(p => encodeURIComponent(p)).join(', '))
|
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 { RemovePluginMetadataPlugin } from './plugins/plugin-metadata'
|
||||||
import { AsyncContextInjectionPlugin } from './plugins/async-context'
|
import { AsyncContextInjectionPlugin } from './plugins/async-context'
|
||||||
import { resolveDeepImportsPlugin } from './plugins/resolve-deep-imports'
|
import { resolveDeepImportsPlugin } from './plugins/resolve-deep-imports'
|
||||||
|
import { prehydrateTransformPlugin } from './plugins/prehydrate'
|
||||||
|
|
||||||
export function createNuxt (options: NuxtOptions): Nuxt {
|
export function createNuxt (options: NuxtOptions): Nuxt {
|
||||||
const hooks = createHooks<NuxtHooks>()
|
const hooks = createHooks<NuxtHooks>()
|
||||||
@ -150,6 +151,9 @@ async function initNuxt (nuxt: Nuxt) {
|
|||||||
// add resolver for modules used in virtual files
|
// add resolver for modules used in virtual files
|
||||||
addVitePlugin(() => resolveDeepImportsPlugin(nuxt))
|
addVitePlugin(() => resolveDeepImportsPlugin(nuxt))
|
||||||
|
|
||||||
|
// Add transform for `onPrehydrate` lifecycle hook
|
||||||
|
addBuildPlugin(prehydrateTransformPlugin(nuxt))
|
||||||
|
|
||||||
if (nuxt.options.experimental.localLayerAliases) {
|
if (nuxt.options.experimental.localLayerAliases) {
|
||||||
// Add layer aliasing support for ~, ~~, @ and @@ aliases
|
// Add layer aliasing support for ~, ~~, @ and @@ aliases
|
||||||
addVitePlugin(() => LayerAliasingPlugin.vite({
|
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',
|
from: '#app/composables/cookie',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
imports: ['prerenderRoutes', 'useRequestHeader', 'useRequestHeaders', 'useRequestEvent', 'useRequestFetch', 'setResponseStatus'],
|
imports: ['onPrehydrate', 'prerenderRoutes', 'useRequestHeader', 'useRequestHeaders', 'useRequestEvent', 'useRequestFetch', 'setResponseStatus'],
|
||||||
from: '#app/composables/ssr',
|
from: '#app/composables/ssr',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -176,7 +176,7 @@ export default defineUntypedSchema({
|
|||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
'vue': ['onRenderTracked', 'onRenderTriggered', 'onServerPrefetch'],
|
'vue': ['onRenderTracked', 'onRenderTriggered', 'onServerPrefetch'],
|
||||||
'#app': ['definePayloadReducer', 'definePageMeta'],
|
'#app': ['definePayloadReducer', 'definePageMeta', 'onPrehydrate'],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
@ -577,6 +577,33 @@ describe('nuxt composables', () => {
|
|||||||
await page.close()
|
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 () => {
|
it('respects preview mode with a token', async () => {
|
||||||
const token = 'hehe'
|
const token = 'hehe'
|
||||||
const page = await createPage(`/preview?preview=true&token=${token}`)
|
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',
|
'preloadRouteComponents',
|
||||||
'reloadNuxtApp',
|
'reloadNuxtApp',
|
||||||
'refreshCookie',
|
'refreshCookie',
|
||||||
|
'onPrehydrate',
|
||||||
'useFetch',
|
'useFetch',
|
||||||
'useHead',
|
'useHead',
|
||||||
'useLazyFetch',
|
'useLazyFetch',
|
||||||
|
Loading…
Reference in New Issue
Block a user