From 7757f6d658b735caa6c392d324fc9031846e355c Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 16 Aug 2026 11:31:00 -0600 Subject: [PATCH] fix(desktop): replace stale channel during navigation Render the destination channel skeleton as soon as sidebar intent is recorded instead of leaving the previous channel visible while cached content renders. Keep that skeleton through route commit and one paint, clear it safely on superseding or failed navigation, and cover both paint boundaries in E2E. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src/app/AppShell.tsx | 12 +++++- .../useDeferredSidebarNavigation.ts | 38 ++++++++++++++---- desktop/src/app/useHuddlePresentation.ts | 2 +- desktop/tests/e2e/messaging.spec.ts | 39 ++++++++++++++++++- 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 91f17cf18..7f26a8eee 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -87,6 +87,7 @@ import { relayClient } from "@/shared/api/relayClient"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal"; import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; import { useWebviewScrollBoundaryLock } from "@/shared/hooks/useWebviewScrollBoundaryLock"; import { joinChannel } from "@/shared/api/tauri"; import type { Channel, ChannelVisibility, SearchHit } from "@/shared/api/types"; @@ -911,7 +912,16 @@ export function AppShell() { mainInsetRef={mainInsetRef} terminal={} > - + {pendingSidebarChannelId ? ( +
+ +
+ ) : ( + + )} {!isHuddleRoom ? ( void; + selectChannel: (channelId: string) => Promise | undefined; }; export function useDeferredSidebarNavigation({ @@ -19,6 +19,8 @@ export function useDeferredSidebarNavigation({ const frameRef = React.useRef(null); const timerRef = React.useRef(null); const generationRef = React.useRef(0); + const isCommittingRef = React.useRef(false); + const ignoreNextNavigationIntentRef = React.useRef(false); const pathnameRef = React.useRef(pathname); pathnameRef.current = pathname; @@ -35,27 +37,42 @@ export function useDeferredSidebarNavigation({ }, []); const cancel = React.useCallback(() => { + isCommittingRef.current = false; + ignoreNextNavigationIntentRef.current = false; cancelDeferred(); setPendingChannelId(null); }, [cancelDeferred]); React.useEffect(() => cancel, [cancel]); React.useEffect(() => { - const handleNavigationIntent = () => cancelDeferred(); + const handleNavigationIntent = () => { + if (ignoreNextNavigationIntentRef.current) { + ignoreNextNavigationIntentRef.current = false; + return; + } + cancel(); + }; window.addEventListener("buzz:navigation-intent", handleNavigationIntent); return () => window.removeEventListener( "buzz:navigation-intent", handleNavigationIntent, ); - }, [cancelDeferred]); + }, [cancel]); React.useEffect(() => { - // A committed route change supersedes any deferred sidebar intent. + // Keep the destination skeleton mounted through the route commit and one + // paint. Clearing state directly in this effect can be batched before the + // browser paints, exposing either the old outlet or expensive new outlet. void pathname; - cancel(); + frameRef.current = window.requestAnimationFrame(() => { + frameRef.current = null; + cancel(); + }); }, [cancel, pathname]); React.useEffect(() => { - if (pendingChannelId === selectedChannelId) setPendingChannelId(null); + if (!isCommittingRef.current && pendingChannelId === selectedChannelId) { + setPendingChannelId(null); + } }, [pendingChannelId, selectedChannelId]); const selectDeferred = React.useCallback( @@ -89,7 +106,14 @@ export function useDeferredSidebarNavigation({ ) { return; } - selectChannel(channelId); + isCommittingRef.current = true; + ignoreNextNavigationIntentRef.current = true; + const navigationResult = selectChannel(channelId); + if (navigationResult) { + void navigationResult.catch(() => { + if (generationRef.current === generation) cancel(); + }); + } }, 0); }); }); diff --git a/desktop/src/app/useHuddlePresentation.ts b/desktop/src/app/useHuddlePresentation.ts index a82916d04..6d9d0dc54 100644 --- a/desktop/src/app/useHuddlePresentation.ts +++ b/desktop/src/app/useHuddlePresentation.ts @@ -319,7 +319,7 @@ export function useHuddlePresentation() { showHuddleInMainApp(channelId); return; } - void goChannel(channelId); + return goChannel(channelId); }, [goChannel, isHuddleDrawerOpen, showHuddleInMainApp], ); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index d5f4d5220..7fe82648c 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -2789,12 +2789,38 @@ test("sidebar selection paints before cached channel work starts", async ({ await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.evaluate(() => { + const sourceHref = window.location.href; const observed = { selectedBeforeRoute: false, singularSelection: false, activeBackgroundTransitions: true, + destinationSkeletonBeforeRoute: false, + destinationSkeletonAfterRoute: false, + destinationContentAfterSkeleton: false, }; Object.assign(window, { __BUZZ_SIDEBAR_SELECTION_OBSERVED__: observed }); + const originalPushState = window.history.pushState.bind(window.history); + window.history.pushState = (...args) => { + observed.destinationSkeletonAfterRoute = Boolean( + document.querySelector('[data-testid="pending-channel-skeleton"]'), + ); + return originalPushState(...args); + }; + const routeObserver = new MutationObserver(() => { + const skeleton = document.querySelector( + '[data-testid="pending-channel-skeleton"]', + ); + const title = document.querySelector('[data-testid="chat-title"]'); + if ( + observed.destinationSkeletonAfterRoute && + !skeleton && + title?.textContent === "random" + ) { + observed.destinationContentAfterSkeleton = true; + routeObserver.disconnect(); + } + }); + routeObserver.observe(document.body, { childList: true, subtree: true }); const observer = new MutationObserver(() => { const general = document.querySelector('[data-testid="channel-general"]'); const random = document.querySelector('[data-testid="channel-random"]'); @@ -2802,8 +2828,10 @@ test("sidebar selection paints before cached channel work starts", async ({ observer.disconnect(); requestAnimationFrame(() => { requestAnimationFrame(() => { - const title = document.querySelector('[data-testid="chat-title"]'); - observed.selectedBeforeRoute = title?.textContent === "general"; + observed.selectedBeforeRoute = window.location.href === sourceHref; + observed.destinationSkeletonBeforeRoute = Boolean( + document.querySelector('[data-testid="pending-channel-skeleton"]'), + ); observed.singularSelection = general?.getAttribute("data-active") === "false" && document.querySelectorAll( @@ -2839,6 +2867,9 @@ test("sidebar selection paints before cached channel work starts", async ({ selectedBeforeRoute: boolean; singularSelection: boolean; activeBackgroundTransitions: boolean; + destinationSkeletonBeforeRoute: boolean; + destinationSkeletonAfterRoute: boolean; + destinationContentAfterSkeleton: boolean; }; } ).__BUZZ_SIDEBAR_SELECTION_OBSERVED__ ?? null, @@ -2848,7 +2879,11 @@ test("sidebar selection paints before cached channel work starts", async ({ selectedBeforeRoute: true, singularSelection: true, activeBackgroundTransitions: false, + destinationSkeletonBeforeRoute: true, + destinationSkeletonAfterRoute: true, + destinationContentAfterSkeleton: true, }); + await expect(page.getByTestId("pending-channel-skeleton")).toHaveCount(0); await expect(page.getByTestId("chat-title")).toHaveText("random"); });