diff --git a/.changeset/stamp-query-flights.md b/.changeset/stamp-query-flights.md new file mode 100644 index 00000000..e057b6e8 --- /dev/null +++ b/.changeset/stamp-query-flights.md @@ -0,0 +1,5 @@ +--- +"@solidjs/router": patch +--- + +`query()` stamps the fetch it starts, and every wrapper it hands a reader, with when that fetch started (`OBSERVE.attribution.markFlight`, observe and dev builds only). Page data a route's preload started, and read a render pass later behind the page's lazy chunk, no longer reads as a waterfall to `ASYNC_WATERFALL` or `SSR_BOUNDARY_WATERFALL`. Takes effect with the solid release that carries the stamps on its core slot (solidjs/solid#4108); older versions ignore it. diff --git a/src/data/query.ts b/src/data/query.ts index 6bdd703a..0097bbc7 100644 --- a/src/data/query.ts +++ b/src/data/query.ts @@ -4,6 +4,7 @@ import { getOwner, isHydratable, isHydrating, + OBSERVE, onCleanup, untrack, type Signal @@ -38,6 +39,23 @@ import { useRouter, getIntent, getInPreloadFn } from "../routing.js"; import type { CacheEntry, NarrowResponse } from "../types.js"; import { isUnderBase } from "../utils.js"; +/** + * Observe: stamps a promise this cache hands out with when its fetch started + * (`from`'s start, or now). Each read gets a fresh wrapper, so without the + * stamp a reader that meets it after a preload (a lazy route a render pass + * later) looks like work that only started then, and the waterfall checks + * read parallel work as a chain (solid #4105). Callers gate on `OBSERVE`, + * which folds out of shipped bundles. + */ +function stampFlight(flight: object, from?: object) { + // Optional: solid-js versions before the core slot carried stamps lack these. + const slot = OBSERVE!.attribution as { + markFlight?: (flight: object, startedAt?: number) => void; + flightOrigin?: (flight: object) => number | undefined; + }; + slot.markFlight?.(flight, from && slot.flightOrigin?.(from)); +} + const LocationHeader = "Location"; const REDIRECT_STATUSES = /* #__PURE__ */ new Set([301, 302, 303, 307, 308]); const PRELOAD_TIMEOUT = 5000; @@ -274,6 +292,8 @@ export function query any>(fn: T, name: string): Cac // read (no intent) may still need it for server-DOM consistency. } if (!adopted) res = fn(...(args as any)); + // Observe: the fetch starts now, whoever reads it later (see `stampFlight`). + OBSERVE && res && typeof res.then === "function" && stampFlight(res); // Adopted entries are stamped at boot — when their data was actually // fetched — not at consumption. Later hydration reads still reuse them @@ -317,9 +337,10 @@ export function query any>(fn: T, name: string): Cac function settle(v: any) { let cookies = isServer ? forwardedCookies.get(cached) : undefined; if (isServer && !cookies) forwardedCookies.set(cached, (cookies = new WeakSet())); - return "then" in v - ? v.then(handleResponse(false, cookies), handleResponse(true, cookies)) - : handleResponse(false, cookies)(v); + if (!("then" in v)) return handleResponse(false, cookies)(v); + const read = v.then(handleResponse(false, cookies), handleResponse(true, cookies)); + OBSERVE && stampFlight(read, v); + return read; } function handleResponse(error: boolean, cookies?: WeakSet) { diff --git a/test/data/query-flight-stamp.spec.ts b/test/data/query-flight-stamp.spec.ts new file mode 100644 index 00000000..907b3704 --- /dev/null +++ b/test/data/query-flight-stamp.spec.ts @@ -0,0 +1,84 @@ +// Observe tier: query() stamps the fetch it starts, and every wrapper it +// hands a reader, with when that fetch started (`OBSERVE.attribution +// .markFlight`). A route's preload starts the fetch; the page reads it a +// render pass later (behind its lazy chunk) through a fresh `.then` wrapper, +// and without the stamp both waterfall checks — the client's ASYNC_WATERFALL +// and the server's SSR_BOUNDARY_WATERFALL — read that wrapper as work that +// only started then (solid #4105). +import { OBSERVE, createRoot } from "solid-js"; +import { vi } from "vitest"; +import { query } from "../../src/data/query.js"; +import { createMockRouter } from "../helpers.js"; + +const mockRouter = createMockRouter(); +let intent: string | undefined = "preload"; + +vi.mock("../../src/routing.js", () => ({ + useRouter: () => mockRouter, + getIntent: () => intent, + getInPreloadFn: () => false +})); + +// The slot's stamp functions, as a solid-js that carries them provides; the +// test owns the map so it can read the stamps back. +type Slot = { + markFlight?: (flight: object, startedAt?: number) => void; + flightOrigin?: (flight: object) => number | undefined; +}; +const slot = OBSERVE!.attribution as Slot; +const saved = { markFlight: slot.markFlight, flightOrigin: slot.flightOrigin }; +let origins: WeakMap; +let clock = 0; + +beforeEach(() => { + query.clear(); + origins = new WeakMap(); + clock = 100; + slot.markFlight = (flight, startedAt = clock) => { + if (!(origins.get(flight)! <= startedAt)) origins.set(flight, startedAt); + }; + slot.flightOrigin = flight => origins.get(flight); +}); +afterEach(() => { + slot.markFlight = saved.markFlight; + slot.flightOrigin = saved.flightOrigin; + intent = "preload"; +}); + +describe("query() stamps the flights it hands out", () => { + test("a later read's wrapper carries the start of the fetch the preload began", async () => { + let fetches = 0; + const getUser = query(async (id: string) => { + fetches++; + return { id }; + }, "flightUser"); + + // The preload starts the fetch at t=100 and hands back the raw promise. + const preloaded = createRoot(() => getUser("1")); + expect(origins.get(preloaded)).toBe(100); + + // The page reads it later, through a fresh settle wrapper. + clock = 250; + intent = "navigate"; + const read = createRoot(() => getUser("1")); + expect(fetches).toBe(1); + expect(read).not.toBe(preloaded); + expect(origins.get(read)).toBe(100); + await expect(read).resolves.toEqual({ id: "1" }); + }); + + test("a fresh fetch read straight away is stamped when it started", () => { + intent = "navigate"; + const getPost = query(async (id: string) => ({ id }), "flightPost"); + const read = createRoot(() => getPost("7")); + expect(origins.get(read)).toBe(100); + }); + + test("a solid-js without the stamp functions is left alone", () => { + slot.markFlight = undefined; + slot.flightOrigin = undefined; + intent = "navigate"; + const getPost = query(async (id: string) => ({ id }), "flightOld"); + expect(() => createRoot(() => getPost("1"))).not.toThrow(); + }); +});