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 <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-08-16 11:31:00 -06:00
co-authored by Carl
parent 73101d2d7f
commit 7757f6d658
4 changed files with 80 additions and 11 deletions
+11 -1
View File
@@ -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={<TerminalBootstrap {...terminalContext} />}
>
<Outlet />
{pendingSidebarChannelId ? (
<div
className="flex min-h-0 min-w-0 flex-1"
data-testid="pending-channel-skeleton"
>
<ViewLoadingFallback includeHeader kind="channel" />
</div>
) : (
<Outlet />
)}
</AppShellChannelSurface>
{!isHuddleRoom ? (
<RelayConnectionOverlay
@@ -5,7 +5,7 @@ import { dispatchNavigationIntent } from "@/app/navigation/navigationIntent";
type DeferredSidebarNavigationOptions = {
pathname: string;
selectedChannelId: string | null;
selectChannel: (channelId: string) => void;
selectChannel: (channelId: string) => Promise<unknown> | undefined;
};
export function useDeferredSidebarNavigation({
@@ -19,6 +19,8 @@ export function useDeferredSidebarNavigation({
const frameRef = React.useRef<number | null>(null);
const timerRef = React.useRef<number | null>(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);
});
});
+1 -1
View File
@@ -319,7 +319,7 @@ export function useHuddlePresentation() {
showHuddleInMainApp(channelId);
return;
}
void goChannel(channelId);
return goChannel(channelId);
},
[goChannel, isHuddleDrawerOpen, showHuddleInMainApp],
);
+37 -2
View File
@@ -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");
});