Persist versioned models and validated network metadata in IndexedDB. Show scene download progress and recoverable errors with the existing frosted material. Connect existing skeletons to pending navigation and route boundaries while preserving the map. Previously only dynamic imports showed fallbacks, and rejected model promises prevented retries. Verified: 32 focused unit tests; 4 navigation and scene browser checks; 7 cache browser checks including invalid-metadata recovery; isolated production build and type checks.
99 lines
3.1 KiB
TypeScript
99 lines
3.1 KiB
TypeScript
"use client";
|
|
|
|
import { Box, LinearProgress } from "@mui/material";
|
|
import { usePathname } from "next/navigation";
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useMemo,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
|
|
import {
|
|
mapSkeletonForPath,
|
|
MapRouteSkeleton,
|
|
PageLoadingSkeleton,
|
|
} from "./PageLoadingSkeleton";
|
|
|
|
type PendingNavigation = { id: string; pathname: string };
|
|
type NavigationFeedback = {
|
|
pendingPath: string | null;
|
|
reportPending: (id: string, pathname: string, pending: boolean) => void;
|
|
};
|
|
|
|
const NavigationContext = createContext<NavigationFeedback>({
|
|
pendingPath: null,
|
|
reportPending: () => {},
|
|
});
|
|
|
|
export const useNavigationFeedback = () => useContext(NavigationContext);
|
|
|
|
export function NavigationFeedbackProvider({ children }: { children: ReactNode }) {
|
|
const pathname = usePathname();
|
|
const [navigation, setNavigation] = useState<PendingNavigation | null>(null);
|
|
const reportPending = useCallback((id: string, target: string, pending: boolean) => {
|
|
setNavigation((current) => {
|
|
if (!pending) return current?.id === id ? null : current;
|
|
return current?.id === id && current.pathname === target ? current : { id, pathname: target };
|
|
});
|
|
}, []);
|
|
// History commits can precede a page's lazy component. Its loading.tsx and
|
|
// dynamic fallback take over at that point, without an artificial delay.
|
|
const pendingPath = navigation && navigation.pathname !== pathname ? navigation.pathname : null;
|
|
const value = useMemo(() => ({ pendingPath, reportPending }), [pendingPath, reportPending]);
|
|
return (
|
|
<NavigationContext.Provider value={value}>
|
|
{children}
|
|
</NavigationContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function PageNavigationContent({ children }: { children: ReactNode }) {
|
|
const pathname = usePathname();
|
|
const { pendingPath } = useNavigationFeedback();
|
|
const withinMap = Boolean(mapSkeletonForPath(pathname) && pendingPath && mapSkeletonForPath(pendingPath));
|
|
return (
|
|
<Box
|
|
data-page-navigation-content
|
|
aria-busy={Boolean(pendingPath)}
|
|
sx={{ position: "relative", height: "100%", minHeight: 0, overflow: "hidden" }}
|
|
>
|
|
<Box
|
|
data-page-scroll-content
|
|
inert={Boolean(pendingPath) && !withinMap}
|
|
aria-hidden={Boolean(pendingPath) && !withinMap}
|
|
sx={{ height: "100%", overflow: "auto" }}
|
|
>
|
|
{children}
|
|
</Box>
|
|
{pendingPath && (
|
|
<>
|
|
<LinearProgress
|
|
aria-label="正在切换页面"
|
|
sx={{ position: "absolute", top: 0, left: 0, right: 0, zIndex: 1401, height: 3 }}
|
|
/>
|
|
{!withinMap && (
|
|
<Box sx={{ position: "absolute", inset: 0, zIndex: 1400, overflow: "hidden" }}>
|
|
<PageLoadingSkeleton pathname={pendingPath} />
|
|
</Box>
|
|
)}
|
|
</>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export function MapNavigationContent({ children }: { children: ReactNode }) {
|
|
const { pendingPath } = useNavigationFeedback();
|
|
return (
|
|
<>
|
|
<Box data-map-page-content sx={{ display: pendingPath ? "none" : "contents" }}>
|
|
{children}
|
|
</Box>
|
|
{pendingPath && <MapRouteSkeleton pathname={pendingPath} />}
|
|
</>
|
|
);
|
|
}
|