Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/strict-proxy-has.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@qwik.dev/partytown': minor
---

Add `strictProxyHas` configuration option for accurate namespace conflict detection

**Summary:**

This release adds a new configuration option `strictProxyHas` that enables accurate property existence checks using the `in` operator. This is required for scripts like FullStory that check for namespace conflicts when loaded via Google Tag Manager (GTM).

**Key Changes:**

- Add `strictProxyHas?: boolean` config option to enable accurate `in` operator behavior
- Update window proxy's `has` trap to use `Reflect.has()` when `strictProxyHas: true`
- Default is `false` for backwards compatibility
- Add FullStory GTM integration test with production-ready snippet
- Document the configuration and provide usage guide

**Usage:**

```html
<script>
partytown = {
forward: ['FS.identify', 'FS.event'],
strictProxyHas: true // Enable for FullStory via GTM
};
</script>
```

**Backwards Compatibility:**

This is a non-breaking change. The default behavior remains unchanged (`strictProxyHas: false`), so existing implementations will continue to work without modifications.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ node_modules/
/index.d.ts
.idea
.history
test-results/
tests/integrations/load-scripts-on-main-thread/snippet.js
34 changes: 25 additions & 9 deletions docs/src/routes/common-services/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,34 @@ Partytown is built with the goal that any third-party script can be ran from wit

Below is a list of plugins / libraries that are tested & known to be working with Partytown with their commonly used [forward configs](/forwarding-events) and [proxied domains](/proxying-requests).

| Service | Forward Config | Proxy Domain |
| --------------------------------------------------------------------------------------------- | ----------------------------------- | --------------------------------------------------- |
| [Facebook Pixel](/facebook-pixel) | "fbq" | "connect.facebook.net" |
| [Google Tag Manager](/google-tag-manager) | "dataLayer.push" | |
| [Hubspot Tracking](https://developers.hubspot.com/docs/api/events/tracking-code) | "\_hsq.push" | |
| [Intercom](https://developers.intercom.com/installing-intercom/docs/intercom-javascript) | "Intercom" | |
| [Klaviyo](https://developers.klaviyo.com/en/docs/javascript-api) | "\_learnq.push" | "static.klaviyo.com", "static-tracking.klaviyo.com" |
| [TikTok Pixel](https://ads.tiktok.com/marketing_api/docs?rid=959icq5stjr&id=1701890973258754) | "ttq.track", "ttq.page", "ttq.load" |
| [Mixpanel](https://developer.mixpanel.com/docs/javascript-quickstart) | "mixpanel.track" | |
| Service | Forward Config | Proxy Domain | Additional Config |
| --------------------------------------------------------------------------------------------- | ----------------------------------- | --------------------------------------------------- | ------------------------------------------ |
| [Facebook Pixel](/facebook-pixel) | "fbq" | "connect.facebook.net" | |
| [FullStory](https://help.fullstory.com/hc/en-us/articles/360020623574) | "FS.identify", "FS.event" | | `strictProxyHas: true` (if loaded via GTM) |
| [Google Tag Manager](/google-tag-manager) | "dataLayer.push" | | |
| [Hubspot Tracking](https://developers.hubspot.com/docs/api/events/tracking-code) | "\_hsq.push" | | |
| [Intercom](https://developers.intercom.com/installing-intercom/docs/intercom-javascript) | "Intercom" | | |
| [Klaviyo](https://developers.klaviyo.com/en/docs/javascript-api) | "\_learnq.push" | "static.klaviyo.com", "static-tracking.klaviyo.com" | |
| [TikTok Pixel](https://ads.tiktok.com/marketing_api/docs?rid=959icq5stjr&id=1701890973258754) | "ttq.track", "ttq.page", "ttq.load" | | |
| [Mixpanel](https://developer.mixpanel.com/docs/javascript-quickstart) | "mixpanel.track" | | |

If you would like to add to this list,

- Refer to the ["for Plugin Authors / Developers"](https://github.com/BuilderIO/partytown/blob/main/CONTRIBUTING.md#plugin-authors--developers) section to see how you can validate whether a library / plugin works with Partytown.
- Send us a PR so that we can have a scenario checked in that validates it.
- Please [edit this doc](https://github.com/BuilderIO/partytown/edit/main/docs/common-services.md) to add your plugin / library and its configuration so that others can start using it!

## FullStory with Google Tag Manager

When loading FullStory via Google Tag Manager (GTM), you need to enable the `strictProxyHas` configuration option. This is because FullStory checks for namespace conflicts using the `in` operator (e.g., `if (!("FS" in window))`), and by default, Partytown's window proxy always returns `true` for the `in` operator for backwards compatibility.

```html
<script>
partytown = {
forward: ['FS.identify', 'FS.event'],
strictProxyHas: true,
};
</script>
```

Without `strictProxyHas: true`, FullStory will detect a false "namespace conflict" and fail to initialize when loaded via GTM's Custom HTML tag. This configuration ensures the `in` operator accurately checks property existence on the window object.
1 change: 1 addition & 0 deletions docs/src/routes/configuration/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Partytown does not require a config for it to work, however a config can be set
| `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/]`. |
| `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). |
| `strictProxyHas` | When `true`, the `in` operator will accurately check if properties exist on the window object. Required for scripts that check for namespace conflicts, such as FullStory loaded via GTM. Default is `false` for backwards compatibility. |
| `fallbackTimeout` | A timeout in ms until Partytown initialization is considered as failed & fallbacks to the regular execution in main thread. Default is 9999 |

## Vanilla Config
Expand Down
17 changes: 17 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,23 @@ export interface PartytownConfig {
* ```
*/
nonce?: string;
/**
* When set to `true`, the window proxy's `has` trap will use `Reflect.has()` to accurately
* check if properties exist on the window object, instead of always returning `true`.
*
* This is required for scripts that check for namespace conflicts using the `in` operator,
* such as FullStory: `if (!("FS" in window)) { ... }`
*
* Default: false (for backwards compatibility)
*
* @example
* ```js
* partytown = {
* strictProxyHas: true
* };
* ```
*/
strictProxyHas?: boolean;
}

export type PartytownInternalConfig = Omit<PartytownConfig, 'loadScriptsOnMainThread'> & {
Expand Down
4 changes: 2 additions & 2 deletions src/lib/web-worker/worker-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export const createImageConstructor = (env: WebWorkerEnvironment) =>
toggleAttribute(name: string, force?: boolean): boolean {
const normalizedName = name.toLowerCase();
const hasAttr = this.attributes.has(normalizedName);

if (force !== undefined) {
if (force) {
if (!hasAttr) {
Expand All @@ -83,7 +83,7 @@ export const createImageConstructor = (env: WebWorkerEnvironment) =>
return false;
}
}

if (hasAttr) {
this.attributes.delete(normalizedName);
return false;
Expand Down
11 changes: 6 additions & 5 deletions src/lib/web-worker/worker-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,11 +374,12 @@ export const createWindow = (
return win[propName];
}
},
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,
// like: <script>globalProp = true</script>
true,
has: (target, prop) =>
// Check if property exists on the window object
// This is used by the 'in' operator (e.g., "propertyName" in window)
// When strictProxyHas is enabled, use accurate Reflect.has() behavior
// Otherwise, always return true for backwards compatibility
webWorkerCtx.$config$.strictProxyHas ? Reflect.has(target, prop) : true,
}) as any,
$document$: $createNode$(NodeName.Document, $winId$ + '.' + WinDocId.document) as any,
$documentElement$: $createNode$(
Expand Down
51 changes: 51 additions & 0 deletions tests/integrations/full-story/full-story.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,54 @@ test('full-story', async ({ page }) => {
const testFullStory = page.locator('#testIdentify');
await expect(testFullStory).toHaveText('called');
});

test('full-story via GTM', async ({ page }) => {
// Capture console messages - only actual console.log output, not Partytown debug logs
const consoleMessages: string[] = [];
page.on('console', (msg) => {
const text = msg.text();
// Filter out Partytown's own debug logging (which would contain the script source)
if (!text.includes('%c')) {
consoleMessages.push(text);
}
});

await page.goto('/tests/integrations/full-story/gtm-fullstory.html');

await page.waitForSelector('.completed');

// Check that FS namespace exists and is properly initialized
const testFSExists = page.locator('#testFSExists');
await expect(testFSExists).toHaveText('yes');

const fsExists = await page.evaluate(() => {
return typeof window['FS'] !== 'undefined' && typeof window['FS'].identify === 'function';
});
expect(fsExists).toBe(true);

// Check for namespace conflict error in actual console output
const hasNamespaceConflict = consoleMessages.some((msg) =>
msg.includes('FullStory namespace conflict')
);

if (hasNamespaceConflict) {
console.error('❌ FullStory namespace conflict detected!');
console.error('Console messages:', consoleMessages);
}

expect(hasNamespaceConflict).toBe(false);

// Test FS.identify
const buttonSendIdentify = page.locator('#buttonSendIdentify');
await buttonSendIdentify.click();

const testIdentify = page.locator('#testIdentify');
await expect(testIdentify).toHaveText('called');

// Test FS.event
const buttonSendEvent = page.locator('#buttonSendEvent');
await buttonSendEvent.click();

const testEvent = page.locator('#testEvent');
await expect(testEvent).toHaveText('called');
});
135 changes: 135 additions & 0 deletions tests/integrations/full-story/gtm-fullstory.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Partytown Test Page - FullStory via GTM" />

<title>Partytown FullStory via GTM</title>

<script>
partytown = {
forward: ['FS.identify', 'FS.event'],
strictProxyHas: true,
};
</script>
<script src="/~partytown/debug/partytown.js"></script>

<!-- Exact FullStory snippet from GTM Custom HTML tag -->
<script type="text/partytown">
window['_fs_host'] = 'fullstory.example.com';
window['_fs_script'] = 'fullstory.example.com/s/fs.js';
window['_fs_app_host'] = 'app.fullstory.com';
window['_fs_org'] = 'o-ABC123-na1';
window['_fs_namespace'] = 'FS';
!function(m,n,e,t,l,o,g,y){var s,f,a=function(h){
return!(h in m)||(m.console&&m.console.log&&m.console.log('FullStory namespace conflict. Please set window["_fs_namespace"].'),!1)}(e)
;function p(b){var h,d=[];function j(){h&&(d.forEach((function(b){var d;try{d=b[h[0]]&&b[h[0]](h[1])}catch(h){return void(b[3]&&b[3](h))}
d&&d.then?d.then(b[2],b[3]):b[2]&&b[2](d)})),d.length=0)}function r(b){return function(d){h||(h=[b,d],j())}}return b(r(0),r(1)),{
then:function(b,h){return p((function(r,i){d.push([b,h,r,i]),j()}))}}}a&&(g=m[e]=function(){var b=function(b,d,j,r){function i(i,c){
h(b,d,j,i,c,r)}r=r||2;var c,u=/Async$/;return u.test(b)?(b=b.replace(u,""),"function"==typeof Promise?new Promise(i):p(i)):h(b,d,j,c,c,r)}
;function h(h,d,j,r,i,c){return b._api?b._api(h,d,j,r,i,c):(b.q&&b.q.push([h,d,j,r,i,c]),null)}return b.q=[],b}(),y=function(b){function h(h){
"function"==typeof h[4]&&h[4](new Error(b))}var d=g.q;if(d){for(var j=0;j<d.length;j++)h(d[j]);d.length=0,d.push=h}},function(){
(o=n.createElement(t)).async=!0,o.crossOrigin="anonymous",o.src="https://"+l,o.onerror=function(){y("Error loading "+l)}
;var b=n.getElementsByTagName(t)[0];b&&b.parentNode?b.parentNode.insertBefore(o,b):n.head.appendChild(o)}(),function(){function b(){}
function h(b,h,d){g(b,h,d,1)}function d(b,d,j){h("setProperties",{type:b,properties:d},j)}function j(b,h){d("user",b,h)}function r(b,h,d){j({
uid:b},d),h&&j(h,d)}g.identify=r,g.setUserVars=j,g.identifyAccount=b,g.clearUserCookie=b,g.setVars=d,g.event=function(b,d,j){h("trackEvent",{
name:b,properties:d},j)},g.anonymize=function(){r(!1)},g.shutdown=function(){h("shutdown")},g.restart=function(){h("restart")},
g.log=function(b,d){h("log",{level:b,msg:d})},g.consent=function(b){h("setIdentity",{consent:!arguments.length||b})}}(),s="fetch",
f="XMLHttpRequest",g._w={},g._w[f]=m[f],g._w[s]=m[s],m[s]&&(m[s]=function(){return g._w[s].apply(this,arguments)}),g._v="2.0.0")
}(window,document,window._fs_namespace,"script",window._fs_script);
</script>

<link
rel="icon"
id="favicon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌎</text></svg>"
/>
<style>
body {
font-family:
-apple-system,
BlinkMacSystemFont,
Segoe UI,
Helvetica,
Arial,
sans-serif,
Apple Color Emoji,
Segoe UI Emoji;
font-size: 12px;
}
h1 {
margin: 0 0 15px 0;
}
button {
padding: 10px 20px;
margin: 10px 0;
font-size: 14px;
}
p {
margin: 10px 0;
}
strong {
font-weight: 600;
}
</style>
</head>
<body>
<h1>Partytown FullStory via GTM</h1>

<p>
<strong>FS exists:</strong>
<span id="testFSExists"></span>
</p>

<p>
<strong>FS.identify Test:</strong>
<span id="testIdentify"></span>
</p>

<p>
<strong>FS.event Test:</strong>
<span id="testEvent"></span>
</p>

<script>
function sendIdentify() {
console.log('Calling FS.identify...');
window['FS'].identify('test-user-123', {
displayName: 'Test User',
email: 'test.user@example.com',
});
document.getElementById('testIdentify').textContent = 'called';
}

function sendEvent() {
console.log('Calling FS.event...');
window['FS'].event('test_event', {
property1: 'value1',
property2: 'value2',
});
document.getElementById('testEvent').textContent = 'called';
}
</script>

<button onclick="sendIdentify()" id="buttonSendIdentify">Test FS.identify</button>
<button onclick="sendEvent()" id="buttonSendEvent">Test FS.event</button>

<script type="text/partytown">
(function () {
// Wait a bit for FullStory to initialize
setTimeout(function() {
if (typeof window['FS'] !== 'undefined' && typeof window['FS'].identify === 'function') {
document.getElementById('testFSExists').textContent = 'yes';
} else {
document.getElementById('testFSExists').textContent = 'no';
}
document.body.classList.add('completed');
}, 100);
})();
</script>

<p><a href="/tests/integrations/full-story/">Back to FullStory Tests</a></p>
<p><a href="/tests/">All Tests</a></p>
</body>
</html>
Loading