-
Notifications
You must be signed in to change notification settings - Fork 75
feat: experimental nuxt/partytown support #576
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
commit: |
- Add `partytown?: boolean` option to useScript - Sets `type="text/partytown"` when enabled for web worker execution - Add `partytown: ['googleAnalytics', ...]` shorthand in module config - Add dev warnings for incompatible scripts (GTM, Hotjar, chat widgets, etc) - Add docs for partytown option and compatibility notes - Add e2e tests with @nuxtjs/partytown integration Closes #182 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add experimental badge to partytown config option - Document incompatible scripts (GTM, Hotjar, chat widgets, etc) - Add general limitations section (DOM access, cookies, debugging) - Update e2e test to verify partytown library integration - Update test fixture to set up forwarded function Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| it('registry with partytown option', async () => { | ||
| const res = templatePlugin({ | ||
| globals: {}, | ||
| registry: { | ||
| googleAnalytics: [ | ||
| { id: 'G-XXXXX' }, | ||
| { partytown: true }, | ||
| ], | ||
| }, | ||
| }, [ | ||
| { | ||
| import: { | ||
| name: 'useScriptGoogleAnalytics', | ||
| }, | ||
| }, | ||
| ]) | ||
| expect(res).toContain('useScriptGoogleAnalytics([{"id":"G-XXXXX"},{"partytown":true}])') | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test expects incorrect behavior - it verifies that the template generates an array being passed as a single argument to registry scripts, when it should verify that the array is properly unpacked into two arguments.
View Details
π Patch Details
diff --git a/src/templates.ts b/src/templates.ts
index f1b2cd4..7256b64 100644
--- a/src/templates.ts
+++ b/src/templates.ts
@@ -110,21 +110,29 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
if (importDefinition) {
// title case
imports.unshift(`import { ${importDefinition.import.name} } from '${importDefinition.import.from}'`)
- const args = (typeof c !== 'object' ? {} : c) || {}
+ let args: any
if (c === 'mock') {
- args.scriptOptions = { trigger: 'manual', skipValidation: true }
+ args = { scriptOptions: { trigger: 'manual', skipValidation: true } }
}
- else if (Array.isArray(c) && c.length === 2 && c[1]?.trigger) {
- const triggerResolved = resolveTriggerForTemplate(c[1].trigger)
+ else if (Array.isArray(c) && c.length === 2) {
+ // Unpack array [input, scriptOptions] into merged options
+ const input = c[0]
+ const scriptOptions = c[1]
+ const triggerResolved = resolveTriggerForTemplate(scriptOptions?.trigger)
+
if (triggerResolved) {
- args.scriptOptions = { ...c[1] } as any
- // Store the resolved trigger as a string that will be replaced later
- if (args.scriptOptions) {
- args.scriptOptions.trigger = `__TRIGGER_${triggerResolved}__` as any
- }
if (triggerResolved.includes('useScriptTriggerIdleTimeout')) needsIdleTimeoutImport = true
if (triggerResolved.includes('useScriptTriggerInteraction')) needsInteractionImport = true
+ const resolvedOptions = { ...scriptOptions, trigger: `__TRIGGER_${triggerResolved}__` } as any
+ args = { ...input, ...resolvedOptions }
}
+ else {
+ args = { ...input, ...scriptOptions }
+ }
+ }
+ else {
+ // Single object or other type
+ args = (typeof c !== 'object' ? {} : c) || {}
}
inits.push(`const ${k} = ${importDefinition.import.name}(${JSON.stringify(args).replace(/"__TRIGGER_(.*?)__"/g, '$1')})`)
}
diff --git a/test/unit/templates.test.ts b/test/unit/templates.test.ts
index fe030bb..ef7855b 100644
--- a/test/unit/templates.test.ts
+++ b/test/unit/templates.test.ts
@@ -139,7 +139,7 @@ describe('template plugin file', () => {
},
},
])
- expect(res).toContain('useScriptStripe([{"id":"test"},{"trigger":"onNuxtReady"}])')
+ expect(res).toContain('useScriptStripe({"id":"test","trigger":"onNuxtReady"})')
})
it('registry with partytown option', async () => {
@@ -158,7 +158,7 @@ describe('template plugin file', () => {
},
},
])
- expect(res).toContain('useScriptGoogleAnalytics([{"id":"G-XXXXX"},{"partytown":true}])')
+ expect(res).toContain('useScriptGoogleAnalytics({"id":"G-XXXXX","partytown":true})')
})
// Test idleTimeout trigger in globals
@@ -203,8 +203,10 @@ describe('template plugin file', () => {
},
},
])
- // Registry scripts pass trigger objects directly, they don't resolve triggers in templates
- expect(res).toContain('useScriptGoogleAnalytics([{"id":"GA_MEASUREMENT_ID"},{"trigger":{"idleTimeout":5000}}])')
+ // Registry scripts merge array input and options into a single argument
+ // When trigger resolvers are available, triggers are replaced with function calls
+ expect(res).toContain('useScriptGoogleAnalytics({"id":"GA_MEASUREMENT_ID","trigger":useScriptTriggerIdleTimeout({ timeout: 5000 })})')
+ expect(res).toContain('import { useScriptTriggerIdleTimeout }')
})
// Test both triggers together (should import both)
Analysis
Registry scripts with array format pass broken options to composables
What fails: When a registry script configuration uses array format [input, scriptOptions], the generated template plugin passes the entire array as a single argument instead of merging input and options into a single object.
How to reproduce:
templatePlugin({
registry: {
googleAnalytics: [
{ id: 'G-XXXXX' },
{ partytown: true },
],
},
}, [/* registry */])Result: Generates broken code that passes array as argument:
useScriptGoogleAnalytics([{"id":"G-XXXXX"},{"partytown":true}])The useScriptGoogleAnalytics() function receives an array with numeric keys ('0', '1') instead of a merged options object with properties (id, partytown).
Expected: Should merge input and options into a single argument:
useScriptGoogleAnalytics({"id":"G-XXXXX","partytown":true})Root cause: The template plugin code for registry scripts only handled arrays when c[1]?.trigger existed. Arrays without explicit triggers, or with other properties like partytown, were passed as-is to JSON.stringify(). This differs from the globals handler which properly unpacks arrays via spread syntax.
Fix: Updated src/templates.ts to properly unpack array registry entries [input, scriptOptions] by merging both elements into a single options object, matching the behavior of globals. Also updated corresponding tests in test/unit/templates.test.ts to validate the correct merged output.
| // Process partytown shorthand - add partytown: true to specified registry scripts | ||
| // and auto-configure @nuxtjs/partytown forward array | ||
| if (config.partytown?.length) { | ||
| config.registry = config.registry || {} | ||
| const requiredForwards: string[] = [] | ||
|
|
||
| for (const scriptKey of config.partytown) { | ||
| // Collect required forwards for this script | ||
| const forwards = PARTYTOWN_FORWARDS[scriptKey] | ||
| if (forwards) { | ||
| requiredForwards.push(...forwards) | ||
| } | ||
| else if (import.meta.dev) { | ||
| logger.warn(`[partytown] "${scriptKey}" has no known Partytown forwards configured. It may not work correctly or may require manual forward configuration.`) | ||
| } | ||
|
|
||
| const existing = config.registry[scriptKey] | ||
| if (Array.isArray(existing)) { | ||
| // [input, options] format - merge partytown into options | ||
| existing[1] = { ...existing[1], partytown: true } | ||
| } | ||
| else if (existing && typeof existing === 'object' && existing !== true && existing !== 'mock') { | ||
| // input object format - wrap with partytown option | ||
| config.registry[scriptKey] = [existing, { partytown: true }] as any | ||
| } | ||
| else if (existing === true || existing === 'mock') { | ||
| // simple enable - convert to array with partytown | ||
| config.registry[scriptKey] = [{}, { partytown: true }] as any | ||
| } | ||
| else { | ||
| // not configured - add with partytown enabled | ||
| config.registry[scriptKey] = [{}, { partytown: true }] as any | ||
| } | ||
| } | ||
|
|
||
| // Auto-configure @nuxtjs/partytown forward array | ||
| if (requiredForwards.length && hasNuxtModule('@nuxtjs/partytown')) { | ||
| const partytownConfig = (nuxt.options as any).partytown || {} | ||
| const existingForwards = partytownConfig.forward || [] | ||
| const newForwards = [...new Set([...existingForwards, ...requiredForwards])] | ||
| ;(nuxt.options as any).partytown = { ...partytownConfig, forward: newForwards } | ||
| logger.info(`[partytown] Auto-configured forwards: ${requiredForwards.join(', ')}`) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The partytown feature wraps registry script entries in array format [input, {partytown: true}], but the template plugin doesn't properly handle this array format when generating initialization code, so the partytown option is never actually applied to registry scripts.
View Details
π Patch Details
diff --git a/src/templates.ts b/src/templates.ts
index f1b2cd4..7aa6de6 100644
--- a/src/templates.ts
+++ b/src/templates.ts
@@ -110,20 +110,26 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
if (importDefinition) {
// title case
imports.unshift(`import { ${importDefinition.import.name} } from '${importDefinition.import.from}'`)
- const args = (typeof c !== 'object' ? {} : c) || {}
+ let args: any = (typeof c !== 'object' ? {} : c) || {}
if (c === 'mock') {
+ args = {}
args.scriptOptions = { trigger: 'manual', skipValidation: true }
}
- else if (Array.isArray(c) && c.length === 2 && c[1]?.trigger) {
- const triggerResolved = resolveTriggerForTemplate(c[1].trigger)
- if (triggerResolved) {
- args.scriptOptions = { ...c[1] } as any
- // Store the resolved trigger as a string that will be replaced later
- if (args.scriptOptions) {
+ else if (Array.isArray(c) && c.length === 2) {
+ // Handle array format [input, options]
+ args = typeof c[0] === 'object' ? c[0] : {}
+ const scriptOptions = c[1]
+ if (scriptOptions) {
+ const triggerResolved = scriptOptions.trigger ? resolveTriggerForTemplate(scriptOptions.trigger) : null
+ if (triggerResolved) {
+ args.scriptOptions = { ...scriptOptions } as any
args.scriptOptions.trigger = `__TRIGGER_${triggerResolved}__` as any
+ if (triggerResolved.includes('useScriptTriggerIdleTimeout')) needsIdleTimeoutImport = true
+ if (triggerResolved.includes('useScriptTriggerInteraction')) needsInteractionImport = true
+ }
+ else {
+ args.scriptOptions = scriptOptions
}
- if (triggerResolved.includes('useScriptTriggerIdleTimeout')) needsIdleTimeoutImport = true
- if (triggerResolved.includes('useScriptTriggerInteraction')) needsInteractionImport = true
}
}
inits.push(`const ${k} = ${importDefinition.import.name}(${JSON.stringify(args).replace(/"__TRIGGER_(.*?)__"/g, '$1')})`)
diff --git a/test/unit/templates.test.ts b/test/unit/templates.test.ts
index fe030bb..be5bf37 100644
--- a/test/unit/templates.test.ts
+++ b/test/unit/templates.test.ts
@@ -139,7 +139,7 @@ describe('template plugin file', () => {
},
},
])
- expect(res).toContain('useScriptStripe([{"id":"test"},{"trigger":"onNuxtReady"}])')
+ expect(res).toContain('useScriptStripe({"id":"test","scriptOptions":{"trigger":"onNuxtReady"}})')
})
it('registry with partytown option', async () => {
@@ -158,7 +158,7 @@ describe('template plugin file', () => {
},
},
])
- expect(res).toContain('useScriptGoogleAnalytics([{"id":"G-XXXXX"},{"partytown":true}])')
+ expect(res).toContain('useScriptGoogleAnalytics({"id":"G-XXXXX","scriptOptions":{"partytown":true}})')
})
// Test idleTimeout trigger in globals
@@ -203,8 +203,9 @@ describe('template plugin file', () => {
},
},
])
- // Registry scripts pass trigger objects directly, they don't resolve triggers in templates
- expect(res).toContain('useScriptGoogleAnalytics([{"id":"GA_MEASUREMENT_ID"},{"trigger":{"idleTimeout":5000}}])')
+ // Registry scripts now properly handle triggers in scriptOptions, including resolving idleTimeout/interaction triggers
+ expect(res).toContain('useScriptTriggerIdleTimeout({ timeout: 5000 })')
+ expect(res).toContain('useScriptGoogleAnalytics({"id":"GA_MEASUREMENT_ID","scriptOptions":{"trigger":useScriptTriggerIdleTimeout({ timeout: 5000 })}}')
})
// Test both triggers together (should import both)
Analysis
Registry scripts with partytown option not properly handled in template generation
What fails: When using scripts.partytown: ['googleAnalytics'] to enable partytown for registry scripts, the generated template code doesn't properly unpack the array format [input, {partytown: true}], causing the partytown option to never reach useScript. Scripts tagged with partytown receive normal script tags instead of type="text/partytown" and run on the main thread instead of in a web worker.
How to reproduce:
- Set config in
nuxt.config.ts:
export default defineNuxtConfig({
scripts: {
partytown: ['googleAnalytics'],
registry: {
googleAnalytics: { id: 'G-XXXXX' }
}
}
})- Build/generate the project
- Inspect the generated template in
.nuxt/plugins/scripts:init.ts
Result: The generated code contains:
const googleAnalytics = useScriptGoogleAnalytics([{id:"G-XXXXX"},{partytown:true}])This passes the entire array as a single options parameter, with partytown buried at array index [1].partytown, making it inaccessible to the registry function.
Expected: The partytown option should be unpacked into scriptOptions:
)Root cause: The template plugin in src/templates.ts (lines 108-131) only checked for c[1]?.trigger when handling array format for registry scripts. When partytown is the only option in the second element, the condition failed and the entire array was treated as a single parameter, unlike how globals handle the same array format correctly (lines 136-147).
Fix: Updated registry script handling in src/templates.ts to properly unpack array format [input, options] regardless of whether options contain a trigger, similar to the existing globals handling. The second element is now correctly placed in scriptOptions for all array-formatted registry entries.
- Disable warmupStrategy for partytown scripts (conflicts with partytown loading) - Update test fixture to use useHead for SSR script rendering - Simplify e2e tests to verify console log output and script type - Partytown changes type to "text/partytown-x" after processing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When partytown: true, bypass normal script loading and use useHead directly to ensure script is rendered in SSR HTML with type="text/partytown". Returns minimal stub since partytown handles execution in worker. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
π Linked issue
Resolves #182
β Type of change
π Description
Add Partytown web worker support for loading third-party scripts off the main thread.
Features:
partytown: trueoption onuseScript- setstype="text/partytown"scripts.partytown: ['googleAnalytics', 'plausible']shorthand in nuxt.config@nuxtjs/partytownforward array for supported scriptsSupported scripts with auto-forwarding:
googleAnalytics,plausible,fathom,umami,matomo,segment,metaPixel,xPixel,tiktokPixel,snapchatPixel,redditPixel,cloudflareWebAnalyticsUsage:
Warning
Google Analytics 4 - GA4 has known issues with Partytown. The
navigator.sendBeaconandfetchAPIs used for collect requests don't work reliably from web workers. Consider using Plausible, Fathom, or Umami instead.Caution
Google Tag Manager - GTM is not compatible with Partytown. GTM dynamically injects scripts and requires full DOM access. Load GTM on main thread instead.
Incompatible scripts (require DOM access):
Recommended for Partytown: Plausible, Fathom, Umami, Matomo, Cloudflare Web Analytics