Skip to content

fix(useScript): render the server status until hydration ends - #919

Open
harlan-zw wants to merge 2 commits into
mainfrom
fix/script-status-hydration
Open

harlan-zw wants to merge 2 commits into
mainfrom
fix/script-status-hydration

Conversation

@harlan-zw

Copy link
Copy Markdown
Collaborator

📚 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

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 <script> inserted hydration ends
default 1x 27.9 → 28.4 28.7 → 29.2 21.9 → 22.1
onNuxtReady 1x 28.0 → 28.0 28.7 → 28.8 21.6 → 21.7
client 1x 20.6 → 20.9 27.8 → 27.9 22.0 → 21.8
default 4x 104.0 → 106.2 107.2 → 109.3 77.8 → 80.4
onNuxtReady 4x 104.8 → 106.4 107.6 → 109.1 80.6 → 78.1
client 4x 77.9 → 76.9 105.9 → 102.5 85.0 → 80.3

With the fix, a status watcher on a client-trigger page sees awaitingLoad > loading > loaded. Before, it saw loading > loaded, because the ref was created after the status had already changed.

Loose ends:

  • A component that hydrates lazily, such as hydrate-on-visible, hydrates after app:suspense:resolve. If it renders a client-trigger status, it still mismatches. I did not find a clean per-component signal for this.
  • basic /reload-trigger (#use-call-count 0 vs 1) is a separate fixture quirk. Unhead calls use() eagerly on the client, and the page renders a counter that use() increments. I left it alone.
  • The new e2e test calls setup() directly because test(e2e): build fixtures with template comments stripped #918 has not merged. Once test(e2e): build fixtures with template comments stripped #918 lands, convert it to setupFixture().

🤖 AI disclosure: Harlan Agent Kit modified this description. My AI open-source policy.

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.
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.
@vercel

vercel Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scripts-playground Ready Ready Preview Sep 16, 2026 2:18pm UTC

Request Review

@harlan-zw

Copy link
Copy Markdown
Collaborator Author

Checked by hand, since CI cannot show it:

  • The new script-status-hydration e2e test fails on the origin/main runtime for /client and /registry-client, with rendered on server: awaitingLoad / expected on client: loading and Hydration completed but contains mismatches. (2 failed, 5 passed). It passes with the fix (7 passed).
  • On the production builds used for the timing table, the client-trigger pages logged a mismatch on 20 of 20 runs before the fix and 0 of 20 after.
  • With the fix, /server still hydrates cleanly. Its server status is loading, so it exercises the payload path.

@pkg-pr-new

pkg-pr-new Bot commented Sep 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@nuxt/scripts@919

commit: 4f52ec8

@github-actions

Copy link
Copy Markdown

📦 Package Size

⚠️ 4 size metrics grew

📚 22 runtime dependencies (no change)

Package output Gzipped Δ
@nuxt/scripts · dist/runtime 101 kB → 102 kB 🔴 +574 B (+0.6%)
@nuxt/scripts · published payload 216 kB → 217 kB 🔴 +574 B (+0.3%)
@nuxt/scripts · composables runtime 7.5 kB → 7.7 kB 🔴 +221 B (+3.0%)
@nuxt/scripts · utils runtime 2.5 kB → 2.9 kB 🔴 +353 B (+13.9%)
All tracked output (25)
Package output Gzipped Raw
@nuxt/scripts-cli · runtime dependencies 72 kB 355 kB
@nuxt/scripts-cli · dependency magicast 72 kB 355 kB
@nuxt/scripts-cli · export . 3.4 kB 12 kB
@nuxt/scripts-cli · published payload 3.4 kB 12 kB
@nuxt/scripts · runtime dependencies 436 kB 1.92 MB
@nuxt/scripts · dependency @nuxt/devtools-kit 2.9 kB 7.7 kB
@nuxt/scripts · dependency @oxc-project/types 0 B 0 B
@nuxt/scripts · dependency @vueuse/core 174 kB 707 kB
@nuxt/scripts · dependency @vueuse/shared 39 kB 154 kB
@nuxt/scripts · dependency h3 34 kB 146 kB
@nuxt/scripts · dependency semver 25 kB 72 kB
@nuxt/scripts · dependency sirv 8.8 kB 21 kB
@nuxt/scripts · dependency unstorage 70 kB 225 kB
@nuxt/scripts · dependency valibot 82 kB 590 kB
@nuxt/scripts · dist/runtime 102 kB 299 kB 🔴
@nuxt/scripts · export . 26 kB 106 kB
@nuxt/scripts · export ./registry 29 kB 90 kB
@nuxt/scripts · export ./stats 13 kB 91 kB
@nuxt/scripts · export ./types-source 47 kB 239 kB
@nuxt/scripts · published payload 217 kB 826 kB 🔴
@nuxt/scripts · components runtime 2.5 kB 6.4 kB
@nuxt/scripts · composables runtime 7.7 kB 25 kB 🔴
@nuxt/scripts · registry runtime 45 kB 133 kB
@nuxt/scripts · server runtime 29 kB 87 kB
@nuxt/scripts · utils runtime 2.9 kB 8.1 kB 🔴
Runtime dependencies (22)
Package Dependency Requested Resolved Cost
@nuxt/scripts-cli magicast ^0.5.5 0.5.5 📦 72 kB gzip
@nuxt/scripts-cli pathe ^2.0.3 2.0.3 ♻️ free via Nuxt 4.5.2
@nuxt/scripts @nuxt/devtools-kit ^3.4.2 3.4.2 📦 2.9 kB gzip
@nuxt/scripts @oxc-project/types ^0.150.0 0.150.0 📦 0 B gzip
@nuxt/scripts @vueuse/core ^14.4.0 14.4.0 📦 174 kB gzip
@nuxt/scripts @vueuse/shared ^14.4.0 14.4.0 📦 39 kB gzip
@nuxt/scripts consola ^3.4.2 3.4.2 ♻️ free via Nuxt 4.5.2
@nuxt/scripts defu ^6.1.7 6.1.7 ♻️ free via Nuxt 4.5.2
@nuxt/scripts h3 ^1.15.11 1.15.11 📦 34 kB gzip
@nuxt/scripts magic-string ^1.4.1 1.4.1 ♻️ free via Nuxt 4.5.2
@nuxt/scripts ofetch ^1.5.1 1.5.1 ♻️ free via Nuxt 4.5.2
@nuxt/scripts ohash ^2.0.12 2.0.12 ♻️ free via Nuxt 4.5.2
@nuxt/scripts oxc-walker ^1.1.1 1.1.1 ♻️ free via Nuxt 4.5.2
@nuxt/scripts pathe ^2.0.3 2.0.3 ♻️ free via Nuxt 4.5.2
@nuxt/scripts semver ^7.8.5 7.8.5 📦 25 kB gzip
@nuxt/scripts sirv ^3.0.2 3.0.2 📦 8.8 kB gzip
@nuxt/scripts std-env ^4.2.0 4.2.0 ♻️ free via Nuxt 4.5.2
@nuxt/scripts ufo ^1.6.4 1.6.4 ♻️ free via Nuxt 4.5.2
@nuxt/scripts ultrahtml ^1.7.0 1.7.0 ♻️ free via Nuxt 4.5.2
@nuxt/scripts unplugin ^3.3.0 3.3.0 ♻️ free via Nuxt 4.5.2
@nuxt/scripts unstorage ^1.17.5 1.17.5 📦 70 kB gzip
@nuxt/scripts valibot ^1.5.0 1.5.0 📦 82 kB gzip

Baseline: main_@_240a9137___2026-09-16 · gzip is the comparison metric · changes below 16 B gzip are ignored

@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: c55e3fca-8f1b-4052-afaa-1b2313f01609

📥 Commits

Reviewing files that changed from the base of the PR and between 240a913 and 4f52ec8.

📒 Files selected for processing (18)
  • package.json
  • packages/script/src/runtime/composables/useScript.ts
  • packages/script/src/runtime/utils/hydration-status.ts
  • test/e2e/script-status-hydration.test.ts
  • test/fixtures/script-status-hydration/app.vue
  • test/fixtures/script-status-hydration/composables/useStatusLog.ts
  • test/fixtures/script-status-hydration/nuxt.config.ts
  • test/fixtures/script-status-hydration/package.json
  • test/fixtures/script-status-hydration/pages/client.vue
  • test/fixtures/script-status-hydration/pages/default.vue
  • test/fixtures/script-status-hydration/pages/manual.vue
  • test/fixtures/script-status-hydration/pages/onNuxtReady.vue
  • test/fixtures/script-status-hydration/pages/registry-client.vue
  • test/fixtures/script-status-hydration/pages/server.vue
  • test/fixtures/script-status-hydration/pages/visible.vue
  • test/fixtures/script-status-hydration/public/probe.js
  • test/fixtures/script-status-hydration/tsconfig.json
  • test/unit/hydration-status.test.ts

Included review availability: Your plan provides up to 4 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The change adds hydration-aware script status handling. Server-rendered statuses are stored in the Nuxt payload and held on the client until suspense resolves. A new helper manages the held and live status values. Unit tests cover its transitions. A Nuxt fixture and end-to-end tests verify hydration behavior across multiple script triggers.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: ⚪ Minimal · up to 4f52e

The hydration status behavior is covered across server rendering, client hydration, and live transitions, with no remaining merge-blocking issue identified.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 3 functions across 7 files. (11 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: preserving the server-rendered useScript status until hydration ends. It follows the conventional commit format.
Description check ✅ Passed The description directly explains the hydration mismatch, the payload-based fix, timing behavior, known limitations, and added test coverage. It is clearly related to the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 3 functions across 7 files. (11 skipped: 11 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/script-status-hydration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@harlan-zw harlan-zw added harlan-agent-running An Agent holds a Task on this issue or pull request right now. harlan-agent-review-required Pull request triage requires an adversarial Review for this head commit. labels Sep 16, 2026
@harlan-zw

harlan-zw commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 READY · 88/100

Harlan Agent Kit posted this automated review. It is not Harlan's personal review or approval. AI open source policy. A person still decides the merge.

  • Merge gate: Passed.
  • Review gate: Passed. No material issues.
  • CI gate: Passed.

@harlan-zw harlan-zw added harlan-agent-running An Agent holds a Task on this issue or pull request right now. harlan-agent-review-required Pull request triage requires an adversarial Review for this head commit. harlan-agent-ready The automated Review passed every gate on this head commit. harlan-agent-pending The automated Review is waiting on a gate for this head commit. and removed harlan-agent-running An Agent holds a Task on this issue or pull request right now. harlan-agent-review-required Pull request triage requires an adversarial Review for this head commit. harlan-agent-ready The automated Review passed every gate on this head commit. harlan-agent-pending The automated Review is waiting on a gate for this head commit. labels Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

harlan-agent-ready The automated Review passed every gate on this head commit.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant