Skip to content

Commit 70d05eb

Browse files
committed
fix(docs): recover shared consent after connection changes
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
1 parent 46aff9b commit 70d05eb

2 files changed

Lines changed: 133 additions & 26 deletions

File tree

‎.github/scripts/ci_changes.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,7 @@
662662
"docs/site/src/lib/docset-retention.mjs",
663663
"docs/site/src/lib/generated-api-bases.mjs",
664664
"docs/site/src/lib/page-markdown.ts",
665+
"docs/site/src/lib/tracking-protocol.mjs",
665666
}
666667
)
667668

‎docs/site/src/lib/shared-tracking-client.mjs‎

Lines changed: 132 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export function createSharedTrackingClient(options = {}) {
7676
let state = { ...EMPTY_STATE };
7777
let frame;
7878
let frameReady;
79+
let frameReadyCancel;
7980
let closed = false;
8081
let suspended = false;
8182
let localRefusal = false;
@@ -104,11 +105,33 @@ export function createSharedTrackingClient(options = {}) {
104105
for (const subscriber of failureSubscribers) subscriber(error);
105106
return error;
106107
};
108+
const recoverableFailures = new WeakSet();
109+
const recoverableFailure = () => {
110+
const error = trackingError();
111+
recoverableFailures.add(error);
112+
return error;
113+
};
107114
const discardFrame = () => {
115+
if (frameReadyCancel) {
116+
const cancel = frameReadyCancel;
117+
frameReadyCancel = undefined;
118+
cancel();
119+
return;
120+
}
108121
frame?.remove();
109122
frame = undefined;
110123
frameReady = undefined;
111124
};
125+
const dropFailedFrame = failedFrame => {
126+
if (!failedFrame || frame !== failedFrame) return;
127+
discardFrame();
128+
for (const [id, item] of [...pending]) {
129+
if (item.frame !== failedFrame) continue;
130+
pending.delete(id);
131+
clearTimeout(item.timer);
132+
item.reject(trackingError());
133+
}
134+
};
112135
const replaceNativeRealm = next => {
113136
discardFrame();
114137
for (const [id, item] of [...pending]) {
@@ -167,10 +190,16 @@ export function createSharedTrackingClient(options = {}) {
167190
pending.delete(id);
168191
clearTimeout(item.timer);
169192
const response = parseTrackingResponse(event.data);
170-
if (!response || !response.ok || (response.deviceId && item.type !== 'associate')) {
193+
if (!response || (response.deviceId && item.type !== 'associate')) {
171194
item.reject(fail());
172195
return;
173196
}
197+
if (!response.ok) {
198+
item.reject(item.recoverUnavailable && response.error === 'unavailable'
199+
? recoverableFailure()
200+
: fail());
201+
return;
202+
}
174203
const current = update(response.state);
175204
if (item.type === 'associate') {
176205
if (!current || effectiveState().choice !== 'accepted' ||
@@ -188,42 +217,113 @@ export function createSharedTrackingClient(options = {}) {
188217
const ensureFrame = () => {
189218
if (closed || !currentWindow || !currentDocument) return Promise.reject(trackingError());
190219
if (frameReady) return frameReady;
191-
frameReady = new Promise((resolve, reject) => {
192-
frame = currentDocument.createElement('iframe');
193-
frame.src = bridge.href;
194-
frame.hidden = true;
195-
frame.title = 'Shared tracking preferences';
196-
frame.referrerPolicy = 'no-referrer';
197-
frame.setAttribute('aria-hidden', 'true');
198-
frame.setAttribute('tabindex', '-1');
199-
const timer = setTimeout(() => reject(trackingError()), timeoutMs);
200-
frame.addEventListener('load', () => { clearTimeout(timer); resolve(frame); }, { once: true });
201-
frame.addEventListener('error', () => { clearTimeout(timer); reject(trackingError()); }, { once: true });
202-
currentDocument.body.append(frame);
220+
const candidate = currentDocument.createElement('iframe');
221+
candidate.src = bridge.href;
222+
candidate.hidden = true;
223+
candidate.title = 'Shared tracking preferences';
224+
candidate.referrerPolicy = 'no-referrer';
225+
candidate.setAttribute('aria-hidden', 'true');
226+
candidate.setAttribute('tabindex', '-1');
227+
let settled = false;
228+
let resolveReady;
229+
let rejectReady;
230+
const ready = new Promise((resolve, reject) => {
231+
resolveReady = resolve;
232+
rejectReady = reject;
203233
});
204-
return frameReady;
205-
};
206-
const request = async (type, payload = {}, requestOptions = {}) => {
207-
let readyFrame;
234+
frame = candidate;
235+
frameReady = ready;
236+
const reset = () => {
237+
if (settled) return;
238+
settled = true;
239+
clearTimeout(timer);
240+
if (frame === candidate) frame = undefined;
241+
if (frameReady === ready) frameReady = undefined;
242+
if (frameReadyCancel === reset) frameReadyCancel = undefined;
243+
candidate.remove();
244+
rejectReady(trackingError());
245+
};
246+
const timer = setTimeout(reset, timeoutMs);
247+
frameReadyCancel = reset;
248+
candidate.addEventListener('load', () => {
249+
if (settled) return;
250+
if (closed || frame !== candidate || frameReady !== ready) {
251+
reset();
252+
return;
253+
}
254+
settled = true;
255+
clearTimeout(timer);
256+
if (frameReadyCancel === reset) frameReadyCancel = undefined;
257+
resolveReady(candidate);
258+
}, { once: true });
259+
candidate.addEventListener('error', reset, { once: true });
208260
try {
209-
readyFrame = await ensureFrame();
261+
currentDocument.body.append(candidate);
210262
} catch {
211-
throw fail();
263+
reset();
212264
}
213-
if (closed || readyFrame !== frame) throw trackingError();
265+
return ready;
266+
};
267+
const request = (type, payload = {}, requestOptions = {}) => {
214268
const message = createTrackingRequest(type, payload, () => currentWindow.crypto.randomUUID());
215269
return new Promise((resolve, reject) => {
270+
let settled = false;
271+
let requestFrame;
272+
const resolveOnce = value => {
273+
if (settled) return;
274+
settled = true;
275+
clearTimeout(timer);
276+
resolve(value);
277+
};
278+
const rejectOnce = error => {
279+
if (settled) return;
280+
settled = true;
281+
clearTimeout(timer);
282+
reject(error);
283+
};
216284
const timer = setTimeout(() => {
217285
pending.delete(message.id);
218-
reject(fail());
286+
dropFailedFrame(requestFrame);
287+
rejectOnce(fail());
219288
}, requestOptions.timeoutMs ?? timeoutMs);
220-
pending.set(message.id, { resolve, reject, timer, type, ...payload });
221-
readyFrame.contentWindow.postMessage(message, bridge.origin);
289+
let readiness;
290+
try {
291+
readiness = ensureFrame();
292+
} catch {
293+
rejectOnce(fail());
294+
return;
295+
}
296+
readiness.then(readyFrame => {
297+
if (settled) return;
298+
if (closed || readyFrame !== frame) {
299+
rejectOnce(trackingError());
300+
return;
301+
}
302+
requestFrame = readyFrame;
303+
pending.set(message.id, {
304+
resolve: resolveOnce,
305+
reject: rejectOnce,
306+
timer,
307+
type,
308+
frame: readyFrame,
309+
recoverUnavailable: requestOptions.recoverUnavailable === true,
310+
...payload,
311+
});
312+
try {
313+
readyFrame.contentWindow.postMessage(message, bridge.origin);
314+
} catch {
315+
pending.delete(message.id);
316+
dropFailedFrame(readyFrame);
317+
rejectOnce(fail());
318+
}
319+
}, () => {
320+
if (!settled) rejectOnce(fail());
321+
});
222322
});
223323
};
224324

225325
const resume = () => {
226-
if (!closed && frame && currentDocument.visibilityState !== 'hidden') {
326+
if (!closed && currentDocument.visibilityState !== 'hidden') {
227327
void client.read().catch(() => notify());
228328
}
229329
};
@@ -258,10 +358,16 @@ export function createSharedTrackingClient(options = {}) {
258358
notify();
259359
}
260360
try {
261-
await request('choose', { choice, expectedRevision });
361+
await request(
362+
'choose',
363+
{ choice, expectedRevision },
364+
{ recoverUnavailable: choice === 'accepted' },
365+
);
262366
} catch (error) {
263367
if (choice === 'rejected') discardFrame();
264-
throw error;
368+
if (choice !== 'accepted' || !recoverableFailures.has(error)) throw error;
369+
await request('read');
370+
if (effectiveState().choice !== 'accepted') throw fail();
265371
}
266372
// A refusal that could not reach the bridge remains local and visible as
267373
// an error, rather than allowing an older shared acceptance to return.

0 commit comments

Comments
 (0)