mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -319,7 +319,7 @@ export function useHuddlePresentation() {
|
||||
showHuddleInMainApp(channelId);
|
||||
return;
|
||||
}
|
||||
void goChannel(channelId);
|
||||
return goChannel(channelId);
|
||||
},
|
||||
[goChannel, isHuddleDrawerOpen, showHuddleInMainApp],
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user