Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"dev": "nuxt dev playground",
"dev:ssl": "nuxt dev playground --https",
"dev:prepare": "pnpm -r dev:prepare && nuxt prepare && nuxt prepare playground && pnpm prepare:fixtures",
"prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn && nuxt prepare test/fixtures/tiktok-pixel && nuxt prepare test/fixtures/calendly && nuxt prepare test/fixtures/calendly-cdn && nuxt prepare test/fixtures/ahrefs-analytics && nuxt prepare test/fixtures/ahrefs-analytics-cdn && nuxt prepare test/fixtures/usercentrics && nuxt prepare test/fixtures/speedcurve && nuxt prepare test/fixtures/maplibre && nuxt prepare test/fixtures/map-hydration",
"prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn && nuxt prepare test/fixtures/tiktok-pixel && nuxt prepare test/fixtures/calendly && nuxt prepare test/fixtures/calendly-cdn && nuxt prepare test/fixtures/ahrefs-analytics && nuxt prepare test/fixtures/ahrefs-analytics-cdn && nuxt prepare test/fixtures/usercentrics && nuxt prepare test/fixtures/speedcurve && nuxt prepare test/fixtures/maplibre && nuxt prepare test/fixtures/map-hydration && nuxt prepare test/fixtures/script-status-hydration",
"typecheck": "pnpm --filter @nuxt/scripts-cli typecheck && nuxt typecheck",
"release": "pnpm build && bumpp -r --output=CHANGELOG.md",
"lint": "eslint .",
Expand Down
29 changes: 29 additions & 0 deletions packages/script/src/runtime/composables/useScript.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { UseScriptInput, UseScriptOptions, VueScriptInstance, VueScriptScope } from '@unhead/vue/scripts'
import type { ScriptInstance } from 'unhead/scripts'
import type { NuxtDevToolsNetworkRequest, NuxtDevToolsScriptInstance, NuxtUseScriptOptions, UseFunctionType, UseScriptContext } from '../types'
import type { ServerScriptStatuses } from '../utils/hydration-status'
import { useScript as _useScript } from '@unhead/vue/scripts'
import { defu } from 'defu'
import { injectHead, onNuxtReady, useHead, useNuxtApp, useRuntimeConfig } from 'nuxt/app'
Expand All @@ -10,6 +11,7 @@ import { resolveTrigger } from '#build/nuxt-scripts-trigger-resolver'
import { debugEnabled } from '../debug'
import { logger } from '../logger'
import { createAbortError } from '../utils/abortable-promise'
import { createHydrationStatus, SCRIPT_STATUS_PAYLOAD_KEY } from '../utils/hydration-status'

type NuxtScriptsApp = ReturnType<typeof useNuxtApp> & {
$scripts: Record<string, UseScriptContext<any> | undefined>
Expand Down Expand Up @@ -346,6 +348,17 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
if (sharedInstance[NUXT_SCRIPT_CONTROLLER])
return instance as UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>>

if (import.meta.client && nuxtApp.isHydrating && nuxtApp.payload.serverRendered) {
// A client trigger changes the live status during setup, before hydration
// compares the DOM. Render the server status until hydration ends. The
// trigger and the loader still run now, so load timing is unchanged.
const serverStatuses = nuxtApp.payload[SCRIPT_STATUS_PAYLOAD_KEY] as ServerScriptStatuses | undefined
const hydrationStatus = createHydrationStatus(sharedInstance.status, serverStatuses?.[id] || 'awaitingLoad')
// Unhead's Vue wrapper reads `_statusRef` on every `status` access and writes each update to it.
;(sharedInstance as { _statusRef?: unknown })._statusRef = hydrationStatus.status
nuxtApp.hooks.hookOnce('app:suspense:resolve', hydrationStatus.release)
}

const publicStatus = instance.status
let currentScript = sharedInstance as ScriptInstance<any>
const appInstance = Object.create(sharedInstance) as UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>>
Expand Down Expand Up @@ -444,6 +457,22 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
return reloadPromise
}
nuxtApp.$scripts[id] = appInstance

if (import.meta.server) {
// The client hydrates against the status the server rendered.
const recordServerStatus = () => {
if (sharedInstance.status === 'awaitingLoad')
return
const statuses = (nuxtApp.payload[SCRIPT_STATUS_PAYLOAD_KEY] ||= {}) as ServerScriptStatuses
statuses[id] = sharedInstance.status
}
recordServerStatus()
addCleanup(headHooks.hook('script:updated', ({ script }) => {
if (script === sharedInstance)
recordServerStatus()
}))
}

addCleanup(nuxtApp.hooks.hook('app:unmount' as any, () => {
sharedInstance.remove()
}))
Expand Down
58 changes: 58 additions & 0 deletions packages/script/src/runtime/utils/hydration-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { UseScriptStatus } from 'unhead/scripts'
import type { Ref } from 'vue'
import { customRef } from 'vue'

/**
* Payload key for the script statuses the server rendered.
* The server writes only statuses other than `awaitingLoad`, so a page whose
* scripts all wait for a client trigger adds nothing to the payload.
*/
export const SCRIPT_STATUS_PAYLOAD_KEY = '_scriptStatus'

export type ServerScriptStatuses = Record<string, UseScriptStatus>

export interface HydrationStatus {
/** Reports the server status until `release()`, then the live status. */
status: Ref<UseScriptStatus>
release: () => void
}

/**
* Create a status ref that agrees with the server-rendered HTML while the app hydrates.
*
* A client trigger can change the live status during setup, before hydration
* compares the DOM. The live status still updates underneath, so the loader
* starts at the same moment. Only the value that rendering and watchers read
* waits for `release()`.
*/
export function createHydrationStatus(live: UseScriptStatus, server: UseScriptStatus): HydrationStatus {
let value = live
let held: UseScriptStatus | undefined = server
let notify = () => {}
const status = customRef<UseScriptStatus>((track, trigger) => {
notify = trigger
return {
get() {
track()
return held ?? value
},
set(next) {
const previous = value
value = next
if (held === undefined && next !== previous)
trigger()
},
}
})
return {
status,
release() {
if (held === undefined)
return
const shown = held
held = undefined
if (value !== shown)
notify()
},
}
}
52 changes: 52 additions & 0 deletions test/e2e/script-status-hydration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createResolver } from '@nuxt/kit'
import { $fetch, createPage, setup, url } from '@nuxt/test-utils/e2e'
import { describe, expect, it } from 'vitest'

const { resolve } = createResolver(import.meta.url)

/**
* A page that renders `{{ status }}` must hydrate without a mismatch for every
* trigger. Each fixture page loads `/probe.js` with one trigger, renders its
* status, and records every value a `status` watcher sees.
*/
const pages: { path: string, server: string, sequence: string[] }[] = [
{ path: '/default', server: 'awaitingLoad', sequence: ['awaitingLoad', 'loading', 'loaded'] },
{ path: '/onNuxtReady', server: 'awaitingLoad', sequence: ['awaitingLoad', 'loading', 'loaded'] },
{ path: '/client', server: 'awaitingLoad', sequence: ['awaitingLoad', 'loading', 'loaded'] },
{ path: '/registry-client', server: 'awaitingLoad', sequence: ['awaitingLoad', 'loading', 'loaded'] },
{ path: '/visible', server: 'awaitingLoad', sequence: ['awaitingLoad', 'loading', 'loaded'] },
{ path: '/server', server: 'loading', sequence: ['loading', 'loaded'] },
{ path: '/manual', server: 'awaitingLoad', sequence: ['awaitingLoad'] },
]

describe('script status hydration', { timeout: 120000 }, async () => {
await setup({
rootDir: resolve('../fixtures/script-status-hydration'),
browser: true,
})

it.each(pages)('hydrates $path without a mismatch', async ({ path, server, sequence }) => {
const html = await $fetch<string>(path)
expect(html).toContain(`<div id="status">${server}</div>`)

const page = await createPage()
const messages: string[] = []
page.on('console', message => messages.push(`${message.type()}: ${message.text()}`))
page.on('pageerror', error => messages.push(`pageerror: ${error.message}`))
await page.goto(url(path), { waitUntil: 'hydration' })

const name = path.slice(1)
const final = sequence.at(-1)!
await page.waitForFunction(
([name, final]) => (window as any).__statusLog?.[name]?.at(-1) === final,
[name, final] as const,
{ timeout: 10000 },
)

expect(messages.filter(message => /hydrat|mismatch/i.test(message))).toEqual([])
// A watcher on `status` still sees every transition, in order.
expect(await page.evaluate(name => (window as any).__statusLog[name], name)).toEqual(sequence)
expect(await page.textContent('#status')).toBe(final)
await page.close()
})
})
3 changes: 3 additions & 0 deletions test/fixtures/script-status-hydration/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<NuxtPage />
</template>
16 changes: 16 additions & 0 deletions test/fixtures/script-status-hydration/composables/useStatusLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Ref } from 'vue'
import { watch } from 'vue'

declare global {
interface Window {
__statusLog?: Record<string, string[]>
}
}

/** Record every value a `status` watcher sees, so a test can read the sequence. */
export function useStatusLog(name: string, status: Ref<string>) {
if (import.meta.server)
return
const log = ((window.__statusLog ||= {})[name] ||= [])
watch(status, value => log.push(value), { immediate: true })
}
10 changes: 10 additions & 0 deletions test/fixtures/script-status-hydration/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineNuxtConfig } from 'nuxt/config'

export default defineNuxtConfig({
modules: [
'@nuxt/scripts',
],
// Log the mismatched node and both values, not only the summary line.
debug: { hydration: true },
compatibilityDate: '2024-07-05',
})
1 change: 1 addition & 0 deletions test/fixtures/script-status-hydration/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
8 changes: 8 additions & 0 deletions test/fixtures/script-status-hydration/pages/client.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script setup lang="ts">
const { status } = useScript('/probe.js?trigger=client', { trigger: 'client' })
useStatusLog('client', status)
</script>

<template>
<div id="status">{{ status }}</div>
</template>
8 changes: 8 additions & 0 deletions test/fixtures/script-status-hydration/pages/default.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script setup lang="ts">
const { status } = useScript('/probe.js?trigger=default')
useStatusLog('default', status)
</script>

<template>
<div id="status">{{ status }}</div>
</template>
8 changes: 8 additions & 0 deletions test/fixtures/script-status-hydration/pages/manual.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script setup lang="ts">
const { status } = useScript('/probe.js?trigger=manual', { trigger: 'manual' })
useStatusLog('manual', status)
</script>

<template>
<div id="status">{{ status }}</div>
</template>
8 changes: 8 additions & 0 deletions test/fixtures/script-status-hydration/pages/onNuxtReady.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script setup lang="ts">
const { status } = useScript('/probe.js?trigger=onNuxtReady', { trigger: 'onNuxtReady' })
useStatusLog('onNuxtReady', status)
</script>

<template>
<div id="status">{{ status }}</div>
</template>
13 changes: 13 additions & 0 deletions test/fixtures/script-status-hydration/pages/registry-client.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script setup lang="ts">
// The Vercel Analytics registry fixes its trigger to `client`. The local src
// keeps the test off the network.
const { status } = useScriptVercelAnalytics({
scriptInput: { src: '/probe.js?trigger=registry-client' },
scriptOptions: { bundle: false },
})
useStatusLog('registry-client', status)
</script>

<template>
<div id="status">{{ status }}</div>
</template>
8 changes: 8 additions & 0 deletions test/fixtures/script-status-hydration/pages/server.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script setup lang="ts">
const { status } = useScript('/probe.js?trigger=server', { trigger: 'server' })
useStatusLog('server', status)
</script>

<template>
<div id="status">{{ status }}</div>
</template>
11 changes: 11 additions & 0 deletions test/fixtures/script-status-hydration/pages/visible.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<script setup lang="ts">
const el = ref<HTMLElement>()
const { status } = useScript('/probe.js?trigger=visible', {
trigger: useScriptTriggerElement({ trigger: 'visible', el }),
})
useStatusLog('visible', status)
</script>

<template>
<div id="status" ref="el">{{ status }}</div>
</template>
1 change: 1 addition & 0 deletions test/fixtures/script-status-hydration/public/probe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
window.__probeLoaded = (window.__probeLoaded || 0) + 1
3 changes: 3 additions & 0 deletions test/fixtures/script-status-hydration/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}
50 changes: 50 additions & 0 deletions test/unit/hydration-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { watch } from 'vue'
import { createHydrationStatus } from '../../packages/script/src/runtime/utils/hydration-status'

describe('createHydrationStatus', () => {
it('reports the server status while the live status changes', () => {
const { status } = createHydrationStatus('loading', 'awaitingLoad')
expect(status.value).toBe('awaitingLoad')

status.value = 'loaded'
expect(status.value).toBe('awaitingLoad')
})

it('reports the live status after release and notifies watchers once', () => {
const { status, release } = createHydrationStatus('loading', 'awaitingLoad')
const seen: string[] = []
watch(status, value => seen.push(value), { flush: 'sync', immediate: true })

status.value = 'loaded'
release()
status.value = 'removed'

expect(seen).toEqual(['awaitingLoad', 'loaded', 'removed'])
})

it('does not notify on release when the live status equals the server status', () => {
const { status, release } = createHydrationStatus('loading', 'loading')
const seen: string[] = []
watch(status, value => seen.push(value), { flush: 'sync' })

release()
release()

expect(seen).toEqual([])
expect(status.value).toBe('loading')
})

it('notifies watchers of every live transition after release', () => {
const { status, release } = createHydrationStatus('awaitingLoad', 'awaitingLoad')
release()
const seen: string[] = []
watch(status, value => seen.push(value), { flush: 'sync' })

status.value = 'loading'
status.value = 'loading'
status.value = 'loaded'

expect(seen).toEqual(['loading', 'loaded'])
})
})
Loading