Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 36 additions & 15 deletions packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,12 @@ const FlowMetaProvider: FC<PropsWithChildren<FlowMetaProviderProps>> = ({
const [error, setError] = useState<Error | null>(null);
const [pendingLanguage, setPendingLanguage] = useState<string | null>(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<void>) | null> = useRef<(() => Promise<void>) | 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<string | null> = useRef<string | null>(null);
const initialMetaConsumedRef: RefObject<boolean> = useRef(false);
const fetchFlowMeta: () => Promise<void> = useCallback(async (): Promise<void> => {
if (!enabled) {
Expand Down Expand Up @@ -127,6 +125,27 @@ const FlowMetaProvider: FC<PropsWithChildren<FlowMetaProviderProps>> = ({
}
}, [enabled, baseUrl, endpoints, applicationId, isInitialized, i18nContext?.currentLanguage, fetchMeta, namespace]);

const fetchMetaRef: RefObject<FlowMetaProviderProps['fetchMeta'] | null> = useRef(fetchMeta);
const fetchMetaVersionRef: RefObject<number> = 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<void> = useCallback(
async (language: string): Promise<void> => {
if (!enabled) return;
Expand Down Expand Up @@ -181,27 +200,29 @@ const FlowMetaProvider: FC<PropsWithChildren<FlowMetaProviderProps>> = ({
}, [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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>) => ({
...(await importOriginal()),
getFlowMeta: vi.fn(),
}));

const i18n = {currentLanguage: 'en-GB', injectBundles: vi.fn(), setLanguage: vi.fn()} as unknown as I18nContextValue;

const renderProvider = (thunderID: Partial<ThunderIDContextProps>, language = 'en-GB'): ReactElement => (
<StrictMode>
<ThunderIDContext.Provider value={{baseUrl: 'https://localhost:8090', ...thunderID} as ThunderIDContextProps}>
<I18nContext.Provider value={{...i18n, currentLanguage: language}}>
<FlowMetaProvider>
<span />
</FlowMetaProvider>
</I18nContext.Provider>
</ThunderIDContext.Provider>
</StrictMode>
);

beforeEach(() => {
vi.mocked(getFlowMeta).mockResolvedValue({} as Awaited<ReturnType<typeof getFlowMeta>>);
});

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'}));
});
});
Loading