Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 11 additions & 12 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
Expand Down Expand Up @@ -75,6 +75,7 @@ const App = () => {

const deployment = useDeployment();
const { ConsentProvider } = deployment.compliance;
const router = useMemo(() => createRouter(), []);
return (
<>
<VisualViewPortCSSVariables />
Expand All @@ -96,15 +97,13 @@ const App = () => {
<SearchProvider>
<SelectionProvider>
<DialogProvider>
<RouterProvider>
<ConsentProvider>
<ProjectDropTarget>
<ActiveEditorProvider>
<Workbench />
</ActiveEditorProvider>
</ProjectDropTarget>
</ConsentProvider>
</RouterProvider>
<ConsentProvider>
<ProjectDropTarget>
<ActiveEditorProvider>
<RouterProvider router={router} />
</ActiveEditorProvider>
</ProjectDropTarget>
</ConsentProvider>
</DialogProvider>
</SelectionProvider>
</SearchProvider>
Expand Down
15 changes: 10 additions & 5 deletions src/documentation/search/SearchResultList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -61,15 +68,13 @@ const SearchResultItem = ({
viewedResults,
onViewResult,
}: SearchResultItemProps) => {
const url = toUrl(navigation);

return (
<Stack pl="3px" pr="3px">
<Link
variant="standalone"
bgColor={viewedResults.includes(id) ? "#efedf5" : "unset"}
borderRadius="md"
href={url}
to={createEditorUrl(navigation)}
onClick={(e) => {
e.preventDefault();
onViewResult(id, navigation);
Expand Down
110 changes: 110 additions & 0 deletions src/router-hooks.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useRouterState> } = {};
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: <Probe /> },
{ path: "*", element: <Probe /> },
],
},
],
{ initialEntries: [path] }
);
render(
<LoggingProvider value={logging}>
<RouterProvider router={router} />
</LoggingProvider>
);
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);
});
});
Loading