mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 11:10:23 +02:00
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:
20
apps/web/app/[workspaceSlug]/(dashboard)/loading.tsx
Normal file
20
apps/web/app/[workspaceSlug]/(dashboard)/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
19
packages/views/layout/navigation-progress.tsx
Normal file
19
packages/views/layout/navigation-progress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
packages/views/navigation/app-link.test.tsx
Normal file
112
packages/views/navigation/app-link.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user