Skip to content

Commit 718b1a4

Browse files
authored
feat: improve forwarding-events with preserveBehavior (#530)
* feat: add preserveBehaviour property * feat: add alternative way to inform the new property * docs: add preserveBehavior documentation
1 parent ca82546 commit 718b1a4

File tree

11 files changed

+424
-25
lines changed

11 files changed

+424
-25
lines changed

docs/forwarding-events.md

+26
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,32 @@ However, since GTM and Facebook Pixel were actually loaded in the web worker, th
2828

2929
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.
3030

31+
You can customize each forwarded variable with the following settings:
32+
33+
- ### preserveBehavior
34+
35+
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.
36+
37+
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.
38+
39+
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.
40+
41+
Here's an example of how to use it:
42+
43+
```js
44+
<script>
45+
partytown = {
46+
forward: [
47+
['dataLayer.push', { preserveBehavior: true }],
48+
['fbq', { preserveBehavior: false }],
49+
'gtm.push'
50+
]
51+
};
52+
</script>
53+
```
54+
55+
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.
56+
3157
## Integrations
3258

3359
Please see the [Integrations](/integrations) section for examples using the `forward` config.

src/lib/main/snippet.ts

+34-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { debug } from '../utils';
1+
import {
2+
debug,
3+
emptyObjectValue,
4+
getOriginalBehavior,
5+
resolvePartytownForwardProperty,
6+
} from '../utils';
27
import type { MainWindow, PartytownConfig } from '../types';
38

49
export function snippet(
@@ -12,7 +17,7 @@ export function snippet(
1217
timeout?: any,
1318
scripts?: NodeListOf<HTMLScriptElement>,
1419
sandbox?: HTMLIFrameElement | HTMLScriptElement,
15-
mainForwardFn?: any,
20+
mainForwardFn: typeof win = win,
1621
isReady?: number
1722
) {
1823
// ES5 just so IE11 doesn't choke on arrow fns
@@ -103,7 +108,8 @@ export function snippet(
103108
// remove any previously patched functions
104109
if (top == win) {
105110
(config!.forward || []).map(function (forwardProps) {
106-
delete win[forwardProps.split('.')[0] as any];
111+
const [property] = resolvePartytownForwardProperty(forwardProps);
112+
delete win[property.split('.')[0] as any];
107113
});
108114
}
109115

@@ -135,17 +141,34 @@ export function snippet(
135141
// this is the top window
136142
// patch the functions that'll be forwarded to the worker
137143
(config.forward || []).map(function (forwardProps) {
144+
const [property, { preserveBehavior }] = resolvePartytownForwardProperty(forwardProps);
138145
mainForwardFn = win;
139-
forwardProps.split('.').map(function (_, i, forwardPropsArr) {
146+
property.split('.').map(function (_, i, forwardPropsArr) {
140147
mainForwardFn = mainForwardFn[forwardPropsArr[i]] =
141148
i + 1 < forwardPropsArr.length
142-
? forwardPropsArr[i + 1] == 'push'
143-
? []
144-
: mainForwardFn[forwardPropsArr[i]] || {}
145-
: function () {
146-
// queue these calls to be forwarded on later, after Partytown is ready
147-
(win._ptf = win._ptf || []).push(forwardPropsArr, arguments);
148-
};
149+
? mainForwardFn[forwardPropsArr[i]] || emptyObjectValue(forwardPropsArr[i + 1])
150+
: (() => {
151+
let originalFunction: ((...args: any[]) => any) | null = null;
152+
if (preserveBehavior) {
153+
const { methodOrProperty, thisObject } = getOriginalBehavior(
154+
win,
155+
forwardPropsArr
156+
);
157+
if (typeof methodOrProperty === 'function') {
158+
originalFunction = (...args: any[]) =>
159+
methodOrProperty.apply(thisObject, ...args);
160+
}
161+
}
162+
return function () {
163+
let returnValue: any;
164+
if (originalFunction) {
165+
returnValue = originalFunction(arguments);
166+
}
167+
// queue these calls to be forwarded on later, after Partytown is ready
168+
(win._ptf = win._ptf || []).push(forwardPropsArr, arguments);
169+
return returnValue;
170+
};
171+
})();
149172
});
150173
});
151174
}

src/lib/sandbox/main-forward-trigger.ts

+28-5
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1-
import { len } from '../utils';
1+
import {
2+
emptyObjectValue,
3+
getOriginalBehavior,
4+
len,
5+
resolvePartytownForwardProperty,
6+
} from '../utils';
27
import { MainWindow, PartytownWebWorker, WinId, WorkerMessageType } from '../types';
38
import { serializeForWorker } from './main-serialization';
49

510
export const mainForwardTrigger = (worker: PartytownWebWorker, $winId$: WinId, win: MainWindow) => {
611
let queuedForwardCalls = win._ptf;
712
let forwards = (win.partytown || {}).forward || [];
813
let i: number;
9-
let mainForwardFn: any;
14+
let mainForwardFn: typeof win;
1015

1116
let forwardCall = ($forward$: string[], args: any) =>
1217
worker.postMessage([
@@ -21,12 +26,30 @@ export const mainForwardTrigger = (worker: PartytownWebWorker, $winId$: WinId, w
2126
win._ptf = undefined;
2227

2328
forwards.map((forwardProps) => {
29+
const [property, { preserveBehavior }] = resolvePartytownForwardProperty(forwardProps);
2430
mainForwardFn = win;
25-
forwardProps.split('.').map((_, i, arr) => {
31+
property.split('.').map((_, i, arr) => {
2632
mainForwardFn = mainForwardFn[arr[i]] =
2733
i + 1 < len(arr)
28-
? mainForwardFn[arr[i]] || (arr[i + 1] === 'push' ? [] : {})
29-
: (...args: any) => forwardCall(arr, args);
34+
? mainForwardFn[arr[i]] || emptyObjectValue(arr[i + 1])
35+
: (() => {
36+
let originalFunction: ((...args: any[]) => any) | null = null;
37+
if (preserveBehavior) {
38+
const { methodOrProperty, thisObject } = getOriginalBehavior(win, arr);
39+
if (typeof methodOrProperty === 'function') {
40+
originalFunction = (...args: any[]) =>
41+
methodOrProperty.apply(thisObject, ...args);
42+
}
43+
}
44+
return (...args: any[]) => {
45+
let returnValue: any;
46+
if (originalFunction) {
47+
returnValue = originalFunction(args);
48+
}
49+
forwardCall(arr, args);
50+
return returnValue;
51+
};
52+
})();
3053
});
3154
});
3255

src/lib/types.ts

+13-3
Original file line numberDiff line numberDiff line change
@@ -522,15 +522,21 @@ export interface PartytownConfig {
522522
nonce?: string;
523523
}
524524

525+
export type PartytownForwardPropertySettings = {
526+
preserveBehavior?: boolean;
527+
};
528+
529+
export type PartytownForwardPropertyWithSettings = [string, PartytownForwardPropertySettings?];
530+
525531
/**
526-
* A foward property to patch on `window`. The foward config property is an string,
532+
* A forward property to patch on `window`. The forward config property is an string,
527533
* representing the call to forward, such as `dataLayer.push` or `fbq`.
528534
*
529535
* https://partytown.builder.io/forwarding-events
530536
*
531537
* @public
532538
*/
533-
export type PartytownForwardProperty = string;
539+
export type PartytownForwardProperty = string | PartytownForwardPropertyWithSettings;
534540

535541
/**
536542
* @public
@@ -576,7 +582,11 @@ export interface ApplyHookOptions extends HookOptions {
576582
args: any[];
577583
}
578584

579-
export interface MainWindow extends Window {
585+
export type StringIndexable = {
586+
[key: string]: any;
587+
};
588+
589+
export interface MainWindow extends Window, StringIndexable {
580590
partytown?: PartytownConfig;
581591
_ptf?: any[];
582592
}

src/lib/utils.ts

+69-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import type { ApplyPath, RandomId } from './types';
1+
import type {
2+
ApplyPath,
3+
MainWindow,
4+
PartytownForwardProperty,
5+
PartytownForwardPropertySettings,
6+
PartytownForwardPropertyWithSettings,
7+
RandomId,
8+
StringIndexable,
9+
} from './types';
210

311
export const debug = !!(globalThis as any).partytownDebug;
412

@@ -137,3 +145,63 @@ export const isValidUrl = (url: any): boolean => {
137145
return false;
138146
}
139147
};
148+
149+
const defaultPartytownForwardPropertySettings: Required<PartytownForwardPropertySettings> = {
150+
preserveBehavior: false,
151+
};
152+
153+
export const resolvePartytownForwardProperty = (
154+
propertyOrPropertyWithSettings: PartytownForwardProperty
155+
): Required<PartytownForwardPropertyWithSettings> => {
156+
if (typeof propertyOrPropertyWithSettings === 'string') {
157+
return [propertyOrPropertyWithSettings, defaultPartytownForwardPropertySettings];
158+
}
159+
const [property, settings = defaultPartytownForwardPropertySettings] =
160+
propertyOrPropertyWithSettings;
161+
return [property, { ...defaultPartytownForwardPropertySettings, ...settings }];
162+
};
163+
164+
type GetOriginalBehaviorReturn = {
165+
thisObject: StringIndexable;
166+
methodOrProperty: Function | Record<string, unknown> | undefined;
167+
};
168+
169+
export const getOriginalBehavior = (
170+
window: MainWindow,
171+
properties: string[]
172+
): GetOriginalBehaviorReturn => {
173+
let thisObject: StringIndexable = window;
174+
175+
for (let i = 0; i < properties.length - 1; i += 1) {
176+
thisObject = thisObject[properties[i]];
177+
}
178+
179+
return {
180+
thisObject,
181+
methodOrProperty:
182+
properties.length > 0 ? thisObject[properties[properties.length - 1]] : undefined,
183+
};
184+
};
185+
186+
const getMethods = (obj: {} | []): string[] => {
187+
const properties = new Set<string>();
188+
let currentObj: any = obj;
189+
do {
190+
Object.getOwnPropertyNames(currentObj).forEach((item) => {
191+
if (typeof currentObj[item] === 'function') {
192+
properties.add(item);
193+
}
194+
});
195+
} while ((currentObj = Object.getPrototypeOf(currentObj)) !== Object.prototype);
196+
return Array.from(properties);
197+
};
198+
199+
const arrayMethods = Object.freeze(getMethods([]));
200+
201+
export const emptyObjectValue = (propertyName: string): [] | {} => {
202+
if (arrayMethods.includes(propertyName)) {
203+
return [];
204+
}
205+
206+
return {};
207+
};

tests/integrations/event-forwarding/event-forwarding.spec.ts

+30
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,45 @@ test('integration event forwarding', async ({ page }) => {
1010
const testArray = page.locator('#testArray');
1111
await expect(testArray).toHaveText('arrayReady');
1212

13+
const testPreservedArray = page.locator('#testPreservedArray');
14+
await expect(testPreservedArray).toHaveText('arrayReady');
15+
1316
const buttonForwardEvent = page.locator('#buttonForwardEvent');
1417
await buttonForwardEvent.click();
1518
await expect(testFn).toHaveText('click1');
1619
await buttonForwardEvent.click();
1720
await expect(testFn).toHaveText('click2');
1821

22+
const windowHandle = await page.evaluateHandle(() => Promise.resolve(window));
23+
24+
const superArrayHandle = await page.evaluateHandle(
25+
(window) => window['superArray'] as Record<string, unknown>[],
26+
windowHandle
27+
);
1928
const buttonArrayPush = page.locator('#buttonArrayPush');
2029
await buttonArrayPush.click();
2130
await expect(testArray).toHaveText(JSON.stringify({ mph: 88 }));
2231
await buttonArrayPush.click();
2332
await expect(testArray).toHaveText(JSON.stringify({ mph: 89 }));
33+
const superArray = await superArrayHandle.jsonValue();
34+
await superArrayHandle.dispose();
35+
await expect(superArray).toStrictEqual([]);
36+
37+
const superPreservedArrayHandle = await page.evaluateHandle(
38+
(window) => window['superPreservedArray'] as Record<string, unknown>[],
39+
windowHandle
40+
);
41+
const buttonPreservedArrayPush = page.locator('#buttonPreservedArrayPush');
42+
const label = page.locator('#testPreservedArrayReturnValue');
43+
await buttonPreservedArrayPush.click();
44+
await expect(testPreservedArray).toHaveText(JSON.stringify({ mph: 88 }));
45+
await expect(label).toHaveText('2');
46+
await buttonPreservedArrayPush.click();
47+
await expect(testPreservedArray).toHaveText(JSON.stringify({ mph: 89 }));
48+
await expect(label).toHaveText('3');
49+
const superPreservedArray = await superPreservedArrayHandle.jsonValue();
50+
await superPreservedArrayHandle.dispose();
51+
await expect(superPreservedArray).toStrictEqual([{ mph: 89 }, { mph: 88 }, 'arrayReady']);
52+
53+
await windowHandle.dispose();
2454
});

0 commit comments

Comments
 (0)