Skip to content

dev HMR still drops GET grants for query()-wrapped server functions (405 until next SSR render) — residual after #3353 #3564

Closed

Description

@Jeysef

Versions

  • @solidjs/web@2.0.0-rc.9
  • @solidjs/vite-plugin@3.0.0-next.44
  • @solidjs/router@2.0.0-next.26
  • solid-js@2.0.0-rc.9

Summary

On rc.9 — which already includes #3353 (the fix for #3346) — any dev edit that triggers an SSR program reload makes every query()-declared server function answer 405 Method Not Allowed (Allow: POST) until the next full document SSR render. Subsequent client navigations keep failing with Server function call failed with status 405.

This is the same user-visible symptom as #3346, but a different mechanism than the one #3353 fixed, so #3353 does not close it.

Reproduction

query()-wrapped "use server" reads (routed to /_server/data/<id>?args=…; ids are stable across reloads — hash(root-relative path)):

  1. GET / — the SSR render runs the GET() declaration, recording the grant.
  2. GET /_server/data/getYearsServer-f84b56ac?args=[] with Sec-Fetch-Site: same-origin → 200.
  3. Edit any file in the SSR graph — a server module or a pure client component (both cause (ssr) program reload). HMR applies.
  4. Same GET → 405, Allow: POST, body Method not allowed for server function: getYearsServer-f84b56ac.
  5. GET / (full page render) → the declaration re-runs; GET is 200 again.

The function id in the URL is byte-identical across the reload, so this is not URL/version skew. A cold dev server also answers 405 before any page has rendered, for the same reason.

Note the SSR program reload fires frequently and sometimes spuriously in dev (it is emitted alongside (client) hmr update /src/index.css?direct), so the 405 appears "randomly" during normal work.

Mechanism

  • The GET grant lives in the SSR-environment METHODS map (server-functions/src/server.ts). It is created only when the declaring site runs in the SSR environment — for the router pattern, that is the query(fn, name) call site (the router wraps the reference with GET(fn) at query-creation).
  • registerServerFunction (introduced by fix(web): revoke GET grants on rebind; never fold single-flight into reads #3150) performs METHODS.delete(id) whenever the id's callback identity changes — which is guaranteed on every HMR re-evaluation of a "use server" module.
  • fix(web): revoke GET grants on rebind; never fold single-flight into reads #3150 relies on "module order makes this self-healing": the module that re-registers an id then re-runs GET() in sequence. With the router pattern, the declaration lives in a different module (query() is called from the app's data layer, which imports the server module). The HMR path re-imports only the server module, not the query() call site, so the revoked grant is never re-armed.
  • fix(web): keep the server-function registry on globalThis across runtime re-evaluation #3353 is not involved here: in rc.9 the three maps already ride globalThis via processState(...), so there is no duplicate-instance divergence; the revocation in the shared map is the operative cause.

Instrumenting the installed dev bundle shows the sequence: the SSR program reload re-registers the id with a fresh callback (same path-derived id), declaresRead(id) flips to false, and dispatch answers 405 until a later document render re-runs the query()/GET() site.

Expected

A declaration scoped to a stable, path-derived id should survive benign dev re-registration of that same id. Dropping it on identity churn is correct in production (rebinding protection, #3129), but in dev it makes the router query() pattern unusable after any edit.

Proposed fix

In server-functions/src/server.ts, carry the grant across an id rebind in dev only:

export function registerServerFunction(id, callback) {
  provideRPC();
  if (REGISTRATIONS.get(id) !== callback) {
    if (DEV && METHODS.has(id)) METHODS.set(id, callback); // HMR: id ↔ source is stable
    else METHODS.delete(id);                                // production: keep revocation
  }
  REGISTRATIONS.set(id, callback);
  return callback;
}

Because compiler ids are path-derived (<name>-<xxhash32(path)>[-ordinal], see #3120), the same id in dev denotes the same declaration site, so the carry-over does not weaken #3129's concern (a different function never inherits the grant in production, and in dev a same-id function is a re-evaluation of the same declaration).

An alternative would be to have the dev middleware re-evaluate the declaration module, but the middleware has no way to know which module declared the read.

Verification

Applied as a dev-only patch to the installed rc.9 bundle:

Step Before After
GET after SSR render 200 200
Edit a client component → HMR program reload 405 (Allow: POST) 200
GET again 405 200

A regression test modeled on #3353's server-functions-registry-reload.spec.tsx can pin it: boot, declare via getServerFunctionRPC().GET, re-register the same id with a fresh callback (simulating HMR), assert declaresRead(id) remains true and dispatch answers 200.

Related: #3346 (same symptom), #3353 (different cause, already fixed), #3150 / #3129 / #3237 (grant lifetime and rebind revocation).

Activity

  1. ryansolid commented on Sep 22, 2026

    @ryansolid
    Member

    Fixed on next by #3566 (5e46732). In dev, a rebind of an id with a live GET() grant now carries a provisional, dispatch-only grant — the router's same-origin query() reads stop answering 405 after a module reload, while the origin gate stays on until the live binding re-declares GET(). Production still revokes on every rebind. Ships with the next rc.

    — Claude via Cursor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions