Skip to content
Open
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
24 changes: 12 additions & 12 deletions assets/js/welcome.js

Large diffs are not rendered by default.

15 changes: 14 additions & 1 deletion src/shared/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,20 @@ export function initAnalytics(): typeof posthog {
// loads should not hit the production flag endpoint at all.
advanced_disable_flags: preview,
...(anonId && !hasPersistedIdentity()
? { bootstrap: { distinctID: anonId, isIdentifiedID: false } }
? {
bootstrap: {
distinctID: anonId,
isIdentifiedID: false,
// Server-resolved experiment flags. The plugin (≥1.9.7) evaluates
// `landing-variant` for this anon_id and injects it into
// window.wcpos.landing.bootstrap_flags, so getFeatureFlag(FLAG_KEY)
// returns synchronously at first paint — no /flags round-trip, no
// 500 ms cold-path timeout, no flicker. Absent on older plugins
// (the cold path then resolves it). Skipped in preview, which seeds
// its own assignment cache and must not resolve production flags.
...(!preview && data?.bootstrap_flags ? { featureFlags: data.bootstrap_flags } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bootstrap flags outside the new-identity branch

When a browser already has ph_${POSTHOG_KEY}_posthog but no valid wcpos_landing_assignment (for example, anyone who loaded the previous broken /flags cold path, or after the assignment cache expires), this spread is never reached because it is nested under anonId && !hasPersistedIdentity(). prepareVariant() then waits on the still-broken flag endpoint, gets no bootstrapped landing-variant, and renders fallback again, so the server-resolved assignment does not take effect for returning users. Keep the distinctID bootstrapping gated, but pass bootstrap.featureFlags whenever bootstrap_flags is present except in preview.

Useful? React with 👍 / 👎.

},
}
: {}),
before_send: (event) => {
if (event?.properties) {
Expand Down
17 changes: 17 additions & 0 deletions src/shared/landing-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ export interface WCPOSLanding {
pro_active: boolean;
/** Random per-site UUID injected by plugin ≥1.10 for analytics identity (spec §5.1). Absent on older plugins. */
anon_id?: string;
/**
* Server-resolved feature flags injected by plugin ≥1.9.7, seeded into
* PostHog's `bootstrap.featureFlags` so `landing-variant` resolves at first
* paint without a network flag fetch (spec §5.1). Absent on older plugins.
*/
bootstrap_flags?: Record<string, string | boolean>;
profile?: ConsentProfile;
updates_server?: UpdatesServerConfig;
}
Expand Down Expand Up @@ -82,6 +88,14 @@ function isConsentProfile(value: unknown): value is ConsentProfile {
);
}

/** Type guard validating that a value is a flag map (string keys → string|boolean), matching PostHog's `bootstrap.featureFlags` contract. */
function isFlagMap(value: unknown): value is Record<string, string | boolean> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
return Object.values(value as Record<string, unknown>).every(
(v) => typeof v === 'string' || typeof v === 'boolean'
);
}

/** Type guard validating that a value conforms to the {@link UpdatesServerConfig} shape. */
function isUpdatesServerConfig(value: unknown): value is UpdatesServerConfig {
if (!value || typeof value !== 'object') return false;
Expand Down Expand Up @@ -124,6 +138,9 @@ export function getLandingData(): WCPOSLanding | undefined {
if (typeof raw.anon_id === 'string') {
validated.anon_id = raw.anon_id;
}
if (isFlagMap(raw.bootstrap_flags)) {
validated.bootstrap_flags = raw.bootstrap_flags;
}
if (isConsentProfile(raw.profile)) {
validated.profile = raw.profile;
}
Expand Down
10 changes: 10 additions & 0 deletions tests/analytics-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ test('Google Analytics is gone (spec §5.1: removed entirely)', () => {
assert.doesNotMatch(source, /react-ga4|ReactGA|G-08SJ28P1E5/);
});

test('bootstrap seeds featureFlags from server-resolved bootstrap_flags (spec §5.1)', () => {
// The plugin injects window.wcpos.landing.bootstrap_flags; init must seed
// them into PostHog's bootstrap so getFeatureFlag(FLAG_KEY) resolves at first
// paint instead of racing the (broken) /flags network fetch.
assert.match(source, /featureFlags\s*:\s*data\.bootstrap_flags/);
// ...but never in preview, which seeds its own assignment cache.
const initBody = source.slice(source.indexOf('export function initAnalytics'), source.indexOf('export function identifyConsented'));
assert.match(initBody, /!preview\s*&&\s*data\?\.bootstrap_flags/);
});

test('flag-before-identify: identify lives in identifyConsented, not init', () => {
assert.match(source, /export function identifyConsented/);
const initBody = source.slice(source.indexOf('export function initAnalytics'), source.indexOf('export function identifyConsented'));
Expand Down
22 changes: 22 additions & 0 deletions tests/landing-data.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';

const source = readFileSync(new URL('../src/shared/landing-data.ts', import.meta.url), 'utf8');

test('WCPOSLanding contract carries optional bootstrap_flags (plugin ≥1.9.7)', () => {
assert.match(source, /bootstrap_flags\?\s*:\s*Record<string,\s*string\s*\|\s*boolean>/);
});

test('getLandingData validates and includes bootstrap_flags', () => {
assert.match(source, /isFlagMap\(raw\.bootstrap_flags\)/);
assert.match(source, /validated\.bootstrap_flags\s*=\s*raw\.bootstrap_flags/);
});

test('flag-map guard rejects arrays and non-string|boolean values', () => {
assert.match(source, /function isFlagMap/);
// Arrays must be rejected (they are objects too).
assert.match(source, /Array\.isArray\(value\)/);
// Every value must be a string or boolean.
assert.match(source, /typeof v === 'string' \|\| typeof v === 'boolean'/);
});
Loading