Skip to content

Commit a0b7f97

Browse files
committed
Fix not-found navigation and markdown status
1 parent 7342039 commit a0b7f97

5 files changed

Lines changed: 123 additions & 3 deletions

File tree

packages/framework/src/router.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,16 @@ export async function initClientRouter(options: InitClientRouterOptions): Promis
444444
}
445445

446446
if (result.type === "error") {
447+
if (result.error.status === 404 && app.notFound) {
448+
const routeModule = (await routeModPromise?.catch(() => null)) as {
449+
ErrorBoundary?: unknown;
450+
} | null;
451+
if (!routeModule?.ErrorBoundary) {
452+
window.location.href = target.browserUrl;
453+
return;
454+
}
455+
}
456+
447457
state = {
448458
data: undefined,
449459
error: result.error,

packages/framework/src/runtime-negotiation.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,14 @@ export function prefersMarkdown(accept: string | null): boolean {
4040
return md.quality >= html.quality;
4141
}
4242

43-
export function markdownResponse(source: string, initHeaders?: HeadersInit): Response {
43+
export function markdownResponse(
44+
source: string,
45+
initHeaders?: HeadersInit,
46+
status = 200,
47+
): Response {
4448
const headers = new Headers(initHeaders);
4549
headers.set("content-type", "text/markdown; charset=utf-8");
4650
appendVaryHeader(headers, "Accept");
4751
applyDefaultSecurityHeaders(headers);
48-
return new Response(source, { status: 200, headers });
52+
return new Response(source, { status, headers });
4953
}

packages/framework/src/runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -482,7 +482,7 @@ export async function handlePrachtRequest<TContext>(
482482
markdownRepresentation !== undefined &&
483483
prefersMarkdown(options.request.headers.get("accept"))
484484
) {
485-
return markdownResponse(markdownRepresentation, documentHeaders);
485+
return markdownResponse(markdownRepresentation, documentHeaders, pageOptions.status);
486486
}
487487

488488
const cssUrls = resolvePageCssUrls(

packages/framework/test/not-found.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,29 @@ describe("app-level notFound page", () => {
170170
expect(response.headers.get("content-type")).toContain("text/plain");
171171
});
172172

173+
it("keeps the 404 status for a markdown representation", async () => {
174+
const response = await handlePrachtRequest({
175+
app: createApp(),
176+
registry: {
177+
...registry,
178+
routeModules: {
179+
...registry.routeModules,
180+
"./routes/not-found.tsx": async () => ({
181+
Component: () => h("h1", null, "Page not found"),
182+
markdown: "# Page not found\n",
183+
}),
184+
},
185+
},
186+
request: new Request("http://localhost/missing", {
187+
headers: { accept: "text/markdown" },
188+
}),
189+
});
190+
191+
expect(response.status).toBe(404);
192+
expect(response.headers.get("content-type")).toContain("text/markdown");
193+
await expect(response.text()).resolves.toBe("# Page not found\n");
194+
});
195+
173196
it("is not prerendered as a page of its own", async () => {
174197
const app = defineApp({
175198
routes: [route("/", "./routes/home.tsx", { render: "ssg" })],

packages/framework/test/router-client.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,89 @@ describe("initClientRouter", () => {
398398
await flush();
399399
expect(root.textContent).toContain("clicked");
400400
});
401+
402+
it("uses a document navigation for loader 404s instead of a shell boundary", async () => {
403+
const app = resolveApp(
404+
defineApp({
405+
shells: { public: "./shells/public.tsx" },
406+
routes: [
407+
route("/", "./routes/home.tsx", { id: "home", render: "ssr" }),
408+
route("/posts/:slug", "./routes/post.tsx", {
409+
id: "post",
410+
render: "ssr",
411+
shell: "public",
412+
}),
413+
],
414+
notFound: "./routes/not-found.tsx",
415+
}),
416+
);
417+
const originalLocation = window.location;
418+
const documentNavigations: string[] = [];
419+
Object.defineProperty(window, "location", {
420+
configurable: true,
421+
value: {
422+
...window.location,
423+
get href() {
424+
return "http://localhost/";
425+
},
426+
set href(value: string) {
427+
documentNavigations.push(value);
428+
},
429+
hash: "",
430+
origin: "http://localhost",
431+
pathname: "/",
432+
search: "",
433+
},
434+
});
435+
436+
try {
437+
root.innerHTML = "<main>home</main>";
438+
fetchSpy.mockResolvedValue(
439+
createJsonResponse(
440+
{
441+
error: {
442+
message: "Post not found",
443+
name: "PrachtHttpError",
444+
status: 404,
445+
},
446+
},
447+
{ status: 404 },
448+
),
449+
);
450+
451+
await initClientRouter({
452+
app,
453+
routeModules: {
454+
"./routes/home.tsx": async () => ({ default: () => h("main", null, "home") }),
455+
"./routes/post.tsx": async () => ({ default: () => h("main", null, "post") }),
456+
"./routes/not-found.tsx": async () => ({ default: () => h("main", null, "404") }),
457+
},
458+
shellModules: {
459+
"./shells/public.tsx": async () => ({
460+
Shell: ({ children }: { children: ComponentChildren }) => h("section", null, children),
461+
ErrorBoundary: () => h("p", null, "shell boundary"),
462+
}),
463+
},
464+
initialState: {
465+
data: null,
466+
routeId: "home",
467+
url: "/",
468+
},
469+
root,
470+
findModuleKey: (_modules, file) => file,
471+
});
472+
473+
await window.__PRACHT_NAVIGATE__!("/posts/missing");
474+
475+
expect(documentNavigations).toEqual(["/posts/missing"]);
476+
expect(root.textContent).not.toContain("shell boundary");
477+
} finally {
478+
Object.defineProperty(window, "location", {
479+
configurable: true,
480+
value: originalLocation,
481+
});
482+
}
483+
});
401484
});
402485

403486
describe("navigate() URL-scheme safety", () => {

0 commit comments

Comments
 (0)