(this, StateProp.loadHandlers);
+ return (callbacks && callbacks[0]) || null;
+ },
+ set(cb) {
+ // Map to onload - when readyState becomes 'complete', same as load
+ setInstanceStateValue(this, StateProp.loadHandlers, cb ? [cb] : null);
+ },
+ },
getAttribute: {
value(attrName: string) {
diff --git a/src/lib/web-worker/worker-storage.ts b/src/lib/web-worker/worker-storage.ts
index efa262fb..7b63be2a 100644
--- a/src/lib/web-worker/worker-storage.ts
+++ b/src/lib/web-worker/worker-storage.ts
@@ -59,18 +59,26 @@ export const addStorageApi = (
};
win[storageName] = new Proxy(storage, {
- get(target, key: string) {
+ get(target, key: PropertyKey) {
+ // Handle Symbol keys (like Symbol.iterator, Symbol.toStringTag) - return undefined
+ if (typeof key === 'symbol') {
+ return undefined;
+ }
if (Reflect.has(target, key)) {
return Reflect.get(target, key);
} else {
- return target.getItem(key);
+ return target.getItem(key as string);
}
},
- set(target, key: string, value: string): boolean {
- target.setItem(key, value);
+ set(target, key: PropertyKey, value: string): boolean {
+ // Ignore Symbol keys
+ if (typeof key === 'symbol') {
+ return true;
+ }
+ target.setItem(key as string, value);
return true;
},
- has(target, key: PropertyKey | string): boolean {
+ has(target, key: PropertyKey): boolean {
if (Reflect.has(target, key)) {
return true;
} else if (typeof key === 'string') {
@@ -79,8 +87,11 @@ export const addStorageApi = (
return false;
}
},
- deleteProperty(target, key: string): boolean {
- target.removeItem(key);
+ deleteProperty(target, key: PropertyKey): boolean {
+ if (typeof key === 'symbol') {
+ return true;
+ }
+ target.removeItem(key as string);
return true;
},
});
diff --git a/src/lib/web-worker/worker-window.ts b/src/lib/web-worker/worker-window.ts
index d7341f13..e1efcb72 100644
--- a/src/lib/web-worker/worker-window.ts
+++ b/src/lib/web-worker/worker-window.ts
@@ -51,6 +51,7 @@ import {
getConstructorName,
len,
randomId,
+ testIfShouldUseNoCors,
} from '../utils';
import {
getInstanceStateValue,
@@ -374,6 +375,10 @@ export const createWindow = (
return win[propName];
}
},
+ set: (win, propName: any, value: any) => {
+ win[propName] = value;
+ return true;
+ },
has: () =>
// window "has" any and all props, this is especially true for global variables
// that are meant to be assigned to window, but without "window." prefix,
@@ -460,6 +465,59 @@ export const createWindow = (
}
win.Worker = undefined;
+
+ // Pre-initialize dataLayer as a REAL array stored separately
+ // to avoid Partytown's proxy serialization issues (objects becoming instance IDs)
+ if (!(win as any)._ptRealDataLayer) {
+ const realDataLayer: any[] = [];
+ (win as any)._ptRealDataLayer = realDataLayer;
+
+ // Define dataLayer as a getter/setter that uses the real array
+ // This prevents GTM from replacing our array with one containing instance IDs
+ Object.defineProperty(win, 'dataLayer', {
+ get: () => (win as any)._ptRealDataLayer,
+ set: (newValue: any) => {
+ // If someone tries to replace dataLayer, preserve our array but copy the enhanced push
+ if (Array.isArray(newValue)) {
+ const real = (win as any)._ptRealDataLayer;
+ // Copy push method if it's GTM's enhanced version
+ if (newValue.push && newValue.push !== Array.prototype.push) {
+ real.push = newValue.push;
+ }
+ }
+ // Don't actually replace the array - this prevents corruption
+ },
+ configurable: true,
+ enumerable: true,
+ });
+ }
+
+ // Pre-initialize gtag function for GA4 compatibility
+ // GA4/gtag.js expects this function to exist before it loads
+ if (typeof (win as any).gtag !== 'function') {
+ (win as any).gtag = function() {
+ (win as any).dataLayer.push(arguments);
+ };
+ }
+
+ // Polyfill for dynamic import() - fetches and executes scripts
+ // This allows gtag.js and similar scripts to load modules in the worker
+ (win as any).__pt_import__ = async (url: string) => {
+ try {
+ const resolvedUrl = resolveUrl(env, url, 'script');
+ const response = await fetch(resolvedUrl);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch module: ${response.status}`);
+ }
+ const scriptContent = await response.text();
+ const fn = new Function(scriptContent);
+ fn.call(win);
+ return {};
+ } catch (error) {
+ console.error('[Partytown] __pt_import__ error:', error);
+ throw error;
+ }
+ };
}
addEventListener = (...args: any[]) => {
@@ -486,7 +544,17 @@ export const createWindow = (
fetch(input: string | URL | Request, init: any) {
input = typeof input === 'string' || input instanceof URL ? String(input) : input.url;
- return fetch(resolveUrl(env, input, 'fetch'), init);
+ const resolvedUrl = resolveUrl(env, input, 'fetch');
+
+ // Check if this URL should use no-cors mode
+ // This is useful for tracking/analytics URLs that fail due to CORS
+ // but don't need response data (fire-and-forget requests)
+ const shouldUseNoCors = testIfShouldUseNoCors(webWorkerCtx.$config$, input);
+ if (shouldUseNoCors) {
+ init = { ...init, mode: 'no-cors', credentials: 'include' };
+ }
+
+ return (self as any).fetch(resolvedUrl, init);
}
get frames() {
diff --git a/test-results/.last-run.json b/test-results/.last-run.json
new file mode 100644
index 00000000..cbcc1fba
--- /dev/null
+++ b/test-results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+}
\ No newline at end of file
diff --git a/tests/integrations/main-thread-iframe/index.html b/tests/integrations/main-thread-iframe/index.html
new file mode 100644
index 00000000..654fa70c
--- /dev/null
+++ b/tests/integrations/main-thread-iframe/index.html
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+ Load iframes on main thread
+
+
+
+
+
+
+
+ Load iframes on main thread
+
+
+ -
+ Main thread iframe loaded:
+
waiting...
+
+ -
+ Main thread iframe (regex) loaded:
+
waiting...
+
+ -
+ Worker iframe loaded:
+
waiting...
+
+
+
+
+
+
+
+
+
+ All Tests
+
+
diff --git a/tests/integrations/main-thread-iframe/main-thread-iframe.spec.ts b/tests/integrations/main-thread-iframe/main-thread-iframe.spec.ts
new file mode 100644
index 00000000..7b218bde
--- /dev/null
+++ b/tests/integrations/main-thread-iframe/main-thread-iframe.spec.ts
@@ -0,0 +1,73 @@
+import { test, expect } from '@playwright/test';
+
+test('loadIframesOnMainThread - string match', async ({ page }) => {
+ await page.goto('/tests/integrations/main-thread-iframe/');
+
+ await page.waitForSelector('.completed');
+
+ // Check that the main thread iframe loaded successfully
+ const mainThreadStatus = page.locator('#mainThreadIframeLoaded');
+ await expect(mainThreadStatus).toHaveText('loaded');
+
+ // Verify the iframe element exists and has the correct src
+ const mainThreadIframe = page.locator('#mainThreadIframe');
+ await expect(mainThreadIframe).toBeVisible();
+
+ // The src should be set correctly
+ const src = await mainThreadIframe.getAttribute('src');
+ expect(src).toContain('test-iframe.html');
+});
+
+test('loadIframesOnMainThread - regex match', async ({ page }) => {
+ await page.goto('/tests/integrations/main-thread-iframe/');
+
+ await page.waitForSelector('.completed');
+
+ // Check that the regex-matched iframe loaded successfully
+ const regexStatus = page.locator('#regexIframeLoaded');
+ await expect(regexStatus).toHaveText('loaded');
+
+ // Verify the iframe element exists
+ const regexIframe = page.locator('#regexIframe');
+ await expect(regexIframe).toBeVisible();
+
+ const src = await regexIframe.getAttribute('src');
+ expect(src).toContain('main-thread-target.html');
+});
+
+test('loadIframesOnMainThread - worker handled iframe', async ({ page }) => {
+ await page.goto('/tests/integrations/main-thread-iframe/');
+
+ await page.waitForSelector('.completed');
+
+ // Check that the worker-handled iframe loaded
+ // (might be 'loaded' or 'error' depending on CORS, but should not crash)
+ const workerStatus = page.locator('#workerIframeLoaded');
+ const statusText = await workerStatus.textContent();
+ expect(['loaded', 'error']).toContain(statusText);
+
+ // Verify the iframe element exists
+ const workerIframe = page.locator('#workerIframe');
+ await expect(workerIframe).toBeVisible();
+});
+
+test('loadIframesOnMainThread - iframes exist with correct src', async ({ page }) => {
+ await page.goto('/tests/integrations/main-thread-iframe/');
+
+ await page.waitForSelector('.completed');
+
+ // Verify main thread iframes are created with correct src attributes
+ // Note: We can't always access cross-origin iframe content, but we can verify
+ // that the iframes are properly created with the correct src
+ const mainThreadIframe = page.locator('#mainThreadIframe');
+ await expect(mainThreadIframe).toBeVisible();
+ const mainSrc = await mainThreadIframe.getAttribute('src');
+ expect(mainSrc).toBeTruthy();
+ expect(mainSrc).toContain('test-iframe.html');
+
+ const regexIframe = page.locator('#regexIframe');
+ await expect(regexIframe).toBeVisible();
+ const regexSrc = await regexIframe.getAttribute('src');
+ expect(regexSrc).toBeTruthy();
+ expect(regexSrc).toContain('main-thread-target.html');
+});
diff --git a/tests/integrations/main-thread-iframe/main-thread-target.html b/tests/integrations/main-thread-iframe/main-thread-target.html
new file mode 100644
index 00000000..e579ca68
--- /dev/null
+++ b/tests/integrations/main-thread-iframe/main-thread-target.html
@@ -0,0 +1,22 @@
+
+
+
+
+ Regex Matched Iframe (Main Thread)
+
+
+ Regex-matched iframe loaded on main thread!
+
+
+
diff --git a/tests/integrations/main-thread-iframe/test-iframe.html b/tests/integrations/main-thread-iframe/test-iframe.html
new file mode 100644
index 00000000..0e04f87c
--- /dev/null
+++ b/tests/integrations/main-thread-iframe/test-iframe.html
@@ -0,0 +1,25 @@
+
+
+
+
+ Test Iframe (Main Thread)
+
+
+ Main thread iframe loaded successfully!
+
+
+
diff --git a/tests/integrations/main-thread-iframe/worker-handled-iframe.html b/tests/integrations/main-thread-iframe/worker-handled-iframe.html
new file mode 100644
index 00000000..0db99917
--- /dev/null
+++ b/tests/integrations/main-thread-iframe/worker-handled-iframe.html
@@ -0,0 +1,22 @@
+
+
+
+
+ Worker Handled Iframe
+
+
+ This iframe is handled by Partytown worker.
+
+
+
diff --git a/tests/unit/utils.spec.ts b/tests/unit/utils.spec.ts
index 29939bc4..e0417839 100644
--- a/tests/unit/utils.spec.ts
+++ b/tests/unit/utils.spec.ts
@@ -1,5 +1,9 @@
import * as assert from 'uvu/assert';
-import { createElementFromConstructor } from '../../src/lib/utils';
+import {
+ createElementFromConstructor,
+ testIfMustLoadIframeOnMainThread,
+ testIfMustLoadScriptOnMainThread,
+} from '../../src/lib/utils';
import { suite } from './utils';
const test = suite();
@@ -51,4 +55,71 @@ test('createElementFromConstructor, HTML', ({ doc }) => {
assert.is(createElementFromConstructor(doc, 'IntersectionObserver'), undefined);
});
+test('testIfMustLoadIframeOnMainThread - string match', ({ config }) => {
+ config.loadIframesOnMainThread = [
+ ['string', 'https://www.googletagmanager.com/static/service_worker'],
+ ];
+ assert.is(
+ testIfMustLoadIframeOnMainThread(
+ config,
+ 'https://www.googletagmanager.com/static/service_worker/123/sw_iframe.html'
+ ),
+ true
+ );
+ assert.is(
+ testIfMustLoadIframeOnMainThread(config, 'https://example.com/iframe.html'),
+ false
+ );
+});
+
+test('testIfMustLoadIframeOnMainThread - regex match', ({ config }) => {
+ config.loadIframesOnMainThread = [['regexp', 'googletagmanager\\.com.*sw_iframe']];
+ assert.is(
+ testIfMustLoadIframeOnMainThread(
+ config,
+ 'https://www.googletagmanager.com/static/service_worker/123/sw_iframe.html'
+ ),
+ true
+ );
+ assert.is(
+ testIfMustLoadIframeOnMainThread(config, 'https://www.googletagmanager.com/gtm.js'),
+ false
+ );
+});
+
+test('testIfMustLoadIframeOnMainThread - multiple patterns', ({ config }) => {
+ config.loadIframesOnMainThread = [
+ ['string', 'https://example.com/special-iframe.html'],
+ ['regexp', 'googletagmanager\\.com'],
+ ];
+ assert.is(
+ testIfMustLoadIframeOnMainThread(config, 'https://example.com/special-iframe.html'),
+ true
+ );
+ assert.is(
+ testIfMustLoadIframeOnMainThread(
+ config,
+ 'https://www.googletagmanager.com/sw_iframe.html'
+ ),
+ true
+ );
+ assert.is(testIfMustLoadIframeOnMainThread(config, 'https://other.com/iframe.html'), false);
+});
+
+test('testIfMustLoadIframeOnMainThread - empty config', ({ config }) => {
+ config.loadIframesOnMainThread = undefined;
+ assert.is(
+ testIfMustLoadIframeOnMainThread(config, 'https://www.googletagmanager.com/sw_iframe.html'),
+ false
+ );
+});
+
+test('testIfMustLoadIframeOnMainThread - empty array', ({ config }) => {
+ config.loadIframesOnMainThread = [];
+ assert.is(
+ testIfMustLoadIframeOnMainThread(config, 'https://www.googletagmanager.com/sw_iframe.html'),
+ false
+ );
+});
+
test.run();