From a9a029e9cd08b2ec50e6301ec1b3a27c15a87ce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Pe=CC=81rez=20Zamora?= Date: Wed, 20 Dec 2023 15:13:44 +0100 Subject: [PATCH 1/3] feat: add preserveBehaviour property --- src/lib/main/snippet.ts | 45 ++++-- src/lib/sandbox/main-forward-trigger.ts | 33 ++++- src/lib/types.ts | 12 +- src/lib/utils.ts | 66 ++++++++- .../event-forwarding/event-forwarding.spec.ts | 30 ++++ .../integrations/event-forwarding/index.html | 55 +++++++- tests/integrations/gtm/gtm.spec.ts | 32 +++++ tests/integrations/gtm/index.html | 7 +- tests/integrations/gtm/preserve-behavior.html | 130 ++++++++++++++++++ tests/integrations/gtm/standard.html | 5 + 10 files changed, 390 insertions(+), 25 deletions(-) create mode 100644 tests/integrations/gtm/preserve-behavior.html diff --git a/src/lib/main/snippet.ts b/src/lib/main/snippet.ts index f4fc46a5..eaec17d4 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..a098a880 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..e212c334 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -522,15 +522,17 @@ export interface PartytownConfig { nonce?: string; } +export type PartytownForwardSettingsProperty = { property: string; preserveBehavior: boolean }; + /** - * 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 | PartytownForwardSettingsProperty; /** * @public @@ -576,7 +578,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..5cdde5d3 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,4 +1,11 @@ -import type { ApplyPath, RandomId } from './types'; +import type { + ApplyPath, + MainWindow, + PartytownForwardProperty, + PartytownForwardSettingsProperty, + RandomId, + StringIndexable, +} from './types'; export const debug = !!(globalThis as any).partytownDebug; @@ -137,3 +144,60 @@ export const isValidUrl = (url: any): boolean => { return false; } }; + +export const resolvePartytownForwardProperty = ( + property: PartytownForwardProperty +): PartytownForwardSettingsProperty => { + if (typeof property === 'string') { + return { + property, + preserveBehavior: false, + }; + } + return property; +}; + +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..41d6b048 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

From 0854f3d8fcf2fbbfa86dab7458c2b4b1d4a7cf90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Pe=CC=81rez=20Zamora?= Date: Sat, 6 Jan 2024 19:32:53 +0100 Subject: [PATCH 2/3] feat: add alternative way to inform the new property --- src/lib/main/snippet.ts | 4 ++-- src/lib/sandbox/main-forward-trigger.ts | 2 +- src/lib/types.ts | 8 +++++-- src/lib/utils.ts | 22 +++++++++++-------- .../integrations/event-forwarding/index.html | 2 +- tests/integrations/gtm/preserve-behavior.html | 2 +- 6 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/lib/main/snippet.ts b/src/lib/main/snippet.ts index eaec17d4..9251f028 100644 --- a/src/lib/main/snippet.ts +++ b/src/lib/main/snippet.ts @@ -108,7 +108,7 @@ export function snippet( // remove any previously patched functions if (top == win) { (config!.forward || []).map(function (forwardProps) { - const { property } = resolvePartytownForwardProperty(forwardProps); + const [property] = resolvePartytownForwardProperty(forwardProps); delete win[property.split('.')[0] as any]; }); } @@ -141,7 +141,7 @@ 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); + const [property, { preserveBehavior }] = resolvePartytownForwardProperty(forwardProps); mainForwardFn = win; property.split('.').map(function (_, i, forwardPropsArr) { mainForwardFn = mainForwardFn[forwardPropsArr[i]] = diff --git a/src/lib/sandbox/main-forward-trigger.ts b/src/lib/sandbox/main-forward-trigger.ts index a098a880..2aaef22b 100644 --- a/src/lib/sandbox/main-forward-trigger.ts +++ b/src/lib/sandbox/main-forward-trigger.ts @@ -26,7 +26,7 @@ export const mainForwardTrigger = (worker: PartytownWebWorker, $winId$: WinId, w win._ptf = undefined; forwards.map((forwardProps) => { - const { property, preserveBehavior } = resolvePartytownForwardProperty(forwardProps); + const [property, { preserveBehavior }] = resolvePartytownForwardProperty(forwardProps); mainForwardFn = win; property.split('.').map((_, i, arr) => { mainForwardFn = mainForwardFn[arr[i]] = diff --git a/src/lib/types.ts b/src/lib/types.ts index e212c334..9613764c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -522,7 +522,11 @@ export interface PartytownConfig { nonce?: string; } -export type PartytownForwardSettingsProperty = { property: string; preserveBehavior: boolean }; +export type PartytownForwardPropertySettings = { + preserveBehavior?: boolean; +}; + +export type PartytownForwardPropertyWithSettings = [string, PartytownForwardPropertySettings?]; /** * A forward property to patch on `window`. The forward config property is an string, @@ -532,7 +536,7 @@ export type PartytownForwardSettingsProperty = { property: string; preserveBehav * * @public */ -export type PartytownForwardProperty = string | PartytownForwardSettingsProperty; +export type PartytownForwardProperty = string | PartytownForwardPropertyWithSettings; /** * @public diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 5cdde5d3..2ed2d59a 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -2,7 +2,8 @@ import type { ApplyPath, MainWindow, PartytownForwardProperty, - PartytownForwardSettingsProperty, + PartytownForwardPropertySettings, + PartytownForwardPropertyWithSettings, RandomId, StringIndexable, } from './types'; @@ -145,16 +146,19 @@ export const isValidUrl = (url: any): boolean => { } }; +const defaultPartytownForwardPropertySettings: Required = { + preserveBehavior: false, +}; + export const resolvePartytownForwardProperty = ( - property: PartytownForwardProperty -): PartytownForwardSettingsProperty => { - if (typeof property === 'string') { - return { - property, - preserveBehavior: false, - }; + propertyOrPropertyWithSettings: PartytownForwardProperty +): Required => { + if (typeof propertyOrPropertyWithSettings === 'string') { + return [propertyOrPropertyWithSettings, defaultPartytownForwardPropertySettings]; } - return property; + const [property, settings = defaultPartytownForwardPropertySettings] = + propertyOrPropertyWithSettings; + return [property, { ...defaultPartytownForwardPropertySettings, ...settings }]; }; type GetOriginalBehaviorReturn = { diff --git a/tests/integrations/event-forwarding/index.html b/tests/integrations/event-forwarding/index.html index 41d6b048..2b7780ce 100644 --- a/tests/integrations/event-forwarding/index.html +++ b/tests/integrations/event-forwarding/index.html @@ -12,7 +12,7 @@ 'superDuperFunction', 'superArray.push', 'KiwiSizing', - { property: 'superPreservedArray.unshift', preserveBehavior: true }, + ['superPreservedArray.unshift', { preserveBehavior: true }], ], logCalls: true, logGetters: true, diff --git a/tests/integrations/gtm/preserve-behavior.html b/tests/integrations/gtm/preserve-behavior.html index 0ea58eca..1f1ab1fc 100644 --- a/tests/integrations/gtm/preserve-behavior.html +++ b/tests/integrations/gtm/preserve-behavior.html @@ -21,7 +21,7 @@ return url; }, - forward: [{ property: 'dataLayer.push', preserveBehavior: true }], + forward: [['dataLayer.push', { preserveBehavior: true }]], logCalls: true, logGetters: true, logSetters: true, From d5f84138457b4a189bc479b60e4fa03f4265fc21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Pe=CC=81rez=20Zamora?= Date: Wed, 10 Jan 2024 13:27:38 +0100 Subject: [PATCH 3/3] docs: add preserveBehavior documentation --- docs/forwarding-events.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) 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.