Skip to content

Commit e171a6e

Browse files
committed
🤖 fix: refresh remote experiments after cold start
ExperimentsService can return { source: 'cache', value: null } on first launch while PostHog refresh runs in the background. The renderer previously fetched experiments.getAll only once, so remote variants never became visible until manual reload. Fix: - Poll experiments.getAll with bounded backoff while any values are pending - Add a regression test for ExperimentsProvider Change-Id: If9533ee2ad0430729600275aedcf9b1939ec612d Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent 8b38cba commit e171a6e

File tree

2 files changed

+156
-0
lines changed

2 files changed

+156
-0
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { cleanup, render, waitFor } from "@testing-library/react";
2+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
3+
import { GlobalWindow } from "happy-dom";
4+
import { ExperimentsProvider, useExperimentValue } from "./ExperimentsContext";
5+
import { EXPERIMENT_IDS } from "@/common/constants/experiments";
6+
import type { ExperimentValue } from "@/common/orpc/types";
7+
import type { APIClient } from "@/browser/contexts/API";
8+
import type { RecursivePartial } from "@/browser/testUtils";
9+
10+
let currentClientMock: RecursivePartial<APIClient> = {};
11+
void mock.module("@/browser/contexts/API", () => ({
12+
useAPI: () => ({
13+
api: currentClientMock as APIClient,
14+
status: "connected" as const,
15+
error: null,
16+
}),
17+
APIProvider: ({ children }: { children: React.ReactNode }) => children,
18+
}));
19+
20+
describe("ExperimentsProvider", () => {
21+
beforeEach(() => {
22+
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
23+
globalThis.document = globalThis.window.document;
24+
globalThis.window.localStorage.clear();
25+
});
26+
27+
afterEach(() => {
28+
cleanup();
29+
globalThis.window = undefined as unknown as Window & typeof globalThis;
30+
globalThis.document = undefined as unknown as Document;
31+
currentClientMock = {};
32+
});
33+
34+
test("polls getAll until remote variants are available", async () => {
35+
let callCount = 0;
36+
37+
const getAllMock = mock(() => {
38+
callCount += 1;
39+
40+
if (callCount === 1) {
41+
return Promise.resolve({
42+
[EXPERIMENT_IDS.POST_COMPACTION_CONTEXT]: { value: null, source: "cache" },
43+
} satisfies Record<string, ExperimentValue>);
44+
}
45+
46+
return Promise.resolve({
47+
[EXPERIMENT_IDS.POST_COMPACTION_CONTEXT]: { value: "test", source: "posthog" },
48+
} satisfies Record<string, ExperimentValue>);
49+
});
50+
51+
currentClientMock = {
52+
experiments: {
53+
getAll: getAllMock,
54+
reload: mock(() => Promise.resolve()),
55+
},
56+
};
57+
58+
function Observer() {
59+
const enabled = useExperimentValue(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT);
60+
return <div data-testid="enabled">{String(enabled)}</div>;
61+
}
62+
63+
const { getByTestId } = render(
64+
<ExperimentsProvider>
65+
<Observer />
66+
</ExperimentsProvider>
67+
);
68+
69+
expect(getByTestId("enabled").textContent).toBe("false");
70+
71+
await waitFor(() => {
72+
expect(getByTestId("enabled").textContent).toBe("true");
73+
});
74+
75+
expect(getAllMock.mock.calls.length).toBeGreaterThanOrEqual(2);
76+
});
77+
});

src/browser/contexts/ExperimentsContext.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import React, {
44
useSyncExternalStore,
55
useCallback,
66
useEffect,
7+
useRef,
78
useState,
89
} from "react";
910
import {
@@ -86,6 +87,28 @@ function getRemoteExperimentEnabled(value: string | boolean): boolean {
8687
return value === "test";
8788
}
8889

90+
/**
91+
* True when any remote experiment value is still pending a background PostHog refresh.
92+
*/
93+
function hasPendingRemoteExperimentValues(
94+
remoteExperiments: Partial<Record<ExperimentId, ExperimentValue>>
95+
): boolean {
96+
return Object.values(remoteExperiments).some(
97+
(remote) => remote?.source === "cache" && remote.value === null
98+
);
99+
}
100+
101+
const REMOTE_EXPERIMENTS_POLL_INITIAL_DELAY_MS = 100;
102+
const REMOTE_EXPERIMENTS_POLL_MAX_DELAY_MS = 5_000;
103+
const REMOTE_EXPERIMENTS_POLL_MAX_ATTEMPTS = 8;
104+
105+
function getRemoteExperimentsPollDelayMs(attempt: number): number {
106+
return Math.min(
107+
REMOTE_EXPERIMENTS_POLL_INITIAL_DELAY_MS * 2 ** attempt,
108+
REMOTE_EXPERIMENTS_POLL_MAX_DELAY_MS
109+
);
110+
}
111+
89112
/**
90113
* Set experiment state to localStorage and dispatch sync event.
91114
*/
@@ -160,6 +183,62 @@ export function ExperimentsProvider(props: { children: React.ReactNode }) {
160183
await loadRemoteExperiments();
161184
}, [apiState.status, apiState.api, loadRemoteExperiments]);
162185

186+
// On cold start, experiments.getAll can return { source: "cache", value: null } while
187+
// ExperimentsService refreshes from PostHog in the background. Poll a few times so the
188+
// renderer picks up remote variants without requiring a manual reload.
189+
const remotePollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
190+
const remotePollAttemptRef = useRef(0);
191+
192+
const clearRemotePoll = useCallback(() => {
193+
if (remotePollTimeoutRef.current === null) {
194+
return;
195+
}
196+
197+
clearTimeout(remotePollTimeoutRef.current);
198+
remotePollTimeoutRef.current = null;
199+
}, []);
200+
201+
useEffect(() => {
202+
return () => {
203+
clearRemotePoll();
204+
};
205+
}, [clearRemotePoll]);
206+
207+
useEffect(() => {
208+
if (apiState.status !== "connected" || !apiState.api) {
209+
remotePollAttemptRef.current = 0;
210+
clearRemotePoll();
211+
return;
212+
}
213+
214+
if (!remoteExperiments) {
215+
remotePollAttemptRef.current = 0;
216+
clearRemotePoll();
217+
return;
218+
}
219+
220+
if (!hasPendingRemoteExperimentValues(remoteExperiments)) {
221+
remotePollAttemptRef.current = 0;
222+
clearRemotePoll();
223+
return;
224+
}
225+
226+
if (remotePollTimeoutRef.current !== null) {
227+
return;
228+
}
229+
230+
const attempt = remotePollAttemptRef.current;
231+
if (attempt >= REMOTE_EXPERIMENTS_POLL_MAX_ATTEMPTS) {
232+
return;
233+
}
234+
235+
const delayMs = getRemoteExperimentsPollDelayMs(attempt);
236+
remotePollTimeoutRef.current = setTimeout(() => {
237+
remotePollTimeoutRef.current = null;
238+
remotePollAttemptRef.current += 1;
239+
void loadRemoteExperiments();
240+
}, delayMs);
241+
}, [apiState.status, apiState.api, remoteExperiments, clearRemotePoll, loadRemoteExperiments]);
163242
useEffect(() => {
164243
void loadRemoteExperiments();
165244
}, [loadRemoteExperiments]);

0 commit comments

Comments
 (0)