From 477b605a94f0e3d1f845cd3a64952596dfbab888 Mon Sep 17 00:00:00 2001 From: Brion Date: Wed, 12 Aug 2026 21:22:05 +0530 Subject: [PATCH] Dedupe FlowMetaProvider fetches by request params instead of callback identity Redundant meta fetches happened whenever endpoints or the i18n context were recreated with equivalent content across renders: fetchFlowMeta's identity changed even though the request itself hadn't, and the previous guard only caught same-reference re-fires. Keys the dedup on the resolved baseUrl/url/id/language instead, so equivalent-content re-renders are skipped while a genuine language switch still fetches. --- .../contexts/FlowMeta/FlowMetaProvider.tsx | 59 +++++--- .../__tests__/FlowMetaProvider.test.tsx | 131 ++++++++++++++++++ 2 files changed, 170 insertions(+), 20 deletions(-) create mode 100644 packages/react/src/contexts/FlowMeta/__tests__/FlowMetaProvider.test.tsx diff --git a/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx b/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx index 127d422f..267f6db9 100644 --- a/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx +++ b/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx @@ -67,15 +67,27 @@ const FlowMetaProvider: FC> = ({ const [error, setError] = useState(null); const [pendingLanguage, setPendingLanguage] = useState(null); - // Track the last fetchFlowMeta reference that was actually dispatched. - // This prevents two classes of double-fetch: - // 1. React StrictMode simulates unmount+remount — the re-mount fires the - // effect again with the same fetchFlowMeta reference; without this guard - // the else-branch would issue a redundant second network request. - // 2. Rapid dependency changes (e.g. baseUrl stabilising) that produce two - // effect firings before the first fetch completes. - const lastFetchedRef: RefObject<(() => Promise) | null> = useRef<(() => Promise) | null>(null); + // Track the request actually dispatched (and in flight), keyed by its real parameters rather + // than the fetchFlowMeta reference. This prevents redundant fetches for the same request: + // 1. React StrictMode simulates unmount+remount — the re-mount fires the effect again with an + // unchanged request; without this guard the else-branch would issue a redundant second + // network request. + // 2. Rapid dependency changes that don't change the request itself — e.g. `endpoints` or + // `i18nContext` being a new object with equivalent content — recreate fetchFlowMeta's + // reference on every render. Keying on identity alone (rather than the resolved URL/id/ + // language) would treat each of those as a distinct request and refire the same fetch. + const lastRequestKeyRef: RefObject = useRef(null); + const inFlightRequestKeyRef: RefObject = useRef(null); const initialMetaConsumedRef: RefObject = useRef(false); + + const getRequestKey = useCallback( + (language?: string): string => { + const url = resolveResourceEndpoint('flowMeta', {endpoints}); + return JSON.stringify([baseUrl, url, applicationId ?? null, language ?? i18nContext?.currentLanguage ?? null]); + }, + [baseUrl, endpoints, applicationId, i18nContext?.currentLanguage], + ); + const fetchFlowMeta: () => Promise = useCallback(async (): Promise => { if (!enabled) { setMeta(null); @@ -90,6 +102,13 @@ const FlowMetaProvider: FC> = ({ return; } + const requestKey = getRequestKey(); + + if (inFlightRequestKeyRef.current === requestKey || lastRequestKeyRef.current === requestKey) { + return; + } + + inFlightRequestKeyRef.current = requestKey; setIsLoading(true); setError(null); @@ -101,12 +120,14 @@ const FlowMetaProvider: FC> = ({ language: i18nContext?.currentLanguage, }); setMeta(result); + lastRequestKeyRef.current = requestKey; } catch (err: unknown) { setError(err instanceof Error ? err : new Error(String(err))); } finally { + inFlightRequestKeyRef.current = null; setIsLoading(false); } - }, [enabled, baseUrl, endpoints, applicationId, isInitialized, i18nContext?.currentLanguage]); + }, [enabled, baseUrl, endpoints, applicationId, isInitialized, i18nContext?.currentLanguage, getRequestKey]); const switchLanguage: (language: string) => Promise = useCallback( async (language: string): Promise => { @@ -139,13 +160,16 @@ const FlowMetaProvider: FC> = ({ // is committed before I18nProvider's setLanguage checks mergedBundles. setPendingLanguage(language); setMeta(result); + // Record this as the last-fetched request so fetchFlowMeta doesn't refetch once + // i18nContext.currentLanguage catches up to the language just switched to. + lastRequestKeyRef.current = getRequestKey(language); } catch (err: unknown) { setError(err instanceof Error ? err : new Error(String(err))); } finally { setIsLoading(false); } }, - [enabled, baseUrl, endpoints, applicationId, i18nContext], + [enabled, baseUrl, endpoints, applicationId, i18nContext, getRequestKey], ); // After injectBundles + setPendingLanguage are batched and committed, this @@ -163,20 +187,15 @@ const FlowMetaProvider: FC> = ({ initialMetaConsumedRef.current = true; if (initialMeta) { - // Seeded from SSR (or another caller) — skip the redundant first client-side fetch. - // Later dependency changes (e.g. an explicit language switch) still fetch normally. - lastFetchedRef.current = fetchFlowMeta; + // Seeded from SSR (or another caller) — record its request key so a later effect firing + // for the same request (e.g. a StrictMode re-mount) skips the redundant first + // client-side fetch. Later dependency changes (e.g. an explicit language switch) still + // fetch normally, since their request key differs. + lastRequestKeyRef.current = getRequestKey(); return; } } - if (lastFetchedRef.current === fetchFlowMeta) { - // Same reference as the last dispatch — this is a StrictMode re-mount - // or an effect re-fire with unchanged deps. Skip to avoid a duplicate fetch. - return; - } - - lastFetchedRef.current = fetchFlowMeta; fetchFlowMeta(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [fetchFlowMeta]); diff --git a/packages/react/src/contexts/FlowMeta/__tests__/FlowMetaProvider.test.tsx b/packages/react/src/contexts/FlowMeta/__tests__/FlowMetaProvider.test.tsx new file mode 100644 index 00000000..db67d830 --- /dev/null +++ b/packages/react/src/contexts/FlowMeta/__tests__/FlowMetaProvider.test.tsx @@ -0,0 +1,131 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {cleanup, render, waitFor} from '@testing-library/react'; +import {ReactNode} from 'react'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import I18nContext, {I18nContextValue} from '../../I18n/I18nContext'; +import ThunderIDContext, {ThunderIDContextProps} from '../../ThunderID/ThunderIDContext'; +import FlowMetaProvider from '../FlowMetaProvider'; + +const mockGetFlowMeta = vi.fn(); + +vi.mock('@thunderid/browser', async (importOriginal) => ({ + ...(await importOriginal()), + getFlowMeta: (...args: unknown[]): unknown => mockGetFlowMeta(...args), +})); + +function createThunderIDContext(overrides: Partial = {}): ThunderIDContextProps { + return { + applicationId: 'app-id', + baseUrl: 'https://localhost:8090', + endpoints: {}, + isInitialized: true, + ...overrides, + } as unknown as ThunderIDContextProps; +} + +function createI18nContext(overrides: Partial = {}): I18nContextValue { + return { + bundles: {}, + currentLanguage: 'en-US', + fallbackLanguage: 'en-US', + injectBundles: vi.fn(), + setLanguage: vi.fn(), + t: (key: string) => key, + ...overrides, + }; +} + +function Providers({ + thunderID, + i18n, + children, +}: { + thunderID: ThunderIDContextProps; + i18n: I18nContextValue; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('FlowMetaProvider', () => { + it('fetches flow meta once on mount', async () => { + mockGetFlowMeta.mockResolvedValue({}); + + render( + + content + , + ); + + await waitFor(() => { + expect(mockGetFlowMeta).toHaveBeenCalledTimes(1); + }); + }); + + it('does not refetch when re-rendered with a new-but-equivalent endpoints object', async () => { + mockGetFlowMeta.mockResolvedValue({}); + + const {rerender} = render( + + content + , + ); + + await waitFor(() => { + expect(mockGetFlowMeta).toHaveBeenCalledTimes(1); + }); + + // A fresh `endpoints` object with the same content: this is what a re-render of a consumer + // that doesn't memoize its config (e.g. deep-merging a config object on every render) would + // pass down, and previously caused a spurious refetch. + rerender( + + content + , + ); + rerender( + + content + , + ); + + await waitFor(() => { + expect(mockGetFlowMeta).toHaveBeenCalledTimes(1); + }); + }); + + it('refetches when the language actually changes', async () => { + mockGetFlowMeta.mockResolvedValue({}); + + const {rerender} = render( + + content + , + ); + + await waitFor(() => { + expect(mockGetFlowMeta).toHaveBeenCalledTimes(1); + }); + + rerender( + + content + , + ); + + await waitFor(() => { + expect(mockGetFlowMeta).toHaveBeenCalledTimes(2); + }); + }); +});