From 7bff4a1e68fe3ea9d9a8ff4de7520ca120f86704 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 17 Sep 2026 00:14:23 +1000 Subject: [PATCH 1/5] test: assert script status hydrates without a mismatch A page that renders a script status must hydrate cleanly for every trigger. The client trigger fails on main: the server renders awaitingLoad and the client renders loading. --- .pr-lens/README.md | 17 + .pr-lens/body.md | 49 +++ ...-dark-cd208b95d530dac90db1ce9b69c76579.svg | 56 +++ .pr-lens/drawn.graph.json | 343 ++++++++++++++++++ .pr-lens/graph.json | 63 ++++ .pr-lens/manifest.json | 40 ++ ...-dark-79873f920d4104d8768aa42c818b5533.svg | 56 +++ package.json | 2 +- test/e2e/script-status-hydration.test.ts | 52 +++ test/fixtures/script-status-hydration/app.vue | 3 + .../composables/useStatusLog.ts | 16 + .../script-status-hydration/nuxt.config.ts | 10 + .../script-status-hydration/package.json | 1 + .../script-status-hydration/pages/client.vue | 8 + .../script-status-hydration/pages/default.vue | 8 + .../script-status-hydration/pages/manual.vue | 8 + .../pages/onNuxtReady.vue | 8 + .../pages/registry-client.vue | 13 + .../script-status-hydration/pages/server.vue | 8 + .../script-status-hydration/pages/visible.vue | 11 + .../script-status-hydration/public/probe.js | 1 + .../script-status-hydration/tsconfig.json | 3 + 22 files changed, 775 insertions(+), 1 deletion(-) create mode 100644 .pr-lens/README.md create mode 100644 .pr-lens/body.md create mode 100644 .pr-lens/client-trigger-view-dark-cd208b95d530dac90db1ce9b69c76579.svg create mode 100644 .pr-lens/drawn.graph.json create mode 100644 .pr-lens/graph.json create mode 100644 .pr-lens/manifest.json create mode 100644 .pr-lens/overview-dark-79873f920d4104d8768aa42c818b5533.svg create mode 100644 test/e2e/script-status-hydration.test.ts create mode 100644 test/fixtures/script-status-hydration/app.vue create mode 100644 test/fixtures/script-status-hydration/composables/useStatusLog.ts create mode 100644 test/fixtures/script-status-hydration/nuxt.config.ts create mode 100644 test/fixtures/script-status-hydration/package.json create mode 100644 test/fixtures/script-status-hydration/pages/client.vue create mode 100644 test/fixtures/script-status-hydration/pages/default.vue create mode 100644 test/fixtures/script-status-hydration/pages/manual.vue create mode 100644 test/fixtures/script-status-hydration/pages/onNuxtReady.vue create mode 100644 test/fixtures/script-status-hydration/pages/registry-client.vue create mode 100644 test/fixtures/script-status-hydration/pages/server.vue create mode 100644 test/fixtures/script-status-hydration/pages/visible.vue create mode 100644 test/fixtures/script-status-hydration/public/probe.js create mode 100644 test/fixtures/script-status-hydration/tsconfig.json diff --git a/.pr-lens/README.md b/.pr-lens/README.md new file mode 100644 index 000000000..c9d9b8219 --- /dev/null +++ b/.pr-lens/README.md @@ -0,0 +1,17 @@ +# .pr-lens + +PR Lens writes its previews here: the diagrams as light and dark SVGs, the +document they were drawn from, and the manifest describing them. + +None of that belongs in a commit. Those files are rebuilt from the diff by +`pr-lens analyze` and `pr-lens render`, so a stale copy in the history is +worth less than nothing โ€” it is a diagram of a pull request somebody already +merged. What readers are meant to see is the comment on the pull request, or +the share page it links to. Delete them whenever you like; nothing reads them +back. + +`canvas.json` is the exception. It holds the write token for every canvas +this checkout has pushed with `pr-lens canvas push`, and nothing can rebuild +it. Without it the canvases stay readable by everyone, but pushing to them +again needs the edit link you were given. Keep it out of commits and out of +other people's hands. diff --git a/.pr-lens/body.md b/.pr-lens/body.md new file mode 100644 index 000000000..8631c1162 --- /dev/null +++ b/.pr-lens/body.md @@ -0,0 +1,49 @@ +### ๐Ÿ“š Description + +If a page renders `{{ status }}` from a script with `trigger: 'client'`, hydration reports a mismatch. The server renders `awaitingLoad`, and the client renders `loading`. Registry scripts that fix their trigger to `client` hit this with no config: Vercel Analytics, Cloudflare Web Analytics, SpeedCurve, and npm-mode PostHog. #918's sweep found it on the `basic` and `speedcurve` fixtures (PC-20). + +Unhead runs a `client` trigger synchronously inside `useScript()`, so `load()` sets `loading` during setup. The default `onNuxtReady` trigger, `visible`, `manual`, and `server` do not mismatch. They either change status after hydration or change it the same way on both sides. + +The server now writes any status other than `awaitingLoad` to the payload. The client status ref reports that value until `app:suspense:resolve`, then shows the live status. The trigger and loader still run at the same moment. Only what templates and watchers read waits. + +![Architecture: the server records the script status in the payload, and the client status ref reports it until hydration ends](.pr-lens/overview-dark-79873f920d4104d8768aa42c818b5533.svg) + +I first computed the server status from the trigger instead of using the payload. That breaks when setup also calls `load()`. A status ref cannot tell those explicit calls apart from trigger loads, because Unhead calls the same `script.load`. The payload costs nothing unless a script loads on the server. + +Load timing, measured on production builds, alternating before and after in headless Chromium. Values are medians in ms from navigation start, 20 runs (30 for `client` at 4x CPU): + +| Page | CPU | `load()` called | ` + + 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" +} From acc3f925b7d0ce97d5a98649b38a069561459280 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 17 Sep 2026 00:14:24 +1000 Subject: [PATCH 2/5] fix(useScript): render the server status until hydration ends A client trigger calls load() during setup, so the status ref starts at loading while the server rendered awaitingLoad. The server now records non-default statuses in the payload. During hydration the status ref reports that value and releases it on app:suspense:resolve. The trigger and loader still run at the same moment. --- .../src/runtime/composables/useScript.ts | 29 ++++++++++ .../src/runtime/utils/hydration-status.ts | 58 +++++++++++++++++++ test/unit/hydration-status.test.ts | 50 ++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 packages/script/src/runtime/utils/hydration-status.ts create mode 100644 test/unit/hydration-status.test.ts 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/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']) + }) +}) From 9aec534e539caf0dcf468683c1a85f1210fce864 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 21 Sep 2026 23:48:46 +1000 Subject: [PATCH 3/5] test: use setupFixture for the hydration e2e suite --- test/e2e/script-status-hydration.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/e2e/script-status-hydration.test.ts b/test/e2e/script-status-hydration.test.ts index 562c3f5a5..cf2e72497 100644 --- a/test/e2e/script-status-hydration.test.ts +++ b/test/e2e/script-status-hydration.test.ts @@ -1,6 +1,7 @@ import { createResolver } from '@nuxt/kit' -import { $fetch, createPage, setup, url } from '@nuxt/test-utils/e2e' +import { $fetch, createPage, url } from '@nuxt/test-utils/e2e' import { describe, expect, it } from 'vitest' +import { setupFixture } from '../utils/setup-fixture' const { resolve } = createResolver(import.meta.url) @@ -20,7 +21,7 @@ const pages: { path: string, server: string, sequence: string[] }[] = [ ] describe('script status hydration', { timeout: 120000 }, async () => { - await setup({ + await setupFixture({ rootDir: resolve('../fixtures/script-status-hydration'), browser: true, }) From 6335059b33be358a080e09522241d70f53265cab Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 22 Sep 2026 00:06:17 +1000 Subject: [PATCH 4/5] chore: stop tracking PR Lens previews --- .gitignore | 3 + .pr-lens/README.md | 17 - .pr-lens/body.md | 49 --- ...-dark-cd208b95d530dac90db1ce9b69c76579.svg | 56 --- .pr-lens/drawn.graph.json | 343 ------------------ .pr-lens/graph.json | 63 ---- .pr-lens/manifest.json | 40 -- ...-dark-79873f920d4104d8768aa42c818b5533.svg | 56 --- 8 files changed, 3 insertions(+), 624 deletions(-) delete mode 100644 .pr-lens/README.md delete mode 100644 .pr-lens/body.md delete mode 100644 .pr-lens/client-trigger-view-dark-cd208b95d530dac90db1ce9b69c76579.svg delete mode 100644 .pr-lens/drawn.graph.json delete mode 100644 .pr-lens/graph.json delete mode 100644 .pr-lens/manifest.json delete mode 100644 .pr-lens/overview-dark-79873f920d4104d8768aa42c818b5533.svg diff --git a/.gitignore b/.gitignore index 9102bc815..03c39c464 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ test/fixtures/**/sw-status.json # Package size analysis base checkout .benchmark + +# PR Lens writes its previews here. They are rebuilt on demand. +.pr-lens/ diff --git a/.pr-lens/README.md b/.pr-lens/README.md deleted file mode 100644 index c9d9b8219..000000000 --- a/.pr-lens/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# .pr-lens - -PR Lens writes its previews here: the diagrams as light and dark SVGs, the -document they were drawn from, and the manifest describing them. - -None of that belongs in a commit. Those files are rebuilt from the diff by -`pr-lens analyze` and `pr-lens render`, so a stale copy in the history is -worth less than nothing โ€” it is a diagram of a pull request somebody already -merged. What readers are meant to see is the comment on the pull request, or -the share page it links to. Delete them whenever you like; nothing reads them -back. - -`canvas.json` is the exception. It holds the write token for every canvas -this checkout has pushed with `pr-lens canvas push`, and nothing can rebuild -it. Without it the canvases stay readable by everyone, but pushing to them -again needs the edit link you were given. Keep it out of commits and out of -other people's hands. diff --git a/.pr-lens/body.md b/.pr-lens/body.md deleted file mode 100644 index 8631c1162..000000000 --- a/.pr-lens/body.md +++ /dev/null @@ -1,49 +0,0 @@ -### ๐Ÿ“š Description - -If a page renders `{{ status }}` from a script with `trigger: 'client'`, hydration reports a mismatch. The server renders `awaitingLoad`, and the client renders `loading`. Registry scripts that fix their trigger to `client` hit this with no config: Vercel Analytics, Cloudflare Web Analytics, SpeedCurve, and npm-mode PostHog. #918's sweep found it on the `basic` and `speedcurve` fixtures (PC-20). - -Unhead runs a `client` trigger synchronously inside `useScript()`, so `load()` sets `loading` during setup. The default `onNuxtReady` trigger, `visible`, `manual`, and `server` do not mismatch. They either change status after hydration or change it the same way on both sides. - -The server now writes any status other than `awaitingLoad` to the payload. The client status ref reports that value until `app:suspense:resolve`, then shows the live status. The trigger and loader still run at the same moment. Only what templates and watchers read waits. - -![Architecture: the server records the script status in the payload, and the client status ref reports it until hydration ends](.pr-lens/overview-dark-79873f920d4104d8768aa42c818b5533.svg) - -I first computed the server status from the trigger instead of using the payload. That breaks when setup also calls `load()`. A status ref cannot tell those explicit calls apart from trigger loads, because Unhead calls the same `script.load`. The payload costs nothing unless a script loads on the server. - -Load timing, measured on production builds, alternating before and after in headless Chromium. Values are medians in ms from navigation start, 20 runs (30 for `client` at 4x CPU): - -| Page | CPU | `load()` called | ` + + diff --git a/test/fixtures/script-status-hydration/pages/lazy-hydration.vue b/test/fixtures/script-status-hydration/pages/lazy-hydration.vue new file mode 100644 index 000000000..f93924883 --- /dev/null +++ b/test/fixtures/script-status-hydration/pages/lazy-hydration.vue @@ -0,0 +1,9 @@ +