@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.
- package/README.md +6 -0
- package/dist/emptyApi.d.ts +4 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/isolated/constants.isolated.js +22 -0
- package/dist/isolated/constants.isolated.js.map +1 -1
- package/dist/openFeatureReactSdkSpike.test.d.ts +2 -0
- package/dist/openFeatureReactSdkSpike.test.d.ts.map +1 -0
- package/dist/openFeatureReactSdkSpike.test.js +29 -0
- package/dist/openFeatureReactSdkSpike.test.js.map +1 -0
- package/dist/useFeatureFlags.d.ts +15 -15
- package/dist/useFeatureFlags.d.ts.map +1 -1
- package/dist/useFeatureFlags.js +65 -78
- package/dist/useFeatureFlags.js.map +1 -1
- package/dist/useFeatureFlags.test.js +119 -53
- package/dist/useFeatureFlags.test.js.map +1 -1
- package/dist/useServerStatus.test.d.ts +2 -0
- package/dist/useServerStatus.test.d.ts.map +1 -0
- package/dist/useServerStatus.test.js +182 -0
- package/dist/useServerStatus.test.js.map +1 -0
- package/dist/useTerrenoFeatureFlags.d.ts +39 -0
- package/dist/useTerrenoFeatureFlags.d.ts.map +1 -0
- package/dist/useTerrenoFeatureFlags.js +157 -0
- package/dist/useTerrenoFeatureFlags.js.map +1 -0
- package/dist/useTerrenoFeatureFlags.test.d.ts +2 -0
- package/dist/useTerrenoFeatureFlags.test.d.ts.map +1 -0
- package/dist/useTerrenoFeatureFlags.test.js +160 -0
- package/dist/useTerrenoFeatureFlags.test.js.map +1 -0
- package/package.json +7 -2
- package/src/index.ts +1 -0
- package/src/isolated/constants.isolated.ts +26 -0
- package/src/openFeatureReactSdkSpike.test.ts +36 -0
- package/src/useFeatureFlags.test.ts +143 -57
- package/src/useFeatureFlags.ts +91 -95
- package/src/useServerStatus.test.ts +266 -0
- package/src/useTerrenoFeatureFlags.test.ts +222 -0
- package/src/useTerrenoFeatureFlags.ts +226 -0
|
@@ -1,19 +1,31 @@
|
|
|
1
1
|
import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test";
|
|
2
|
-
import {
|
|
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
|
|
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?:
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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:
|
|
111
|
+
return {useQuery: useTerrenoFlagConfigurationQuery};
|
|
83
112
|
},
|
|
84
113
|
};
|
|
85
114
|
opts.endpoints(builder);
|
|
86
|
-
return {
|
|
115
|
+
return {useTerrenoFlagConfigurationQuery};
|
|
87
116
|
}),
|
|
88
117
|
};
|
|
89
|
-
return {api, capturedQueryBuilder, refetch,
|
|
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
|
|
108
|
-
const {api, capturedQueryBuilder} = buildApi({data: {foo:
|
|
109
|
-
renderHook(() =>
|
|
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/
|
|
147
|
+
url: "/feature-flags/flagConfiguration",
|
|
113
148
|
});
|
|
114
149
|
});
|
|
115
150
|
|
|
116
|
-
it("builds the
|
|
117
|
-
const {api, capturedQueryBuilder} = buildApi({data: {foo:
|
|
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/
|
|
156
|
+
expect(capturedQueryBuilder[0]).toEqual({method: "GET", url: "/flags/flagConfiguration"});
|
|
122
157
|
});
|
|
123
158
|
|
|
124
|
-
it("
|
|
159
|
+
it("projects FlagConfiguration to flat flags like the legacy /evaluate map", async () => {
|
|
125
160
|
const {api} = buildApi({
|
|
126
|
-
data: {
|
|
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:
|
|
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
|
|
158
|
-
const {api} = buildApi({isLoading: true});
|
|
159
|
-
renderHook(() =>
|
|
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]
|
|
235
|
+
(args) => args[0] === "[feature-flags] flagConfiguration request started"
|
|
162
236
|
);
|
|
163
237
|
expect(started).toBeDefined();
|
|
164
238
|
});
|
|
165
239
|
|
|
166
|
-
it("logs
|
|
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.
|
|
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]
|
|
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
|
|
271
|
+
it("injects the flagConfiguration endpoint only once per (api, basePath)", () => {
|
|
192
272
|
let injectCallCount = 0;
|
|
193
273
|
const refetch = mock(() => {});
|
|
194
|
-
const
|
|
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 {
|
|
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
|
|
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.
|
|
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
|
});
|
package/src/useFeatureFlags.ts
CHANGED
|
@@ -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}/
|
|
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
|
|
64
|
+
const raw =
|
|
101
65
|
typeof basePathOrOptions === "string"
|
|
102
66
|
? {basePath: basePathOrOptions, skip: false}
|
|
103
67
|
: (basePathOrOptions ?? {});
|
|
104
|
-
return {
|
|
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
|
-
|
|
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} =
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
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 ||
|
|
103
|
+
if (!isLoading || fetchStartedAtRef.current !== null) {
|
|
125
104
|
return;
|
|
126
105
|
}
|
|
127
106
|
|
|
128
|
-
|
|
129
|
-
console.debug("[feature-flags]
|
|
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 (!
|
|
114
|
+
if (!error || fetchStartedAtRef.current === null) {
|
|
137
115
|
return;
|
|
138
116
|
}
|
|
139
117
|
|
|
140
|
-
const durationMs = Date.now() -
|
|
141
|
-
|
|
142
|
-
console.debug("[feature-flags]
|
|
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
|
-
|
|
146
|
-
evaluatedFlags: flags,
|
|
123
|
+
error,
|
|
147
124
|
});
|
|
148
|
-
}, [basePath,
|
|
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 (
|
|
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() -
|
|
157
|
-
|
|
158
|
-
console.debug("[feature-flags]
|
|
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
|
-
|
|
150
|
+
evaluatedFlagCount: Object.keys(flatFlags).length,
|
|
151
|
+
evaluatedFlags: flatFlags,
|
|
162
152
|
});
|
|
163
|
-
}, [basePath,
|
|
153
|
+
}, [basePath, flatFlags, isLoading]);
|
|
164
154
|
|
|
165
155
|
const getFlag = useCallback(
|
|
166
156
|
(key: string): boolean => {
|
|
167
|
-
|
|
168
|
-
return value === true;
|
|
157
|
+
return client.getBooleanValue(key, false);
|
|
169
158
|
},
|
|
170
|
-
[
|
|
159
|
+
[client]
|
|
171
160
|
);
|
|
172
161
|
|
|
173
162
|
const getVariant = useCallback(
|
|
174
163
|
(key: string): string | null => {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
164
|
+
if (!rawFlags[key]) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
const value = client.getStringValue(key, "");
|
|
168
|
+
if (value === "") {
|
|
169
|
+
return null;
|
|
178
170
|
}
|
|
179
|
-
return
|
|
171
|
+
return value;
|
|
180
172
|
},
|
|
181
|
-
[
|
|
173
|
+
[client, rawFlags]
|
|
182
174
|
);
|
|
183
175
|
|
|
184
|
-
|
|
185
|
-
|
|
176
|
+
const stableRefetch = useCallback((): void => {
|
|
177
|
+
void refetch();
|
|
178
|
+
}, [refetch]);
|
|
179
|
+
|
|
180
|
+
return {error, flags: flatFlags, getFlag, getVariant, isLoading, refetch: stableRefetch};
|
|
181
|
+
};
|