Skip to content

Commit 3d3ab6a

Browse files
committed
feat(toolbox/domtargeter): adding a releaseTargets method to allow for retargeting in SPA
1 parent 354796b commit 3d3ab6a

3 files changed

Lines changed: 207 additions & 9 deletions

File tree

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

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ export class RecommendationInstantiator {
143143
},
144144
],
145145
async (target: Target, elem: Element | undefined, _originalElem?: Element, targeter?: DomTargeter) => {
146+
this.cleanupStaleControllers();
147+
146148
this.targeter = this.targeter || targeter!;
147149

148150
const scriptElement = elem as HTMLScriptElement;
@@ -206,8 +208,11 @@ export class RecommendationInstantiator {
206208
_originalElem?: Element,
207209
targeter?: DomTargeter
208210
) => {
209-
// skip retarget if the source script element was removed from the DOM (SPA navigation)
211+
// skip retarget if the source script element was removed and release the current target
210212
if (!scriptElement.isConnected) {
213+
if (targetElem) {
214+
targeter?.releaseTargets([targetElem]);
215+
}
211216
return;
212217
}
213218

@@ -278,8 +283,11 @@ export class RecommendationInstantiator {
278283
new DomTargeter(
279284
[{ selector: `[searchspring-recommend="${profileAttr}"]`, name: `legacy_${profile}_${profileCount[profile || ''] || 0}` }],
280285
async (_target: Target, targetElem: Element | undefined, _originalElem?: Element, targeter?: DomTargeter) => {
281-
// skip retarget if the source script element was removed from the DOM (SPA navigation)
286+
// skip retarget if the source script element was removed and release the current target
282287
if (!scriptElement.isConnected) {
288+
if (targetElem) {
289+
targeter?.releaseTargets([targetElem]);
290+
}
283291
return;
284292
}
285293

@@ -309,8 +317,11 @@ export class RecommendationInstantiator {
309317
const controller = this.controller[id];
310318
const targeters = Object.values(controller.targeters);
311319

312-
const hasConnectedTarget = targeters.some((targeter) => targeter.getTargetedElems().some((elem) => elem.isConnected));
320+
const hasConnectedTarget = targeters.some((targeter) =>
321+
targeter.getTargetedElems().some((elem) => elem.isConnected && elem.getAttribute('ss-controller-id'))
322+
);
313323
if (!hasConnectedTarget) {
324+
Object.keys(controller.targeters).forEach((targeterId) => controller.targeters[targeterId].releaseTargets());
314325
controller.targeters = {};
315326
delete this.controller[id];
316327
if (window.searchspring?.controller) {
@@ -362,8 +373,10 @@ async function readyTheController(
362373
controllerConfigBase.branch = profile?.branch;
363374
}
364375

365-
// clean up controllers whose rendered elements are no longer in the DOM (SPA navigation)
366-
instance.cleanupStaleControllers();
376+
// mark element immediately to prevent cleanupStaleControllers from releasing it before async work completes
377+
if (targetElem) {
378+
targetElem.setAttribute('ss-controller-id', '...');
379+
}
367380

368381
profileCount[tag] = profileCount[tag] + 1 || 1;
369382
const controllerConfig = {
@@ -429,6 +442,11 @@ async function renderController(
429442
targetElem: Element,
430443
scriptElem: Element | undefined
431444
) {
445+
// update the element with the real controller ID (replaces 'loading' placeholder)
446+
if (targetElem) {
447+
targetElem.setAttribute('ss-controller-id', controller.config.id);
448+
}
449+
432450
const tag = controller.config.tag;
433451
const component = controller.store.profile.display.template?.component;
434452

@@ -451,7 +469,6 @@ async function renderController(
451469
}
452470

453471
setTimeout(() => {
454-
targetElem.setAttribute('ss-controller-id', controller.config.id);
455472
render(<RecommendationsComponent controller={controller} />, targetElem);
456473
});
457474
}

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

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,6 +1162,172 @@ describe('DomTargeter', () => {
11621162
});
11631163
});
11641164

1165+
describe('releaseTargets', () => {
1166+
it('clears tracked elements and allows retargeting', () => {
1167+
const dom = createDocument(`
1168+
<div id="content">
1169+
<div class="target-a"></div>
1170+
<div class="target-b"></div>
1171+
</div>
1172+
`);
1173+
1174+
const document = dom.window.document;
1175+
const onTarget = jest.fn();
1176+
1177+
const targeter = new DomTargeter([{ selector: '.target-a' }, { selector: '.target-b' }], onTarget, document);
1178+
1179+
expect(onTarget).toHaveBeenCalledTimes(2);
1180+
expect(targeter.getTargetedElems().length).toBe(2);
1181+
1182+
// retarget without release should not re-trigger
1183+
targeter.retarget();
1184+
expect(onTarget).toHaveBeenCalledTimes(2);
1185+
1186+
// release and retarget should re-trigger for the same elements
1187+
targeter.releaseTargets();
1188+
expect(targeter.getTargetedElems().length).toBe(0);
1189+
1190+
targeter.retarget();
1191+
expect(onTarget).toHaveBeenCalledTimes(4);
1192+
expect(targeter.getTargetedElems().length).toBe(2);
1193+
});
1194+
1195+
it('removes elements from global tracking so other targeters can pick them up', () => {
1196+
const dom = createDocument(`
1197+
<div id="content">
1198+
<div class="shared"></div>
1199+
</div>
1200+
`);
1201+
1202+
const document = dom.window.document;
1203+
const onTargetA = jest.fn();
1204+
const onTargetB = jest.fn();
1205+
1206+
// first targeter claims the element
1207+
const targeterA = new DomTargeter([{ selector: '.shared' }], onTargetA, document);
1208+
expect(onTargetA).toHaveBeenCalledTimes(1);
1209+
1210+
// second targeter cannot target it (globally tracked)
1211+
new DomTargeter([{ selector: '.shared' }], onTargetB, document);
1212+
expect(onTargetB).toHaveBeenCalledTimes(0);
1213+
1214+
// release from first targeter
1215+
targeterA.releaseTargets();
1216+
1217+
// now a new targeter can claim it
1218+
const onTargetC = jest.fn();
1219+
new DomTargeter([{ selector: '.shared' }], onTargetC, document);
1220+
expect(onTargetC).toHaveBeenCalledTimes(1);
1221+
});
1222+
1223+
it('works with inject targets', () => {
1224+
const dom = createDocument(`
1225+
<div id="content">
1226+
<div class="original"></div>
1227+
</div>
1228+
`);
1229+
1230+
const document = dom.window.document;
1231+
const onTarget = jest.fn();
1232+
1233+
const targeter = new DomTargeter(
1234+
[
1235+
{
1236+
selector: '.original',
1237+
inject: {
1238+
action: 'append',
1239+
element: () => {
1240+
const el = document.createElement('div');
1241+
el.className = 'injected';
1242+
return el;
1243+
},
1244+
},
1245+
},
1246+
],
1247+
onTarget,
1248+
document
1249+
);
1250+
1251+
expect(onTarget).toHaveBeenCalledTimes(1);
1252+
expect(targeter.getTargetedElems().length).toBe(1);
1253+
1254+
targeter.releaseTargets();
1255+
expect(targeter.getTargetedElems().length).toBe(0);
1256+
});
1257+
1258+
it('is safe to call when there are no tracked elements', () => {
1259+
const dom = createDocument(`<div id="content"></div>`);
1260+
const document = dom.window.document;
1261+
1262+
const targeter = new DomTargeter([{ selector: '.nonexistent' }], () => {}, document);
1263+
1264+
expect(targeter.getTargetedElems().length).toBe(0);
1265+
expect(() => targeter.releaseTargets()).not.toThrow();
1266+
expect(targeter.getTargetedElems().length).toBe(0);
1267+
});
1268+
1269+
it('releases only specified elements when an array is provided', () => {
1270+
const dom = createDocument(`
1271+
<div id="content">
1272+
<div class="target-a"></div>
1273+
<div class="target-b"></div>
1274+
</div>
1275+
`);
1276+
1277+
const document = dom.window.document;
1278+
const onTarget = jest.fn();
1279+
1280+
const targeter = new DomTargeter([{ selector: '.target-a' }, { selector: '.target-b' }], onTarget, document);
1281+
1282+
expect(onTarget).toHaveBeenCalledTimes(2);
1283+
expect(targeter.getTargetedElems().length).toBe(2);
1284+
1285+
const elemA = document.querySelector('.target-a')!;
1286+
1287+
// release only element A
1288+
targeter.releaseTargets([elemA]);
1289+
expect(targeter.getTargetedElems().length).toBe(1);
1290+
expect(targeter.getTargetedElems()[0]).toBe(document.querySelector('.target-b'));
1291+
1292+
// retarget should only re-trigger for released element A
1293+
targeter.retarget();
1294+
expect(onTarget).toHaveBeenCalledTimes(3);
1295+
expect(targeter.getTargetedElems().length).toBe(2);
1296+
});
1297+
1298+
it('releases specified elements from global tracking so other targeters can pick them up', () => {
1299+
const dom = createDocument(`
1300+
<div id="content">
1301+
<div class="target-a"></div>
1302+
<div class="target-b"></div>
1303+
</div>
1304+
`);
1305+
1306+
const document = dom.window.document;
1307+
const onTargetA = jest.fn();
1308+
const onTargetB = jest.fn();
1309+
1310+
// first targeter claims both elements
1311+
const targeterA = new DomTargeter([{ selector: '.target-a' }, { selector: '.target-b' }], onTargetA, document);
1312+
expect(onTargetA).toHaveBeenCalledTimes(2);
1313+
1314+
// second targeter cannot target either (globally tracked)
1315+
new DomTargeter([{ selector: '.target-a' }, { selector: '.target-b' }], onTargetB, document);
1316+
expect(onTargetB).toHaveBeenCalledTimes(0);
1317+
1318+
const elemA = document.querySelector('.target-a')!;
1319+
1320+
// release only element A from first targeter
1321+
targeterA.releaseTargets([elemA]);
1322+
1323+
// a new targeter can claim element A but not element B
1324+
const onTargetC = jest.fn();
1325+
new DomTargeter([{ selector: '.target-a' }, { selector: '.target-b' }], onTargetC, document);
1326+
expect(onTargetC).toHaveBeenCalledTimes(1);
1327+
expect(onTargetC.mock.calls[0][1]).toBe(elemA);
1328+
});
1329+
});
1330+
11651331
describe('onTarget targeter parameter', () => {
11661332
it('passes the DomTargeter instance as the fourth argument to onTarget', () => {
11671333
const dom = createDocument(`

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,21 @@ export class DomTargeter {
8989
return [...this.targetedElems];
9090
}
9191

92+
releaseTargets(elems?: Element[]): void {
93+
const toRelease = elems || this.targetedElems;
94+
toRelease.forEach((elem) => {
95+
const idx = globallyTargetedElems.indexOf(elem);
96+
if (idx !== -1) {
97+
globallyTargetedElems.splice(idx, 1);
98+
}
99+
});
100+
if (elems) {
101+
this.targetedElems = this.targetedElems.filter((elem) => !elems.includes(elem));
102+
} else {
103+
this.targetedElems = [];
104+
}
105+
}
106+
92107
retarget(): void {
93108
// prune references to elements no longer in the DOM
94109
globallyTargetedElems = globallyTargetedElems.filter((elem) => elem.isConnected);
@@ -116,9 +131,11 @@ export class DomTargeter {
116131

117132
for (const { target, elem } of targetElemPairs) {
118133
try {
134+
// track targeted elements
135+
this.targetedElems = this.targetedElems.concat(elem);
136+
119137
if (target.inject) {
120138
const injectedElem = this.inject(elem, target);
121-
this.targetedElems = this.targetedElems.concat(elem);
122139

123140
// handle both sync and async onTarget functions
124141
const result = this.onTarget(target, injectedElem, elem, this);
@@ -129,8 +146,6 @@ export class DomTargeter {
129146
});
130147
}
131148
} else {
132-
this.targetedElems = this.targetedElems.concat(elem);
133-
134149
// empty target selector by default
135150
target.emptyTarget = target.emptyTarget ?? true;
136151
if (target.emptyTarget) while (elem.firstChild && elem.removeChild(elem.firstChild));

0 commit comments

Comments
 (0)