Keep latest navigation in control of pages and sidebars
16 files+188−480/16 viewed
| 20 | 20 | X, | |
| 21 | 21 | } from "lucide-react"; | |
| 22 | 22 | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 23 | − | import { Link, useNavigate, useRouteLoaderData, useSearchParams } from "react-router"; | |
| 23 | + | import { Link, useRouteLoaderData, useSearchParams } from "react-router"; | |
| 24 | + | import { usePageNavigation } from "../../lib/use-page-navigation"; | |
| 24 | 25 | ||
| 25 | 26 | import { | |
| 26 | 27 | type Channel, | |
| ⋯ | |||
| 185 | 186 | return () => clearTimeout(timer); | |
| 186 | 187 | }, [view, messages, members, joined, canManage]); | |
| 187 | 188 | // Renamed while open, here or by someone else: the address follows the name. | |
| 188 | − | const navigate = useNavigate(); | |
| 189 | + | const { navigate } = usePageNavigation(); | |
| 189 | 190 | const nameShown = useRef(data.channel.name); | |
| 190 | 191 | useEffect(() => { | |
| 191 | 192 | if (channel.kind !== "channel" || channel.name === nameShown.current || !slug) return; | |
| 1 | + | import { useSidebarLocation } from "../../lib/use-sidebar-location"; | |
| 1 | 2 | import { ArrowLeft, BellOff, Check, ChevronDown, Compass, Globe, Hash, Lock, Pin, PinOff, Search, X } from "lucide-react"; | |
| 2 | 3 | import { ContextMenu } from "radix-ui"; | |
| 3 | 4 | import { type ReactNode, useEffect, useState } from "react"; | |
| 4 | − | import { NavLink, useFetcher, useLocation, useNavigate } from "react-router"; | |
| 5 | + | import { NavLink, useFetcher, useNavigate } from "react-router"; | |
| 5 | 6 | ||
| 6 | 7 | import type { Channel, ChannelInvitation, ChatSidebarEntry, GuestChannel } from "@g1t/contracts"; | |
| 7 | 8 | ||
| ⋯ | |||
| 58 | 59 | const [filter, setFilter] = useState<ChatFilter>("all"); | |
| 59 | 60 | const [query, setQuery] = useState(""); | |
| 60 | 61 | const navigate = useNavigate(); | |
| 61 | − | const { pathname } = useLocation(); | |
| 62 | + | const { pathname } = useSidebarLocation(); | |
| 62 | 63 | ||
| 63 | 64 | useEffect(() => { | |
| 64 | 65 | if (!data) return; | |
| 9 | 9 | import type { FolioSuggestion, FoliosLiveEvent, MemberProfile } from "@g1t/contracts"; | |
| 10 | 10 | import { AlertTriangle, Check, MessageSquareQuote, SmilePlus, Sparkles } from "lucide-react"; | |
| 11 | 11 | import { type ComponentType, Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 12 | − | import { Link, useNavigate, useRevalidator, useRouteLoaderData } from "react-router"; | |
| 12 | + | import { Link, useRevalidator, useRouteLoaderData } from "react-router"; | |
| 13 | + | import { usePageNavigation } from "../../../lib/use-page-navigation"; | |
| 13 | 14 | ||
| 14 | 15 | import { usercontentFrom } from "../../../lib/addresses"; | |
| 15 | 16 | import { canDo, readingTime } from "../../../lib/folios"; | |
| ⋯ | |||
| 37 | 38 | const layout = useFoliosData(); | |
| 38 | 39 | const root = useRouteLoaderData("root"); | |
| 39 | 40 | const usercontent = usercontentFrom(root); | |
| 40 | − | const navigate = useNavigate(); | |
| 41 | + | const { navigate } = usePageNavigation(); | |
| 41 | 42 | const revalidator = useRevalidator(); | |
| 42 | 43 | const hydrated = useHydrated(); | |
| 43 | 44 | const editable = canDo(role, "edit"); | |
| 1 | + | import { useSidebarLocation } from "../../lib/use-sidebar-location"; | |
| 1 | 2 | /** | |
| 2 | 3 | * Artifacts mode's sidebar (beside the rail; docs.g1t.sh/guides/artifacts/, | |
| 3 | 4 | * "The sidebar"): search, Home, New and Templates; Favorites; the spaces | |
| ⋯ | |||
| 10 | 11 | import { FOLIO_KINDS, folioIdFrom, folioSlug, type DocRepoSpace, type FolioKind, type FolioRef, type FolioTreeNode, type FoliosSidebarSpace } from "@g1t/contracts"; | |
| 11 | 12 | import { AlertTriangle, ChevronDown, ChevronRight, Compass, FileText, Folder, FolderGit2, Home, LayoutTemplate, Lock, Plus, Search, Settings, Star, Trash2, X } from "lucide-react"; | |
| 12 | 13 | import { type DragEvent, type ReactNode, useEffect, useMemo, useState } from "react"; | |
| 13 | − | import { NavLink, useLocation, useNavigate, useParams, useRevalidator } from "react-router"; | |
| 14 | + | import { NavLink, useNavigate, useParams, useRevalidator } from "react-router"; | |
| 14 | 15 | ||
| 15 | 16 | import { buildTree, canDo, pathTo, repoFilePath, repoFolders, spacePath, type RepoFolder, type TreeItem } from "../../lib/folios"; | |
| 16 | 17 | import { Button } from "../ui/button"; | |
| ⋯ | |||
| 308 | 309 | ||
| 309 | 310 | /** A project's docs folder: read-only, its files as folders, from the repository's default branch. */ | |
| 310 | 311 | function RepoTree({ slug, space }: { slug: string; space: DocRepoSpace }) { | |
| 311 | − | const { pathname } = useLocation(); | |
| 312 | + | const { pathname } = useSidebarLocation(); | |
| 312 | 313 | const base = `/${slug}/-/artifacts/repo/${space.repo}/`; | |
| 313 | 314 | const here = decodeURIComponent(pathname).startsWith(base); | |
| 314 | 315 | const [collapsed, setCollapsed] = useState(!here); | |
| 1 | + | import { useSidebarLocation } from "../lib/use-sidebar-location"; | |
| 1 | 2 | import { Check, ChevronDown, CircleUserRound, Compass, LayoutGrid, Plus, Settings } from "lucide-react"; | |
| 2 | 3 | import { Fragment, type ReactNode, useState } from "react"; | |
| 3 | − | import { Link, useLocation, useNavigation } from "react-router"; | |
| 4 | + | import { Link } from "react-router"; | |
| 4 | 5 | ||
| 5 | 6 | import { type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts"; | |
| 6 | 7 | ||
| ⋯ | |||
| 224 | 225 | unread: { notifications: number; chat: number; mentions: number }; | |
| 225 | 226 | account: ReactNode; | |
| 226 | 227 | }) { | |
| 227 | − | const { pathname } = useLocation(); | |
| 228 | − | const going = useNavigation().location?.pathname; | |
| 229 | − | const path = going ?? pathname; | |
| 228 | + | const { pathname: path } = useSidebarLocation(); | |
| 230 | 229 | const here = mode ?? modeOf(path, workspace.slug); | |
| 231 | 230 | const slug = workspace.slug; | |
| 232 | 231 | const code = hasCodeAccess(workspace); | |
| 11 | 11 | import { isNetworkFailure } from "../lib/resilient-fetch"; | |
| 12 | 12 | import { RELOADED_KEY, RELOAD_GIVE_UP_MS, clientNavigated, reloadFixes, reloadedBefore } from "../lib/stale-build"; | |
| 13 | 13 | import { isNeedsSignIn } from "../lib/website-token"; | |
| 14 | + | import { usePageNavigation } from "../lib/use-page-navigation"; | |
| 14 | 15 | ||
| 15 | 16 | /** | |
| 16 | 17 | * What a page shows in place of itself when it failed: the root's error | |
| ⋯ | |||
| 25 | 26 | */ | |
| 26 | 27 | export function RouteError({ error, contained = false }: { error: unknown; contained?: boolean }) { | |
| 27 | 28 | const location = useLocation(); | |
| 29 | + | const { owns } = usePageNavigation(); | |
| 28 | 30 | const matches = useMatches(); | |
| 29 | 31 | const revalidator = useRevalidator(); | |
| 30 | 32 | const href = location.pathname + location.search + location.hash; | |
| ⋯ | |||
| 48 | 50 | const offline = !reload && isNetworkFailure(error); | |
| 49 | 51 | useReconnect(offline); | |
| 50 | 52 | useEffect(() => { | |
| 51 | − | if (!reload) return; | |
| 53 | + | if (!reload || !owns()) return; | |
| 52 | 54 | try { | |
| 53 | 55 | sessionStorage.setItem(RELOADED_KEY, href); | |
| 54 | 56 | } catch { | |
| ⋯ | |||
| 57 | 59 | window.location.assign(href); | |
| 58 | 60 | const timer = window.setTimeout(() => setGaveUp(href), RELOAD_GIVE_UP_MS); | |
| 59 | 61 | return () => window.clearTimeout(timer); | |
| 60 | − | }, [reload, href]); | |
| 62 | + | }, [reload, href, owns]); | |
| 61 | 63 | ||
| 62 | 64 | const space = contained ? "py-16" : "py-32"; | |
| 63 | 65 | if (reload) { | |
| 58 | 58 | pagePath, | |
| 59 | 59 | workspaceSettingsPage, | |
| 60 | 60 | } from "../lib/workspace-nav"; | |
| 61 | + | import { useSidebarLocation } from "../lib/use-sidebar-location"; | |
| 61 | 62 | import { setupPath } from "../lib/onboarding"; | |
| 62 | 63 | import { ChatSidebar } from "./chat/sidebar"; | |
| 63 | 64 | import { FoliosSidebar } from "./folios/sidebar"; | |
| ⋯ | |||
| 143 | 144 | * row's, query and all. | |
| 144 | 145 | */ | |
| 145 | 146 | function useRowState(to: string, { end, also, current: lit }: { end?: boolean; also?: string | string[]; current?: boolean }) { | |
| 146 | − | const { pathname } = useLocation(); | |
| 147 | − | const going = useNavigation().location; | |
| 147 | + | const { pathname, key } = useLocation(); | |
| 148 | + | const destination = useSidebarLocation(); | |
| 149 | + | const going = destination.key !== key ? destination : undefined; | |
| 148 | 150 | const current = lit ?? (under(pathname, to, end) || [also ?? []].flat().some((prefix) => under(pathname, prefix))); | |
| 149 | 151 | const pending = | |
| 150 | 152 | going != null && !current && (lit === undefined ? under(going.pathname, to, end) : `${going.pathname}${going.search}` === to); | |
| ⋯ | |||
| 1028 | 1030 | browsing?: { owner: BrowsedOwner | null } | null; | |
| 1029 | 1031 | }) { | |
| 1030 | 1032 | const ws = shell.workspace; | |
| 1031 | − | const { pathname } = useLocation(); | |
| 1032 | − | const going = useNavigation().location?.pathname; | |
| 1033 | + | const location = useLocation(); | |
| 1034 | + | const destination = useSidebarLocation(); | |
| 1033 | 1035 | // Where the sidebar is drilled to follows the page being gone to, so it | |
| 1034 | 1036 | // moves as the link is followed, not once the page arrives. | |
| 1035 | − | const target = going ?? pathname; | |
| 1037 | + | const target = destination.pathname; | |
| 1036 | 1038 | // On a 404, the address is not a place: no list is drilled into from it, | |
| 1037 | 1039 | // except the project's own when the project is real and visible and only | |
| 1038 | 1040 | // something inside it is missing (a file, a commit, an issue). | |
| 1039 | − | const lost = missing && going == null; | |
| 1041 | + | const lost = missing && destination.key === location.key; | |
| 1040 | 1042 | const active = shell.repo; | |
| 1041 | 1043 | // In a repository, or on the way into one, its own list. | |
| 1042 | 1044 | const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target); | |
| ⋯ | |||
| 1151 | 1153 | * pages. A project of theirs drills into its own list, as in Code. | |
| 1152 | 1154 | */ | |
| 1153 | 1155 | function BrowseMenu({ user, owner }: { user: User; owner: BrowsedOwner | null }) { | |
| 1154 | − | const location = useLocation(); | |
| 1155 | − | const going = useNavigation().location; | |
| 1156 | − | const path = pagePath((going ?? location).pathname); | |
| 1157 | − | const query = new URLSearchParams((going ?? location).search); | |
| 1156 | + | const location = useSidebarLocation(); | |
| 1157 | + | const path = pagePath(location.pathname); | |
| 1158 | + | const query = new URLSearchParams(location.search); | |
| 1158 | 1159 | const atExplore = path === "/explore"; | |
| 1159 | 1160 | const fresh = atExplore && query.get("sort") === "new"; | |
| 1160 | 1161 | const mine = profilePath(user.username); | |
| ⋯ | |||
| 1269 | 1270 | * coming. The workspace's people, money and settings are elsewhere. | |
| 1270 | 1271 | */ | |
| 1271 | 1272 | function CodeMenu({ shell, slug }: { shell: ShellData; slug: string }) { | |
| 1272 | − | const { pathname } = useLocation(); | |
| 1273 | − | const going = useNavigation().location?.pathname; | |
| 1274 | − | const path = going ?? pathname; | |
| 1273 | + | const { pathname: path } = useSidebarLocation(); | |
| 1275 | 1274 | const at = (page: string) => path === `/${slug}/-/${page}` || path.startsWith(`/${slug}/-/${page}/`); | |
| 1276 | 1275 | const shared = shell.shared ?? []; | |
| 1277 | 1276 | return ( | |
| ⋯ | |||
| 1386 | 1385 | * left out for members, and a group with none left goes with them. | |
| 1387 | 1386 | */ | |
| 1388 | 1387 | export function WorkspaceSidebar({ slug, owner }: { slug: string; owner: boolean }) { | |
| 1389 | − | const { pathname } = useLocation(); | |
| 1390 | − | const going = useNavigation().location?.pathname; | |
| 1391 | − | const path = going ?? pathname; | |
| 1388 | + | const { pathname: path } = useSidebarLocation(); | |
| 1392 | 1389 | const here = workspaceSettingsPage(path, slug); | |
| 1393 | 1390 | const overview = path === `/${slug}/-/workspace` || path.startsWith(`/${slug}/-/workspace/`); | |
| 1394 | 1391 | const shown = (page: WorkspaceSettingsPage) => owner || !page.owner; | |
| ⋯ | |||
| 1428 | 1425 | * inviting people, is the workspace's: Workspace mode's Access group. | |
| 1429 | 1426 | */ | |
| 1430 | 1427 | function PeopleSidebar({ slug }: { slug: string }) { | |
| 1431 | − | const { pathname } = useLocation(); | |
| 1432 | − | const going = useNavigation().location?.pathname; | |
| 1433 | − | const path = going ?? pathname; | |
| 1428 | + | const { pathname: path } = useSidebarLocation(); | |
| 1434 | 1429 | const at = (page: string) => path === `/${slug}/-/${page}` || path.startsWith(`/${slug}/-/${page}/`); | |
| 1435 | 1430 | return ( | |
| 1436 | 1431 | <div className="flex h-full flex-col"> | |
| ⋯ | |||
| 1454 | 1449 | * and what is done; then why you were told, which narrows any of them. | |
| 1455 | 1450 | */ | |
| 1456 | 1451 | function NotificationsSidebar({ counts }: { counts: InboxCounts | null }) { | |
| 1457 | − | const { pathname, search } = useLocation(); | |
| 1452 | + | const { pathname, search } = useSidebarLocation(); | |
| 1458 | 1453 | const params = new URLSearchParams(search); | |
| 1459 | 1454 | // Saved and Done are paths (`/notifications/saved`); the rest narrows them. | |
| 1460 | 1455 | const view = notificationViewAt(pathname); | |
| ⋯ | |||
| 2018 | 2013 | const titleSearch = useTitleBarSearch(`Search ${shell.workspace?.name ?? shell.workspace?.slug ?? "g1t"}`, () => setPalette(true)); | |
| 2019 | 2014 | const leaving = useLeaving(); | |
| 2020 | 2015 | useVisualViewport(); | |
| 2021 | − | const going = useNavigation().location?.pathname; | |
| 2016 | + | const target = useSidebarLocation().pathname; | |
| 2022 | 2017 | const ws = shell.workspace; | |
| 2023 | 2018 | // Beyond your workspaces (Explore, a profile, another workspace's | |
| 2024 | 2019 | // projects), the shell is Explore's; anything of yours is its mode's. | |
| 2025 | − | const browsing = browsingOf(going ?? pathname, { | |
| 2020 | + | const browsing = browsingOf(target, { | |
| 2026 | 2021 | slug: ws?.slug ?? null, | |
| 2027 | 2022 | memberships: (user.workspaces ?? []).map((membership) => membership.slug), | |
| 2028 | 2023 | shared: shell.shared, | |
| 2029 | 2024 | username: user.username, | |
| 2030 | 2025 | }); | |
| 2031 | − | const mode: ModeKey = browsing ? "browse" : modeOf(going ?? pathname, ws?.slug ?? null); | |
| 2026 | + | const mode: ModeKey = browsing ? "browse" : modeOf(target, ws?.slug ?? null); | |
| 2032 | 2027 | const panel = sidebarFor(mode, ws != null); | |
| 2033 | 2028 | const { sidebar: chatSidebar } = useChatSidebar(); | |
| 2034 | 2029 | // Notify: the feed socket's live counts (lib/notify-client.ts) once it has them; the page's until then. | |
| ⋯ | |||
| 2108 | 2103 | ); | |
| 2109 | 2104 | ||
| 2110 | 2105 | // On a phone (below 768px): the bottom bar, and a conversation full screen. | |
| 2111 | − | const conversation = isConversation(going ?? pathname); | |
| 2106 | + | const conversation = isConversation(target); | |
| 2112 | 2107 | // Where the page panel starts: 8px past the rail (56px), or 8px past the sidebar (56 + 240px) when it is open. | |
| 2113 | 2108 | // The page starts where the rail (3.5rem) and, open, the sidebar (15rem) end. | |
| 2114 | 2109 | const pad = ws ? (inline ? "md:pl-14 lg:pl-74" : "md:pl-14") : inline ? "lg:pl-60" : ""; | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | import { createMemoryRouter, redirect } from "react-router"; | |
| 4 | + | import { pageOwnsNavigation, pendingNavigationOwns, sidebarLocation } from "./navigation-owner.ts"; | |
| 5 | + | ||
| 6 | + | test("an old page loses navigation ownership as soon as a newer click starts", async () => { | |
| 7 | + | let finish!: (value: Response) => void; | |
| 8 | + | const slow = new Promise<Response>((resolve) => { finish = resolve; }); | |
| 9 | + | const router = createMemoryRouter([ | |
| 10 | + | { path: "/home" }, | |
| 11 | + | { path: "/artifacts", loader: () => slow }, | |
| 12 | + | { path: "/code", loader: () => "latest" }, | |
| 13 | + | { path: "/old-redirect" }, | |
| 14 | + | ], { initialEntries: ["/home"] }); | |
| 15 | + | try { | |
| 16 | + | const homeKey = router.state.location.key; | |
| 17 | + | assert.equal(pageOwnsNavigation(router.state, homeKey), true); | |
| 18 | + | const first = router.navigate("/artifacts"); | |
| 19 | + | const firstKey = router.state.navigation.location?.key; | |
| 20 | + | assert.equal(sidebarLocation(router.state).pathname, "/artifacts", "sidebar follows the pending click immediately"); | |
| 21 | + | assert.equal(pageOwnsNavigation(router.state, homeKey), false, "old effects must not override a pending click"); | |
| 22 | + | assert.equal(pendingNavigationOwns(router.state.navigation, firstKey), true); | |
| 23 | + | await router.navigate("/code"); | |
| 24 | + | assert.equal(sidebarLocation(router.state).pathname, "/code"); | |
| 25 | + | finish(redirect("/old-redirect")); | |
| 26 | + | await first; | |
| 27 | + | assert.equal(router.state.location.pathname, "/code"); | |
| 28 | + | assert.equal(sidebarLocation(router.state).pathname, "/code", "late redirects cannot move the sidebar back"); | |
| 29 | + | assert.equal(pageOwnsNavigation(router.state, homeKey), false); | |
| 30 | + | assert.equal(pendingNavigationOwns(router.state.navigation, firstKey), false, "an old recovery timer must not fire"); | |
| 31 | + | await router.navigate("/home"); | |
| 32 | + | assert.equal(pageOwnsNavigation(router.state, homeKey), false, "returning to the same URL is a new visit"); | |
| 33 | + | } finally { router.dispose(); } | |
| 34 | + | }); | |
| 35 | + | ||
| 36 | + | test("repeated clicks to the same path have different recovery owners", () => { | |
| 37 | + | assert.equal(pendingNavigationOwns({ state: "loading", location: { key: "second-click" } }, "first-click"), false); | |
| 38 | + | assert.equal(pendingNavigationOwns({ state: "submitting", location: { key: "first-click" } }, "first-click"), false); | |
| 39 | + | }); | |
| 40 | + | ||
| 41 | + | for (const [firstPath, latestPath] of [["/artifacts/one", "/artifacts/two"], ["/artifacts/one", "/code"]]) { | |
| 42 | + | test(`latest sidebar destination wins: ${firstPath} then ${latestPath}`, async () => { | |
| 43 | + | let finishFirst!: (value: string) => void; | |
| 44 | + | let finishLatest!: (value: string) => void; | |
| 45 | + | const firstLoad = new Promise<string>((resolve) => { finishFirst = resolve; }); | |
| 46 | + | const latestLoad = new Promise<string>((resolve) => { finishLatest = resolve; }); | |
| 47 | + | const router = createMemoryRouter([ | |
| 48 | + | { path: "/home" }, | |
| 49 | + | { path: firstPath, loader: () => firstLoad }, | |
| 50 | + | { path: latestPath, loader: () => latestLoad }, | |
| 51 | + | ], { initialEntries: ["/home"] }); | |
| 52 | + | const seen: string[] = []; | |
| 53 | + | const unsubscribe = router.subscribe((state) => seen.push(sidebarLocation(state).pathname)); | |
| 54 | + | try { | |
| 55 | + | const first = router.navigate(firstPath); | |
| 56 | + | const latest = router.navigate(latestPath); | |
| 57 | + | assert.equal(sidebarLocation(router.state).pathname, latestPath); | |
| 58 | + | finishFirst("stale"); | |
| 59 | + | await first; | |
| 60 | + | assert.equal(router.state.location.pathname, "/home", "stale page must not commit while the latest page loads"); | |
| 61 | + | assert.equal(sidebarLocation(router.state).pathname, latestPath); | |
| 62 | + | finishLatest("latest"); | |
| 63 | + | await latest; | |
| 64 | + | assert.equal(router.state.location.pathname, latestPath); | |
| 65 | + | assert.deepEqual(seen, [firstPath, latestPath, latestPath], "subscribers never see the stale destination again"); | |
| 66 | + | } finally { unsubscribe(); router.dispose(); } | |
| 67 | + | }); | |
| 68 | + | } |
| 1 | + | /** A page's automatic work cannot override a newer navigation or a later visit. */ | |
| 2 | + | export function pageOwnsNavigation(state: { location: { key: string }; navigation: { state: string } }, key: string): boolean { | |
| 3 | + | return state.location.key === key && state.navigation.state === "idle"; | |
| 4 | + | } | |
| 5 | + | ||
| 6 | + | /** Compare navigation identities, not just paths: a repeat click is a new attempt. */ | |
| 7 | + | export function pendingNavigationOwns(state: { state: string; location?: { key: string } }, key: string | undefined): boolean { | |
| 8 | + | return key !== undefined && state.state === "loading" && state.location?.key === key; | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | export function sidebarLocation<T>(state: { location: T; navigation: { location?: T } }): T { | |
| 12 | + | return state.navigation.location ?? state.location; | |
| 13 | + | } |
| 10 | 10 | test("only an active navigation carries its build; fetchers, refreshes and writes never force a document switch", async () => { | |
| 11 | 11 | const calls: Headers[] = []; | |
| 12 | 12 | const previous = Object.getOwnPropertyDescriptor(globalThis, "window"); | |
| 13 | − | const browser = { location: new URL("https://g1t.sh/edit"), fetch: async (_input: unknown, init?: RequestInit) => { calls.push(new Headers(init?.headers)); return new Response(null); } }; | |
| 13 | + | let late: Promise<Response> | undefined; | |
| 14 | + | const browser = { location: new URL("https://g1t.sh/edit"), fetch: async (_input: unknown, init?: RequestInit) => { calls.push(new Headers(init?.headers)); return late ?? new Response(null); } }; | |
| 14 | 15 | Object.defineProperty(globalThis, "window", { value: browser, configurable: true }); | |
| 15 | 16 | let destination: { pathname: string; search: string } | undefined; | |
| 16 | 17 | const stop = watchBuildNavigation(() => destination); | |
| ⋯ | |||
| 24 | 25 | await browser.fetch("/edit.data"); | |
| 25 | 26 | await browser.fetch("https://external.example/next.data?tab=one"); | |
| 26 | 27 | assert.deepEqual(calls.map((headers) => headers.get("x-g1t-build")), [null, "old-build", null, null, null, null]); | |
| 28 | + | for (const path of ["/next.data?tab=one", "/__manifest?paths=/next"]) { | |
| 29 | + | let finish!: (response: Response) => void; | |
| 30 | + | late = new Promise<Response>((resolve) => { finish = resolve; }); | |
| 31 | + | const controller = new AbortController(); | |
| 32 | + | const abandoned = browser.fetch(path, { signal: controller.signal }); | |
| 33 | + | // A transport can finish after the second click canceled the first. | |
| 34 | + | controller.abort(); | |
| 35 | + | const rejected = assert.rejects(abandoned, { name: "AbortError" }); | |
| 36 | + | finish(new Response(null, { status: 204, headers: { "X-Remix-Reload-Document": "true" } })); | |
| 37 | + | await rejected; | |
| 38 | + | late = undefined; | |
| 39 | + | assert.equal((await browser.fetch("/newer.data")).status, 200); | |
| 40 | + | } | |
| 27 | 41 | } finally { | |
| 28 | 42 | stop(); | |
| 29 | 43 | if (previous) Object.defineProperty(globalThis, "window", previous); | |
| 198 | 198 | for (let attempt = 1; ; attempt++) { | |
| 199 | 199 | try { | |
| 200 | 200 | // A Request's body is read once; a GET has none, but clone so each try is whole. | |
| 201 | − | return await original(request ? (request.clone() as RequestInfo) : input, init); | |
| 201 | + | signal?.throwIfAborted(); | |
| 202 | + | const response = await original(request ? (request.clone() as RequestInfo) : input, init); | |
| 203 | + | // A late manifest response can trigger a document redirect before | |
| 204 | + | // the router checks cancellation. Never hand it an abandoned read. | |
| 205 | + | if (signal?.aborted) { | |
| 206 | + | void response.body?.cancel().catch(() => {}); | |
| 207 | + | signal.throwIfAborted(); | |
| 208 | + | } | |
| 209 | + | return response; | |
| 202 | 210 | } catch (error) { | |
| 203 | 211 | const wait = retryDelay(attempt); | |
| 204 | 212 | if (!isNetworkFailure(error) || signal?.aborted) throw error; |
| 1 | − | import { useEffect } from "react"; | |
| 2 | − | import { type Location, createPath, useNavigation } from "react-router"; | |
| 1 | + | import { useContext, useEffect } from "react"; | |
| 2 | + | import { type Location, createPath, useNavigation, UNSAFE_DataRouterContext } from "react-router"; | |
| 3 | + | import { pendingNavigationOwns } from "./navigation-owner.ts"; | |
| 3 | 4 | ||
| 4 | 5 | /** | |
| 5 | 6 | * How long a client navigation may load before the page gives up on it and | |
| ⋯ | |||
| 29 | 30 | * address, which either arrives or shows the server's error page. | |
| 30 | 31 | */ | |
| 31 | 32 | export function useStuckNavigationRescue(ms = STUCK_NAVIGATION_MS) { | |
| 32 | − | const target = stuckTarget(useNavigation()); | |
| 33 | + | const context = useContext(UNSAFE_DataRouterContext); | |
| 34 | + | const navigation = useNavigation(); | |
| 35 | + | const target = stuckTarget(navigation); | |
| 36 | + | const key = navigation.location?.key; | |
| 33 | 37 | useEffect(() => { | |
| 34 | 38 | if (!target) return; | |
| 35 | 39 | const timer = window.setTimeout(() => { | |
| 40 | + | if (!context || !pendingNavigationOwns(context.router.state.navigation, key)) return; | |
| 36 | 41 | console.warn(`g1t: navigation to ${target} still loading after ${ms}ms; loading it as a whole page`); | |
| 37 | 42 | window.location.assign(target); | |
| 38 | 43 | }, ms); | |
| 39 | 44 | return () => window.clearTimeout(timer); | |
| 40 | − | }, [target, ms]); | |
| 45 | + | }, [target, key, ms, context?.router]); | |
| 41 | 46 | } | |
| 1 | + | import { useCallback, useContext } from "react"; | |
| 2 | + | import { UNSAFE_DataRouterContext, useLocation, useNavigate, type NavigateOptions, type To } from "react-router"; | |
| 3 | + | import { pageOwnsNavigation } from "./navigation-owner"; | |
| 4 | + | ||
| 5 | + | /** For automatic redirects and delayed work, never for an explicit link click. */ | |
| 6 | + | export function usePageNavigation() { | |
| 7 | + | const context = useContext(UNSAFE_DataRouterContext); | |
| 8 | + | const { key } = useLocation(); | |
| 9 | + | const navigate = useNavigate(); | |
| 10 | + | const owns = useCallback(() => context != null && pageOwnsNavigation(context.router.state, key), [context?.router, key]); | |
| 11 | + | const automaticNavigate = useCallback((to: To, options?: NavigateOptions) => { | |
| 12 | + | if (owns()) return navigate(to, options); | |
| 13 | + | }, [navigate, owns]); | |
| 14 | + | return { owns, navigate: automaticNavigate }; | |
| 15 | + | } |
| 1 | + | import { useCallback, useContext, useSyncExternalStore } from "react"; | |
| 2 | + | import { UNSAFE_DataRouterContext, useLocation, useNavigation } from "react-router"; | |
| 3 | + | import { sidebarLocation } from "./navigation-owner"; | |
| 4 | + | ||
| 5 | + | /** Keep the navigation chrome on the latest destination, even while React commits a route transition. */ | |
| 6 | + | export function useSidebarLocation() { | |
| 7 | + | const context = useContext(UNSAFE_DataRouterContext); | |
| 8 | + | const location = useLocation(); | |
| 9 | + | const navigation = useNavigation(); | |
| 10 | + | const router = context?.router; | |
| 11 | + | const subscribe = useCallback((notify: () => void) => router?.subscribe(notify) ?? (() => {}), [router]); | |
| 12 | + | const snapshot = useCallback(() => router ? sidebarLocation(router.state) : location, [router, location]); | |
| 13 | + | const serverSnapshot = () => navigation.location ?? location; | |
| 14 | + | return useSyncExternalStore(subscribe, snapshot, serverSnapshot); | |
| 15 | + | } |
| 1 | 1 | import { useEffect } from "react"; | |
| 2 | − | import { Outlet, useLocation, useNavigate } from "react-router"; | |
| 2 | + | import { Outlet, useLocation } from "react-router"; | |
| 3 | + | import { usePageNavigation } from "../../lib/use-page-navigation"; | |
| 3 | 4 | ||
| 4 | 5 | import { ACCOUNT_SETTINGS, accountSettingsPage, settingsPathForHash } from "../../lib/account-settings"; | |
| 5 | 6 | ||
| ⋯ | |||
| 14 | 15 | */ | |
| 15 | 16 | export default function SettingsLayout() { | |
| 16 | 17 | const { pathname, hash } = useLocation(); | |
| 17 | − | const navigate = useNavigate(); | |
| 18 | + | const { navigate } = usePageNavigation(); | |
| 18 | 19 | useEffect(() => { | |
| 19 | 20 | const to = settingsPathForHash(hash); | |
| 20 | 21 | if (to && to !== pathname) navigate(to, { replace: true }); | |
| 1 | 1 | import { useEffect } from "react"; | |
| 2 | − | import { redirect, useNavigate } from "react-router"; | |
| 2 | + | import { redirect } from "react-router"; | |
| 3 | + | import { usePageNavigation } from "../../../lib/use-page-navigation"; | |
| 3 | 4 | ||
| 4 | 5 | import type { Route } from "./+types/index"; | |
| 5 | 6 | import { ChatUnavailable, NoChannels } from "../../../components/chat/empty"; | |
| ⋯ | |||
| 41 | 42 | } | |
| 42 | 43 | ||
| 43 | 44 | export default function ChatIndex({ loaderData, params }: Route.ComponentProps) { | |
| 44 | − | const navigate = useNavigate(); | |
| 45 | + | const { navigate } = usePageNavigation(); | |
| 45 | 46 | const { to } = loaderData; | |
| 46 | 47 | // Told it was a phone, but wider: on to the conversation after all. | |
| 47 | 48 | useEffect(() => { | |
Comments
Sign in to comment.