diff --git a/desktop/src/app/communityViewTransition.test.mjs b/desktop/src/app/communityViewTransition.test.mjs index 4e2c6db6d57..79ec2961ed6 100644 --- a/desktop/src/app/communityViewTransition.test.mjs +++ b/desktop/src/app/communityViewTransition.test.mjs @@ -5,20 +5,49 @@ import { completeCommunityViewTransition, replaceCommunityDestinationRoute, runCommunityViewTransition, + shouldSkipCommunityViewTransition, } from "./communityViewTransition.ts"; const originalDocument = globalThis.document; const originalWindow = globalThis.window; +const originalNavigator = Object.getOwnPropertyDescriptor( + globalThis, + "navigator", +); + +const WEBKITGTK_UA = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"; +const MAC_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15"; afterEach(() => { globalThis.document = originalDocument; globalThis.window = originalWindow; + if (originalNavigator) { + Object.defineProperty(globalThis, "navigator", originalNavigator); + } else { + delete globalThis.navigator; + } mock.restoreAll(); }); +// `undefined` removes navigator entirely, so `typeof navigator === "undefined"`. +// afterEach puts the real one back. +function setNavigator(value) { + if (value === undefined) { + delete globalThis.navigator; + return; + } + Object.defineProperty(globalThis, "navigator", { configurable: true, value }); +} + function installBrowser(startViewTransition) { globalThis.window = { clearTimeout, setTimeout }; globalThis.document = { startViewTransition }; + // node reports navigator.platform as "Linux x86_64", which is exactly what + // the crash guard skips the transition for. Default these tests to a + // platform that keeps the animation path; the linux cases set their own. + setNavigator({ platform: "MacIntel", userAgent: MAC_UA }); } function transitionFor(callback) { @@ -33,6 +62,48 @@ test("replaceCommunityDestinationRoute uses router history and encodes the chann assert.deepEqual(replacements, ["/channels/channel%2Fwith%20spaces"]); }); +test("the transition is skipped on linux and kept everywhere else", () => { + setNavigator({ platform: "Linux x86_64", userAgent: WEBKITGTK_UA }); + assert.equal(shouldSkipCommunityViewTransition(), true); + + // Chromium on Linux is skipped too. The guard fails closed on purpose: it + // cannot tell a WebKitGTK webview from a Linux browser without sniffing the + // user agent, and losing an animation costs less than a segfault. + setNavigator({ + platform: "Linux x86_64", + userAgent: + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + }); + assert.equal(shouldSkipCommunityViewTransition(), true); + + setNavigator({ platform: "MacIntel", userAgent: MAC_UA }); + assert.equal(shouldSkipCommunityViewTransition(), false); + + // Android reports a Linux platform but is not a WebKitGTK desktop webview. + setNavigator({ + platform: "Linux armv8l", + userAgent: + "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + }); + assert.equal(shouldSkipCommunityViewTransition(), false); + + setNavigator(undefined); + assert.equal(shouldSkipCommunityViewTransition(), false); +}); + +test("linux runs the update without starting a transition", async () => { + const startViewTransition = mock.fn((callback) => transitionFor(callback)); + installBrowser(startViewTransition); + setNavigator({ platform: "Linux x86_64", userAgent: WEBKITGTK_UA }); + + let updated = false; + await runCommunityViewTransition(async () => { + updated = true; + }); + assert.equal(updated, true); + assert.equal(startViewTransition.mock.callCount(), 0); +}); + test("unsupported browsers execute the update and contain rejection", async () => { installBrowser(undefined); const expected = new Error("navigation failed"); diff --git a/desktop/src/app/communityViewTransition.ts b/desktop/src/app/communityViewTransition.ts index 1bbc395f9d0..b4873d40ae2 100644 --- a/desktop/src/app/communityViewTransition.ts +++ b/desktop/src/app/communityViewTransition.ts @@ -1,3 +1,5 @@ +import { isLinuxPlatform } from "@/shared/lib/platform"; + const COMMUNITY_TRANSITION_TIMEOUT_MS = 5_000; let finishPendingTransition: (() => void) | null = null; @@ -13,11 +15,31 @@ export function replaceCommunityDestinationRoute( history.replace(`/channels/${encodeURIComponent(channelId)}`); } +// WebKitGTK (the engine under every Linux Tauri webview) crashes the UI +// process when a view transition is the first thing to demand accelerated +// compositing on a machine whose accelerated backing store could not be +// created (X11 sessions, dmabuf transport disabled or unavailable): the +// missing-store guard is a debug ASSERT that release builds compile out, so +// document.startViewTransition() segfaults instead of animating. Reproduced +// 100% outside Buzz on WebKitGTK 2.52 regardless of the dmabuf env vars — +// see #3488, #4142, and https://bugs.webkit.org/show_bug.cgi?id=321683. +// +// Keyed on the platform alone, deliberately: this guards a UI-process +// segfault, so it has to fail closed. Narrowing it to WebKitGTK would mean +// sniffing the user agent for the engine, and a UA test that quietly stops +// matching re-enables the crash with nothing pointing back at this line. On +// Tauri, Linux means WebKitGTK anyway, so the cost of over-matching is only a +// missing cross-fade in a Linux browser session — a crash is not a tradeoff +// worth taking for an animation. +export function shouldSkipCommunityViewTransition(): boolean { + return isLinuxPlatform(); +} + export async function runCommunityViewTransition( update: () => Promise | void, options: { timeoutMs?: number } = {}, ): Promise { - if (!document.startViewTransition) { + if (!document.startViewTransition || shouldSkipCommunityViewTransition()) { try { await update(); } catch (error) {