From 17d34a6fe3dcfcea992d93562cd992c60245449f Mon Sep 17 00:00:00 2001
From: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Date: Thu, 16 Jul 2026 17:49:44 +0200
Subject: [PATCH] `[Front-comp-renderer]` Host `componentSource` caching
(#22958)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Context
The front component source cache introduced in the sandbox refactor was
a silent no-op: it ran inside the sandboxed worker (opaque-origin
`allow-scripts` iframe), where the `caches` global does not exist. Every
render re-fetched the component JS from the network — nothing ever
appeared in Cache Storage.
## Change
Moves component source resolution and caching from the worker to the
host, where Cache Storage works:
- `fetchComponentSource`, `fetchComponentSourceFromNetwork`,
`frontComponentCacheStorageService` and
`extractComponentChecksumFromUrl` relocated from `remote/worker/utils/`
to `host/utils/` (`buildAuthorizationHeadersFromAccessToken` to shared
`utils/`, still used by the worker for SDK module fetches)
- `FrontComponentWorkerEffect` resolves the source before
`thread.imports.render(...)` (with a cancellation guard) and passes
`componentSource` in the render payload
- `loadFrontComponentModule` no longer fetches: it keeps only
sandbox-side work (SDK import rewrite, blob URL creation, `import()`)
- New: stale-entry eviction — writing a new checksummed entry deletes
older entries of the same `front-components/{id}/` prefix
## Security invariant
The host only fetches, hashes and caches the source string — it never
executes it. Execution stays exclusively in the opaque-origin worker via
blob URL import. SHA-256 checksum verification is kept on both cache
read (poisoned-entry guard: any same-origin code can write to Cache
Storage) and cache write.
## Out of scope
SDK client module caching — follow-up tracked in
[twentyhq/core-team-issues#2688](https://github.com/twentyhq/core-team-issues/issues/2688),
requires content-addressed URLs (server-side checksum at SDK generation
time, exposed via GraphQL and embedded in the `/rest/sdk-client/...`
URL), then reuses this same host-side cache path.
---
.../__tests__/fetchComponentSource.spec.ts | 46 ++++++++++++++++-
.../fetchComponentSourceFromNetwork.spec.ts | 10 ++--
.../utils/extractComponentChecksumFromUrl.ts | 0
.../utils/fetchComponentSource.ts | 10 ++--
.../utils/fetchComponentSourceFromNetwork.ts | 5 +-
.../frontComponentCacheStorageService.ts | 25 ++++++++++
.../components/FrontComponentWorkerEffect.tsx | 49 ++++++++++++++-----
.../__tests__/renderFrontComponent.test.ts | 1 +
.../worker/utils/loadFrontComponentModule.ts | 12 ++---
.../worker/utils/renderFrontComponent.ts | 2 +-
.../src/types/HostToWorkerRenderContext.ts | 1 +
...uthorizationHeadersFromAccessToken.test.ts | 0
...uildAuthorizationHeadersFromAccessToken.ts | 0
13 files changed, 129 insertions(+), 32 deletions(-)
rename packages/twenty-front-component-renderer/src/{remote/worker => host}/utils/__tests__/fetchComponentSource.spec.ts (83%)
rename packages/twenty-front-component-renderer/src/{remote/worker => host}/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts (93%)
rename packages/twenty-front-component-renderer/src/{remote/worker => host}/utils/extractComponentChecksumFromUrl.ts (100%)
rename packages/twenty-front-component-renderer/src/{remote/worker => host}/utils/fetchComponentSource.ts (77%)
rename packages/twenty-front-component-renderer/src/{remote/worker => host}/utils/fetchComponentSourceFromNetwork.ts (93%)
rename packages/twenty-front-component-renderer/src/{remote/worker => host}/utils/frontComponentCacheStorageService.ts (73%)
rename packages/twenty-front-component-renderer/src/{remote/worker => }/utils/__tests__/buildAuthorizationHeadersFromAccessToken.test.ts (100%)
rename packages/twenty-front-component-renderer/src/{remote/worker => }/utils/buildAuthorizationHeadersFromAccessToken.ts (100%)
diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSource.spec.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/fetchComponentSource.spec.ts
similarity index 83%
rename from packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSource.spec.ts
rename to packages/twenty-front-component-renderer/src/host/utils/__tests__/fetchComponentSource.spec.ts
index 66ca456345..303da49c41 100644
--- a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSource.spec.ts
+++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/fetchComponentSource.spec.ts
@@ -1,7 +1,7 @@
import { createHash, webcrypto } from 'node:crypto';
import { TextEncoder as NodeTextEncoder } from 'node:util';
-import { fetchComponentSource } from '@/remote/worker/utils/fetchComponentSource';
+import { fetchComponentSource } from '@/host/utils/fetchComponentSource';
const COMPONENT_SOURCE = 'export default () => {};';
@@ -46,7 +46,13 @@ class FakeCache {
},
);
- delete = jest.fn(async (key: string) => this.store.delete(key));
+ delete = jest.fn(async (key: string | { url: string }) =>
+ this.store.delete(typeof key === 'string' ? key : key.url),
+ );
+
+ keys = jest.fn(async () =>
+ Array.from(this.store.keys()).map((key) => ({ url: key })),
+ );
}
const isDefinedString = (value: string | undefined): value is string =>
@@ -120,6 +126,42 @@ describe('fetchComponentSource', () => {
expect(cache.put.mock.calls[0][0]).toBe(FINGERPRINTED_URL);
});
+ it('evicts stale entries of the same component when caching a new build', async () => {
+ const cache = new FakeCache();
+
+ const staleUrl = buildFingerprintedUrl(
+ computeSha256Hex('previous build output'),
+ );
+ const otherComponentUrl =
+ 'https://api.twenty.com/rest/front-components/other-component-id/0000000000000000000000000000000000000000000000000000000000000000.js';
+
+ await cache.put(staleUrl, {
+ text: async () => 'previous build output',
+ });
+ await cache.put(otherComponentUrl, {
+ text: async () => 'other component source',
+ });
+ cache.put.mockClear();
+
+ setupCaches(cache);
+
+ const fetchMock = jest.fn(async () =>
+ createFakeJsResponse(COMPONENT_SOURCE),
+ );
+
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
+
+ const source = await fetchComponentSource({ url: FINGERPRINTED_URL });
+
+ // Eviction is fire-and-forget; flush pending promise callbacks
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(source).toBe(COMPONENT_SOURCE);
+ expect(cache.delete).toHaveBeenCalledWith({ url: staleUrl });
+ expect(cache.delete).not.toHaveBeenCalledWith({ url: otherComponentUrl });
+ expect(cache.delete).not.toHaveBeenCalledWith({ url: FINGERPRINTED_URL });
+ });
+
it('serves a verified cache hit without hitting the network', async () => {
const cache = new FakeCache();
diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts
similarity index 93%
rename from packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts
rename to packages/twenty-front-component-renderer/src/host/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts
index 5b4733d130..055e9a339f 100644
--- a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts
+++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/fetchComponentSourceFromNetwork.spec.ts
@@ -1,4 +1,4 @@
-import { fetchComponentSourceFromNetwork } from '@/remote/worker/utils/fetchComponentSourceFromNetwork';
+import { fetchComponentSourceFromNetwork } from '@/host/utils/fetchComponentSourceFromNetwork';
const COMPONENT_URL =
'https://api.twenty.com/rest/front-components/component-id/checksum-abc.js';
@@ -55,6 +55,7 @@ describe('fetchComponentSourceFromNetwork', () => {
expect(source).toBe('export default () => {};');
expect(fetchMock).toHaveBeenCalledWith(CACHE_BUSTED_COMPONENT_URL, {
headers: { Authorization: 'Bearer token' },
+ credentials: 'omit',
});
});
@@ -81,8 +82,11 @@ describe('fetchComponentSourceFromNetwork', () => {
expect(source).toBe('presigned bundle source');
expect(fetchMock).toHaveBeenNthCalledWith(1, CACHE_BUSTED_COMPONENT_URL, {
headers: { Authorization: 'Bearer token' },
+ credentials: 'omit',
+ });
+ expect(fetchMock).toHaveBeenNthCalledWith(2, PRESIGNED_URL, {
+ credentials: 'omit',
});
- expect(fetchMock).toHaveBeenNthCalledWith(2, PRESIGNED_URL);
});
it('preserves existing query parameters when appending the cache bust parameter', async () => {
@@ -98,7 +102,7 @@ describe('fetchComponentSourceFromNetwork', () => {
expect(fetchMock).toHaveBeenCalledWith(
`${COMPONENT_URL}?token=abc&cacheBust=v2`,
- { headers: undefined },
+ { headers: undefined, credentials: 'omit' },
);
});
diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/extractComponentChecksumFromUrl.ts b/packages/twenty-front-component-renderer/src/host/utils/extractComponentChecksumFromUrl.ts
similarity index 100%
rename from packages/twenty-front-component-renderer/src/remote/worker/utils/extractComponentChecksumFromUrl.ts
rename to packages/twenty-front-component-renderer/src/host/utils/extractComponentChecksumFromUrl.ts
diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSource.ts b/packages/twenty-front-component-renderer/src/host/utils/fetchComponentSource.ts
similarity index 77%
rename from packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSource.ts
rename to packages/twenty-front-component-renderer/src/host/utils/fetchComponentSource.ts
index a2345c11d9..314696aa01 100644
--- a/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSource.ts
+++ b/packages/twenty-front-component-renderer/src/host/utils/fetchComponentSource.ts
@@ -1,8 +1,8 @@
import { isDefined } from 'twenty-shared/utils';
-import { extractComponentChecksumFromUrl } from '@/remote/worker/utils/extractComponentChecksumFromUrl';
-import { fetchComponentSourceFromNetwork } from '@/remote/worker/utils/fetchComponentSourceFromNetwork';
-import { frontComponentCacheStorageService } from '@/remote/worker/utils/frontComponentCacheStorageService';
+import { extractComponentChecksumFromUrl } from '@/host/utils/extractComponentChecksumFromUrl';
+import { fetchComponentSourceFromNetwork } from '@/host/utils/fetchComponentSourceFromNetwork';
+import { frontComponentCacheStorageService } from '@/host/utils/frontComponentCacheStorageService';
export const fetchComponentSource = async ({
url,
@@ -47,6 +47,10 @@ export const fetchComponentSource = async ({
if (sourceChecksum === expectedChecksum) {
frontComponentCacheStorageService.write({ cache, url, source });
+ frontComponentCacheStorageService.evictStaleEntriesForComponent({
+ cache,
+ url,
+ });
}
}
diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSourceFromNetwork.ts b/packages/twenty-front-component-renderer/src/host/utils/fetchComponentSourceFromNetwork.ts
similarity index 93%
rename from packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSourceFromNetwork.ts
rename to packages/twenty-front-component-renderer/src/host/utils/fetchComponentSourceFromNetwork.ts
index 1d32fecfb3..e90775efc1 100644
--- a/packages/twenty-front-component-renderer/src/remote/worker/utils/fetchComponentSourceFromNetwork.ts
+++ b/packages/twenty-front-component-renderer/src/host/utils/fetchComponentSourceFromNetwork.ts
@@ -31,6 +31,7 @@ export const fetchComponentSourceFromNetwork = async ({
}): Promise => {
const response = await fetch(appendCacheBustQueryParameter(url), {
headers,
+ credentials: 'omit',
});
if (!response.ok) {
@@ -51,7 +52,9 @@ export const fetchComponentSourceFromNetwork = async ({
throw new Error(`Invalid component source handoff response from ${url}`);
}
- const presignedResponse = await fetch(handoff.data.url);
+ const presignedResponse = await fetch(handoff.data.url, {
+ credentials: 'omit',
+ });
if (!presignedResponse.ok) {
throw new Error(
diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/frontComponentCacheStorageService.ts b/packages/twenty-front-component-renderer/src/host/utils/frontComponentCacheStorageService.ts
similarity index 73%
rename from packages/twenty-front-component-renderer/src/remote/worker/utils/frontComponentCacheStorageService.ts
rename to packages/twenty-front-component-renderer/src/host/utils/frontComponentCacheStorageService.ts
index 242e976557..cf83e4d158 100644
--- a/packages/twenty-front-component-renderer/src/remote/worker/utils/frontComponentCacheStorageService.ts
+++ b/packages/twenty-front-component-renderer/src/host/utils/frontComponentCacheStorageService.ts
@@ -78,4 +78,29 @@ export const frontComponentCacheStorageService = {
delete: ({ cache, url }: { cache: Cache; url: string }): void => {
cache.delete(url).catch(() => undefined);
},
+
+ evictStaleEntriesForComponent: ({
+ cache,
+ url,
+ }: {
+ cache: Cache;
+ url: string;
+ }): void => {
+ const componentUrlPrefix = url.slice(0, url.lastIndexOf('/') + 1);
+
+ cache
+ .keys()
+ .then((cachedRequests) => {
+ const staleRequests = cachedRequests.filter(
+ (cachedRequest) =>
+ cachedRequest.url !== url &&
+ cachedRequest.url.startsWith(componentUrlPrefix),
+ );
+
+ for (const staleRequest of staleRequests) {
+ cache.delete(staleRequest).catch(() => undefined);
+ }
+ })
+ .catch(() => undefined);
+ },
};
diff --git a/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx
index 18c71c28ae..1b6d732d36 100644
--- a/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx
+++ b/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx
@@ -5,11 +5,13 @@ import { useEffect, useRef } from 'react';
import { buildHostFetchPolicyFromFrontComponentUrls } from '@/host/utils/buildHostFetchPolicyFromFrontComponentUrls';
import { createFrontComponentHostThread } from '@/host/utils/createFrontComponentHostThread';
import { createHostFetchEnforcingPolicy } from '@/host/utils/createHostFetchEnforcingPolicy';
+import { fetchComponentSource } from '@/host/utils/fetchComponentSource';
import { FRONT_COMPONENT_SANDBOX_DOCUMENT } from '@/remote/sandbox/generated/frontComponentSandboxDocument';
import { createFrontComponentSandboxIframe } from '@/remote/sandbox/utils/createFrontComponentSandboxIframe';
import { createFrontComponentSandboxMessageHandler } from '@/remote/sandbox/utils/createFrontComponentSandboxMessageHandler';
import { type FrontComponentThread } from '@/types/FrontComponentThread';
import { type SdkClientUrls } from '@/types/SdkClientUrls';
+import { buildAuthorizationHeadersFromAccessToken } from '@/utils/buildAuthorizationHeadersFromAccessToken';
type FrontComponentWorkerEffectProps = {
componentUrl: string;
@@ -71,24 +73,45 @@ export const FrontComponentWorkerEffect = ({
setThread(thread);
- thread.imports
- .render(newReceiver.connection, {
- componentUrl,
- applicationAccessToken,
- apiUrl,
- functionsBaseUrl,
- sdkClientUrls,
- hostFetchOrigins: hostFetchPolicy.allowedOrigins,
- applicationVariables,
- })
- .catch((error: Error) => {
- setError(error);
- });
+ let isCancelled = false;
+
+ const resolveComponentSourceAndRender = async () => {
+ try {
+ const componentSource = await fetchComponentSource({
+ url: componentUrl,
+ headers: buildAuthorizationHeadersFromAccessToken(
+ applicationAccessToken,
+ ),
+ });
+
+ if (isCancelled) {
+ return;
+ }
+
+ await thread.imports.render(newReceiver.connection, {
+ componentUrl,
+ componentSource,
+ applicationAccessToken,
+ apiUrl,
+ functionsBaseUrl,
+ sdkClientUrls,
+ hostFetchOrigins: hostFetchPolicy.allowedOrigins,
+ applicationVariables,
+ });
+ } catch (error) {
+ if (!isCancelled) {
+ setError(error instanceof Error ? error : new Error(String(error)));
+ }
+ }
+ };
+
+ resolveComponentSourceAndRender();
setReceiver(newReceiver);
isInitializedRef.current = true;
return () => {
+ isCancelled = true;
window.removeEventListener('message', handleSandboxMessage);
setThread(null);
channel.port1.close();
diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/renderFrontComponent.test.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/renderFrontComponent.test.ts
index 04f16e2315..6f21166eca 100644
--- a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/renderFrontComponent.test.ts
+++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/renderFrontComponent.test.ts
@@ -8,6 +8,7 @@ describe('renderFrontComponent', () => {
connection: {} as RemoteConnection,
renderContext: {
componentUrl: 'https://api.twenty.test/rest/front-components/id',
+ componentSource: 'export default () => {};',
},
hostFetch: null,
}),
diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/loadFrontComponentModule.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/loadFrontComponentModule.ts
index cf086f55d4..b00ad1bfd4 100644
--- a/packages/twenty-front-component-renderer/src/remote/worker/utils/loadFrontComponentModule.ts
+++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/loadFrontComponentModule.ts
@@ -1,16 +1,15 @@
import { isDefined } from 'twenty-shared/utils';
-import { buildAuthorizationHeadersFromAccessToken } from '@/remote/worker/utils/buildAuthorizationHeadersFromAccessToken';
import { containsSdkClientImportSpecifier } from '@/remote/worker/utils/containsSdkClientImportSpecifier';
import { createJavaScriptModuleBlobUrl } from '@/remote/worker/utils/createJavaScriptModuleBlobUrl';
-import { fetchComponentSource } from '@/remote/worker/utils/fetchComponentSource';
import { fetchSdkClientModulesAsBlobUrls } from '@/remote/worker/utils/fetchSdkClientModulesAsBlobUrls';
import { revokeSdkClientModuleBlobUrls } from '@/remote/worker/utils/revokeSdkClientModuleBlobUrls';
import { rewriteSdkClientImportsToBlobUrls } from '@/remote/worker/utils/rewriteSdkClientImportsToBlobUrls';
import { type SdkClientUrls } from '@/types/SdkClientUrls';
+import { buildAuthorizationHeadersFromAccessToken } from '@/utils/buildAuthorizationHeadersFromAccessToken';
type LoadFrontComponentModuleInput = {
- componentUrl: string;
+ componentSource: string;
sdkClientUrls?: SdkClientUrls;
applicationAccessToken?: string;
};
@@ -20,7 +19,7 @@ type FrontComponentModule = {
};
export const loadFrontComponentModule = async ({
- componentUrl,
+ componentSource,
sdkClientUrls,
applicationAccessToken,
}: LoadFrontComponentModuleInput): Promise => {
@@ -28,11 +27,6 @@ export const loadFrontComponentModule = async ({
applicationAccessToken,
);
- const componentSource = await fetchComponentSource({
- url: componentUrl,
- headers: authorizationHeaders,
- });
-
const sdkModuleBlobUrls =
isDefined(sdkClientUrls) &&
containsSdkClientImportSpecifier(componentSource)
diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/renderFrontComponent.ts b/packages/twenty-front-component-renderer/src/remote/worker/utils/renderFrontComponent.ts
index abe186f364..386ebe77b6 100644
--- a/packages/twenty-front-component-renderer/src/remote/worker/utils/renderFrontComponent.ts
+++ b/packages/twenty-front-component-renderer/src/remote/worker/utils/renderFrontComponent.ts
@@ -33,7 +33,7 @@ export const renderFrontComponent = async ({
setWorkerEnvironmentVariablesFromRenderContext(renderContext);
const componentModule = await loadFrontComponentModule({
- componentUrl: renderContext.componentUrl,
+ componentSource: renderContext.componentSource,
sdkClientUrls: renderContext.sdkClientUrls,
applicationAccessToken: renderContext.applicationAccessToken,
});
diff --git a/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts b/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts
index 5d76dc09aa..873d06aa7a 100644
--- a/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts
+++ b/packages/twenty-front-component-renderer/src/types/HostToWorkerRenderContext.ts
@@ -2,6 +2,7 @@ import { type SdkClientUrls } from '@/types/SdkClientUrls';
export type HostToWorkerRenderContext = {
componentUrl: string;
+ componentSource: string;
applicationAccessToken?: string;
apiUrl?: string;
functionsBaseUrl?: string;
diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildAuthorizationHeadersFromAccessToken.test.ts b/packages/twenty-front-component-renderer/src/utils/__tests__/buildAuthorizationHeadersFromAccessToken.test.ts
similarity index 100%
rename from packages/twenty-front-component-renderer/src/remote/worker/utils/__tests__/buildAuthorizationHeadersFromAccessToken.test.ts
rename to packages/twenty-front-component-renderer/src/utils/__tests__/buildAuthorizationHeadersFromAccessToken.test.ts
diff --git a/packages/twenty-front-component-renderer/src/remote/worker/utils/buildAuthorizationHeadersFromAccessToken.ts b/packages/twenty-front-component-renderer/src/utils/buildAuthorizationHeadersFromAccessToken.ts
similarity index 100%
rename from packages/twenty-front-component-renderer/src/remote/worker/utils/buildAuthorizationHeadersFromAccessToken.ts
rename to packages/twenty-front-component-renderer/src/utils/buildAuthorizationHeadersFromAccessToken.ts