Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/js/welcome.js

Large diffs are not rendered by default.

31 changes: 24 additions & 7 deletions src/bootstrap/variant-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,19 +92,36 @@ function recordCachedExposure(ph: typeof posthog): void {
* next load (spec §3.1.4). Read with `send_event: false` so it never fires
* `$feature_flag_called` — the ops kill-switch is bucket-agnostic, so reading it
* after identify() is safe (unlike the experiment flag's exposure, which must
* stay pre-identify). Same synchronous-fire guard as resolveFlag; unsubscribes
* after the first reload.
* stay pre-identify). Unlike resolveFlag, this deliberately does NOT use the
* synchronous-fire / unsubscribe-after-first guard: that would re-read the stale
* inline value and miss the fresh network reload. It stays subscribed and clears
* on any observed ON value, bounded by a timeout so the listener never lingers
* (see the inline note on the subscription below).
*/
function watchKillSwitch(ph: typeof posthog): void {
let unsub: (() => void) | undefined;
let fired = false;
const stop = () => {
if (unsub) {
unsub();
unsub = undefined;
}
};
// Deliberately NO synchronous-fire / unsubscribe-after-first guard here. On a
// cached repeat visit posthog-js fires onFeatureFlags inline with the STALE
// persisted flags; unsubscribing then (the resolveFlag pattern) would only
// re-read the value recordCachedExposure already saw and miss the fresh
// network reload — so a kill-switch flipped since the last visit would never
// clear the cache. Instead, stay subscribed and clear on any observed ON
// value (the fresh reload, and the post-identify reload), then stop. Bounded
// by a timeout so the listener never lingers.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
unsub = ph.onFeatureFlags(() => {
fired = true;
const killValue = ph.getFeatureFlag(KILL_SWITCH_KEY, { send_event: false });
if (killValue === true || killValue === 'on') clearCache();
unsub?.();
if (killValue === true || killValue === 'on') {
clearCache(); // kill takes effect next load (spec §3.1.4)
stop();
}
});
if (fired) unsub();
setTimeout(stop, 8000);
}

/** Waits for flags or times out. Exposure accounting: getFeatureFlag is always
Expand Down
5 changes: 5 additions & 0 deletions tests/exposure-ordering.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ test('fresh kill-switch is watched without re-reading the experiment flag (no po
assert.match(watch, /KILL_SWITCH_KEY,\s*\{\s*send_event:\s*false\s*\}/, 'kill-switch watch is non-exposure');
assert.doesNotMatch(watch, /getFeatureFlag\(FLAG_KEY\)/, 'kill-switch watch must not read the experiment flag');
assert.match(watch, /clearCache\(\)/);
// Must NOT unsubscribe after the first (inline/stale) callback — that would
// miss the fresh network reload. It stays subscribed (bounded by a timeout)
// and clears only on an observed ON value.
assert.doesNotMatch(watch, /if \(fired\)\s*unsub/, 'must not unsubscribe after the first stale callback');
assert.match(watch, /setTimeout\([^,]+,\s*8000\)/, 'subscription is bounded by the documented 8s timeout, not closed on first fire');
const cacheBranch = slice(loader, "fast.renderSource === 'cache'", 'return {');
assert.match(cacheBranch, /watchKillSwitch\(ph\)/, 'cache path arms the fresh kill-switch watch');
});
Expand Down
Loading