feat(views): prefetch + transition + skeleton for snappy web navigation (MUL-2269) (#2677)

Internal navigation on web feels laggy because clicking a sidebar link blocks
0.2–0.6s with zero visual feedback — no prefetch, no Suspense fallback in the
dashboard segment, and no React transition to mark the route commit as pending.

This change adds the three pieces App Router needs to make the click→commit
window feel instant, scoped to the (dashboard) segment so auth/landing keep
their existing chrome:

- NavigationAdapter gains an optional prefetch(path). The web adapter wires
  it to router.prefetch; desktop leaves it undefined (react-router has no
  equivalent and doesn't need one). AppLink prefetches on hover/focus and
  preserves caller-supplied onMouseEnter/onFocus/onClick.
- NavigationProvider wraps push/replace in useTransition and exposes the
  pending flag via useIsNavigating(). Every useNavigation().push caller —
  sidebar AppLink, command palette, post-create modal jumps — picks this up
  automatically.
- New apps/web/app/[workspaceSlug]/(dashboard)/loading.tsx renders a minimal
  skeleton during cold transitions inside the dashboard segment only.
- DashboardLayout renders a 1px top progress bar driven by useIsNavigating.

packages/views remains free of next/* imports; desktop is unaffected by
construction (no prefetch, transition flips quickly, no loading.tsx).

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-05-15 17:01:42 +08:00
committed by GitHub
parent 319b23eb39
commit e8d6c912c4
10 changed files with 237 additions and 7 deletions

View File

@@ -0,0 +1,20 @@
import { Skeleton } from "@multica/ui/components/ui/skeleton";
// Rendered by Next.js as the Suspense fallback during route transitions
// inside the (dashboard) segment. Scoped to this segment only — auth /
// landing keep their own full-screen fallbacks.
export default function DashboardLoading() {
return (
<div className="flex h-svh w-full flex-col">
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
<Skeleton className="h-5 w-5 rounded-md" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex-1 space-y-2 p-4">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-9 w-full" />
))}
</div>
</div>
);
}

View File

@@ -24,6 +24,12 @@ function NavigationProviderInner({
searchParams: new URLSearchParams(searchParams.toString()),
getShareableUrl: (path: string) =>
typeof window === "undefined" ? path : window.location.origin + path,
// router.prefetch is a no-op in dev mode by Next.js design; in production
// it warms the RSC payload + route chunk so the next push() commits with
// no network round-trip. Safe to call repeatedly — Next dedupes internally.
prefetch: (path: string) => {
router.prefetch(path);
},
};
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;

View File

@@ -114,6 +114,19 @@
animation: chat-text-shimmer 2.5s linear infinite;
}
/* Navigation progress bar: 1px brand-tinted indeterminate sweep that shows
* across the top of the dashboard while a transition-wrapped push/replace is
* committing. Driven by useIsNavigating(); independent of the actual network,
* so it disappears the moment React commits the new route. */
@keyframes nav-progress-sweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.animate-nav-progress-sweep {
animation: nav-progress-sweep 1.1s ease-in-out infinite;
}
/* Border beam: a brand-tinted highlight sweeps continuously around the
* element's rounded border, drawing the eye to a CTA that would otherwise
* blend into the chrome (e.g. the "switch to agent" affordance in manual

View File

@@ -5,6 +5,7 @@ import { SidebarProvider, SidebarInset } from "@multica/ui/components/ui/sidebar
import { ModalRegistry } from "../modals/registry";
import { AppSidebar } from "./app-sidebar";
import { DashboardGuard } from "./dashboard-guard";
import { NavigationProgress } from "./navigation-progress";
import { WorkspacePresencePrefetch } from "./workspace-presence-prefetch";
interface DashboardLayoutProps {
@@ -35,6 +36,7 @@ export function DashboardLayout({
<WorkspacePresencePrefetch />
<AppSidebar searchSlot={searchSlot} />
<SidebarInset className="relative overflow-hidden">
<NavigationProgress />
{children}
<ModalRegistry />
{extra}

View File

@@ -0,0 +1,19 @@
"use client";
import { useIsNavigating } from "../navigation";
// 1px top-of-content progress bar shown while a transition-wrapped
// push/replace is mid-flight. Indeterminate by design — we don't know
// when the next route will commit, just that it's coming.
export function NavigationProgress() {
const isNavigating = useIsNavigating();
if (!isNavigating) return null;
return (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 z-50 h-px overflow-hidden"
>
<div className="h-full w-1/3 animate-nav-progress-sweep bg-primary" />
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { AppLink } from "./app-link";
import { NavigationProvider } from "./context";
import type { NavigationAdapter } from "./types";
function makeAdapter(overrides: Partial<NavigationAdapter> = {}): NavigationAdapter {
return {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
pathname: "/",
searchParams: new URLSearchParams(),
getShareableUrl: (p) => p,
...overrides,
};
}
function renderLink(
adapter: NavigationAdapter,
props: React.ComponentProps<typeof AppLink> = { href: "/issues" },
) {
return render(
<NavigationProvider value={adapter}>
<AppLink {...props}>go</AppLink>
</NavigationProvider>,
);
}
describe("AppLink", () => {
it("calls caller onClick BEFORE push so synchronous side effects (close menu, etc) commit before the transition starts", () => {
const order: string[] = [];
const adapter = makeAdapter({
push: vi.fn(() => order.push("push")),
});
renderLink(adapter, {
href: "/issues",
onClick: () => order.push("onClick"),
});
fireEvent.click(screen.getByText("go"));
expect(order).toEqual(["onClick", "push"]);
});
it("calls adapter.prefetch on hover, alongside the caller's onMouseEnter — neither is overridden by {...props}", () => {
const prefetch = vi.fn();
const callerMouseEnter = vi.fn();
const adapter = makeAdapter({ prefetch });
renderLink(adapter, {
href: "/issues",
onMouseEnter: callerMouseEnter,
});
fireEvent.mouseEnter(screen.getByText("go"));
expect(prefetch).toHaveBeenCalledWith("/issues");
expect(callerMouseEnter).toHaveBeenCalledTimes(1);
});
it("calls adapter.prefetch on focus, alongside the caller's onFocus", () => {
const prefetch = vi.fn();
const callerFocus = vi.fn();
const adapter = makeAdapter({ prefetch });
renderLink(adapter, {
href: "/issues",
onFocus: callerFocus,
});
fireEvent.focus(screen.getByText("go"));
expect(prefetch).toHaveBeenCalledWith("/issues");
expect(callerFocus).toHaveBeenCalledTimes(1);
});
it("is a no-op when adapter does not implement prefetch (desktop)", () => {
const adapter = makeAdapter();
renderLink(adapter);
expect(() => fireEvent.mouseEnter(screen.getByText("go"))).not.toThrow();
expect(() => fireEvent.focus(screen.getByText("go"))).not.toThrow();
});
it("modifier-click (cmd / ctrl) delegates to openInNewTab and does NOT push", () => {
const push = vi.fn();
const openInNewTab = vi.fn();
const adapter = makeAdapter({ push, openInNewTab });
renderLink(adapter);
fireEvent.click(screen.getByText("go"), { metaKey: true });
expect(openInNewTab).toHaveBeenCalledWith("/issues");
expect(push).not.toHaveBeenCalled();
});
it("a caller-supplied onClick passed via spread cannot silently override the navigation handler", () => {
const push = vi.fn();
const adapter = makeAdapter({ push });
const spreadOnClick = vi.fn((e: React.MouseEvent) => e.preventDefault());
render(
<NavigationProvider value={adapter}>
{/* simulate a caller that passes onClick through a spread bag */}
<AppLink href="/issues" {...{ onClick: spreadOnClick }}>
go
</AppLink>
</NavigationProvider>,
);
fireEvent.click(screen.getByText("go"));
// Caller still runs (it was hoisted into the named param), but push runs too.
expect(spreadOnClick).toHaveBeenCalled();
expect(push).toHaveBeenCalledWith("/issues");
});
});

View File

@@ -8,8 +8,11 @@ interface AppLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
}
export const AppLink = forwardRef<HTMLAnchorElement, AppLinkProps>(
function AppLink({ href, children, onClick, ...props }, ref) {
const { push, openInNewTab } = useNavigation();
function AppLink(
{ href, children, onClick, onMouseEnter, onFocus, ...props },
ref,
) {
const { push, openInNewTab, prefetch } = useNavigation();
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (e.metaKey || e.ctrlKey || e.shiftKey) {
@@ -20,12 +23,35 @@ export const AppLink = forwardRef<HTMLAnchorElement, AppLinkProps>(
return;
}
e.preventDefault();
// Caller's onClick runs BEFORE push so any synchronous side effect
// (close popover, clear selection, blur the trigger) lands in the
// same tick rather than getting deferred behind the transition.
onClick?.(e);
push(href);
};
const handleMouseEnter = (e: React.MouseEvent<HTMLAnchorElement>) => {
prefetch?.(href);
onMouseEnter?.(e);
};
const handleFocus = (e: React.FocusEvent<HTMLAnchorElement>) => {
prefetch?.(href);
onFocus?.(e);
};
return (
<a ref={ref} href={href} onClick={handleClick} {...props}>
<a
ref={ref}
href={href}
// Spread props first so that the navigation handlers below cannot be
// silently overridden by a caller passing onClick/onMouseEnter/onFocus
// through {...rest}. AppLink owns these three events.
{...props}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onFocus={handleFocus}
>
{children}
</a>
);

View File

@@ -1,9 +1,10 @@
"use client";
import { createContext, useContext } from "react";
import { createContext, useContext, useMemo, useTransition } from "react";
import type { NavigationAdapter } from "./types";
const NavigationContext = createContext<NavigationAdapter | null>(null);
const NavigationPendingContext = createContext<boolean>(false);
export function NavigationProvider({
value,
@@ -12,9 +13,25 @@ export function NavigationProvider({
value: NavigationAdapter;
children: React.ReactNode;
}) {
// Wrap push/replace in startTransition so any caller of useNavigation()
// (sidebar AppLink, command palette, modal post-create jumps) gets a
// React pending signal during route commit. On web this stays true until
// Next.js commits the new RSC payload; on desktop it flips off quickly
// because react-router commits synchronously — both are correct.
const [isPending, startTransition] = useTransition();
const wrapped = useMemo<NavigationAdapter>(
() => ({
...value,
push: (path: string) => startTransition(() => value.push(path)),
replace: (path: string) => startTransition(() => value.replace(path)),
}),
[value],
);
return (
<NavigationContext.Provider value={value}>
{children}
<NavigationContext.Provider value={wrapped}>
<NavigationPendingContext.Provider value={isPending}>
{children}
</NavigationPendingContext.Provider>
</NavigationContext.Provider>
);
}
@@ -25,3 +42,8 @@ export function useNavigation(): NavigationAdapter {
throw new Error("useNavigation must be used within NavigationProvider");
return ctx;
}
/** True while a transition-wrapped push/replace is committing. */
export function useIsNavigating(): boolean {
return useContext(NavigationPendingContext);
}

View File

@@ -1,3 +1,7 @@
export { NavigationProvider, useNavigation } from "./context";
export {
NavigationProvider,
useNavigation,
useIsNavigating,
} from "./context";
export { AppLink } from "./app-link";
export type { NavigationAdapter } from "./types";

View File

@@ -8,4 +8,10 @@ export interface NavigationAdapter {
openInNewTab?: (path: string, title?: string) => void;
/** Return a shareable URL for a path. Web: origin + path. Desktop: public web URL of the connected environment. */
getShareableUrl: (path: string) => string;
/**
* Optional: warm up route assets / RSC payload for a path. Web wires this
* to `router.prefetch`; desktop leaves it undefined because react-router
* already loads the whole SPA. Callers must invoke via `prefetch?.(href)`.
*/
prefetch?: (path: string) => void;
}