Skip to content

Commit 1d2fc97

Browse files
committed
feat(toolbox/domtargeter): adding navigationRetarget for SPA support
1 parent 3d3ab6a commit 1d2fc97

4 files changed

Lines changed: 229 additions & 10 deletions

File tree

packages/snap-preact/src/Instantiators/RecommendationInstantiator.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,14 @@ export class RecommendationInstantiator {
133133
}, script[type="searchspring/recommend"][profile="email"]`,
134134
autoRetarget: true,
135135
clickRetarget: true,
136+
navigationRetarget: true,
136137
emptyTarget: false,
137138
},
138139
{
139140
selector: 'script[type="searchspring/recommendations"]',
140141
autoRetarget: true,
141142
clickRetarget: true,
143+
navigationRetarget: true,
142144
emptyTarget: false,
143145
},
144146
],
@@ -318,7 +320,10 @@ export class RecommendationInstantiator {
318320
const targeters = Object.values(controller.targeters);
319321

320322
const hasConnectedTarget = targeters.some((targeter) =>
321-
targeter.getTargetedElems().some((elem) => elem.isConnected && elem.getAttribute('ss-controller-id'))
323+
targeter.getTargetedElems().some((elem) => {
324+
const attr = elem.isConnected && elem.getAttribute('ss-controller-id');
325+
return attr === id || attr === '...';
326+
})
322327
);
323328
if (!hasConnectedTarget) {
324329
Object.keys(controller.targeters).forEach((targeterId) => controller.targeters[targeterId].releaseTargets());

packages/snap-toolbox/src/DomTargeter/DomTargeter.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1383,4 +1383,131 @@ describe('DomTargeter', () => {
13831383
expect(receivedTargeter).toBe(targeter);
13841384
});
13851385
});
1386+
1387+
describe('destroy', () => {
1388+
it('releases all tracked elements', () => {
1389+
const dom = createDocument(`
1390+
<div id="content">
1391+
<div class="target-a"></div>
1392+
<div class="target-b"></div>
1393+
</div>
1394+
`);
1395+
1396+
const document = dom.window.document;
1397+
const targeter = new DomTargeter([{ selector: '.target-a' }, { selector: '.target-b' }], () => {}, document);
1398+
1399+
expect(targeter.getTargetedElems().length).toBe(2);
1400+
1401+
targeter.destroy();
1402+
expect(targeter.getTargetedElems().length).toBe(0);
1403+
});
1404+
1405+
it('stops autoRetarget polling after destroy', async () => {
1406+
const dom = createDocument(`
1407+
<div id="content"></div>
1408+
`);
1409+
1410+
const document = dom.window.document;
1411+
const onTarget = jest.fn();
1412+
1413+
const targeter = new DomTargeter([{ selector: '.dynamic', autoRetarget: true }], onTarget, document);
1414+
1415+
expect(onTarget).toHaveBeenCalledTimes(0);
1416+
1417+
targeter.destroy();
1418+
1419+
// add the element after destroy
1420+
const elem = document.createElement('div');
1421+
elem.className = 'dynamic';
1422+
document.getElementById('content')!.appendChild(elem);
1423+
1424+
await wait(500);
1425+
1426+
// should not have been targeted because polling was stopped
1427+
expect(onTarget).toHaveBeenCalledTimes(0);
1428+
});
1429+
1430+
it('removes click listeners after destroy', async () => {
1431+
const dom = createDocument(`
1432+
<div id="content"></div>
1433+
`);
1434+
1435+
const document = dom.window.document;
1436+
const onTarget = jest.fn();
1437+
1438+
const targeter = new DomTargeter([{ selector: '.dynamic', clickRetarget: true }], onTarget, document);
1439+
1440+
targeter.destroy();
1441+
1442+
// add element and click
1443+
const elem = document.createElement('div');
1444+
elem.className = 'dynamic';
1445+
document.getElementById('content')!.appendChild(elem);
1446+
document.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
1447+
1448+
await wait(300);
1449+
1450+
expect(onTarget).toHaveBeenCalledTimes(0);
1451+
});
1452+
1453+
it('stops autoRetarget and listeners after destroy', async () => {
1454+
const dom = createDocument(`
1455+
<div id="content"></div>
1456+
`);
1457+
1458+
const document = dom.window.document;
1459+
const onTarget = jest.fn();
1460+
1461+
const targeter = new DomTargeter([{ selector: '.dynamic', autoRetarget: true }], onTarget, document);
1462+
1463+
targeter.destroy();
1464+
1465+
// add element after destroy - listeners should be cleaned up
1466+
const elem = document.createElement('div');
1467+
elem.className = 'dynamic';
1468+
document.getElementById('content')!.appendChild(elem);
1469+
1470+
await wait(300);
1471+
1472+
expect(onTarget).toHaveBeenCalledTimes(0);
1473+
});
1474+
1475+
it('frees elements for other targeters after destroy', () => {
1476+
const dom = createDocument(`
1477+
<div id="content">
1478+
<div class="target"></div>
1479+
</div>
1480+
`);
1481+
1482+
const document = dom.window.document;
1483+
1484+
const targeterA = new DomTargeter([{ selector: '.target' }], () => {}, document);
1485+
1486+
const onTargetB = jest.fn();
1487+
new DomTargeter([{ selector: '.target' }], onTargetB, document);
1488+
expect(onTargetB).toHaveBeenCalledTimes(0);
1489+
1490+
targeterA.destroy();
1491+
1492+
const onTargetC = jest.fn();
1493+
new DomTargeter([{ selector: '.target' }], onTargetC, document);
1494+
expect(onTargetC).toHaveBeenCalledTimes(1);
1495+
});
1496+
1497+
it('is safe to call multiple times', () => {
1498+
const dom = createDocument(`
1499+
<div id="content">
1500+
<div class="target"></div>
1501+
</div>
1502+
`);
1503+
1504+
const document = dom.window.document;
1505+
const targeter = new DomTargeter([{ selector: '.target' }], () => {}, document);
1506+
1507+
expect(() => {
1508+
targeter.destroy();
1509+
targeter.destroy();
1510+
}).not.toThrow();
1511+
});
1512+
});
13861513
});

packages/snap-toolbox/src/DomTargeter/DomTargeter.ts

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,33 @@ export type Target = {
99
autoRetarget?: boolean;
1010
unsetTargetMinHeight?: boolean;
1111
clickRetarget?: boolean | string;
12+
navigationRetarget?: boolean;
1213
[any: string]: unknown;
1314
};
1415

1516
export type OnTarget = (target: Target, elem: Element, originalElem?: Element, targeter?: DomTargeter) => void | Promise<void>;
1617

1718
let globallyTargetedElems: Array<Element> = [];
19+
1820
export class DomTargeter {
1921
private targets: Array<Target> = [];
2022
private onTarget: OnTarget;
2123
private document: Document;
2224
private styleBlockRefs: Record<string, Node> = {};
2325
private targetedElems: Array<Element> = [];
26+
private abortController?: AbortController;
2427

2528
constructor(targets: Array<Target>, onTarget: OnTarget, document?: Document) {
2629
this.document = document || window.document;
2730

31+
// use the document's own window AbortController to ensure realm compatibility (e.g., JSDOM)
32+
// and gracefully handle environments where AbortController is not available (e.g., IE11)
33+
try {
34+
this.abortController = new ((this.document.defaultView || window) as any).AbortController();
35+
} catch (e) {
36+
// AbortController not available - listeners won't auto-cleanup on destroy()
37+
}
38+
2839
this.targets = targets;
2940

3041
this.onTarget = onTarget;
@@ -34,6 +45,9 @@ export class DomTargeter {
3445
this.targets.forEach((target) => {
3546
let timeoutTime = 100;
3647
const checker = () => {
48+
if (this.abortController?.signal.aborted) {
49+
return;
50+
}
3751
// lets not just keep trying forever - this waits roughly 12 seconds before giving up.
3852
if (timeoutTime < 2000) {
3953
// increase the time till next check
@@ -57,13 +71,33 @@ export class DomTargeter {
5771
}
5872

5973
clickElems.map((elem) => {
60-
elem.addEventListener('click', () => {
61-
timeoutTime = 100;
62-
checker();
63-
});
74+
elem.addEventListener(
75+
'click',
76+
() => {
77+
timeoutTime = 100;
78+
setTimeout(checker); // allow the click to complete
79+
},
80+
{ capture: true, signal: this.abortController!.signal }
81+
);
6482
});
6583
}
6684

85+
// listen for SPA navigations via the Navigation API to restart retargeting
86+
if (target.navigationRetarget) {
87+
try {
88+
(this.document.defaultView as any)?.navigation?.addEventListener(
89+
'navigate',
90+
() => {
91+
timeoutTime = 100;
92+
checker();
93+
},
94+
{ signal: this.abortController!.signal }
95+
);
96+
} catch (e) {
97+
// Navigation API not available
98+
}
99+
}
100+
67101
if (target.autoRetarget) {
68102
// do initial retargeting check
69103
checker();
@@ -72,10 +106,14 @@ export class DomTargeter {
72106
target.hideTarget && this.unhideTarget(target.selector);
73107
} else {
74108
// attempt retarget on DOMContentLoaded
75-
this.document.addEventListener('DOMContentLoaded', () => {
76-
this.retarget();
77-
target.hideTarget && this.unhideTarget(target.selector);
78-
});
109+
this.document.addEventListener(
110+
'DOMContentLoaded',
111+
() => {
112+
this.retarget();
113+
target.hideTarget && this.unhideTarget(target.selector);
114+
},
115+
{ signal: this.abortController!.signal }
116+
);
79117
}
80118
});
81119
}
@@ -89,7 +127,7 @@ export class DomTargeter {
89127
return [...this.targetedElems];
90128
}
91129

92-
releaseTargets(elems?: Element[]): void {
130+
releaseTargets(elems?: ReadonlyArray<Element>): void {
93131
const toRelease = elems || this.targetedElems;
94132
toRelease.forEach((elem) => {
95133
const idx = globallyTargetedElems.indexOf(elem);
@@ -104,6 +142,12 @@ export class DomTargeter {
104142
}
105143
}
106144

145+
destroy(): void {
146+
this.abortController?.abort();
147+
this.releaseTargets();
148+
Object.keys(this.styleBlockRefs).forEach((selector) => this.unhideTarget(selector));
149+
}
150+
107151
retarget(): void {
108152
// prune references to elements no longer in the DOM
109153
globallyTargetedElems = globallyTargetedElems.filter((elem) => elem.isConnected);

packages/snap-toolbox/src/DomTargeter/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,22 @@ Default: `false`
202202
}
203203
```
204204

205+
Note: Click listeners use the capture phase to ensure they fire even when frameworks call `stopPropagation()` on click events.
206+
207+
### `navigationRetarget`
208+
When `true`, listens for SPA navigation events using the [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API) (`navigate` event). When a navigation occurs, the retargeting checker is restarted. This is useful for single-page applications that use `pushState` or `replaceState` for client-side routing.
209+
210+
Falls back gracefully in browsers that do not support the Navigation API.
211+
212+
Default: `false`
213+
214+
```js
215+
{
216+
selector: '#content',
217+
navigationRetarget: true
218+
}
219+
```
220+
205221
### `unsetTargetMinHeight`
206222
When `true`, removes any `min-height` CSS property from the target element after successful targeting. This is useful for removing placeholder styles.
207223

@@ -259,6 +275,31 @@ Returns the array of targets specified during construction.
259275
const targets = contentTarget.getTargets();
260276
```
261277

278+
### `getTargetedElems` method
279+
Returns a read-only array of elements currently tracked by this DomTargeter instance. Automatically prunes disconnected elements before returning.
280+
281+
```js
282+
const elems = contentTarget.getTargetedElems();
283+
```
284+
285+
### `releaseTargets` method
286+
Releases elements from both instance and global tracking, allowing them to be retargeted by other DomTargeter instances. Can release all tracked elements or a specific subset.
287+
288+
```js
289+
// release all tracked elements
290+
contentTarget.releaseTargets();
291+
292+
// release specific elements
293+
contentTarget.releaseTargets([elem1, elem2]);
294+
```
295+
296+
### `destroy` method
297+
Fully cleans up the DomTargeter instance. Aborts all event listeners (click, navigation, DOMContentLoaded), releases all tracked elements, and removes any style blocks created by `hideTarget`.
298+
299+
```js
300+
contentTarget.destroy();
301+
```
302+
262303
## Complete Example
263304

264305
```js
@@ -272,6 +313,8 @@ const targeter = new DomTargeter(
272313
component: <SearchResults />,
273314
hideTarget: true,
274315
autoRetarget: true,
316+
clickRetarget: true,
317+
navigationRetarget: true,
275318
unsetTargetMinHeight: true
276319
},
277320
{

0 commit comments

Comments
 (0)