diff --git a/package-lock.json b/package-lock.json index 30bf8847c..469505abf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "react-hotkeys-hook": "^4.5.0", "react-icons": "^4.12.0", "react-intl": "^6.6.8", + "react-router": "^7.18.3", "vite": "^7.3.1", "vscode-jsonrpc": "^9.0.0", "vscode-languageserver-protocol": "^3.16.0", @@ -6352,6 +6353,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/core-js-compat": { "version": "3.50.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", @@ -10726,6 +10740,28 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-stately": { "version": "3.50.0", "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.50.0.tgz", @@ -11100,6 +11136,12 @@ "node": ">=20.0.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/package.json b/package.json index 2e91edfc5..43eb41652 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "react-hotkeys-hook": "^4.5.0", "react-icons": "^4.12.0", "react-intl": "^6.6.8", + "react-router": "^7.18.3", "vite": "^7.3.1", "vscode-jsonrpc": "^9.0.0", "vscode-languageserver-protocol": "^3.16.0", diff --git a/src/App.tsx b/src/App.tsx index e4691f06c..37a7bae50 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,7 @@ */ import { SharedUIProvider, ToastProvider } from "@microbit/ui"; import { polyfill } from "mobile-drag-drop"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import "./App.css"; import { DialogProvider } from "./common/use-dialogs"; import VisualViewPortCSSVariables from "./common/VisualViewportCSSVariables"; @@ -29,12 +29,12 @@ import { logDeviceStatusChange } from "./logging/analytics"; import { LoggingProvider } from "./logging/logging-hooks"; import TranslationProvider from "./messages/TranslationProvider"; import ProjectDropTarget from "./project/ProjectDropTarget"; -import { RouterProvider } from "./router-hooks"; +import { RouterProvider } from "react-router/dom"; +import { createRouter } from "./router"; import SessionSettingsProvider from "./settings/session-settings"; import SettingsProvider from "./settings/settings"; import BeforeUnloadDirtyCheck from "./workbench/BeforeUnloadDirtyCheck"; import { SelectionProvider } from "./workbench/use-selection"; -import Workbench from "./workbench/Workbench"; const isMockDeviceMode = () => // We use a cookie set from the e2e tests. Avoids having separate test and live builds. @@ -75,6 +75,7 @@ const App = () => { const deployment = useDeployment(); const { ConsentProvider } = deployment.compliance; + const router = useMemo(() => createRouter(), []); return ( <> @@ -96,15 +97,13 @@ const App = () => { - - - - - - - - - + + + + + + + diff --git a/src/documentation/search/SearchResultList.tsx b/src/documentation/search/SearchResultList.tsx index c31dd4b8f..93bae85d7 100644 --- a/src/documentation/search/SearchResultList.tsx +++ b/src/documentation/search/SearchResultList.tsx @@ -3,13 +3,20 @@ * * SPDX-License-Identifier: MIT */ -import { Divider, Link, Text } from "@microbit/ui"; +import { Divider, styled, Text } from "@microbit/ui"; import { ComponentProps } from "react"; import { FormattedMessage } from "react-intl"; +import { Link as RouterLink } from "react-router"; import { Stack } from "styled-system/jsx"; -import { RouterState, toUrl } from "../../router-hooks"; +import { link } from "styled-system/recipes"; +import { RouterState } from "../../router-hooks"; +import { createEditorUrl } from "../../urls"; import { Extract, Result } from "./common"; +// @microbit/ui's Link with react-router underneath, so the href respects the +// basename and a modifier-click opens the result in a new tab. +const Link = styled(RouterLink, link); + interface SearchResultListProps { title: string; results: Result[]; @@ -61,15 +68,13 @@ const SearchResultItem = ({ viewedResults, onViewResult, }: SearchResultItemProps) => { - const url = toUrl(navigation); - return ( { e.preventDefault(); onViewResult(id, navigation); diff --git a/src/router-hooks.test.tsx b/src/router-hooks.test.tsx new file mode 100644 index 000000000..cff888e3a --- /dev/null +++ b/src/router-hooks.test.tsx @@ -0,0 +1,110 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { act, render } from "@testing-library/react"; +import { useEffect } from "react"; +import { createMemoryRouter, RouterProvider } from "react-router"; +import { LoggingProvider } from "./logging/logging-hooks"; +import { MockLogging } from "./logging/mock"; +import { NavigationSource, RouterState, useRouterState } from "./router-hooks"; +import { editorRoutePath } from "./urls"; + +const result: { current?: ReturnType } = {}; +const state = (): RouterState => result.current![0]; +const setState = (state: RouterState, source?: NavigationSource) => + result.current![1](state, source); + +const Probe = () => { + const value = useRouterState(); + useEffect(() => { + result.current = value; + }); + return null; +}; + +const renderAt = (path: string) => { + const logging = new MockLogging(); + const router = createMemoryRouter( + [ + { + path: "", + children: [ + { path: editorRoutePath, element: }, + { path: "*", element: }, + ], + }, + ], + { initialEntries: [path] } + ); + render( + + + + ); + return { router, logging }; +}; + +describe("useRouterState", () => { + it("is empty at the root", () => { + renderAt("/"); + expect(state()).toEqual({}); + }); + + it("reads the tab and slug from the path", () => { + renderAt("/reference/display"); + expect(state()).toEqual({ + tab: "reference", + slug: { id: "display" }, + focus: false, + }); + }); + + it("ignores unknown tabs", () => { + renderAt("/nonsense/display"); + expect(state()).toEqual({}); + }); + + it("treats deeper paths as the editor with no tab", () => { + renderAt("/api/a/b"); + expect(state()).toEqual({}); + }); + + it("navigates, carrying focus in history state, and logs the source", async () => { + const { router, logging } = renderAt("/"); + await act(async () => { + setState({ tab: "api", slug: { id: "microbit" }, focus: true }, "code"); + }); + expect(router.state.location.pathname).toEqual("/api/microbit"); + expect(state()).toEqual({ + tab: "api", + slug: { id: "microbit" }, + focus: true, + }); + expect(logging.events).toEqual([ + { + type: "docs_navigate", + detail: { via: "code", surface: "api", id: "microbit" }, + }, + ]); + }); + + it("does not log without a source", async () => { + const { logging } = renderAt("/"); + await act(async () => { + setState({ tab: "ideas" }); + }); + expect(logging.events).toEqual([]); + }); + + it("gives a new state object when navigating to the same anchor again", async () => { + renderAt("/reference/display"); + const before = state(); + await act(async () => { + setState({ tab: "reference", slug: { id: "display" } }); + }); + expect(state()).not.toBe(before); + expect(state()).toEqual(before); + }); +}); diff --git a/src/router-hooks.tsx b/src/router-hooks.tsx index f9e7d1dc2..aa728b435 100644 --- a/src/router-hooks.tsx +++ b/src/router-hooks.tsx @@ -1,29 +1,26 @@ /** - * A simple custom router. We don't have pages as such, but the different UI areas - * use query parameters to keep some state, for example to allow navigating back from - * drilling down into the documentation in the side panel or making tab selections. + * Editor URL state. * - * Which UI state is encoded into the URL might be subject to change in future - * based on user feedback and discussion. + * The documentation tabs and their drill-down anchors are encoded in the URL + * so that browser navigation works for them. These hooks are the app's view + * of that state over react-router. * - * (c) 2021-2022, Micro:bit Educational Foundation and contributors + * (c) 2021-2026, Micro:bit Educational Foundation and contributors * * SPDX-License-Identifier: MIT */ -import { - createContext, - ReactNode, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; -import { baseUrl } from "./base"; +import { useCallback, useMemo } from "react"; +import { useLocation, useNavigate, useParams } from "react-router"; import { useLogging } from "./logging/logging-hooks"; +import { createEditorUrl } from "./urls"; export type TabName = "api" | "ideas" | "reference" | "project"; +const tabNames: readonly string[] = ["api", "ideas", "reference", "project"]; + +const isTabName = (value: string | undefined): value is TabName => + value !== undefined && tabNames.includes(value); + /** * An anchor-like navigation used for scroll positions. * @@ -33,8 +30,6 @@ export type TabName = "api" | "ideas" | "reference" | "project"; export interface Anchor { id: string; } -const anchorForParam = (param: string | null): Anchor | undefined => - param ? { id: param } : undefined; export interface RouterState { tab?: TabName; @@ -52,63 +47,38 @@ type RouterContextValue = [ (state: RouterState, source?: NavigationSource) => void ]; -const RouterContext = createContext(undefined); - -const parse = (pathname: string): RouterState => { - pathname = pathname.slice(baseUrl.length); - if (pathname) { - const parts = pathname.split("/"); - const tab = parts[0]; - if ( - tab === "api" || - tab === "reference" || - tab === "ideas" || - tab === "project" - ) { - return { tab, slug: anchorForParam(parts[1]) }; - } - } - return {}; -}; +/** Carried in history state rather than the URL. */ +interface LocationState { + focus?: boolean; +} /** * The full router state. - * Consider using useRouterParam instead if you only care about one parameter. + * Consider using useRouterTabSlug instead if you only care about one parameter. * * Updating the state updates the URL. * * @return a [state, setState] pair. */ export const useRouterState = (): RouterContextValue => { - const value = useContext(RouterContext); - if (!value) { - throw new Error("Missing provider!"); - } - return value; -}; + const { tab, slug } = useParams<"tab" | "slug">(); + const location = useLocation(); + const navigate = useNavigate(); + const logging = useLogging(); -export const toUrl = (state: RouterState): string => { - const parts = [state.tab, state.slug?.id]; - const pathname = baseUrl + parts.filter((x): x is string => !!x).join("/"); - return window.location.toString().split("/", 1)[0] + pathname; -}; + const focus = (location.state as LocationState | null)?.focus ?? false; + const state = useMemo( + () => + isTabName(tab) + ? { tab, slug: slug ? { id: slug } : undefined, focus } + : {}, + // location.key: navigating to the current anchor again must produce a new + // state object so that the scroll and focus effects run again. + // eslint-disable-next-line react-hooks/exhaustive-deps + [tab, slug, focus, location.key] + ); -export const RouterProvider = ({ children }: { children: ReactNode }) => { - const logging = useLogging(); - const [state, setState] = useState(() => parse(window.location.pathname)); - useEffect(() => { - // This detects browser navigation but not our programatic changes, - // so we need to update state there ourselves. - const listener = (_: PopStateEvent) => { - const newState = parse(window.location.pathname); - setState(newState); - }; - window.addEventListener("popstate", listener); - return () => { - window.removeEventListener("popstate", listener); - }; - }, [setState]); - const navigate = useCallback( + const setState = useCallback( (newState: RouterState, source?: NavigationSource) => { if (source) { logging.event({ @@ -116,19 +86,13 @@ export const RouterProvider = ({ children }: { children: ReactNode }) => { detail: { via: source, surface: newState.tab, id: newState.slug?.id }, }); } - const url = toUrl(newState); - window.history.pushState(newState, "", url); - - setState(newState); + const locationState: LocationState = { focus: newState.focus }; + void navigate(createEditorUrl(newState), { state: locationState }); }, - [logging, setState] - ); - const value: RouterContextValue = useMemo(() => { - return [state, navigate]; - }, [state, navigate]); - return ( - {children} + [logging, navigate] ); + + return useMemo(() => [state, setState], [state, setState]); }; /** diff --git a/src/router.tsx b/src/router.tsx new file mode 100644 index 000000000..e564fdc53 --- /dev/null +++ b/src/router.tsx @@ -0,0 +1,31 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { createBrowserRouter, Outlet } from "react-router"; +import ErrorBoundary from "./common/ErrorBoundary"; +import { basename, editorRoutePath } from "./urls"; +import Workbench from "./workbench/Workbench"; + +export const createRouter = () => + createBrowserRouter( + [ + { + id: "root", + path: "", + // Without this an uncaught render error unmounts the whole app. + element: ( + + + + ), + children: [ + { path: editorRoutePath, element: }, + // Deeper paths are the editor with no tab selected, as before. + { path: "*", element: }, + ], + }, + ], + { basename } + ); diff --git a/src/urls.ts b/src/urls.ts new file mode 100644 index 000000000..166405198 --- /dev/null +++ b/src/urls.ts @@ -0,0 +1,32 @@ +/** + * App URLs. + * + * Paths are relative to the router basename: react-router adds it when + * navigating or rendering a Link. Do not use them with window.location or a + * plain anchor. + * + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { baseUrl } from "./base"; +import type { RouterState } from "./router-hooks"; + +/** + * The react-router basename: the Vite base URL without its trailing slash, so + * that both `/v/3` and `/v/3/` are the editor. + */ +export const basename = + baseUrl === "/" ? undefined : baseUrl.replace(/\/$/, ""); + +/** + * Route path for the editor. The documentation tab and its anchor are + * optional segments so the editor stays mounted as they change. + */ +export const editorRoutePath = ":tab?/:slug?"; + +/** + * Path for the editor showing the given documentation tab and anchor. + */ +export const createEditorUrl = ({ tab, slug }: RouterState = {}): string => + "/" + [tab, slug?.id].filter((x): x is string => !!x).join("/"); diff --git a/src/workbench/SideBar.tsx b/src/workbench/SideBar.tsx index d2a73bd41..693a10c28 100644 --- a/src/workbench/SideBar.tsx +++ b/src/workbench/SideBar.tsx @@ -160,7 +160,7 @@ const SideBar = ({ // previous selection is retained (but not styled). Clicking it // fires no selection change, so expand from the click instead. handleTabChange(panes.findIndex((p) => p.id === id)); - } else if (slug) { + } else if (id === tab && slug) { // A click on a tab when it's already selected should // reset any other parameters so we go back to the top // level.