Skip to content

Commit b67c9a3

Browse files
harlan-zwclaude
andauthored
feat: experimental nuxt/partytown support (#576)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent aa542c0 commit b67c9a3

File tree

18 files changed

+1011
-550
lines changed

18 files changed

+1011
-550
lines changed

docs/content/docs/3.api/1.use-script.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ export type NuxtUseScriptOptions<T = any> = Omit<UseScriptOptions<T>, 'trigger'>
7171
* - `false` - Do not bundle the script. (default)
7272
*/
7373
bundle?: boolean
74+
/**
75+
* [Experimental] Load the script in a web worker using Partytown.
76+
* When enabled, adds `type="text/partytown"` to the script tag.
77+
* Requires @nuxtjs/partytown to be installed and configured separately.
78+
* Note: Scripts requiring DOM access (GTM, Hotjar, chat widgets) are not compatible.
79+
* @see https://partytown.qwik.dev/
80+
*/
81+
partytown?: boolean
7482
/**
7583
* Skip any schema validation for the script input. This is useful for loading the script stubs for development without
7684
* loading the actual script and not getting warnings.

docs/content/docs/3.api/5.nuxt-config.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,61 @@ Global registry scripts that should be loaded.
1111

1212
See the [Script Registry](/scripts) for more details.
1313

14+
## `partytown` :badge[Experimental]{color="amber"}
15+
16+
- Type: `(keyof ScriptRegistry)[]`
17+
- Default: `[]`
18+
19+
Registry scripts to load via [Partytown](https://partytown.qwik.dev/) (web worker).
20+
21+
This is a shorthand for setting `partytown: true` on individual registry scripts. When a script is listed here, it will be loaded with `type="text/partytown"` so Partytown can execute it in a web worker.
22+
23+
::code-group
24+
```ts [nuxt.config.ts]
25+
export default defineNuxtConfig({
26+
scripts: {
27+
partytown: ['googleAnalytics', 'plausible', 'fathom'],
28+
registry: {
29+
googleAnalytics: { id: 'G-XXXXX' },
30+
plausible: { domain: 'example.com' },
31+
fathom: { site: 'XXXXX' }
32+
}
33+
}
34+
})
35+
```
36+
::
37+
38+
::callout{icon="i-heroicons-exclamation-triangle" color="amber"}
39+
Requires [`@nuxtjs/partytown`](https://github.com/nuxt-modules/partytown) to be installed. The `forward` array is **automatically configured** for supported registry scripts.
40+
::
41+
42+
### Supported Scripts
43+
44+
Scripts with auto-forwarding configured:
45+
- `googleAnalytics`, `plausible`, `fathom`, `umami`, `matomo`, `segment`
46+
- `metaPixel`, `xPixel`, `tiktokPixel`, `snapchatPixel`, `redditPixel`
47+
- `cloudflareWebAnalytics`
48+
49+
### Limitations
50+
51+
::callout{icon="i-heroicons-x-circle" color="red"}
52+
**Incompatible scripts** - The following cannot work with Partytown due to DOM access requirements:
53+
- **Tag managers**: Google Tag Manager, Adobe Launch
54+
- **Session replay**: Hotjar, Clarity, FullStory, LogRocket
55+
- **Chat widgets**: Intercom, Crisp, Drift, HubSpot Chat
56+
- **Video players**: YouTube, Vimeo embeds
57+
- **A/B testing**: Optimizely, VWO, Google Optimize
58+
::
59+
60+
::callout{icon="i-heroicons-exclamation-triangle" color="amber"}
61+
**General limitations**:
62+
- Scripts cannot directly access or modify the DOM
63+
- `document.cookie` access requires additional Partytown configuration
64+
- Debugging is more complex (code runs in a web worker)
65+
- Some scripts may have timing differences compared to main thread execution
66+
- localStorage/sessionStorage access may require forwarding configuration
67+
::
68+
1469
## `defaultScriptOptions`
1570

1671
- Type: `NuxtUseScriptOptions`

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"prepack": "pnpm dev:prepare && nuxt-module-build build && npm run client:build",
4747
"dev": "nuxi dev playground",
4848
"dev:ssl": "nuxi dev playground --https",
49-
"prepare:fixtures": "nuxi prepare test/fixtures/basic && nuxi prepare test/fixtures/cdn && nuxi prepare test/fixtures/extend-registry",
49+
"prepare:fixtures": "nuxi prepare test/fixtures/basic && nuxi prepare test/fixtures/cdn && nuxi prepare test/fixtures/extend-registry && nuxi prepare test/fixtures/partytown",
5050
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground && pnpm run prepare:fixtures",
5151
"typecheck": "vue-tsc --noEmit",
5252
"bump": "bumpp package.json --commit --push --tag",
@@ -124,6 +124,7 @@
124124
"valibot": "^1.2.0"
125125
},
126126
"devDependencies": {
127+
"@nuxtjs/partytown": "^2.0.0",
127128
"@nuxt/devtools-kit": "^3.1.1",
128129
"@nuxt/devtools-ui-kit": "^3.1.1",
129130
"@nuxt/eslint-config": "^1.12.1",

pnpm-lock.yaml

Lines changed: 691 additions & 534 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/module.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,37 @@ import type {
2828
import { NuxtScriptsCheckScripts } from './plugins/check-scripts'
2929
import { registerTypeTemplates, templatePlugin, templateTriggerResolver } from './templates'
3030

31+
/**
32+
* Partytown forward config for registry scripts.
33+
* Scripts not listed here are likely incompatible due to DOM access requirements.
34+
* @see https://partytown.qwik.dev/forwarding-events
35+
*/
36+
const PARTYTOWN_FORWARDS: Record<string, string[]> = {
37+
googleAnalytics: ['dataLayer.push', 'gtag'],
38+
plausible: ['plausible'],
39+
fathom: ['fathom', 'fathom.trackEvent', 'fathom.trackPageview'],
40+
umami: ['umami', 'umami.track'],
41+
matomo: ['_paq.push'],
42+
segment: ['analytics', 'analytics.track', 'analytics.page', 'analytics.identify'],
43+
metaPixel: ['fbq'],
44+
xPixel: ['twq'],
45+
tiktokPixel: ['ttq.track', 'ttq.page', 'ttq.identify'],
46+
snapchatPixel: ['snaptr'],
47+
redditPixel: ['rdt'],
48+
cloudflareWebAnalytics: ['__cfBeacon'],
49+
}
50+
3151
export interface ModuleOptions {
3252
/**
3353
* The registry of supported third-party scripts. Loads the scripts in globally using the default script options.
3454
*/
3555
registry?: NuxtConfigScriptRegistry
56+
/**
57+
* Registry scripts to load via Partytown (web worker).
58+
* Shorthand for setting `partytown: true` on individual registry scripts.
59+
* @example ['googleAnalytics', 'plausible', 'fathom']
60+
*/
61+
partytown?: (keyof NuxtConfigScriptRegistry)[]
3662
/**
3763
* Default options for scripts.
3864
*/
@@ -186,6 +212,51 @@ export default defineNuxtModule<ModuleOptions>({
186212
)
187213
}
188214

215+
// Process partytown shorthand - add partytown: true to specified registry scripts
216+
// and auto-configure @nuxtjs/partytown forward array
217+
if (config.partytown?.length) {
218+
config.registry = config.registry || {}
219+
const requiredForwards: string[] = []
220+
221+
for (const scriptKey of config.partytown) {
222+
// Collect required forwards for this script
223+
const forwards = PARTYTOWN_FORWARDS[scriptKey]
224+
if (forwards) {
225+
requiredForwards.push(...forwards)
226+
}
227+
else if (import.meta.dev) {
228+
logger.warn(`[partytown] "${scriptKey}" has no known Partytown forwards configured. It may not work correctly or may require manual forward configuration.`)
229+
}
230+
231+
const existing = config.registry[scriptKey]
232+
if (Array.isArray(existing)) {
233+
// [input, options] format - merge partytown into options
234+
existing[1] = { ...existing[1], partytown: true }
235+
}
236+
else if (existing && typeof existing === 'object' && existing !== true && existing !== 'mock') {
237+
// input object format - wrap with partytown option
238+
config.registry[scriptKey] = [existing, { partytown: true }] as any
239+
}
240+
else if (existing === true || existing === 'mock') {
241+
// simple enable - convert to array with partytown
242+
config.registry[scriptKey] = [{}, { partytown: true }] as any
243+
}
244+
else {
245+
// not configured - add with partytown enabled
246+
config.registry[scriptKey] = [{}, { partytown: true }] as any
247+
}
248+
}
249+
250+
// Auto-configure @nuxtjs/partytown forward array
251+
if (requiredForwards.length && hasNuxtModule('@nuxtjs/partytown')) {
252+
const partytownConfig = (nuxt.options as any).partytown || {}
253+
const existingForwards = partytownConfig.forward || []
254+
const newForwards = [...new Set([...existingForwards, ...requiredForwards])]
255+
;(nuxt.options as any).partytown = { ...partytownConfig, forward: newForwards }
256+
logger.info(`[partytown] Auto-configured forwards: ${requiredForwards.join(', ')}`)
257+
}
258+
}
259+
189260
const composables = [
190261
'useScript',
191262
'useScriptEventPage',

src/runtime/composables/useScript.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import type { UseScriptInput, UseScriptOptions, VueScriptInstance } from '@unhead/vue/scripts'
22
import { defu } from 'defu'
33
import { useScript as _useScript } from '@unhead/vue/scripts'
4-
import { reactive } from 'vue'
4+
import { reactive, ref } from 'vue'
55
import type { NuxtDevToolsScriptInstance, NuxtUseScriptOptions, UseFunctionType, UseScriptContext } from '../types'
6-
import { onNuxtReady, useNuxtApp, useRuntimeConfig, injectHead } from 'nuxt/app'
6+
import { onNuxtReady, useNuxtApp, useRuntimeConfig, injectHead, useHead } from 'nuxt/app'
77
import { logger } from '../logger'
88
// @ts-expect-error virtual template
99
import { resolveTrigger } from '#build/nuxt-scripts-trigger-resolver'
@@ -22,6 +22,31 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
2222
input = typeof input === 'string' ? { src: input } : input
2323
options = defu(options, useNuxtScriptRuntimeConfig()?.defaultScriptOptions) as NuxtUseScriptOptions<T>
2424

25+
// Partytown quick-path: use useHead for SSR rendering
26+
// Partytown needs scripts in initial HTML with type="text/partytown"
27+
if (options.partytown) {
28+
const src = input.src
29+
if (!src) {
30+
throw new Error('useScript with partytown requires a src')
31+
}
32+
useHead({
33+
script: [{ src, type: 'text/partytown' }],
34+
})
35+
// Register with nuxtApp.$scripts for DevTools visibility
36+
const nuxtApp = useNuxtApp()
37+
nuxtApp.$scripts = nuxtApp.$scripts! || reactive({})
38+
const status = ref('loaded')
39+
const stub = {
40+
id: src,
41+
status,
42+
load: () => Promise.resolve({} as T),
43+
remove: () => false,
44+
entry: undefined,
45+
} as any as UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>>
46+
nuxtApp.$scripts[src] = stub
47+
return stub
48+
}
49+
2550
// Warn about unsupported bundling for dynamic sources (internal value set by transform)
2651
if (import.meta.dev && (options.bundle as any) === 'unsupported') {
2752
console.warn('[Nuxt Scripts] Bundling is not supported for dynamic script sources. Static URLs are required for bundling.')

src/runtime/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ export type NuxtUseScriptOptions<T extends Record<symbol | string, any> = {}> =
6565
* Note: Using 'force' may significantly increase build time as scripts will be re-downloaded on every build.
6666
*/
6767
bundle?: boolean | 'force'
68+
/**
69+
* Load the script in a web worker using Partytown.
70+
* When enabled, adds `type="text/partytown"` to the script tag.
71+
* Requires @nuxtjs/partytown to be installed and configured separately.
72+
* @see https://partytown.qwik.dev/
73+
*/
74+
partytown?: boolean
6875
/**
6976
* Skip any schema validation for the script input. This is useful for loading the script stubs for development without
7077
* loading the actual script and not getting warnings.

src/templates.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -110,23 +110,26 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
110110
if (importDefinition) {
111111
// title case
112112
imports.unshift(`import { ${importDefinition.import.name} } from '${importDefinition.import.from}'`)
113-
const args = (typeof c !== 'object' ? {} : c) || {}
114113
if (c === 'mock') {
115-
args.scriptOptions = { trigger: 'manual', skipValidation: true }
114+
inits.push(`const ${k} = ${importDefinition.import.name}({ scriptOptions: { trigger: 'manual', skipValidation: true } })`)
116115
}
117-
else if (Array.isArray(c) && c.length === 2 && c[1]?.trigger) {
118-
const triggerResolved = resolveTriggerForTemplate(c[1].trigger)
116+
else if (Array.isArray(c) && c.length === 2) {
117+
// [input, options] format - unpack properly
118+
const input = c[0] || {}
119+
const scriptOptions = { ...c[1] }
120+
const triggerResolved = resolveTriggerForTemplate(scriptOptions?.trigger)
119121
if (triggerResolved) {
120-
args.scriptOptions = { ...c[1] } as any
121-
// Store the resolved trigger as a string that will be replaced later
122-
if (args.scriptOptions) {
123-
args.scriptOptions.trigger = `__TRIGGER_${triggerResolved}__` as any
124-
}
122+
scriptOptions.trigger = `__TRIGGER_${triggerResolved}__` as any
125123
if (triggerResolved.includes('useScriptTriggerIdleTimeout')) needsIdleTimeoutImport = true
126124
if (triggerResolved.includes('useScriptTriggerInteraction')) needsInteractionImport = true
127125
}
126+
const args = { ...input, scriptOptions }
127+
inits.push(`const ${k} = ${importDefinition.import.name}(${JSON.stringify(args).replace(/"__TRIGGER_(.*?)__"/g, '$1')})`)
128+
}
129+
else {
130+
const args = (typeof c !== 'object' ? {} : c) || {}
131+
inits.push(`const ${k} = ${importDefinition.import.name}(${JSON.stringify(args)})`)
128132
}
129-
inits.push(`const ${k} = ${importDefinition.import.name}(${JSON.stringify(args).replace(/"__TRIGGER_(.*?)__"/g, '$1')})`)
130133
}
131134
}
132135
for (const [k, c] of Object.entries(config.globals || {})) {

test/e2e/basic.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,16 @@ describe('basic', () => {
180180
const text = await page.$eval('#script-src', el => el.textContent)
181181
expect(text).toMatchInlineSnapshot(`"/_scripts/6bEy8slcRmYcRT4E2QbQZ1CMyWw9PpHA7L87BtvSs2U.js"`)
182182
})
183+
it('partytown adds type attribute', async () => {
184+
const { page } = await createPage('/partytown')
185+
await page.waitForTimeout(500)
186+
// verify the script tag has type="text/partytown"
187+
const scriptType = await page.evaluate(() => {
188+
const script = document.querySelector('script[src="/myScript.js"]')
189+
return script?.getAttribute('type')
190+
})
191+
expect(scriptType).toBe('text/partytown')
192+
})
183193
})
184194

185195
describe('youtube', () => {

test/e2e/partytown.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { createResolver } from '@nuxt/kit'
3+
import { getBrowser, url, waitForHydration, setup } from '@nuxt/test-utils/e2e'
4+
5+
const { resolve } = createResolver(import.meta.url)
6+
7+
await setup({
8+
rootDir: resolve('../fixtures/partytown'),
9+
browser: true,
10+
})
11+
12+
describe('partytown integration', () => {
13+
it('script tag has type="text/partytown" when partytown option is enabled', async () => {
14+
const browser = await getBrowser()
15+
const page = await browser.newPage()
16+
17+
await page.goto(url('/'), { waitUntil: 'networkidle' })
18+
await waitForHydration(page, '/')
19+
20+
// Verify our module correctly sets the type attribute for partytown
21+
// Note: Partytown changes type to "text/partytown-x" after processing
22+
const scriptType = await page.evaluate(() => {
23+
const script = document.querySelector('script[src="/worker-script.js"]')
24+
return script?.getAttribute('type')
25+
})
26+
expect(scriptType?.startsWith('text/partytown')).toBe(true)
27+
})
28+
29+
it('partytown library is loaded and script executes in worker', async () => {
30+
const browser = await getBrowser()
31+
const page = await browser.newPage()
32+
33+
// Capture console messages to verify worker execution
34+
const consoleLogs: string[] = []
35+
page.on('console', msg => consoleLogs.push(msg.text()))
36+
37+
await page.goto(url('/'), { waitUntil: 'networkidle' })
38+
await waitForHydration(page, '/')
39+
40+
// Wait for partytown to execute scripts
41+
await page.waitForTimeout(1000)
42+
43+
// Verify partytown library is loaded
44+
const partytownLib = await page.evaluate(() => {
45+
const scripts = Array.from(document.querySelectorAll('script'))
46+
return scripts.some(s => s.id === 'partytown' || s.src?.includes('partytown'))
47+
})
48+
expect(partytownLib).toBe(true)
49+
50+
// Verify our script executed in the worker (check console log)
51+
expect(consoleLogs.some(log => log.includes('Partytown script executing in worker'))).toBe(true)
52+
})
53+
})

0 commit comments

Comments
 (0)