@terreno/rtk 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import type {Api} from "@reduxjs/toolkit/query/react";
2
- import {useCallback, useEffect, useRef} from "react";
2
+ import {useCallback, useEffect, useMemo, useRef} from "react";
3
+ import {useTerrenoFeatureFlags} from "./useTerrenoFeatureFlags";
3
4
 
4
5
  interface FlagValues {
5
6
  [key: string]: boolean | string | null;
@@ -8,56 +9,19 @@ interface FlagValues {
8
9
  // biome-ignore lint/suspicious/noExplicitAny: RTK Query API generic typing is intentionally flexible here.
9
10
  type FlagsApi = Api<any, any, any, any>;
10
11
 
11
- // biome-ignore lint/suspicious/noExplicitAny: Endpoint hook is injected dynamically by RTK Query.
12
- type EnhancedFlagsApi = FlagsApi & {useEvaluateFeatureFlagsQuery: any};
13
-
14
- /**
15
- * Cache the enhanced api per (api, basePath). `injectEndpoints` logs a console
16
- * error in development whenever an endpoint with the same name is re-injected
17
- * with `overrideExisting: false`, so calling it on every render of every
18
- * consumer would flood the console. WeakMap-by-api lets the GC reclaim entries
19
- * when the api object is unreachable.
20
- */
21
- const enhancedApiCache = new WeakMap<FlagsApi, Map<string, EnhancedFlagsApi>>();
22
-
23
- const getEnhancedApi = (api: FlagsApi, basePath: string): EnhancedFlagsApi => {
24
- let byBase = enhancedApiCache.get(api);
25
- if (!byBase) {
26
- byBase = new Map();
27
- enhancedApiCache.set(api, byBase);
28
- }
29
- const cached = byBase.get(basePath);
30
- if (cached) {
31
- return cached;
32
- }
33
- const enhanced = api.injectEndpoints({
34
- endpoints: (builder) => ({
35
- evaluateFeatureFlags: builder.query<FlagValues, void>({
36
- query: () => ({
37
- method: "GET",
38
- url: `${basePath}/evaluate`,
39
- }),
40
- }),
41
- }),
42
- overrideExisting: false,
43
- }) as EnhancedFlagsApi;
44
- byBase.set(basePath, enhanced);
45
- return enhanced;
46
- };
47
-
48
12
  interface UseFeatureFlagsResult {
13
+ error: unknown;
49
14
  flags: FlagValues;
50
15
  getFlag: (key: string) => boolean;
51
16
  getVariant: (key: string) => string | null;
52
17
  isLoading: boolean;
53
- error: unknown;
54
18
  refetch: () => void;
55
19
  }
56
20
 
57
21
  /**
58
22
  * Creates feature flag accessors from an RTK Query API instance.
59
23
  *
60
- * Injects a `GET {basePath}/evaluate` endpoint into the API and returns
24
+ * Injects a `GET {basePath}/flagConfiguration` endpoint into the API and returns
61
25
  * accessors for reading flag values. Fetches once on mount and caches via
62
26
  * RTK Query. Both `api` and `basePath` should be stable references.
63
27
  *
@@ -70,116 +34,148 @@ interface UseFeatureFlagsResult {
70
34
  * ```
71
35
  */
72
36
  export interface UseFeatureFlagsOptions {
73
- /**
74
- * Base path for the feature-flags endpoint. Defaults to "/feature-flags".
75
- */
76
37
  basePath?: string;
77
- /**
78
- * When true, the underlying evaluate query is not fired. Use this to avoid
79
- * fetching before the user is authenticated.
80
- */
38
+ domain?: string;
81
39
  skip?: boolean;
40
+ socket?: {
41
+ off: (event: string, handler: (...args: unknown[]) => void) => void;
42
+ on: (event: string, handler: (...args: unknown[]) => void) => void;
43
+ } | null;
44
+ socketEventName?: string;
45
+ userId?: string | null;
82
46
  }
83
47
 
84
48
  interface ResolvedFeatureFlagsOptions {
85
49
  basePath: string;
50
+ domain: string;
86
51
  skip: boolean;
52
+ socket?: UseFeatureFlagsOptions["socket"];
53
+ socketEventName?: string;
54
+ userId?: string | null;
87
55
  }
88
56
 
89
57
  /**
90
58
  * Normalizes the legacy-compatible `basePathOrOptions` argument into a
91
- * `{basePath, skip}` pair with defaults applied.
92
- *
93
- * - `undefined` -> `{basePath: "/feature-flags", skip: false}`
94
- * - `string` -> `{basePath: <string>, skip: false}` (legacy form)
95
- * - `object` -> `{basePath: opts.basePath ?? "/feature-flags", skip: opts.skip ?? false}`
59
+ * `{basePath, skip, ...}` pair with defaults applied.
96
60
  */
97
61
  export const resolveFeatureFlagsOptions = (
98
62
  basePathOrOptions?: string | UseFeatureFlagsOptions
99
63
  ): ResolvedFeatureFlagsOptions => {
100
- const {basePath = "/feature-flags", skip = false} =
64
+ const raw =
101
65
  typeof basePathOrOptions === "string"
102
66
  ? {basePath: basePathOrOptions, skip: false}
103
67
  : (basePathOrOptions ?? {});
104
- return {basePath, skip};
68
+ return {
69
+ basePath: raw.basePath ?? "/feature-flags",
70
+ domain: raw.domain ?? "feature-flags",
71
+ skip: raw.skip ?? false,
72
+ socket: raw.socket,
73
+ socketEventName: raw.socketEventName,
74
+ userId: raw.userId,
75
+ };
105
76
  };
106
77
 
107
- // Overloaded signature preserves backwards compatibility with callers that
108
- // pass a string basePath as the second argument.
109
- export function useFeatureFlags(
78
+ export const useFeatureFlags = (
110
79
  api: FlagsApi,
111
80
  basePathOrOptions?: string | UseFeatureFlagsOptions
112
- ): UseFeatureFlagsResult {
113
- const {basePath, skip} = resolveFeatureFlagsOptions(basePathOrOptions);
114
-
115
- const enhancedApi = getEnhancedApi(api, basePath);
116
- const useEvaluateQuery = enhancedApi.useEvaluateFeatureFlagsQuery;
117
- const {data, isLoading, error, refetch} = useEvaluateQuery(undefined, {skip});
118
- const evaluateStartedAtRef = useRef<number | null>(null);
81
+ ): UseFeatureFlagsResult => {
82
+ const {basePath, domain, skip, socket, socketEventName, userId} =
83
+ resolveFeatureFlagsOptions(basePathOrOptions);
84
+
85
+ const {
86
+ client,
87
+ error,
88
+ flags: rawFlags,
89
+ isLoading,
90
+ refetch,
91
+ } = useTerrenoFeatureFlags(api, {
92
+ basePath,
93
+ domain,
94
+ skip,
95
+ socket,
96
+ socketEventName,
97
+ userId,
98
+ });
99
+
100
+ const fetchStartedAtRef = useRef<number | null>(null);
119
101
 
120
- const flags: FlagValues = data ?? {};
121
-
122
- // Log when evaluate request enters loading state so client timing can be measured.
123
102
  useEffect((): void => {
124
- if (!isLoading || evaluateStartedAtRef.current !== null) {
103
+ if (!isLoading || fetchStartedAtRef.current !== null) {
125
104
  return;
126
105
  }
127
106
 
128
- evaluateStartedAtRef.current = Date.now();
129
- console.debug("[feature-flags] evaluate request started", {
107
+ fetchStartedAtRef.current = Date.now();
108
+ console.debug("[feature-flags] flagConfiguration request started", {
130
109
  basePath,
131
110
  });
132
111
  }, [basePath, isLoading]);
133
112
 
134
- // Log evaluate success with duration and number of resolved flag values.
135
113
  useEffect((): void => {
136
- if (!data || evaluateStartedAtRef.current === null) {
114
+ if (!error || fetchStartedAtRef.current === null) {
137
115
  return;
138
116
  }
139
117
 
140
- const durationMs = Date.now() - evaluateStartedAtRef.current;
141
- evaluateStartedAtRef.current = null;
142
- console.debug("[feature-flags] evaluate request completed", {
118
+ const durationMs = Date.now() - fetchStartedAtRef.current;
119
+ fetchStartedAtRef.current = null;
120
+ console.debug("[feature-flags] flagConfiguration request failed", {
143
121
  basePath,
144
122
  durationMs,
145
- evaluatedFlagCount: Object.keys(flags).length,
146
- evaluatedFlags: flags,
123
+ error,
147
124
  });
148
- }, [basePath, data, flags]);
125
+ }, [basePath, error]);
126
+
127
+ const flatFlags = useMemo((): FlagValues => {
128
+ const out: FlagValues = {};
129
+ for (const [key, def] of Object.entries(rawFlags)) {
130
+ const value = def.variants[def.defaultVariant];
131
+ out[key] = value ?? null;
132
+ }
133
+ return out;
134
+ }, [rawFlags]);
149
135
 
150
- // Log evaluate failures with duration so request issues can be correlated.
151
136
  useEffect((): void => {
152
- if (!error || evaluateStartedAtRef.current === null) {
137
+ if (Object.keys(flatFlags).length === 0 || fetchStartedAtRef.current === null) {
138
+ return;
139
+ }
140
+
141
+ if (isLoading) {
153
142
  return;
154
143
  }
155
144
 
156
- const durationMs = Date.now() - evaluateStartedAtRef.current;
157
- evaluateStartedAtRef.current = null;
158
- console.debug("[feature-flags] evaluate request failed", {
145
+ const durationMs = Date.now() - fetchStartedAtRef.current;
146
+ fetchStartedAtRef.current = null;
147
+ console.debug("[feature-flags] flagConfiguration request completed", {
159
148
  basePath,
160
149
  durationMs,
161
- error,
150
+ evaluatedFlagCount: Object.keys(flatFlags).length,
151
+ evaluatedFlags: flatFlags,
162
152
  });
163
- }, [basePath, error]);
153
+ }, [basePath, flatFlags, isLoading]);
164
154
 
165
155
  const getFlag = useCallback(
166
156
  (key: string): boolean => {
167
- const value = flags[key];
168
- return value === true;
157
+ return client.getBooleanValue(key, false);
169
158
  },
170
- [flags]
159
+ [client]
171
160
  );
172
161
 
173
162
  const getVariant = useCallback(
174
163
  (key: string): string | null => {
175
- const value = flags[key];
176
- if (typeof value === "string") {
177
- return value;
164
+ if (!rawFlags[key]) {
165
+ return null;
166
+ }
167
+ const value = client.getStringValue(key, "");
168
+ if (value === "") {
169
+ return null;
178
170
  }
179
- return null;
171
+ return value;
180
172
  },
181
- [flags]
173
+ [client, rawFlags]
182
174
  );
183
175
 
184
- return {error, flags, getFlag, getVariant, isLoading, refetch};
185
- }
176
+ const stableRefetch = useCallback((): void => {
177
+ void refetch();
178
+ }, [refetch]);
179
+
180
+ return {error, flags: flatFlags, getFlag, getVariant, isLoading, refetch: stableRefetch};
181
+ };
@@ -0,0 +1,222 @@
1
+ import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test";
2
+ import EventEmitter from "node:events";
3
+ import {NOOP_PROVIDER, OpenFeature} from "@openfeature/web-sdk";
4
+ import {renderHook, waitFor} from "@testing-library/react-native";
5
+ import React, {StrictMode} from "react";
6
+
7
+ import {useTerrenoFeatureFlags} from "./useTerrenoFeatureFlags";
8
+
9
+ type TerrenoFlagConfiguration = Record<
10
+ string,
11
+ {
12
+ defaultVariant: string;
13
+ disabled: boolean;
14
+ variants: Record<string, boolean | string>;
15
+ }
16
+ >;
17
+
18
+ type QueryResult = {
19
+ data?: TerrenoFlagConfiguration;
20
+ error?: unknown;
21
+ isError?: boolean;
22
+ isFetching?: boolean;
23
+ isLoading?: boolean;
24
+ isSuccess?: boolean;
25
+ };
26
+
27
+ const boolDef = (variant: "on" | "off") => ({
28
+ defaultVariant: variant,
29
+ disabled: false,
30
+ variants: {off: false, on: true},
31
+ });
32
+
33
+ const buildApi = (queryResult: QueryResult) => {
34
+ const refetch = mock(() => {});
35
+ const useTerrenoFlagConfigurationQuery = mock(() => {
36
+ const hasData = Boolean(queryResult.data);
37
+ return {
38
+ data: queryResult.data,
39
+ error: queryResult.error,
40
+ isError: queryResult.isError ?? Boolean(queryResult.error),
41
+ isFetching: queryResult.isFetching ?? false,
42
+ isLoading: queryResult.isLoading ?? false,
43
+ isSuccess: queryResult.isSuccess ?? hasData,
44
+ refetch,
45
+ };
46
+ });
47
+ const api = {
48
+ injectEndpoints: mock((opts: {endpoints: (builder: unknown) => void}) => {
49
+ const builder = {
50
+ query: (def: {query: () => {method: string; url: string}}) => {
51
+ def.query();
52
+ return {useQuery: useTerrenoFlagConfigurationQuery};
53
+ },
54
+ };
55
+ opts.endpoints(builder);
56
+ return {useTerrenoFlagConfigurationQuery};
57
+ }),
58
+ };
59
+ return {api, refetch, useTerrenoFlagConfigurationQuery};
60
+ };
61
+
62
+ describe("useTerrenoFeatureFlags", () => {
63
+ beforeEach(async () => {
64
+ await OpenFeature.setProviderAndWait("feature-flags", NOOP_PROVIDER);
65
+ await OpenFeature.setProviderAndWait("custom-ff", NOOP_PROVIDER);
66
+ });
67
+
68
+ afterEach(async () => {
69
+ await OpenFeature.setProviderAndWait("feature-flags", NOOP_PROVIDER);
70
+ await OpenFeature.setProviderAndWait("custom-ff", NOOP_PROVIDER);
71
+ });
72
+
73
+ it("sets TypedInMemoryProvider and exposes client values after success", async () => {
74
+ const {api} = buildApi({data: {alpha: boolDef("on")}});
75
+ const {result} = renderHook(() =>
76
+ useTerrenoFeatureFlags(api as never, {domain: "feature-flags", userId: "u1"})
77
+ );
78
+ await waitFor(() => {
79
+ expect(result.current.isLoading).toBe(false);
80
+ });
81
+ expect(result.current.client.getBooleanValue("alpha", false)).toBe(true);
82
+ expect(result.current.flags.alpha?.defaultVariant).toBe("on");
83
+ });
84
+
85
+ it("does not leave loading stuck when skip is true", () => {
86
+ const {api} = buildApi({data: {alpha: boolDef("on")}});
87
+ const {result} = renderHook(() =>
88
+ useTerrenoFeatureFlags(api as never, {domain: "feature-flags", skip: true, userId: "u1"})
89
+ );
90
+ expect(result.current.isLoading).toBe(false);
91
+ });
92
+
93
+ it("does not leave loading stuck when the flag configuration query errors", () => {
94
+ const {api} = buildApi({
95
+ error: new Error("request failed"),
96
+ isError: true,
97
+ isLoading: false,
98
+ isSuccess: false,
99
+ });
100
+ const {result} = renderHook(() =>
101
+ useTerrenoFeatureFlags(api as never, {domain: "feature-flags", userId: "u1"})
102
+ );
103
+ expect(result.current.isLoading).toBe(false);
104
+ expect(result.current.error).toBeDefined();
105
+ });
106
+
107
+ it("isolates domains so a custom domain does not read another domain's provider", async () => {
108
+ const {api} = buildApi({data: {alpha: boolDef("on")}});
109
+ const {result} = renderHook(() =>
110
+ useTerrenoFeatureFlags(api as never, {domain: "custom-ff", userId: "u1"})
111
+ );
112
+ await waitFor(() => {
113
+ expect(result.current.isLoading).toBe(false);
114
+ });
115
+ const defaultClient = OpenFeature.getClient();
116
+ expect(defaultClient.getBooleanValue("alpha", false)).toBe(false);
117
+ expect(result.current.client.getBooleanValue("alpha", false)).toBe(true);
118
+ });
119
+
120
+ it("refetches when the socket receives the configured event", async () => {
121
+ const socket = new EventEmitter();
122
+ const {api, refetch} = buildApi({data: {alpha: boolDef("off")}});
123
+ const {result} = renderHook(() =>
124
+ useTerrenoFeatureFlags(api as never, {
125
+ domain: "feature-flags",
126
+ socket: {
127
+ off: (ev, fn) => {
128
+ socket.off(ev, fn);
129
+ },
130
+ on: (ev, fn) => {
131
+ socket.on(ev, fn);
132
+ },
133
+ },
134
+ socketEventName: "featureFlagsChanged",
135
+ userId: "u1",
136
+ })
137
+ );
138
+ await waitFor(() => {
139
+ expect(result.current.isLoading).toBe(false);
140
+ });
141
+ socket.emit("featureFlagsChanged");
142
+ expect(refetch).toHaveBeenCalled();
143
+ });
144
+
145
+ it("survives React StrictMode double-mount without throwing", async () => {
146
+ const {api} = buildApi({data: {alpha: boolDef("on")}});
147
+ const {result} = renderHook(
148
+ () => useTerrenoFeatureFlags(api as never, {domain: "feature-flags", userId: "u1"}),
149
+ {
150
+ wrapper: ({children}: {children: React.ReactNode}) =>
151
+ React.createElement(StrictMode, null, children),
152
+ }
153
+ );
154
+ await waitFor(() => {
155
+ expect(result.current.isLoading).toBe(false);
156
+ });
157
+ expect(result.current.client.getBooleanValue("alpha", false)).toBe(true);
158
+ });
159
+
160
+ it("re-applies provider when fetched configuration changes", async () => {
161
+ const built = buildApi({data: {alpha: boolDef("off")}});
162
+ const {result, rerender} = renderHook(
163
+ ({data}: {data: TerrenoFlagConfiguration}) => {
164
+ built.useTerrenoFlagConfigurationQuery.mockImplementation(() => ({
165
+ data,
166
+ error: undefined,
167
+ isError: false,
168
+ isFetching: false,
169
+ isLoading: false,
170
+ isSuccess: true,
171
+ refetch: built.refetch,
172
+ }));
173
+ return useTerrenoFeatureFlags(built.api as never, {domain: "feature-flags", userId: "u1"});
174
+ },
175
+ {initialProps: {data: {alpha: boolDef("off")}}}
176
+ );
177
+ await waitFor(() => {
178
+ expect(result.current.client.getBooleanValue("alpha", false)).toBe(false);
179
+ });
180
+ rerender({data: {alpha: boolDef("on")}});
181
+ await waitFor(() => {
182
+ expect(result.current.client.getBooleanValue("alpha", false)).toBe(true);
183
+ });
184
+ });
185
+ });
186
+
187
+ describe("useTerrenoFeatureFlags ref-count cleanup", () => {
188
+ /** Dedicated domain so parallel test files do not hold extra `feature-flags` hook refs. */
189
+ const refcountTestDomain = "terreno-refcount-test-isolation";
190
+
191
+ beforeEach(async () => {
192
+ await OpenFeature.setProviderAndWait(refcountTestDomain, NOOP_PROVIDER);
193
+ });
194
+
195
+ afterEach(async () => {
196
+ await OpenFeature.setProviderAndWait(refcountTestDomain, NOOP_PROVIDER);
197
+ });
198
+
199
+ it("installs NOOP provider after the last hook instance unmounts", async () => {
200
+ const {api} = buildApi({data: {alpha: boolDef("on")}});
201
+ const {unmount} = renderHook(() =>
202
+ useTerrenoFeatureFlags(api as never, {domain: refcountTestDomain, userId: "u1"})
203
+ );
204
+ await waitFor(
205
+ () => {
206
+ expect(OpenFeature.getClient(refcountTestDomain).getBooleanValue("alpha", false)).toBe(
207
+ true
208
+ );
209
+ },
210
+ {timeout: 5000}
211
+ );
212
+ unmount();
213
+ await waitFor(
214
+ () => {
215
+ expect(OpenFeature.getClient(refcountTestDomain).getBooleanValue("alpha", false)).toBe(
216
+ false
217
+ );
218
+ },
219
+ {timeout: 5000}
220
+ );
221
+ });
222
+ });