From 031d7239d2c8252323d43721d4081ce922e6373f Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 4 Aug 2026 15:57:54 +1000 Subject: [PATCH 01/19] test: cover non-breaking script lifecycle --- test/unit/use-script-lifecycle.test.ts | 214 +++++++++++++++++++++++++ vitest.config.ts | 2 + 2 files changed, 216 insertions(+) create mode 100644 test/unit/use-script-lifecycle.test.ts diff --git a/test/unit/use-script-lifecycle.test.ts b/test/unit/use-script-lifecycle.test.ts new file mode 100644 index 00000000..ec31e7e5 --- /dev/null +++ b/test/unit/use-script-lifecycle.test.ts @@ -0,0 +1,214 @@ +/** + * @vitest-environment happy-dom + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { useScript } from '../../packages/script/src/runtime/composables/useScript' + +const mocks = vi.hoisted(() => { + const baseRemove = vi.fn(() => true) + const baseLoad = vi.fn(() => Promise.resolve({})) + const shared = { + id: 'https://example.com/sdk.js', + status: { value: 'awaitingLoad' }, + _statusRef: { value: 'awaitingLoad' }, + signal: new AbortController().signal, + entry: undefined, + load: baseLoad, + remove: baseRemove, + } + const createHandle = (target = shared) => { + return new Proxy(target, { + get(_, key, receiver) { + return Reflect.get(_, key === 'status' ? '_statusRef' : key, receiver) + }, + }) + } + const head = { + hooks: { + hook: vi.fn(() => vi.fn()), + }, + } + const appHookCallbacks = new Map void)[]>() + const app = { + $scripts: {} as Record, + _scripts: {} as Record, + hooks: { + hook: vi.fn((name: string, callback: () => void) => { + const callbacks = appHookCallbacks.get(name) || [] + callbacks.push(callback) + appHookCallbacks.set(name, callbacks) + return vi.fn() + }), + callHook: vi.fn(), + }, + } + return { + app, + appHookCallbacks, + baseLoad, + baseRemove, + createHandle, + head, + shared, + unheadUseScript: vi.fn(createHandle), + } +}) + +vi.mock('@unhead/vue/scripts', () => ({ + useScript: mocks.unheadUseScript, +})) + +vi.mock('nuxt/app', () => ({ + injectHead: () => mocks.head, + onNuxtReady: vi.fn(), + useHead: vi.fn(), + useNuxtApp: () => mocks.app, + useRuntimeConfig: () => ({ + public: { + 'nuxt-scripts': { defaultScriptOptions: {} }, + 'nuxt-scripts-devtools': {}, + }, + }), +})) + +vi.mock('#build/nuxt-scripts-trigger-resolver', () => ({ + resolveTrigger: vi.fn(), +})) + +describe('useScript shared instance lifecycle', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.app.$scripts = {} + mocks.app._scripts = {} + mocks.appHookCallbacks.clear() + Object.assign(mocks.shared, { + id: 'https://example.com/sdk.js', + status: { value: 'awaitingLoad' }, + _statusRef: { value: 'awaitingLoad' }, + signal: new AbortController().signal, + entry: undefined, + load: mocks.baseLoad, + remove: mocks.baseRemove, + }) + delete (mocks.shared as any).reload + delete (mocks.shared as any).toJSON + mocks.unheadUseScript.mockImplementation(() => mocks.createHandle()) + }) + + it('decorates a shared Unhead instance only once and removes its registry entry', () => { + const first = useScript('https://example.com/sdk.js') + const decoratedRemove = first.remove + const second = useScript('https://example.com/sdk.js') + + // Unhead still sees both callers so Vue can bind their callbacks to the + // active component scope. + expect(mocks.unheadUseScript).toHaveBeenCalledTimes(2) + expect(second).not.toBe(first) + expect(second.remove).toBe(decoratedRemove) + expect(second.reload).toBe(first.reload) + const appInstance = mocks.app.$scripts['https://example.com/sdk.js'] + expect(appInstance).toBe(first) + expect(appInstance.remove).toBe(decoratedRemove) + expect(mocks.app.hooks.hook).toHaveBeenCalledTimes(1) + expect(mocks.unheadUseScript.mock.calls[0]?.[1]).not.toHaveProperty('scope') + + expect(first.remove()).toBe(true) + expect(mocks.baseRemove).toHaveBeenCalledOnce() + expect(mocks.app.$scripts).not.toHaveProperty('https://example.com/sdk.js') + }) + + it('preserves shared identity with older Unhead versions', () => { + mocks.unheadUseScript.mockReturnValue(mocks.shared as any) + + const first = useScript('https://example.com/sdk.js') + const second = useScript('https://example.com/sdk.js') + + expect(second).toBe(first) + expect(mocks.app.$scripts['https://example.com/sdk.js']).toBe(first) + }) + + it('releases event context added by older Vue integrations', () => { + mocks.unheadUseScript.mockImplementation((_input, options) => { + options.eventContext = { component: true } + return mocks.createHandle() + }) + + useScript('https://example.com/sdk.js') + + expect(mocks.unheadUseScript.mock.calls[0]?.[1]).not.toHaveProperty('eventContext') + }) + + it('runs provider cleanup wrappers during app teardown', () => { + const instance = useScript('https://example.com/sdk.js') + const remove = instance.remove + const providerRemove = vi.fn(() => remove()) + instance.remove = providerRemove + + mocks.appHookCallbacks.get('app:unmount')?.[0]?.() + + expect(providerRemove).toHaveBeenCalledOnce() + expect(mocks.baseRemove).toHaveBeenCalledOnce() + }) + + it('keeps the public status ref live across reloads without requesting a scope', async () => { + const reloadedStatus = ref('loading') + const reloadedLoad = vi.fn(() => { + reloadedStatus.value = 'loaded' + return Promise.resolve({ ready: true }) + }) + const reloaded = { + id: 'reload-script', + _statusRef: reloadedStatus, + status: 'loading', + entry: { dispose: vi.fn() }, + load: reloadedLoad, + remove: vi.fn(() => true), + } + mocks.unheadUseScript + .mockImplementationOnce(() => mocks.createHandle()) + .mockImplementationOnce(() => mocks.createHandle(reloaded as any)) + const instance = useScript('https://example.com/sdk.js') + const status = instance.status + + await expect((instance as any).reload()).resolves.toEqual({ ready: true }) + + expect(instance.status).toBe(status) + expect(status.value).toBe('loaded') + expect(instance.entry).toBe(reloaded.entry) + expect(reloadedLoad).toHaveBeenCalledOnce() + expect(mocks.unheadUseScript.mock.calls[1]?.[1]).not.toHaveProperty('scope') + }) + + it('releases event context added while reloading with older Unhead versions', async () => { + const reloaded = { + id: 'reload-script', + _statusRef: ref('loading'), + entry: undefined, + load: vi.fn(() => Promise.resolve({ ready: true })), + remove: vi.fn(() => true), + } + mocks.unheadUseScript + .mockImplementationOnce(() => mocks.createHandle()) + .mockImplementationOnce((_input, options) => { + options.eventContext = { component: true } + return mocks.createHandle(reloaded as any) + }) + const instance = useScript('https://example.com/sdk.js') + + await (instance as any).reload() + + expect(mocks.unheadUseScript.mock.calls[1]?.[1]).not.toHaveProperty('eventContext') + }) + + it('does not bypass validation when reload is called', async () => { + const validationError = { issues: [{ message: 'invalid test options' }] } + const instance = useScript('https://example.com/sdk.js', { + _validate: () => validationError, + } as any) + + await expect((instance as any).reload()).rejects.toBe(validationError) + expect(mocks.baseRemove).not.toHaveBeenCalled() + }) +}) + diff --git a/vitest.config.ts b/vitest.config.ts index f2a03b26..fece8f8d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ defineProject({ resolve: { alias: { + '@unhead/vue/scripts': new URL('./packages/script/node_modules/@unhead/vue/dist/scripts.mjs', import.meta.url).pathname, '#nuxt-scripts/h3': 'h3', '#nuxt-scripts/nitro': new URL('./test/unit/__mocks__/empty.ts', import.meta.url).pathname, // Virtual emitted by the Nuxt module at build time; unit tests @@ -30,6 +31,7 @@ export default defineConfig({ // import must first resolve to *something* the bundler accepts. // The alias points at an empty placeholder. '#build/nuxt-scripts-snippets': new URL('./test/unit/__mocks__/empty.ts', import.meta.url).pathname, + '#build/nuxt-scripts-trigger-resolver': new URL('./test/unit/__mocks__/empty.ts', import.meta.url).pathname, }, }, test: { From 6877f4c451f832118681f8e4096ce12e9bba2a54 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 16 Jul 2026 02:26:47 +1000 Subject: [PATCH 02/19] perf: memory leak hardening --- packages/devtools-app/composables/rpc.ts | 107 +++++++++--- packages/devtools-app/composables/state.ts | 35 +++- packages/devtools-app/utils/fetch.ts | 7 +- packages/script/src/devtools.ts | 46 +++++- packages/script/src/module.ts | 20 +++ .../GoogleMaps/ScriptGoogleMaps.vue | 109 ++++++++++--- .../GoogleMaps/useGoogleMapsResource.ts | 55 ++++++- .../runtime/components/ScriptCarbonAds.vue | 14 +- .../src/runtime/components/ScriptCrisp.vue | 7 +- .../src/runtime/components/ScriptIntercom.vue | 7 +- .../runtime/components/ScriptLemonSqueezy.vue | 11 +- .../components/ScriptPayPalButtons.vue | 7 +- .../components/ScriptPayPalMessages.vue | 4 + .../components/ScriptStripePricingTable.vue | 4 +- .../runtime/components/ScriptVimeoPlayer.vue | 13 +- .../src/runtime/composables/useScript.ts | 152 +++++++++++++----- .../runtime/composables/useScriptEventPage.ts | 39 +++-- .../useScriptTriggerIdleTimeout.ts | 30 +++- .../useScriptTriggerInteraction.ts | 37 +++-- .../useScriptTriggerServiceWorker.ts | 42 ++--- .../devtools-standalone-bridge.client.ts | 31 +++- .../script/src/runtime/npm-script-stub.ts | 8 +- .../script/src/runtime/registry/speedcurve.ts | 35 +++- .../src/runtime/registry/usercentrics.ts | 61 +++++-- .../src/runtime/registry/youtube-player.ts | 13 +- .../src/runtime/server/instagram-embed.ts | 2 + .../src/runtime/server/proxy-handler.ts | 120 ++++++++++++-- .../src/runtime/server/utils/cache-config.ts | 10 ++ .../runtime/server/utils/cached-upstream.ts | 3 + .../src/runtime/utils/after-next-paint.ts | 20 ++- test/unit/google-maps-lifecycle.test.ts | 24 +++ test/unit/speedcurve-after-next-paint.test.ts | 18 +++ 32 files changed, 905 insertions(+), 186 deletions(-) create mode 100644 packages/script/src/runtime/server/utils/cache-config.ts diff --git a/packages/devtools-app/composables/rpc.ts b/packages/devtools-app/composables/rpc.ts index 1dc4e930..651bf0cb 100644 --- a/packages/devtools-app/composables/rpc.ts +++ b/packages/devtools-app/composables/rpc.ts @@ -4,7 +4,7 @@ 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() @@ -12,7 +12,7 @@ export const devtools: Ref = 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 } @@ -27,20 +27,33 @@ 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> = [] + + const cleanupConnection = () => { + connectionCleanups.splice(0).forEach(cleanup => cleanup()) + devtools.value = undefined + } // Embedded mode: connect via devtools-kit iframe client + let stopClientConnection = () => {} if (inIframe) { - onDevtoolsClientConnected(async (client) => { + stopClientConnection = onDevtoolsClientConnected((client) => { + if (disposed) + return + 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 @@ -48,22 +61,44 @@ export function useDevtoolsConnection(options: DevtoolsConnectionOptions = {}): 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 | undefined + let pollController: AbortController | undefined - watch(() => standaloneUrl.value, (url) => { - // Clean up previous polling + const stopPolling = () => { if (pollTimer) { clearInterval(pollTimer) pollTimer = undefined } + pollController?.abort() + pollController = 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 + } + } + + const stopStandaloneWatch = watch(() => standaloneUrl.value, (url) => { + // Clean up previous polling + stopPolling() if (url && !isConnected.value) { appFetch.value = ofetch.create({ baseURL: url }) as unknown as $Fetch @@ -71,22 +106,32 @@ export function useDevtoolsConnection(options: DevtoolsConnectionOptions = {}): 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() + appFetch.value = undefined + isConnected.value = false + } } -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 @@ -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) diff --git a/packages/devtools-app/composables/state.ts b/packages/devtools-app/composables/state.ts index 3275c6b8..79a37ce9 100644 --- a/packages/devtools-app/composables/state.ts +++ b/packages/devtools-app/composables/state.ts @@ -84,6 +84,7 @@ export const version = ref(null) export const firstPartyData = ref(null) let _lastSyncedScripts: any[] | null = null +const scriptFetches = new Map() export async function initRegistry() { scriptRegistry.value = await _registryPromise @@ -107,9 +108,16 @@ export function syncScripts(_scripts: any[]) { if (!_scripts || typeof _scripts !== 'object') { _lastSyncedScripts = null scripts.value = {} + pruneScriptState(new Set()) return } _lastSyncedScripts = _scripts + const activeSources = new Set( + Object.values(_scripts) + .map((script: any) => script?.src) + .filter((src): src is string => typeof src === 'string' && !!src), + ) + pruneScriptState(activeSources) scripts.value = Object.fromEntries( Object.entries({ ..._scripts }) .map(([key, script]: [string, any]) => { @@ -124,9 +132,13 @@ export function syncScripts(_scripts: any[]) { script.loadTime = msToHumanReadable(loadedAt - loadingAt) const scriptSizeKey = script.src // Skip size fetching in standalone mode (cross-origin fetch blocked by CORS) - if (!scriptSizes[scriptSizeKey] && script.src && !isStandalone.value) { - fetchScript(script.src) + if (!scriptSizes[scriptSizeKey] && !scriptErrors[scriptSizeKey] && script.src && !isStandalone.value && !scriptFetches.has(scriptSizeKey)) { + const controller = new AbortController() + scriptFetches.set(scriptSizeKey, controller) + fetchScript(script.src, controller.signal) .then((res) => { + if (controller.signal.aborted || !activeSources.has(scriptSizeKey)) + return if (res.size) { scriptSizes[scriptSizeKey] = res.size script.size = res.size @@ -136,12 +148,31 @@ export function syncScripts(_scripts: any[]) { script.error = scriptErrors[scriptSizeKey] } }) + .finally(() => { + if (scriptFetches.get(scriptSizeKey) === controller) + scriptFetches.delete(scriptSizeKey) + }) } return [key, script] }), ) } +function pruneScriptState(activeSources: Set) { + for (const [src, controller] of scriptFetches) { + if (!activeSources.has(src)) { + controller.abort() + scriptFetches.delete(src) + } + } + for (const state of [scriptSizes, scriptErrors, scriptTabs]) { + for (const src of Object.keys(state)) { + if (!activeSources.has(src)) + delete state[src] + } + } +} + // Script status helper (handles both reactive refs from embedded mode and plain strings from standalone) export function getScriptStatus(script: any): string { const status = script?.$script?.status diff --git a/packages/devtools-app/utils/fetch.ts b/packages/devtools-app/utils/fetch.ts index 3d70db82..47d6d356 100644 --- a/packages/devtools-app/utils/fetch.ts +++ b/packages/devtools-app/utils/fetch.ts @@ -1,5 +1,8 @@ -export async function fetchScript(url: string) { - const compressedResponse = await fetch(url, { headers: { 'Accept-Encoding': 'gzip' } }).catch((err) => { +export async function fetchScript(url: string, signal?: AbortSignal) { + const compressedResponse = await fetch(url, { + headers: { 'Accept-Encoding': 'gzip' }, + signal, + }).catch((err) => { return { size: null, error: err, diff --git a/packages/script/src/devtools.ts b/packages/script/src/devtools.ts index 5a78d289..5e8a3fb0 100644 --- a/packages/script/src/devtools.ts +++ b/packages/script/src/devtools.ts @@ -7,6 +7,7 @@ import { createResolver, extendViteConfig } from '@nuxt/kit' const DEVTOOLS_UI_ROUTE = '/__nuxt-scripts' const DEVTOOLS_UI_LOCAL_PORT = 3030 const DEVTOOLS_API_STATE_ROUTE = '/__nuxt-scripts-api/state' +const DEVTOOLS_API_MAX_BODY_SIZE = 2 * 1024 * 1024 export interface DevtoolsOptions { standalone?: boolean @@ -82,10 +83,41 @@ function setupStandaloneApi(nuxt: Nuxt) { if (req.method === 'POST') { let body = '' - req.on('data', (chunk: Buffer) => { + let size = 0 + let finished = false + + function cleanup() { + req.off('data', onData) + req.off('end', onEnd) + req.off('aborted', onAborted) + req.off('error', onAborted) + } + function onAborted() { + finished = true + body = '' + cleanup() + } + function onData(chunk: Buffer) { + if (finished) + return + size += chunk.byteLength + if (size > DEVTOOLS_API_MAX_BODY_SIZE) { + finished = true + body = '' + cleanup() + // Drain the remainder so the keep-alive connection can be reused. + req.resume() + res.statusCode = 413 + res.end('payload too large') + return + } body += chunk.toString() - }) - req.on('end', () => { + } + function onEnd() { + if (finished) + return + finished = true + cleanup() try { const data = JSON.parse(body) scriptsState = { ...data, updatedAt: Date.now() } @@ -96,7 +128,13 @@ function setupStandaloneApi(nuxt: Nuxt) { res.statusCode = 400 res.end('invalid json') } - }) + body = '' + } + + req.on('data', onData) + req.on('end', onEnd) + req.on('aborted', onAborted) + req.on('error', onAborted) return } diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 56be2ac1..c57b82c0 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -42,6 +42,12 @@ import { generateInterceptPluginContents } from './plugins/intercept' import { NuxtScriptBundleTransformer } from './plugins/transform' import { aliasProxyValue, buildDomainAliasMap, invertAliasMap, isSafeAliasSegment } from './proxy-alias' import { buildProxyConfigsFromRegistry, generatePartytownResolveUrl, getPartytownForwards, registry, resolveCapabilities } from './registry' +import { + NUXT_SCRIPTS_CACHE_BASE, + NUXT_SCRIPTS_CACHE_MAX_ENTRIES, + NUXT_SCRIPTS_CACHE_MAX_ENTRY_SIZE, + NUXT_SCRIPTS_CACHE_MAX_SIZE, +} from './runtime/server/utils/cache-config' import { registerTypeTemplates, templatePlugin, templateTriggerResolver } from './templates' import { validateScriptsEnvVars } from './validate-env' @@ -1096,6 +1102,20 @@ export default defineNuxtModule({ } } + if (Object.keys(enabledEndpoints).length > 0) { + const nitroOptions = nuxt.options.nitro as any + nitroOptions.storage ||= {} + // Nitro's default memory storage has no eviction policy. Keep proxy and + // embed caches bounded unless the application supplied a dedicated + // persistent/distributed mount for this namespace. + nitroOptions.storage[NUXT_SCRIPTS_CACHE_BASE] ||= { + driver: 'lru-cache', + max: NUXT_SCRIPTS_CACHE_MAX_ENTRIES, + maxSize: NUXT_SCRIPTS_CACHE_MAX_SIZE, + maxEntrySize: NUXT_SCRIPTS_CACHE_MAX_ENTRY_SIZE, + } + } + // Publish enabled endpoints to client for component opt-in checks nuxt.options.runtimeConfig.public['nuxt-scripts'] = defu( { endpoints: enabledEndpoints }, diff --git a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue index 702e8c02..292f0354 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue @@ -152,7 +152,7 @@ export interface ScriptGoogleMapsSlots { diff --git a/test/fixtures/unhead-v3/nuxt.config.ts b/test/fixtures/unhead-v3/nuxt.config.ts index 4e28aa08..e5654b91 100644 --- a/test/fixtures/unhead-v3/nuxt.config.ts +++ b/test/fixtures/unhead-v3/nuxt.config.ts @@ -1,6 +1,6 @@ import { defineNuxtConfig } from 'nuxt/config' -// Regression fixture pinned to @unhead/vue@3.3.0 via the +// Regression fixture pinned to @unhead/vue@3.3.1 via the // `unhead-v3-fixture>@unhead/vue` override in pnpm-workspace.yaml. // Module resolution picks v3 up through the fixture's own node_modules symlink. export default defineNuxtConfig({ diff --git a/test/fixtures/unhead-v3/package.json b/test/fixtures/unhead-v3/package.json index 8e9e821d..1b9d7750 100644 --- a/test/fixtures/unhead-v3/package.json +++ b/test/fixtures/unhead-v3/package.json @@ -4,7 +4,7 @@ "type": "module", "dependencies": { "@nuxt/scripts": "workspace:*", - "@unhead/vue": "3.3.0", + "@unhead/vue": "3.3.1", "nuxt": "catalog:" } } diff --git a/test/unit/npm-script-proxy.test.ts b/test/unit/npm-script-proxy.test.ts new file mode 100644 index 00000000..f25f34e8 --- /dev/null +++ b/test/unit/npm-script-proxy.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest' +import { createNpmScriptProxy } from '../../packages/script/src/runtime/npm-script-proxy' + +describe('createNpmScriptProxy', () => { + it('preserves method return values and receivers after the SDK resolves', () => { + const queuedTrack = vi.fn() + const fallback = { + track: queuedTrack, + } + let api: { prefix: string, track: (value: string) => string } | undefined + const proxy = createNpmScriptProxy(fallback, () => api) + const retainedTrack = proxy.track + + expect(retainedTrack('before')).toBeUndefined() + expect(queuedTrack).toHaveBeenCalledWith('before') + + api = { + prefix: 'tracked', + track(value) { + return `${this.prefix}:${value}` + }, + } + + expect(proxy.track).toBe(retainedTrack) + expect(retainedTrack('after')).toBe('tracked:after') + expect(proxy.prefix).toBe('tracked') + }) +}) diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index 8d295568..fe97bcb6 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -7,7 +7,7 @@ const runtimeConfig = vi.hoisted(() => ({ }, })) -const useScriptMock = vi.hoisted(() => vi.fn((input, options) => ({ input, options }))) +const useScriptMock = vi.hoisted(() => vi.fn((input, options) => ({ input, options, proxy: {} }))) const unheadFeatures = vi.hoisted(() => ({ sourceLessScriptLoader: false })) // Mock dependencies @@ -74,7 +74,7 @@ describe('useRegistryScript scriptOptions', () => { it('delegates npm mode to an Unhead source-less loader when supported', async () => { unheadFeatures.sourceLessScriptLoader = true - const api = { track: vi.fn() } + const api = { track: vi.fn(() => 'tracked') } const clientInit = vi.fn(async () => api) const use = vi.fn(() => api) const result = useRegistryScript('posthog', () => ({ @@ -95,6 +95,7 @@ describe('useRegistryScript scriptOptions', () => { const signal = new AbortController().signal await expect(result.input.loader({ signal })).resolves.toBe(api) + expect(result.proxy.track()).toBe('tracked') expect(clientInit).toHaveBeenCalledWith({ signal }) expect(use).toHaveBeenCalledOnce() }) From b7adbecdd03eaa87a034c83f922aae6c75cce94b Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 4 Aug 2026 18:19:40 +1000 Subject: [PATCH 19/19] fix(script): narrow MapLibre worker contract --- packages/script/src/runtime/maplibre-styles.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/script/src/runtime/maplibre-styles.ts b/packages/script/src/runtime/maplibre-styles.ts index a61c437d..f18944e1 100644 --- a/packages/script/src/runtime/maplibre-styles.ts +++ b/packages/script/src/runtime/maplibre-styles.ts @@ -1,11 +1,9 @@ -import type * as MapLibre from 'maplibre-gl' - export const MAPLIBRE_STYLESHEET_URL = 'https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.css' export const MAPLIBRE_STYLESHEET_INTEGRITY = 'sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK' const MAPLIBRE_STYLE_ID = 'nuxt-scripts-maplibre-styles' -export function configureMapLibreWorker(maplibre: typeof MapLibre | undefined, workerUrl?: string): void { +export function configureMapLibreWorker(maplibre: { setWorkerUrl: (value: string) => void } | undefined, workerUrl?: string): void { if (maplibre && workerUrl) maplibre.setWorkerUrl(workerUrl) }