Skip to content

[FEATURE] Ability to suspend until provider context readyΒ #1279

@MattIPv4

Description

@MattIPv4

Requirements

We want to be able to use the React hooks to suspend the app until we've loaded an async context and the provider has evaluated flags with that context (loaded from a hook and passed in via useContextMutator).

If we do not set a provider immediately, the default provider in the SDK (noop) does not trigger the suspend, and if we're in a non-default domain and a provider has been set on the default domain, that will also not suspend.

So, we have to set the provider immediately, but then it will try to evaluate the flags without the context as we don't have that yet, and so won't remain suspended, instead becoming ready with the fallback values for any flag hooks.

Our current workaround is to wrap a provider with logic that blocks the initialization until we call the context change, at which point we pass that context back to the paused initialization and allow it to complete before returning from the context change (without passing the context change to the underlying provider).

This allows for the provider to sit in the NOT_READY state until we pass in a context, at which point it flips into RECONCILING as we have an async context change method (waiting for the async initialization to complete), and then to READY.

// Wrap an OpenFeature provider to ensure it waits for the full context to be available before initializing.
// This allows for us to use the suspend functionality of OpenFeature to delay rendering...
//  ...until our flags have been evaluated with the full context, rather than using defaults.
function createContextualProvider<
  T extends new (...args: any[]) => Provider, // eslint-disable-line @typescript-eslint/no-explicit-any
>(ProviderClass: T): new (...args: ConstructorParameters<T>) => Provider {
  return class ContextualProvider extends ProviderClass implements Provider {
    private contextPromise: Promise<EvaluationContext> | undefined = undefined;
    private contextResolve: ((context: EvaluationContext) => void) | undefined = undefined;

    private initializePromise: Promise<void> | undefined = undefined;
    private initializeResolve: (() => void) | undefined = undefined;

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    constructor(...args: any[]) {
      super(...args);
    }

    async initialize(context?: EvaluationContext) {
      // If called a second time for some reason, wait until the initial initialization has completed
      if (this.contextPromise) {
        await this.contextPromise;
        return super.initialize?.(context);
      }

      // Otherwise, disregard any initial context passed in, and wait for the full context to be provided via `onContextChange`
      this.contextPromise = new Promise<EvaluationContext>((resolve) => {
        this.contextResolve = resolve;
      });
      this.initializePromise = new Promise<void>((resolve) => {
        this.initializeResolve = resolve;
      });
      const data = await this.contextPromise;

      // With the full context provided, initialize the provider using it, not the initial context passed in
      await super.initialize?.(data);
      this.initializeResolve?.();
    }

    onContextChange(oldContext: EvaluationContext, newContext: EvaluationContext) {
      // If we've already done the initial wait for a full context, pass through to base implementation
      if (!this.contextResolve) {
        return super.onContextChange?.(oldContext, newContext);
      }

      // Otherwise, allow the initialization to proceed with the full context we've now got, and do not call the base implementation
      // But, leave the promise around in case initialize is called again for some reason, so we know not to wait again
      this.contextResolve(newContext);
      this.contextResolve = undefined;

      // Wait until initialization has completed before considering the context change complete
      // Returning a promise here puts OpenFeature into RECONCILING and ensures it waits
      return Promise.resolve(this.initializePromise).then(() => {
        this.initializePromise = undefined;
        this.initializeResolve = undefined;
      });
    }

    async onClose() {
      this.contextPromise = undefined;
      this.contextResolve = undefined;
      this.initializePromise = undefined;
      this.initializeResolve = undefined;

      return super.onClose?.();
    }
  };
}

However, as you might be able to see, this is a bit janky and it would definitely be nice for a solution to this in some form to exist in the SDK itself.

Metadata

Metadata

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions