@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
@@ -0,0 +1,266 @@
1
+ import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test";
2
+ import {configureStore} from "@reduxjs/toolkit";
3
+ import {act, renderHook} from "@testing-library/react-native";
4
+ import React from "react";
5
+ import {Provider} from "react-redux";
6
+
7
+ import {offlineReducer, setOnlineStatus} from "./offlineSlice";
8
+ import {useServerStatus} from "./useServerStatus";
9
+
10
+ const createTestStore = () =>
11
+ configureStore({
12
+ reducer: {offline: offlineReducer},
13
+ });
14
+
15
+ const createWrapper = (store: ReturnType<typeof createTestStore>) => {
16
+ const Wrapper: React.FC<{children: React.ReactNode}> = ({children}) =>
17
+ React.createElement(Provider, {children, store});
18
+ return Wrapper;
19
+ };
20
+
21
+ interface MockWindow {
22
+ addEventListener: (event: string, handler: () => void) => void;
23
+ removeEventListener: (event: string, handler: () => void) => void;
24
+ _listeners: Map<string, Set<() => void>>;
25
+ _dispatch: (event: string) => void;
26
+ }
27
+
28
+ const createMockWindow = (): MockWindow => {
29
+ const listeners = new Map<string, Set<() => void>>();
30
+ return {
31
+ _dispatch: (event: string) => {
32
+ for (const handler of listeners.get(event) ?? []) {
33
+ handler();
34
+ }
35
+ },
36
+ _listeners: listeners,
37
+ addEventListener: (event: string, handler: () => void) => {
38
+ if (!listeners.has(event)) {
39
+ listeners.set(event, new Set());
40
+ }
41
+ listeners.get(event)?.add(handler);
42
+ },
43
+ removeEventListener: (event: string, handler: () => void) => {
44
+ listeners.get(event)?.delete(handler);
45
+ },
46
+ };
47
+ };
48
+
49
+ describe("useServerStatus", () => {
50
+ let store: ReturnType<typeof createTestStore>;
51
+ let originalFetch: typeof globalThis.fetch;
52
+ let mockWindow: MockWindow;
53
+
54
+ beforeEach(() => {
55
+ store = createTestStore();
56
+ originalFetch = globalThis.fetch;
57
+ mockWindow = createMockWindow();
58
+ (globalThis as Record<string, unknown>).window = mockWindow;
59
+ });
60
+
61
+ afterEach(() => {
62
+ globalThis.fetch = originalFetch;
63
+ });
64
+
65
+ it("returns initial online state from offlineSlice", () => {
66
+ globalThis.fetch = mock(
67
+ async () => new Response("ok", {status: 200})
68
+ ) as unknown as typeof fetch;
69
+
70
+ const {result, unmount} = renderHook(() => useServerStatus({skip: true}), {
71
+ wrapper: createWrapper(store),
72
+ });
73
+
74
+ expect(result.current.isOnline).toBe(true);
75
+ expect(result.current.queueLength).toBe(0);
76
+ expect(result.current.isSyncing).toBe(false);
77
+ unmount();
78
+ });
79
+
80
+ it("skips polling when skip is true", async () => {
81
+ const fetchFn = mock(async () => new Response("ok", {status: 200}));
82
+ globalThis.fetch = fetchFn as unknown as typeof fetch;
83
+
84
+ const {unmount} = renderHook(() => useServerStatus({skip: true}), {
85
+ wrapper: createWrapper(store),
86
+ });
87
+
88
+ await act(async () => {
89
+ await new Promise((resolve) => setTimeout(resolve, 50));
90
+ });
91
+
92
+ expect(fetchFn).not.toHaveBeenCalled();
93
+ unmount();
94
+ });
95
+
96
+ it("dispatches online status when health check succeeds", async () => {
97
+ store.dispatch(setOnlineStatus(false));
98
+ const fetchFn = mock(async () => new Response("ok", {status: 200}));
99
+ globalThis.fetch = fetchFn as unknown as typeof fetch;
100
+
101
+ const {unmount} = renderHook(
102
+ () =>
103
+ useServerStatus({
104
+ healthUrl: "http://localhost:4000/health",
105
+ pollIntervalMs: 60_000,
106
+ }),
107
+ {wrapper: createWrapper(store)}
108
+ );
109
+
110
+ await act(async () => {
111
+ await new Promise((resolve) => setTimeout(resolve, 100));
112
+ });
113
+
114
+ expect(fetchFn).toHaveBeenCalled();
115
+ expect(store.getState().offline.isOnline).toBe(true);
116
+ unmount();
117
+ });
118
+
119
+ it("dispatches offline status when health check returns non-ok", async () => {
120
+ const fetchFn = mock(async () => new Response("error", {status: 500}));
121
+ globalThis.fetch = fetchFn as unknown as typeof fetch;
122
+
123
+ const {unmount} = renderHook(
124
+ () =>
125
+ useServerStatus({
126
+ healthUrl: "http://localhost:4000/health",
127
+ pollIntervalMs: 60_000,
128
+ }),
129
+ {wrapper: createWrapper(store)}
130
+ );
131
+
132
+ await act(async () => {
133
+ await new Promise((resolve) => setTimeout(resolve, 100));
134
+ });
135
+
136
+ expect(fetchFn).toHaveBeenCalled();
137
+ expect(store.getState().offline.isOnline).toBe(false);
138
+ unmount();
139
+ });
140
+
141
+ it("dispatches offline status when health check throws", async () => {
142
+ const fetchFn = mock(async () => {
143
+ throw new Error("Network error");
144
+ });
145
+ globalThis.fetch = fetchFn as unknown as typeof fetch;
146
+
147
+ const {unmount} = renderHook(
148
+ () =>
149
+ useServerStatus({
150
+ healthUrl: "http://localhost:4000/health",
151
+ pollIntervalMs: 60_000,
152
+ }),
153
+ {wrapper: createWrapper(store)}
154
+ );
155
+
156
+ await act(async () => {
157
+ await new Promise((resolve) => setTimeout(resolve, 100));
158
+ });
159
+
160
+ expect(fetchFn).toHaveBeenCalled();
161
+ expect(store.getState().offline.isOnline).toBe(false);
162
+ unmount();
163
+ });
164
+
165
+ it("does not change status when health succeeds and already online", async () => {
166
+ const fetchFn = mock(async () => new Response("ok", {status: 200}));
167
+ globalThis.fetch = fetchFn as unknown as typeof fetch;
168
+
169
+ const {unmount} = renderHook(
170
+ () =>
171
+ useServerStatus({
172
+ healthUrl: "http://localhost:4000/health",
173
+ pollIntervalMs: 60_000,
174
+ }),
175
+ {wrapper: createWrapper(store)}
176
+ );
177
+
178
+ await act(async () => {
179
+ await new Promise((resolve) => setTimeout(resolve, 100));
180
+ });
181
+
182
+ expect(fetchFn).toHaveBeenCalled();
183
+ expect(store.getState().offline.isOnline).toBe(true);
184
+ unmount();
185
+ });
186
+
187
+ it("accepts custom health URL", async () => {
188
+ const fetchFn = mock(async () => new Response("ok", {status: 200}));
189
+ globalThis.fetch = fetchFn as unknown as typeof fetch;
190
+
191
+ const {unmount} = renderHook(
192
+ () =>
193
+ useServerStatus({
194
+ healthUrl: "http://custom-api:3000/ping",
195
+ pollIntervalMs: 60_000,
196
+ skip: false,
197
+ }),
198
+ {wrapper: createWrapper(store)}
199
+ );
200
+
201
+ await act(async () => {
202
+ await new Promise((resolve) => setTimeout(resolve, 100));
203
+ });
204
+
205
+ expect(fetchFn).toHaveBeenCalled();
206
+ const callUrl = (fetchFn.mock.calls[0] as unknown[])[0] as string;
207
+ expect(callUrl).toBe("http://custom-api:3000/ping");
208
+ unmount();
209
+ });
210
+
211
+ it("dispatches offline when browser fires offline event", async () => {
212
+ const fetchFn = mock(async () => new Response("ok", {status: 200}));
213
+ globalThis.fetch = fetchFn as unknown as typeof fetch;
214
+
215
+ const {unmount} = renderHook(
216
+ () =>
217
+ useServerStatus({
218
+ healthUrl: "http://localhost:4000/health",
219
+ pollIntervalMs: 60_000,
220
+ }),
221
+ {wrapper: createWrapper(store)}
222
+ );
223
+
224
+ await act(async () => {
225
+ await new Promise((resolve) => setTimeout(resolve, 50));
226
+ });
227
+
228
+ expect(store.getState().offline.isOnline).toBe(true);
229
+
230
+ act(() => {
231
+ mockWindow._dispatch("offline");
232
+ });
233
+
234
+ expect(store.getState().offline.isOnline).toBe(false);
235
+ unmount();
236
+ });
237
+
238
+ it("triggers health check when browser fires online event", async () => {
239
+ store.dispatch(setOnlineStatus(false));
240
+ const fetchFn = mock(async () => new Response("ok", {status: 200}));
241
+ globalThis.fetch = fetchFn as unknown as typeof fetch;
242
+
243
+ const {unmount} = renderHook(
244
+ () =>
245
+ useServerStatus({
246
+ healthUrl: "http://localhost:4000/health",
247
+ pollIntervalMs: 60_000,
248
+ }),
249
+ {wrapper: createWrapper(store)}
250
+ );
251
+
252
+ await act(async () => {
253
+ await new Promise((resolve) => setTimeout(resolve, 100));
254
+ });
255
+
256
+ fetchFn.mockClear();
257
+
258
+ await act(async () => {
259
+ mockWindow._dispatch("online");
260
+ await new Promise((resolve) => setTimeout(resolve, 100));
261
+ });
262
+
263
+ expect(fetchFn).toHaveBeenCalled();
264
+ unmount();
265
+ });
266
+ });
@@ -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
+ });
@@ -0,0 +1,226 @@
1
+ import {NOOP_PROVIDER, OpenFeature, TypedInMemoryProvider} from "@openfeature/web-sdk";
2
+ import type {Api} from "@reduxjs/toolkit/query/react";
3
+ import {useCallback, useEffect, useMemo, useState} from "react";
4
+
5
+ /** Shape returned by `GET .../flagConfiguration` (OpenFeature static flag map). */
6
+ export interface TerrenoFlagDefinition {
7
+ defaultVariant: string;
8
+ disabled: boolean;
9
+ variants: Record<string, boolean | string>;
10
+ }
11
+
12
+ export type TerrenoFlagConfiguration = Record<string, TerrenoFlagDefinition>;
13
+
14
+ export interface UseTerrenoFeatureFlagsOptions {
15
+ basePath?: string;
16
+ skip?: boolean;
17
+ /** Current user id — included in RTK cache key and OpenFeature targeting context. */
18
+ userId?: string | null;
19
+ /** Optional socket.io client for live flag refresh. */
20
+ socket?: {
21
+ off: (event: string, handler: (...args: unknown[]) => void) => void;
22
+ on: (event: string, handler: (...args: unknown[]) => void) => void;
23
+ } | null;
24
+ socketEventName?: string;
25
+ /** OpenFeature domain — must match `<OpenFeatureProvider domain>` on the client. */
26
+ domain?: string;
27
+ }
28
+
29
+ export interface UseTerrenoFeatureFlagsResult {
30
+ client: ReturnType<typeof OpenFeature.getClient>;
31
+ error: unknown;
32
+ flags: TerrenoFlagConfiguration;
33
+ isLoading: boolean;
34
+ refetch: () => unknown;
35
+ }
36
+
37
+ // biome-ignore lint/suspicious/noExplicitAny: RTK Query API generic typing is intentionally flexible here.
38
+ type FlagsApi = Api<any, any, any, any>;
39
+
40
+ // biome-ignore lint/suspicious/noExplicitAny: Endpoint hook is injected dynamically by RTK Query.
41
+ type EnhancedTerrenoFlagsApi = FlagsApi & {useTerrenoFlagConfigurationQuery: any};
42
+
43
+ const enhancedApiCache = new WeakMap<FlagsApi, Map<string, EnhancedTerrenoFlagsApi>>();
44
+
45
+ const getEnhancedApi = (api: FlagsApi, basePath: string): EnhancedTerrenoFlagsApi => {
46
+ let byBase = enhancedApiCache.get(api);
47
+ if (!byBase) {
48
+ byBase = new Map();
49
+ enhancedApiCache.set(api, byBase);
50
+ }
51
+ const cached = byBase.get(basePath);
52
+ if (cached) {
53
+ return cached;
54
+ }
55
+ const enhanced = api.injectEndpoints({
56
+ endpoints: (builder) => ({
57
+ terrenoFlagConfiguration: builder.query<TerrenoFlagConfiguration, {cacheKey: string}>({
58
+ providesTags: (_result, _err, arg) => [{id: arg.cacheKey, type: "feature-flags"}],
59
+ query: () => ({
60
+ method: "GET",
61
+ url: `${basePath}/flagConfiguration`,
62
+ }),
63
+ }),
64
+ }),
65
+ overrideExisting: false,
66
+ }) as EnhancedTerrenoFlagsApi;
67
+ byBase.set(basePath, enhanced);
68
+ return enhanced;
69
+ };
70
+
71
+ /** In-flight `useTerrenoFeatureFlags` instances per OpenFeature domain (for last-unmount NOOP cleanup). */
72
+ const terrenoOpenFeatureHookRefCounts = new Map<string, number>();
73
+
74
+ /** Last in-flight `setProviderAndWait` per OpenFeature domain (shared across hook instances). */
75
+ const domainProviderSwitchPromises = new Map<string, Promise<void>>();
76
+
77
+ /**
78
+ * Wires Terreno's bulk `/flagConfiguration` fetch into OpenFeature's
79
+ * {@link TypedInMemoryProvider} for the given domain. Prefer OpenFeature React
80
+ * hooks (`useBooleanFlagValue`, etc.) as children of `<OpenFeatureProvider>`.
81
+ */
82
+ export const useTerrenoFeatureFlags = (
83
+ api: FlagsApi,
84
+ options?: UseTerrenoFeatureFlagsOptions
85
+ ): UseTerrenoFeatureFlagsResult => {
86
+ const basePath = options?.basePath ?? "/feature-flags";
87
+ const domain = options?.domain ?? "feature-flags";
88
+ const userId = options?.userId ?? null;
89
+ const skip = options?.skip === true;
90
+ const socket = options?.socket ?? null;
91
+ const socketEventName = options?.socketEventName ?? "featureFlagsChanged";
92
+
93
+ const enhancedApi = useMemo(() => getEnhancedApi(api, basePath), [api, basePath]);
94
+ const useTerrenoFlagConfigurationQuery = enhancedApi.useTerrenoFlagConfigurationQuery;
95
+ const {
96
+ data,
97
+ error,
98
+ isError,
99
+ isFetching,
100
+ isLoading: isQueryLoading,
101
+ isSuccess,
102
+ refetch,
103
+ } = useTerrenoFlagConfigurationQuery({cacheKey: userId ?? ""}, {skip});
104
+
105
+ const [providerReady, setProviderReady] = useState<boolean>(skip);
106
+ const client = useMemo(() => OpenFeature.getClient(domain), [domain]);
107
+
108
+ useEffect(() => {
109
+ const prev = terrenoOpenFeatureHookRefCounts.get(domain) ?? 0;
110
+ terrenoOpenFeatureHookRefCounts.set(domain, prev + 1);
111
+ return (): void => {
112
+ const current = terrenoOpenFeatureHookRefCounts.get(domain) ?? 0;
113
+ const next = current - 1;
114
+ if (next <= 0) {
115
+ terrenoOpenFeatureHookRefCounts.delete(domain);
116
+ void (async (): Promise<void> => {
117
+ const pending = domainProviderSwitchPromises.get(domain);
118
+ if (pending) {
119
+ await pending;
120
+ }
121
+ await OpenFeature.setProviderAndWait(domain, NOOP_PROVIDER);
122
+ })();
123
+ } else {
124
+ terrenoOpenFeatureHookRefCounts.set(domain, next);
125
+ }
126
+ };
127
+ }, [domain]);
128
+
129
+ useEffect((): void => {
130
+ if (skip) {
131
+ return;
132
+ }
133
+ void OpenFeature.setContext(domain, {targetingKey: userId ?? ""});
134
+ }, [domain, skip, userId]);
135
+
136
+ useEffect((): void => {
137
+ if (skip) {
138
+ setProviderReady(true);
139
+ return;
140
+ }
141
+ setProviderReady(false);
142
+ }, [skip, userId]);
143
+
144
+ useEffect(() => {
145
+ if (skip || !isSuccess || !data) {
146
+ return;
147
+ }
148
+
149
+ let cancelled = false;
150
+
151
+ const applyProvider = async (): Promise<void> => {
152
+ const pending = domainProviderSwitchPromises.get(domain);
153
+ const run = (async (): Promise<void> => {
154
+ if (pending) {
155
+ await pending;
156
+ }
157
+ await OpenFeature.setProviderAndWait(domain, new TypedInMemoryProvider(data));
158
+ })();
159
+ domainProviderSwitchPromises.set(domain, run);
160
+ try {
161
+ await run;
162
+ } finally {
163
+ if (domainProviderSwitchPromises.get(domain) === run) {
164
+ domainProviderSwitchPromises.delete(domain);
165
+ }
166
+ }
167
+ if (!cancelled) {
168
+ setProviderReady(true);
169
+ }
170
+ };
171
+
172
+ void applyProvider();
173
+
174
+ return (): void => {
175
+ cancelled = true;
176
+ };
177
+ }, [data, domain, isSuccess, skip]);
178
+
179
+ useEffect(() => {
180
+ if (!socket) {
181
+ return;
182
+ }
183
+
184
+ const handler = (): void => {
185
+ void refetch();
186
+ };
187
+
188
+ socket.on(socketEventName, handler);
189
+ return (): void => {
190
+ socket.off(socketEventName, handler);
191
+ };
192
+ }, [refetch, socket, socketEventName]);
193
+
194
+ const flags = data ?? {};
195
+
196
+ const isLoading = useMemo((): boolean => {
197
+ if (skip) {
198
+ return false;
199
+ }
200
+ if (isError) {
201
+ return false;
202
+ }
203
+ if (!isSuccess || !data) {
204
+ return true;
205
+ }
206
+ if (isQueryLoading || isFetching) {
207
+ return true;
208
+ }
209
+ if (!providerReady) {
210
+ return true;
211
+ }
212
+ return false;
213
+ }, [data, isError, isFetching, isQueryLoading, isSuccess, providerReady, skip]);
214
+
215
+ const stableRefetch = useCallback((): unknown => {
216
+ return refetch();
217
+ }, [refetch]);
218
+
219
+ return {
220
+ client,
221
+ error,
222
+ flags,
223
+ isLoading,
224
+ refetch: stableRefetch,
225
+ };
226
+ };