diff --git a/docs/forwarding-events.md b/docs/forwarding-events.md index c7c00e00..4963e8ca 100644 --- a/docs/forwarding-events.md +++ b/docs/forwarding-events.md @@ -28,6 +28,32 @@ However, since GTM and Facebook Pixel were actually loaded in the web worker, th Notice the forward configs are just strings, not actual objects. We're using strings here so we can easily serialize what service variable was called, along with the function argument values. When the web worker receives the information, it then knows how to correctly apply the call and arguments that were fired from the main thread. +You can customize each forwarded variable with the following settings: + +- ### preserveBehavior + + In addition to the `forward` config, we also provide a `preserveBehavior` property. This property allows you to customize each forwarded property, preserving the original behavior of the function. + + When `preserveBehavior` is set to `true`, the original function's behavior on the main thread is maintained, while also forwarding the calls to partytown. This is useful in cases where the function has side effects on the main thread that you want to keep. + + If `preserveBehavior` is not explicitly set, its default value is `false`. This means that, by default, calls will only be forwarded to partytown and won't execute on the main thread. + + Here's an example of how to use it: + + ```js + + ``` + + In this example, calls to `dataLayer.push` will execute as normal on the main thread and also be forwarded to partytown. Calls to `fbq` will only be forwarded to partytown, and won't execute on the main thread. For `gtm.push`, since preserveBehavior is not explicitly set, it will behave as if preserveBehavior was set to false, meaning it will only be forwarded to partytown. + ## Integrations Please see the [Integrations](/integrations) section for examples using the `forward` config. diff --git a/src/lib/main/snippet.ts b/src/lib/main/snippet.ts index f4fc46a5..9251f028 100644 --- a/src/lib/main/snippet.ts +++ b/src/lib/main/snippet.ts @@ -1,4 +1,9 @@ -import { debug } from '../utils'; +import { + debug, + emptyObjectValue, + getOriginalBehavior, + resolvePartytownForwardProperty, +} from '../utils'; import type { MainWindow, PartytownConfig } from '../types'; export function snippet( @@ -12,7 +17,7 @@ export function snippet( timeout?: any, scripts?: NodeListOf, sandbox?: HTMLIFrameElement | HTMLScriptElement, - mainForwardFn?: any, + mainForwardFn: typeof win = win, isReady?: number ) { // ES5 just so IE11 doesn't choke on arrow fns @@ -103,7 +108,8 @@ export function snippet( // remove any previously patched functions if (top == win) { (config!.forward || []).map(function (forwardProps) { - delete win[forwardProps.split('.')[0] as any]; + const [property] = resolvePartytownForwardProperty(forwardProps); + delete win[property.split('.')[0] as any]; }); } @@ -135,17 +141,34 @@ export function snippet( // this is the top window // patch the functions that'll be forwarded to the worker (config.forward || []).map(function (forwardProps) { + const [property, { preserveBehavior }] = resolvePartytownForwardProperty(forwardProps); mainForwardFn = win; - forwardProps.split('.').map(function (_, i, forwardPropsArr) { + property.split('.').map(function (_, i, forwardPropsArr) { mainForwardFn = mainForwardFn[forwardPropsArr[i]] = i + 1 < forwardPropsArr.length - ? forwardPropsArr[i + 1] == 'push' - ? [] - : mainForwardFn[forwardPropsArr[i]] || {} - : function () { - // queue these calls to be forwarded on later, after Partytown is ready - (win._ptf = win._ptf || []).push(forwardPropsArr, arguments); - }; + ? mainForwardFn[forwardPropsArr[i]] || emptyObjectValue(forwardPropsArr[i + 1]) + : (() => { + let originalFunction: ((...args: any[]) => any) | null = null; + if (preserveBehavior) { + const { methodOrProperty, thisObject } = getOriginalBehavior( + win, + forwardPropsArr + ); + if (typeof methodOrProperty === 'function') { + originalFunction = (...args: any[]) => + methodOrProperty.apply(thisObject, ...args); + } + } + return function () { + let returnValue: any; + if (originalFunction) { + returnValue = originalFunction(arguments); + } + // queue these calls to be forwarded on later, after Partytown is ready + (win._ptf = win._ptf || []).push(forwardPropsArr, arguments); + return returnValue; + }; + })(); }); }); } diff --git a/src/lib/sandbox/main-forward-trigger.ts b/src/lib/sandbox/main-forward-trigger.ts index dd0808bd..2aaef22b 100644 --- a/src/lib/sandbox/main-forward-trigger.ts +++ b/src/lib/sandbox/main-forward-trigger.ts @@ -1,4 +1,9 @@ -import { len } from '../utils'; +import { + emptyObjectValue, + getOriginalBehavior, + len, + resolvePartytownForwardProperty, +} from '../utils'; import { MainWindow, PartytownWebWorker, WinId, WorkerMessageType } from '../types'; import { serializeForWorker } from './main-serialization'; @@ -6,7 +11,7 @@ export const mainForwardTrigger = (worker: PartytownWebWorker, $winId$: WinId, w let queuedForwardCalls = win._ptf; let forwards = (win.partytown || {}).forward || []; let i: number; - let mainForwardFn: any; + let mainForwardFn: typeof win; let forwardCall = ($forward$: string[], args: any) => worker.postMessage([ @@ -21,12 +26,30 @@ export const mainForwardTrigger = (worker: PartytownWebWorker, $winId$: WinId, w win._ptf = undefined; forwards.map((forwardProps) => { + const [property, { preserveBehavior }] = resolvePartytownForwardProperty(forwardProps); mainForwardFn = win; - forwardProps.split('.').map((_, i, arr) => { + property.split('.').map((_, i, arr) => { mainForwardFn = mainForwardFn[arr[i]] = i + 1 < len(arr) - ? mainForwardFn[arr[i]] || (arr[i + 1] === 'push' ? [] : {}) - : (...args: any) => forwardCall(arr, args); + ? mainForwardFn[arr[i]] || emptyObjectValue(arr[i + 1]) + : (() => { + let originalFunction: ((...args: any[]) => any) | null = null; + if (preserveBehavior) { + const { methodOrProperty, thisObject } = getOriginalBehavior(win, arr); + if (typeof methodOrProperty === 'function') { + originalFunction = (...args: any[]) => + methodOrProperty.apply(thisObject, ...args); + } + } + return (...args: any[]) => { + let returnValue: any; + if (originalFunction) { + returnValue = originalFunction(args); + } + forwardCall(arr, args); + return returnValue; + }; + })(); }); }); diff --git a/src/lib/types.ts b/src/lib/types.ts index 7cd40679..9613764c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -522,15 +522,21 @@ export interface PartytownConfig { nonce?: string; } +export type PartytownForwardPropertySettings = { + preserveBehavior?: boolean; +}; + +export type PartytownForwardPropertyWithSettings = [string, PartytownForwardPropertySettings?]; + /** - * A foward property to patch on `window`. The foward config property is an string, + * A forward property to patch on `window`. The forward config property is an string, * representing the call to forward, such as `dataLayer.push` or `fbq`. * * https://partytown.builder.io/forwarding-events * * @public */ -export type PartytownForwardProperty = string; +export type PartytownForwardProperty = string | PartytownForwardPropertyWithSettings; /** * @public @@ -576,7 +582,11 @@ export interface ApplyHookOptions extends HookOptions { args: any[]; } -export interface MainWindow extends Window { +export type StringIndexable = { + [key: string]: any; +}; + +export interface MainWindow extends Window, StringIndexable { partytown?: PartytownConfig; _ptf?: any[]; } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 2ace171f..2ed2d59a 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,4 +1,12 @@ -import type { ApplyPath, RandomId } from './types'; +import type { + ApplyPath, + MainWindow, + PartytownForwardProperty, + PartytownForwardPropertySettings, + PartytownForwardPropertyWithSettings, + RandomId, + StringIndexable, +} from './types'; export const debug = !!(globalThis as any).partytownDebug; @@ -137,3 +145,63 @@ export const isValidUrl = (url: any): boolean => { return false; } }; + +const defaultPartytownForwardPropertySettings: Required = { + preserveBehavior: false, +}; + +export const resolvePartytownForwardProperty = ( + propertyOrPropertyWithSettings: PartytownForwardProperty +): Required => { + if (typeof propertyOrPropertyWithSettings === 'string') { + return [propertyOrPropertyWithSettings, defaultPartytownForwardPropertySettings]; + } + const [property, settings = defaultPartytownForwardPropertySettings] = + propertyOrPropertyWithSettings; + return [property, { ...defaultPartytownForwardPropertySettings, ...settings }]; +}; + +type GetOriginalBehaviorReturn = { + thisObject: StringIndexable; + methodOrProperty: Function | Record | undefined; +}; + +export const getOriginalBehavior = ( + window: MainWindow, + properties: string[] +): GetOriginalBehaviorReturn => { + let thisObject: StringIndexable = window; + + for (let i = 0; i < properties.length - 1; i += 1) { + thisObject = thisObject[properties[i]]; + } + + return { + thisObject, + methodOrProperty: + properties.length > 0 ? thisObject[properties[properties.length - 1]] : undefined, + }; +}; + +const getMethods = (obj: {} | []): string[] => { + const properties = new Set(); + let currentObj: any = obj; + do { + Object.getOwnPropertyNames(currentObj).forEach((item) => { + if (typeof currentObj[item] === 'function') { + properties.add(item); + } + }); + } while ((currentObj = Object.getPrototypeOf(currentObj)) !== Object.prototype); + return Array.from(properties); +}; + +const arrayMethods = Object.freeze(getMethods([])); + +export const emptyObjectValue = (propertyName: string): [] | {} => { + if (arrayMethods.includes(propertyName)) { + return []; + } + + return {}; +}; diff --git a/tests/integrations/event-forwarding/event-forwarding.spec.ts b/tests/integrations/event-forwarding/event-forwarding.spec.ts index aaad4c78..f840717c 100644 --- a/tests/integrations/event-forwarding/event-forwarding.spec.ts +++ b/tests/integrations/event-forwarding/event-forwarding.spec.ts @@ -10,15 +10,45 @@ test('integration event forwarding', async ({ page }) => { const testArray = page.locator('#testArray'); await expect(testArray).toHaveText('arrayReady'); + const testPreservedArray = page.locator('#testPreservedArray'); + await expect(testPreservedArray).toHaveText('arrayReady'); + const buttonForwardEvent = page.locator('#buttonForwardEvent'); await buttonForwardEvent.click(); await expect(testFn).toHaveText('click1'); await buttonForwardEvent.click(); await expect(testFn).toHaveText('click2'); + const windowHandle = await page.evaluateHandle(() => Promise.resolve(window)); + + const superArrayHandle = await page.evaluateHandle( + (window) => window['superArray'] as Record[], + windowHandle + ); const buttonArrayPush = page.locator('#buttonArrayPush'); await buttonArrayPush.click(); await expect(testArray).toHaveText(JSON.stringify({ mph: 88 })); await buttonArrayPush.click(); await expect(testArray).toHaveText(JSON.stringify({ mph: 89 })); + const superArray = await superArrayHandle.jsonValue(); + await superArrayHandle.dispose(); + await expect(superArray).toStrictEqual([]); + + const superPreservedArrayHandle = await page.evaluateHandle( + (window) => window['superPreservedArray'] as Record[], + windowHandle + ); + const buttonPreservedArrayPush = page.locator('#buttonPreservedArrayPush'); + const label = page.locator('#testPreservedArrayReturnValue'); + await buttonPreservedArrayPush.click(); + await expect(testPreservedArray).toHaveText(JSON.stringify({ mph: 88 })); + await expect(label).toHaveText('2'); + await buttonPreservedArrayPush.click(); + await expect(testPreservedArray).toHaveText(JSON.stringify({ mph: 89 })); + await expect(label).toHaveText('3'); + const superPreservedArray = await superPreservedArrayHandle.jsonValue(); + await superPreservedArrayHandle.dispose(); + await expect(superPreservedArray).toStrictEqual([{ mph: 89 }, { mph: 88 }, 'arrayReady']); + + await windowHandle.dispose(); }); diff --git a/tests/integrations/event-forwarding/index.html b/tests/integrations/event-forwarding/index.html index fdb65ad7..2b7780ce 100644 --- a/tests/integrations/event-forwarding/index.html +++ b/tests/integrations/event-forwarding/index.html @@ -8,7 +8,12 @@ - + + - + + +

Google Tag Manager (GTM) 🎉

+ +

+ dataLayer.push + +

+ + + + + + +

Partytown GTM

+

Standard GTM

+

All Tests

+ + diff --git a/tests/integrations/gtm/standard.html b/tests/integrations/gtm/standard.html index 69fba1d2..9bb953e0 100644 --- a/tests/integrations/gtm/standard.html +++ b/tests/integrations/gtm/standard.html @@ -86,6 +86,11 @@

Standard Google Tag Manager (GTM)


Partytown GTM

+

+ Partytown GTM with preserveBehavior +

All Tests