js-sdk icon indicating copy to clipboard operation
js-sdk copied to clipboard

[FEATURE] Ability to suspend until provider context ready

Open MattIPv4 opened this issue 3 weeks ago • 2 comments

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.

MattIPv4 avatar Nov 05 '25 01:11 MattIPv4

Hey @MattIPv4, would allowing you to configure the noop provider to always be in a suspension state solve your problem? The reason the noop provider behaves the way it does is that it was confusing to users trying to learn OpenFeature for the first time to see an endless loading screen just because a provider hadn't been registered.

Perhaps we could add a configuration option that either enables suspense for the noop provider indefinitely or allow the user to specify a specific length of time. If users opt in to this setting, then I think the behavior is totally fine.

Another option may be to add a lazy option to the provider registration that prevents the initialization method from firing until after the first time setContext is called.

Do you think either of these options would work? FYI @lukas-reining @toddbaert

beeme1mr avatar Nov 05 '25 13:11 beeme1mr

👍 Allowing the noop to be treated as suspended would definitely be a solution, I think. The only thing that comes to mind is that if we switch providers once we're ready, would the memoized client in the React SDK with event listeners already bound to it carry over to the new provider?

Adding a lazy option into providers that allows them to delay initialization until they're provided a context later, essentially what our wrapper does currently (very jankily), also seems like quite a good solution, and would avoid that potential issue with swapping providers.

MattIPv4 avatar Nov 05 '25 16:11 MattIPv4

Adding a lazy option into providers that allows them to delay initialization until they're provided a context later, essentially what our wrapper does currently (very jankily), also seems like quite a good solution, and would avoid that potential issue with swapping providers.

I think this definitely is a valid solution! I am wondering where we should add this. This concept will be valuable for React, Angular and other static-context use-cases. Maybe there is something in this, that should be implemented and specified at static-context SDK level? I am convinced that this applies for all the above cases. But I am not 100% sure if it is a good idea to have as a general concept. What do you think @toddbaert @beeme1mr?

lukas-reining avatar Nov 08 '25 22:11 lukas-reining

I think it could make sense as an option during provider registration. It would only make sense for static context. We should probably add it to the spec but prototype it here first.

beeme1mr avatar Nov 09 '25 01:11 beeme1mr

I think it could make sense as an option during provider registration. It would only make sense for static context.

Yes, that's also what I think.

We should probably add it to the spec but prototype it here first.

Makes sense.

lukas-reining avatar Nov 13 '25 13:11 lukas-reining

Hey @MattIPv4, would you be interested in working on a prototype? No worries if you're busy; it just may be a while before we're able to work on it.

beeme1mr avatar Nov 17 '25 18:11 beeme1mr

I may have some free time coming into the holidays to poke around at doing this, but I definitely don't want to commit to it; if someone else wants to jump on it, feel free!

MattIPv4 avatar Nov 19 '25 02:11 MattIPv4

From Slack:

I've been thinking about this... some unorganized thoughts (please correct my assumptions if they are wrong):

I'm guessing your context is user attributes, and that your user isn't really available when your provider is set? One of the reasons many people won't run into this is because their app never exists in a state wherein the provider is set, but the context is not yet available - for example, at Dynatrace, all our many front-end apps (sort of as "micro frontends" which manage their own SDK instances) are only rendered after the user is logged in, and all the user's attributes are already loaded and available; meaning we never really run into this problem. I think many consumers are in that sort of situation.

However, rendering some stuff, but not other stuff, before a login is completed, for instance, is perfectly valid, and I think your feature is targeting that situation or similar.

All that said, here are some ideas on solutions:

A kind of "initial context validation" lambda that can be passed as an option - the SDK would run this function before it called init to determine if the context is valid for init or not... this doesn't solve your overloading issue, so we still need to choose an API (I favor an overload and additional params here, with a forced argument for context (undefined/null being acceptable). Pushing this back on to providers, but perhaps we could deliver a "meta" provider that would wrap any provider and add this functionality... we could package it and ship it from the SDK or contribs. We do something similar with the debounce hook (it's a wrapper for hooks) and the multi-provider.

Which solution we go for, to me, is really a question of how common this situation is.

toddbaert avatar Nov 25 '25 20:11 toddbaert

Adding a lazy option into providers that allows them to delay initialization until they're provided a context later, essentially what our wrapper does currently (very jankily), also seems like quite a good solution, and would avoid that potential issue with swapping providers.

A kind of "initial context validation" lambda that can be passed as an option

@toddbaert do you prefer the lambda because it functionally is a superset of the lazy flag?

this doesn't solve your overloading issue, so we still need to choose an API (I favor an overload and additional params here, with a forced argument for context (undefined/null being acceptable).

This is also what I would go for. I also think this is okay as the lazy initialization is not the most common case. And I think the API would be acceptable like this without a breaking change.

Pushing this back on to providers, but perhaps we could deliver a "meta" provider that would wrap any provider and add this functionality... we could package it and ship it from the SDK or contribs. We do something similar with the debounce hook (it's a wrapper for hooks) and the multi-provider.

For initialization this can be implemented by the client isn't it? The init method is only called by the client: https://github.com/open-feature/js-sdk/blob/main/packages/shared/src/open-feature.ts#L254 The same counts for the onContextChanged: https://github.com/open-feature/js-sdk/blob/main/packages/web/src/open-feature.ts#L392

So if we had this, the client could just evaluate the lambda based on it right?

Pushing this back on to providers, but perhaps we could deliver a "meta" provider that would wrap any provider and add this functionality... we could package it and ship it from the SDK or contribs. We do something similar with the debounce hook (it's a wrapper for hooks) and the multi-provider.

What would this do then?

lukas-reining avatar Nov 26 '25 16:11 lukas-reining

For initialization this can be implemented by the client isn't it? The init method is only called by the client: https://github.com/open-feature/js-sdk/blob/main/packages/shared/src/open-feature.ts#L254 The same counts for the onContextChanged: https://github.com/open-feature/js-sdk/blob/main/packages/web/src/open-feature.ts#L392

So if we had this, the client could just evaluate the lambda based on it right?

👀 This is indeed, I believe, what I've done in #1300

MattIPv4 avatar Nov 26 '25 16:11 MattIPv4