diff --git a/.changeset/server-route-get-preload-handoff.md b/.changeset/server-route-get-preload-handoff.md new file mode 100644 index 00000000..5f2be678 --- /dev/null +++ b/.changeset/server-route-get-preload-handoff.md @@ -0,0 +1,5 @@ +--- +"@solidjs/router": patch +--- + +`serverRouteComponent` with a bare `GET(fn)` source sends one request per link visit instead of two (#677). The router hands a preload's call to the next render with the same args, so the route mounts the result the hover or focus preload already started. The hand-off happens once, within the same 5 s window `query()` gives a preload. A later visit calls fresh, a failed preload makes the render retry, and concurrent preloads of the same args share one call. `query()` and `liveQuery()` sources are unchanged. diff --git a/src/serverRouteComponent.ts b/src/serverRouteComponent.ts index 36a32bdd..80e2a1af 100644 --- a/src/serverRouteComponent.ts +++ b/src/serverRouteComponent.ts @@ -36,9 +36,10 @@ // A server component that takes other client positions — handlers, refs, // named slots — has a client half, and that half is a client component; the // helper does not pretend otherwise. Write the wrapper for that route. -import { dynamicComponent } from "@solidjs/web"; +import { dynamicComponent, getServerFunctionMetadata, isServer } from "@solidjs/web"; import type { JSX } from "@solidjs/web"; -import { createComponent, DEV } from "solid-js"; +import { createComponent, DEV, untrack } from "solid-js"; +import { getIntent } from "./routing.js"; import type { Component } from "solid-js"; import { SERVER_ROUTE, type BrandedRouteComponent } from "./serverRouteShared.js"; import type { @@ -72,7 +73,7 @@ export function serverRouteComponent

| ((...args: never[]) => ReturnType>) ): Component>> { - const call = source as ServerRouteFunction; + const call = handOffPreloads(source as ServerRouteFunction); // The value is a component only so it fits the `component` field; the // router core never calls it — it mounts through the brand, with THIS @@ -106,3 +107,38 @@ export function serverRouteComponent

>>; } + +// How long a preload's call stays claimable by the render it warmed — the +// same window `query()` gives a preloaded entry. +const PRELOAD_TIMEOUT = 5000; + +// A bare `GET(fn)` source has no cache of its own, so a link preload and the +// render it warms would each send the request (#677). Hand the preload's +// call to the next render with the same args, once: the render mounts the +// result the preload already started, and a later render calls fresh, as a +// bare GET always does. Concurrent preloads of the same args share one call. +// `query()`/`liveQuery()` sources and plain functions keep their own path. +function handOffPreloads(source: ServerRouteFunction): ServerRouteFunction { + if (isServer || getServerFunctionMetadata(source)?.method !== "GET") return source; + const preloads = new Map]>(); + return args => { + const now = Date.now(); + for (const [k, [time]] of preloads) now - time >= PRELOAD_TIMEOUT && preloads.delete(k); + const key = JSON.stringify(args); + const entry = preloads.get(key); + if (untrack(getIntent) === "preload") { + if (entry) return entry[1]; + const result = source(args); + preloads.set(key, [now, result]); + // A failed preload is not handed on: the render retries. + const drop = () => preloads.get(key)?.[1] === result && preloads.delete(key); + result && typeof (result as any).then === "function" && (result as any).then(undefined, drop); + return result; + } + if (entry) { + preloads.delete(key); + return entry[1]; + } + return source(args); + }; +} diff --git a/test/server-routes.spec.tsx b/test/server-routes.spec.tsx index 4ea70790..774b4396 100644 --- a/test/server-routes.spec.tsx +++ b/test/server-routes.spec.tsx @@ -30,10 +30,13 @@ const SERVER_FUNCTION_METADATA = Symbol.for("solid.ServerFunctionMetadata"); let nextId = 0; /** Brand `fn` as a server function reference (what compiled `"use server"` output produces). */ -function serverFunction any>(fn: F): F & { id: string } { +function serverFunction any>( + fn: F, + meta: Record = {} +): F & { id: string } { const branded = fn as F & { id: string }; branded.id = "srv#" + nextId++; - (branded as any)[SERVER_FUNCTION_METADATA] = {}; + (branded as any)[SERVER_FUNCTION_METADATA] = meta; return branded; } @@ -290,6 +293,109 @@ describe("server component routes", () => { cleanup(); }); + describe("a bare GET source", () => { + function setup(fn: (args: ServerRouteArgs<{ id: string }>) => unknown) { + let navigate!: Navigator; + let preload!: ReturnType; + const Router = createRouter({ + routes: [ + defineRoute({ + path: "/stories/:id", + component: serverRouteComponent(serverFunction(fn, { method: "GET" }) as any) + }), + defineRoute({ + path: "/", + component: () => { + navigate = useNavigate(); + preload = usePreloadRoute(); + return

home

; + } + }) + ], + history: memoryHistory("/") + }); + const mounted = mount(() => ( + {props => {props.children}} + )); + return { + ...mounted, + navigate: (to: string) => navigate(to), + preload: (to: string) => preload(to, { preloadData: true }) + }; + } + + test("the render mounts the preload's call instead of sending its own (#677)", async () => { + const fn = vi.fn(async ({ params }: ServerRouteArgs<{ id: string }>) => { + await new Promise(r => setTimeout(r, 10)); + return () =>
; + }); + const app = setup(fn); + await settle(); + app.preload("/stories/5"); + app.preload("/stories/5"); + expect(fn).toHaveBeenCalledTimes(1); + + app.navigate("/stories/5"); + await settle(30); + expect(app.div.querySelector("[data-story]")!.getAttribute("data-story")).toBe("5"); + expect(fn).toHaveBeenCalledTimes(1); + app.cleanup(); + }); + + test("a preload is handed on once: a later visit calls fresh", async () => { + const fn = vi.fn(async ({ params }: ServerRouteArgs<{ id: string }>) => { + return () =>
; + }); + const app = setup(fn); + await settle(); + app.preload("/stories/5"); + app.navigate("/stories/5"); + await settle(20); + expect(fn).toHaveBeenCalledTimes(1); + + app.navigate("/"); + await settle(20); + app.navigate("/stories/5"); + await settle(20); + expect(fn).toHaveBeenCalledTimes(2); + expect(app.div.querySelector("[data-story]")!.getAttribute("data-story")).toBe("5"); + app.cleanup(); + }); + + test("a failed preload is not handed on: the render retries", async () => { + let fail = true; + const fn = vi.fn(async ({ params }: ServerRouteArgs<{ id: string }>) => { + if (fail) throw new Error("upstream down"); + return () =>
; + }); + const app = setup(fn); + await settle(); + app.preload("/stories/5"); + await settle(); + fail = false; + app.navigate("/stories/5"); + await settle(20); + expect(fn).toHaveBeenCalledTimes(2); + expect(app.div.querySelector("[data-story]")!.getAttribute("data-story")).toBe("5"); + app.cleanup(); + }); + + test("a preload for other args is not handed to this render", async () => { + const fn = vi.fn(async ({ params }: ServerRouteArgs<{ id: string }>) => { + return () =>
; + }); + const app = setup(fn); + await settle(); + app.preload("/stories/4"); + app.navigate("/stories/5"); + await settle(20); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn.mock.calls[1][0]).toEqual({ params: { id: "5" } }); + expect(app.div.querySelector("[data-story]")!.getAttribute("data-story")).toBe("5"); + app.cleanup(); + }); + }); + test("the key is the app's: `revalidate(key)` refetches the route", async () => { let version = 0; const fn = vi.fn(async ({ params }: ServerRouteArgs<{ id: string }>) => {