Skip to content

Add hook to transform toggles before updating #248

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
besLisbeth opened this issue Apr 21, 2025 · 10 comments · May be fixed by #249
Open

Add hook to transform toggles before updating #248

besLisbeth opened this issue Apr 21, 2025 · 10 comments · May be fixed by #249
Assignees
Labels
enhancement New feature or request

Comments

@besLisbeth
Copy link

Describe the feature request

I would like to propose a new configuration option for the unleash-proxy-client-js library that allows clients to filter or transform the list of feature toggles before the internal state is updated. This would provide a clean, flexible way to manage which toggles are updated, without overriding core logic such as the fetch method.

Background

In the current implementation, when the Unleash proxy client fetches toggles, it updates the internal state with the entire list of toggles returned by the proxy. This behavior works well for most scenarios, but introduces limitations for more complex use cases where only a subset of toggles should be processed. In our project, we want to update the list of feature flags with not very complex logic to not break the client and not to over-complicate the logic that could result in bad UX.

To work around this, I’m currently overriding the fetch method entirely, just to inject toggle transformation logic. However, this approach has significant downsides:

  • Hard to maintain: I must reimplement logic that the library already handles correctly.
  • Error-prone: Custom fetch logic risks breaking behavior like headers, caching, or error handling.
  • Tightly coupled: If internal fetch logic changes in future versions, the override could break silently.

My actual goal is not to replace fetch, but simply to intercept or shape the list of toggles before they’re applied to the client.

Solution suggestions

I propose introducing a new optional hook in the client configuration that allows consumers to control how the fetched toggles are processed before updating internal state.

Three possible designs:

Option A: Filtering hook

filterToggles?: (toggle: IToggle) => boolean;

Usage:
filterToggles: (toggle) => toggle.name.startsWith('app:feature:');

Option B: Transformation hook

transformToggles?: (toggles: IToggle[]) => IToggle[];

Usage:
transformToggles: (toggles) => toggles.filter(t => t.name.includes('beta'));

Option C: Asynchronous transformation hook

transformToggles?: (toggles: IToggle[]) => Promise<IToggle[]>;

Usage:

transformToggles: async (toggles) => {
  сonst isOk = await userConfirmation(); 
  return isOk ? toggles.filter(toggle => toggles.filter(t => t.name.includes('beta:for:best:users'))) : toggles;
}

These hooks would execute after the toggle list is fetched but before the client updates its internal feature map.

I prefer Option C as it's more flexible compared with the previous ones. It also opens the door to dynamic filtering or enrichment, such as:

  • Pulling context from APIs or storage;
  • Applying user- or environment-specific logic;
  • Delaying the toggle application until external criteria are met.

Benefits to have this feature overall:

  1. Clean and idiomatic: Keeps the library usage declarative
  2. Maintains encapsulation: No need to override core behavior like fetch
  3. More flexible: Allows both filtering and advanced transformation logic
  4. Safer: Leverages existing fetch implementation without duplicating logic

This addition would significantly improve flexibility while keeping the existing API clean and backward-compatible.
I would also like to write the PR to add this functionality to the code.

What do you think?

@besLisbeth besLisbeth added the enhancement New feature or request label Apr 21, 2025
@besLisbeth besLisbeth linked a pull request Apr 21, 2025 that will close this issue
@gastonfournier
Copy link
Contributor

Hi @besLisbeth this type of filtering is accomplished in Unleash using different project-scoped tokens. That's the recommended way and it's standard to all SDKs. The problem of this proposal is that either will have to be implemented in all SDKs (for consistency), which has a high cost, or it will create a drift in how our SDKs work, which is something we want to prevent.

We understand that open source users are limited to one project, but that's because the expected target of Open Source users is for the smaller scale, and in those situations filtering toggles wouldn't be necessary.

@gastonfournier gastonfournier self-assigned this Apr 23, 2025
@gastonfournier gastonfournier moved this from New to Investigating in Issues and PRs Apr 23, 2025
@besLisbeth
Copy link
Author

besLisbeth commented Apr 24, 2025

Hi @gastonfournier, unfortunately, dividing our Unleash environment on projects wouldn't help us, as we are trying to have that filtering in the one project scope

@besLisbeth
Copy link
Author

We've got a number of feature flags with a very complex logic, both on the Model and the UI side. This prevents us from updating feature flags without reloading the page. But now, we decide to have a couple of FF that would be updating with the refreshInterval, and we need to somehow "freeze" the complex ones. Maybe you've got any other ideas on how we can accomplish this?

@gastonfournier
Copy link
Contributor

that filtering in the one project scope

Unfortunately, having multiple projects is one of the enterprise features we have and it's also more comprehensive to group features by project than filtering on the frontend side. Doing it by project also gives us the benefit of having project specific metrics. So, implementing this feature request goes against our goals.

Maybe you've got any other ideas on how we can accomplish this?

  • The SDK client is Open Source so you could fork it and build your own functionality in your forked version. It comes with maintenance costs
  • You can have a different installation of Unleash for this new project. The trade off is now you have 2 servers to maintain and 2 backend calls
  • You can run a proxy in between your app and Unleash which transforms the response. It's another server to maintain but might be doable and you can always fallback to no-op if your transformation logic fails.

@besLisbeth
Copy link
Author

besLisbeth commented Apr 25, 2025

Can we talk about the multi-project approach?
In the application, we have two feature flags (FFs):

  1. The first FF adds a new option to the dropdown, which we can safely update anytime during runtime.
  2. The second FF alters the entire user experience (e.g. redesign). In this case, we need to check with the user to see if it's acceptable to update this FF while they're in the process of editing, as we don't want to lose their unsaved changes.

How can we utilize the multi-project approach to address this problem? Would we need two proxies for one project, and two proxy clients on the side of the frontend? It's more than inconvenient

@gastonfournier
Copy link
Contributor

I think we need to go back to the problem statement. You want some features to be active only if the customer gave their consent.

I can think of a few ways of doing that without having to modify the internals of the feature flagging system.

Option 1: using Unleash context

unleash.setContextField('isOk', 'false');
userConfirmation().then((ok) => unleash.setContextField('userId', `${ok}`));

then just use 'isOk' as a constraint to whether some features are enabled or not.

Option 2: having it as part of the condition

const isOk = await userConfirmation();
if (unleash.isEnabled('flag') && isOk) {
    // do something
}

You could hide this complexity with a library wrapper, but because you have cases where the flags needs to account for the confirmation and other cases where it should not, I think the wrapper would add another layer of complexity.

I believe both options should work fine. The first one allows you to have that control in Unleash UI, you just always include the user consent in the context and let Unleash constraints dictate which flags are enabled and which flags are not. So I'd prefer option 1, but we've also seen option 2 in some cases where you want that control to be in your code.

Let me know if that works for you, I think we started discussing solutions before discussing the problem you were trying to solve. I tried my best to go back to the original problem based on your last message and the description of the issue, so feel free to add more details or correct me if the proposed solution is missing some details from the problem you're trying to solve.

@besLisbeth
Copy link
Author

The initial goal is to somehow filter the toggles that are easy to update and can be processed in real-time from the complex ones we want to avoid until the refresh of the page. The way we are dealing with it right now - we reevaluate the fetch function from our side, and the way it's working now is bad for a lot of reasons:

  1. Lacks encapsulation: We override core behavior like fetch, which could break anytime, updating the library
  2. Not safe: We know the inside logic of Unleash behaviour, dealing with toggles, like when the toggle is disabled, it's not returning with the new update.

Adding asynchronicity to the filtering may also add flexibility to the existing scenarios, as you showed in Option 2. Unfortunately, it seems to me that both options provided won't help us a lot.

Below, I provide an example of how filtering could be added to the library in a bad existing way:

this.runtimeUpdatableFlags = ["my-FF.that-is-easy-to-update-realtime"];

const customFetch = this.createCustomFetch();

 this.unleashClient = new UnleashClient({
            ...this.unleashConfig,
            fetch: customFetch,
});

private createCustomFetch(): (input: RequestInfo, init?: RequestInit) => Promise<Response> {
        return async (input: string, init?: RequestInit) => {
            // Store the original fetch function if we haven't already
            if (!this.initialFetch) {
                this.initialFetch = window.fetch.bind(window);
            }

            // Proceed with the original fetch
            const response = await this.initialFetch(input, init);
            // If client is not ready yet (initial load), return the original response
            if (!this.isClientReady || !input.includes('OUR_PATH_TO_PROXY') || !response.ok) {
                return response;
            }

            const currentTogglesMap = new Map<string, IToggle>();
            const currentToggles = this.unleashClient.getAllToggles();
            currentToggles.forEach(toggle => {
                currentTogglesMap.set(toggle.name, toggle);
            });

            const newTogglesMap = new Map<string, IToggle>();
            const data = await response.json();
            const newToggles = data.toggles as IToggle[];
            newToggles.forEach(toggle => {
                newTogglesMap.set(toggle.name, toggle);
            });

            this.runtimeUpdatableFlags.forEach(toggleName => {
                const newToggle = newTogglesMap.get(toggleName);
                if (newToggle) {
                    currentTogglesMap.set(toggleName, newToggle);
                } else {
                    const currentToggle = currentTogglesMap.get(toggleName);
                    if (currentToggle) {
                        currentTogglesMap.delete(toggleName);
                    }
                }
            });
            // Process all toggles from the original response
            const updatedToggles: IToggle[] = Array.from(currentTogglesMap.values());

            // Create a new response with filtered toggles
            const modifiedBody = JSON.stringify({ toggles: updatedToggles });
            return new Response(modifiedBody, {
                status: response.status,
                statusText: response.statusText,
                headers: response.headers,
            });
        };
    }

@gastonfournier
Copy link
Contributor

Unfortunately, it seems to me that both options provided won't help us a lot.

Can you elaborate on the reasons why?

@besLisbeth
Copy link
Author

@gastonfournier, because you went back to the problem statement but evaluated the wrong problem

@gastonfournier
Copy link
Contributor

gastonfournier commented Apr 25, 2025

Hi @besLisbeth, sorry for any confusion earlier.
After re-reading our conversation, here's how I understand the need:

  • Most flags should keep updating on every poll.
  • A smaller set should "freeze" after the first evaluation so they never flip during the user’s session.

I'm trying to find an approach that fits your goal and the way we've seen this problem solved (both in this SDK as well as in other SDKs). The common pattern is a tiny wrapper:

const stickyCache = new Map<string, boolean>();

function isEnabledSticky(name: string, ctx?: Context) {
  if (!stickyCache.has(name)) {
    stickyCache.set(name, unleash.isEnabled(name, ctx));
  }
  return stickyCache.get(name)!;
}
  • isEnabled() → normal behaviour (keeps updating).
  • isEnabledSticky() → evaluates once and stays fixed.

Most teams lean on this wrapper instead of changing the SDK internals, and it's the approach I'd recommend.

We definitely see the potential value here—the challenge is that we maintain ~20 SDKs. We'd rather solve it once at the protocol level (e.g., a sticky flag attribute every client understands) than introduce a special-case hook in just this SDK. Right now we're heads-down rolling out Yggdrasil across all clients, so realistically we couldn't tackle a protocol change for a few months unless we start hearing stronger demand. Still, let's keep the idea on the table and revisit when the timing's better, but do let me know if the stickyCache proposal is better aligned with your original problem.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
Status: Investigating
Development

Successfully merging a pull request may close this issue.

2 participants