Bypass corrupted cached front-component responses with a cache-bust query parameter (#22854)

## Context

Follow-up to #22672. Users' browsers hold corrupted cached responses for
front-component request URLs from before the fix. #22672 fixed serving
and caching for newly built front components, but the corrupted entries
already sitting in browsers keep being served and need to be bypassed
programmatically.

## What changed

- `fetchComponentSourceFromNetwork` appends a constant `cacheBust=v2`
query parameter to the component request (`GET
/rest/front-components/:id/:cacheKey`). This changes the cache key, so
any corrupted response cached under the old URL is never served again
and the bundle is refetched.
- Existing query parameters on the URL are preserved; if the URL cannot
be parsed, the request falls back to the original URL unchanged.
- The presigned S3 URL from the JSON handoff is left untouched: adding a
query parameter there would invalidate its SigV4 signature.
- The `CacheStorage` layer keeps using the logical component URL as its
key, so its checksum-verified entries and hit behavior are unchanged.

## Test plan

- `fetchComponentSourceFromNetwork.spec.ts`: assertions updated to
expect the cache-busted component URL, plus a new test that existing
query parameters are preserved and one that the presigned fetch stays
unmodified; full renderer suite passes (226 tests).
- `npx nx typecheck twenty-front-component-renderer` and `npx nx lint
twenty-front-component-renderer` pass.
This commit is contained in:
martmull
2026-07-13 11:34:53 +02:00
committed by GitHub
parent 652adc3c03
commit 6185c74786
2 changed files with 43 additions and 3 deletions
@@ -2,6 +2,7 @@ import { fetchComponentSourceFromNetwork } from '@/remote/worker/utils/fetchComp
const COMPONENT_URL =
'https://api.twenty.com/rest/front-components/component-id/checksum-abc.js';
const CACHE_BUSTED_COMPONENT_URL = `${COMPONENT_URL}?cacheBust=v2`;
const PRESIGNED_URL = 'https://s3.example.com/component.js?signed=abc';
type FakeResponseInit = {
@@ -52,7 +53,7 @@ describe('fetchComponentSourceFromNetwork', () => {
});
expect(source).toBe('export default () => {};');
expect(fetchMock).toHaveBeenCalledWith(COMPONENT_URL, {
expect(fetchMock).toHaveBeenCalledWith(CACHE_BUSTED_COMPONENT_URL, {
headers: { Authorization: 'Bearer token' },
});
});
@@ -78,12 +79,29 @@ describe('fetchComponentSourceFromNetwork', () => {
});
expect(source).toBe('presigned bundle source');
expect(fetchMock).toHaveBeenNthCalledWith(1, COMPONENT_URL, {
expect(fetchMock).toHaveBeenNthCalledWith(1, CACHE_BUSTED_COMPONENT_URL, {
headers: { Authorization: 'Bearer token' },
});
expect(fetchMock).toHaveBeenNthCalledWith(2, PRESIGNED_URL);
});
it('preserves existing query parameters when appending the cache bust parameter', async () => {
const fetchMock = jest.fn(async () =>
createFakeResponse({ body: 'export default () => {};' }),
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
await fetchComponentSourceFromNetwork({
url: `${COMPONENT_URL}?token=abc`,
});
expect(fetchMock).toHaveBeenCalledWith(
`${COMPONENT_URL}?token=abc&cacheBust=v2`,
{ headers: undefined },
);
});
it('throws when the initial response is not ok', async () => {
const fetchMock = jest.fn(async () =>
createFakeResponse({ ok: false, status: 404, statusText: 'Not Found' }),
@@ -2,6 +2,26 @@ import { z } from 'zod';
const componentSourceHandoffSchema = z.object({ url: z.url() });
// Changes the HTTP cache key so component responses cached before the
// front-component cache fix are bypassed instead of served corrupted.
const CACHE_BUST_QUERY_PARAMETER_NAME = 'cacheBust';
const CACHE_BUST_QUERY_PARAMETER_VALUE = 'v2';
const appendCacheBustQueryParameter = (url: string): string => {
try {
const parsedUrl = new URL(url);
parsedUrl.searchParams.set(
CACHE_BUST_QUERY_PARAMETER_NAME,
CACHE_BUST_QUERY_PARAMETER_VALUE,
);
return parsedUrl.toString();
} catch {
return url;
}
};
export const fetchComponentSourceFromNetwork = async ({
url,
headers,
@@ -9,7 +29,9 @@ export const fetchComponentSourceFromNetwork = async ({
url: string;
headers?: Record<string, string>;
}): Promise<string> => {
const response = await fetch(url, { headers });
const response = await fetch(appendCacheBustQueryParameter(url), {
headers,
});
if (!response.ok) {
throw new Error(