Repository navigation
[2.0.0-next.38] serverRouteComponent sends the same GET up to three times on one click (hover preload, focus preload, render) #677
Description
With a route backed by a bare GET server function through serverRouteComponent, one click on a link sends the same request three times. A focus followed by Enter sends it twice. The query() version of the same app sends it once.
Found in solidjs/solid's examples/hackernews (server components) against its twin examples/hackernews-spa (the same app on query). Measured with Chromium on solid next (4937f0a) and @solidjs/router 2.0.0-next.38, using a local mock upstream that answers item/* after 2 s.
Counts
Each cell is identical GET /_server/data/getStory-…?args=[{"params":{"id":"N"}}] requests from the client, then hits that reached the upstream.
| Interaction | hackernews, as shipped (max-age=60) |
hackernews, cache disabled | hackernews, bare GET (no-store) |
hackernews-spa (query) |
|---|---|---|---|---|
| Hover only | 1 / 1 | 1 / 1 | 1 / 1 | 1 / 1 |
| Focus only | 1 / 1 | 1 / 1 | 1 / 1 | 1 / 1 |
| Hover, 60 ms, click | 3 / 1 | 3 / 3 | 3 / 3, in series | 1 / 1 |
| Click with no dwell | 3 / 1 | 3 / 3 | 3 / 3, in series | 1 / 1 |
| Focus, then Enter | 2 / 1 | 2 / 2 | 2 / 2 | 1 / 1 |
How many requests reach the upstream depends on caching:
- As shipped: the example sets
cache-control: public, max-age=60, so Chromium's HTTP cache merges the in-flight duplicates and the upstream sees one hit. - Cache disabled: with DevTools' "Disable cache" on, all three reach the upstream at once.
- Bare GET (
no-store, the default): Chromium's cache lock runs the three one after another. The navigation waits for the last one, the render's, so a 2 s upstream made the click take about 6.5 s. - Firefox and Safari: not tested. Their caches may not merge in-flight GETs even with
max-age.
The server has no dedupe of its own: three concurrent curls of the same URL gave three upstream hits.
Order for hover then click: the hover-rest preload fires at +0 ms, the click's mousedown focuses the link at about +75 ms (a second preload), and the render's call follows at about +83 ms. On a click with no dwell, the pending 20 ms hover timer still fires after the focus preload.
Where it comes from (next branch)
src/preload.ts,intentPreload→onIntent.- Only the
mousemovepath checks and setslastElement. focusinandtouchstartcallctx.preloaddirectly, without checking it.- A focus preload doesn't clear a pending hover timer for the same anchor.
- So one link gets two preloads, and this applies to every route, not only server routes.
- Only the
src/preloadRoute.ts(line 73).server && server.call(serverRouteArgs(route, params, query))calls the source directly each time, with nothing shared.src/serverRouteComponent.ts(line 98).dynamicComponent(() => call(args()))makes a fresh call on mount and never sees the preload's in-flight call.- The transport doesn't dedupe in-flight GETs either. In solid's
packages/web/server-functions, onlyliveshares connections.query()is what dedupes the SPA twin.
Proposed fix (sketch, not implemented)
Add a special case in serverRouteComponent for a bare GET server function. People who want more can use query. Wrapping call once covers both callers, because preloadRoute goes through server.call and the render through call:
const bareGet = isServerFunction(source) && getServerFunctionMetadata(source)?.method === "GET";
const inflight = new Map<string, Promise<unknown>>();
const call = !bareGet || isServer ? source : (args: unknown) => {
const key = JSON.stringify(args);
let p = inflight.get(key);
if (!p) {
p = source(args);
inflight.set(key, p);
const drop = () => inflight.get(key) === p && inflight.delete(key);
Promise.resolve(p).then(drop, drop); // or keep settled entries for query's preload window
}
return p;
};Open questions:
- Can a decoded server-component result (a frame stream) be mounted by the render after a preload already produced it? That decides whether the promise can be shared or whether a frame is consumed once.
query()andliveQuery()sources should keep their own path.
A separate tidy-up: have focusin in intentPreload respect and set lastElement, and clear the hover timer. That removes the duplicate preload() for ordinary routes too.
Activity
fix(preload): intentPreload preloads a link once per visit
ryansolid commentedon Oct 14, 2026
Generated by Claude Code