Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stamp-query-flights.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 24 additions & 3 deletions src/data/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
getOwner,
isHydratable,
isHydrating,
OBSERVE,
onCleanup,
untrack,
type Signal
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -274,6 +292,8 @@ export function query<T extends (...args: any) => 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
Expand Down Expand Up @@ -317,9 +337,10 @@ export function query<T extends (...args: any) => 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<Headers>) {
Expand Down
84 changes: 84 additions & 0 deletions test/data/query-flight-stamp.spec.ts
Original file line number Diff line number Diff line change
@@ -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<object, number>;
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();
});
});
Loading