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
10 changes: 5 additions & 5 deletions assets/js/welcome.js

Large diffs are not rendered by default.

42 changes: 23 additions & 19 deletions src/bootstrap/variant-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,27 +59,29 @@ function clearCache(): void {
}

/**
* Fire-and-forget exposure accounting for cache-served (instant) renders.
* The custom assignment cache short-circuits the variant→chunk mapping so the
* page renders without waiting for PostHog's network flag load (~500ms), but
* exposure must still fire: once flags arrive, getFeatureFlag('landing-variant')
* records `$feature_flag_called`, keeping the SRM canary in lockstep with
* `landing_variant_rendered` (spec §3.1.4). If the kill-switch is on, the
* assignment cache is cleared so the kill takes effect on the next load (spec
* §3.1.4 — never switch a rendered variant mid-session). Same synchronous-fire
* guard as resolveFlag.
* Exposure accounting for a cache-served (instant) render. The read is
* **synchronous** — getFeatureFlag returns from PostHog's persisted flags, which
* exist after the first load that wrote the assignment cache (spec §3.1.4). It
* therefore runs inside prepareVariant, BEFORE the bootstrap calls
* identify(site_uuid): `$feature_flag_called` is recorded in the same anon_id
* bucket the cached assignment was made in, keeping the SRM/stickiness canary
* valid (flag-before-identify, spec §5.1).
*
* There is deliberately NO onFeatureFlags subscription here. An async read could
* fire AFTER identify() reloads flags under site_uuid (which hashes to a
* different bucket), recording the exposure for the wrong bucket. The kill-switch
* is read synchronously too; if on, the assignment cache is cleared so the kill
* takes effect on the next load (spec §3.1.4 — never switch a rendered variant
* mid-session).
*/
function watchExposure(ph: typeof posthog): void {
let unsub: (() => void) | undefined;
let fired = false;
unsub = ph.onFeatureFlags(() => {
fired = true;
ph.getFeatureFlag(FLAG_KEY); // fires $feature_flag_called
function recordCachedExposure(ph: typeof posthog): void {
try {
ph.getFeatureFlag(FLAG_KEY); // fires $feature_flag_called (anon_id bucket)
const killValue = ph.getFeatureFlag(KILL_SWITCH_KEY, { send_event: false });
if (killValue === true || killValue === 'on') clearCache();
Comment on lines 80 to 81

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve fresh kill-switch handling on cache hits

When the ops kill-switch is flipped after a visitor's last load, this cache path only reads the synchronously persisted value, so a stale false leaves wcpos_landing_assignment intact and the user gets at least one extra cached variant render after PostHog refreshes flags in the background. The previous cache-path subscription cleared the assignment when the fresh flag response arrived; keeping exposure synchronous is fine, but the kill-switch still needs a fresh, non-exposure (send_event: false) path so the kill takes effect on the next load as documented.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right — fixed in #30. Added watchKillSwitch(ph): a background onFeatureFlags watch that re-reads the kill-switch with send_event: false (non-exposure, so it's safe after identify — the ops flag is bucket-agnostic) and clears the assignment cache so a freshly-flipped kill takes effect next load. The experiment-flag exposure read stays synchronous + pre-identify, so #29's invariant is intact.

unsub?.();
});
if (fired) unsub();
} catch {
/* flags not yet persisted on a rare cache-without-flags load — skip exposure */
}
}

/** Waits for flags or times out. Exposure accounting: getFeatureFlag is always
Expand Down Expand Up @@ -139,7 +141,9 @@ export async function prepareVariant(ph: typeof posthog, anonId: string | undefi
return { variant: fast.variant, renderSource: 'fallback', assets: resolveAssets(fast.variant, null, VARIANT_ASSETS, ASSET_VERSION) };
}
if (fast.renderSource === 'cache') {
watchExposure(ph); // fire-and-forget: $feature_flag_called + kill-switch (next-load)
// Synchronous, before this function returns → before the bootstrap's
// identify(): keeps $feature_flag_called in the anon_id bucket (spec §5.1).
recordCachedExposure(ph);
return { variant: fast.variant, renderSource: 'cache', assets: resolveAssets(fast.variant, null, VARIANT_ASSETS, ASSET_VERSION) };
}

Expand Down
2 changes: 1 addition & 1 deletion src/shared/roadmap.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"generated_at": "2026-06-13",
"generated_at": "2026-06-15",
"done": [
"Refunds at the register",
"Receipt template gallery",
Expand Down
2 changes: 1 addition & 1 deletion src/shared/wporg-reviews.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"generated_at": "2026-06-13",
"generated_at": "2026-06-15",
"reviews": [
{
"author": "adeline",
Expand Down
49 changes: 49 additions & 0 deletions tests/exposure-ordering.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// tests/exposure-ordering.test.mjs
// Guards the flag-before-identify invariant on the instant-cache render path
// (spec §3.1.4 / §5.1). The cache path MUST read landing-variant synchronously
// (so $feature_flag_called lands in the anon_id bucket before the bootstrap's
// identify(site_uuid)) and MUST NOT use an async onFeatureFlags subscription
// there — an async read could fire post-identify and record the wrong bucket,
// breaking the SRM/stickiness canary.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';

const loader = readFileSync(new URL('../src/bootstrap/variant-loader.ts', import.meta.url), 'utf8');
const bootstrap = readFileSync(new URL('../src/bootstrap/index.tsx', import.meta.url), 'utf8');

/** Extract the body of a named function/arrow up to a heuristic close. */
function slice(source, from, to) {
const a = source.indexOf(from);
const b = source.indexOf(to, a + from.length);
return a >= 0 && b > a ? source.slice(a, b) : '';
}

test('cache path records exposure synchronously, not via onFeatureFlags', () => {
// The synchronous exposure helper exists and reads the flag.
assert.match(loader, /function recordCachedExposure/);
const helper = slice(loader, 'function recordCachedExposure', '\n}');
assert.match(helper, /getFeatureFlag\(FLAG_KEY\)/, 'must read landing-variant (fires $feature_flag_called)');
assert.doesNotMatch(helper, /onFeatureFlags/, 'exposure read must be synchronous — no subscription that could fire post-identify');
});

test('cache branch calls the synchronous helper before returning', () => {
const cacheBranch = slice(loader, "fast.renderSource === 'cache'", 'return {');
assert.match(cacheBranch, /recordCachedExposure\(ph\)/);
assert.doesNotMatch(cacheBranch, /onFeatureFlags|watchExposure/, 'cache path must not subscribe for exposure');
});

test('kill-switch read does not pollute exposure and clears cache for next load', () => {
const helper = slice(loader, 'function recordCachedExposure', '\n}');
assert.match(helper, /KILL_SWITCH_KEY,\s*\{\s*send_event:\s*false\s*\}/, 'kill-switch must not fire $feature_flag_called');
assert.match(helper, /clearCache\(\)/, 'kill-switch on a cached render clears the cache (takes effect next load)');
});

test('bootstrap reads the flag (prepareVariant) before identifyConsented', () => {
// Ordering: prepareVariant (which does the synchronous exposure read on the
// cache path, and awaits resolveFlag on the cold path) resolves before identify.
const prepareIdx = bootstrap.indexOf('await prepareVariant');
const identifyIdx = bootstrap.indexOf('identifyConsented()');
assert.ok(prepareIdx >= 0 && identifyIdx >= 0, 'both calls present');
assert.ok(prepareIdx < identifyIdx, 'prepareVariant (flag read) must precede identifyConsented()');
});
Loading