Skip to content

Commit e347d65

Browse files
committed
fix: recover shared tracking choices after outages
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
1 parent bb14dc7 commit e347d65

2 files changed

Lines changed: 126 additions & 5 deletions

File tree

‎docs/site/scripts/shared-tracking-client.test.mjs‎

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@ const generation = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
1313
const accepted = { choice: 'accepted', generation, expiresAt: Date.now() + 60_000, revision: 10 };
1414
const rejected = { choice: 'rejected', generation: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', expiresAt: Date.now() + 60_000, revision: 11 };
1515
const envelope = { protocol: 'registrystack-tracking', version: 2 };
16+
const localRefusalKey = 'registrystack.tracking-local-refusal.v2';
1617

1718
function fixture(options = {}) {
18-
const { factory = createSharedTrackingClient, ...clientOptions } = options;
19+
const { factory = createSharedTrackingClient, initialValues = [], ...clientOptions } = options;
1920
const listeners = new Map();
2021
const documentListeners = new Map();
2122
const sent = [];
2223
const frames = [];
23-
const values = new Map();
24+
const values = new Map(initialValues);
2425
const storage = {
2526
getItem: key => values.get(key) ?? null,
2627
setItem: (key, value) => values.set(key, value),
@@ -457,6 +458,115 @@ test('a same-origin outage refusal gates an already-open tab and is relayed to t
457458
f.client.close();
458459
});
459460

461+
test('a retained local refusal reads the shared revision before replaying rejection', async (t) => {
462+
const f = fixture({ initialValues: [[localRefusalKey, 'rejected']] });
463+
t.after(() => f.client.close());
464+
const recovery = f.client.read();
465+
const outcome = recovery.then(value => ({ value }), error => ({ error }));
466+
await new Promise(resolve => setImmediate(resolve));
467+
468+
const readRequest = f.sent[0].data;
469+
assert.equal(readRequest.type, 'read');
470+
f.message({ ...envelope, id: readRequest.id, ok: true, state: accepted });
471+
await new Promise(resolve => setImmediate(resolve));
472+
473+
assert.equal(f.client.state.choice, 'rejected');
474+
await assert.rejects(f.client.pageview({
475+
page_url: 'https://docs.registrystack.org/', page_title: 'Docs', page_referrer: '',
476+
}), /tracking/i);
477+
assert.equal(f.sent.some(({ data }) => data.type === 'pageview'), false);
478+
const replay = f.sent[1].data;
479+
assert.equal(replay.type, 'choose');
480+
assert.equal(replay.choice, 'rejected');
481+
assert.equal(replay.expectedRevision, accepted.revision);
482+
f.message({ ...envelope, id: replay.id, ok: true, state: rejected });
483+
484+
assert.deepEqual(await outcome, { value: rejected });
485+
assert.equal(f.values.has(localRefusalKey), false);
486+
assert.equal(f.failures.length, 0);
487+
});
488+
489+
test('an explicit acceptance supersedes a delayed startup refusal replay', async (t) => {
490+
const f = fixture({ initialValues: [[localRefusalKey, 'rejected']] });
491+
t.after(() => f.client.close());
492+
const startup = f.client.read();
493+
const startupOutcome = startup.then(value => ({ value }), error => ({ error }));
494+
await new Promise(resolve => setImmediate(resolve));
495+
const readRequest = f.sent[0].data;
496+
assert.equal(readRequest.type, 'read');
497+
498+
const explicit = await nextRequest(() => f.client.choose('accepted'));
499+
const acceptRequest = f.sent[1].data;
500+
assert.equal(acceptRequest.type, 'choose');
501+
assert.equal(acceptRequest.choice, 'accepted');
502+
f.message({ ...envelope, id: acceptRequest.id, ok: true, state: accepted });
503+
assert.deepEqual(await explicit.promise, accepted);
504+
505+
f.message({ ...envelope, id: readRequest.id, ok: true, state: accepted });
506+
assert.deepEqual(await startupOutcome, { value: accepted });
507+
await new Promise(resolve => setImmediate(resolve));
508+
assert.equal(
509+
f.sent.filter(({ data }) => data.type === 'choose' && data.choice === 'rejected').length,
510+
0,
511+
);
512+
assert.equal(f.values.has(localRefusalKey), false);
513+
assert.equal(f.failures.length, 0);
514+
});
515+
516+
test('an explicit acceptance reconciles a stale revision while a retained refusal masks startup', async (t) => {
517+
const f = fixture({ initialValues: [[localRefusalKey, 'rejected']] });
518+
t.after(() => f.client.close());
519+
const startup = f.client.read();
520+
const startupOutcome = startup.then(value => ({ value }), error => ({ error }));
521+
await new Promise(resolve => setImmediate(resolve));
522+
const startupRead = f.sent[0].data;
523+
524+
const explicit = await nextRequest(() => f.client.choose('accepted'));
525+
const explicitOutcome = explicit.promise.then(value => ({ value }), error => ({ error }));
526+
const acceptRequest = f.sent[1].data;
527+
assert.equal(acceptRequest.expectedRevision, 0);
528+
f.message({ ...envelope, id: acceptRequest.id, ok: false, error: 'unavailable' });
529+
await new Promise(resolve => setImmediate(resolve));
530+
const recoveryRead = f.sent[2].data;
531+
assert.equal(recoveryRead.type, 'read');
532+
assert.equal(f.client.state.choice, 'rejected');
533+
f.message({ ...envelope, id: recoveryRead.id, ok: true, state: accepted });
534+
535+
assert.deepEqual(await explicitOutcome, { value: accepted });
536+
f.message({ ...envelope, id: startupRead.id, ok: true, state: accepted });
537+
assert.deepEqual(await startupOutcome, { value: accepted });
538+
assert.equal(f.sent.filter(({ data }) => data.type === 'choose').length, 1);
539+
assert.equal(f.values.has(localRefusalKey), false);
540+
assert.equal(f.failures.length, 0);
541+
});
542+
543+
test('a later refusal survives an older acceptance reconciliation and a failed rejection request', async (t) => {
544+
const f = fixture({ initialValues: [[localRefusalKey, 'rejected']] });
545+
t.after(() => f.client.close());
546+
const acceptance = await nextRequest(() => f.client.choose('accepted'));
547+
const acceptanceOutcome = acceptance.promise.then(value => ({ value }), error => ({ error }));
548+
f.message({ ...envelope, id: f.sent[0].data.id, ok: false, error: 'unavailable' });
549+
await new Promise(resolve => setImmediate(resolve));
550+
const recoveryRead = f.sent[1].data;
551+
assert.equal(recoveryRead.type, 'read');
552+
553+
const refusal = await nextRequest(() => f.client.choose('rejected'));
554+
const refusalOutcome = refusal.promise.then(value => ({ value }), error => ({ error }));
555+
const rejectionRequest = f.sent[2].data;
556+
f.message({ ...envelope, id: recoveryRead.id, ok: true, state: accepted });
557+
assert.equal((await acceptanceOutcome).value?.choice, 'rejected');
558+
assert.equal(f.values.get(localRefusalKey), 'rejected');
559+
560+
f.message({ ...envelope, id: rejectionRequest.id, ok: false, error: 'unavailable' });
561+
assert.match((await refusalOutcome).error?.message, /tracking/i);
562+
assert.equal(f.client.state.choice, 'rejected');
563+
assert.equal(f.values.get(localRefusalKey), 'rejected');
564+
await assert.rejects(f.client.pageview({
565+
page_url: 'https://docs.registrystack.org/', page_title: 'Docs', page_referrer: '',
566+
}), /tracking/i);
567+
assert.equal(f.sent.some(({ data }) => data.type === 'pageview'), false);
568+
});
569+
460570
test('page data preserves external acquisition and removes identity, search, and internal handoff parameters', () => {
461571
const page = buildMauticPageview({
462572
canonicalUrl: 'https://docs.registrystack.org/tutorials/first-registry/?search=private#record',

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export function createSharedTrackingClient(options = {}) {
8080
let closed = false;
8181
let suspended = false;
8282
let localRefusal = false;
83+
let choiceIntent = 0;
8384
let expiryTimer;
8485
try {
8586
localRefusal = currentWindow?.localStorage?.getItem(LOCAL_REFUSAL_KEY) === 'rejected';
@@ -353,13 +354,19 @@ export function createSharedTrackingClient(options = {}) {
353354
subscribe(listener) { subscribers.add(listener); return () => subscribers.delete(listener); },
354355
subscribeFailure(listener) { failureSubscribers.add(listener); return () => failureSubscribers.delete(listener); },
355356
async read() {
356-
if (localRefusal) return client.choose('rejected');
357-
return request('read');
357+
if (!localRefusal) return request('read');
358+
const intent = choiceIntent;
359+
// Keep the local refusal active while learning the current shared
360+
// revision. Replaying revision zero would strand it after an outage.
361+
await request('read');
362+
if (!localRefusal || choiceIntent !== intent) return effectiveState();
363+
return client.choose('rejected');
358364
},
359365
async choose(choice) {
360366
if (!['accepted', 'rejected'].includes(choice) || (choice === 'accepted' && denied())) {
361367
throw trackingError();
362368
}
369+
const intent = ++choiceIntent;
363370
const expectedRevision = state.revision;
364371
if (choice === 'rejected') {
365372
localRefusal = true;
@@ -376,10 +383,14 @@ export function createSharedTrackingClient(options = {}) {
376383
if (choice === 'rejected') discardFrame();
377384
if (choice !== 'accepted' || !recoverableFailures.has(error)) throw error;
378385
await request('read');
379-
if (effectiveState().choice !== 'accepted') throw fail();
386+
// A retained refusal masks the shared state until this explicit
387+
// acceptance finishes reconciling. Privacy signals still take priority.
388+
if (state.choice !== 'accepted' || denied() ||
389+
(state.expiresAt !== null && state.expiresAt <= now())) throw fail();
380390
}
381391
// A refusal that could not reach the bridge remains local and visible as
382392
// an error, rather than allowing an older shared acceptance to return.
393+
if (choiceIntent !== intent) return effectiveState();
383394
localRefusal = false;
384395
try { currentWindow?.localStorage?.removeItem(LOCAL_REFUSAL_KEY); } catch { suspended = true; }
385396
notify();

0 commit comments

Comments
 (0)