Skip to content

[2.0.0-next.38] serverRouteComponent sends the same GET up to three times on one click (hover preload, focus preload, render) #677

Closed

Description

@ryansolid

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)

  1. src/preload.ts, intentPreload → onIntent.
    • Only the mousemove path checks and sets lastElement.
    • focusin and touchstart call ctx.preload directly, 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.
  2. src/preloadRoute.ts (line 73). server && server.call(serverRouteArgs(route, params, query)) calls the source directly each time, with nothing shared.
  3. src/serverRouteComponent.ts (line 98). dynamicComponent(() => call(args())) makes a fresh call on mount and never sees the preload's in-flight call.
  4. The transport doesn't dedupe in-flight GETs either. In solid's packages/web/server-functions, only live shares 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() and liveQuery() 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

  1. ryansolid commented on Oct 14, 2026

    @ryansolid
    MemberAuthor

    Fixed by #680 (intentPreload preloads a link once per visit, shipped in 2.0.0-next.39) and #682 (the render takes over a bare GET preload's in-flight call, which ships in the next prerelease). One click now sends one request. Both merged into next, so this issue didn't close automatically.

    — Claude via Claude Code


    Generated by Claude Code

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