@terreno/rtk 0.18.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.
Files changed (39) hide show
  1. package/README.md +6 -0
  2. package/dist/emptyApi.d.ts +4 -4
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/isolated/constants.isolated.js +22 -0
  8. package/dist/isolated/constants.isolated.js.map +1 -1
  9. package/dist/openFeatureReactSdkSpike.test.d.ts +2 -0
  10. package/dist/openFeatureReactSdkSpike.test.d.ts.map +1 -0
  11. package/dist/openFeatureReactSdkSpike.test.js +29 -0
  12. package/dist/openFeatureReactSdkSpike.test.js.map +1 -0
  13. package/dist/useFeatureFlags.d.ts +15 -15
  14. package/dist/useFeatureFlags.d.ts.map +1 -1
  15. package/dist/useFeatureFlags.js +65 -78
  16. package/dist/useFeatureFlags.js.map +1 -1
  17. package/dist/useFeatureFlags.test.js +119 -53
  18. package/dist/useFeatureFlags.test.js.map +1 -1
  19. package/dist/useServerStatus.test.d.ts +2 -0
  20. package/dist/useServerStatus.test.d.ts.map +1 -0
  21. package/dist/useServerStatus.test.js +182 -0
  22. package/dist/useServerStatus.test.js.map +1 -0
  23. package/dist/useTerrenoFeatureFlags.d.ts +39 -0
  24. package/dist/useTerrenoFeatureFlags.d.ts.map +1 -0
  25. package/dist/useTerrenoFeatureFlags.js +157 -0
  26. package/dist/useTerrenoFeatureFlags.js.map +1 -0
  27. package/dist/useTerrenoFeatureFlags.test.d.ts +2 -0
  28. package/dist/useTerrenoFeatureFlags.test.d.ts.map +1 -0
  29. package/dist/useTerrenoFeatureFlags.test.js +160 -0
  30. package/dist/useTerrenoFeatureFlags.test.js.map +1 -0
  31. package/package.json +7 -2
  32. package/src/index.ts +1 -0
  33. package/src/isolated/constants.isolated.ts +26 -0
  34. package/src/openFeatureReactSdkSpike.test.ts +36 -0
  35. package/src/useFeatureFlags.test.ts +143 -57
  36. package/src/useFeatureFlags.ts +91 -95
  37. package/src/useServerStatus.test.ts +266 -0
  38. package/src/useTerrenoFeatureFlags.test.ts +222 -0
  39. package/src/useTerrenoFeatureFlags.ts +226 -0
@@ -1,19 +1,31 @@
1
1
  import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test";
2
- import {renderHook} from "@testing-library/react-native";
2
+ import {NOOP_PROVIDER, OpenFeature} from "@openfeature/web-sdk";
3
+ import {renderHook, waitFor} from "@testing-library/react-native";
3
4
 
4
5
  import {resolveFeatureFlagsOptions, useFeatureFlags} from "./useFeatureFlags";
5
6
 
6
- type FlagValues = Record<string, boolean | string | null>;
7
+ type TerrenoFlagConfiguration = Record<
8
+ string,
9
+ {
10
+ defaultVariant: string;
11
+ disabled: boolean;
12
+ variants: Record<string, boolean | string>;
13
+ }
14
+ >;
15
+
7
16
  type QueryResult = {
8
- data?: FlagValues;
17
+ data?: TerrenoFlagConfiguration;
9
18
  error?: unknown;
19
+ isFetching?: boolean;
10
20
  isLoading?: boolean;
21
+ isSuccess?: boolean;
11
22
  };
12
23
 
13
24
  describe("resolveFeatureFlagsOptions", () => {
14
25
  it("applies defaults when no argument is provided", () => {
15
26
  expect(resolveFeatureFlagsOptions()).toEqual({
16
27
  basePath: "/feature-flags",
28
+ domain: "feature-flags",
17
29
  skip: false,
18
30
  });
19
31
  });
@@ -21,6 +33,7 @@ describe("resolveFeatureFlagsOptions", () => {
21
33
  it("applies defaults when an empty options object is provided", () => {
22
34
  expect(resolveFeatureFlagsOptions({})).toEqual({
23
35
  basePath: "/feature-flags",
36
+ domain: "feature-flags",
24
37
  skip: false,
25
38
  });
26
39
  });
@@ -28,6 +41,7 @@ describe("resolveFeatureFlagsOptions", () => {
28
41
  it("treats a string argument as a legacy basePath with skip=false", () => {
29
42
  expect(resolveFeatureFlagsOptions("/custom-path")).toEqual({
30
43
  basePath: "/custom-path",
44
+ domain: "feature-flags",
31
45
  skip: false,
32
46
  });
33
47
  });
@@ -35,6 +49,7 @@ describe("resolveFeatureFlagsOptions", () => {
35
49
  it("preserves an empty string basePath when passed as a legacy string argument", () => {
36
50
  expect(resolveFeatureFlagsOptions("")).toEqual({
37
51
  basePath: "",
52
+ domain: "feature-flags",
38
53
  skip: false,
39
54
  });
40
55
  });
@@ -42,6 +57,7 @@ describe("resolveFeatureFlagsOptions", () => {
42
57
  it("uses options.basePath when provided", () => {
43
58
  expect(resolveFeatureFlagsOptions({basePath: "/flags"})).toEqual({
44
59
  basePath: "/flags",
60
+ domain: "feature-flags",
45
61
  skip: false,
46
62
  });
47
63
  });
@@ -49,6 +65,7 @@ describe("resolveFeatureFlagsOptions", () => {
49
65
  it("uses options.skip when provided", () => {
50
66
  expect(resolveFeatureFlagsOptions({skip: true})).toEqual({
51
67
  basePath: "/feature-flags",
68
+ domain: "feature-flags",
52
69
  skip: true,
53
70
  });
54
71
  });
@@ -56,6 +73,7 @@ describe("resolveFeatureFlagsOptions", () => {
56
73
  it("uses both options.basePath and options.skip when provided together", () => {
57
74
  expect(resolveFeatureFlagsOptions({basePath: "/flags", skip: true})).toEqual({
58
75
  basePath: "/flags",
76
+ domain: "feature-flags",
59
77
  skip: true,
60
78
  });
61
79
  });
@@ -65,69 +83,120 @@ describe("resolveFeatureFlagsOptions", () => {
65
83
  });
66
84
  });
67
85
 
86
+ const boolDef = (variant: "on" | "off") => ({
87
+ defaultVariant: variant,
88
+ disabled: false,
89
+ variants: {off: false, on: true},
90
+ });
91
+
68
92
  const buildApi = (queryResult: QueryResult) => {
69
93
  const refetch = mock(() => {});
70
94
  const capturedQueryBuilder: Array<{method: string; url: string}> = [];
71
- const useEvaluateFeatureFlagsQuery = mock(() => ({
72
- data: queryResult.data,
73
- error: queryResult.error,
74
- isLoading: queryResult.isLoading ?? false,
75
- refetch,
76
- }));
95
+ const useTerrenoFlagConfigurationQuery = mock(() => {
96
+ const hasData = Boolean(queryResult.data);
97
+ return {
98
+ data: queryResult.data,
99
+ error: queryResult.error,
100
+ isFetching: queryResult.isFetching ?? false,
101
+ isLoading: queryResult.isLoading ?? false,
102
+ isSuccess: queryResult.isSuccess ?? hasData,
103
+ refetch,
104
+ };
105
+ });
77
106
  const api = {
78
107
  injectEndpoints: mock((opts: {endpoints: (builder: unknown) => void}) => {
79
108
  const builder = {
80
109
  query: (def: {query: () => {method: string; url: string}}) => {
81
110
  capturedQueryBuilder.push(def.query());
82
- return {useQuery: useEvaluateFeatureFlagsQuery};
111
+ return {useQuery: useTerrenoFlagConfigurationQuery};
83
112
  },
84
113
  };
85
114
  opts.endpoints(builder);
86
- return {useEvaluateFeatureFlagsQuery};
115
+ return {useTerrenoFlagConfigurationQuery};
87
116
  }),
88
117
  };
89
- return {api, capturedQueryBuilder, refetch, useEvaluateFeatureFlagsQuery};
118
+ return {api, capturedQueryBuilder, refetch, useTerrenoFlagConfigurationQuery};
90
119
  };
91
120
 
92
121
  describe("useFeatureFlags hook", () => {
93
122
  const debugCalls: unknown[][] = [];
94
123
  const originalDebug = console.debug;
95
124
 
96
- beforeEach(() => {
125
+ beforeEach(async () => {
97
126
  debugCalls.length = 0;
98
127
  console.debug = (...args: unknown[]): void => {
99
128
  debugCalls.push(args);
100
129
  };
130
+ await OpenFeature.setProviderAndWait("feature-flags", NOOP_PROVIDER);
101
131
  });
102
132
 
103
- afterEach(() => {
133
+ afterEach(async () => {
104
134
  console.debug = originalDebug;
135
+ await OpenFeature.setProviderAndWait("feature-flags", NOOP_PROVIDER);
105
136
  });
106
137
 
107
- it("builds the evaluate endpoint with the default basePath", () => {
108
- const {api, capturedQueryBuilder} = buildApi({data: {foo: true}});
109
- renderHook(() => useFeatureFlags(api as unknown as Parameters<typeof useFeatureFlags>[0]));
138
+ it("builds the flagConfiguration endpoint with the default basePath", () => {
139
+ const {api, capturedQueryBuilder} = buildApi({data: {foo: boolDef("on")}});
140
+ renderHook(() =>
141
+ useFeatureFlags(api as unknown as Parameters<typeof useFeatureFlags>[0], {
142
+ userId: "u1",
143
+ })
144
+ );
110
145
  expect(capturedQueryBuilder[0]).toEqual({
111
146
  method: "GET",
112
- url: "/feature-flags/evaluate",
147
+ url: "/feature-flags/flagConfiguration",
113
148
  });
114
149
  });
115
150
 
116
- it("builds the evaluate endpoint with a custom basePath from legacy string argument", () => {
117
- const {api, capturedQueryBuilder} = buildApi({data: {foo: true}});
151
+ it("builds the flagConfiguration endpoint with a custom basePath from legacy string argument", () => {
152
+ const {api, capturedQueryBuilder} = buildApi({data: {foo: boolDef("on")}});
118
153
  renderHook(() =>
119
154
  useFeatureFlags(api as unknown as Parameters<typeof useFeatureFlags>[0], "/flags")
120
155
  );
121
- expect(capturedQueryBuilder[0]).toEqual({method: "GET", url: "/flags/evaluate"});
156
+ expect(capturedQueryBuilder[0]).toEqual({method: "GET", url: "/flags/flagConfiguration"});
122
157
  });
123
158
 
124
- it("returns flag accessors that read boolean and string values", () => {
159
+ it("projects FlagConfiguration to flat flags like the legacy /evaluate map", async () => {
125
160
  const {api} = buildApi({
126
- data: {booleanOff: false, booleanOn: true, variantFlag: "variant-a"},
161
+ data: {
162
+ b1: boolDef("on"),
163
+ v1: {
164
+ defaultVariant: "compact",
165
+ disabled: false,
166
+ variants: {compact: "compact", detailed: "detailed"},
167
+ },
168
+ },
127
169
  });
128
170
  const {result} = renderHook(() =>
129
- useFeatureFlags(api as unknown as Parameters<typeof useFeatureFlags>[0])
171
+ useFeatureFlags(api as unknown as Parameters<typeof useFeatureFlags>[0], {userId: "u1"})
130
172
  );
173
+ await waitFor(() => {
174
+ expect(result.current.isLoading).toBe(false);
175
+ });
176
+ expect(result.current.flags).toEqual({
177
+ b1: true,
178
+ v1: "compact",
179
+ });
180
+ });
181
+
182
+ it("returns flag accessors that read boolean and string values", async () => {
183
+ const {api} = buildApi({
184
+ data: {
185
+ booleanOff: boolDef("off"),
186
+ booleanOn: boolDef("on"),
187
+ variantFlag: {
188
+ defaultVariant: "variant-a",
189
+ disabled: false,
190
+ variants: {compact: "compact", detailed: "detailed", "variant-a": "variant-a"},
191
+ },
192
+ },
193
+ });
194
+ const {result} = renderHook(() =>
195
+ useFeatureFlags(api as unknown as Parameters<typeof useFeatureFlags>[0], {userId: "u1"})
196
+ );
197
+ await waitFor(() => {
198
+ expect(result.current.isLoading).toBe(false);
199
+ });
131
200
  expect(result.current.getFlag("booleanOn")).toBe(true);
132
201
  expect(result.current.getFlag("booleanOff")).toBe(false);
133
202
  expect(result.current.getFlag("variantFlag")).toBe(false);
@@ -137,98 +206,115 @@ describe("useFeatureFlags hook", () => {
137
206
  });
138
207
 
139
208
  it("returns an empty flags map when no data is available", () => {
140
- const {api} = buildApi({isLoading: true});
209
+ const {api} = buildApi({isLoading: true, isSuccess: false});
141
210
  const {result} = renderHook(() =>
142
- useFeatureFlags(api as unknown as Parameters<typeof useFeatureFlags>[0])
211
+ useFeatureFlags(api as unknown as Parameters<typeof useFeatureFlags>[0], {userId: "u1"})
143
212
  );
144
213
  expect(result.current.flags).toEqual({});
145
214
  expect(result.current.isLoading).toBe(true);
146
215
  });
147
216
 
148
- it("exposes refetch from the underlying query hook", () => {
149
- const {api, refetch} = buildApi({data: {foo: true}});
217
+ it("exposes refetch from the underlying query hook", async () => {
218
+ const {api, refetch} = buildApi({data: {foo: boolDef("on")}});
150
219
  const {result} = renderHook(() =>
151
- useFeatureFlags(api as unknown as Parameters<typeof useFeatureFlags>[0])
220
+ useFeatureFlags(api as unknown as Parameters<typeof useFeatureFlags>[0], {userId: "u1"})
152
221
  );
222
+ await waitFor(() => {
223
+ expect(result.current.isLoading).toBe(false);
224
+ });
153
225
  result.current.refetch();
154
226
  expect(refetch).toHaveBeenCalled();
155
227
  });
156
228
 
157
- it("logs evaluate request started when loading begins without prior data", () => {
158
- const {api} = buildApi({isLoading: true});
159
- renderHook(() => useFeatureFlags(api as unknown as Parameters<typeof useFeatureFlags>[0]));
229
+ it("logs flagConfiguration request started when loading begins without prior data", () => {
230
+ const {api} = buildApi({isLoading: true, isSuccess: false});
231
+ renderHook(() =>
232
+ useFeatureFlags(api as unknown as Parameters<typeof useFeatureFlags>[0], {userId: "u1"})
233
+ );
160
234
  const started = debugCalls.find(
161
- (args) => args[0] === "[feature-flags] evaluate request started"
235
+ (args) => args[0] === "[feature-flags] flagConfiguration request started"
162
236
  );
163
237
  expect(started).toBeDefined();
164
238
  });
165
239
 
166
- it("logs evaluate request completed when data resolves after a loading phase", () => {
167
- const api = buildApi({isLoading: true});
240
+ it("logs flagConfiguration request completed when data resolves after a loading phase", async () => {
241
+ const api = buildApi({isLoading: true, isSuccess: false});
168
242
  const {rerender} = renderHook(
169
243
  ({queryResult}: {queryResult: QueryResult}) => {
170
- api.useEvaluateFeatureFlagsQuery.mockImplementationOnce(() => ({
244
+ api.useTerrenoFlagConfigurationQuery.mockImplementationOnce(() => ({
171
245
  data: queryResult.data,
172
246
  error: queryResult.error,
247
+ isFetching: queryResult.isFetching ?? false,
173
248
  isLoading: queryResult.isLoading ?? false,
249
+ isSuccess: queryResult.isSuccess ?? Boolean(queryResult.data),
174
250
  refetch: api.refetch,
175
251
  }));
176
- return useFeatureFlags(api.api as unknown as Parameters<typeof useFeatureFlags>[0]);
252
+ return useFeatureFlags(api.api as unknown as Parameters<typeof useFeatureFlags>[0], {
253
+ userId: "u1",
254
+ });
177
255
  },
178
- {initialProps: {queryResult: {isLoading: true}}}
179
- );
180
- rerender({queryResult: {data: {alpha: true}, isLoading: false}});
181
- const completed = debugCalls.find(
182
- (args) => args[0] === "[feature-flags] evaluate request completed"
256
+ {initialProps: {queryResult: {isLoading: true, isSuccess: false}}}
183
257
  );
258
+ rerender({queryResult: {data: {alpha: boolDef("on")}, isLoading: false, isSuccess: true}});
259
+ await waitFor(() => {
260
+ const completed = debugCalls.find(
261
+ (args) => args[0] === "[feature-flags] flagConfiguration request completed"
262
+ );
263
+ expect(completed).toBeDefined();
264
+ });
184
265
  const started = debugCalls.find(
185
- (args) => args[0] === "[feature-flags] evaluate request started"
266
+ (args) => args[0] === "[feature-flags] flagConfiguration request started"
186
267
  );
187
268
  expect(started).toBeDefined();
188
- expect(completed).toBeDefined();
189
269
  });
190
270
 
191
- it("injects the evaluate endpoint only once per (api, basePath)", () => {
271
+ it("injects the flagConfiguration endpoint only once per (api, basePath)", () => {
192
272
  let injectCallCount = 0;
193
273
  const refetch = mock(() => {});
194
- const useEvaluateFeatureFlagsQuery = mock(() => ({
274
+ const useTerrenoFlagConfigurationQuery = mock(() => ({
195
275
  data: {},
196
276
  error: undefined,
277
+ isFetching: false,
197
278
  isLoading: false,
279
+ isSuccess: true,
198
280
  refetch,
199
281
  }));
200
282
  const api = {
201
283
  injectEndpoints: () => {
202
284
  injectCallCount += 1;
203
- return {useEvaluateFeatureFlagsQuery};
285
+ return {useTerrenoFlagConfigurationQuery};
204
286
  },
205
287
  };
206
288
  const {rerender} = renderHook(() =>
207
- useFeatureFlags(api as unknown as Parameters<typeof useFeatureFlags>[0])
289
+ useFeatureFlags(api as unknown as Parameters<typeof useFeatureFlags>[0], {userId: "u1"})
208
290
  );
209
291
  rerender(undefined);
210
292
  rerender(undefined);
211
- // The hook reuses the cached enhanced api after the first render so the
212
- // dev-mode RTK warning about re-injecting endpoints never fires.
213
293
  expect(injectCallCount).toBe(1);
214
294
  });
215
295
 
216
- it("logs evaluate request failed when error is returned after loading", () => {
217
- const api = buildApi({isLoading: true});
296
+ it("logs flagConfiguration request failed when error is returned after loading", () => {
297
+ const api = buildApi({isLoading: true, isSuccess: false});
218
298
  const {rerender} = renderHook(
219
299
  ({queryResult}: {queryResult: QueryResult}) => {
220
- api.useEvaluateFeatureFlagsQuery.mockImplementationOnce(() => ({
300
+ api.useTerrenoFlagConfigurationQuery.mockImplementationOnce(() => ({
221
301
  data: queryResult.data,
222
302
  error: queryResult.error,
303
+ isFetching: queryResult.isFetching ?? false,
223
304
  isLoading: queryResult.isLoading ?? false,
305
+ isSuccess: queryResult.isSuccess ?? false,
224
306
  refetch: api.refetch,
225
307
  }));
226
- return useFeatureFlags(api.api as unknown as Parameters<typeof useFeatureFlags>[0]);
308
+ return useFeatureFlags(api.api as unknown as Parameters<typeof useFeatureFlags>[0], {
309
+ userId: "u1",
310
+ });
227
311
  },
228
- {initialProps: {queryResult: {isLoading: true}}}
312
+ {initialProps: {queryResult: {isLoading: true, isSuccess: false}}}
313
+ );
314
+ rerender({queryResult: {error: new Error("boom"), isLoading: false, isSuccess: false}});
315
+ const failed = debugCalls.find(
316
+ (args) => args[0] === "[feature-flags] flagConfiguration request failed"
229
317
  );
230
- rerender({queryResult: {error: new Error("boom"), isLoading: false}});
231
- const failed = debugCalls.find((args) => args[0] === "[feature-flags] evaluate request failed");
232
318
  expect(failed).toBeDefined();
233
319
  });
234
320
  });
@@ -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
+ };