Skip to content

Conversation

@harlan-zw
Copy link
Collaborator

@harlan-zw harlan-zw commented Jan 15, 2026

πŸ”— Linked issue

Resolves #182

❓ Type of change

  • πŸ“– Documentation (updates to the documentation or readme)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • πŸ‘Œ Enhancement (improving an existing functionality)
  • ✨ New feature (a non-breaking change that adds functionality)
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

πŸ“š Description

Add Partytown web worker support for loading third-party scripts off the main thread.

Features:

  • partytown: true option on useScript - sets type="text/partytown"
  • scripts.partytown: ['googleAnalytics', 'plausible'] shorthand in nuxt.config
  • Auto-configures @nuxtjs/partytown forward array for supported scripts
  • Warns if script has no known forwards configured

Supported scripts with auto-forwarding:
googleAnalytics, plausible, fathom, umami, matomo, segment, metaPixel, xPixel, tiktokPixel, snapchatPixel, redditPixel, cloudflareWebAnalytics

Usage:

// nuxt.config.ts - just list the scripts, forwards are auto-configured!
export default defineNuxtConfig({
  modules: ['@nuxtjs/partytown', '@nuxt/scripts'],
  scripts: {
    partytown: ['plausible', 'fathom'],
    registry: {
      plausible: { domain: 'example.com' },
      fathom: { site: 'XXXXX' }
    }
  }
  // No need to manually configure partytown.forward!
})

⚠️ Known Limitations

Warning

Google Analytics 4 - GA4 has known issues with Partytown. The navigator.sendBeacon and fetch APIs 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):

  • Tag managers: GTM, Adobe Launch
  • Session replay: Hotjar, Clarity, FullStory, LogRocket
  • Chat widgets: Intercom, Crisp, Drift
  • Video embeds: YouTube, Vimeo
  • A/B testing: Optimizely, VWO

Recommended for Partytown: Plausible, Fathom, Umami, Matomo, Cloudflare Web Analytics

@vercel
Copy link

vercel bot commented Jan 15, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
scripts-docs Ready Ready Preview, Comment Jan 20, 2026 8:46am
scripts-playground Ready Ready Preview, Comment Jan 20, 2026 8:46am

@pkg-pr-new
Copy link

pkg-pr-new bot commented Jan 15, 2026

Open in StackBlitz

npm i https://pkg.pr.new/nuxt/scripts/@nuxt/scripts@576

commit: 842f32f

- 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>
@harlan-zw harlan-zw changed the title feat: add partytown web worker support feat: nuxt/partytown support Jan 16, 2026
@harlan-zw harlan-zw changed the title feat: nuxt/partytown support feat: experimental nuxt/partytown support Jan 20, 2026
- 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>
Comment on lines +145 to +162
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}])')
})
Copy link

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.

Comment on lines +176 to +219
// 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(', ')}`)
}
}
Copy link

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:

  1. Set config in nuxt.config.ts:
export default defineNuxtConfig({
  scripts: {
    partytown: ['googleAnalytics'],
    registry: {
      googleAnalytics: { id: 'G-XXXXX' }
    }
  }
})
  1. Build/generate the project
  2. 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add web worker support (Partytown)

2 participants