feat(nuxt): support object-syntax plugins (#20003)

This commit is contained in:
Daniel Roe 2023-04-11 12:58:43 +01:00 committed by GitHub
parent f951a15232
commit 4285092879
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 621 additions and 486 deletions

View File

@ -40,6 +40,31 @@ export default defineNuxtPlugin(nuxtApp => {
}) })
``` ```
### Object Syntax Plugins
It is also possible to define a plugin using an object syntax, for more advanced use cases. For example:
```ts
export default defineNuxtPlugin({
name: 'my-plugin',
enforce: 'pre', // or 'post'
async setup (nuxtApp) {
// this is the equivalent of a normal functional plugin
},
hooks: {
// You can directly register Nuxt app hooks here
'app:created'() {
const nuxtApp = useNuxtApp()
//
}
}
})
```
::alert
If you are using an object-syntax plugin, the properties may be statically analyzed in future to produce a more optimized build. So you should not define them at runtime. For example, setting `enforce: process.server ? 'pre' : 'post'` would defeat any future optimization Nuxt is able to do for your plugins.
::
## Plugin Registration Order ## Plugin Registration Order
You can control the order in which plugins are registered by prefixing a number to the file names. You can control the order in which plugins are registered by prefixing a number to the file names.

View File

@ -116,19 +116,19 @@ export default defineNuxtPlugin((nuxtApp) => {
::alert ::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`. 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: You can also add your own types, with a special plugin helper:
```ts ```ts [plugins/custom-payload.ts]
export default defineNuxtConfig({ /**
modules: [ * This kind of plugin runs very early in the Nuxt lifecycle, before we revive the payload.
function (_options, nuxt) { * You will not have access to the router or other Nuxt-injected properties.
// TODO: support directly via object syntax plugins: https://github.com/nuxt/nuxt/issues/14628 */
nuxt.hook('modules:done', () => { export default definePayloadPlugin((nuxtApp) => {
nuxt.options.plugins.unshift('~/plugins/custom-type-plugin') definePayloadReducer('BlinkingText', data => data === '<blink>' && '_')
}) definePayloadReviver('BlinkingText', () => '<blink>')
},
]
}) })
```
:: ::
### `isHydrating` ### `isHydrating`

View File

@ -139,9 +139,31 @@ interface _NuxtApp {
export interface NuxtApp extends _NuxtApp {} export interface NuxtApp extends _NuxtApp {}
export const NuxtPluginIndicator = '__nuxt_plugin' export const NuxtPluginIndicator = '__nuxt_plugin'
export interface PluginMeta {
name?: string
enforce?: 'pre' | 'default' | 'post'
/**
* This allows more granular control over plugin order and should only be used by advanced users.
* It overrides the value of `enforce` and is used to sort plugins.
*/
order?: number
}
export interface ResolvedPluginMeta {
name?: string
order: number
}
export interface Plugin<Injections extends Record<string, unknown> = Record<string, unknown>> { export interface Plugin<Injections extends Record<string, unknown> = Record<string, unknown>> {
(nuxt: _NuxtApp): Promise<void> | Promise<{ provide?: Injections }> | void | { provide?: Injections } (nuxt: _NuxtApp): Promise<void> | Promise<{ provide?: Injections }> | void | { provide?: Injections }
[NuxtPluginIndicator]?: true [NuxtPluginIndicator]?: true
meta?: ResolvedPluginMeta
}
export interface ObjectPluginInput<Injections extends Record<string, unknown> = Record<string, unknown>> extends PluginMeta {
hooks?: Partial<RuntimeNuxtHooks>
setup?: Plugin<Injections>
} }
export interface CreateOptions { export interface CreateOptions {
@ -306,25 +328,30 @@ export function normalizePlugins (_plugins: Plugin[]) {
const legacyInjectPlugins: Plugin[] = [] const legacyInjectPlugins: Plugin[] = []
const invalidPlugins: Plugin[] = [] const invalidPlugins: Plugin[] = []
const plugins = _plugins.map((plugin) => { const plugins: Plugin[] = []
for (const plugin of _plugins) {
if (typeof plugin !== 'function') { if (typeof plugin !== 'function') {
invalidPlugins.push(plugin) if (process.dev) { invalidPlugins.push(plugin) }
return null continue
} }
// TODO: Skip invalid plugins in next releases
let _plugin = plugin
if (plugin.length > 1) { if (plugin.length > 1) {
legacyInjectPlugins.push(plugin)
// Allow usage without wrapper but warn // Allow usage without wrapper but warn
// TODO: Skip invalid in next releases if (process.dev) { legacyInjectPlugins.push(plugin) }
// @ts-ignore // @ts-expect-error deliberate invalid second argument
return (nuxtApp: NuxtApp) => plugin(nuxtApp, nuxtApp.provide) _plugin = (nuxtApp: NuxtApp) => plugin(nuxtApp, nuxtApp.provide)
// return null
} }
if (!isNuxtPlugin(plugin)) {
unwrappedPlugins.push(plugin) // Allow usage without wrapper but warn
// Allow usage without wrapper but warn if (process.dev && !isNuxtPlugin(_plugin)) { unwrappedPlugins.push(_plugin) }
}
return plugin plugins.push(_plugin)
}).filter(Boolean) }
plugins.sort((a, b) => (a.meta?.order || orderMap.default) - (b.meta?.order || orderMap.default))
if (process.dev && legacyInjectPlugins.length) { if (process.dev && legacyInjectPlugins.length) {
console.warn('[warn] [nuxt] You are using a plugin with legacy Nuxt 2 format (context, inject) which is likely to be broken. In the future they will be ignored:', legacyInjectPlugins.map(p => p.name || p).join(',')) console.warn('[warn] [nuxt] You are using a plugin with legacy Nuxt 2 format (context, inject) which is likely to be broken. In the future they will be ignored:', legacyInjectPlugins.map(p => p.name || p).join(','))
@ -336,12 +363,53 @@ export function normalizePlugins (_plugins: Plugin[]) {
console.warn('[warn] [nuxt] You are using a plugin that has not been wrapped in `defineNuxtPlugin`. It is advised to wrap your plugins as in the future this may enable enhancements:', unwrappedPlugins.map(p => p.name || p).join(',')) console.warn('[warn] [nuxt] You are using a plugin that has not been wrapped in `defineNuxtPlugin`. It is advised to wrap your plugins as in the future this may enable enhancements:', unwrappedPlugins.map(p => p.name || p).join(','))
} }
return plugins as Plugin[] return plugins
} }
export function defineNuxtPlugin<T extends Record<string, unknown>> (plugin: Plugin<T>) { // -50: pre-all (nuxt)
plugin[NuxtPluginIndicator] = true // -40: custom payload revivers (user)
return plugin // -30: payload reviving (nuxt)
// -20: pre (user) <-- pre mapped to this
// -10: default (nuxt)
// 0: default (user) <-- default behavior
// +10: post (nuxt)
// +20: post (user) <-- post mapped to this
// +30: post-all (nuxt)
const orderMap: Record<NonNullable<ObjectPluginInput['enforce']>, number> = {
pre: -20,
default: 0,
post: 20
}
export function definePayloadPlugin<T extends Record<string, unknown>> (plugin: Plugin<T> | ObjectPluginInput<T>) {
return defineNuxtPlugin(plugin, { order: -40 })
}
export function defineNuxtPlugin<T extends Record<string, unknown>> (plugin: Plugin<T> | ObjectPluginInput<T>, meta?: PluginMeta): Plugin<T> {
if (typeof plugin === 'function') { return defineNuxtPlugin({ setup: plugin }, meta) }
const wrapper: Plugin<T> = (nuxtApp) => {
if (plugin.hooks) {
nuxtApp.hooks.addHooks(plugin.hooks)
}
if (plugin.setup) {
return plugin.setup(nuxtApp)
}
}
wrapper.meta = {
name: meta?.name || plugin.name || plugin.setup?.name,
order:
meta?.order ||
plugin.order ||
orderMap[plugin.enforce || 'default'] ||
orderMap.default
}
wrapper[NuxtPluginIndicator] = true
return wrapper
} }
export function isNuxtPlugin (plugin: unknown) { export function isNuxtPlugin (plugin: unknown) {

View File

@ -3,20 +3,23 @@ import { defineNuxtPlugin, useRuntimeConfig } from '#app/nuxt'
import { useRouter } from '#app/composables/router' import { useRouter } from '#app/composables/router'
import { reloadNuxtApp } from '#app/composables/chunk' import { reloadNuxtApp } from '#app/composables/chunk'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin({
const router = useRouter() name: 'nuxt:chunk-reload',
const config = useRuntimeConfig() setup (nuxtApp) {
const router = useRouter()
const config = useRuntimeConfig()
const chunkErrors = new Set() const chunkErrors = new Set()
router.beforeEach(() => { chunkErrors.clear() }) router.beforeEach(() => { chunkErrors.clear() })
nuxtApp.hook('app:chunkError', ({ error }) => { chunkErrors.add(error) }) nuxtApp.hook('app:chunkError', ({ error }) => { chunkErrors.add(error) })
router.onError((error, to) => { router.onError((error, to) => {
if (chunkErrors.has(error)) { if (chunkErrors.has(error)) {
const isHash = 'href' in to && (to.href as string).startsWith('#') const isHash = 'href' in to && (to.href as string).startsWith('#')
const path = isHash ? config.app.baseURL + (to as any).href : joinURL(config.app.baseURL, to.fullPath) const path = isHash ? config.app.baseURL + (to as any).href : joinURL(config.app.baseURL, to.fullPath)
reloadNuxtApp({ path, persistState: true }) reloadNuxtApp({ path, persistState: true })
} }
}) })
}
}) })

View File

@ -3,33 +3,36 @@ import { parseURL } from 'ufo'
import { useHead } from '@unhead/vue' import { useHead } from '@unhead/vue'
import { defineNuxtPlugin } from '#app/nuxt' import { defineNuxtPlugin } from '#app/nuxt'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin({
const externalURLs = ref(new Set<string>()) name: 'nuxt:cross-origin-prefetch',
function generateRules () { setup (nuxtApp) {
return { const externalURLs = ref(new Set<string>())
type: 'speculationrules', function generateRules () {
key: 'speculationrules', return {
innerHTML: JSON.stringify({ type: 'speculationrules',
prefetch: [ key: 'speculationrules',
{ innerHTML: JSON.stringify({
source: 'list', prefetch: [
urls: [...externalURLs.value], {
requires: ['anonymous-client-ip-when-cross-origin'] source: 'list',
} urls: [...externalURLs.value],
] requires: ['anonymous-client-ip-when-cross-origin']
}) }
]
})
}
} }
const head = useHead({
script: [generateRules()]
})
nuxtApp.hook('link:prefetch', (url) => {
const { protocol } = parseURL(url)
if (protocol && ['http:', 'https:'].includes(protocol)) {
externalURLs.value.add(url)
head?.patch({
script: [generateRules()]
})
}
})
} }
const head = useHead({
script: [generateRules()]
})
nuxtApp.hook('link:prefetch', (url) => {
const { protocol } = parseURL(url)
if (protocol && ['http:', 'https:'].includes(protocol)) {
externalURLs.value.add(url)
head?.patch({
script: [generateRules()]
})
}
})
}) })

View File

@ -1,6 +1,10 @@
import { createDebugger } from 'hookable' import { createDebugger } from 'hookable'
import { defineNuxtPlugin } from '#app/nuxt' import { defineNuxtPlugin } from '#app/nuxt'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin({
createDebugger(nuxtApp.hooks, { tag: 'nuxt-app' }) name: 'nuxt:debug',
enforce: 'pre',
setup (nuxtApp) {
createDebugger(nuxtApp.hooks, { tag: 'nuxt-app' })
}
}) })

View File

@ -3,25 +3,28 @@ import { defineNuxtPlugin } from '#app/nuxt'
import { isPrerendered, loadPayload } from '#app/composables/payload' import { isPrerendered, loadPayload } from '#app/composables/payload'
import { useRouter } from '#app/composables/router' import { useRouter } from '#app/composables/router'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin({
// Only enable behavior if initial page is prerendered name: 'nuxt:payload',
// TODO: Support hybrid and dev setup (nuxtApp) {
if (!isPrerendered()) { // Only enable behavior if initial page is prerendered
return // TODO: Support hybrid and dev
} if (!isPrerendered()) {
return
// Load payload into cache
nuxtApp.hooks.hook('link:prefetch', async (url) => {
if (!parseURL(url).protocol) {
await loadPayload(url)
} }
})
// Load payload after middleware & once final route is resolved // Load payload into cache
useRouter().beforeResolve(async (to, from) => { nuxtApp.hooks.hook('link:prefetch', async (url) => {
if (to.path === from.path) { return } if (!parseURL(url).protocol) {
const payload = await loadPayload(to.path) await loadPayload(url)
if (!payload) { return } }
Object.assign(nuxtApp.static.data, payload.data) })
})
// Load payload after middleware & once final route is resolved
useRouter().beforeResolve(async (to, from) => {
if (to.path === from.path) { return }
const payload = await loadPayload(to.path)
if (!payload) { return }
Object.assign(nuxtApp.static.data, payload.data)
})
}
}) })

View File

@ -1,11 +1,14 @@
import { defineNuxtPlugin } from '#app/nuxt' import { defineNuxtPlugin } from '#app/nuxt'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin({
nuxtApp.vueApp.mixin({ name: 'nuxt:webpack-preload',
beforeCreate () { setup (nuxtApp) {
const { _registeredComponents } = this.$nuxt.ssrContext nuxtApp.vueApp.mixin({
const { __moduleIdentifier } = this.$options beforeCreate () {
_registeredComponents.add(__moduleIdentifier) const { _registeredComponents } = this.$nuxt.ssrContext
} const { __moduleIdentifier } = this.$options
}) _registeredComponents.add(__moduleIdentifier)
}
})
}
}) })

View File

@ -1,13 +1,17 @@
import { defineNuxtPlugin } from '#app/nuxt' import { defineNuxtPlugin, useNuxtApp } from '#app/nuxt'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin({
nuxtApp.hook('app:mounted', () => { name: 'nuxt:restore-state',
try { hooks: {
const state = sessionStorage.getItem('nuxt:reload:state') 'app:mounted' () {
if (state) { const nuxtApp = useNuxtApp()
sessionStorage.removeItem('nuxt:reload:state') try {
Object.assign(nuxtApp.payload.state, JSON.parse(state)?.state) const state = sessionStorage.getItem('nuxt:reload:state')
} if (state) {
} catch {} sessionStorage.removeItem('nuxt:reload:state')
}) Object.assign(nuxtApp.payload.state, JSON.parse(state)?.state)
}
} catch {}
}
}
}) })

View File

@ -13,11 +13,15 @@ const revivers = {
Reactive: (data: any) => reactive(data) Reactive: (data: any) => reactive(data)
} }
export default defineNuxtPlugin(async (nuxtApp) => { export default defineNuxtPlugin({
for (const reviver in revivers) { name: 'nuxt:revive-payload:client',
definePayloadReviver(reviver, revivers[reviver as keyof typeof revivers]) order: -30,
async setup (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
} }
Object.assign(nuxtApp.payload, await callWithNuxt(nuxtApp, getNuxtClientPayload, []))
// For backwards compatibility - TODO: remove later
window.__NUXT__ = nuxtApp.payload
}) })

View File

@ -14,8 +14,11 @@ const reducers = {
Reactive: (data: any) => isReactive(data) && toRaw(data) Reactive: (data: any) => isReactive(data) && toRaw(data)
} }
export default defineNuxtPlugin(() => { export default defineNuxtPlugin({
for (const reducer in reducers) { name: 'nuxt:revive-payload:server',
definePayloadReducer(reducer, reducers[reducer as keyof typeof reducers]) setup () {
for (const reducer in reducers) {
definePayloadReducer(reducer, reducers[reducer as keyof typeof reducers])
}
} }
}) })

View File

@ -96,176 +96,180 @@ interface Router {
removeRoute: (name: string) => void removeRoute: (name: string) => void
} }
export default defineNuxtPlugin<{ route: Route, router: Router }>((nuxtApp) => { export default defineNuxtPlugin<{ route: Route, router: Router }>({
const initialURL = process.client name: 'nuxt:router',
? withoutBase(window.location.pathname, useRuntimeConfig().app.baseURL) + window.location.search + window.location.hash enforce: 'pre',
: nuxtApp.ssrContext!.url setup (nuxtApp) {
const initialURL = process.client
? withoutBase(window.location.pathname, useRuntimeConfig().app.baseURL) + window.location.search + window.location.hash
: nuxtApp.ssrContext!.url
const routes: Route[] = [] const routes: Route[] = []
const hooks: { [key in keyof RouterHooks]: RouterHooks[key][] } = { const hooks: { [key in keyof RouterHooks]: RouterHooks[key][] } = {
'navigate:before': [], 'navigate:before': [],
'resolve:before': [], 'resolve:before': [],
'navigate:after': [], 'navigate:after': [],
error: [] error: []
} }
const registerHook = <T extends keyof RouterHooks>(hook: T, guard: RouterHooks[T]) => { const registerHook = <T extends keyof RouterHooks> (hook: T, guard: RouterHooks[T]) => {
hooks[hook].push(guard) hooks[hook].push(guard)
return () => hooks[hook].splice(hooks[hook].indexOf(guard), 1) return () => hooks[hook].splice(hooks[hook].indexOf(guard), 1)
} }
const baseURL = useRuntimeConfig().app.baseURL const baseURL = useRuntimeConfig().app.baseURL
const route: Route = reactive(getRouteFromPath(initialURL)) const route: Route = reactive(getRouteFromPath(initialURL))
async function handleNavigation (url: string | Partial<Route>, replace?: boolean): Promise<void> { async function handleNavigation (url: string | Partial<Route>, replace?: boolean): Promise<void> {
try { try {
// Resolve route // Resolve route
const to = getRouteFromPath(url) const to = getRouteFromPath(url)
// Run beforeEach hooks // Run beforeEach hooks
for (const middleware of hooks['navigate:before']) { for (const middleware of hooks['navigate:before']) {
const result = await middleware(to, route) const result = await middleware(to, route)
// Cancel navigation // Cancel navigation
if (result === false || result instanceof Error) { return } if (result === false || result instanceof Error) { return }
// Redirect // Redirect
if (result) { return handleNavigation(result, true) } if (result) { return handleNavigation(result, true) }
}
for (const handler of hooks['resolve:before']) {
await handler(to, route)
}
// Perform navigation
Object.assign(route, to)
if (process.client) {
window.history[replace ? 'replaceState' : 'pushState']({}, '', joinURL(baseURL, to.fullPath))
if (!nuxtApp.isHydrating) {
// Clear any existing errors
await callWithNuxt(nuxtApp, clearError)
} }
}
// Run afterEach hooks
for (const middleware of hooks['navigate:after']) {
await middleware(to, route)
}
} catch (err) {
if (process.dev && !hooks.error.length) {
console.warn('No error handlers registered to handle middleware errors. You can register an error handler with `router.onError()`', err)
}
for (const handler of hooks.error) {
await handler(err)
}
}
}
const router: Router = { for (const handler of hooks['resolve:before']) {
currentRoute: route, await handler(to, route)
isReady: () => Promise.resolve(), }
// These options provide a similar API to vue-router but have no effect // Perform navigation
options: {}, Object.assign(route, to)
install: () => Promise.resolve(), if (process.client) {
// Navigation window.history[replace ? 'replaceState' : 'pushState']({}, '', joinURL(baseURL, to.fullPath))
push: (url: string) => handleNavigation(url, false), if (!nuxtApp.isHydrating) {
replace: (url: string) => handleNavigation(url, true), // Clear any existing errors
back: () => window.history.go(-1), await callWithNuxt(nuxtApp, clearError)
go: (delta: number) => window.history.go(delta),
forward: () => window.history.go(1),
// Guards
beforeResolve: (guard: RouterHooks['resolve:before']) => registerHook('resolve:before', guard),
beforeEach: (guard: RouterHooks['navigate:before']) => registerHook('navigate:before', guard),
afterEach: (guard: RouterHooks['navigate:after']) => registerHook('navigate:after', guard),
onError: (handler: RouterHooks['error']) => registerHook('error', handler),
// Routes
resolve: getRouteFromPath,
addRoute: (parentName: string, route: Route) => { routes.push(route) },
getRoutes: () => routes,
hasRoute: (name: string) => routes.some(route => route.name === name),
removeRoute: (name: string) => {
const index = routes.findIndex(route => route.name === name)
if (index !== -1) {
routes.splice(index, 1)
}
}
}
nuxtApp.vueApp.component('RouterLink', {
functional: true,
props: {
to: String,
custom: Boolean,
replace: Boolean,
// Not implemented
activeClass: String,
exactActiveClass: String,
ariaCurrentValue: String
},
setup: (props, { slots }) => {
const navigate = () => handleNavigation(props.to, props.replace)
return () => {
const route = router.resolve(props.to)
return props.custom
? slots.default?.({ href: props.to, navigate, route })
: h('a', { href: props.to, onClick: (e: MouseEvent) => { e.preventDefault(); return navigate() } }, slots)
}
}
})
if (process.client) {
window.addEventListener('popstate', (event) => {
const location = (event.target as Window).location
router.replace(location.href.replace(location.origin, ''))
})
}
nuxtApp._route = route
// Handle middleware
nuxtApp._middleware = nuxtApp._middleware || {
global: [],
named: {}
}
const initialLayout = useState('_layout')
nuxtApp.hooks.hookOnce('app:created', async () => {
router.beforeEach(async (to, from) => {
to.meta = reactive(to.meta || {})
if (nuxtApp.isHydrating && initialLayout.value && !isReadonly(to.meta.layout)) {
to.meta.layout = initialLayout.value
}
nuxtApp._processingMiddleware = true
const middlewareEntries = new Set<RouteGuard>([...globalMiddleware, ...nuxtApp._middleware.global])
for (const middleware of middlewareEntries) {
const result = await callWithNuxt(nuxtApp, middleware, [to, from])
if (process.server) {
if (result === false || result instanceof Error) {
const error = result || createError({
statusCode: 404,
statusMessage: `Page Not Found: ${initialURL}`
})
return callWithNuxt(nuxtApp, showError, [error])
} }
} }
if (result || result === false) { return result } // Run afterEach hooks
for (const middleware of hooks['navigate:after']) {
await middleware(to, route)
}
} catch (err) {
if (process.dev && !hooks.error.length) {
console.warn('No error handlers registered to handle middleware errors. You can register an error handler with `router.onError()`', err)
}
for (const handler of hooks.error) {
await handler(err)
}
}
}
const router: Router = {
currentRoute: route,
isReady: () => Promise.resolve(),
// These options provide a similar API to vue-router but have no effect
options: {},
install: () => Promise.resolve(),
// Navigation
push: (url: string) => handleNavigation(url, false),
replace: (url: string) => handleNavigation(url, true),
back: () => window.history.go(-1),
go: (delta: number) => window.history.go(delta),
forward: () => window.history.go(1),
// Guards
beforeResolve: (guard: RouterHooks['resolve:before']) => registerHook('resolve:before', guard),
beforeEach: (guard: RouterHooks['navigate:before']) => registerHook('navigate:before', guard),
afterEach: (guard: RouterHooks['navigate:after']) => registerHook('navigate:after', guard),
onError: (handler: RouterHooks['error']) => registerHook('error', handler),
// Routes
resolve: getRouteFromPath,
addRoute: (parentName: string, route: Route) => { routes.push(route) },
getRoutes: () => routes,
hasRoute: (name: string) => routes.some(route => route.name === name),
removeRoute: (name: string) => {
const index = routes.findIndex(route => route.name === name)
if (index !== -1) {
routes.splice(index, 1)
}
}
}
nuxtApp.vueApp.component('RouterLink', {
functional: true,
props: {
to: String,
custom: Boolean,
replace: Boolean,
// Not implemented
activeClass: String,
exactActiveClass: String,
ariaCurrentValue: String
},
setup: (props, { slots }) => {
const navigate = () => handleNavigation(props.to, props.replace)
return () => {
const route = router.resolve(props.to)
return props.custom
? slots.default?.({ href: props.to, navigate, route })
: h('a', { href: props.to, onClick: (e: MouseEvent) => { e.preventDefault(); return navigate() } }, slots)
}
} }
}) })
router.afterEach(() => { if (process.client) {
delete nuxtApp._processingMiddleware window.addEventListener('popstate', (event) => {
const location = (event.target as Window).location
router.replace(location.href.replace(location.origin, ''))
})
}
nuxtApp._route = route
// Handle middleware
nuxtApp._middleware = nuxtApp._middleware || {
global: [],
named: {}
}
const initialLayout = useState('_layout')
nuxtApp.hooks.hookOnce('app:created', async () => {
router.beforeEach(async (to, from) => {
to.meta = reactive(to.meta || {})
if (nuxtApp.isHydrating && initialLayout.value && !isReadonly(to.meta.layout)) {
to.meta.layout = initialLayout.value
}
nuxtApp._processingMiddleware = true
const middlewareEntries = new Set<RouteGuard>([...globalMiddleware, ...nuxtApp._middleware.global])
for (const middleware of middlewareEntries) {
const result = await callWithNuxt(nuxtApp, middleware, [to, from])
if (process.server) {
if (result === false || result instanceof Error) {
const error = result || createError({
statusCode: 404,
statusMessage: `Page Not Found: ${initialURL}`
})
return callWithNuxt(nuxtApp, showError, [error])
}
}
if (result || result === false) { return result }
}
})
router.afterEach(() => {
delete nuxtApp._processingMiddleware
})
await router.replace(initialURL)
if (!isEqual(route.fullPath, initialURL)) {
const event = await callWithNuxt(nuxtApp, useRequestEvent)
const options = { redirectCode: event.node.res.statusCode !== 200 ? event.node.res.statusCode || 302 : 302 }
await callWithNuxt(nuxtApp, navigateTo, [route.fullPath, options])
}
}) })
await router.replace(initialURL) return {
if (!isEqual(route.fullPath, initialURL)) { provide: {
const event = await callWithNuxt(nuxtApp, useRequestEvent) route,
const options = { redirectCode: event.node.res.statusCode !== 200 ? event.node.res.statusCode || 302 : 302 } router
await callWithNuxt(nuxtApp, navigateTo, [route.fullPath, options]) }
}
})
return {
provide: {
route,
router
} }
} }
}) })

View File

@ -40,10 +40,13 @@ const components = ${genObjectFromRawEntries(globalComponents.map((c) => {
return [c.pascalName, `defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${exp}))`] return [c.pascalName, `defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${exp}))`]
}))} }))}
export default defineNuxtPlugin(nuxtApp => { export default defineNuxtPlugin({
for (const name in components) { name: 'nuxt:global-components',
nuxtApp.vueApp.component(name, components[name]) setup (nuxtApp) {
nuxtApp.vueApp.component('Lazy' + name, components[name]) for (const name in components) {
nuxtApp.vueApp.component(name, components[name])
nuxtApp.vueApp.component('Lazy' + name, components[name])
}
} }
}) })
` `

View File

@ -292,9 +292,9 @@ async function initNuxt (nuxt: Nuxt) {
// Add experimental support for custom types in JSON payload // Add experimental support for custom types in JSON payload
if (nuxt.options.experimental.renderJsonPayloads) { if (nuxt.options.experimental.renderJsonPayloads) {
nuxt.hook('modules:done', () => { nuxt.hooks.hook('modules:done', () => {
nuxt.options.plugins.unshift(resolve(nuxt.options.appDir, 'plugins/revive-payload.client')) addPlugin(resolve(nuxt.options.appDir, 'plugins/revive-payload.client'))
nuxt.options.plugins.unshift(resolve(nuxt.options.appDir, 'plugins/revive-payload.server')) addPlugin(resolve(nuxt.options.appDir, 'plugins/revive-payload.server'))
}) })
} }

View File

@ -4,37 +4,40 @@ import { defineNuxtPlugin } from '#app/nuxt'
// @ts-expect-error untyped // @ts-expect-error untyped
import { appHead } from '#build/nuxt.config.mjs' import { appHead } from '#build/nuxt.config.mjs'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin({
const createHead = process.server ? createServerHead : createClientHead name: 'nuxt:head',
const head = createHead() setup (nuxtApp) {
head.push(appHead) const createHead = process.server ? createServerHead : createClientHead
const head = createHead()
head.push(appHead)
nuxtApp.vueApp.use(head) nuxtApp.vueApp.use(head)
if (process.client) { if (process.client) {
// pause dom updates until page is ready and between page transitions // pause dom updates until page is ready and between page transitions
let pauseDOMUpdates = true let pauseDOMUpdates = true
const unpauseDom = () => { const unpauseDom = () => {
pauseDOMUpdates = false pauseDOMUpdates = false
// trigger the debounced DOM update // trigger the debounced DOM update
head.hooks.callHook('entries:updated', head) head.hooks.callHook('entries:updated', head)
}
head.hooks.hook('dom:beforeRender', (context) => { context.shouldRender = !pauseDOMUpdates })
nuxtApp.hooks.hook('page:start', () => { pauseDOMUpdates = true })
// wait for new page before unpausing dom updates (triggered after suspense resolved)
nuxtApp.hooks.hook('page:finish', unpauseDom)
// unpause the DOM once the mount suspense is resolved
nuxtApp.hooks.hook('app:suspense:resolve', unpauseDom)
} }
head.hooks.hook('dom:beforeRender', (context) => { context.shouldRender = !pauseDOMUpdates })
nuxtApp.hooks.hook('page:start', () => { pauseDOMUpdates = true })
// wait for new page before unpausing dom updates (triggered after suspense resolved)
nuxtApp.hooks.hook('page:finish', unpauseDom)
// unpause the DOM once the mount suspense is resolved
nuxtApp.hooks.hook('app:suspense:resolve', unpauseDom)
}
if (process.server) { if (process.server) {
nuxtApp.ssrContext!.renderMeta = async () => { nuxtApp.ssrContext!.renderMeta = async () => {
const meta = await renderSSRHead(head) const meta = await renderSSRHead(head)
return { return {
...meta, ...meta,
bodyScriptsPrepend: meta.bodyTagsOpen, bodyScriptsPrepend: meta.bodyTagsOpen,
// resolves naming difference with NuxtMeta and Unhead // resolves naming difference with NuxtMeta and Unhead
bodyScripts: meta.bodyTags bodyScripts: meta.bodyTags
}
} }
} }
} }

View File

@ -2,7 +2,10 @@
import { polyfillAsVueUseHead } from '@unhead/vue/polyfill' import { polyfillAsVueUseHead } from '@unhead/vue/polyfill'
import { defineNuxtPlugin } from '#app/nuxt' import { defineNuxtPlugin } from '#app/nuxt'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin({
// avoid breaking ecosystem dependencies using low-level @vueuse/head APIs name: 'nuxt:vueuse-head-polyfill',
polyfillAsVueUseHead(nuxtApp.vueApp._context.provides.usehead) setup (nuxtApp) {
// avoid breaking ecosystem dependencies using low-level @vueuse/head APIs
polyfillAsVueUseHead(nuxtApp.vueApp._context.provides.usehead)
}
}) })

View File

@ -23,6 +23,7 @@ const appPreset = defineUnimportPreset({
'defineNuxtComponent', 'defineNuxtComponent',
'useNuxtApp', 'useNuxtApp',
'defineNuxtPlugin', 'defineNuxtPlugin',
'definePayloadPlugin',
'reloadNuxtApp', 'reloadNuxtApp',
'useRuntimeConfig', 'useRuntimeConfig',
'useState', 'useState',

View File

@ -1,41 +1,43 @@
import { hasProtocol } from 'ufo' import { hasProtocol } from 'ufo'
import { defineNuxtPlugin, useNuxtApp } from '#app/nuxt' import { defineNuxtPlugin } from '#app/nuxt'
import { useRouter } from '#app/composables/router' import { useRouter } from '#app/composables/router'
// @ts-ignore // @ts-ignore
import layouts from '#build/layouts' import layouts from '#build/layouts'
// @ts-ignore // @ts-ignore
import { namedMiddleware } from '#build/middleware' import { namedMiddleware } from '#build/middleware'
export default defineNuxtPlugin(() => { export default defineNuxtPlugin({
const nuxtApp = useNuxtApp() name: 'nuxt:prefetch',
const router = useRouter() setup (nuxtApp) {
const router = useRouter()
// Force layout prefetch on route changes
nuxtApp.hooks.hook('app:mounted', () => {
router.beforeEach(async (to) => {
const layout = to?.meta?.layout
if (layout && typeof layouts[layout] === 'function') {
await layouts[layout]()
}
})
})
// Prefetch layouts & middleware
nuxtApp.hooks.hook('link:prefetch', (url) => {
if (hasProtocol(url)) { return }
const route = router.resolve(url)
if (!route) { return }
const layout = route?.meta?.layout
let middleware = Array.isArray(route?.meta?.middleware) ? route?.meta?.middleware : [route?.meta?.middleware]
middleware = middleware.filter(m => typeof m === 'string')
for (const name of middleware) {
if (typeof namedMiddleware[name] === 'function') {
namedMiddleware[name]()
}
}
// Force layout prefetch on route changes
nuxtApp.hooks.hook('app:mounted', () => {
router.beforeEach(async (to) => {
const layout = to?.meta?.layout
if (layout && typeof layouts[layout] === 'function') { if (layout && typeof layouts[layout] === 'function') {
await layouts[layout]() layouts[layout]()
} }
}) })
}) }
// Prefetch layouts & middleware
nuxtApp.hooks.hook('link:prefetch', (url) => {
if (hasProtocol(url)) { return }
const route = router.resolve(url)
if (!route) { return }
const layout = route?.meta?.layout
let middleware = Array.isArray(route?.meta?.middleware) ? route?.meta?.middleware : [route?.meta?.middleware]
middleware = middleware.filter(m => typeof m === 'string')
for (const name of middleware) {
if (typeof namedMiddleware[name] === 'function') {
namedMiddleware[name]()
}
}
if (layout && typeof layouts[layout] === 'function') {
layouts[layout]()
}
})
}) })

View File

@ -45,157 +45,161 @@ function createCurrentLocation (
return path + search + hash return path + search + hash
} }
export default defineNuxtPlugin(async (nuxtApp) => { export default defineNuxtPlugin({
let routerBase = useRuntimeConfig().app.baseURL name: 'nuxt:router',
if (routerOptions.hashMode && !routerBase.includes('#')) { enforce: 'pre',
// allow the user to provide a `#` in the middle: `/base/#/app` async setup (nuxtApp) {
routerBase += '#' let routerBase = useRuntimeConfig().app.baseURL
} if (routerOptions.hashMode && !routerBase.includes('#')) {
// allow the user to provide a `#` in the middle: `/base/#/app`
const history = routerOptions.history?.(routerBase) ?? (process.client routerBase += '#'
? (routerOptions.hashMode ? createWebHashHistory(routerBase) : createWebHistory(routerBase))
: createMemoryHistory(routerBase)
)
const routes = routerOptions.routes?.(_routes) ?? _routes
const initialURL = process.server ? nuxtApp.ssrContext!.url : createCurrentLocation(routerBase, window.location)
const router = createRouter({
...routerOptions,
history,
routes
})
nuxtApp.vueApp.use(router)
const previousRoute = shallowRef(router.currentRoute.value)
router.afterEach((_to, from) => {
previousRoute.value = from
})
Object.defineProperty(nuxtApp.vueApp.config.globalProperties, 'previousRoute', {
get: () => previousRoute.value
})
// Allows suspending the route object until page navigation completes
const _route = shallowRef(router.resolve(initialURL) as RouteLocation)
const syncCurrentRoute = () => { _route.value = router.currentRoute.value }
nuxtApp.hook('page:finish', syncCurrentRoute)
router.afterEach((to, from) => {
// We won't trigger suspense if the component is reused between routes
// so we need to update the route manually
if (to.matched[0]?.components?.default === from.matched[0]?.components?.default) {
syncCurrentRoute()
}
})
// https://github.com/vuejs/router/blob/main/packages/router/src/router.ts#L1225-L1233
const route = {} as RouteLocation
for (const key in _route.value) {
(route as any)[key] = computed(() => _route.value[key as keyof RouteLocation])
}
nuxtApp._route = reactive(route)
nuxtApp._middleware = nuxtApp._middleware || {
global: [],
named: {}
}
const error = useError()
try {
if (process.server) {
await router.push(initialURL)
} }
await router.isReady() const history = routerOptions.history?.(routerBase) ?? (process.client
} catch (error: any) { ? (routerOptions.hashMode ? createWebHashHistory(routerBase) : createWebHistory(routerBase))
// We'll catch 404s here : createMemoryHistory(routerBase)
await callWithNuxt(nuxtApp, showError, [error]) )
}
const initialLayout = useState('_layout') const routes = routerOptions.routes?.(_routes) ?? _routes
router.beforeEach(async (to, from) => {
to.meta = reactive(to.meta)
if (nuxtApp.isHydrating && initialLayout.value && !isReadonly(to.meta.layout)) {
to.meta.layout = initialLayout.value as Exclude<PageMeta['layout'], Ref | false>
}
nuxtApp._processingMiddleware = true
type MiddlewareDef = string | RouteMiddleware const initialURL = process.server ? nuxtApp.ssrContext!.url : createCurrentLocation(routerBase, window.location)
const middlewareEntries = new Set<MiddlewareDef>([...globalMiddleware, ...nuxtApp._middleware.global]) const router = createRouter({
for (const component of to.matched) { ...routerOptions,
const componentMiddleware = component.meta.middleware as MiddlewareDef | MiddlewareDef[] history,
if (!componentMiddleware) { continue } routes
if (Array.isArray(componentMiddleware)) { })
for (const entry of componentMiddleware) { nuxtApp.vueApp.use(router)
middlewareEntries.add(entry)
} const previousRoute = shallowRef(router.currentRoute.value)
} else { router.afterEach((_to, from) => {
middlewareEntries.add(componentMiddleware) previousRoute.value = from
})
Object.defineProperty(nuxtApp.vueApp.config.globalProperties, 'previousRoute', {
get: () => previousRoute.value
})
// Allows suspending the route object until page navigation completes
const _route = shallowRef(router.resolve(initialURL) as RouteLocation)
const syncCurrentRoute = () => { _route.value = router.currentRoute.value }
nuxtApp.hook('page:finish', syncCurrentRoute)
router.afterEach((to, from) => {
// We won't trigger suspense if the component is reused between routes
// so we need to update the route manually
if (to.matched[0]?.components?.default === from.matched[0]?.components?.default) {
syncCurrentRoute()
} }
})
// https://github.com/vuejs/router/blob/main/packages/router/src/router.ts#L1225-L1233
const route = {} as RouteLocation
for (const key in _route.value) {
(route as any)[key] = computed(() => _route.value[key as keyof RouteLocation])
} }
for (const entry of middlewareEntries) { nuxtApp._route = reactive(route)
const middleware = typeof entry === 'string' ? nuxtApp._middleware.named[entry] || await namedMiddleware[entry]?.().then((r: any) => r.default || r) : entry
if (!middleware) { nuxtApp._middleware = nuxtApp._middleware || {
if (process.dev) { global: [],
throw new Error(`Unknown route middleware: '${entry}'. Valid middleware: ${Object.keys(namedMiddleware).map(mw => `'${mw}'`).join(', ')}.`) named: {}
}
throw new Error(`Unknown route middleware: '${entry}'.`)
}
const result = await callWithNuxt(nuxtApp, middleware, [to, from])
if (process.server || (!nuxtApp.payload.serverRendered && nuxtApp.isHydrating)) {
if (result === false || result instanceof Error) {
const error = result || createError({
statusCode: 404,
statusMessage: `Page Not Found: ${initialURL}`
})
await callWithNuxt(nuxtApp, showError, [error])
return false
}
}
if (result || result === false) { return result }
} }
})
router.afterEach(async (to) => { const error = useError()
delete nuxtApp._processingMiddleware
if (process.client && !nuxtApp.isHydrating && error.value) {
// Clear any existing errors
await callWithNuxt(nuxtApp, clearError)
}
if (to.matched.length === 0) {
await callWithNuxt(nuxtApp, showError, [createError({
statusCode: 404,
fatal: false,
statusMessage: `Page not found: ${to.fullPath}`
})])
} else if (process.server) {
const currentURL = to.fullPath || '/'
if (!isEqual(currentURL, initialURL, { trailingSlash: true })) {
const event = await callWithNuxt(nuxtApp, useRequestEvent)
const options = { redirectCode: event.node.res.statusCode !== 200 ? event.node.res.statusCode || 302 : 302 }
await callWithNuxt(nuxtApp, navigateTo, [currentURL, options])
}
}
})
nuxtApp.hooks.hookOnce('app:created', async () => {
try { try {
await router.replace({ if (process.server) {
...router.resolve(initialURL), await router.push(initialURL)
name: undefined, // #4920, #$4982 }
force: true
}) await router.isReady()
} catch (error: any) { } catch (error: any) {
// We'll catch middleware errors or deliberate exceptions here // We'll catch 404s here
await callWithNuxt(nuxtApp, showError, [error]) await callWithNuxt(nuxtApp, showError, [error])
} }
})
return { provide: { router } } const initialLayout = useState('_layout')
router.beforeEach(async (to, from) => {
to.meta = reactive(to.meta)
if (nuxtApp.isHydrating && initialLayout.value && !isReadonly(to.meta.layout)) {
to.meta.layout = initialLayout.value as Exclude<PageMeta['layout'], Ref | false>
}
nuxtApp._processingMiddleware = true
type MiddlewareDef = string | RouteMiddleware
const middlewareEntries = new Set<MiddlewareDef>([...globalMiddleware, ...nuxtApp._middleware.global])
for (const component of to.matched) {
const componentMiddleware = component.meta.middleware as MiddlewareDef | MiddlewareDef[]
if (!componentMiddleware) { continue }
if (Array.isArray(componentMiddleware)) {
for (const entry of componentMiddleware) {
middlewareEntries.add(entry)
}
} else {
middlewareEntries.add(componentMiddleware)
}
}
for (const entry of middlewareEntries) {
const middleware = typeof entry === 'string' ? nuxtApp._middleware.named[entry] || await namedMiddleware[entry]?.().then((r: any) => r.default || r) : entry
if (!middleware) {
if (process.dev) {
throw new Error(`Unknown route middleware: '${entry}'. Valid middleware: ${Object.keys(namedMiddleware).map(mw => `'${mw}'`).join(', ')}.`)
}
throw new Error(`Unknown route middleware: '${entry}'.`)
}
const result = await callWithNuxt(nuxtApp, middleware, [to, from])
if (process.server || (!nuxtApp.payload.serverRendered && nuxtApp.isHydrating)) {
if (result === false || result instanceof Error) {
const error = result || createError({
statusCode: 404,
statusMessage: `Page Not Found: ${initialURL}`
})
await callWithNuxt(nuxtApp, showError, [error])
return false
}
}
if (result || result === false) { return result }
}
})
router.afterEach(async (to) => {
delete nuxtApp._processingMiddleware
if (process.client && !nuxtApp.isHydrating && error.value) {
// Clear any existing errors
await callWithNuxt(nuxtApp, clearError)
}
if (to.matched.length === 0) {
await callWithNuxt(nuxtApp, showError, [createError({
statusCode: 404,
fatal: false,
statusMessage: `Page not found: ${to.fullPath}`
})])
} else if (process.server) {
const currentURL = to.fullPath || '/'
if (!isEqual(currentURL, initialURL, { trailingSlash: true })) {
const event = await callWithNuxt(nuxtApp, useRequestEvent)
const options = { redirectCode: event.node.res.statusCode !== 200 ? event.node.res.statusCode || 302 : 302 }
await callWithNuxt(nuxtApp, navigateTo, [currentURL, options])
}
}
})
nuxtApp.hooks.hookOnce('app:created', async () => {
try {
await router.replace({
...router.resolve(initialURL),
name: undefined, // #4920, #$4982
force: true
})
} catch (error: any) {
// We'll catch middleware errors or deliberate exceptions here
await callWithNuxt(nuxtApp, showError, [error])
}
})
return { provide: { router } }
}
}) as Plugin<{ router: Router }> }) as Plugin<{ router: Router }>

View File

@ -195,6 +195,7 @@ export default defineUntypedSchema({
asyncFunctions: ['defineNuxtPlugin', 'defineNuxtRouteMiddleware'], asyncFunctions: ['defineNuxtPlugin', 'defineNuxtRouteMiddleware'],
objectDefinitions: { objectDefinitions: {
defineNuxtComponent: ['asyncData', 'setup'], defineNuxtComponent: ['asyncData', 'setup'],
defineNuxtPlugin: ['setup'],
definePageMeta: ['middleware', 'validate'] definePageMeta: ['middleware', 'validate']
} }
} }

View File

@ -26,7 +26,7 @@ describe.skipIf(isWindows || process.env.ECOSYSTEM_CI)('minimal nuxt application
it('default client bundle size', async () => { it('default client bundle size', async () => {
stats.client = await analyzeSizes('**/*.js', publicDir) stats.client = await analyzeSizes('**/*.js', publicDir)
expect(roundToKilobytes(stats.client.totalBytes)).toMatchInlineSnapshot('"104k"') expect(roundToKilobytes(stats.client.totalBytes)).toMatchInlineSnapshot('"105k"')
expect(stats.client.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(` expect(stats.client.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(`
[ [
"_nuxt/_plugin-vue_export-helper.js", "_nuxt/_plugin-vue_export-helper.js",
@ -40,7 +40,7 @@ describe.skipIf(isWindows || process.env.ECOSYSTEM_CI)('minimal nuxt application
it('default server bundle size', async () => { it('default server bundle size', async () => {
stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect(roundToKilobytes(stats.server.totalBytes)).toMatchInlineSnapshot('"91k"') expect(roundToKilobytes(stats.server.totalBytes)).toMatchInlineSnapshot('"92k"')
const modules = await analyzeSizes('node_modules/**/*', serverDir) const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2650k"') expect(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2650k"')

View File

@ -108,12 +108,6 @@ export default defineNuxtConfig({
addVitePlugin(plugin.vite()) addVitePlugin(plugin.vite())
addWebpackPlugin(plugin.webpack()) addWebpackPlugin(plugin.webpack())
}, },
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-registration')
})
},
function (_options, nuxt) { function (_options, nuxt) {
const routesToDuplicate = ['/async-parent', '/fixed-keyed-child-parent', '/keyed-child-parent', '/with-layout', '/with-layout2'] const routesToDuplicate = ['/async-parent', '/fixed-keyed-child-parent', '/keyed-child-parent', '/with-layout', '/with-layout2']
const stripLayout = (page: NuxtPage): NuxtPage => ({ const stripLayout = (page: NuxtPage): NuxtPage => ({

View File

@ -1,4 +1,4 @@
export default defineNuxtPlugin((nuxtApp) => { export default definePayloadPlugin((nuxtApp) => {
definePayloadReducer('BlinkingText', data => data === '<original-blink>' && '_') definePayloadReducer('BlinkingText', data => data === '<original-blink>' && '_')
definePayloadReviver('BlinkingText', () => '<revivified-blink>') definePayloadReviver('BlinkingText', () => '<revivified-blink>')
if (process.server) { if (process.server) {