diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a7d418..514e0e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ Changelog +## 0.0.2 - 2025-10-12 + +- Support re-hydration of flags via ReforgeProvider + ## 0.0.1 - 2025-10-01 - Official patch release diff --git a/package.json b/package.json index 991ede9..27e91b9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "packageManager": "yarn@4.9.2", "name": "@reforge-com/react", - "version": "0.0.1", + "version": "0.0.2", "description": "Feature Flags & Dynamic Configuration as a Service", "main": "dist/index.cjs", "module": "dist/index.mjs", @@ -43,10 +43,8 @@ "feature-flags", "config" ], - "dependencies": { - "@reforge-com/javascript": "^0" - }, "devDependencies": { + "@reforge-com/javascript": "^0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.3.0", "@types/jest": "^28.1.6", @@ -75,6 +73,7 @@ "typescript": "^4.7.4" }, "peerDependencies": { + "@reforge-com/javascript": "^0", "react": "^16 || ^17 || ^18 || ^19" } } diff --git a/src/ReforgeProvider.tsx b/src/ReforgeProvider.tsx index b3dcbf5..6ef1564 100644 --- a/src/ReforgeProvider.tsx +++ b/src/ReforgeProvider.tsx @@ -147,6 +147,7 @@ export const assignReforgeClient = () => { export type ReforgeProviderProps = SharedSettings & { sdkKey: string; contextAttributes?: Contexts; + initialFlags?: Record; }; const getContext = ( @@ -178,6 +179,7 @@ function ReforgeProvider({ // eslint-disable-next-line no-console console.error(e); }, + initialFlags, children, timeout, endpoints, @@ -207,6 +209,7 @@ function ReforgeProvider({ // We use this state to pass the loading state to the Provider (updating // currentLoadingContextKey won't trigger an update) const [loading, setLoading] = React.useState(true); + const [initialLoad, setInitialLoad] = React.useState(true); // Here we track the current identity so we can reload our config when it // changes const [loadedContextKey, setLoadedContextKey] = React.useState(""); @@ -215,7 +218,22 @@ function ReforgeProvider({ const [context, contextKey] = getContext(contextAttributes, onError); + if (initialFlags && initialLoad) { + reforgeClient.hydrate(initialFlags); + setInitialLoad(false); + setLoadedContextKey(contextKey); + setLoading(false); + mostRecentlyLoadingContextKey.current = contextKey; + + if (pollInterval) { + // eslint-disable-next-line no-console + console.warn("Polling is not supported when hydrating flags via initialFlags"); + } + } + React.useEffect(() => { + setInitialLoad(false); + if (mostRecentlyLoadingContextKey.current === contextKey) { return; } diff --git a/src/__tests__/ReforgeProvider.test.tsx b/src/__tests__/ReforgeProvider.test.tsx index 2948864..605874d 100644 --- a/src/__tests__/ReforgeProvider.test.tsx +++ b/src/__tests__/ReforgeProvider.test.tsx @@ -51,12 +51,19 @@ describe("ReforgeProvider", () => { const renderInProvider = ({ contextAttributes, onError, + initialFlags, }: { contextAttributes?: { [key: string]: Record }; onError?: (err: Error) => void; + initialFlags?: Record; }) => render( - + ); @@ -230,6 +237,38 @@ describe("ReforgeProvider", () => { expect(updatedAlert).toHaveTextContent("UPDATED FROM CONTEXT"); }); + it.only("shows pre-hydrated flags without making a request", () => { + const context = { user: { email: "test@example.com" } }; + + // Mock the fetch response to return nothing + // If this ran, we would end up rendering only default values + // and no secret feature + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => ({ evaluations: {} }), + }) + ) as jest.Mock; + + render( + {}} + initialFlags={{ greeting: "My seeded greeting", secretFeature: true }} + > + + + ); + + const alert = screen.queryByRole("alert"); + expect(alert).toHaveTextContent("My seeded greeting"); + const banner = screen.queryByRole("banner"); + expect(banner).toHaveTextContent("Default Subtitle"); + const secretFeature = screen.queryByTitle("secret-feature"); + expect(secretFeature).toBeInTheDocument(); + }); + it("allows providing an afterEvaluationCallback", async () => { const context = { user: { email: "test@example.com" } }; @@ -402,7 +441,7 @@ describe("createReforgeHook functionality with ReforgeProvider", () => { React.useEffect(() => { // Force multiple re-renders - if (counter < 3) { + if (counter < 6) { setTimeout(() => setCounter(counter + 1), 10); } }, [counter]); @@ -430,13 +469,16 @@ describe("createReforgeHook functionality with ReforgeProvider", () => { // Wait for all re-renders to complete await waitFor(() => { - expect(screen.getByTestId("hook-result")).toHaveTextContent("(Render count: 3)"); + expect(screen.getByTestId("hook-result")).toHaveTextContent("(Render count: 6)"); }); - // In ReforgeProvider, constructor may be called twice due to React's strict mode + // In ReforgeProvider, constructor is called: + // - once on initial render + // - once during initialization (set's context key) + // - once for unclear reasons, but unrelated to renders per increased render count in test component // or the provider's initialization process, which is still valid behavior - expect(constructorSpy).toHaveBeenCalledTimes(2); + expect(constructorSpy).toHaveBeenCalledTimes(3); // Method is called once on initial render, once during initialization, and three more times for re-renders - expect(methodSpy).toHaveBeenCalledTimes(5); + expect(methodSpy).toHaveBeenCalledTimes(9); }); }); diff --git a/yarn.lock b/yarn.lock index 5847c6a..9fc0e02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1124,6 +1124,7 @@ __metadata: tsup: "npm:^8.4.0" typescript: "npm:^4.7.4" peerDependencies: + "@reforge-com/javascript": ^0 react: ^16 || ^17 || ^18 || ^19 languageName: unknown linkType: soft