Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
7 changes: 6 additions & 1 deletion docs/content/docs/1.getting-started/2.installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ description: Install Nuxt Scripts in an existing Nuxt project.

## Quick Start

Nuxt Scripts 1.x requires Nuxt 3.16 or newer.
Nuxt Scripts 2 requires Nuxt 4.5 or newer and Unhead 3.2 or newer. Upgrade an
existing project before installing:

```bash
npx nuxi@latest upgrade --force
```

Run:

Expand Down
29 changes: 27 additions & 2 deletions docs/content/docs/3.api/1.use-script.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ Unhead's [complete script example](https://unhead.unjs.io/docs/head/guides/core-

Nuxt Scripts extends Unhead's [script triggers and warmup options](https://unhead.unjs.io/docs/head/guides/core-concepts/loading-scripts/#how-do-i-control-when-scripts-load) with these options:

- `use` - The function to resolve the script.
- `resolve` - Resolve the loaded SDK with lifecycle-bound `signal` and `waitFor` helpers.
- `use` - Legacy synchronous or async SDK resolver. Prefer `resolve` for callback-based readiness.
- `trigger` - [Triggering Script Loading](/docs/guides/script-triggers)
- `bundle` - Control [first-party bundling](/docs/guides/first-party).
- `proxy` - Enable or disable supported collection proxying for a registry script.
Expand Down Expand Up @@ -119,10 +120,34 @@ Outside the Partytown path, the returned object includes:
- `proxy` - A typed proxy that queues calls until loading finishes
- `status` - Reactive ref with the script status: `'awaitingLoad'` | `'loading'` | `'loaded'` | `'error'` | `'removed'`
- `load()`{lang="ts"} - Function to manually load the script
- `remove()`{lang="ts"} - Function to remove the script from the DOM
- `signal` - An `AbortSignal` scoped to this composable consumer
- `dispose()`{lang="ts"} - Release this consumer's callbacks and triggers without removing the shared script
- `script` - The shared Unhead script instance
- `remove()`{lang="ts"} - Remove the shared script for every consumer
- `reload()`{lang="ts"} - Function to remove and reload the script (see below)
- `onLoaded()`{lang="ts"} and `onError()`{lang="ts"} - Script lifecycle callbacks

Each call receives its own consumer scope. Vue disposes that scope when its
component unmounts, while the shared script remains available to other callers.
Call `remove()`{lang="ts"} only to remove the script globally.

### Lifecycle-aware SDK readiness

Use `resolve({ waitFor })`{lang="ts"} when a vendor exposes a callback that fires
after the script element's `load` event. Listener cleanup and abort rejection are
then tied to the shared script lifecycle.

```ts
const sdk = useScript<{ ready: true }>('https://example.com/sdk.js', {
resolve: ({ waitFor }) => waitFor<{ ready: true }>((resolve) => {
window.onExampleReady = () => resolve({ ready: true })
return () => delete window.onExampleReady
}),
})

const api = await sdk.load()
```

### `reload()`{lang="ts"}

Removes the script, inserts it again, and re-executes it. Use this for a script that scans the DOM once and must scan again after SPA navigation.
Expand Down
68 changes: 68 additions & 0 deletions docs/content/docs/4.migration-guide/2.v1-to-v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
title: v1 to v2
description: Migration guide for upgrading from Nuxt Scripts v1.x to v2.0.
---

Nuxt Scripts 2 moves script ownership and SDK readiness onto the lifecycle APIs
introduced in Unhead 3.2. This removes component callbacks and trigger listeners
as soon as their consumer unmounts, without tearing down a script still used by
other components.

## Requirements

| Dependency | Required version |
|---|---|
| Nuxt | `>=4.5.0` |
| `@unhead/vue` | `>=3.2.0 <4` |
| `unhead` | `>=3.2.0 <4` |

Upgrade Nuxt and refresh its locked dependencies before installing v2:

```bash
npx nuxi@latest upgrade --force
```

The module now stops setup with an actionable error when either Unhead package
is missing or outside the supported range.

## Consumer scopes

Every `useScript()`{lang="ts"} call now returns an Unhead consumer scope.
Component unmount automatically releases callbacks and trigger listeners owned
by that call.

- `dispose()`{lang="ts"} releases only the current consumer.
- `signal` aborts when you dispose that consumer or any caller removes the shared script.
- `script` points to the shared script instance.
- `remove()`{lang="ts"} still removes the shared script for all consumers.

If application code used `remove()`{lang="ts"} as component cleanup, switch it
to `dispose()`{lang="ts"}. In Vue components, manual cleanup is normally no
longer necessary.

## Custom readiness callbacks

The legacy `use` option remains supported. Callback-driven SDKs should migrate
to `resolve({ waitFor })`{lang="ts"}, which automatically removes listeners and
rejects pending readiness when the script lifecycle ends.

```diff
const script = useScript('https://example.com/sdk.js', {
- use: () => readyPromise.then(() => window.example),
+ resolve: ({ waitFor }) => waitFor((resolve) => {
+ window.onExampleReady = () => resolve(window.example)
+ return () => delete window.onExampleReady
+ }),
})
```

The bundled Google Maps, YouTube Player, Crisp, and Usercentrics integrations
now use this API. `load()`{lang="ts"} resolves only after each vendor's concrete
SDK API is ready.

## Script triggers

Nuxt's idle-timeout, interaction, and service-worker helpers now return Unhead
trigger functions. Existing `scriptOptions.trigger` usage is unchanged. Custom
trigger functions may return a cleanup callback; Unhead calls it when the
consumer scope is disposed.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@
"@types/google.maps": "catalog:",
"@types/jest-image-snapshot": "catalog:",
"@types/node": "catalog:",
"@types/semver": "catalog:",
"@types/youtube": "catalog:",
"@unhead/vue": "catalog:",
"@vue/test-utils": "catalog:",
"bumpp": "catalog:",
"defu": "catalog:",
Expand All @@ -59,6 +61,7 @@
"typescript": "catalog:",
"ufo": "catalog:",
"ultrahtml": "catalog:",
"unhead": "catalog:",
"vitest": "catalog:",
"vue": "catalog:",
"vue-tsc": "catalog:"
Expand Down
115 changes: 87 additions & 28 deletions packages/devtools-app/composables/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import type { $Fetch } from 'nitropack/types'
import type { Ref } from 'vue'
import { onDevtoolsClientConnected } from '@nuxt/devtools-kit/iframe-client'
import { ofetch } from 'ofetch'
import { onScopeDispose, ref, watch, watchEffect } from 'vue'
import { ref, watch, watchEffect } from 'vue'
import { firstPartyData, isConnected, path, query, refreshSources, standaloneUrl, syncScripts, version } from './state'

export const appFetch: Ref<$Fetch | undefined> = ref()
export const devtools: Ref<NuxtDevtoolsClient | undefined> = ref()
export const colorMode: Ref<'dark' | 'light'> = ref('dark')

export interface DevtoolsConnectionOptions {
onConnected?: (client: any) => void
onConnected?: (client: any) => void | (() => void)
onRouteChange?: (route: any) => void
}

Expand All @@ -27,66 +27,111 @@ const STANDALONE_POLL_INTERVAL = 2000
* - **Embedded**: running inside Nuxt DevTools iframe (automatic)
* - **Standalone**: running directly in a browser tab with a manual dev server URL
*/
export function useDevtoolsConnection(options: DevtoolsConnectionOptions = {}): void {
export function useDevtoolsConnection(options: DevtoolsConnectionOptions = {}): () => void {
const inIframe = window.parent !== window
let disposed = false
const connectionCleanups: Array<() => void> = []
let pollTimer: ReturnType<typeof setInterval> | undefined
let pollController: AbortController | undefined

const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = undefined
}
pollController?.abort()
pollController = undefined
}

const cleanupConnection = () => {
connectionCleanups.splice(0).forEach(cleanup => cleanup())
devtools.value = undefined
appFetch.value = undefined
isConnected.value = false
}

// Embedded mode: connect via devtools-kit iframe client
let stopClientConnection = () => {}
if (inIframe) {
onDevtoolsClientConnected(async (client) => {
stopClientConnection = onDevtoolsClientConnected((client) => {
if (disposed)
return
stopPolling()
cleanupConnection()
isConnected.value = true
// @ts-expect-error untyped
appFetch.value = client.host.app.$fetch
watchEffect(() => {
connectionCleanups.push(watchEffect(() => {
colorMode.value = client.host.app.colorMode.value
})
}))
devtools.value = client.devtools
options.onConnected?.(client)
const cleanupConnected = options.onConnected?.(client)
if (cleanupConnected)
connectionCleanups.push(cleanupConnected)

if (options.onRouteChange) {
const $route = client.host.nuxt.vueApp.config.globalProperties?.$route
options.onRouteChange($route)
const removeAfterEach = client.host.nuxt.$router.afterEach((route: any) => {
options.onRouteChange!(route)
})
// Clean up when devtools client disconnects
// @ts-expect-error app:unmount exists at runtime but is not in RuntimeNuxtHooks
client.host.nuxt.hook('app:unmount', removeAfterEach)
connectionCleanups.push(removeAfterEach)
}
})
// @ts-expect-error app:unmount exists at runtime but is not in RuntimeNuxtHooks
connectionCleanups.push(client.host.nuxt.hook('app:unmount', cleanupConnection))
}) || (() => {})
}

// Standalone mode: create appFetch from manually entered URL and poll for state
let pollTimer: ReturnType<typeof setInterval> | undefined
const poll = async (url: string) => {
// A slow/unreachable app must not accumulate overlapping interval requests.
if (pollController)
return
const controller = new AbortController()
pollController = controller
try {
await pollStandaloneState(url, controller.signal)
}
finally {
if (pollController === controller)
pollController = undefined
}
}

watch(() => standaloneUrl.value, (url) => {
const stopStandaloneWatch = watch(() => standaloneUrl.value, (url) => {
// Clean up previous polling
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = undefined
}
stopPolling()

if (url && !isConnected.value) {
appFetch.value = ofetch.create({ baseURL: url }) as unknown as $Fetch
// Use system color scheme preference
colorMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
refreshSources()
// Start polling the standalone API for script state
pollStandaloneState(url)
pollTimer = setInterval(pollStandaloneState, STANDALONE_POLL_INTERVAL, url)
void poll(url)
pollTimer = setInterval(() => void poll(url), STANDALONE_POLL_INTERVAL)
}
}, { immediate: true })

onScopeDispose(() => {
if (pollTimer) {
clearInterval(pollTimer)
}
})
return () => {
if (disposed)
return
disposed = true
stopPolling()
stopStandaloneWatch()
stopClientConnection()
cleanupConnection()
}
}

async function pollStandaloneState(baseUrl: string) {
async function pollStandaloneState(baseUrl: string, signal: AbortSignal) {
const timeoutController = new AbortController()
const timeout = setTimeout(() => timeoutController.abort(), 3000)
const onAbort = () => timeoutController.abort()
signal.addEventListener('abort', onAbort, { once: true })
try {
const res = await fetch(`${baseUrl}${STANDALONE_API_PATH}`, {
signal: AbortSignal.timeout(3000),
signal: timeoutController.signal,
})
if (!res.ok)
return
Expand All @@ -106,15 +151,29 @@ async function pollStandaloneState(baseUrl: string) {
catch {
// Standalone API not available or not enabled, silently ignore
}
finally {
clearTimeout(timeout)
signal.removeEventListener('abort', onAbort)
}
}

useDevtoolsConnection({
const disposeConnection = useDevtoolsConnection({
onConnected: (client) => {
client.host.nuxt.hooks.hook('scripts:updated', (ctx: any) => {
const stopScriptsHook = client.host.nuxt.hooks.hook('scripts:updated', (ctx: any) => {
syncScripts(ctx.scripts)
})
version.value = client.host.nuxt.$config.public['nuxt-scripts'].version
firstPartyData.value = client.host.nuxt.$config.public['nuxt-scripts-devtools'] || null
syncScripts(client.host.nuxt._scripts || {})
return stopScriptsHook
},
})

function disposeModuleConnection() {
window.removeEventListener('beforeunload', disposeModuleConnection)
disposeConnection()
}

window.addEventListener('beforeunload', disposeModuleConnection, { once: true })
if (import.meta.hot)
import.meta.hot.dispose(disposeModuleConnection)
Loading
Loading