Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Keep latest navigation in control of pages and sidebars

syntaqxcommitted Parentc4336e0Browse files
16 files+188−480/16 viewed
+3−2
2020 X,
2121 } from "lucide-react";
2222 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";
2425
2526 import {
2627 type Channel,
185186 return () => clearTimeout(timer);
186187 }, [view, messages, members, joined, canManage]);
187188 // Renamed while open, here or by someone else: the address follows the name.
188− const navigate = useNavigate();
189+ const { navigate } = usePageNavigation();
189190 const nameShown = useRef(data.channel.name);
190191 useEffect(() => {
191192 if (channel.kind !== "channel" || channel.name === nameShown.current || !slug) return;
+3−2
1+import { useSidebarLocation } from "../../lib/use-sidebar-location";
12 import { ArrowLeft, BellOff, Check, ChevronDown, Compass, Globe, Hash, Lock, Pin, PinOff, Search, X } from "lucide-react";
23 import { ContextMenu } from "radix-ui";
34 import { type ReactNode, useEffect, useState } from "react";
4−import { NavLink, useFetcher, useLocation, useNavigate } from "react-router";
5+import { NavLink, useFetcher, useNavigate } from "react-router";
56
67 import type { Channel, ChannelInvitation, ChatSidebarEntry, GuestChannel } from "@g1t/contracts";
78
5859 const [filter, setFilter] = useState<ChatFilter>("all");
5960 const [query, setQuery] = useState("");
6061 const navigate = useNavigate();
61− const { pathname } = useLocation();
62+ const { pathname } = useSidebarLocation();
6263
6364 useEffect(() => {
6465 if (!data) return;
+3−2
99 import type { FolioSuggestion, FoliosLiveEvent, MemberProfile } from "@g1t/contracts";
1010 import { AlertTriangle, Check, MessageSquareQuote, SmilePlus, Sparkles } from "lucide-react";
1111 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";
1314
1415 import { usercontentFrom } from "../../../lib/addresses";
1516 import { canDo, readingTime } from "../../../lib/folios";
3738 const layout = useFoliosData();
3839 const root = useRouteLoaderData("root");
3940 const usercontent = usercontentFrom(root);
40− const navigate = useNavigate();
41+ const { navigate } = usePageNavigation();
4142 const revalidator = useRevalidator();
4243 const hydrated = useHydrated();
4344 const editable = canDo(role, "edit");
+3−2
1+import { useSidebarLocation } from "../../lib/use-sidebar-location";
12 /**
23 * Artifacts mode's sidebar (beside the rail; docs.g1t.sh/guides/artifacts/,
34 * "The sidebar"): search, Home, New and Templates; Favorites; the spaces
1011 import { FOLIO_KINDS, folioIdFrom, folioSlug, type DocRepoSpace, type FolioKind, type FolioRef, type FolioTreeNode, type FoliosSidebarSpace } from "@g1t/contracts";
1112 import { AlertTriangle, ChevronDown, ChevronRight, Compass, FileText, Folder, FolderGit2, Home, LayoutTemplate, Lock, Plus, Search, Settings, Star, Trash2, X } from "lucide-react";
1213 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";
1415
1516 import { buildTree, canDo, pathTo, repoFilePath, repoFolders, spacePath, type RepoFolder, type TreeItem } from "../../lib/folios";
1617 import { Button } from "../ui/button";
308309
309310 /** A project's docs folder: read-only, its files as folders, from the repository's default branch. */
310311 function RepoTree({ slug, space }: { slug: string; space: DocRepoSpace }) {
311− const { pathname } = useLocation();
312+ const { pathname } = useSidebarLocation();
312313 const base = `/${slug}/-/artifacts/repo/${space.repo}/`;
313314 const here = decodeURIComponent(pathname).startsWith(base);
314315 const [collapsed, setCollapsed] = useState(!here);
+3−4
1+import { useSidebarLocation } from "../lib/use-sidebar-location";
12 import { Check, ChevronDown, CircleUserRound, Compass, LayoutGrid, Plus, Settings } from "lucide-react";
23 import { Fragment, type ReactNode, useState } from "react";
3−import { Link, useLocation, useNavigation } from "react-router";
4+import { Link } from "react-router";
45
56 import { type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts";
67
224225 unread: { notifications: number; chat: number; mentions: number };
225226 account: ReactNode;
226227 }) {
227− const { pathname } = useLocation();
228− const going = useNavigation().location?.pathname;
229− const path = going ?? pathname;
228+ const { pathname: path } = useSidebarLocation();
230229 const here = mode ?? modeOf(path, workspace.slug);
231230 const slug = workspace.slug;
232231 const code = hasCodeAccess(workspace);
+4−2
1111 import { isNetworkFailure } from "../lib/resilient-fetch";
1212 import { RELOADED_KEY, RELOAD_GIVE_UP_MS, clientNavigated, reloadFixes, reloadedBefore } from "../lib/stale-build";
1313 import { isNeedsSignIn } from "../lib/website-token";
14+import { usePageNavigation } from "../lib/use-page-navigation";
1415
1516 /**
1617 * What a page shows in place of itself when it failed: the root's error
2526 */
2627 export function RouteError({ error, contained = false }: { error: unknown; contained?: boolean }) {
2728 const location = useLocation();
29+ const { owns } = usePageNavigation();
2830 const matches = useMatches();
2931 const revalidator = useRevalidator();
3032 const href = location.pathname + location.search + location.hash;
4850 const offline = !reload && isNetworkFailure(error);
4951 useReconnect(offline);
5052 useEffect(() => {
51− if (!reload) return;
53+ if (!reload || !owns()) return;
5254 try {
5355 sessionStorage.setItem(RELOADED_KEY, href);
5456 } catch {
5759 window.location.assign(href);
5860 const timer = window.setTimeout(() => setGaveUp(href), RELOAD_GIVE_UP_MS);
5961 return () => window.clearTimeout(timer);
60− }, [reload, href]);
62+ }, [reload, href, owns]);
6163
6264 const space = contained ? "py-16" : "py-32";
6365 if (reload) {
+19−24
5858 pagePath,
5959 workspaceSettingsPage,
6060 } from "../lib/workspace-nav";
61+import { useSidebarLocation } from "../lib/use-sidebar-location";
6162 import { setupPath } from "../lib/onboarding";
6263 import { ChatSidebar } from "./chat/sidebar";
6364 import { FoliosSidebar } from "./folios/sidebar";
143144 * row's, query and all.
144145 */
145146 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;
148150 const current = lit ?? (under(pathname, to, end) || [also ?? []].flat().some((prefix) => under(pathname, prefix)));
149151 const pending =
150152 going != null && !current && (lit === undefined ? under(going.pathname, to, end) : `${going.pathname}${going.search}` === to);
10281030 browsing?: { owner: BrowsedOwner | null } | null;
10291031 }) {
10301032 const ws = shell.workspace;
1031− const { pathname } = useLocation();
1032− const going = useNavigation().location?.pathname;
1033+ const location = useLocation();
1034+ const destination = useSidebarLocation();
10331035 // Where the sidebar is drilled to follows the page being gone to, so it
10341036 // moves as the link is followed, not once the page arrives.
1035− const target = going ?? pathname;
1037+ const target = destination.pathname;
10361038 // On a 404, the address is not a place: no list is drilled into from it,
10371039 // except the project's own when the project is real and visible and only
10381040 // 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;
10401042 const active = shell.repo;
10411043 // In a repository, or on the way into one, its own list.
10421044 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
11511153 * pages. A project of theirs drills into its own list, as in Code.
11521154 */
11531155 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);
11581159 const atExplore = path === "/explore";
11591160 const fresh = atExplore && query.get("sort") === "new";
11601161 const mine = profilePath(user.username);
12691270 * coming. The workspace's people, money and settings are elsewhere.
12701271 */
12711272 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();
12751274 const at = (page: string) => path === `/${slug}/-/${page}` || path.startsWith(`/${slug}/-/${page}/`);
12761275 const shared = shell.shared ?? [];
12771276 return (
13861385 * left out for members, and a group with none left goes with them.
13871386 */
13881387 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();
13921389 const here = workspaceSettingsPage(path, slug);
13931390 const overview = path === `/${slug}/-/workspace` || path.startsWith(`/${slug}/-/workspace/`);
13941391 const shown = (page: WorkspaceSettingsPage) => owner || !page.owner;
14281425 * inviting people, is the workspace's: Workspace mode's Access group.
14291426 */
14301427 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();
14341429 const at = (page: string) => path === `/${slug}/-/${page}` || path.startsWith(`/${slug}/-/${page}/`);
14351430 return (
14361431 <div className="flex h-full flex-col">
14541449 * and what is done; then why you were told, which narrows any of them.
14551450 */
14561451 function NotificationsSidebar({ counts }: { counts: InboxCounts | null }) {
1457− const { pathname, search } = useLocation();
1452+ const { pathname, search } = useSidebarLocation();
14581453 const params = new URLSearchParams(search);
14591454 // Saved and Done are paths (`/notifications/saved`); the rest narrows them.
14601455 const view = notificationViewAt(pathname);
20182013 const titleSearch = useTitleBarSearch(`Search ${shell.workspace?.name ?? shell.workspace?.slug ?? "g1t"}`, () => setPalette(true));
20192014 const leaving = useLeaving();
20202015 useVisualViewport();
2021− const going = useNavigation().location?.pathname;
2016+ const target = useSidebarLocation().pathname;
20222017 const ws = shell.workspace;
20232018 // Beyond your workspaces (Explore, a profile, another workspace's
20242019 // projects), the shell is Explore's; anything of yours is its mode's.
2025− const browsing = browsingOf(going ?? pathname, {
2020+ const browsing = browsingOf(target, {
20262021 slug: ws?.slug ?? null,
20272022 memberships: (user.workspaces ?? []).map((membership) => membership.slug),
20282023 shared: shell.shared,
20292024 username: user.username,
20302025 });
2031− const mode: ModeKey = browsing ? "browse" : modeOf(going ?? pathname, ws?.slug ?? null);
2026+ const mode: ModeKey = browsing ? "browse" : modeOf(target, ws?.slug ?? null);
20322027 const panel = sidebarFor(mode, ws != null);
20332028 const { sidebar: chatSidebar } = useChatSidebar();
20342029 // Notify: the feed socket's live counts (lib/notify-client.ts) once it has them; the page's until then.
21082103 );
21092104
21102105 // On a phone (below 768px): the bottom bar, and a conversation full screen.
2111− const conversation = isConversation(going ?? pathname);
2106+ const conversation = isConversation(target);
21122107 // Where the page panel starts: 8px past the rail (56px), or 8px past the sidebar (56 + 240px) when it is open.
21132108 // The page starts where the rail (3.5rem) and, open, the sidebar (15rem) end.
21142109 const pad = ws ? (inline ? "md:pl-14 lg:pl-74" : "md:pl-14") : inline ? "lg:pl-60" : "";
+68−0
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+}
+13−0
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+}
+15−1
1010 test("only an active navigation carries its build; fetchers, refreshes and writes never force a document switch", async () => {
1111 const calls: Headers[] = [];
1212 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); } };
1415 Object.defineProperty(globalThis, "window", { value: browser, configurable: true });
1516 let destination: { pathname: string; search: string } | undefined;
1617 const stop = watchBuildNavigation(() => destination);
2425 await browser.fetch("/edit.data");
2526 await browser.fetch("https://external.example/next.data?tab=one");
2627 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+ }
2741 } finally {
2842 stop();
2943 if (previous) Object.defineProperty(globalThis, "window", previous);
+9−1
198198 for (let attempt = 1; ; attempt++) {
199199 try {
200200 // 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;
202210 } catch (error) {
203211 const wait = retryDelay(attempt);
204212 if (!isNetworkFailure(error) || signal?.aborted) throw error;
+9−4
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";
34
45 /**
56 * How long a client navigation may load before the page gives up on it and
2930 * address, which either arrives or shows the server's error page.
3031 */
3132 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;
3337 useEffect(() => {
3438 if (!target) return;
3539 const timer = window.setTimeout(() => {
40+ if (!context || !pendingNavigationOwns(context.router.state.navigation, key)) return;
3641 console.warn(`g1t: navigation to ${target} still loading after ${ms}ms; loading it as a whole page`);
3742 window.location.assign(target);
3843 }, ms);
3944 return () => window.clearTimeout(timer);
40− }, [target, ms]);
45+ }, [target, key, ms, context?.router]);
4146 }
+15−0
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+}
+15−0
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+}
+3−2
11 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";
34
45 import { ACCOUNT_SETTINGS, accountSettingsPage, settingsPathForHash } from "../../lib/account-settings";
56
1415 */
1516 export default function SettingsLayout() {
1617 const { pathname, hash } = useLocation();
17− const navigate = useNavigate();
18+ const { navigate } = usePageNavigation();
1819 useEffect(() => {
1920 const to = settingsPathForHash(hash);
2021 if (to && to !== pathname) navigate(to, { replace: true });
+3−2
11 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";
34
45 import type { Route } from "./+types/index";
56 import { ChatUnavailable, NoChannels } from "../../../components/chat/empty";
4142 }
4243
4344 export default function ChatIndex({ loaderData, params }: Route.ComponentProps) {
44− const navigate = useNavigate();
45+ const { navigate } = usePageNavigation();
4546 const { to } = loaderData;
4647 // Told it was a phone, but wider: on to the conversation after all.
4748 useEffect(() => {

Comments

Sign in to comment.