Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
44 changes: 34 additions & 10 deletions src/frontend/apps/conversations/src/core/config/api/useConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

Expand All @@ -90,17 +114,17 @@ export const getConfig = async (): Promise<ConfigResponse> => {

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<ConfigResponse, APIError, ConfigResponse>({
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,
});
}
Loading