diff --git a/docs/src/routes/configuration/index.mdx b/docs/src/routes/configuration/index.mdx index 9810deda..4617f058 100644 --- a/docs/src/routes/configuration/index.mdx +++ b/docs/src/routes/configuration/index.mdx @@ -12,6 +12,7 @@ Partytown does not require a config for it to work, however a config can be set | `forward` | An array of strings representing function calls on the main thread to forward to the web worker. See [Forwarding Events and Triggers](/forwarding-events) for more info. | | `lib` | Path where the Partytown library can be found your server. Note that the path must both start and end with a `/` character, and the files must be hosted from the same origin as the webpage. Default is `/~partytown/` | | `loadScriptsOnMainThread` | An array of strings or regular expressions (RegExp) used to filter out which script are executed via Partytown and the main thread. An example is as follows: `loadScriptsOnMainThread: ["https://test.com/analytics.js", "inline-script-id", /regex-matched-script\.js/]`. | +| `loadIframesOnMainThread` | An array of strings or regular expressions (RegExp) used to filter which iframes are loaded directly on the main thread instead of being handled by Partytown's worker. This is essential for iframes that require service worker registration, cross-origin cookie access, or other main-thread-only capabilities. See [Google Tag Manager Compatibility](#google-tag-manager-sw_iframe-compatibility) for more info. | | `resolveUrl` | Hook that is called to resolve URLs which can be used to modify URLs. The hook uses the API: `resolveUrl(url: URL, location: URL, method: string)`. See the [Proxying Requests](/proxying-requests) for more information. | | `nonce` | The nonce property may be set on script elements created by Partytown. This should be set only when dealing with content security policies and when the use of `unsafe-inline` is disabled (using `nonce-*` instead). | | `fallbackTimeout` | A timeout in ms until Partytown initialization is considered as failed & fallbacks to the regular execution in main thread. Default is 9999 | @@ -40,3 +41,36 @@ What we mean by "vanilla config", is that the Partytown config can be set withou ``` Please see the [integration guides](/integrations) for more information. + +## Google Tag Manager sw_iframe Compatibility + +Google Tag Manager (GTM) uses a service worker iframe (`sw_iframe.html`) for certain features like: + +- Enhanced Conversions for Google Ads +- Cross-origin cookie access for retargeting +- Remarketing audience building + +By default, Partytown intercepts iframe creation and attempts to load the content via XHR, which fails for `sw_iframe.html` due to CORS restrictions. Additionally, service worker registration requires the main thread and cannot be done from within a web worker. + +To resolve this, use the `loadIframesOnMainThread` configuration to allow these iframes to load directly on the main thread: + +```html + +``` + +This configuration ensures that: + +1. The iframe is created directly on the main thread +2. The browser loads the iframe content natively (no XHR interception) +3. Service workers can be registered normally +4. Cross-origin cookie access works as expected for Google Ads diff --git a/src/integration/api.md b/src/integration/api.md index 2e13163d..569f77e8 100644 --- a/src/integration/api.md +++ b/src/integration/api.md @@ -26,6 +26,7 @@ export interface PartytownConfig { get?: GetHook; globalFns?: string[]; lib?: string; + loadIframesOnMainThread?: (string | RegExp)[]; loadScriptsOnMainThread?: (string | RegExp)[]; logCalls?: boolean; logGetters?: boolean; @@ -37,6 +38,7 @@ export interface PartytownConfig { logStackTraces?: boolean; // (undocumented) mainWindowAccessors?: string[]; + noCorsUrls?: (string | RegExp)[]; nonce?: string; // Warning: (ae-forgotten-export) The symbol "SendBeaconParameters" needs to be exported by the entry point index.d.ts resolveSendBeaconRequestParameters?(url: URL, location: Location): SendBeaconParameters | undefined | null; diff --git a/src/lib/atomics/sync-create-messenger-atomics.ts b/src/lib/atomics/sync-create-messenger-atomics.ts index 513bc155..12ddf25f 100644 --- a/src/lib/atomics/sync-create-messenger-atomics.ts +++ b/src/lib/atomics/sync-create-messenger-atomics.ts @@ -9,7 +9,9 @@ import { onMessageFromWebWorker } from '../sandbox/on-messenge-from-worker'; import { readMainInterfaces, readMainPlatform } from '../sandbox/read-main-platform'; const createMessengerAtomics: Messenger = async (receiveMessage) => { - const size = 1024 * 1024 * 1024; + // Use a reasonable size for the shared buffer (64MB should be plenty for most responses) + // A 1GB buffer was causing issues in some browsers + const size = 64 * 1024 * 1024; const sharedDataBuffer = new SharedArrayBuffer(size); const sharedData = new Int32Array(sharedDataBuffer); diff --git a/src/lib/sandbox/main-access-handler.ts b/src/lib/sandbox/main-access-handler.ts index d7c4d372..95223b68 100644 --- a/src/lib/sandbox/main-access-handler.ts +++ b/src/lib/sandbox/main-access-handler.ts @@ -13,6 +13,7 @@ import { deserializeFromWorker, serializeForWorker } from './main-serialization' import { getInstance, setInstanceId } from './main-instances'; import { normalizedWinId } from '../log'; import { winCtxs } from './main-constants'; +import { mainWindow, shouldLoadIframeOnMainThread } from './main-globals'; export const mainAccessHandler = async ( worker: PartytownWebWorker, @@ -169,7 +170,35 @@ const applyToInstance = ( // previous is the setter name // current is the setter value // next tells us this was a setter - instance[previous] = deserializeFromWorker(worker, current); + const value = deserializeFromWorker(worker, current); + + // Check if this is an iframe src being set that should load on main thread + if ( + previous === 'src' && + instance?.nodeName === 'IFRAME' && + typeof value === 'string' && + shouldLoadIframeOnMainThread(value) + ) { + // Create iframe directly in the main document (parent of sandbox) + // so it can register service workers and access cookies properly + const mainThreadIframe = mainWindow.document.createElement('iframe'); + mainThreadIframe.src = value; + mainThreadIframe.style.display = 'none'; + mainThreadIframe.setAttribute('data-partytown-main-thread', 'true'); + + // Append to body or sandboxParent in main document + const sandboxParent = + mainWindow.document.querySelector( + mainWindow.partytown?.sandboxParent || 'body' + ) || mainWindow.document.body; + sandboxParent.appendChild(mainThreadIframe); + + // Don't set src on the sandbox iframe to avoid duplicate loading + // The worker state will still reference this iframe but it won't load + return; + } + + instance[previous] = value; // setters never return a value return; diff --git a/src/lib/sandbox/main-forward-trigger.ts b/src/lib/sandbox/main-forward-trigger.ts index cd788145..fc8659c5 100644 --- a/src/lib/sandbox/main-forward-trigger.ts +++ b/src/lib/sandbox/main-forward-trigger.ts @@ -13,7 +13,7 @@ export const mainForwardTrigger = (worker: PartytownWebWorker, $winId$: WinId, w let i: number; let mainForwardFn: typeof win; - let forwardCall = ($forward$: string[], args: any) => + let forwardCall = ($forward$: string[], args: any) => { worker.postMessage([ WorkerMessageType.ForwardMainTrigger, { @@ -22,6 +22,7 @@ export const mainForwardTrigger = (worker: PartytownWebWorker, $winId$: WinId, w $args$: serializeForWorker($winId$, Array.from(args)), }, ]); + }; win._ptf = undefined; diff --git a/src/lib/sandbox/main-globals.ts b/src/lib/sandbox/main-globals.ts index f760db5d..5dc62e8c 100644 --- a/src/lib/sandbox/main-globals.ts +++ b/src/lib/sandbox/main-globals.ts @@ -6,3 +6,33 @@ export const docImpl = document.implementation.createHTMLDocument(); export const config: PartytownConfig = mainWindow.partytown || {}; export const libPath = (config.lib || '/~partytown/') + (debug ? 'debug/' : ''); + +/** + * Check if an iframe URL should be loaded on the main thread instead of inside the sandbox. + * Handles both original format (string | RegExp)[] and serialized format ['regexp'|'string', pattern][]. + */ +export const shouldLoadIframeOnMainThread = (url: string): boolean => { + const patterns = config.loadIframesOnMainThread; + if (!patterns) return false; + + return patterns.some((pattern: any) => { + // Handle serialized format: ['regexp', 'pattern'] or ['string', 'pattern'] + if (Array.isArray(pattern)) { + const [type, value] = pattern; + if (type === 'regexp') { + return new RegExp(value).test(url); + } else if (type === 'string') { + return url.includes(value); + } + return false; + } + // Handle original format: string or RegExp + if (typeof pattern === 'string') { + return url.includes(pattern); + } + if (pattern instanceof RegExp) { + return pattern.test(url); + } + return false; + }); +}; diff --git a/src/lib/types.ts b/src/lib/types.ts index e3c17e1b..0a52435c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -472,6 +472,37 @@ export interface PartytownConfig { * // Loads the `https://test.com/analytics.js` script on the main thread */ loadScriptsOnMainThread?: (string | RegExp)[]; + /** + * This array can be used to filter which iframes are loaded via + * Partytown's worker and which should load directly on the main thread. + * This is particularly useful for iframes that require service worker + * registration, cross-origin cookie access, or other main-thread-only + * capabilities (e.g., Google Tag Manager's sw_iframe.html). + * + * When an iframe's `src` matches a pattern in this list, Partytown will: + * - Create the iframe on the main thread + * - Allow it to load naturally without worker interception + * - Enable full browser capabilities (service workers, cookies, etc.) + * + * @example loadIframesOnMainThread:['https://www.googletagmanager.com/static/service_worker', /googletagmanager\.com/] + * // Allows GTM service worker iframes to load on the main thread + */ + loadIframesOnMainThread?: (string | RegExp)[]; + /** + * An array of URL patterns for which fetch() requests should use + * `mode: 'no-cors'`. This is useful for third-party tracking pixels + * and conversion tracking URLs that don't need response data but + * fail due to CORS when running in the worker context. + * + * Note: With `no-cors` mode, the response body cannot be read, but the + * request is still sent to the server (fire-and-forget). This is suitable + * for tracking/analytics requests where you just need the request to reach + * the server. + * + * @example noCorsUrls: [/googleads\.g\.doubleclick\.net/, /google-analytics\.com/] + * // Makes fetch requests to Google Ads and Analytics use no-cors mode + */ + noCorsUrls?: (string | RegExp)[]; get?: GetHook; set?: SetHook; apply?: ApplyHook; @@ -552,8 +583,13 @@ export interface PartytownConfig { nonce?: string; } -export type PartytownInternalConfig = Omit & { +export type PartytownInternalConfig = Omit< + PartytownConfig, + 'loadScriptsOnMainThread' | 'loadIframesOnMainThread' | 'noCorsUrls' +> & { loadScriptsOnMainThread?: ['regexp' | 'string', string][]; + loadIframesOnMainThread?: ['regexp' | 'string', string][]; + noCorsUrls?: ['regexp' | 'string', string][]; }; /** diff --git a/src/lib/utils.ts b/src/lib/utils.ts index da23e419..129fa301 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -214,12 +214,34 @@ function escapeRegExp(input: string) { export function testIfMustLoadScriptOnMainThread( config: PartytownInternalConfig, - value: string + url: string ): boolean { return ( config.loadScriptsOnMainThread - ?.map(([type, value]) => new RegExp(type === 'string' ? escapeRegExp(value) : value)) - .some((regexp) => regexp.test(value)) ?? false + ?.map(([type, pattern]) => new RegExp(type === 'string' ? escapeRegExp(pattern) : pattern)) + .some((regexp) => regexp.test(url)) ?? false + ); +} + +export function testIfMustLoadIframeOnMainThread( + config: PartytownInternalConfig, + url: string +): boolean { + return ( + config.loadIframesOnMainThread + ?.map(([type, pattern]) => new RegExp(type === 'string' ? escapeRegExp(pattern) : pattern)) + .some((regexp) => regexp.test(url)) ?? false + ); +} + +export function testIfShouldUseNoCors( + config: PartytownInternalConfig, + url: string +): boolean { + return ( + config.noCorsUrls + ?.map(([type, pattern]) => new RegExp(type === 'string' ? escapeRegExp(pattern) : pattern)) + .some((regexp) => regexp.test(url)) ?? false ); } @@ -243,6 +265,30 @@ export function serializeConfig(config: PartytownConfig) { ] ) satisfies Required['loadScriptsOnMainThread']; } + if (key === 'loadIframesOnMainThread') { + value = ( + value as Required['loadIframesOnMainThread'] + ).map((iframeUrl) => + Array.isArray(iframeUrl) + ? iframeUrl + : [ + typeof iframeUrl === 'string' ? 'string' : 'regexp', + typeof iframeUrl === 'string' ? iframeUrl : iframeUrl.source, + ] + ) satisfies Required['loadIframesOnMainThread']; + } + if (key === 'noCorsUrls') { + value = ( + value as Required['noCorsUrls'] + ).map((url) => + Array.isArray(url) + ? url + : [ + typeof url === 'string' ? 'string' : 'regexp', + typeof url === 'string' ? url : url.source, + ] + ) satisfies Required['noCorsUrls']; + } return value; }); } diff --git a/src/lib/web-worker/init-web-worker.ts b/src/lib/web-worker/init-web-worker.ts index 4ec08132..4382baa9 100644 --- a/src/lib/web-worker/init-web-worker.ts +++ b/src/lib/web-worker/init-web-worker.ts @@ -1,5 +1,6 @@ import { commaSplit, webWorkerCtx } from './worker-constants'; import type { InitWebWorkerData, PartytownInternalConfig } from '../types'; +import { testIfShouldUseNoCors } from '../utils'; export const initWebWorker = (initWebWorkerData: InitWebWorkerData) => { const config: PartytownInternalConfig = (webWorkerCtx.$config$ = JSON.parse( @@ -18,6 +19,17 @@ export const initWebWorker = (initWebWorkerData: InitWebWorkerData) => { delete (self as any).postMessage; delete (self as any).WorkerGlobalScope; + // Patch self.fetch to support noCorsUrls config + // This is needed because minified code might call self.fetch() directly + const originalFetch = (self as any).fetch; + (self as any).fetch = (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (testIfShouldUseNoCors(webWorkerCtx.$config$, url)) { + init = { ...init, mode: 'no-cors', credentials: 'include' }; + } + return originalFetch(input, init); + }; + (commaSplit('resolveUrl,resolveSendBeaconRequestParameters,get,set,apply') as any).map( (configName: keyof PartytownInternalConfig) => { if (config[configName]) { diff --git a/src/lib/web-worker/worker-document.ts b/src/lib/web-worker/worker-document.ts index a9fbc86d..25e7a782 100644 --- a/src/lib/web-worker/worker-document.ts +++ b/src/lib/web-worker/worker-document.ts @@ -1,4 +1,4 @@ -import { callMethod, getter, setter } from './worker-proxy'; +import { blockingSetter, callMethod, getter, setter } from './worker-proxy'; import { CallType, NodeName, @@ -43,7 +43,7 @@ export const patchDocument = ( }, set(value) { if (env.$isSameOrigin$) { - setter(this, ['cookie'], value); + blockingSetter(this, ['cookie'], value); } else if (debug) { warnCrossOrigin('set', 'cookie', env); } diff --git a/src/lib/web-worker/worker-exec.ts b/src/lib/web-worker/worker-exec.ts index 2d5c37cb..221601e6 100644 --- a/src/lib/web-worker/worker-exec.ts +++ b/src/lib/web-worker/worker-exec.ts @@ -16,6 +16,7 @@ import { debug } from '../utils'; import { environments, partytownLibUrl, webWorkerCtx } from './worker-constants'; import { getOrCreateNodeInstance } from './worker-constructors'; import { getInstanceStateValue, setInstanceStateValue } from './worker-state'; +import { sendGA4Collect, setupHistoryChangeListener } from './worker-ga4-collect'; export const initNextScriptsInWebWorker = async (initScript: InitializeScriptData) => { let winId = initScript.$winId$; @@ -58,6 +59,22 @@ export const initNextScriptsInWebWorker = async (initScript: InitializeScriptDat scriptContent = await rsp.text(); env.$currentScriptId$ = instanceId; run(env, scriptContent, scriptOrgSrc || scriptSrc); + + // After gtag.js loads, send initial page_view and setup history listeners for SPA navigation + // GA4's automatic page_view doesn't work in Partytown because tidr.destination is broken + const isGtagJs = scriptOrgSrc?.includes('gtag/js') && scriptOrgSrc?.includes('id=G-'); + if (isGtagJs && !(env.$window$ as any)._ptPageViewSent) { + (env.$window$ as any)._ptPageViewSent = true; + + // Small delay to ensure cookies and GTM state are set + setTimeout(() => { + // Send initial page_view + sendGA4Collect(env.$window$, 'page_view', {}, { isPageView: true }); + + // Setup history listeners to fire page_view on SPA navigation + setupHistoryChangeListener(env.$window$); + }, 100); + } } runStateLoadHandlers(instance!, StateProp.loadHandlers); } else { @@ -120,18 +137,8 @@ export const runScriptContent = ( * Replace some `this` symbols with a new value. * Still not perfect, but might be better than a less advanced regex * Check out the tests for examples: tests/unit/worker-exec.spec.ts - * - * This still fails with simple strings like: - * 'sadly we fail at this simple string' - * - * One way to do that would be to remove all comments from code and do single / double quote counting - * per symbol. But this will still fail with evals. */ export const replaceThisInSource = (scriptContent: string, newThis: string): string => { - /** - * Best for now but not perfect - * We don't use Regex lookbehind, because of Safari - */ const FIND_THIS = /([a-zA-Z0-9_$\.\'\"\`])?(\.\.\.)?this(?![a-zA-Z0-9_$:])/g; return scriptContent.replace(FIND_THIS, (match, p1, p2) => { @@ -139,7 +146,6 @@ export const replaceThisInSource = (scriptContent: string, newThis: string): str if (p1 != null) { return prefix + 'this'; } - // If there was a preceding character, include it unchanged return prefix + newThis; }); }; @@ -150,14 +156,29 @@ export const run = (env: WebWorkerEnvironment, scriptContent: string, scriptUrl? // First we want to replace all `this` symbols let sourceWithReplacedThis = replaceThisInSource(scriptContent, '(thi$(this)?window:this)'); + // Replace dynamic import() calls with our custom polyfill + sourceWithReplacedThis = sourceWithReplacedThis.replace( + /\bimport\s*\(\s*([^)]+)\s*\)/g, + '__pt_import__($1)' + ); + + // Combine user-defined globalFns with GA4/GTM functions that should always be exposed + // This ensures function declarations inside the with() block become window properties + const ga4GlobalFns = ['gtag', 'ga', 'google_tag_manager', 'google_tag_data', 'dataLayer']; + const allGlobalFns = [...new Set([...(webWorkerCtx.$config$.globalFns || []), ...ga4GlobalFns])]; + + // Build the exposure code - this runs INSIDE the with block + // so that function declarations are accessible + const exposureCode = allGlobalFns + .filter((globalFnName) => /[a-zA-Z_$][0-9a-zA-Z_$]*/.test(globalFnName)) + .map((g) => `try{if(typeof ${g}!=='undefined'){this.${g}=${g};}}catch(e){}`) + .join(''); + scriptContent = `with(this){${sourceWithReplacedThis.replace( /\/\/# so/g, '//Xso' - )}\n;function thi$(t){return t===this}};${(webWorkerCtx.$config$.globalFns || []) - .filter((globalFnName) => /[a-zA-Z_$][0-9a-zA-Z_$]*/.test(globalFnName)) - .map((g) => `(typeof ${g}=='function'&&(this.${g}=${g}))`) - .join(';')};` + (scriptUrl ? '\n//# sourceURL=' + scriptUrl : ''); + )}\n;function thi$(t){return t===this};${exposureCode}};` + (scriptUrl ? '\n//# sourceURL=' + scriptUrl : ''); if (!env.$isSameOrigin$) { scriptContent = scriptContent.replace(/.postMessage\(/g, `.postMessage('${env.$winId$}',`); @@ -175,13 +196,20 @@ const runStateLoadHandlers = ( ) => { handlers = getInstanceStateValue(instance, type); if (handlers) { - setTimeout(() => handlers!.map((cb) => cb({ type }))); + const event = { + type, + target: instance, + currentTarget: instance, + srcElement: instance, + preventDefault: () => {}, + stopPropagation: () => {}, + stopImmediatePropagation: () => {}, + }; + setTimeout(() => handlers!.map((cb) => cb(event))); } }; export const insertIframe = (winId: WinId, iframe: WorkerInstance) => { - // an iframe element's instanceId is also - // the winId of its contentWindow let i = 0; let type: string; let handlers: EventHandler[]; diff --git a/src/lib/web-worker/worker-forwarded-trigger.ts b/src/lib/web-worker/worker-forwarded-trigger.ts index 86d89e3f..1673b499 100644 --- a/src/lib/web-worker/worker-forwarded-trigger.ts +++ b/src/lib/web-worker/worker-forwarded-trigger.ts @@ -2,6 +2,7 @@ import { deserializeFromMain } from './worker-serialization'; import { environments } from './worker-constants'; import type { ForwardMainTriggerData } from '../types'; import { len } from '../utils'; +import { sendGA4Collect, GA4_ECOMMERCE_EVENTS } from './worker-ga4-collect'; export const workerForwardedTriggerHandle = ({ $winId$, @@ -10,18 +11,63 @@ export const workerForwardedTriggerHandle = ({ }: ForwardMainTriggerData) => { // see src/lib/main/snippet.ts and src/lib/sandbox/main-forward-trigger.ts try { - let target: any = environments[$winId$].$window$; + const env = environments[$winId$]; + if (!env) { + return; + } + let target: any = env.$window$; + const win = env.$window$; let i = 0; let l = len($forward$); + // Check if this is a dataLayer.push for GA events + const forwardPath = $forward$.join('.'); + const isDataLayerPush = forwardPath === 'dataLayer.push'; + for (; i < l; i++) { if (i + 1 < l) { target = target[$forward$[i]]; } else { - target[$forward$[i]].apply(target, deserializeFromMain(null, $winId$, [], $args$)); + const args = deserializeFromMain(null, $winId$, [], $args$); + + // Execute the original dataLayer.push + target[$forward$[i]].apply(target, args); + + // Direct GA4 Measurement Protocol for GA events + // This bypasses GTM's broken internal processing in Partytown's sandbox + // while ensuring GA4 receives all event data + if (isDataLayerPush) { + const eventData = args?.[0]; + + // Handle multiple formats: + // 1. dataLayer.push({event: 'view_item', ...}) + // 2. gtag('event', 'view_item', {...}) + // 3. Array format ['event', 'view_item', {...}] + let eventName = eventData?.event; + let eventParams = eventData; + + // Check for gtag() Arguments format + if (!eventName && eventData?.[0] === 'event' && typeof eventData?.[1] === 'string') { + eventName = eventData[1]; + eventParams = eventData[2] || {}; + } + + // Check for array format + if (!eventName && Array.isArray(eventData) && eventData[0] === 'event') { + eventName = eventData[1]; + eventParams = eventData[2] || {}; + } + + // Send via GA4 Measurement Protocol if it's a supported event + if (eventName && GA4_ECOMMERCE_EVENTS.includes(eventName)) { + sendGA4Collect(win, eventName, eventParams, { + isPageView: eventName === 'page_view', + }); + } + } } } } catch (e) { - console.error(e); + console.error('[Partytown] Forward trigger error:', e); } }; diff --git a/src/lib/web-worker/worker-ga4-collect.ts b/src/lib/web-worker/worker-ga4-collect.ts new file mode 100644 index 00000000..7ef6ccff --- /dev/null +++ b/src/lib/web-worker/worker-ga4-collect.ts @@ -0,0 +1,363 @@ +/** + * GA4 Measurement Protocol Helper + * + * This module provides shared functionality for building and sending GA4 /collect requests. + * It reads from GTM's internal state (google_tag_data, google_tag_manager) when available + * to ensure parameters match what native GTM would send. + */ + +// Standard GA4 item parameter mappings +const STANDARD_ITEM_KEYS: Record = { + item_id: 'id', + item_name: 'nm', + affiliation: 'af', + item_brand: 'br', + item_variant: 'va', + item_category: 'ca', + item_category2: 'c2', + item_category3: 'c3', + item_category4: 'c4', + item_category5: 'c5', + location_id: 'lo', + price: 'pr', + quantity: 'qt', + coupon: 'cp', + discount: 'ds', + index: 'lp', + item_list_id: 'li', + item_list_name: 'ln', +}; + +// GA4 Measurement ID - should match your configuration +const GA4_MEASUREMENT_ID = 'G-52LKG2B3L1'; +const GTM_CONTAINER_ID = 'GTM-T5GF7DB'; + +/** + * Serialize an ecommerce item to GA4 format with custom parameter support + */ +export const serializeItem = (item: any): string => { + const parts: string[] = []; + let customIdx = 0; + + for (const [key, value] of Object.entries(item)) { + if (value === undefined || value === '' || value === null) continue; + + if (STANDARD_ITEM_KEYS[key]) { + parts.push(`${STANDARD_ITEM_KEYS[key]}${value}`); + } else if (!key.startsWith('_') && typeof value !== 'object') { + // Custom parameter - use k{n}key~v{n}value format + parts.push(`k${customIdx}${key}~v${customIdx}${value}`); + customIdx++; + } + } + + return parts.join('~'); +}; + +/** + * Build the gcd (Google Consent Default) string from consent entries + */ +const buildGcdString = (entries: any): string => { + const getConsentChar = (entry: any) => { + if (!entry) return 'l'; + if (entry.granted === true) return '1'; + if (entry.denied === true) return '3'; + return 'l'; // implicit/not set + }; + + // gcd format: prefix + consent states + return ( + '13' + + getConsentChar(entries.ad_storage) + + getConsentChar(entries.analytics_storage) + + getConsentChar(entries.ad_user_data) + + getConsentChar(entries.ad_personalization) + + 'l1l1' + ); +}; + +/** + * Extract parameters from GTM's internal state + */ +const getGTMInternalParams = (win: any): Record => { + const params: Record = {}; + const gtd = win.google_tag_data; + const gtm = win.google_tag_manager; + + if (!gtd) return params; + + // User Agent Client Hints from google_tag_data.uach + const uach = gtd.uach; + if (uach) { + if (uach.architecture) params.uaa = uach.architecture; + if (uach.bitness) params.uab = uach.bitness; + if (uach.platform) params.uap = uach.platform; + if (uach.platformVersion) params.uapv = uach.platformVersion; + params.uaw = uach.mobile ? '1' : '0'; + params.uamb = uach.mobile ? '1' : '0'; + if (uach.model !== undefined) params.uam = uach.model; + + if (uach.fullVersionList && Array.isArray(uach.fullVersionList)) { + params.uafvl = uach.fullVersionList + .map((b: any) => `${encodeURIComponent(b.brand)};${b.version}`) + .join('|'); + } + } + + // Privacy Sandbox CDL + if (gtm?.pscdl) { + params.pscdl = gtm.pscdl; + } + + // Tag experiments from tidr and xcd + const experiments: (string | number)[] = []; + try { + const loadExp = gtd.tidr?.container?.[GA4_MEASUREMENT_ID]?.context?.loadExperiments; + if (Array.isArray(loadExp)) { + experiments.push(...loadExp); + } + + const pageExp = gtd.xcd?.page_experiment_ids?.get?.(); + if (pageExp?.exp) { + Object.keys(pageExp.exp).forEach((id) => experiments.push(id)); + } + } catch { + // Ignore errors reading experiments + } + + if (experiments.length) { + params.tag_exp = [...new Set(experiments.map(String))].join('~'); + } + + // Consent state -> gcd parameter + const ics = gtd.ics?.entries; + if (ics) { + params.gcd = buildGcdString(ics); + } + + return params; +}; + +/** + * Build the gtm version parameter from container info + */ +const buildGtmParam = (win: any): string => { + const base = '45je61d1'; + try { + const tidr = win.google_tag_data?.tidr?.container; + const ga4Container = tidr?.[GA4_MEASUREMENT_ID]; + const gtmContainer = tidr?.[GTM_CONTAINER_ID]; + + if (ga4Container?.canonicalContainerId) { + let gtmParam = base + 'v8' + ga4Container.canonicalContainerId; + if (gtmContainer?.canonicalContainerId) { + gtmParam += 'z8' + gtmContainer.canonicalContainerId; + } + return gtmParam; + } + } catch { + // Ignore errors + } + return base; +}; + +/** + * Extract client ID from _ga cookie + */ +const getClientId = (cookies: string): string => { + const gaCookie = cookies.match(/_ga=([^;]+)/)?.[1]; + return gaCookie?.split('.')?.slice(-2)?.join('.') || 'fallback.' + Date.now(); +}; + +/** + * Extract session info from _ga_XXXXX cookie + */ +const getSessionInfo = (cookies: string): { sid: string; sct: string } => { + const sessionCookiePattern = new RegExp(`_ga_${GA4_MEASUREMENT_ID.replace('G-', '')}=([^;]+)`); + const sessionCookie = cookies.match(sessionCookiePattern)?.[1]; + + if (sessionCookie) { + // Format: GS2.1.s{timestamp}$o{count}$g{engaged}$t{timestamp}... + const sidMatch = sessionCookie.match(/\.s(\d+)/); + const sctMatch = sessionCookie.match(/\$o(\d+)/); + + return { + sid: sidMatch?.[1] || Date.now().toString(), + sct: sctMatch?.[1] || '1', + }; + } + + return { + sid: Date.now().toString(), + sct: '1', + }; +}; + +/** + * Build and send a GA4 /collect request + */ +export const sendGA4Collect = ( + win: any, + eventName: string, + eventParams: any, + options?: { + isPageView?: boolean; + } +): void => { + try { + const doc = win.document; + const nav = win.navigator; + const cookies = doc?.cookie || ''; + + const clientId = getClientId(cookies); + const sessionInfo = getSessionInfo(cookies); + const screenRes = `${win.screen?.width || 1920}x${win.screen?.height || 1080}`; + + // Get window init time for timing calculations + const windowInitTime = win._ptInitTime || 0; + const now = Date.now(); + + // Build base parameters + const params = new URLSearchParams({ + v: '2', + tid: GA4_MEASUREMENT_ID, + gtm: buildGtmParam(win), + _p: now.toString(), + cid: clientId, + ul: nav?.language?.toLowerCase() || 'en-us', + sr: screenRes, + _s: '1', + sid: sessionInfo.sid, + sct: sessionInfo.sct, + seg: '1', + dl: doc?.location?.href || '', + dt: doc?.title || '', + en: eventName, + }); + + // Add GTM internal parameters (uach, tag_exp, gcd, pscdl, etc.) + const gtmParams = getGTMInternalParams(win); + for (const [key, value] of Object.entries(gtmParams)) { + if (value) params.set(key, value); + } + + // Add simple flags + params.set('npa', '0'); + params.set('dma', '0'); + params.set('are', '1'); + params.set('frm', '0'); + + // Add timing parameters + if (windowInitTime > 0) { + const eventTime = now - windowInitTime; + params.set('_et', eventTime.toString()); + params.set('tfd', eventTime.toString()); + } + + // Add referrer for page_view + if (options?.isPageView && doc?.referrer) { + params.set('dr', doc.referrer); + } + + // Handle page-specific parameters + if (eventName === 'page_view') { + if (eventParams?.page_location) params.set('dl', eventParams.page_location); + if (eventParams?.page_title) params.set('dt', eventParams.page_title); + if (eventParams?.page_referrer) params.set('dr', eventParams.page_referrer); + } + + // Add ecommerce items + const ecommerce = eventParams?.ecommerce || eventParams; + if (ecommerce?.items && Array.isArray(ecommerce.items)) { + ecommerce.items.forEach((item: any, idx: number) => { + const serialized = serializeItem(item); + if (serialized) { + params.set(`pr${idx + 1}`, serialized); + } + }); + } + + // Add value and currency + const value = ecommerce?.value || eventParams?.value; + const currency = ecommerce?.currency || eventParams?.currency; + if (value !== undefined) { + params.set('epn.value', value.toString()); + } + if (currency) { + params.set('cu', currency); + } + + // Send the request + const collectUrl = `https://analytics.google.com/g/collect?${params.toString()}`; + + fetch(collectUrl, { + method: 'POST', + mode: 'no-cors', + keepalive: true, + credentials: 'include', + }); + } catch { + // Silently fail - GTM may still process the event + } +}; + +/** + * Standard GA4 ecommerce events that should be sent via Measurement Protocol + */ +export const GA4_ECOMMERCE_EVENTS = [ + 'page_view', + 'view_item', + 'add_to_cart', + 'begin_checkout', + 'purchase', + 'session_start', + 'add_to_card', + 'first_purchase', + 'proceed_to_payment', + 'view_search_results', +]; + +/** + * Setup history change listeners for SPA navigation + * This ensures page_view fires on every URL change (like native GTM behavior) + */ +export const setupHistoryChangeListener = (win: any): void => { + if (win._ptHistoryListenerSetup) return; + win._ptHistoryListenerSetup = true; + + let lastUrl = win.location?.href || ''; + + const sendPageViewIfUrlChanged = () => { + const currentUrl = win.location?.href || ''; + if (currentUrl !== lastUrl) { + lastUrl = currentUrl; + // Small delay to let the page update title etc. + setTimeout(() => { + sendGA4Collect(win, 'page_view', {}, { isPageView: true }); + }, 50); + } + }; + + // Listen for popstate (back/forward navigation) + win.addEventListener('popstate', sendPageViewIfUrlChanged); + + // Wrap history.pushState + const originalPushState = win.history?.pushState; + if (originalPushState) { + win.history.pushState = function (...args: any[]) { + const result = originalPushState.apply(this, args); + sendPageViewIfUrlChanged(); + return result; + }; + } + + // Wrap history.replaceState + const originalReplaceState = win.history?.replaceState; + if (originalReplaceState) { + win.history.replaceState = function (...args: any[]) { + const result = originalReplaceState.apply(this, args); + sendPageViewIfUrlChanged(); + return result; + }; + } +}; diff --git a/src/lib/web-worker/worker-iframe.ts b/src/lib/web-worker/worker-iframe.ts index a9154404..1a7ebd8e 100644 --- a/src/lib/web-worker/worker-iframe.ts +++ b/src/lib/web-worker/worker-iframe.ts @@ -1,5 +1,9 @@ import { createEnvironment } from './worker-environment'; -import { definePrototypePropertyDescriptor, SCRIPT_TYPE } from '../utils'; +import { + definePrototypePropertyDescriptor, + SCRIPT_TYPE, + testIfMustLoadIframeOnMainThread, +} from '../utils'; import { ABOUT_BLANK, environments, @@ -8,10 +12,12 @@ import { WinIdKey, } from './worker-constants'; import { getPartytownScript, resolveUrl } from './worker-exec'; -import { getter, sendToMain, setter } from './worker-proxy'; +import { callMethod, getter, sendToMain, setter } from './worker-proxy'; import { HTMLSrcElementDescriptorMap } from './worker-src-element'; import { setInstanceStateValue, getInstanceStateValue } from './worker-state'; import { + CallType, + type EventHandler, StateProp, type WebWorkerEnvironment, type WorkerInstance, @@ -51,14 +57,60 @@ export const patchHTMLIFrameElement = (WorkerHTMLIFrameElement: any, env: WebWor return; } if (!src.startsWith('about:')) { - let xhr = new XMLHttpRequest(); - let xhrStatus: number; let env = getIframeEnv(this); + const config = webWorkerCtx.$config$; + + // Check if this iframe should be loaded on the main thread BEFORE resolving URL + // This is important because resolveUrl might transform the URL (e.g., proxy it) + // which would break pattern matching. We check the original URL. + const originalSrc = new URL(src, env.$location$.href).href; + const shouldLoadOnMainThread = testIfMustLoadIframeOnMainThread(config, originalSrc); + + if (shouldLoadOnMainThread) { + // Let the iframe load naturally on the main thread + // by setting the src attribute directly without XHR interception. + // We must REMOVE srcdoc attribute (not just set to empty) since per HTML spec + // if srcdoc attribute is present (even empty), it takes precedence over src. + // Use the ORIGINAL URL, not the resolved/proxied URL, since we want the browser + // to load the actual Google iframe directly. + callMethod(this, ['removeAttribute'], ['srcdoc'], CallType.NonBlocking); + setter(this, ['src'], originalSrc); + // Flush the queue to ensure removeAttribute and src setter execute immediately + // before any subsequent appendChild/insertBefore calls + sendToMain(true); + + setInstanceStateValue(this, StateProp.src, originalSrc); + env.$location$.href = originalSrc; + env.$isSameOrigin$ = webWorkerCtx.$origin$ === new URL(originalSrc).origin; + + // Mark the environment as initialized immediately since + // the iframe will load independently on the main thread + env.$isInitialized$ = 1; + env.$isLoading$ = 0; + // Call the load handlers after a short delay to allow the iframe to load + // We use a callback mechanism similar to insertIframe() in worker-exec.ts + const iframe = this; + const checkLoaded = () => { + const handlers = getInstanceStateValue(iframe, StateProp.loadHandlers); + if (handlers) { + handlers.map((handler) => handler({ type: 'load' })); + } + }; + // Delay to allow the iframe to load on main thread + setTimeout(checkLoaded, 100); + return; + } + + // For iframes NOT loaded on main thread, resolve URL (may proxy it) env.$location$.href = src = resolveUrl(env, src, 'iframe'); - env.$isLoading$ = 1; env.$isSameOrigin$ = webWorkerCtx.$origin$ === env.$location$.origin; + let xhr = new XMLHttpRequest(); + let xhrStatus: number; + + env.$isLoading$ = 1; + setInstanceStateValue(this, StateProp.loadErrorStatus, undefined); xhr.open('GET', src, false); diff --git a/src/lib/web-worker/worker-image.ts b/src/lib/web-worker/worker-image.ts index 33b8509b..03dbdef3 100644 --- a/src/lib/web-worker/worker-image.ts +++ b/src/lib/web-worker/worker-image.ts @@ -32,12 +32,13 @@ export const createImageConstructor = (env: WebWorkerEnvironment) => this.s = src; - fetch(resolveUrl(env, src, 'image'), { + // Use self.fetch to ensure we use the patched version from init-web-worker + (self as any).fetch(resolveUrl(env, src, 'image'), { mode: 'no-cors', credentials: 'include', keepalive: true, }).then( - (rsp) => { + (rsp: Response) => { if (rsp.ok || rsp.status === 0) { this.l.map((cb) => cb({ type: 'load' })); } else { diff --git a/src/lib/web-worker/worker-navigator.ts b/src/lib/web-worker/worker-navigator.ts index 1daec1da..2aeff47c 100644 --- a/src/lib/web-worker/worker-navigator.ts +++ b/src/lib/web-worker/worker-navigator.ts @@ -6,7 +6,29 @@ import { webWorkerCtx } from './worker-constants'; import { getter } from './worker-proxy'; export const createNavigator = (env: WebWorkerEnvironment) => { + // Create a stub for navigator.serviceWorker that exists but gracefully fails + // This is needed because GA4/gtag.js may try to register service workers + // and in a web worker context, navigator.serviceWorker is undefined + const serviceWorkerStub = { + register: (scriptURL: string, options?: any) => { + // Return a rejected promise - service workers can't be registered from web workers + return Promise.reject(new DOMException('Service workers are not supported in this context', 'SecurityError')); + }, + getRegistration: (scope?: string) => Promise.resolve(undefined), + getRegistrations: () => Promise.resolve([]), + ready: new Promise(() => {}), + controller: null, + oncontrollerchange: null, + onmessage: null, + onmessageerror: null, + startMessages: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }; + const nav: any = { + serviceWorker: serviceWorkerStub, sendBeacon: (url: string, body?: any) => { if (debug && webWorkerCtx.$config$.logSendBeaconRequests) { try { @@ -20,7 +42,8 @@ export const createNavigator = (env: WebWorkerEnvironment) => { } } try { - fetch(resolveUrl(env, url, null), { + const resolvedUrl = resolveUrl(env, url, null); + (self as any).fetch(resolvedUrl, { method: 'POST', body, mode: 'no-cors', @@ -35,10 +58,21 @@ export const createNavigator = (env: WebWorkerEnvironment) => { }, }; + // Copy navigator properties from the worker's navigator for (let key in navigator) { nav[key] = (navigator as any)[key]; } + // IMPORTANT: Set these AFTER the loop to ensure they're not overwritten + // by the worker's navigator which may have different/undefined values + // GA4 and Mixpanel check navigator.cookieEnabled before setting cookies + nav.cookieEnabled = true; + nav.onLine = true; + // Web workers may have doNotTrack="1" by default, which causes analytics + // scripts like Mixpanel (with ignore_dnt:false) to disable persistence. + // Set to null (no preference) to match typical main thread behavior. + nav.doNotTrack = null; + return new Proxy(nav, { set(_, propName, propValue) { (navigator as any)[propName] = propValue; @@ -48,8 +82,7 @@ export const createNavigator = (env: WebWorkerEnvironment) => { if (Object.prototype.hasOwnProperty.call(target, prop)) { return target[prop]; } - const value = getter(env.$window$, ['navigator', prop]); - return value; + return getter(env.$window$, ['navigator', prop]); }, }); }; diff --git a/src/lib/web-worker/worker-proxy.ts b/src/lib/web-worker/worker-proxy.ts index 0226da98..bfe7dba4 100644 --- a/src/lib/web-worker/worker-proxy.ts +++ b/src/lib/web-worker/worker-proxy.ts @@ -140,6 +140,27 @@ export const sendToMain = (isBlocking?: boolean) => { } }; +/** + * Flush any pending async tasks to main thread. + * This ensures all queued operations are sent before continuing. + * Useful for ensuring consistency before critical operations like cookie reads. + */ +export const flushPendingTasks = () => { + clearTimeout(webWorkerCtx.$asyncMsgTimer$); + if (len(taskQueue)) { + if (debug) { + logWorker(`Flushing ${taskQueue.length} pending tasks`); + } + // Send pending tasks as async (non-blocking) + const accessReq: MainAccessRequest = { + $msgId$: `${randomId()}.${webWorkerCtx.$tabId$}`, + $tasks$: [...taskQueue], + }; + taskQueue.length = 0; + webWorkerCtx.$postMessage$([WorkerMessageType.AsyncAccessRequest, accessReq]); + } +}; + export const getter: Getter = ( instance: WorkerInstance, applyPath: ApplyPath, @@ -193,6 +214,45 @@ export const setter: Setter = ( queue(instance, applyPath, CallType.NonBlocking); }; +/** + * Blocking setter - waits for the set operation to complete before returning. + * Use this for properties that may be read immediately after setting (e.g., cookies). + */ +export const blockingSetter: Setter = ( + instance: WorkerInstance, + applyPath: ApplyPath, + value: any, + hookSetterValue?: any +) => { + if (webWorkerCtx.$config$.set) { + hookSetterValue = webWorkerCtx.$config$.set({ + value, + prevent: HookPrevent, + ...createHookOptions(instance, applyPath), + }); + if (hookSetterValue === HookPrevent) { + return; + } + if (hookSetterValue !== HookContinue) { + value = hookSetterValue; + } + } + + if (dimensionChangingSetterNames.some((s) => applyPath.includes(s))) { + cachedDimensions.clear(); + logDimensionCacheClearSetter(instance, applyPath[applyPath.length - 1]); + } + + applyPath = [...applyPath, serializeInstanceForMain(instance, value), ApplyPathType.SetValue]; + + logWorkerSetter(instance, applyPath, value); + + // Use Blocking to ensure the set completes before returning + // This is critical for operations like document.cookie where + // scripts may immediately read the value after setting + queue(instance, applyPath, CallType.Blocking); +}; + export const callMethod: CallMethod = ( instance: WorkerInstance, applyPath: ApplyPath, diff --git a/src/lib/web-worker/worker-src-element.ts b/src/lib/web-worker/worker-src-element.ts index 998fe6d0..2311b05b 100644 --- a/src/lib/web-worker/worker-src-element.ts +++ b/src/lib/web-worker/worker-src-element.ts @@ -39,6 +39,31 @@ export const HTMLSrcElementDescriptorMap: PropertyDescriptorMap & ThisType(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();