Skip to content

fix(pages): keep a static file out of a loader not-found response - #822

Merged
cpojer merged 2 commits into
voidzero-dev:mainfrom
0froq:fix/pages-loader-not-found-asset
Nov 13, 2026
Merged

cpojer merged 2 commits into
voidzero-dev:mainfrom
0froq:fix/pages-loader-not-found-asset

Conversation

@0froq

@0froq 0froq commented Nov 13, 2026

Copy link
Copy Markdown
Collaborator

Fixes #776

Description

A page loader that calls c.notFound() returned the app fallback's response, because Hono resolves c.notFound() inside the loader call. When a static file sat at the same path, the fallback served it, runLoader read the 200 JSON as page props, and the page rendered the file's data instead of a 404.

The request now records the loader's decision, and the fallback skips static assets for it. A matched page route owns its URL once the loader answers 404, which matches the rule this fallback already applied to root rendering. Apps with their own not-found boundary keep their current behaviour.

Test

packages/void/test/unit/page-boundaries.test.ts — new case serves a JSON asset at the requested path and asserts a 404 with no asset read and no page render. It fails on main with expected 200 to be 404.

Also built playground/vue-pages with a public/docs/readme.json and a /docs/[name] page whose loader returns c.notFound():

  • before: /docs/readme.json → 200 text/html, page props {"static":"docs"}, server logged TypeError: Cannot read properties of undefined (reading 'title')
  • after: /docs/readme.json → 404, the same as any other missing asset path

Hono resolves `c.notFound()` inside the loader call, so the loader returns
whatever the app fallback produced. When a static file sat at the same path,
the asset fallback returned its bytes and `runLoader` read a 200 JSON
response as page props, rendering the file as the page's data.

Mark the request when a page loader calls `c.notFound()`, and let the
fallback skip asset lookup for it. The marker outlives the loader because
the fallback answers inside the call.

A matching page route now owns the URL once its loader answers 404.
@0froq

0froq commented Nov 13, 2026

Copy link
Copy Markdown
Collaborator Author

👁️‍🗨️ Review this pull request with grouped, summarized diffs at:
👉 https://pulls.review/gh/voidzero-dev/void/822?from=0froq

raw result

💭 analyzed by openai-compatible/opencode-go/deepseek-v4.1-flash
🕰️ 2026-11-13 22:13 UTC
🔗 head c0dec01
🤖 automated by pulls.review

{
  "headSha": "c0dec0124672dea884bd86c9a11ce424da9804f1",
  "result": {
    "overallSummary": "Fixes #776. When a page loader called `c.notFound()`, Hono resolved the not-found inside the loader, and if a static asset existed at the same path the fallback served it; `runLoader` then read that 200 response as page props and the page rendered the file's data instead of a 404.\n\nThe loader's `c.notFound()` now records a per-request decision, the loader is always wrapped (not just when the app has its own boundary), and the fallback treats the URL as claimed: it skips static assets and answers 404 without rendering the page. Apps with their own not-found boundary keep their current behaviour.",
    "groups": [
      {
        "key": "loader-not-found-decision",
        "label": "Loader not-found decision",
        "summary": "Replaces the ephemeral `WeakSet` boundary marker with a per-request `WeakMap` decision (`boundary` vs `fallback`) set by an instrumented `c.notFound()`, so the loader's choice outlives the loader call and is visible to the fallback.\n\n`runLoader` always wraps the handler now — previously the wrapper was skipped when the app had no boundary — and the fallback splits the old single check into \"app boundary owns it, return bare 404\" and \"loader claimed the URL, do not serve a static asset\".",
        "category": "core",
        "filePaths": [
          "packages/void/src/pages/not-found-context.ts",
          "packages/void/src/pages/protocol.ts",
          "packages/void/src/runtime/routing-pages.ts"
        ],
        "lineNotes": [
          {
            "path": "packages/void/src/pages/not-found-context.ts",
            "side": "additions",
            "line": 37,
            "text": "Only the first `c.notFound()` of a request is recorded, so an outer page's decision wins when a fallback render re-enters another loader. The decision is deliberately never cleared: only `c.notFound` is restored in `finally`, because the fallback reads the marker after the loader returns."
          },
          {
            "path": "packages/void/src/runtime/routing-pages.ts",
            "side": "additions",
            "line": 204,
            "text": "This branch returns a plain 404 instead of going through `options.routing.markNoMatch(...)` like the other no-asset path — worth confirming routing bookkeeping/caching does not depend on that call for loader-claimed URLs."
          }
        ]
      },
      {
        "key": "not-found-asset-test",
        "label": "Not-found asset test",
        "summary": "New case in the existing `loader not-found dispatch` suite serves a JSON asset at the requested path and asserts a 404, that the asset was never fetched, and that no page render happened — the exact regression from #776.",
        "category": "tests",
        "filePaths": [
          "packages/void/test/unit/page-boundaries.test.ts"
        ],
        "fileNotes": [
          {
            "path": "packages/void/test/unit/page-boundaries.test.ts",
            "text": "The `fetched` assertion is the meaningful one: it pins the \"asset not read\" half of the fix, while the status/text checks would also pass if the asset were fetched and then discarded."
          }
        ]
      }
    ],
    "schemaVersion": 1,
    "source": "llm",
    "generatedAt": "2026-11-13T22:13:54.716Z",
    "model": "openai-compatible/opencode-go/deepseek-v4.1-flash",
    "locale": "en"
  }
}

@cpojer cpojer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix!

@cpojer
cpojer merged commit 5973629 into voidzero-dev:main Nov 13, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants