Skip to content

Commit

Permalink
feat: improve forwarding-events with preserveBehavior (#530)
Browse files Browse the repository at this point in the history
* feat: add preserveBehaviour property

* feat: add alternative way to inform the new property

* docs: add preserveBehavior documentation
  • Loading branch information
franpeza authored Jan 23, 2024
1 parent ca82546 commit 718b1a4
Show file tree
Hide file tree
Showing 11 changed files with 424 additions and 25 deletions.
26 changes: 26 additions & 0 deletions docs/forwarding-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<script>
partytown = {
forward: [
['dataLayer.push', { preserveBehavior: true }],
['fbq', { preserveBehavior: false }],
'gtm.push'
]
};
</script>
```

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.
Expand Down
45 changes: 34 additions & 11 deletions src/lib/main/snippet.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { debug } from '../utils';
import {
debug,
emptyObjectValue,
getOriginalBehavior,
resolvePartytownForwardProperty,
} from '../utils';
import type { MainWindow, PartytownConfig } from '../types';

export function snippet(
Expand All @@ -12,7 +17,7 @@ export function snippet(
timeout?: any,
scripts?: NodeListOf<HTMLScriptElement>,
sandbox?: HTMLIFrameElement | HTMLScriptElement,
mainForwardFn?: any,
mainForwardFn: typeof win = win,
isReady?: number
) {
// ES5 just so IE11 doesn't choke on arrow fns
Expand Down Expand Up @@ -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];
});
}

Expand Down Expand Up @@ -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;
};
})();
});
});
}
Expand Down
33 changes: 28 additions & 5 deletions src/lib/sandbox/main-forward-trigger.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { len } from '../utils';
import {
emptyObjectValue,
getOriginalBehavior,
len,
resolvePartytownForwardProperty,
} from '../utils';
import { MainWindow, PartytownWebWorker, WinId, WorkerMessageType } from '../types';
import { serializeForWorker } from './main-serialization';

export const mainForwardTrigger = (worker: PartytownWebWorker, $winId$: WinId, win: MainWindow) => {
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([
Expand All @@ -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;
};
})();
});
});

Expand Down
16 changes: 13 additions & 3 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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[];
}
Expand Down
70 changes: 69 additions & 1 deletion src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -137,3 +145,63 @@ export const isValidUrl = (url: any): boolean => {
return false;
}
};

const defaultPartytownForwardPropertySettings: Required<PartytownForwardPropertySettings> = {
preserveBehavior: false,
};

export const resolvePartytownForwardProperty = (
propertyOrPropertyWithSettings: PartytownForwardProperty
): Required<PartytownForwardPropertyWithSettings> => {
if (typeof propertyOrPropertyWithSettings === 'string') {
return [propertyOrPropertyWithSettings, defaultPartytownForwardPropertySettings];
}
const [property, settings = defaultPartytownForwardPropertySettings] =
propertyOrPropertyWithSettings;
return [property, { ...defaultPartytownForwardPropertySettings, ...settings }];
};

type GetOriginalBehaviorReturn = {
thisObject: StringIndexable;
methodOrProperty: Function | Record<string, unknown> | 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<string>();
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 {};
};
30 changes: 30 additions & 0 deletions tests/integrations/event-forwarding/event-forwarding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>[],
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<string, unknown>[],
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();
});
Loading

1 comment on commit 718b1a4

@vercel
Copy link

@vercel vercel bot commented on 718b1a4 Jan 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.