@tomehq/theme 0.3.4 → 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.
- package/CHANGELOG.md +31 -0
- package/dist/chunk-2APCPR2Y.js +2110 -0
- package/dist/chunk-2AXAEADQ.js +2525 -0
- package/dist/chunk-2WNOJXK3.js +2581 -0
- package/dist/chunk-37JI6XGT.js +1720 -0
- package/dist/chunk-3A2LPGUL.js +1991 -0
- package/dist/chunk-3I2QTWTW.js +1948 -0
- package/dist/chunk-3I4SJMER.js +2538 -0
- package/dist/chunk-45M5UIAB.js +2110 -0
- package/dist/chunk-462AGU3S.js +1959 -0
- package/dist/chunk-5GVQFIPI.js +2581 -0
- package/dist/chunk-7MUTU5D4.js +1720 -0
- package/dist/chunk-7NQ4IMDY.js +2294 -0
- package/dist/chunk-ABNPB6BB.js +2133 -0
- package/dist/chunk-BZGWSKT2.js +573 -0
- package/dist/chunk-BZIB2LMI.js +2519 -0
- package/dist/chunk-CMQCNCSY.js +2127 -0
- package/dist/chunk-CTPOZMMK.js +1703 -0
- package/dist/chunk-DKSQZLWR.js +2569 -0
- package/dist/chunk-DO544M3G.js +1702 -0
- package/dist/chunk-DPKZBFQP.js +1777 -0
- package/dist/chunk-EK7PZUEB.js +2147 -0
- package/dist/chunk-FMOLIHQF.js +2182 -0
- package/dist/chunk-FWBTK5TL.js +1444 -0
- package/dist/chunk-GDQIBNX5.js +1962 -0
- package/dist/chunk-GHQ2MODM.js +2127 -0
- package/dist/chunk-GR2WCRGK.js +2182 -0
- package/dist/chunk-H5XZVNBW.js +2291 -0
- package/dist/chunk-HNLKDQ64.js +2139 -0
- package/dist/chunk-INUMUXN5.js +2095 -0
- package/dist/chunk-IW3NHNOQ.js +2187 -0
- package/dist/chunk-JA4PMX6M.js +1500 -0
- package/dist/chunk-JSPFS7G5.js +2102 -0
- package/dist/chunk-JZRT4WNC.js +1441 -0
- package/dist/chunk-KQBY2JDB.js +2112 -0
- package/dist/chunk-LIMYFTPC.js +1468 -0
- package/dist/chunk-LIY62BGC.js +2519 -0
- package/dist/chunk-MEP7P6A7.js +1500 -0
- package/dist/chunk-MHYKO7KM.js +2570 -0
- package/dist/chunk-MSXVVBDW.js +2542 -0
- package/dist/chunk-NOZBIES7.js +1948 -0
- package/dist/chunk-O4GH3KYX.js +1712 -0
- package/dist/chunk-OEDJTH5F.js +2569 -0
- package/dist/chunk-OEXM3BEC.js +1702 -0
- package/dist/chunk-PGKSFQ7A.js +2459 -0
- package/dist/chunk-PIV6CPY2.js +2395 -0
- package/dist/chunk-Q7PYTVW3.js +1771 -0
- package/dist/chunk-QCWZYABW.js +1468 -0
- package/dist/chunk-RDF25WB2.js +2085 -0
- package/dist/chunk-RKTT3ZEX.js +1500 -0
- package/dist/chunk-S47BRMNQ.js +1715 -0
- package/dist/chunk-S4ZH5F56.js +1949 -0
- package/dist/chunk-SRD7NJHS.js +1949 -0
- package/dist/chunk-SWFYJO5H.js +2187 -0
- package/dist/chunk-TQDWPSTO.js +2087 -0
- package/dist/chunk-TTRXRPP6.js +1941 -0
- package/dist/chunk-UKYFJSUA.js +509 -0
- package/dist/chunk-VKEQHP2E.js +2133 -0
- package/dist/chunk-VUT2FMSI.js +1937 -0
- package/dist/chunk-VVCC5JHK.js +1949 -0
- package/dist/chunk-W732TVBK.js +1944 -0
- package/dist/chunk-X4VQYPKO.js +1768 -0
- package/dist/chunk-YX7HV4EP.js +2568 -0
- package/dist/chunk-YXKONM3A.js +2192 -0
- package/dist/chunk-YZ3P3TNS.js +1760 -0
- package/dist/chunk-ZVZ7JN3V.js +2568 -0
- package/dist/chunk-ZXW4STTN.js +2568 -0
- package/dist/entry.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/src/Shell.test.tsx +25 -0
- package/src/Shell.tsx +8 -8
- package/src/entry-helpers.test.ts +60 -12
- package/src/entry-helpers.ts +56 -30
- package/src/entry.test.tsx +208 -3
- package/src/entry.tsx +27 -4
package/dist/entry.js
CHANGED
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomehq/theme",
|
|
3
|
-
"version": "0.
|
|
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/
|
|
13
|
-
"@tomehq/
|
|
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",
|
package/src/Shell.test.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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)", height: "100vh", overflow: "
|
|
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("
|
|
202
|
+
it("throws PageNotFoundError when module has no default export", async () => {
|
|
200
203
|
const mockLoader = vi.fn().mockResolvedValue({ default: null });
|
|
201
|
-
|
|
202
|
-
expect(
|
|
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("
|
|
208
|
+
it("throws PageLoadError on loader error", async () => {
|
|
206
209
|
const mockLoader = vi.fn().mockRejectedValue(new Error("Module not found"));
|
|
207
|
-
|
|
208
|
-
|
|
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
|
+
});
|
package/src/entry-helpers.ts
CHANGED
|
@@ -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
|
|
105
|
+
): Promise<LoadedPage> {
|
|
106
|
+
const route = routes.find((r) => r.id === id);
|
|
107
|
+
let mod: any;
|
|
80
108
|
try {
|
|
81
|
-
|
|
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
|
-
|
|
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 ─────────────────────────────────────
|
package/src/entry.test.tsx
CHANGED
|
@@ -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
|
|
543
|
+
it("shows 'Not Found' title when page load throws and not loading", async () => {
|
|
516
544
|
await renderApp();
|
|
517
545
|
|
|
518
|
-
|
|
519
|
-
|
|
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,8 +61,8 @@ 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
|
|
|
63
|
-
html, body { margin: 0; padding: 0; height: 100%; overflow:
|
|
64
|
-
#tome-root { height: 100%; overflow:
|
|
64
|
+
html, body { margin: 0; padding: 0; height: 100%; overflow: clip; }
|
|
65
|
+
#tome-root { height: 100%; overflow: clip; }
|
|
65
66
|
|
|
66
67
|
.tome-content h1 { display: none; }
|
|
67
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; }
|
|
@@ -307,16 +308,38 @@ function App() {
|
|
|
307
308
|
const [pageData, setPageData] = useState<LoadedPage | null>(null);
|
|
308
309
|
const [loading, setLoading] = useState(true);
|
|
309
310
|
|
|
311
|
+
// Navigation counter for race condition protection — only the latest navigation wins
|
|
312
|
+
const navCounterRef = useRef(0);
|
|
313
|
+
|
|
310
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
|
+
|
|
311
318
|
setLoading(true);
|
|
312
|
-
|
|
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
|
|
313
335
|
const fullPath = pageIdToPath(id);
|
|
314
336
|
if (opts?.replace) {
|
|
315
337
|
window.history.replaceState(null, "", fullPath);
|
|
316
338
|
} else {
|
|
317
339
|
window.history.pushState(null, "", fullPath);
|
|
318
340
|
}
|
|
319
|
-
|
|
341
|
+
|
|
342
|
+
setCurrentPageId(id);
|
|
320
343
|
setPageData(data);
|
|
321
344
|
setLoading(false);
|
|
322
345
|
// Scroll to heading anchor if present, otherwise scroll to top
|