Skip to content

Commit 2207609

Browse files
Move the editor's URL state onto react-router
Replaces the bespoke pushState/popstate router with react-router 7, as ml-trainer uses, ahead of adding pages beyond the editor. URLs are unchanged: the editor stays at the base URL with the documentation tab and anchor as optional path segments, and deeper paths remain the editor with no tab selected. - urls.ts holds the basename (derived from the Vite base URL) and the editor route path and link builder, used for both routes and links. - router.tsx creates the router with a root route whose errorElement logs the error and shows the existing content-load-error message, rather than an uncaught render error unmounting the whole app. - router-hooks.tsx keeps the useRouterState/useRouterTabSlug API over useParams/useNavigate/useLocation. Focus travels in history state and the state identity is tied to location.key so navigating to the same anchor again re-runs scroll and focus effects, as before. - Search results use react-router's Link so the href respects the basename (the old toUrl produced a broken absolute URL). - The sidebar only resets the anchor when the clicked tab is the current one. It previously reset whenever an anchor was set and relied on the old router having updated state synchronously between pointer-up and click; with react-router that made clicking another tab from a deep link land on the current tab's top level.
1 parent 11462f0 commit 2207609

9 files changed

Lines changed: 291 additions & 93 deletions

File tree

package-lock.json

Lines changed: 42 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"react-hotkeys-hook": "^4.5.0",
5151
"react-icons": "^4.12.0",
5252
"react-intl": "^6.6.8",
53+
"react-router": "^7.18.3",
5354
"vite": "^7.3.1",
5455
"vscode-jsonrpc": "^9.0.0",
5556
"vscode-languageserver-protocol": "^3.16.0",

src/App.tsx

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66
import { SharedUIProvider, ToastProvider } from "@microbit/ui";
77
import { polyfill } from "mobile-drag-drop";
8-
import { useEffect } from "react";
8+
import { useEffect, useMemo } from "react";
99
import "./App.css";
1010
import { DialogProvider } from "./common/use-dialogs";
1111
import VisualViewPortCSSVariables from "./common/VisualViewportCSSVariables";
@@ -29,12 +29,12 @@ import { logDeviceStatusChange } from "./logging/analytics";
2929
import { LoggingProvider } from "./logging/logging-hooks";
3030
import TranslationProvider from "./messages/TranslationProvider";
3131
import ProjectDropTarget from "./project/ProjectDropTarget";
32-
import { RouterProvider } from "./router-hooks";
32+
import { RouterProvider } from "react-router/dom";
33+
import { createRouter } from "./router";
3334
import SessionSettingsProvider from "./settings/session-settings";
3435
import SettingsProvider from "./settings/settings";
3536
import BeforeUnloadDirtyCheck from "./workbench/BeforeUnloadDirtyCheck";
3637
import { SelectionProvider } from "./workbench/use-selection";
37-
import Workbench from "./workbench/Workbench";
3838

3939
const isMockDeviceMode = () =>
4040
// We use a cookie set from the e2e tests. Avoids having separate test and live builds.
@@ -75,6 +75,7 @@ const App = () => {
7575

7676
const deployment = useDeployment();
7777
const { ConsentProvider } = deployment.compliance;
78+
const router = useMemo(() => createRouter(), []);
7879
return (
7980
<>
8081
<VisualViewPortCSSVariables />
@@ -96,15 +97,13 @@ const App = () => {
9697
<SearchProvider>
9798
<SelectionProvider>
9899
<DialogProvider>
99-
<RouterProvider>
100-
<ConsentProvider>
101-
<ProjectDropTarget>
102-
<ActiveEditorProvider>
103-
<Workbench />
104-
</ActiveEditorProvider>
105-
</ProjectDropTarget>
106-
</ConsentProvider>
107-
</RouterProvider>
100+
<ConsentProvider>
101+
<ProjectDropTarget>
102+
<ActiveEditorProvider>
103+
<RouterProvider router={router} />
104+
</ActiveEditorProvider>
105+
</ProjectDropTarget>
106+
</ConsentProvider>
108107
</DialogProvider>
109108
</SelectionProvider>
110109
</SearchProvider>

src/documentation/search/SearchResultList.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,20 @@
33
*
44
* SPDX-License-Identifier: MIT
55
*/
6-
import { Divider, Link, Text } from "@microbit/ui";
6+
import { Divider, styled, Text } from "@microbit/ui";
77
import { ComponentProps } from "react";
88
import { FormattedMessage } from "react-intl";
9+
import { Link as RouterLink } from "react-router";
910
import { Stack } from "styled-system/jsx";
10-
import { RouterState, toUrl } from "../../router-hooks";
11+
import { link } from "styled-system/recipes";
12+
import { RouterState } from "../../router-hooks";
13+
import { createEditorUrl } from "../../urls";
1114
import { Extract, Result } from "./common";
1215

16+
// @microbit/ui's Link with react-router underneath, so the href respects the
17+
// basename and a modifier-click opens the result in a new tab.
18+
const Link = styled(RouterLink, link);
19+
1320
interface SearchResultListProps {
1421
title: string;
1522
results: Result[];
@@ -61,15 +68,13 @@ const SearchResultItem = ({
6168
viewedResults,
6269
onViewResult,
6370
}: SearchResultItemProps) => {
64-
const url = toUrl(navigation);
65-
6671
return (
6772
<Stack pl="3px" pr="3px">
6873
<Link
6974
variant="standalone"
7075
bgColor={viewedResults.includes(id) ? "#efedf5" : "unset"}
7176
borderRadius="md"
72-
href={url}
77+
to={createEditorUrl(navigation)}
7378
onClick={(e) => {
7479
e.preventDefault();
7580
onViewResult(id, navigation);

src/router-hooks.test.tsx

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* (c) 2026, Micro:bit Educational Foundation and contributors
3+
*
4+
* SPDX-License-Identifier: MIT
5+
*/
6+
import { act, render } from "@testing-library/react";
7+
import { useEffect } from "react";
8+
import { createMemoryRouter, RouterProvider } from "react-router";
9+
import { LoggingProvider } from "./logging/logging-hooks";
10+
import { MockLogging } from "./logging/mock";
11+
import { NavigationSource, RouterState, useRouterState } from "./router-hooks";
12+
import { editorRoutePath } from "./urls";
13+
14+
const result: { current?: ReturnType<typeof useRouterState> } = {};
15+
const state = (): RouterState => result.current![0];
16+
const setState = (state: RouterState, source?: NavigationSource) =>
17+
result.current![1](state, source);
18+
19+
const Probe = () => {
20+
const value = useRouterState();
21+
useEffect(() => {
22+
result.current = value;
23+
});
24+
return null;
25+
};
26+
27+
const renderAt = (path: string) => {
28+
const logging = new MockLogging();
29+
const router = createMemoryRouter(
30+
[
31+
{
32+
path: "",
33+
children: [
34+
{ path: editorRoutePath, element: <Probe /> },
35+
{ path: "*", element: <Probe /> },
36+
],
37+
},
38+
],
39+
{ initialEntries: [path] }
40+
);
41+
render(
42+
<LoggingProvider value={logging}>
43+
<RouterProvider router={router} />
44+
</LoggingProvider>
45+
);
46+
return { router, logging };
47+
};
48+
49+
describe("useRouterState", () => {
50+
it("is empty at the root", () => {
51+
renderAt("/");
52+
expect(state()).toEqual({});
53+
});
54+
55+
it("reads the tab and slug from the path", () => {
56+
renderAt("/reference/display");
57+
expect(state()).toEqual({
58+
tab: "reference",
59+
slug: { id: "display" },
60+
focus: false,
61+
});
62+
});
63+
64+
it("ignores unknown tabs", () => {
65+
renderAt("/nonsense/display");
66+
expect(state()).toEqual({});
67+
});
68+
69+
it("treats deeper paths as the editor with no tab", () => {
70+
renderAt("/api/a/b");
71+
expect(state()).toEqual({});
72+
});
73+
74+
it("navigates, carrying focus in history state, and logs the source", async () => {
75+
const { router, logging } = renderAt("/");
76+
await act(async () => {
77+
setState({ tab: "api", slug: { id: "microbit" }, focus: true }, "code");
78+
});
79+
expect(router.state.location.pathname).toEqual("/api/microbit");
80+
expect(state()).toEqual({
81+
tab: "api",
82+
slug: { id: "microbit" },
83+
focus: true,
84+
});
85+
expect(logging.events).toEqual([
86+
{
87+
type: "docs_navigate",
88+
detail: { via: "code", surface: "api", id: "microbit" },
89+
},
90+
]);
91+
});
92+
93+
it("does not log without a source", async () => {
94+
const { logging } = renderAt("/");
95+
await act(async () => {
96+
setState({ tab: "ideas" });
97+
});
98+
expect(logging.events).toEqual([]);
99+
});
100+
101+
it("gives a new state object when navigating to the same anchor again", async () => {
102+
renderAt("/reference/display");
103+
const before = state();
104+
await act(async () => {
105+
setState({ tab: "reference", slug: { id: "display" } });
106+
});
107+
expect(state()).not.toBe(before);
108+
expect(state()).toEqual(before);
109+
});
110+
});

0 commit comments

Comments
 (0)