Skip to content

Commit 6c25f29

Browse files
authored
fix: skip reconciling event for synchronous onContextChange operations (#931)
## This PR - skips emitting a reconciling event for synchronous onContextChange operations ### Notes This will avoid unexpected component rerenders for synchronous onContextChange operations. The spec states that the SDK may avoid emitting the `PROVIDER_RECONCILING` if a provider can reconcile synchronously. https://openfeature.dev/specification/sections/events#event-handlers-and-context-reconciliation --------- Signed-off-by: Michael Beemer <[email protected]>
1 parent 488ec8a commit 6c25f29

File tree

3 files changed

+61
-26
lines changed

3 files changed

+61
-26
lines changed

packages/client/src/open-feature.ts

+14-8
Original file line numberDiff line numberDiff line change
@@ -231,14 +231,20 @@ export class OpenFeatureAPI
231231

232232
try {
233233
if (typeof wrapper.provider.onContextChange === 'function') {
234-
wrapper.incrementPendingContextChanges();
235-
wrapper.status = this._statusEnumType.RECONCILING;
236-
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
237-
emitter?.emit(ProviderEvents.Reconciling, { domain, providerName });
238-
});
239-
this._apiEmitter?.emit(ProviderEvents.Reconciling, { domain, providerName });
240-
await wrapper.provider.onContextChange(oldContext, newContext);
241-
wrapper.decrementPendingContextChanges();
234+
const maybePromise = wrapper.provider.onContextChange(oldContext, newContext);
235+
236+
// only reconcile if the onContextChange method returns a promise
237+
if (typeof maybePromise?.then === 'function') {
238+
wrapper.incrementPendingContextChanges();
239+
wrapper.status = this._statusEnumType.RECONCILING;
240+
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
241+
emitter?.emit(ProviderEvents.Reconciling, { domain, providerName });
242+
});
243+
this._apiEmitter?.emit(ProviderEvents.Reconciling, { domain, providerName });
244+
245+
await maybePromise;
246+
wrapper.decrementPendingContextChanges();
247+
}
242248
}
243249
// only run the event handlers, and update the state if the onContextChange method succeeded
244250
wrapper.status = this._statusEnumType.READY;

packages/client/src/provider/provider.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,18 @@ export interface Provider extends CommonProvider<ClientProviderStatus> {
1919
readonly hooks?: Hook[];
2020

2121
/**
22-
* A handler function to reconcile changes when the static context.
22+
* A handler function to reconcile changes made to the static context.
2323
* Called by the SDK when the context is changed.
24+
*
25+
* Returning a promise will put the provider in the RECONCILING state and
26+
* emit the ProviderEvents.Reconciling event.
27+
*
28+
* Return void will avoid putting the provider in the RECONCILING state and
29+
* **not** emit the ProviderEvents.Reconciling event.
2430
* @param oldContext
2531
* @param newContext
2632
*/
27-
onContextChange?(oldContext: EvaluationContext, newContext: EvaluationContext): Promise<void>;
33+
onContextChange?(oldContext: EvaluationContext, newContext: EvaluationContext): Promise<void> | void;
2834

2935
/**
3036
* Resolve a boolean flag and its evaluation details.

packages/client/test/events.spec.ts

+39-16
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@ class MockProvider implements Provider {
2222
readonly runsOn = 'client';
2323
private hasInitialize: boolean;
2424
private hasContextChanged: boolean;
25+
private asyncContextChangedHandler: boolean;
2526
private failOnInit: boolean;
2627
private failOnContextChange: boolean;
2728
private asyncDelay?: number;
2829
private enableEvents: boolean;
29-
onContextChange?: () => Promise<void>;
30+
onContextChange?: () => Promise<void> | void;
3031
initialize?: () => Promise<void>;
3132

3233
constructor(options?: {
@@ -35,6 +36,7 @@ class MockProvider implements Provider {
3536
enableEvents?: boolean;
3637
failOnInit?: boolean;
3738
hasContextChanged?: boolean;
39+
asyncContextChangedHandler?: boolean;
3840
failOnContextChange?: boolean;
3941
name?: string;
4042
}) {
@@ -45,6 +47,7 @@ class MockProvider implements Provider {
4547
this.enableEvents = options?.enableEvents ?? true;
4648
this.failOnInit = options?.failOnInit ?? false;
4749
this.failOnContextChange = options?.failOnContextChange ?? false;
50+
this.asyncContextChangedHandler = options?.asyncContextChangedHandler ?? true;
4851
if (this.hasContextChanged) {
4952
this.onContextChange = this.changeHandler;
5053
}
@@ -80,15 +83,19 @@ class MockProvider implements Provider {
8083
}
8184

8285
private changeHandler() {
83-
return new Promise<void>((resolve, reject) =>
84-
setTimeout(() => {
85-
if (this.failOnContextChange) {
86-
reject(new Error(ERR_MESSAGE));
87-
} else {
88-
resolve();
89-
}
90-
}, this.asyncDelay),
91-
);
86+
if (this.asyncContextChangedHandler) {
87+
return new Promise<void>((resolve, reject) =>
88+
setTimeout(() => {
89+
if (this.failOnContextChange) {
90+
reject(new Error(ERR_MESSAGE));
91+
} else {
92+
resolve();
93+
}
94+
}, this.asyncDelay),
95+
);
96+
} else if (this.failOnContextChange) {
97+
throw new Error(ERR_MESSAGE);
98+
}
9299
}
93100
}
94101

@@ -598,6 +605,25 @@ describe('Events', () => {
598605

599606
expect(handler).toHaveBeenCalledTimes(2);
600607
});
608+
609+
it('Reconciling events are not emitted for synchronous onContextChange operations', async () => {
610+
const provider = new MockProvider({
611+
hasInitialize: false,
612+
hasContextChanged: true,
613+
asyncContextChangedHandler: false,
614+
});
615+
616+
const reconcileHandler = jest.fn(() => {});
617+
const changedEventHandler = jest.fn(() => {});
618+
619+
await OpenFeature.setProviderAndWait(domain, provider);
620+
OpenFeature.addHandler(ProviderEvents.Reconciling, reconcileHandler);
621+
OpenFeature.addHandler(ProviderEvents.ContextChanged, changedEventHandler);
622+
await OpenFeature.setContext(domain, {});
623+
624+
expect(reconcileHandler).not.toHaveBeenCalled();
625+
expect(changedEventHandler).toHaveBeenCalledTimes(1);
626+
});
601627
});
602628

603629
describe('provider has no context changed handler', () => {
@@ -615,7 +641,7 @@ describe('Events', () => {
615641
});
616642
});
617643
});
618-
644+
619645
describe('client', () => {
620646
describe('provider has context changed handler', () => {
621647
it('Stale and ContextChanged are emitted', async () => {
@@ -810,12 +836,9 @@ describe('Events', () => {
810836
};
811837

812838
client.addHandler(ProviderEvents.ContextChanged, handler);
813-
839+
814840
// update context change twice
815-
await Promise.all([
816-
OpenFeature.setContext(domain, {}),
817-
OpenFeature.setContext(domain, {}),
818-
]);
841+
await Promise.all([OpenFeature.setContext(domain, {}), OpenFeature.setContext(domain, {})]);
819842

820843
// should only have run once
821844
expect(runs).toEqual(1);

0 commit comments

Comments
 (0)