@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.
@@ -0,0 +1,160 @@
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
+ import { useTerrenoFeatureFlags } from "./useTerrenoFeatureFlags";
7
+ const boolDef = (variant) => ({
8
+ defaultVariant: variant,
9
+ disabled: false,
10
+ variants: { off: false, on: true },
11
+ });
12
+ const buildApi = (queryResult) => {
13
+ const refetch = mock(() => { });
14
+ const useTerrenoFlagConfigurationQuery = mock(() => {
15
+ const hasData = Boolean(queryResult.data);
16
+ return {
17
+ data: queryResult.data,
18
+ error: queryResult.error,
19
+ isError: queryResult.isError ?? Boolean(queryResult.error),
20
+ isFetching: queryResult.isFetching ?? false,
21
+ isLoading: queryResult.isLoading ?? false,
22
+ isSuccess: queryResult.isSuccess ?? hasData,
23
+ refetch,
24
+ };
25
+ });
26
+ const api = {
27
+ injectEndpoints: mock((opts) => {
28
+ const builder = {
29
+ query: (def) => {
30
+ def.query();
31
+ return { useQuery: useTerrenoFlagConfigurationQuery };
32
+ },
33
+ };
34
+ opts.endpoints(builder);
35
+ return { useTerrenoFlagConfigurationQuery };
36
+ }),
37
+ };
38
+ return { api, refetch, useTerrenoFlagConfigurationQuery };
39
+ };
40
+ describe("useTerrenoFeatureFlags", () => {
41
+ beforeEach(async () => {
42
+ await OpenFeature.setProviderAndWait("feature-flags", NOOP_PROVIDER);
43
+ await OpenFeature.setProviderAndWait("custom-ff", NOOP_PROVIDER);
44
+ });
45
+ afterEach(async () => {
46
+ await OpenFeature.setProviderAndWait("feature-flags", NOOP_PROVIDER);
47
+ await OpenFeature.setProviderAndWait("custom-ff", NOOP_PROVIDER);
48
+ });
49
+ it("sets TypedInMemoryProvider and exposes client values after success", async () => {
50
+ const { api } = buildApi({ data: { alpha: boolDef("on") } });
51
+ const { result } = renderHook(() => useTerrenoFeatureFlags(api, { domain: "feature-flags", userId: "u1" }));
52
+ await waitFor(() => {
53
+ expect(result.current.isLoading).toBe(false);
54
+ });
55
+ expect(result.current.client.getBooleanValue("alpha", false)).toBe(true);
56
+ expect(result.current.flags.alpha?.defaultVariant).toBe("on");
57
+ });
58
+ it("does not leave loading stuck when skip is true", () => {
59
+ const { api } = buildApi({ data: { alpha: boolDef("on") } });
60
+ const { result } = renderHook(() => useTerrenoFeatureFlags(api, { domain: "feature-flags", skip: true, userId: "u1" }));
61
+ expect(result.current.isLoading).toBe(false);
62
+ });
63
+ it("does not leave loading stuck when the flag configuration query errors", () => {
64
+ const { api } = buildApi({
65
+ error: new Error("request failed"),
66
+ isError: true,
67
+ isLoading: false,
68
+ isSuccess: false,
69
+ });
70
+ const { result } = renderHook(() => useTerrenoFeatureFlags(api, { domain: "feature-flags", userId: "u1" }));
71
+ expect(result.current.isLoading).toBe(false);
72
+ expect(result.current.error).toBeDefined();
73
+ });
74
+ it("isolates domains so a custom domain does not read another domain's provider", async () => {
75
+ const { api } = buildApi({ data: { alpha: boolDef("on") } });
76
+ const { result } = renderHook(() => useTerrenoFeatureFlags(api, { domain: "custom-ff", userId: "u1" }));
77
+ await waitFor(() => {
78
+ expect(result.current.isLoading).toBe(false);
79
+ });
80
+ const defaultClient = OpenFeature.getClient();
81
+ expect(defaultClient.getBooleanValue("alpha", false)).toBe(false);
82
+ expect(result.current.client.getBooleanValue("alpha", false)).toBe(true);
83
+ });
84
+ it("refetches when the socket receives the configured event", async () => {
85
+ const socket = new EventEmitter();
86
+ const { api, refetch } = buildApi({ data: { alpha: boolDef("off") } });
87
+ const { result } = renderHook(() => useTerrenoFeatureFlags(api, {
88
+ domain: "feature-flags",
89
+ socket: {
90
+ off: (ev, fn) => {
91
+ socket.off(ev, fn);
92
+ },
93
+ on: (ev, fn) => {
94
+ socket.on(ev, fn);
95
+ },
96
+ },
97
+ socketEventName: "featureFlagsChanged",
98
+ userId: "u1",
99
+ }));
100
+ await waitFor(() => {
101
+ expect(result.current.isLoading).toBe(false);
102
+ });
103
+ socket.emit("featureFlagsChanged");
104
+ expect(refetch).toHaveBeenCalled();
105
+ });
106
+ it("survives React StrictMode double-mount without throwing", async () => {
107
+ const { api } = buildApi({ data: { alpha: boolDef("on") } });
108
+ const { result } = renderHook(() => useTerrenoFeatureFlags(api, { domain: "feature-flags", userId: "u1" }), {
109
+ wrapper: ({ children }) => React.createElement(StrictMode, null, children),
110
+ });
111
+ await waitFor(() => {
112
+ expect(result.current.isLoading).toBe(false);
113
+ });
114
+ expect(result.current.client.getBooleanValue("alpha", false)).toBe(true);
115
+ });
116
+ it("re-applies provider when fetched configuration changes", async () => {
117
+ const built = buildApi({ data: { alpha: boolDef("off") } });
118
+ const { result, rerender } = renderHook(({ data }) => {
119
+ built.useTerrenoFlagConfigurationQuery.mockImplementation(() => ({
120
+ data,
121
+ error: undefined,
122
+ isError: false,
123
+ isFetching: false,
124
+ isLoading: false,
125
+ isSuccess: true,
126
+ refetch: built.refetch,
127
+ }));
128
+ return useTerrenoFeatureFlags(built.api, { domain: "feature-flags", userId: "u1" });
129
+ }, { initialProps: { data: { alpha: boolDef("off") } } });
130
+ await waitFor(() => {
131
+ expect(result.current.client.getBooleanValue("alpha", false)).toBe(false);
132
+ });
133
+ rerender({ data: { alpha: boolDef("on") } });
134
+ await waitFor(() => {
135
+ expect(result.current.client.getBooleanValue("alpha", false)).toBe(true);
136
+ });
137
+ });
138
+ });
139
+ describe("useTerrenoFeatureFlags ref-count cleanup", () => {
140
+ /** Dedicated domain so parallel test files do not hold extra `feature-flags` hook refs. */
141
+ const refcountTestDomain = "terreno-refcount-test-isolation";
142
+ beforeEach(async () => {
143
+ await OpenFeature.setProviderAndWait(refcountTestDomain, NOOP_PROVIDER);
144
+ });
145
+ afterEach(async () => {
146
+ await OpenFeature.setProviderAndWait(refcountTestDomain, NOOP_PROVIDER);
147
+ });
148
+ it("installs NOOP provider after the last hook instance unmounts", async () => {
149
+ const { api } = buildApi({ data: { alpha: boolDef("on") } });
150
+ const { unmount } = renderHook(() => useTerrenoFeatureFlags(api, { domain: refcountTestDomain, userId: "u1" }));
151
+ await waitFor(() => {
152
+ expect(OpenFeature.getClient(refcountTestDomain).getBooleanValue("alpha", false)).toBe(true);
153
+ }, { timeout: 5000 });
154
+ unmount();
155
+ await waitFor(() => {
156
+ expect(OpenFeature.getClient(refcountTestDomain).getBooleanValue("alpha", false)).toBe(false);
157
+ }, { timeout: 5000 });
158
+ });
159
+ });
160
+ //# sourceMappingURL=useTerrenoFeatureFlags.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useTerrenoFeatureFlags.test.js","sourceRoot":"","sources":["../src/useTerrenoFeatureFlags.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "dependencies": {
3
3
  "@better-auth/expo": "^1.2.8",
4
+ "@openfeature/core": "^1.11.0",
4
5
  "@react-native-async-storage/async-storage": "2.2.0",
5
6
  "@reduxjs/toolkit": "^2.11.1",
6
- "@terreno/ui": "0.19.0",
7
+ "@terreno/ui": "0.20.0",
7
8
  "async-mutex": "^0.5.0",
8
9
  "axios": "^1.13.2",
9
10
  "axios-retry": "^4.5.0",
@@ -22,6 +23,8 @@
22
23
  "description": "Redux Toolkit Query utilities for @terreno/api backends",
23
24
  "devDependencies": {
24
25
  "@biomejs/biome": "^2.3.6",
26
+ "@openfeature/react-sdk": "^1.4.0",
27
+ "@openfeature/web-sdk": "^1.9.0",
25
28
  "@testing-library/react-native": "^13.2.0",
26
29
  "@types/bun": "^1.2.4",
27
30
  "@types/luxon": "^3.7.1",
@@ -50,6 +53,8 @@
50
53
  "main": "dist/index.js",
51
54
  "name": "@terreno/rtk",
52
55
  "peerDependencies": {
56
+ "@openfeature/react-sdk": "^1.4.0",
57
+ "@openfeature/web-sdk": "^1.9.0",
53
58
  "react": "19.1.0"
54
59
  },
55
60
  "publishConfig": {
@@ -67,5 +72,5 @@
67
72
  "test:coverage": "bun run ../scripts/check-coverage.ts"
68
73
  },
69
74
  "types": "dist/index.d.ts",
70
- "version": "0.19.0"
75
+ "version": "0.20.0"
71
76
  }
package/src/index.ts CHANGED
@@ -18,4 +18,5 @@ export * from "./useFeatureFlags";
18
18
  export * from "./useOfflineStatus";
19
19
  export * from "./useRealtimeDebug";
20
20
  export * from "./useServerStatus";
21
+ export * from "./useTerrenoFeatureFlags";
21
22
  export * from "./useUpgradeCheck";
@@ -0,0 +1,36 @@
1
+ import {afterEach, beforeEach, describe, expect, it} from "bun:test";
2
+ import {OpenFeatureProvider, useBooleanFlagValue} from "@openfeature/react-sdk";
3
+ import {NOOP_PROVIDER, OpenFeature, TypedInMemoryProvider} from "@openfeature/web-sdk";
4
+ import {renderHook, waitFor} from "@testing-library/react-native";
5
+ import React from "react";
6
+
7
+ const SPIKE_DOMAIN = "openfeature-rn-spike";
8
+
9
+ describe("@openfeature/react-sdk with React Native test renderer", () => {
10
+ beforeEach(async () => {
11
+ await OpenFeature.setProviderAndWait(
12
+ SPIKE_DOMAIN,
13
+ new TypedInMemoryProvider({
14
+ "demo-flag": {
15
+ defaultVariant: "on",
16
+ disabled: false,
17
+ variants: {off: false, on: true},
18
+ },
19
+ })
20
+ );
21
+ });
22
+
23
+ afterEach(async () => {
24
+ await OpenFeature.setProviderAndWait(SPIKE_DOMAIN, NOOP_PROVIDER);
25
+ });
26
+
27
+ it("exposes useBooleanFlagValue when wrapped by OpenFeatureProvider", async () => {
28
+ const {result} = renderHook(() => useBooleanFlagValue("demo-flag", false), {
29
+ wrapper: ({children}: {children: React.ReactNode}) =>
30
+ React.createElement(OpenFeatureProvider, {domain: SPIKE_DOMAIN}, children),
31
+ });
32
+ await waitFor(() => {
33
+ expect(result.current).toBe(true);
34
+ });
35
+ });
36
+ });
@@ -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
  });