diff --git a/package.json b/package.json index 722ebb844..1a1dbe943 100644 --- a/package.json +++ b/package.json @@ -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 .", diff --git a/packages/script/src/runtime/composables/useScript.ts b/packages/script/src/runtime/composables/useScript.ts index d7f548a86..c4ae1aca3 100644 --- a/packages/script/src/runtime/composables/useScript.ts +++ b/packages/script/src/runtime/composables/useScript.ts @@ -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' @@ -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 & { $scripts: Record | undefined> @@ -346,6 +348,17 @@ export function useScript = Record, 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 const appInstance = Object.create(sharedInstance) as UseScriptContext, T>> @@ -444,6 +457,22 @@ export function useScript = Record { + 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() })) diff --git a/packages/script/src/runtime/utils/hydration-status.ts b/packages/script/src/runtime/utils/hydration-status.ts new file mode 100644 index 000000000..88acf5a2e --- /dev/null +++ b/packages/script/src/runtime/utils/hydration-status.ts @@ -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 + +export interface HydrationStatus { + /** Reports the server status until `release()`, then the live status. */ + status: Ref + 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((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() + }, + } +} diff --git a/test/e2e/script-status-hydration.test.ts b/test/e2e/script-status-hydration.test.ts new file mode 100644 index 000000000..562c3f5a5 --- /dev/null +++ b/test/e2e/script-status-hydration.test.ts @@ -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(path) + expect(html).toContain(`
${server}
`) + + 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() + }) +}) diff --git a/test/fixtures/script-status-hydration/app.vue b/test/fixtures/script-status-hydration/app.vue new file mode 100644 index 000000000..8f62b8bf9 --- /dev/null +++ b/test/fixtures/script-status-hydration/app.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/script-status-hydration/composables/useStatusLog.ts b/test/fixtures/script-status-hydration/composables/useStatusLog.ts new file mode 100644 index 000000000..e5a76fd79 --- /dev/null +++ b/test/fixtures/script-status-hydration/composables/useStatusLog.ts @@ -0,0 +1,16 @@ +import type { Ref } from 'vue' +import { watch } from 'vue' + +declare global { + interface Window { + __statusLog?: Record + } +} + +/** Record every value a `status` watcher sees, so a test can read the sequence. */ +export function useStatusLog(name: string, status: Ref) { + if (import.meta.server) + return + const log = ((window.__statusLog ||= {})[name] ||= []) + watch(status, value => log.push(value), { immediate: true }) +} diff --git a/test/fixtures/script-status-hydration/nuxt.config.ts b/test/fixtures/script-status-hydration/nuxt.config.ts new file mode 100644 index 000000000..49f9d1edc --- /dev/null +++ b/test/fixtures/script-status-hydration/nuxt.config.ts @@ -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', +}) diff --git a/test/fixtures/script-status-hydration/package.json b/test/fixtures/script-status-hydration/package.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/test/fixtures/script-status-hydration/package.json @@ -0,0 +1 @@ +{} diff --git a/test/fixtures/script-status-hydration/pages/client.vue b/test/fixtures/script-status-hydration/pages/client.vue new file mode 100644 index 000000000..799861207 --- /dev/null +++ b/test/fixtures/script-status-hydration/pages/client.vue @@ -0,0 +1,8 @@ + + + diff --git a/test/fixtures/script-status-hydration/pages/default.vue b/test/fixtures/script-status-hydration/pages/default.vue new file mode 100644 index 000000000..1bebcf823 --- /dev/null +++ b/test/fixtures/script-status-hydration/pages/default.vue @@ -0,0 +1,8 @@ + + + diff --git a/test/fixtures/script-status-hydration/pages/manual.vue b/test/fixtures/script-status-hydration/pages/manual.vue new file mode 100644 index 000000000..5b4754848 --- /dev/null +++ b/test/fixtures/script-status-hydration/pages/manual.vue @@ -0,0 +1,8 @@ + + + diff --git a/test/fixtures/script-status-hydration/pages/onNuxtReady.vue b/test/fixtures/script-status-hydration/pages/onNuxtReady.vue new file mode 100644 index 000000000..51ea4a31e --- /dev/null +++ b/test/fixtures/script-status-hydration/pages/onNuxtReady.vue @@ -0,0 +1,8 @@ + + + diff --git a/test/fixtures/script-status-hydration/pages/registry-client.vue b/test/fixtures/script-status-hydration/pages/registry-client.vue new file mode 100644 index 000000000..14f345ae0 --- /dev/null +++ b/test/fixtures/script-status-hydration/pages/registry-client.vue @@ -0,0 +1,13 @@ + + + diff --git a/test/fixtures/script-status-hydration/pages/server.vue b/test/fixtures/script-status-hydration/pages/server.vue new file mode 100644 index 000000000..68ef41ca0 --- /dev/null +++ b/test/fixtures/script-status-hydration/pages/server.vue @@ -0,0 +1,8 @@ + + + diff --git a/test/fixtures/script-status-hydration/pages/visible.vue b/test/fixtures/script-status-hydration/pages/visible.vue new file mode 100644 index 000000000..728142c0f --- /dev/null +++ b/test/fixtures/script-status-hydration/pages/visible.vue @@ -0,0 +1,11 @@ + + + diff --git a/test/fixtures/script-status-hydration/public/probe.js b/test/fixtures/script-status-hydration/public/probe.js new file mode 100644 index 000000000..3a064e3e4 --- /dev/null +++ b/test/fixtures/script-status-hydration/public/probe.js @@ -0,0 +1 @@ +window.__probeLoaded = (window.__probeLoaded || 0) + 1 diff --git a/test/fixtures/script-status-hydration/tsconfig.json b/test/fixtures/script-status-hydration/tsconfig.json new file mode 100644 index 000000000..4b34df157 --- /dev/null +++ b/test/fixtures/script-status-hydration/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} diff --git a/test/unit/hydration-status.test.ts b/test/unit/hydration-status.test.ts new file mode 100644 index 000000000..ad1413af9 --- /dev/null +++ b/test/unit/hydration-status.test.ts @@ -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']) + }) +})