diff --git a/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx b/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx index 29204561..29b560f1 100644 --- a/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx +++ b/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx @@ -83,14 +83,12 @@ 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); + // The last request the initial-fetch effect dispatched, as a key of everything that shapes it. + // `fetchFlowMeta` is recreated whenever a dependency changes identity, and several do while + // ThunderID initializes (`isInitialized` flips, `endpoints` is replaced with the resolved + // configuration) without changing the request. Keying on the request skips those repeats, and + // the StrictMode re-mount, so only a real change (e.g. a language switch) fetches again. + const lastRequestKeyRef: RefObject = useRef(null); const initialMetaConsumedRef: RefObject = useRef(false); const fetchFlowMeta: () => Promise = useCallback(async (): Promise => { if (!enabled) { @@ -127,6 +125,27 @@ const FlowMetaProvider: FC> = ({ } }, [enabled, baseUrl, endpoints, applicationId, isInitialized, i18nContext?.currentLanguage, fetchMeta, namespace]); + const fetchMetaRef: RefObject = useRef(fetchMeta); + const fetchMetaVersionRef: RefObject = useRef(0); + if (fetchMetaRef.current !== fetchMeta) { + fetchMetaRef.current = fetchMeta; + fetchMetaVersionRef.current += 1; + } + + // Null while the fetch would be skipped, so the request that follows initialization still runs. + const requestKey: string | null = + enabled && !isInitialized && !applicationId + ? null + : JSON.stringify([ + enabled, + enabled && !fetchMeta ? baseUrl : null, + enabled && !fetchMeta ? resolveResourceEndpoint('flowMeta', {endpoints}) : null, + applicationId ?? null, + namespace ?? null, + i18nContext?.currentLanguage ?? null, + fetchMetaVersionRef.current, + ]); + const switchLanguage: (language: string) => Promise = useCallback( async (language: string): Promise => { if (!enabled) return; @@ -181,27 +200,29 @@ const FlowMetaProvider: FC> = ({ }, [pendingLanguage, i18nContext?.setLanguage]); useEffect(() => { + if (requestKey === null) { + return; + } + if (!initialMetaConsumedRef.current) { 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; + // Later changes to the request (e.g. an explicit language switch) still fetch normally. + lastRequestKeyRef.current = requestKey; 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. + if (lastRequestKeyRef.current === requestKey) { return; } - lastFetchedRef.current = fetchFlowMeta; + lastRequestKeyRef.current = requestKey; fetchFlowMeta(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fetchFlowMeta]); + }, [requestKey]); // When meta loads with i18n translations, inject them into the i18n system. // Meta translations act as the base layer — prop-provided bundles still take precedence. 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..71d6a7e1 --- /dev/null +++ b/packages/react/src/contexts/FlowMeta/__tests__/FlowMetaProvider.test.tsx @@ -0,0 +1,69 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {cleanup, render, waitFor} from '@testing-library/react'; +import {getFlowMeta} from '@thunderid/browser'; +import {ReactElement, StrictMode} from 'react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import I18nContext, {I18nContextValue} from '../../I18n/I18nContext'; +import ThunderIDContext, {ThunderIDContextProps} from '../../ThunderID/ThunderIDContext'; +import FlowMetaProvider from '../FlowMetaProvider'; + +vi.mock('@thunderid/browser', async (importOriginal: () => Promise>) => ({ + ...(await importOriginal()), + getFlowMeta: vi.fn(), +})); + +const i18n = {currentLanguage: 'en-GB', injectBundles: vi.fn(), setLanguage: vi.fn()} as unknown as I18nContextValue; + +const renderProvider = (thunderID: Partial, language = 'en-GB'): ReactElement => ( + + + + + + + + + +); + +beforeEach(() => { + vi.mocked(getFlowMeta).mockResolvedValue({} as Awaited>); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('FlowMetaProvider', () => { + it('fetches once while ThunderID initializes around the same request', async () => { + const {rerender} = render(renderProvider({applicationId: 'app-1', endpoints: undefined, isInitialized: false})); + rerender(renderProvider({applicationId: 'app-1', endpoints: {}, isInitialized: false})); + rerender(renderProvider({applicationId: 'app-1', endpoints: {}, isInitialized: true})); + + await waitFor(() => expect(getFlowMeta).toHaveBeenCalled()); + expect(getFlowMeta).toHaveBeenCalledTimes(1); + expect(getFlowMeta).toHaveBeenCalledWith(expect.objectContaining({id: 'app-1', language: 'en-GB'})); + }); + + it('waits for ThunderID to initialize when there is no application', async () => { + const {rerender} = render(renderProvider({isInitialized: false})); + expect(getFlowMeta).not.toHaveBeenCalled(); + + rerender(renderProvider({isInitialized: true})); + + await waitFor(() => expect(getFlowMeta).toHaveBeenCalledTimes(1)); + }); + + it('fetches again when the language changes', async () => { + const {rerender} = render(renderProvider({applicationId: 'app-1', isInitialized: true})); + await waitFor(() => expect(getFlowMeta).toHaveBeenCalledTimes(1)); + + rerender(renderProvider({applicationId: 'app-1', isInitialized: true}, 'fr-FR')); + + await waitFor(() => expect(getFlowMeta).toHaveBeenCalledTimes(2)); + expect(getFlowMeta).toHaveBeenLastCalledWith(expect.objectContaining({language: 'fr-FR'})); + }); +});