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
Contributor

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 Error Error Jan 24, 2026 2:58pm
scripts-playground Ready Ready Preview, Comment Jan 24, 2026 2:58pm

@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: 361d975

- 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
Contributor

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
Contributor

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>
@coderabbitai
Copy link

coderabbitai bot commented Jan 24, 2026

Warning

Rate limit exceeded

@harlan-zw has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 10 minutes and 10 seconds before requesting another review.

βŒ› How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

πŸ“ Walkthrough

Walkthrough

Adds Partytown support to Nuxt Scripts. Introduces a new partytown?: boolean option to useScript and the module options, updates runtime to emit scripts with type="text/partytown" when enabled, and auto-configures Partytown forwards in the module. Adds docs for partytown, a devDependency on @nuxtjs/partytown, new fixtures, unit and e2e tests, and template generation updates to serialize script registry entries and handle triggers via scriptOptions.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

πŸš₯ Pre-merge checks | βœ… 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
βœ… Passed checks (4 passed)
Check name Status Explanation
Title check βœ… Passed The title 'feat: experimental nuxt/partytown support' clearly and concisely summarizes the main change: adding experimental Partytown support to Nuxt scripts.
Description check βœ… Passed The PR description includes all required template sections: linked issue (#182), type of change (new feature selected), and comprehensive description of features, usage, and known limitations.
Linked Issues check βœ… Passed The PR successfully implements all core objectives from issue #182: provides partytown flag on useScript, integrates with @nuxtjs/partytown, supports module-level configuration, auto-configures forwards for supported scripts, and preserves existing useScript behavior.
Out of Scope Changes check βœ… Passed All changes are directly related to Partytown support: new partytown option in types/composables, module configuration, documentation, tests, and fixtures. No unrelated changes detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch partytown

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❀️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/module.ts (1)

203-258: Keep runtimeConfig in sync after Partytown shorthand mutates the registry.

The runtimeConfig merge runs before config.partytown mutates/creates config.registry. If a user only sets scripts.partytown (or if existing entries get wrapped), runtimeConfig.public.scripts can be stale, which may desync downstream consumers (e.g., bundle transformer). Re-merge after Partytown changes or move the merge block below.

βœ… Suggested fix (re-sync after Partytown processing)
@@
     if (config.partytown?.length) {
       config.registry = config.registry || {}
       const requiredForwards: string[] = []
@@
       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(', ')}`)
       }
     }
+
+    // Re-sync runtimeConfig after Partytown shorthand updates registry
+    if (config.partytown?.length && config.registry) {
+      nuxt.options.runtimeConfig.public = nuxt.options.runtimeConfig.public || {}
+      nuxt.options.runtimeConfig.public.scripts = defu(
+        nuxt.options.runtimeConfig.public.scripts || {},
+        config.registry,
+      )
+    }
πŸ€– Fix all issues with AI agents
In `@src/runtime/composables/useScript.ts`:
- Around line 25-43: The partytown early-return stub in useScript currently
returns status as a plain string; change it to a reactive Ref (e.g., use Vue's
ref('loaded')) so the returned
UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>> exposes
status.value correctly, and also register the stub entry with nuxtApp.$scripts
(push an appropriate record or call the same registration helper used for
non-partytown scripts) so DevTools sees the script; ensure you import/ref from
Vue if not already and update the returned object to use the ref instance for
the status field and add the registration step referencing useScript,
UseScriptContext, and nuxtApp.$scripts.

In `@test/e2e/partytown.test.ts`:
- Around line 44-48: The page.evaluate snippet that builds partytownLib can
throw when s.src is null for inline scripts; update the predicate used inside
Array.from(document.querySelectorAll('script')) (the arrow function that checks
s.id === 'partytown' || s.src.includes('partytown')) to guard s.src before
calling includes (e.g., check s.src exists or use optional chaining) so inline
scripts with null src won't cause an exception when evaluating partytownLib.
♻️ Duplicate comments (1)
test/unit/templates.test.ts (1)

145-162: Align registry array expectation with merged options (if that’s the intended API).

Line 161 currently asserts an array argument; this locks in the same behavior previously flagged for registry arrays. If the registry shorthand is meant to merge input + options into one argument (as globals do), adjust the expectation.

Potential adjustment
-    expect(res).toContain('useScriptGoogleAnalytics([{"id":"G-XXXXX"},{"partytown":true}])')
+    expect(res).toContain('useScriptGoogleAnalytics({"id":"G-XXXXX","partytown":true})')
🧹 Nitpick comments (4)
test/e2e/basic.test.ts (1)

183-191: Prefer waiting for the script element instead of fixed sleeps.

Line 185 uses a fixed timeout; this can be flaky under slower CI. Waiting for the script tag makes the assertion deterministic.

Proposed change
-    await page.waitForTimeout(500)
-    // verify the script tag has type="text/partytown"
-    const scriptType = await page.evaluate(() => {
-      const script = document.querySelector('script[src="/myScript.js"]')
-      return script?.getAttribute('type')
-    })
+    await page.waitForSelector('script[src="/myScript.js"]')
+    // verify the script tag has type="text/partytown"
+    const scriptType = await page.$eval(
+      'script[src="/myScript.js"]',
+      el => el.getAttribute('type'),
+    )
src/runtime/composables/useScript.ts (1)

32-34: Consider passing through additional script attributes.

Only src and type are passed to useHead. If users specify attributes like crossorigin, referrerpolicy, or custom attributes in the input, they're silently ignored for Partytown scripts.

Optional enhancement
     useHead({
-      script: [{ src, type: 'text/partytown' }],
+      script: [{
+        ...input,
+        type: 'text/partytown',
+      }],
     })
docs/content/docs/3.api/1.use-script.md (1)

74-81: Documentation looks good; consider noting src requirement.

The documentation clearly explains the experimental nature and limitations. One minor addition: since the implementation throws an error when partytown: true is used without a src, it would be helpful to explicitly document this requirement.

Suggested addition
    * [Experimental] Load the script in a web worker using Partytown.
    * When enabled, adds `type="text/partytown"` to the script tag.
    * Requires `@nuxtjs/partytown` to be installed and configured separately.
+   * Requires a `src` attribute - inline scripts are not supported.
    * Note: Scripts requiring DOM access (GTM, Hotjar, chat widgets) are not compatible.
test/e2e/partytown.test.ts (1)

40-41: Replace hardcoded timeout with condition polling to reduce flakiness.

waitForTimeout(1000) is fragileβ€”slower CI environments may need longer, faster ones waste time. Polling for the expected condition is more reliable.

Proposed fix
-    // Wait for partytown to execute scripts
-    await page.waitForTimeout(1000)
+    // Wait for partytown to execute scripts (poll for console output)
+    await page.waitForFunction(
+      () => (window as any).__partytownWorkerRan === true,
+      { timeout: 5000 }
+    )

This assumes the worker script sets a flag. Alternatively, poll until the console log appears or use page.waitForEvent('console', ...).

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

πŸ€– Fix all issues with AI agents
In `@src/templates.ts`:
- Around line 116-127: The code embeds triggerResolved into
scriptOptions.trigger as a templated value which then gets JSON.stringify'd,
producing escaped quotes when triggerResolved contains JSON (e.g.,
trigger.interaction), so change the approach: set scriptOptions.trigger to a
fixed placeholder token (e.g., "__TRIGGER_PLACEHOLDER__") instead of
`__TRIGGER_${triggerResolved}__`, keep the import flags
(needsIdleTimeoutImport/needsInteractionImport) based on triggerResolved as you
already do, then after building the JSON string for args in the inits.push call,
replace the quoted placeholder string with the raw triggerResolved expression
(i.e., JSON.stringify(args).replace(/"__TRIGGER_PLACEHOLDER__"/g,
triggerResolved)) so the final emitted JS injects the unescaped trigger object;
update the line that sets scriptOptions.trigger and the replacement in the
inits.push accordingly (refer to resolveTriggerForTemplate,
needsIdleTimeoutImport, needsInteractionImport, and the inits.push constructing
const ${k} = ${importDefinition.import.name}(...)).

Comment on lines +116 to +127
else if (Array.isArray(c) && c.length === 2) {
// [input, options] format - unpack properly
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
}
scriptOptions.trigger = `__TRIGGER_${triggerResolved}__` as any
if (triggerResolved.includes('useScriptTriggerIdleTimeout')) needsIdleTimeoutImport = true
if (triggerResolved.includes('useScriptTriggerInteraction')) needsInteractionImport = true
}
const args = { ...input, scriptOptions }
inits.push(`const ${k} = ${importDefinition.import.name}(${JSON.stringify(args).replace(/"__TRIGGER_(.*?)__"/g, '$1')})`)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid escaped quotes in trigger placeholder serialization.

When trigger.interaction is used, resolveTriggerForTemplate returns a string containing JSON with double quotes. Embedding that in the placeholder and then JSON.stringify causes escaped quotes (\") to remain after replacement, producing invalid JS (e.g., events: [\"click\"]). Use a fixed placeholder and inject the raw expression after stringifying.

πŸ› οΈ Suggested fix
-        const scriptOptions = { ...c[1] }
-        const triggerResolved = resolveTriggerForTemplate(scriptOptions?.trigger)
+        const scriptOptions = { ...c[1] }
+        let triggerResolved: string | null = null
+        let triggerPlaceholder: string | null = null
+        triggerResolved = resolveTriggerForTemplate(scriptOptions?.trigger)
         if (triggerResolved) {
-          scriptOptions.trigger = `__TRIGGER_${triggerResolved}__` as any
+          triggerPlaceholder = '__TRIGGER__'
+          scriptOptions.trigger = triggerPlaceholder as any
           if (triggerResolved.includes('useScriptTriggerIdleTimeout')) needsIdleTimeoutImport = true
           if (triggerResolved.includes('useScriptTriggerInteraction')) needsInteractionImport = true
         }
         const args = { ...input, scriptOptions }
-        inits.push(`const ${k} = ${importDefinition.import.name}(${JSON.stringify(args).replace(/"__TRIGGER_(.*?)__"/g, '$1')})`)
+        let argsString = JSON.stringify(args)
+        if (triggerResolved && triggerPlaceholder) {
+          argsString = argsString.replace(`"${triggerPlaceholder}"`, triggerResolved)
+        }
+        inits.push(`const ${k} = ${importDefinition.import.name}(${argsString})`)
πŸ€– Prompt for AI Agents
In `@src/templates.ts` around lines 116 - 127, The code embeds triggerResolved
into scriptOptions.trigger as a templated value which then gets
JSON.stringify'd, producing escaped quotes when triggerResolved contains JSON
(e.g., trigger.interaction), so change the approach: set scriptOptions.trigger
to a fixed placeholder token (e.g., "__TRIGGER_PLACEHOLDER__") instead of
`__TRIGGER_${triggerResolved}__`, keep the import flags
(needsIdleTimeoutImport/needsInteractionImport) based on triggerResolved as you
already do, then after building the JSON string for args in the inits.push call,
replace the quoted placeholder string with the raw triggerResolved expression
(i.e., JSON.stringify(args).replace(/"__TRIGGER_PLACEHOLDER__"/g,
triggerResolved)) so the final emitted JS injects the unescaped trigger object;
update the line that sets scriptOptions.trigger and the replacement in the
inits.push accordingly (refer to resolveTriggerForTemplate,
needsIdleTimeoutImport, needsInteractionImport, and the inits.push constructing
const ${k} = ${importDefinition.import.name}(...)).

@harlan-zw harlan-zw merged commit b67c9a3 into main Jan 24, 2026
10 of 11 checks passed
@harlan-zw harlan-zw deleted the partytown branch January 24, 2026 15:00
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