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