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"); });