Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
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 && nuxt prepare test/fixtures/production-compile",
"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/production-compile && 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
50 changes: 49 additions & 1 deletion packages/script/src/runtime/composables/useScript.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
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'
import { useScript as useUnheadScript } from 'unhead/scripts'
import { markRaw, ref } from 'vue'
import { getCurrentInstance, markRaw, onMounted, onUnmounted, ref } from 'vue'
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,36 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
if (sharedInstance[NUXT_SCRIPT_CONTROLLER])
return instance as UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>>

const ownerInstance = getCurrentInstance()
// A lazily hydrated component (e.g. `hydrate-on-visible`) hydrates after the
// app suspense resolved. Nuxt no longer reports the app as hydrating, but the
// component still compares its own server HTML, which Vue attached to the
// vnode before mounting it.
const isHydratingServerRender = import.meta.client
&& nuxtApp.payload.serverRendered
&& (nuxtApp.isHydrating || (ownerInstance != null && ownerInstance.vnode.el != null))

if (isHydratingServerRender) {
// 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
if (nuxtApp.isHydrating) {
nuxtApp.hooks.hookOnce('app:suspense:resolve', hydrationStatus.release)
}
else if (ownerInstance) {
// Late hydration has no suspense resolve left to wait for. The
// component's own hydration ends when its `mounted` hook runs.
onMounted(hydrationStatus.release)
// A component can unmount before it mounts. Without this the hold would
// stick to the shared status forever.
onUnmounted(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 +476,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()
},
}
}
34 changes: 0 additions & 34 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,40 +1,6 @@
minimumReleaseAgeExcludePrune: true
minimumReleaseAgeExclude:
- '@oxc-parser/binding-android-arm-eabi@0.134.0'
- '@oxc-parser/binding-android-arm64@0.134.0'
- '@oxc-parser/binding-darwin-arm64@0.134.0'
- '@oxc-parser/binding-darwin-x64@0.134.0'
- '@oxc-parser/binding-freebsd-x64@0.134.0'
- '@oxc-parser/binding-linux-arm-gnueabihf@0.134.0'
- '@oxc-parser/binding-linux-arm-musleabihf@0.134.0'
- '@oxc-parser/binding-linux-arm64-gnu@0.134.0'
- '@oxc-parser/binding-linux-arm64-musl@0.134.0'
- '@oxc-parser/binding-linux-ppc64-gnu@0.134.0'
- '@oxc-parser/binding-linux-riscv64-gnu@0.134.0'
- '@oxc-parser/binding-linux-riscv64-musl@0.134.0'
- '@oxc-parser/binding-linux-s390x-gnu@0.134.0'
- '@oxc-parser/binding-linux-x64-gnu@0.134.0'
- '@oxc-parser/binding-linux-x64-musl@0.134.0'
- '@oxc-parser/binding-openharmony-arm64@0.134.0'
- '@oxc-parser/binding-wasm32-wasi@0.134.0'
- '@oxc-parser/binding-win32-arm64-msvc@0.134.0'
- '@oxc-parser/binding-win32-ia32-msvc@0.134.0'
- '@oxc-parser/binding-win32-x64-msvc@0.134.0'
- '@oxc-project/types@0.134.0'
- '@posthog/core@1.30.2'
- '@posthog/types@1.378.1 || 1.409.2'
- oxc-parser@0.134.0
- posthog-js@1.378.1 || 1.428.7
- '@nuxt/kit@4.5.1'
- '@nuxt/nitro-server@4.5.1'
- '@nuxt/schema@4.5.1'
- '@nuxt/vite-builder@4.5.1'
- nuxt@4.5.1
- '@unhead/bundler@3.3.1'
- '@unhead/vue@3.3.1'
- unhead@3.3.1
- '@types/jest-image-snapshot@6.4.2'
- unimport@7.0.1

trustPolicy: no-downgrade
trustPolicyIgnoreAfter: 262800
Expand Down
79 changes: 79 additions & 0 deletions test/e2e/script-status-hydration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { Page } from 'playwright-core'
import { createResolver } from '@nuxt/kit'
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)

/**
* 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
name?: string
server: string
final: string
hydrate?: (page: Page) => Promise<void>
}[] = [
{ path: '/default', server: 'awaitingLoad', final: 'loaded' },
{ path: '/onNuxtReady', server: 'awaitingLoad', final: 'loaded' },
{ path: '/client', server: 'awaitingLoad', final: 'loaded' },
{ path: '/registry-client', server: 'awaitingLoad', final: 'loaded' },
{ path: '/visible', server: 'awaitingLoad', final: 'loaded' },
{
path: '/lazy-hydration',
name: 'lazy-client',
server: 'awaitingLoad',
final: 'loaded',
// Bring the lazily hydrated component into view, which starts its own late hydration.
hydrate: page => page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)),
},
{ path: '/server', server: 'loading', final: 'loaded' },
{ path: '/manual', server: 'awaitingLoad', final: 'awaitingLoad' },
]

/** The order the status moves in. An unknown status lands before `awaitingLoad`. */
const statuses = ['awaitingLoad', 'loading', 'loaded']

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

it.each(pages)('hydrates $path without a mismatch', async ({ path, name, server, final, hydrate }) => {
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' })

await hydrate?.(page)

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

expect(messages.filter(message => /hydrat|mismatch/i.test(message))).toEqual([])
// A watcher on `status` sees the server status first and the final status
// last, with any `loading` in between. Whether `loading` shows up depends
// on whether the script executes before hydration ends, so only the order
// is asserted, never an exact sequence.
const sequence = await page.evaluate(key => (window as any).__statusLog[key], key)
expect(sequence[0]).toBe(server)
expect(sequence.at(-1)).toBe(final)
for (let i = 1; i < sequence.length; i++)
expect(statuses.indexOf(sequence[i])).toBeGreaterThan(statuses.indexOf(sequence[i - 1]))
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>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script setup lang="ts">
const { status } = useScript('/probe.js?trigger=lazy-client', { trigger: 'client' })
useStatusLog('lazy-client', status)
</script>

<template>
<div id="status">{{ status }}</div>
</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>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<template>
<!--
The status renders inside a component that hydrates on visibility. Placed
below the fold, so it hydrates long after the app suspense resolved, while
the app no longer reports itself as hydrating.
-->
<div style="height: 150vh" />
<LazyStatusClient hydrate-on-visible />
</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>
Loading
Loading