Skip to content

Commit 7341f8e

Browse files
committed
feat: add rehydration support to ReforgeProvider
1 parent f4cfdc7 commit 7341f8e

5 files changed

Lines changed: 74 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
Changelog
22

3+
## 0.0.2 - 2025-10-12
4+
5+
- Support re-hydration of flags via ReforgeProvider
6+
37
## 0.0.1 - 2025-10-01
48

59
- Official patch release

package.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"packageManager": "yarn@4.9.2",
33
"name": "@reforge-com/react",
4-
"version": "0.0.1",
4+
"version": "0.0.2",
55
"description": "Feature Flags & Dynamic Configuration as a Service",
66
"main": "dist/index.cjs",
77
"module": "dist/index.mjs",
@@ -43,10 +43,8 @@
4343
"feature-flags",
4444
"config"
4545
],
46-
"dependencies": {
47-
"@reforge-com/javascript": "^0"
48-
},
4946
"devDependencies": {
47+
"@reforge-com/javascript": "^0",
5048
"@testing-library/jest-dom": "^5.16.5",
5149
"@testing-library/react": "^13.3.0",
5250
"@types/jest": "^28.1.6",
@@ -75,6 +73,7 @@
7573
"typescript": "^4.7.4"
7674
},
7775
"peerDependencies": {
76+
"@reforge-com/javascript": "^0",
7877
"react": "^16 || ^17 || ^18 || ^19"
7978
}
8079
}

src/ReforgeProvider.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export const assignReforgeClient = () => {
147147
export type ReforgeProviderProps = SharedSettings & {
148148
sdkKey: string;
149149
contextAttributes?: Contexts;
150+
initialFlags?: Record<string, unknown>;
150151
};
151152

152153
const getContext = (
@@ -178,6 +179,7 @@ function ReforgeProvider({
178179
// eslint-disable-next-line no-console
179180
console.error(e);
180181
},
182+
initialFlags,
181183
children,
182184
timeout,
183185
endpoints,
@@ -207,6 +209,7 @@ function ReforgeProvider({
207209
// We use this state to pass the loading state to the Provider (updating
208210
// currentLoadingContextKey won't trigger an update)
209211
const [loading, setLoading] = React.useState(true);
212+
const [initialLoad, setInitialLoad] = React.useState(true);
210213
// Here we track the current identity so we can reload our config when it
211214
// changes
212215
const [loadedContextKey, setLoadedContextKey] = React.useState("");
@@ -215,7 +218,22 @@ function ReforgeProvider({
215218

216219
const [context, contextKey] = getContext(contextAttributes, onError);
217220

221+
if (initialFlags && initialLoad) {
222+
reforgeClient.hydrate(initialFlags);
223+
setInitialLoad(false);
224+
setLoadedContextKey(contextKey);
225+
setLoading(false);
226+
mostRecentlyLoadingContextKey.current = contextKey;
227+
228+
if (pollInterval) {
229+
// eslint-disable-next-line no-console
230+
console.warn("Polling is not supported when hydrating flags via initialFlags");
231+
}
232+
}
233+
218234
React.useEffect(() => {
235+
setInitialLoad(false);
236+
219237
if (mostRecentlyLoadingContextKey.current === contextKey) {
220238
return;
221239
}

src/__tests__/ReforgeProvider.test.tsx

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,19 @@ describe("ReforgeProvider", () => {
5151
const renderInProvider = ({
5252
contextAttributes,
5353
onError,
54+
initialFlags,
5455
}: {
5556
contextAttributes?: { [key: string]: Record<string, ContextValue> };
5657
onError?: (err: Error) => void;
58+
initialFlags?: Record<string, unknown>;
5759
}) =>
5860
render(
59-
<ReforgeProvider sdkKey="sdk-key" contextAttributes={contextAttributes} onError={onError}>
61+
<ReforgeProvider
62+
sdkKey="sdk-key"
63+
contextAttributes={contextAttributes}
64+
onError={onError}
65+
initialFlags={initialFlags}
66+
>
6067
<MyComponent />
6168
</ReforgeProvider>
6269
);
@@ -230,6 +237,38 @@ describe("ReforgeProvider", () => {
230237
expect(updatedAlert).toHaveTextContent("UPDATED FROM CONTEXT");
231238
});
232239

240+
it.only("shows pre-hydrated flags without making a request", () => {
241+
const context = { user: { email: "test@example.com" } };
242+
243+
// Mock the fetch response to return nothing
244+
// If this ran, we would end up rendering only default values
245+
// and no secret feature
246+
global.fetch = jest.fn(() =>
247+
Promise.resolve({
248+
ok: true,
249+
json: () => ({ evaluations: {} }),
250+
})
251+
) as jest.Mock;
252+
253+
render(
254+
<ReforgeProvider
255+
sdkKey="sdk-key"
256+
contextAttributes={context}
257+
onError={() => {}}
258+
initialFlags={{ greeting: "My seeded greeting", secretFeature: true }}
259+
>
260+
<MyComponent />
261+
</ReforgeProvider>
262+
);
263+
264+
const alert = screen.queryByRole("alert");
265+
expect(alert).toHaveTextContent("My seeded greeting");
266+
const banner = screen.queryByRole("banner");
267+
expect(banner).toHaveTextContent("Default Subtitle");
268+
const secretFeature = screen.queryByTitle("secret-feature");
269+
expect(secretFeature).toBeInTheDocument();
270+
});
271+
233272
it("allows providing an afterEvaluationCallback", async () => {
234273
const context = { user: { email: "test@example.com" } };
235274

@@ -402,7 +441,7 @@ describe("createReforgeHook functionality with ReforgeProvider", () => {
402441

403442
React.useEffect(() => {
404443
// Force multiple re-renders
405-
if (counter < 3) {
444+
if (counter < 6) {
406445
setTimeout(() => setCounter(counter + 1), 10);
407446
}
408447
}, [counter]);
@@ -430,13 +469,16 @@ describe("createReforgeHook functionality with ReforgeProvider", () => {
430469

431470
// Wait for all re-renders to complete
432471
await waitFor(() => {
433-
expect(screen.getByTestId("hook-result")).toHaveTextContent("(Render count: 3)");
472+
expect(screen.getByTestId("hook-result")).toHaveTextContent("(Render count: 6)");
434473
});
435474

436-
// In ReforgeProvider, constructor may be called twice due to React's strict mode
475+
// In ReforgeProvider, constructor is called:
476+
// - once on initial render
477+
// - once during initialization (set's context key)
478+
// - once for unclear reasons, but unrelated to renders per increased render count in test component
437479
// or the provider's initialization process, which is still valid behavior
438-
expect(constructorSpy).toHaveBeenCalledTimes(2);
480+
expect(constructorSpy).toHaveBeenCalledTimes(3);
439481
// Method is called once on initial render, once during initialization, and three more times for re-renders
440-
expect(methodSpy).toHaveBeenCalledTimes(5);
482+
expect(methodSpy).toHaveBeenCalledTimes(9);
441483
});
442484
});

yarn.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,6 +1124,7 @@ __metadata:
11241124
tsup: "npm:^8.4.0"
11251125
typescript: "npm:^4.7.4"
11261126
peerDependencies:
1127+
"@reforge-com/javascript": ^0
11271128
react: ^16 || ^17 || ^18 || ^19
11281129
languageName: unknown
11291130
linkType: soft

0 commit comments

Comments
 (0)