Skip to content

Commit f41b9e6

Browse files
authored
fix(browser-sdk) set isLoading on refetch due to context update (#547)
1 parent 88cec52 commit f41b9e6

6 files changed

Lines changed: 407 additions & 36 deletions

File tree

packages/browser-sdk/src/client.ts

Lines changed: 57 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -676,24 +676,20 @@ export class ReflagClient {
676676
* @param user
677677
*/
678678
async updateUser(user: { [key: string]: string | number | undefined }) {
679-
const userIdChanged = user.id && user.id !== this.context.user?.id;
680679
const newUserContext = {
681680
...this.context.user,
682681
...user,
683682
id: user.id ?? this.context.user?.id,
684683
};
685684

686-
// Nothing has changed, skipping update
687-
if (deepEqual(this.context.user, newUserContext)) return;
688-
this.context.user = newUserContext;
689-
void this.user();
690-
691-
// Update the feedback user if the user ID has changed
692-
if (userIdChanged) {
693-
void this.updateAutoFeedbackUser(String(user.id));
694-
}
695-
696-
await this.flagsClient.setContext(this.context);
685+
return this.applyContext(
686+
{
687+
user: newUserContext,
688+
company: this.context.company,
689+
other: this.context.other,
690+
},
691+
{ warnOnMissingIds: false },
692+
);
697693
}
698694

699695
/**
@@ -710,12 +706,14 @@ export class ReflagClient {
710706
id: company.id ?? this.context.company?.id,
711707
};
712708

713-
// Nothing has changed, skipping update
714-
if (deepEqual(this.context.company, newCompanyContext)) return;
715-
this.context.company = newCompanyContext;
716-
void this.company();
717-
718-
await this.flagsClient.setContext(this.context);
709+
return this.applyContext(
710+
{
711+
user: this.context.user,
712+
company: newCompanyContext,
713+
other: this.context.other,
714+
},
715+
{ warnOnMissingIds: false },
716+
);
719717
}
720718

721719
/**
@@ -733,11 +731,14 @@ export class ReflagClient {
733731
...otherContext,
734732
};
735733

736-
// Nothing has changed, skipping update
737-
if (deepEqual(this.context.other, newOtherContext)) return;
738-
this.context.other = newOtherContext;
739-
740-
await this.flagsClient.setContext(this.context);
734+
return this.applyContext(
735+
{
736+
user: this.context.user,
737+
company: this.context.company,
738+
other: newOtherContext,
739+
},
740+
{ warnOnMissingIds: false },
741+
);
741742
}
742743

743744
/**
@@ -746,9 +747,15 @@ export class ReflagClient {
746747
*
747748
* @param context The context to update.
748749
*/
749-
async setContext({ otherContext, ...context }: ReflagDeprecatedContext) {
750-
const userIdChanged =
751-
context.user?.id && context.user.id !== this.context.user?.id;
750+
async setContext(context: ReflagDeprecatedContext) {
751+
return this.applyContext(context, { warnOnMissingIds: true });
752+
}
753+
754+
private async applyContext(
755+
{ otherContext, ...context }: ReflagDeprecatedContext,
756+
{ warnOnMissingIds }: { warnOnMissingIds: boolean },
757+
) {
758+
const previousContext = this.context;
752759

753760
// Create a new context object making sure to clone the user and company objects
754761
const newContext = {
@@ -757,32 +764,49 @@ export class ReflagClient {
757764
other: { ...otherContext, ...context.other },
758765
};
759766

760-
if (!context.user?.id) {
767+
if (warnOnMissingIds && !context.user?.id) {
761768
this.logger.warn("No user Id provided in context, user will be ignored");
762769
}
763-
if (!context.company?.id) {
770+
if (warnOnMissingIds && !context.company?.id) {
764771
this.logger.warn(
765772
"No company Id provided in context, company will be ignored",
766773
);
767774
}
768775

769776
// Nothing has changed, skipping update
770-
if (deepEqual(this.context, newContext)) return;
777+
if (deepEqual(previousContext, newContext)) return;
778+
779+
const userChanged = !deepEqual(previousContext.user, newContext.user);
780+
const companyChanged = !deepEqual(
781+
previousContext.company,
782+
newContext.company,
783+
);
784+
const userIdChanged =
785+
!!newContext.user?.id && newContext.user.id !== previousContext.user?.id;
786+
771787
this.context = newContext;
772788

773-
if (context.company) {
789+
if (companyChanged) {
774790
void this.company();
775791
}
776792

777-
if (context.user) {
793+
if (userChanged) {
778794
void this.user();
779795
// Update the automatic feedback user if the user ID has changed
780796
if (userIdChanged) {
781-
void this.updateAutoFeedbackUser(String(context.user.id));
797+
void this.updateAutoFeedbackUser(String(newContext.user!.id));
782798
}
783799
}
784800

785-
await this.flagsClient.setContext(this.context);
801+
const shouldTrackLoading = this.state === "initialized";
802+
if (shouldTrackLoading) {
803+
this.setState("initializing");
804+
}
805+
806+
const didApply = await this.flagsClient.setContext(this.context);
807+
if (didApply && this.state === "initializing") {
808+
this.setState("initialized");
809+
}
786810
}
787811

788812
/**

packages/browser-sdk/src/flag/flags.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ export class FlagsClient {
209209
private flagOverrides: FlagOverrides = {};
210210
private flags: RawFlags = {};
211211
private fallbackFlags: FallbackFlags = {};
212+
private contextFetchVersion = 0;
212213
private storage: StorageAdapter;
213214
private refreshEvents: number[] = [];
214215
private enqueueBulkEvent?: (event: BulkEvent) => Promise<void>;
@@ -297,7 +298,15 @@ export class FlagsClient {
297298

298299
async setContext(context: ReflagContext) {
299300
this.context = context;
300-
this.setFetchedFlags((await this.maybeFetchFlags()) || {});
301+
const requestVersion = ++this.contextFetchVersion;
302+
const fetchedFlags = (await this.maybeFetchFlags()) || {};
303+
304+
if (requestVersion !== this.contextFetchVersion) {
305+
return false;
306+
}
307+
308+
this.setFetchedFlags(fetchedFlags);
309+
return true;
301310
}
302311

303312
updateFlags(triggerEvent = true) {

packages/browser-sdk/test/client.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,20 @@ describe("ReflagClient", () => {
5757
);
5858
expect(flagClientSetContext).toHaveBeenCalledWith(client["context"]);
5959
});
60+
61+
it("does not warn about a missing company when updating a user-only context", async () => {
62+
client = new ReflagClient({
63+
publishableKey: "test-key-user-only",
64+
user: { id: "user1" },
65+
});
66+
const warnSpy = vi.spyOn(client.logger, "warn");
67+
68+
await client.updateUser({ name: "Updated User" });
69+
70+
expect(warnSpy).not.toHaveBeenCalledWith(
71+
"No company Id provided in context, company will be ignored",
72+
);
73+
});
6074
});
6175

6276
describe("updateCompany", () => {
@@ -194,6 +208,101 @@ describe("ReflagClient", () => {
194208
expect(checkHook).not.toHaveBeenCalled();
195209
expect(flagsUpdated).not.toHaveBeenCalled();
196210
});
211+
212+
it("sets state to initializing while refetching flags after initialization", async () => {
213+
await client.initialize();
214+
215+
let resolveFetch: (() => void) | undefined;
216+
const setContextPromise = new Promise<boolean>((resolve) => {
217+
resolveFetch = () => resolve(true);
218+
});
219+
const setContext = vi
220+
.spyOn(FlagsClient.prototype, "setContext")
221+
.mockImplementation(async () => {
222+
return setContextPromise;
223+
});
224+
225+
const stateUpdated = vi.fn();
226+
client.on("stateUpdated", stateUpdated);
227+
228+
const updatePromise = client.updateOtherContext({ workspaceId: "ws-1" });
229+
230+
expect(client.getState()).toBe("initializing");
231+
expect(stateUpdated).toHaveBeenCalledWith("initializing");
232+
expect(setContext).toHaveBeenCalledWith(client["context"]);
233+
234+
resolveFetch?.();
235+
await updatePromise;
236+
237+
expect(client.getState()).toBe("initialized");
238+
expect(stateUpdated).toHaveBeenLastCalledWith("initialized");
239+
});
240+
241+
it("keeps loading tied to the latest context update", async () => {
242+
await client.initialize();
243+
244+
let resolveFirstFetch: (() => void) | undefined;
245+
let resolveSecondFetch: (() => void) | undefined;
246+
const firstFetch = new Promise<boolean>((resolve) => {
247+
resolveFirstFetch = () => resolve(false);
248+
});
249+
const secondFetch = new Promise<boolean>((resolve) => {
250+
resolveSecondFetch = () => resolve(true);
251+
});
252+
253+
vi.spyOn(FlagsClient.prototype, "setContext")
254+
.mockImplementationOnce(async () => firstFetch)
255+
.mockImplementationOnce(async () => secondFetch);
256+
257+
const firstUpdate = client.updateOtherContext({ workspaceId: "ws-1" });
258+
const secondUpdate = client.updateOtherContext({ workspaceId: "ws-2" });
259+
260+
expect(client.getState()).toBe("initializing");
261+
262+
resolveSecondFetch?.();
263+
await secondUpdate;
264+
265+
expect(client.getState()).toBe("initialized");
266+
267+
resolveFirstFetch?.();
268+
await firstUpdate;
269+
270+
expect(client.getState()).toBe("initialized");
271+
});
272+
});
273+
274+
describe("setContext warnings", () => {
275+
it("does not warn about missing ids when updating anonymous other context", async () => {
276+
client = new ReflagClient({
277+
publishableKey: "test-key-anon",
278+
});
279+
const warnSpy = vi.spyOn(client.logger, "warn");
280+
281+
await client.updateOtherContext({ workspaceId: "ws-1" });
282+
283+
expect(warnSpy).not.toHaveBeenCalledWith(
284+
"No user Id provided in context, user will be ignored",
285+
);
286+
expect(warnSpy).not.toHaveBeenCalledWith(
287+
"No company Id provided in context, company will be ignored",
288+
);
289+
});
290+
291+
it("still warns when setContext replaces context without user or company ids", async () => {
292+
client = new ReflagClient({
293+
publishableKey: "test-key-set-context",
294+
});
295+
const warnSpy = vi.spyOn(client.logger, "warn");
296+
297+
await client.setContext({ other: { workspaceId: "ws-1" } });
298+
299+
expect(warnSpy).toHaveBeenCalledWith(
300+
"No user Id provided in context, user will be ignored",
301+
);
302+
expect(warnSpy).toHaveBeenCalledWith(
303+
"No company Id provided in context, company will be ignored",
304+
);
305+
});
197306
});
198307

199308
describe("stop", () => {

packages/browser-sdk/test/flags.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,75 @@ describe("FlagsClient", () => {
141141
expect(timeoutMs).toEqual(5000);
142142
});
143143

144+
test("only applies flags from the latest context update", async () => {
145+
const { newFlagsClient } = flagsClientFactory();
146+
const flagsClient = newFlagsClient();
147+
148+
let resolveFirstFetch:
149+
| ((flags: Record<string, RawFlag>) => void)
150+
| undefined;
151+
let resolveSecondFetch:
152+
| ((flags: Record<string, RawFlag>) => void)
153+
| undefined;
154+
const firstFetch = new Promise<Record<string, RawFlag>>((resolve) => {
155+
resolveFirstFetch = resolve;
156+
});
157+
const secondFetch = new Promise<Record<string, RawFlag>>((resolve) => {
158+
resolveSecondFetch = resolve;
159+
});
160+
161+
vi.spyOn(flagsClient as any, "maybeFetchFlags")
162+
.mockImplementationOnce(async () => firstFetch)
163+
.mockImplementationOnce(async () => secondFetch);
164+
165+
const firstUpdate = flagsClient.setContext({
166+
user: { id: "user-1" },
167+
company: { id: "company-1" },
168+
other: { workspaceId: "workspace-1" },
169+
});
170+
const secondUpdate = flagsClient.setContext({
171+
user: { id: "user-2" },
172+
company: { id: "company-2" },
173+
other: { workspaceId: "workspace-2" },
174+
});
175+
176+
resolveSecondFetch?.({
177+
latestFlag: {
178+
key: "latestFlag",
179+
isEnabled: true,
180+
targetingVersion: 2,
181+
},
182+
});
183+
184+
await expect(secondUpdate).resolves.toBe(true);
185+
expect(flagsClient.getFlags()).toEqual({
186+
latestFlag: {
187+
key: "latestFlag",
188+
isEnabled: true,
189+
targetingVersion: 2,
190+
isEnabledOverride: null,
191+
},
192+
});
193+
194+
resolveFirstFetch?.({
195+
staleFlag: {
196+
key: "staleFlag",
197+
isEnabled: false,
198+
targetingVersion: 1,
199+
},
200+
});
201+
202+
await expect(firstUpdate).resolves.toBe(false);
203+
expect(flagsClient.getFlags()).toEqual({
204+
latestFlag: {
205+
key: "latestFlag",
206+
isEnabled: true,
207+
targetingVersion: 2,
208+
isEnabledOverride: null,
209+
},
210+
});
211+
});
212+
144213
test("return fallback flags on failure (string list)", async () => {
145214
const { newFlagsClient, httpClient } = flagsClientFactory();
146215

0 commit comments

Comments
 (0)