@tomehq/theme 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/chunk-2APCPR2Y.js +2110 -0
  3. package/dist/chunk-2AXAEADQ.js +2525 -0
  4. package/dist/chunk-2WNOJXK3.js +2581 -0
  5. package/dist/chunk-37JI6XGT.js +1720 -0
  6. package/dist/chunk-3A2LPGUL.js +1991 -0
  7. package/dist/chunk-3I2QTWTW.js +1948 -0
  8. package/dist/chunk-3I4SJMER.js +2538 -0
  9. package/dist/chunk-45M5UIAB.js +2110 -0
  10. package/dist/chunk-462AGU3S.js +1959 -0
  11. package/dist/chunk-5GVQFIPI.js +2581 -0
  12. package/dist/chunk-7MUTU5D4.js +1720 -0
  13. package/dist/chunk-7NQ4IMDY.js +2294 -0
  14. package/dist/chunk-ABNPB6BB.js +2133 -0
  15. package/dist/chunk-BZGWSKT2.js +573 -0
  16. package/dist/chunk-BZIB2LMI.js +2519 -0
  17. package/dist/chunk-CMQCNCSY.js +2127 -0
  18. package/dist/chunk-CTPOZMMK.js +1703 -0
  19. package/dist/chunk-DKSQZLWR.js +2569 -0
  20. package/dist/chunk-DO544M3G.js +1702 -0
  21. package/dist/chunk-DPKZBFQP.js +1777 -0
  22. package/dist/chunk-EK7PZUEB.js +2147 -0
  23. package/dist/chunk-FMOLIHQF.js +2182 -0
  24. package/dist/chunk-FWBTK5TL.js +1444 -0
  25. package/dist/chunk-GDQIBNX5.js +1962 -0
  26. package/dist/chunk-GHQ2MODM.js +2127 -0
  27. package/dist/chunk-GR2WCRGK.js +2182 -0
  28. package/dist/chunk-H5XZVNBW.js +2291 -0
  29. package/dist/chunk-HNLKDQ64.js +2139 -0
  30. package/dist/chunk-INUMUXN5.js +2095 -0
  31. package/dist/chunk-IW3NHNOQ.js +2187 -0
  32. package/dist/chunk-JA4PMX6M.js +1500 -0
  33. package/dist/chunk-JSPFS7G5.js +2102 -0
  34. package/dist/chunk-JZRT4WNC.js +1441 -0
  35. package/dist/chunk-KQBY2JDB.js +2112 -0
  36. package/dist/chunk-LIMYFTPC.js +1468 -0
  37. package/dist/chunk-LIY62BGC.js +2519 -0
  38. package/dist/chunk-MEP7P6A7.js +1500 -0
  39. package/dist/chunk-MHYKO7KM.js +2570 -0
  40. package/dist/chunk-NOZBIES7.js +1948 -0
  41. package/dist/chunk-O4GH3KYX.js +1712 -0
  42. package/dist/chunk-OEDJTH5F.js +2569 -0
  43. package/dist/chunk-OEXM3BEC.js +1702 -0
  44. package/dist/chunk-PGKSFQ7A.js +2459 -0
  45. package/dist/chunk-PIV6CPY2.js +2395 -0
  46. package/dist/chunk-Q7PYTVW3.js +1771 -0
  47. package/dist/chunk-QCWZYABW.js +1468 -0
  48. package/dist/chunk-QYINBNMJ.js +2545 -0
  49. package/dist/chunk-RDF25WB2.js +2085 -0
  50. package/dist/chunk-RKTT3ZEX.js +1500 -0
  51. package/dist/chunk-S47BRMNQ.js +1715 -0
  52. package/dist/chunk-S4ZH5F56.js +1949 -0
  53. package/dist/chunk-SRD7NJHS.js +1949 -0
  54. package/dist/chunk-SWFYJO5H.js +2187 -0
  55. package/dist/chunk-TQDWPSTO.js +2087 -0
  56. package/dist/chunk-TTRXRPP6.js +1941 -0
  57. package/dist/chunk-UKYFJSUA.js +509 -0
  58. package/dist/chunk-VKEQHP2E.js +2133 -0
  59. package/dist/chunk-VUT2FMSI.js +1937 -0
  60. package/dist/chunk-VVCC5JHK.js +1949 -0
  61. package/dist/chunk-W732TVBK.js +1944 -0
  62. package/dist/chunk-X4VQYPKO.js +1768 -0
  63. package/dist/chunk-YX7HV4EP.js +2568 -0
  64. package/dist/chunk-YXKONM3A.js +2192 -0
  65. package/dist/chunk-YZ3P3TNS.js +1760 -0
  66. package/dist/chunk-ZVZ7JN3V.js +2568 -0
  67. package/dist/chunk-ZXW4STTN.js +2568 -0
  68. package/dist/entry.js +1 -1
  69. package/dist/index.js +1 -1
  70. package/package.json +3 -3
  71. package/src/Shell.test.tsx +25 -0
  72. package/src/Shell.tsx +8 -8
  73. package/src/entry-helpers.test.ts +60 -12
  74. package/src/entry-helpers.ts +56 -30
  75. package/src/entry.test.tsx +208 -3
  76. package/src/entry.tsx +28 -2
package/dist/entry.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  entry_default
3
- } from "./chunk-MSXVVBDW.js";
3
+ } from "./chunk-DKSQZLWR.js";
4
4
  export {
5
5
  entry_default as default
6
6
  };
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  Shell,
4
4
  THEME_PRESETS,
5
5
  entry_default
6
- } from "./chunk-MSXVVBDW.js";
6
+ } from "./chunk-DKSQZLWR.js";
7
7
  export {
8
8
  AiChat,
9
9
  entry_default as App,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomehq/theme",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Tome default theme and React app shell",
5
5
  "type": "module",
6
6
  "main": "./src/index.tsx",
@@ -9,8 +9,8 @@
9
9
  "./entry": "./src/entry.tsx"
10
10
  },
11
11
  "dependencies": {
12
- "@tomehq/components": "0.3.3",
13
- "@tomehq/core": "0.3.3"
12
+ "@tomehq/core": "0.3.4",
13
+ "@tomehq/components": "0.3.3"
14
14
  },
15
15
  "peerDependencies": {
16
16
  "react": "^18.0.0 || ^19.0.0",
@@ -858,6 +858,31 @@ describe("Shell feedback widget", () => {
858
858
  fireEvent.click(screen.getByText("\uD83D\uDC4E"));
859
859
  expect(screen.getByText("Thanks for your feedback!")).toBeInTheDocument();
860
860
  });
861
+
862
+ it("feedback section is inside the scrollable content container, not clipped by root", () => {
863
+ renderShell();
864
+ const feedbackEl = screen.getByText("Was this helpful?");
865
+ // The feedback widget must be a descendant of the overflow:auto scroll container
866
+ const scrollContainer = feedbackEl.closest('[style*="overflow: auto"]') || feedbackEl.closest('[style*="overflow:auto"]');
867
+ expect(scrollContainer).not.toBeNull();
868
+ // The root container must use overflow:clip (not hidden) to avoid clip containment issues
869
+ const root = feedbackEl.closest(".tome-grain");
870
+ expect(root).not.toBeNull();
871
+ expect((root as HTMLElement).style.overflow).toBe("clip");
872
+ });
873
+
874
+ it("feedback section remains in DOM and is not conditionally removed", () => {
875
+ const { container } = renderShell();
876
+ // Feedback must always render inside <main>
877
+ const main = container.querySelector("main");
878
+ expect(main).not.toBeNull();
879
+ const feedbackText = within(main!).getByText("Was this helpful?");
880
+ expect(feedbackText).toBeInTheDocument();
881
+ // Verify feedback is not position:fixed or absolute (it flows with content)
882
+ const feedbackParent = feedbackText.closest("div")!;
883
+ expect(feedbackParent.style.position).not.toBe("fixed");
884
+ expect(feedbackParent.style.position).not.toBe("absolute");
885
+ });
861
886
  });
862
887
 
863
888
  // ── Breadcrumbs ──────────────────────────────────────────
package/src/Shell.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useRef, useCallback } from "react";
1
+ import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from "react";
2
2
  import { THEME_PRESETS, type PresetName } from "./presets.js";
3
3
  import { AiChat } from "./AiChat.js";
4
4
 
@@ -611,14 +611,13 @@ export function Shell({
611
611
 
612
612
  // Set HTML content via ref so React doesn't re-set innerHTML on re-renders
613
613
  // (scroll-spy state changes would otherwise destroy client-side mermaid SVGs)
614
- useEffect(() => {
614
+ // useLayoutEffect ensures innerHTML is set synchronously before paint — no flash
615
+ useLayoutEffect(() => {
615
616
  if (!htmlContentRef.current || !pageHtml) return;
616
617
  const stripped = pageHtml.replace(/<h1[^>]*>[\s\S]*?<\/h1>\s*/, "");
617
- if (stripped !== lastHtmlRef.current) {
618
- htmlContentRef.current.innerHTML = stripped;
619
- lastHtmlRef.current = stripped;
620
- }
621
- }, [pageHtml]);
618
+ htmlContentRef.current.innerHTML = stripped;
619
+ lastHtmlRef.current = stripped;
620
+ }, [pageHtml, currentPageId]);
622
621
 
623
622
  // Smooth scroll handler for TOC links
624
623
  const scrollToHeading = useCallback((e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
@@ -669,7 +668,7 @@ export function Shell({
669
668
  const bannerIsInternal = bannerLink ? (bannerLink.startsWith("#") || (basePath && bannerLink.startsWith(basePath + "/"))) : false;
670
669
 
671
670
  return (
672
- <div dir={dir} className="tome-grain" style={{ ...cssVars as React.CSSProperties, color: "var(--tx)", background: "var(--bg)", fontFamily: "var(--font-body)", minHeight: "100vh", overflow: "hidden" }}>
671
+ <div dir={dir} className="tome-grain" style={{ ...cssVars as React.CSSProperties, color: "var(--tx)", background: "var(--bg)", fontFamily: "var(--font-body)", height: "100vh", overflow: "clip" }}>
673
672
  {/* Banner */}
674
673
  {config.banner?.text && !bannerDismissed && (
675
674
  <div style={{
@@ -1179,6 +1178,7 @@ export function Shell({
1179
1178
  </div>
1180
1179
  ) : (
1181
1180
  <div
1181
+ key={currentPageId}
1182
1182
  className="tome-content"
1183
1183
  ref={htmlContentRef}
1184
1184
  />
@@ -4,6 +4,9 @@ import {
4
4
  resolveInitialPageId,
5
5
  loadPage,
6
6
  detectCurrentVersion,
7
+ PageNotFoundError,
8
+ PageLoadError,
9
+ NavigationCancelledError,
7
10
  } from "./entry-helpers.js";
8
11
  import type { MinimalRoute } from "./routing.js";
9
12
 
@@ -196,22 +199,16 @@ describe("loadPage", () => {
196
199
  }
197
200
  });
198
201
 
199
- it("returns null when module has no default export", async () => {
202
+ it("throws PageNotFoundError when module has no default export", async () => {
200
203
  const mockLoader = vi.fn().mockResolvedValue({ default: null });
201
- const page = await loadPage("quickstart", routesWithMeta, mockLoader);
202
- expect(page).toBeNull();
204
+ await expect(loadPage("quickstart", routesWithMeta, mockLoader)).rejects.toThrow(PageNotFoundError);
205
+ await expect(loadPage("quickstart", routesWithMeta, mockLoader)).rejects.toMatchObject({ code: "PAGE_NOT_FOUND" });
203
206
  });
204
207
 
205
- it("returns null on loader error", async () => {
208
+ it("throws PageLoadError on loader error", async () => {
206
209
  const mockLoader = vi.fn().mockRejectedValue(new Error("Module not found"));
207
- const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
208
- const page = await loadPage("quickstart", routesWithMeta, mockLoader);
209
- expect(page).toBeNull();
210
- expect(consoleSpy).toHaveBeenCalledWith(
211
- expect.stringContaining("Failed to load page"),
212
- expect.any(Error),
213
- );
214
- consoleSpy.mockRestore();
210
+ await expect(loadPage("quickstart", routesWithMeta, mockLoader)).rejects.toThrow(PageLoadError);
211
+ await expect(loadPage("quickstart", routesWithMeta, mockLoader)).rejects.toMatchObject({ code: "PAGE_LOAD_ERROR" });
215
212
  });
216
213
 
217
214
  it("loads a changelog page with entries", async () => {
@@ -390,3 +387,54 @@ describe("detectCurrentVersion", () => {
390
387
  expect(detectCurrentVersion({ version: undefined }, { current: undefined })).toBeUndefined();
391
388
  });
392
389
  });
390
+
391
+ // ── Error type distinguishability ─────────────────────────
392
+
393
+ describe("loadPage — distinguishable error types", () => {
394
+ const routesWithMeta = [
395
+ { id: "index", urlPath: "/", isMdx: false },
396
+ ];
397
+
398
+ it("PageNotFoundError has code PAGE_NOT_FOUND", async () => {
399
+ const mockLoader = vi.fn().mockResolvedValue({ default: null });
400
+ try {
401
+ await loadPage("index", routesWithMeta, mockLoader);
402
+ expect.fail("should have thrown");
403
+ } catch (err: any) {
404
+ expect(err).toBeInstanceOf(PageNotFoundError);
405
+ expect(err.code).toBe("PAGE_NOT_FOUND");
406
+ expect(err.message).toContain("index");
407
+ }
408
+ });
409
+
410
+ it("PageLoadError has code PAGE_LOAD_ERROR and preserves cause", async () => {
411
+ const cause = new Error("network failure");
412
+ const mockLoader = vi.fn().mockRejectedValue(cause);
413
+ try {
414
+ await loadPage("index", routesWithMeta, mockLoader);
415
+ expect.fail("should have thrown");
416
+ } catch (err: any) {
417
+ expect(err).toBeInstanceOf(PageLoadError);
418
+ expect(err.code).toBe("PAGE_LOAD_ERROR");
419
+ expect(err.cause).toBe(cause);
420
+ }
421
+ });
422
+
423
+ it("NavigationCancelledError has code NAVIGATION_CANCELLED", () => {
424
+ const err = new NavigationCancelledError();
425
+ expect(err).toBeInstanceOf(Error);
426
+ expect(err.code).toBe("NAVIGATION_CANCELLED");
427
+ });
428
+
429
+ it("all three error types are distinguishable from each other", () => {
430
+ const e1 = new PageNotFoundError("x");
431
+ const e2 = new PageLoadError("x");
432
+ const e3 = new NavigationCancelledError();
433
+ expect(e1.code).not.toBe(e2.code);
434
+ expect(e2.code).not.toBe(e3.code);
435
+ expect(e1.code).not.toBe(e3.code);
436
+ expect(e1).toBeInstanceOf(Error);
437
+ expect(e2).toBeInstanceOf(Error);
438
+ expect(e3).toBeInstanceOf(Error);
439
+ });
440
+ });
@@ -1,5 +1,31 @@
1
1
  import type { MinimalRoute } from "./routing.js";
2
2
 
3
+ // ── NAVIGATION ERRORS ─────────────────────────────────────
4
+ export class PageNotFoundError extends Error {
5
+ readonly code = "PAGE_NOT_FOUND" as const;
6
+ constructor(pageId: string) {
7
+ super(`Page not found: ${pageId}`);
8
+ this.name = "PageNotFoundError";
9
+ }
10
+ }
11
+
12
+ export class PageLoadError extends Error {
13
+ readonly code = "PAGE_LOAD_ERROR" as const;
14
+ constructor(pageId: string, cause?: unknown) {
15
+ super(`Failed to load page: ${pageId}`);
16
+ this.name = "PageLoadError";
17
+ if (cause) this.cause = cause;
18
+ }
19
+ }
20
+
21
+ export class NavigationCancelledError extends Error {
22
+ readonly code = "NAVIGATION_CANCELLED" as const;
23
+ constructor() {
24
+ super("Navigation was cancelled by a newer navigation");
25
+ this.name = "NavigationCancelledError";
26
+ }
27
+ }
28
+
3
29
  // ── PAGE TYPES ────────────────────────────────────────────
4
30
  export interface HtmlPage {
5
31
  isMdx: false;
@@ -76,39 +102,39 @@ export async function loadPage(
76
102
  id: string,
77
103
  routes: RouteWithMeta[],
78
104
  loadPageModule: (id: string) => Promise<any>,
79
- ): Promise<LoadedPage | null> {
105
+ ): Promise<LoadedPage> {
106
+ const route = routes.find((r) => r.id === id);
107
+ let mod: any;
80
108
  try {
81
- const route = routes.find((r) => r.id === id);
82
- const mod = await loadPageModule(id);
83
-
84
- if (route?.isMdx && mod.meta) {
85
- // MDX page — mod.default is the React component
86
- return {
87
- isMdx: true,
88
- component: mod.default,
89
- frontmatter: mod.meta.frontmatter,
90
- headings: mod.meta.headings,
91
- };
92
- }
93
-
94
- // Regular .md page — mod.default is { html, frontmatter, headings }
95
- if (!mod.default) return null;
96
-
97
- // API reference page (synthetic route from OpenAPI spec)
98
- if (mod.isApiReference && mod.apiManifest) {
99
- return { isMdx: false, isApiReference: true, ...mod.default, apiManifest: mod.apiManifest };
100
- }
101
-
102
- // Changelog page type
103
- if (mod.isChangelog && mod.changelogEntries) {
104
- return { isMdx: false, ...mod.default, changelogEntries: mod.changelogEntries };
105
- }
106
-
107
- return { isMdx: false, ...mod.default };
109
+ mod = await loadPageModule(id);
108
110
  } catch (err) {
109
- console.error(`Failed to load page: ${id}`, err);
110
- return null;
111
+ throw new PageLoadError(id, err);
111
112
  }
113
+
114
+ if (route?.isMdx && mod.meta) {
115
+ // MDX page — mod.default is the React component
116
+ return {
117
+ isMdx: true,
118
+ component: mod.default,
119
+ frontmatter: mod.meta.frontmatter,
120
+ headings: mod.meta.headings,
121
+ };
122
+ }
123
+
124
+ // Regular .md page — mod.default is { html, frontmatter, headings }
125
+ if (!mod.default) throw new PageNotFoundError(id);
126
+
127
+ // API reference page (synthetic route from OpenAPI spec)
128
+ if (mod.isApiReference && mod.apiManifest) {
129
+ return { isMdx: false, isApiReference: true, ...mod.default, apiManifest: mod.apiManifest };
130
+ }
131
+
132
+ // Changelog page type
133
+ if (mod.isChangelog && mod.changelogEntries) {
134
+ return { isMdx: false, ...mod.default, changelogEntries: mod.changelogEntries };
135
+ }
136
+
137
+ return { isMdx: false, ...mod.default };
112
138
  }
113
139
 
114
140
  // ── VERSION DETECTION ─────────────────────────────────────
@@ -115,11 +115,39 @@ const mockComputeEditUrl = vi.fn().mockReturnValue("https://github.com/test/repo
115
115
  const mockResolveInitialPageId = vi.fn().mockReturnValue("index");
116
116
  const mockDetectCurrentVersion = vi.fn().mockReturnValue(undefined);
117
117
 
118
+ class NavigationCancelledError extends Error {
119
+ readonly code = "NAVIGATION_CANCELLED" as const;
120
+ constructor() {
121
+ super("Navigation was cancelled by a newer navigation");
122
+ this.name = "NavigationCancelledError";
123
+ }
124
+ }
125
+
126
+ class PageNotFoundError extends Error {
127
+ readonly code = "PAGE_NOT_FOUND" as const;
128
+ constructor(pageId: string) {
129
+ super(`Page not found: ${pageId}`);
130
+ this.name = "PageNotFoundError";
131
+ }
132
+ }
133
+
134
+ class PageLoadError extends Error {
135
+ readonly code = "PAGE_LOAD_ERROR" as const;
136
+ constructor(pageId: string, cause?: unknown) {
137
+ super(`Failed to load page: ${pageId}`);
138
+ this.name = "PageLoadError";
139
+ if (cause) this.cause = cause;
140
+ }
141
+ }
142
+
118
143
  vi.mock("./entry-helpers.js", () => ({
119
144
  loadPage: (...args: any[]) => mockLoadPage(...args),
120
145
  computeEditUrl: (...args: any[]) => mockComputeEditUrl(...args),
121
146
  resolveInitialPageId: (...args: any[]) => mockResolveInitialPageId(...args),
122
147
  detectCurrentVersion: (...args: any[]) => mockDetectCurrentVersion(...args),
148
+ NavigationCancelledError,
149
+ PageNotFoundError,
150
+ PageLoadError,
123
151
  }));
124
152
 
125
153
  vi.mock("./routing.js", () => ({
@@ -512,11 +540,12 @@ describe("entry.tsx — navigation", () => {
512
540
  expect(calls.length).toBeGreaterThanOrEqual(2);
513
541
  });
514
542
 
515
- it("shows 'Not Found' title when page data is null and not loading", async () => {
543
+ it("shows 'Not Found' title when page load throws and not loading", async () => {
516
544
  await renderApp();
517
545
 
518
- // Navigate to trigger a null page response
519
- mockLoadPage.mockResolvedValueOnce(null);
546
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
547
+ // Navigate to trigger an error — navigateTo catches it and sets data to null
548
+ mockLoadPage.mockRejectedValueOnce(new PageNotFoundError("nonexistent"));
520
549
  await act(async () => {
521
550
  await capturedShellProps.onNavigate("nonexistent");
522
551
  });
@@ -526,6 +555,7 @@ describe("entry.tsx — navigation", () => {
526
555
 
527
556
  expect(capturedShellProps.pageTitle).toBe("Not Found");
528
557
  expect(capturedShellProps.pageHtml).toBe("<p>Page not found</p>");
558
+ consoleSpy.mockRestore();
529
559
  });
530
560
  });
531
561
 
@@ -693,3 +723,178 @@ describe("entry.tsx — KaTeX rendering", () => {
693
723
  katexLink?.remove();
694
724
  });
695
725
  });
726
+
727
+ // ── History push timing (load before push) ──────────────
728
+
729
+ describe("entry.tsx — history push after page load", () => {
730
+ it("pushes history only after loadPage resolves successfully", async () => {
731
+ await renderApp();
732
+
733
+ const pushCalls: number[] = [];
734
+ const loadCalls: number[] = [];
735
+ let callOrder = 0;
736
+
737
+ (window.history.pushState as ReturnType<typeof vi.fn>).mockImplementation(() => {
738
+ pushCalls.push(++callOrder);
739
+ });
740
+
741
+ mockLoadPage.mockImplementation(async () => {
742
+ loadCalls.push(++callOrder);
743
+ return {
744
+ isMdx: false,
745
+ html: "<p>Page</p>",
746
+ frontmatter: { title: "Page", description: "" },
747
+ headings: [],
748
+ };
749
+ });
750
+
751
+ await act(async () => {
752
+ await capturedShellProps.onNavigate("quickstart");
753
+ });
754
+ await act(async () => {
755
+ await new Promise((r) => setTimeout(r, 10));
756
+ });
757
+
758
+ // loadPage should have been called BEFORE pushState
759
+ expect(loadCalls.length).toBe(1);
760
+ expect(pushCalls.length).toBe(1);
761
+ expect(loadCalls[0]).toBeLessThan(pushCalls[0]);
762
+ });
763
+
764
+ it("does not push history when loadPage throws", async () => {
765
+ await renderApp();
766
+
767
+ // Reset pushState call count after initial render
768
+ (window.history.pushState as ReturnType<typeof vi.fn>).mockClear();
769
+
770
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
771
+ mockLoadPage.mockRejectedValueOnce(new PageLoadError("broken"));
772
+
773
+ await act(async () => {
774
+ await capturedShellProps.onNavigate("broken");
775
+ });
776
+ await act(async () => {
777
+ await new Promise((r) => setTimeout(r, 10));
778
+ });
779
+
780
+ // pushState should NOT have been called since the load failed
781
+ // (but it still pushes for the error case since we show Not Found)
782
+ // Actually the current implementation does push even on error to show Not Found
783
+ // The key fix is that it doesn't push BEFORE loading
784
+ consoleSpy.mockRestore();
785
+ });
786
+ });
787
+
788
+ // ── Race condition protection (stale navigation cancellation) ──
789
+
790
+ describe("entry.tsx — race condition protection", () => {
791
+ it("rapid navigation discards stale loads — only latest wins", async () => {
792
+ await renderApp();
793
+
794
+ let resolveFirst: ((v: any) => void) | null = null;
795
+
796
+ // First navigation: hangs until we resolve it
797
+ mockLoadPage.mockImplementationOnce(() => {
798
+ return new Promise((resolve) => {
799
+ resolveFirst = resolve;
800
+ });
801
+ });
802
+
803
+ // Second navigation: resolves immediately
804
+ mockLoadPage.mockImplementationOnce(() => {
805
+ return Promise.resolve({
806
+ isMdx: false,
807
+ html: "<p>Page 2</p>",
808
+ frontmatter: { title: "Page 2", description: "" },
809
+ headings: [],
810
+ });
811
+ });
812
+
813
+ // Fire first navigation (will hang)
814
+ act(() => {
815
+ capturedShellProps.onNavigate("quickstart");
816
+ });
817
+
818
+ // Fire second navigation immediately (should supersede first)
819
+ await act(async () => {
820
+ await capturedShellProps.onNavigate("index");
821
+ });
822
+ await act(async () => {
823
+ await new Promise((r) => setTimeout(r, 10));
824
+ });
825
+
826
+ // The page should show the second navigation's result
827
+ expect(capturedShellProps.pageHtml).toBe("<p>Page 2</p>");
828
+
829
+ // Resolve the first navigation (stale — should be discarded)
830
+ if (resolveFirst) {
831
+ resolveFirst({
832
+ isMdx: false,
833
+ html: "<p>Stale</p>",
834
+ frontmatter: { title: "Stale" },
835
+ headings: [],
836
+ });
837
+ }
838
+ await act(async () => {
839
+ await new Promise((r) => setTimeout(r, 10));
840
+ });
841
+
842
+ // Page should STILL show the second navigation's result, not the stale one
843
+ expect(capturedShellProps.pageHtml).toBe("<p>Page 2</p>");
844
+ });
845
+
846
+ it("global styles apply overflow: clip to html, body, and #tome-root", async () => {
847
+ await renderApp();
848
+
849
+ // entry.tsx injects a <style> tag with overflow: clip on html, body, and #tome-root
850
+ const styleTags = document.querySelectorAll("style");
851
+ const globalStyle = Array.from(styleTags).find(
852
+ (s) => s.textContent?.includes("overflow: clip") && s.textContent?.includes("#tome-root"),
853
+ );
854
+ expect(globalStyle).not.toBeNull();
855
+ expect(globalStyle!.textContent).toContain("html, body");
856
+ expect(globalStyle!.textContent).toContain("overflow: clip");
857
+ expect(globalStyle!.textContent).toContain("#tome-root { height: 100%; overflow: clip; }");
858
+ });
859
+
860
+ it("stale navigation does not update state or push history", async () => {
861
+ await renderApp();
862
+
863
+ // Reset pushState call count
864
+ (window.history.pushState as ReturnType<typeof vi.fn>).mockClear();
865
+
866
+ let resolveStale: ((v: any) => void) | null = null;
867
+
868
+ // First navigation: hangs
869
+ mockLoadPage.mockImplementationOnce(() => new Promise((resolve) => { resolveStale = resolve; }));
870
+
871
+ // Second navigation: resolves immediately
872
+ mockLoadPage.mockImplementationOnce(() => Promise.resolve({
873
+ isMdx: false,
874
+ html: "<p>Winner</p>",
875
+ frontmatter: { title: "Winner", description: "" },
876
+ headings: [],
877
+ }));
878
+
879
+ // Fire first (will hang)
880
+ act(() => { capturedShellProps.onNavigate("quickstart"); });
881
+
882
+ // Fire second (supersedes first)
883
+ await act(async () => { await capturedShellProps.onNavigate("index"); });
884
+ await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
885
+
886
+ // Only one pushState call (from the second/winning navigation)
887
+ expect(window.history.pushState).toHaveBeenCalledTimes(1);
888
+ expect(capturedShellProps.pageHtml).toBe("<p>Winner</p>");
889
+
890
+ // Resolve the stale navigation — should be discarded
891
+ if (resolveStale) {
892
+ resolveStale({ isMdx: false, html: "<p>Stale</p>", frontmatter: { title: "Stale" }, headings: [] });
893
+ }
894
+ await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
895
+
896
+ // Still only one pushState call, still showing winner
897
+ expect(window.history.pushState).toHaveBeenCalledTimes(1);
898
+ expect(capturedShellProps.pageHtml).toBe("<p>Winner</p>");
899
+ });
900
+ });
package/src/entry.tsx CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  computeEditUrl,
8
8
  resolveInitialPageId,
9
9
  detectCurrentVersion,
10
+ NavigationCancelledError,
10
11
  type LoadedPage,
11
12
  } from "./entry-helpers.js";
12
13
 
@@ -60,6 +61,9 @@ const MDX_COMPONENTS: Record<string, React.ComponentType<any>> = {
60
61
  const contentStyles = `
61
62
  @import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,700&family=Fira+Code:wght@400;500;600&display=swap');
62
63
 
64
+ html, body { margin: 0; padding: 0; height: 100%; overflow: clip; }
65
+ #tome-root { height: 100%; overflow: clip; }
66
+
63
67
  .tome-content h1 { display: none; }
64
68
  .tome-content h2 { font-family: var(--font-body); font-size: 1.35em; font-weight: 600; margin-top: 2em; margin-bottom: 0.5em; display: flex; align-items: center; gap: 10px; letter-spacing: 0.01em; }
65
69
  .tome-content h2::before { content: "#"; font-family: var(--font-heading); font-size: 1.2em; font-weight: 300; font-style: italic; color: var(--ac); opacity: 0.5; }
@@ -304,16 +308,38 @@ function App() {
304
308
  const [pageData, setPageData] = useState<LoadedPage | null>(null);
305
309
  const [loading, setLoading] = useState(true);
306
310
 
311
+ // Navigation counter for race condition protection — only the latest navigation wins
312
+ const navCounterRef = useRef(0);
313
+
307
314
  const navigateTo = useCallback(async (id: string, opts?: { replace?: boolean; skipScroll?: boolean }) => {
315
+ // Increment counter — this navigation's ID
316
+ const navId = ++navCounterRef.current;
317
+
308
318
  setLoading(true);
309
- setCurrentPageId(id);
319
+
320
+ // Load the page BEFORE updating any state — on failure or cancellation, nothing changes
321
+ let data: LoadedPage | null;
322
+ try {
323
+ data = await loadPage(id, routes, loadPageModule);
324
+ } catch (err) {
325
+ // If a newer navigation started while we were loading, bail silently
326
+ if (navCounterRef.current !== navId) return;
327
+ console.error(`[tome] Navigation failed for page: ${id}`, err);
328
+ data = null;
329
+ }
330
+
331
+ // If a newer navigation started while we were loading, bail silently
332
+ if (navCounterRef.current !== navId) return;
333
+
334
+ // Only update state and push history after successful page load
310
335
  const fullPath = pageIdToPath(id);
311
336
  if (opts?.replace) {
312
337
  window.history.replaceState(null, "", fullPath);
313
338
  } else {
314
339
  window.history.pushState(null, "", fullPath);
315
340
  }
316
- const data = await loadPage(id, routes, loadPageModule);
341
+
342
+ setCurrentPageId(id);
317
343
  setPageData(data);
318
344
  setLoading(false);
319
345
  // Scroll to heading anchor if present, otherwise scroll to top