Skip to content

Commit 8d46bbd

Browse files
authored
fix: Fix a race condition when handling multiple flag updates. (#365)
The setState function is effectively asynchronous, multiple setState methods may be called in a single cycle before this.state is updated. So if there were multiple dispatches of changes within a single batch, then some of the updates could be missed. The way to handle this in react is to use the previous state provided using the method version of setState. The operations will be done sequentially with each having the correct view of the previous state.
1 parent 63dd495 commit 8d46bbd

File tree

3 files changed

+82
-17
lines changed

3 files changed

+82
-17
lines changed

src/provider.test.tsx

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -456,10 +456,18 @@ describe('LDProvider', () => {
456456
const mockSetState = jest.spyOn(instance, 'setState');
457457

458458
await instance.componentDidMount();
459-
const setStateFunction = mockSetState.mock?.lastCall?.[0] as (p: ProviderState) => ProviderState;
459+
460+
// Each set of the state depends on the previous state, so to re-create the final state, we need to call the
461+
// setState function for each call.
462+
let finalState = previousState;
463+
464+
for(const call of mockSetState.mock.calls) {
465+
const setStateFunction = call[0] as (p: ProviderState) => ProviderState;
466+
finalState = setStateFunction(finalState);
467+
}
460468

461469
expect(mockLDClient.on).toHaveBeenCalledWith('change', expect.any(Function));
462-
expect(setStateFunction(previousState)).toEqual({
470+
expect(finalState).toMatchObject({
463471
flags: { anotherTestFlag: true, testFlag: false },
464472
unproxiedFlags: { 'another-test-flag': true, 'test-flag': false },
465473
flagKeyMap: { anotherTestFlag: 'another-test-flag', testFlag: 'test-flag' },
@@ -480,16 +488,56 @@ describe('LDProvider', () => {
480488
const mockSetState = jest.spyOn(instance, 'setState');
481489

482490
await instance.componentDidMount();
483-
const setStateFunction = mockSetState.mock?.lastCall?.[0] as (p: ProviderState) => ProviderState;
491+
492+
// Each set of the state depends on the previous state, so to re-create the final state, we need to call the
493+
// setState function for each call.
494+
let finalState = previousState;
495+
496+
for (const call of mockSetState.mock.calls) {
497+
const setStateFunction = call[0] as (p: ProviderState) => ProviderState;
498+
finalState = setStateFunction(finalState);
499+
}
484500

485501
expect(mockLDClient.on).toHaveBeenCalledWith('change', expect.any(Function));
486-
expect(setStateFunction(previousState)).toEqual({
502+
expect(finalState).toMatchObject({
487503
flagKeyMap: {},
488504
unproxiedFlags: { 'another-test-flag': false, 'test-flag': false },
489505
flags: { 'another-test-flag': false, 'test-flag': false },
490506
});
491507
});
492508

509+
test('handles deletion of flags', async () => {
510+
mockLDClient.on.mockImplementation((_e: string, cb: (c: LDFlagChangeset) => void) => {
511+
cb({ 'another-test-flag': { current: undefined, previous: true }, 'test-flag': { current: false, previous: true } });
512+
});
513+
const props: ProviderConfig = { clientSideID, reactOptions: { useCamelCaseFlagKeys: false } };
514+
const LaunchDarklyApp = (
515+
<LDProvider {...props}>
516+
<App />
517+
</LDProvider>
518+
);
519+
const instance = create(LaunchDarklyApp).root.findByType(LDProvider).instance as EnhancedComponent;
520+
const mockSetState = jest.spyOn(instance, 'setState');
521+
522+
await instance.componentDidMount();
523+
524+
// Each set of the state depends on the previous state, so to re-create the final state, we need to call the
525+
// setState function for each call.
526+
let finalState = previousState;
527+
528+
for (const call of mockSetState.mock.calls) {
529+
const setStateFunction = call[0] as (p: ProviderState) => ProviderState;
530+
finalState = setStateFunction(finalState);
531+
}
532+
533+
expect(mockLDClient.on).toHaveBeenCalledWith('change', expect.any(Function));
534+
expect(finalState).toMatchObject({
535+
flagKeyMap: {},
536+
unproxiedFlags: { 'test-flag': false },
537+
flags: { 'test-flag': false },
538+
});
539+
});
540+
493541
test(`if props.deferInitialization is true, ld client will only initialize once props.user is defined`, async () => {
494542
options = { ...options, bootstrap: {} };
495543
const props: ProviderConfig = { clientSideID, deferInitialization: true, options };

src/provider.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,16 +57,19 @@ class LDProvider extends Component<PropsWithChildren<ProviderConfig>, ProviderSt
5757
ldClient.on('change', (changes: LDFlagChangeset) => {
5858
const reactOptions = this.getReactOptions();
5959
const updates = getFlattenedFlagsFromChangeset(changes, targetFlags);
60-
const unproxiedFlags = {
61-
...this.state.unproxiedFlags,
62-
...updates,
63-
};
60+
6461
if (Object.keys(updates).length > 0) {
65-
this.setState((prevState) => ({
66-
...prevState,
67-
unproxiedFlags,
68-
...getFlagsProxy(ldClient, unproxiedFlags, reactOptions, targetFlags),
69-
}));
62+
this.setState((prevState: ProviderState) => {
63+
const unproxiedFlags = {
64+
...prevState.unproxiedFlags,
65+
...updates,
66+
};
67+
return {
68+
...prevState,
69+
unproxiedFlags,
70+
...getFlagsProxy(ldClient, unproxiedFlags, reactOptions, targetFlags),
71+
}
72+
});
7073
}
7174
});
7275
};

src/withLDProvider.test.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,17 @@ describe('withLDProvider', () => {
130130
const mockSetState = jest.spyOn(instance, 'setState');
131131

132132
await instance.componentDidMount();
133-
const setStateFunction = mockSetState.mock?.lastCall?.[0] as (p: ProviderState) => ProviderState;
133+
// Each set of the state depends on the previous state, so to re-create the final state, we need to call the
134+
// setState function for each call.
135+
let finalState = previousState;
136+
137+
for (const call of mockSetState.mock.calls) {
138+
const setStateFunction = call[0] as (p: ProviderState) => ProviderState;
139+
finalState = setStateFunction(finalState);
140+
}
134141

135142
expect(mockLDClient.on).toHaveBeenCalledWith('change', expect.any(Function));
136-
expect(setStateFunction(previousState)).toEqual({
143+
expect(finalState).toMatchObject({
137144
flags: { anotherTestFlag: true, testFlag: false },
138145
unproxiedFlags: { 'test-flag': false, 'another-test-flag': true },
139146
flagKeyMap: { testFlag: 'test-flag', anotherTestFlag: 'another-test-flag' },
@@ -149,10 +156,17 @@ describe('withLDProvider', () => {
149156
const mockSetState = jest.spyOn(instance, 'setState');
150157

151158
await instance.componentDidMount();
152-
const setStateFunction = mockSetState.mock?.lastCall?.[0] as (p: ProviderState) => ProviderState;
159+
// Each set of the state depends on the previous state, so to re-create the final state, we need to call the
160+
// setState function for each call.
161+
let finalState = previousState;
162+
163+
for (const call of mockSetState.mock.calls) {
164+
const setStateFunction = call[0] as (p: ProviderState) => ProviderState;
165+
finalState = setStateFunction(finalState);
166+
}
153167

154168
expect(mockLDClient.on).toHaveBeenCalledWith('change', expect.any(Function));
155-
expect(setStateFunction(previousState)).toEqual({
169+
expect(finalState).toMatchObject({
156170
flags: { 'test-flag': false, 'another-test-flag': false },
157171
unproxiedFlags: { 'test-flag': false, 'another-test-flag': false },
158172
flagKeyMap: {},

0 commit comments

Comments
 (0)