Skip to content

Commit 1a012b7

Browse files
authored
fix: enable strictProxyHas for namespace conflict detection in window proxy (#692)
* fix: enable strictProxyHas for namespace conflict detection in window proxy - Add strictProxyHas config option to PartytownConfig for accurate 'in' operator behavior - Update window proxy has trap to check config flag and use Reflect.has when enabled - Add FullStory GTM integration test with exact production snippet - Document strictProxyHas in configuration docs - Add FullStory to common services with GTM configuration guide - Add test-results to .gitignore to prevent committing test artifacts This fixes the issue where FullStory loaded via GTM would detect a false 'namespace conflict' because the window proxy's 'in' operator always returned true. With strictProxyHas enabled, scripts can accurately check for property existence using the 'in' operator while maintaining backwards compatibility for existing implementations. * chore: fix formatting in documentation files
1 parent 14d6b20 commit 1a012b7

9 files changed

Lines changed: 270 additions & 16 deletions

File tree

.changeset/strict-proxy-has.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@qwik.dev/partytown': minor
3+
---
4+
5+
Add `strictProxyHas` configuration option for accurate namespace conflict detection
6+
7+
**Summary:**
8+
9+
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).
10+
11+
**Key Changes:**
12+
13+
- Add `strictProxyHas?: boolean` config option to enable accurate `in` operator behavior
14+
- Update window proxy's `has` trap to use `Reflect.has()` when `strictProxyHas: true`
15+
- Default is `false` for backwards compatibility
16+
- Add FullStory GTM integration test with production-ready snippet
17+
- Document the configuration and provide usage guide
18+
19+
**Usage:**
20+
21+
```html
22+
<script>
23+
partytown = {
24+
forward: ['FS.identify', 'FS.event'],
25+
strictProxyHas: true // Enable for FullStory via GTM
26+
};
27+
</script>
28+
```
29+
30+
**Backwards Compatibility:**
31+
32+
This is a non-breaking change. The default behavior remains unchanged (`strictProxyHas: false`), so existing implementations will continue to work without modifications.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ node_modules/
2323
/index.d.ts
2424
.idea
2525
.history
26+
test-results/
2627
tests/integrations/load-scripts-on-main-thread/snippet.js

docs/src/routes/common-services/index.mdx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,34 @@ Partytown is built with the goal that any third-party script can be ran from wit
88

99
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).
1010

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

2122
If you would like to add to this list,
2223

2324
- 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.
2425
- Send us a PR so that we can have a scenario checked in that validates it.
2526
- 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!
27+
28+
## FullStory with Google Tag Manager
29+
30+
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.
31+
32+
```html
33+
<script>
34+
partytown = {
35+
forward: ['FS.identify', 'FS.event'],
36+
strictProxyHas: true,
37+
};
38+
</script>
39+
```
40+
41+
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.

docs/src/routes/configuration/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Partytown does not require a config for it to work, however a config can be set
1414
| `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/]`. |
1515
| `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. |
1616
| `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). |
17+
| `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. |
1718
| `fallbackTimeout` | A timeout in ms until Partytown initialization is considered as failed & fallbacks to the regular execution in main thread. Default is 9999 |
1819

1920
## Vanilla Config

src/lib/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,23 @@ export interface PartytownConfig {
550550
* ```
551551
*/
552552
nonce?: string;
553+
/**
554+
* When set to `true`, the window proxy's `has` trap will use `Reflect.has()` to accurately
555+
* check if properties exist on the window object, instead of always returning `true`.
556+
*
557+
* This is required for scripts that check for namespace conflicts using the `in` operator,
558+
* such as FullStory: `if (!("FS" in window)) { ... }`
559+
*
560+
* Default: false (for backwards compatibility)
561+
*
562+
* @example
563+
* ```js
564+
* partytown = {
565+
* strictProxyHas: true
566+
* };
567+
* ```
568+
*/
569+
strictProxyHas?: boolean;
553570
}
554571

555572
export type PartytownInternalConfig = Omit<PartytownConfig, 'loadScriptsOnMainThread'> & {

src/lib/web-worker/worker-image.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export const createImageConstructor = (env: WebWorkerEnvironment) =>
7171
toggleAttribute(name: string, force?: boolean): boolean {
7272
const normalizedName = name.toLowerCase();
7373
const hasAttr = this.attributes.has(normalizedName);
74-
74+
7575
if (force !== undefined) {
7676
if (force) {
7777
if (!hasAttr) {
@@ -83,7 +83,7 @@ export const createImageConstructor = (env: WebWorkerEnvironment) =>
8383
return false;
8484
}
8585
}
86-
86+
8787
if (hasAttr) {
8888
this.attributes.delete(normalizedName);
8989
return false;

src/lib/web-worker/worker-window.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -374,11 +374,12 @@ export const createWindow = (
374374
return win[propName];
375375
}
376376
},
377-
has: () =>
378-
// window "has" any and all props, this is especially true for global variables
379-
// that are meant to be assigned to window, but without "window." prefix,
380-
// like: <script>globalProp = true</script>
381-
true,
377+
has: (target, prop) =>
378+
// Check if property exists on the window object
379+
// This is used by the 'in' operator (e.g., "propertyName" in window)
380+
// When strictProxyHas is enabled, use accurate Reflect.has() behavior
381+
// Otherwise, always return true for backwards compatibility
382+
webWorkerCtx.$config$.strictProxyHas ? Reflect.has(target, prop) : true,
382383
}) as any,
383384
$document$: $createNode$(NodeName.Document, $winId$ + '.' + WinDocId.document) as any,
384385
$documentElement$: $createNode$(

tests/integrations/full-story/full-story.spec.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,54 @@ test('full-story', async ({ page }) => {
1111
const testFullStory = page.locator('#testIdentify');
1212
await expect(testFullStory).toHaveText('called');
1313
});
14+
15+
test('full-story via GTM', async ({ page }) => {
16+
// Capture console messages - only actual console.log output, not Partytown debug logs
17+
const consoleMessages: string[] = [];
18+
page.on('console', (msg) => {
19+
const text = msg.text();
20+
// Filter out Partytown's own debug logging (which would contain the script source)
21+
if (!text.includes('%c')) {
22+
consoleMessages.push(text);
23+
}
24+
});
25+
26+
await page.goto('/tests/integrations/full-story/gtm-fullstory.html');
27+
28+
await page.waitForSelector('.completed');
29+
30+
// Check that FS namespace exists and is properly initialized
31+
const testFSExists = page.locator('#testFSExists');
32+
await expect(testFSExists).toHaveText('yes');
33+
34+
const fsExists = await page.evaluate(() => {
35+
return typeof window['FS'] !== 'undefined' && typeof window['FS'].identify === 'function';
36+
});
37+
expect(fsExists).toBe(true);
38+
39+
// Check for namespace conflict error in actual console output
40+
const hasNamespaceConflict = consoleMessages.some((msg) =>
41+
msg.includes('FullStory namespace conflict')
42+
);
43+
44+
if (hasNamespaceConflict) {
45+
console.error('❌ FullStory namespace conflict detected!');
46+
console.error('Console messages:', consoleMessages);
47+
}
48+
49+
expect(hasNamespaceConflict).toBe(false);
50+
51+
// Test FS.identify
52+
const buttonSendIdentify = page.locator('#buttonSendIdentify');
53+
await buttonSendIdentify.click();
54+
55+
const testIdentify = page.locator('#testIdentify');
56+
await expect(testIdentify).toHaveText('called');
57+
58+
// Test FS.event
59+
const buttonSendEvent = page.locator('#buttonSendEvent');
60+
await buttonSendEvent.click();
61+
62+
const testEvent = page.locator('#testEvent');
63+
await expect(testEvent).toHaveText('called');
64+
});
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<meta name="description" content="Partytown Test Page - FullStory via GTM" />
7+
8+
<title>Partytown FullStory via GTM</title>
9+
10+
<script>
11+
partytown = {
12+
forward: ['FS.identify', 'FS.event'],
13+
strictProxyHas: true,
14+
};
15+
</script>
16+
<script src="/~partytown/debug/partytown.js"></script>
17+
18+
<!-- Exact FullStory snippet from GTM Custom HTML tag -->
19+
<script type="text/partytown">
20+
window['_fs_host'] = 'fullstory.example.com';
21+
window['_fs_script'] = 'fullstory.example.com/s/fs.js';
22+
window['_fs_app_host'] = 'app.fullstory.com';
23+
window['_fs_org'] = 'o-ABC123-na1';
24+
window['_fs_namespace'] = 'FS';
25+
!function(m,n,e,t,l,o,g,y){var s,f,a=function(h){
26+
return!(h in m)||(m.console&&m.console.log&&m.console.log('FullStory namespace conflict. Please set window["_fs_namespace"].'),!1)}(e)
27+
;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))}
28+
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)),{
29+
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){
30+
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)}
31+
;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){
32+
"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(){
33+
(o=n.createElement(t)).async=!0,o.crossOrigin="anonymous",o.src="https://"+l,o.onerror=function(){y("Error loading "+l)}
34+
;var b=n.getElementsByTagName(t)[0];b&&b.parentNode?b.parentNode.insertBefore(o,b):n.head.appendChild(o)}(),function(){function b(){}
35+
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({
36+
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",{
37+
name:b,properties:d},j)},g.anonymize=function(){r(!1)},g.shutdown=function(){h("shutdown")},g.restart=function(){h("restart")},
38+
g.log=function(b,d){h("log",{level:b,msg:d})},g.consent=function(b){h("setIdentity",{consent:!arguments.length||b})}}(),s="fetch",
39+
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")
40+
}(window,document,window._fs_namespace,"script",window._fs_script);
41+
</script>
42+
43+
<link
44+
rel="icon"
45+
id="favicon"
46+
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>"
47+
/>
48+
<style>
49+
body {
50+
font-family:
51+
-apple-system,
52+
BlinkMacSystemFont,
53+
Segoe UI,
54+
Helvetica,
55+
Arial,
56+
sans-serif,
57+
Apple Color Emoji,
58+
Segoe UI Emoji;
59+
font-size: 12px;
60+
}
61+
h1 {
62+
margin: 0 0 15px 0;
63+
}
64+
button {
65+
padding: 10px 20px;
66+
margin: 10px 0;
67+
font-size: 14px;
68+
}
69+
p {
70+
margin: 10px 0;
71+
}
72+
strong {
73+
font-weight: 600;
74+
}
75+
</style>
76+
</head>
77+
<body>
78+
<h1>Partytown FullStory via GTM</h1>
79+
80+
<p>
81+
<strong>FS exists:</strong>
82+
<span id="testFSExists"></span>
83+
</p>
84+
85+
<p>
86+
<strong>FS.identify Test:</strong>
87+
<span id="testIdentify"></span>
88+
</p>
89+
90+
<p>
91+
<strong>FS.event Test:</strong>
92+
<span id="testEvent"></span>
93+
</p>
94+
95+
<script>
96+
function sendIdentify() {
97+
console.log('Calling FS.identify...');
98+
window['FS'].identify('test-user-123', {
99+
displayName: 'Test User',
100+
email: 'test.user@example.com',
101+
});
102+
document.getElementById('testIdentify').textContent = 'called';
103+
}
104+
105+
function sendEvent() {
106+
console.log('Calling FS.event...');
107+
window['FS'].event('test_event', {
108+
property1: 'value1',
109+
property2: 'value2',
110+
});
111+
document.getElementById('testEvent').textContent = 'called';
112+
}
113+
</script>
114+
115+
<button onclick="sendIdentify()" id="buttonSendIdentify">Test FS.identify</button>
116+
<button onclick="sendEvent()" id="buttonSendEvent">Test FS.event</button>
117+
118+
<script type="text/partytown">
119+
(function () {
120+
// Wait a bit for FullStory to initialize
121+
setTimeout(function() {
122+
if (typeof window['FS'] !== 'undefined' && typeof window['FS'].identify === 'function') {
123+
document.getElementById('testFSExists').textContent = 'yes';
124+
} else {
125+
document.getElementById('testFSExists').textContent = 'no';
126+
}
127+
document.body.classList.add('completed');
128+
}, 100);
129+
})();
130+
</script>
131+
132+
<p><a href="/tests/integrations/full-story/">Back to FullStory Tests</a></p>
133+
<p><a href="/tests/">All Tests</a></p>
134+
</body>
135+
</html>

0 commit comments

Comments
 (0)