diff --git a/echo/frontend/src/components/project/ProjectLegalBasisSection.tsx b/echo/frontend/src/components/project/ProjectLegalBasisSection.tsx index e27646ae..7c83e5b4 100644 --- a/echo/frontend/src/components/project/ProjectLegalBasisSection.tsx +++ b/echo/frontend/src/components/project/ProjectLegalBasisSection.tsx @@ -30,7 +30,7 @@ import { type LegalBasisValue, } from "@/components/settings/LegalBasisCard"; import { API_BASE_URL } from "@/config"; -import { useI18nNavigate } from "@/hooks/useI18nNavigate"; +import { useLanguage } from "@/hooks/useLanguage"; type LegalSource = "project" | "workspace" | "legacy_user" | "default"; @@ -127,7 +127,7 @@ export const ProjectLegalBasisSection = ({ projectId: string; }) => { const { workspaceId } = useParams<{ workspaceId: string }>(); - const navigate = useI18nNavigate(); + const { language } = useLanguage(); const queryClient = useQueryClient(); const { data: user } = useCurrentUser(); const isDembraneUser = (user?.email ?? "") @@ -264,13 +264,13 @@ export const ProjectLegalBasisSection = ({ ) : ( )} + {/* New tab keeps the editor form the host is filling in */} - navigate(`/w/${workspaceId}/settings/general`) - } - style={{ cursor: "pointer" }} + href={`/${language}/w/${workspaceId}/settings/general`} + target="_blank" + rel="noopener noreferrer" > Workspace settings diff --git a/echo/frontend/src/lib/appVersion.test.ts b/echo/frontend/src/lib/appVersion.test.ts index 12bb1c20..98438f32 100644 --- a/echo/frontend/src/lib/appVersion.test.ts +++ b/echo/frontend/src/lib/appVersion.test.ts @@ -199,6 +199,39 @@ it("does not refetch the manifest on every navigation", async () => { await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); }); +// --- chunk-error message class --- + +it("recognises the stale-deploy chunk-error messages across browsers", async () => { + const { isChunkLoadErrorMessage } = await loadModule(); + + expect( + isChunkLoadErrorMessage( + "Unable to preload CSS for /assets/WorkspaceSelectorRoute-abc123.css", + ), + ).toBe(true); + expect( + isChunkLoadErrorMessage( + "Failed to fetch dynamically imported module: https://app/assets/Route-x.js", + ), + ).toBe(true); + expect( + isChunkLoadErrorMessage( + "error loading dynamically imported module: https://app/assets/Route-x.js", + ), + ).toBe(true); + expect(isChunkLoadErrorMessage("Importing a module script failed.")).toBe( + true, + ); +}); + +it("leaves unrelated exceptions untouched", async () => { + const { isChunkLoadErrorMessage } = await loadModule(); + + expect(isChunkLoadErrorMessage("TypeError: x is not a function")).toBe(false); + expect(isChunkLoadErrorMessage(undefined)).toBe(false); + expect(isChunkLoadErrorMessage(null)).toBe(false); +}); + // --- dev --- it("is inert in dev, where no manifest is emitted", async () => { diff --git a/echo/frontend/src/lib/appVersion.ts b/echo/frontend/src/lib/appVersion.ts index eeb4bf51..17f4e0d6 100644 --- a/echo/frontend/src/lib/appVersion.ts +++ b/echo/frontend/src/lib/appVersion.ts @@ -16,6 +16,25 @@ const targetKey = (target: string) => `dembrane:version-reload:${target}`; export type ReloadReason = "new_version_detected" | "chunk_load_failed"; +// The messages Vite's `vite:preloadError` carries for a stale-deploy chunk miss, +// across browsers. `recoverFromChunkFailure` already reloads the tab for these, +// but the listener rethrows on purpose, so autocapture files each one as an +// exception. Drop that noise in `before_send`; `app_version_reloaded` stays the +// health metric. +const CHUNK_LOAD_ERROR_PATTERNS = [ + "unable to preload css", + "failed to fetch dynamically imported module", + "error loading dynamically imported module", + "importing a module script failed", +]; + +/** True when an exception message is a handled stale-deploy chunk miss. */ +export const isChunkLoadErrorMessage = (message: unknown): boolean => { + if (typeof message !== "string") return false; + const lower = message.toLowerCase(); + return CHUNK_LOAD_ERROR_PATTERNS.some((pattern) => lower.includes(pattern)); +}; + /** Dev emits no manifest, so there is nothing to compare against. */ const isEnabled = (): boolean => !import.meta.env.DEV && BUILD_ID !== "unknown"; diff --git a/echo/frontend/src/main.tsx b/echo/frontend/src/main.tsx index ea07d8c2..37124ff7 100644 --- a/echo/frontend/src/main.tsx +++ b/echo/frontend/src/main.tsx @@ -12,10 +12,30 @@ import { POSTHOG_UI_HOST, USE_PARTICIPANT_ROUTER, } from "./config"; -import { recoverFromChunkFailure } from "./lib/appVersion"; +import { + isChunkLoadErrorMessage, + recoverFromChunkFailure, +} from "./lib/appVersion"; posthog.init(POSTHOG_TOKEN, { api_host: POSTHOG_HOST, + // Stale-deploy chunk misses are already recovered by recoverFromChunkFailure + // (a tab on an old build reloads onto the new one). Vite still rethrows them, + // so drop the autocaptured exception here to keep error tracking clean. + before_send: (event) => { + if (event?.event === "$exception") { + const exceptions = event.properties?.$exception_list; + if ( + Array.isArray(exceptions) && + exceptions.some((exception) => + isChunkLoadErrorMessage(exception?.value), + ) + ) { + return null; + } + } + return event; + }, // Error tracking: autocapture unhandled errors and promise rejections. // React render errors are reported separately via ErrorBoundary. capture_exceptions: {