From 3c35784f97b76c751dfcb988677c6185a0a4b3b0 Mon Sep 17 00:00:00 2001 From: Laurent Paoletti Date: Mon, 27 Jul 2026 17:21:59 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F(front)=20read=20the=20cached?= =?UTF-8?q?=20config=20once=20per=20page=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Laurent Paoletti --- CHANGELOG.md | 6 ++ .../config/api/__tests__/useConfig.test.tsx | 58 +++++++++++++++++++ .../src/core/config/api/useConfig.tsx | 44 ++++++++++---- 3 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 src/frontend/apps/conversations/src/core/config/api/__tests__/useConfig.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 89f4fc75..ce1e179e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- ⚡️(front) read the cached config once per page load instead of on every render + ## [0.0.20] - 2026-07-23 ### Added diff --git a/src/frontend/apps/conversations/src/core/config/api/__tests__/useConfig.test.tsx b/src/frontend/apps/conversations/src/core/config/api/__tests__/useConfig.test.tsx new file mode 100644 index 00000000..13cc248c --- /dev/null +++ b/src/frontend/apps/conversations/src/core/config/api/__tests__/useConfig.test.tsx @@ -0,0 +1,58 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import fetchMock from 'fetch-mock'; + +import { AppWrapper } from '@/tests/utils'; + +import { useConfig } from '../useConfig'; + +const API_BASE = 'http://test.jest/api/v1.0/'; +// Kept private by the hook module, repeated here so the test can watch reads. +const LOCAL_STORAGE_KEY = 'conversations_config'; + +const CACHED_CONFIG = { + ACTIVATION_REQUIRED: false, + ENVIRONMENT: 'test', + FEATURE_FLAGS: {}, + LANGUAGES: [['en-us', 'English']], + LANGUAGE_CODE: 'en-us', +}; + +describe('useConfig', () => { + beforeEach(() => { + fetchMock.restore(); + }); + + // The config is read by ~19 call sites, several of which render continuously + // while a response streams. Reading and parsing it per render put a + // synchronous localStorage hit and a JSON parse of the whole payload on the + // main thread every time; it only ever seeds the query cache. + it('reads the cached config from storage once, not on every render', async () => { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(CACHED_CONFIG)); + fetchMock.get(`${API_BASE}config/`, { status: 200, body: CACHED_CONFIG }); + + const getItem = jest.spyOn(Storage.prototype, 'getItem'); + + // Several consumers per render, as in the real component tree. + const { result, rerender } = renderHook( + () => [useConfig(), useConfig(), useConfig()], + { wrapper: AppWrapper }, + ); + + await waitFor(() => expect(result.current[0].isFetching).toBe(false)); + + rerender(); + rerender(); + rerender(); + + const configReads = getItem.mock.calls.filter( + ([key]) => key === LOCAL_STORAGE_KEY, + ); + expect(configReads).toHaveLength(1); + + // The cached payload still seeds the query, so consumers keep rendering + // against it instead of waiting for the request. + expect(result.current[0].data!.LANGUAGE_CODE).toBe('en-us'); + + getItem.mockRestore(); + }); +}); diff --git a/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx b/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx index 895a239d..6cced820 100644 --- a/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx @@ -61,17 +61,41 @@ export interface ConfigResponse { } const LOCAL_STORAGE_KEY = 'conversations_config'; +const ONE_HOUR = 1000 * 60 * 60; +const FIVE_MINUTES = 1000 * 60 * 5; + +// Read and parsed once per page load instead of on every render. `useConfig` +// has ~19 call sites, several of them in components that render continuously +// while a response streams, and both the localStorage read and the parse are +// synchronous main-thread work over a payload that carries the whole +// translation bundle. The result only ever seeds the query cache, so re-parsing +// it on later renders was pure waste. +let cachedConfig: ConfigResponse | undefined; +let hasReadCachedConfig = false; function getCachedConfig() { + if (hasReadCachedConfig) { + return cachedConfig; + } + hasReadCachedConfig = true; + try { const jsonString = localStorage.getItem(LOCAL_STORAGE_KEY); - return jsonString ? (JSON.parse(jsonString) as ConfigResponse) : undefined; + cachedConfig = jsonString + ? (JSON.parse(jsonString) as ConfigResponse) + : undefined; } catch { - return undefined; + cachedConfig = undefined; } + + return cachedConfig; } function setCachedConfig(config: ConfigResponse) { + // Keep the in-memory copy in step, so a query cache reset re-seeds from the + // freshest config rather than the one read when the page loaded. + cachedConfig = config; + hasReadCachedConfig = true; localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(config)); } @@ -90,17 +114,17 @@ export const getConfig = async (): Promise => { export const KEY_CONFIG = 'config'; -export function useConfig() { - const cachedData = getCachedConfig(); - const oneHour = 1000 * 60 * 60; - const fiveMinutes = 1000 * 60 * 5; +// Force initial data to be considered stale. Any timestamp at least staleTime +// old does that, so computing it once when the module loads keeps it true. +const INITIAL_DATA_UPDATED_AT = Date.now() - ONE_HOUR; +export function useConfig() { return useQuery({ queryKey: [KEY_CONFIG], queryFn: () => getConfig(), - initialData: cachedData, - staleTime: oneHour, - initialDataUpdatedAt: Date.now() - oneHour, // Force initial data to be considered stale - refetchInterval: fiveMinutes, + initialData: getCachedConfig(), + staleTime: ONE_HOUR, + initialDataUpdatedAt: INITIAL_DATA_UPDATED_AT, + refetchInterval: FIVE_MINUTES, }); }