From 6185c74786cc283b353312a1341f236d653d7a86 Mon Sep 17 00:00:00 2001 From: martmull Date: Mon, 13 Jul 2026 11:34:53 +0200 Subject: [PATCH] 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. --- .../fetchComponentSourceFromNetwork.spec.ts | 22 +++++++++++++++-- .../utils/fetchComponentSourceFromNetwork.ts | 24 ++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts index 13f5012016..5b4733d130 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts @@ -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' }), diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSourceFromNetwork.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSourceFromNetwork.ts index afd8ce74a0..1d32fecfb3 100644 --- a/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSourceFromNetwork.ts +++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSourceFromNetwork.ts @@ -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; }): Promise => { - const response = await fetch(url, { headers }); + const response = await fetch(appendCacheBustQueryParameter(url), { + headers, + }); if (!response.ok) { throw new Error(