Skip to content

Commit eee71c5

Browse files
ryansolidclaude
andauthored
feat(liveQuery): server function producers are declared live() (#690)
* feat(liveQuery): server function producers are declared live() liveQuery wraps a server function producer in live() (which implies GET) so the channel multicasts a connection with the transport's live lifecycle, and forwards hydration's served value to the first connection so an unchanged takeover costs nothing on the wire. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fmb4aQtDJWoA8CUktR9QoZ * chore: require @solidjs/web and solid-js rc.17 (live implies GET) The fs server-page specs' hand-branded stubs now share the process with a real reference, as any app with a server page does, so liveQuery's live() finds the transport's GET. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fmb4aQtDJWoA8CUktR9QoZ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 24eee07 commit eee71c5

6 files changed

Lines changed: 155 additions & 44 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/router": patch
3+
---
4+
5+
`liveQuery(fn, key)` now declares a server function `live()` itself, so `liveQuery(fn, key)` gets the transport's live lifecycle (live address, `Last-Event-ID` resume, response-lifetime deaths, `Retry-After`) without wrapping by hand. An explicit `live(fn)` passes through untouched. Hydration takeover forwards the served value to the first connection, so an unchanged value costs nothing on the wire.

‎package.json‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,22 +48,22 @@
4848
"@rollup/plugin-node-resolve": "15.3.0",
4949
"@rollup/plugin-terser": "0.4.4",
5050
"@solidjs/vite-plugin": "3.0.0-next.50",
51-
"@solidjs/web": "^2.0.0-rc.15",
51+
"@solidjs/web": "^2.0.0-rc.17",
5252
"@types/jest": "^29.5.14",
5353
"@types/node": "^22.10.0",
5454
"babel-preset-solid": "^2.0.0-rc.2",
5555
"jsdom": "^25.0.1",
5656
"prettier": "^3.4.1",
5757
"rollup": "^4.27.4",
58-
"solid-js": "^2.0.0-rc.15",
58+
"solid-js": "^2.0.0-rc.17",
5959
"typescript": "^5.7.2",
6060
"vite": "^8.2.2",
6161
"vitest": "^4.1.11"
6262
},
6363
"peerDependencies": {
64-
"@solidjs/web": "^2.0.0-rc.15",
64+
"@solidjs/web": "^2.0.0-rc.17",
6565
"filesystem-routing": ">=0.4.0",
66-
"solid-js": "^2.0.0-rc.15"
66+
"solid-js": "^2.0.0-rc.17"
6767
},
6868
"peerDependenciesMeta": {
6969
"filesystem-routing": {

‎pnpm-lock.yaml‎

Lines changed: 23 additions & 23 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/data/liveQuery.ts‎

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import { createSignal, type Signal } from "solid-js";
22
import {
33
getRequestEvent,
44
getServerFunctionMetadata,
5-
getServerFunctionRPC,
65
isServer,
76
isServerFunction
87
} from "@solidjs/web";
8+
import { live } from "@solidjs/web/server-functions";
99
import { getIntent } from "../routing.js";
1010
import { hashKey, matchKey, registerFlightDataHook, registerRevalidateHook } from "./query.js";
1111

@@ -17,9 +17,16 @@ const PRELOAD_TIMEOUT = 5000;
1717
// computations meeting a branded iterable apply live SSR policy (document
1818
// face takes the first value; hydration re-runs the compute after adoption
1919
// to reconnect). liveQuery writes it on both faces itself — the server-side
20-
// resolved iterable and the client-side multicast iterable — so a producer
21-
// needs no separate live() declaration to be live here.
20+
// resolved iterable and the client-side multicast iterable. A server function
21+
// producer is declared live() by liveQuery (below); a plain producer needs no
22+
// declaration at all.
2223
const LIVE_SOURCE = Symbol.for("solid.LiveSource");
24+
// Stamped by hydration's takeover on the iterable the compute returned (the
25+
// subscriber iterable below): the value the page was served with. Forwarded
26+
// onto the channel's first live() iterable, which yields it first and names
27+
// it as its position, so a takeover whose value is unchanged costs nothing
28+
// on the wire.
29+
const LIVE_RESUME_FROM = Symbol.for("solid.LiveResumeFrom");
2330

2431
export type LiveQueryStatus = "idle" | "connecting" | "connected" | "reconnecting" | "closed";
2532

@@ -47,6 +54,8 @@ type Channel = {
4754
/** keep the record (latest value) past teardown — request-scoped channels
4855
* replay it to later consumers of the same render for value consistency */
4956
retain: boolean;
57+
/** the hydrated value the first connection resumes from (see LIVE_RESUME_FROM) */
58+
resume?: any;
5059
};
5160

5261
const channelMap = new Map<string, Channel>();
@@ -96,6 +105,10 @@ async function connect(ch: Channel) {
96105
};
97106
} catch {}
98107
}
108+
if (ch.resume !== undefined && result !== null && typeof result === "object") {
109+
result[LIVE_RESUME_FROM] = ch.resume;
110+
ch.resume = undefined;
111+
}
99112
const it =
100113
result !== null && typeof result === "object" && result[Symbol.asyncIterator]
101114
? result[Symbol.asyncIterator]()
@@ -179,7 +192,8 @@ function openChannel(
179192
key: string,
180193
call: () => any,
181194
report: boolean,
182-
retain: boolean
195+
retain: boolean,
196+
resume?: any
183197
): Channel {
184198
let ch = map.get(key);
185199
if (!ch) {
@@ -196,7 +210,8 @@ function openChannel(
196210
close: () => {},
197211
call,
198212
report,
199-
retain
213+
retain,
214+
resume
200215
};
201216
map.set(key, ch);
202217
connect(ch);
@@ -208,11 +223,11 @@ function openChannel(
208223
// channel lazily (first pull connects — a hydration trace or speculative
209224
// call opens nothing), replay the latest value immediately, then follow
210225
// with latest-wins delivery.
211-
function subscriberIterable(open: () => Channel) {
212-
return {
226+
function subscriberIterable(open: (resume?: any) => Channel) {
227+
const iterable: any = {
213228
[LIVE_SOURCE]: true,
214229
[Symbol.asyncIterator]() {
215-
const ch = open();
230+
const ch = open(iterable[LIVE_RESUME_FROM]);
216231
ch.count++;
217232
let seen = 0;
218233
let released = false;
@@ -248,6 +263,7 @@ function subscriberIterable(open: () => Channel) {
248263
};
249264
}
250265
};
266+
return iterable;
251267
}
252268

253269
function reconnectChannel(ch: Channel) {
@@ -334,7 +350,8 @@ export type LiveFunction<T extends (...args: any) => any> = T extends (...args:
334350
* definite rejection (4xx: the transport stamps HTTP statuses onto
335351
* failures) ends the channel and surfaces the error to consumers, as does
336352
* a first-connect failure.
337-
* - Server functions are declared GET at creation (like `query`). Live keys
353+
* - Server functions are declared `live()` (which implies GET) at creation,
354+
* so the connection gets the transport's live lifecycle. Live keys
338355
* are neither collected for single-flight nor swept after a mutation: the
339356
* stream is the freshness mechanism, and the mutation reaches a live
340357
* query through its producer. (The flight-data hook below still adopts a
@@ -359,13 +376,11 @@ export type LiveFunction<T extends (...args: any) => any> = T extends (...args:
359376
*/
360377
export function liveQuery<T extends (...args: any) => any>(fn: T, name: string): LiveFunction<T> {
361378
hookRevalidate();
362-
// liveQuery implies GET, exactly as query does: reads belong on the GET
363-
// transport (cacheable URLs, no single-flight enveloping). An explicit
364-
// GET(fn) or live(GET(fn)) declaration passes through untouched.
365-
if (isServerFunction(fn) && !getServerFunctionMetadata(fn)?.method) {
366-
const rpc = getServerFunctionRPC();
367-
if (rpc) fn = rpc.GET(fn) as unknown as T;
368-
}
379+
// liveQuery implies live (which implies GET): live() brings the wire
380+
// lifecycle — the live address, Last-Event-ID resume, response-lifetime
381+
// deaths, Retry-After — so the channel multicasts a live connection. An
382+
// explicit live() declaration passes through untouched.
383+
if (isServerFunction(fn) && !getServerFunctionMetadata(fn)?.live) fn = live(fn) as unknown as T;
369384
const liveFn = ((...args: Parameters<T>) => {
370385
const key = name + hashKey(args);
371386
if (isServer) {
@@ -395,7 +410,8 @@ export function liveQuery<T extends (...args: any) => any>(fn: T, name: string):
395410
openChannel(channels, key, () => fn(...(args as any)), false, true)
396411
);
397412
}
398-
const open = () => openChannel(channelMap, key, () => fn(...(args as any)), true, false);
413+
const open = (resume?: any) =>
414+
openChannel(channelMap, key, () => fn(...(args as any)), true, false, resume);
399415
// Preload warms the channel, exactly as calling a query in a preload
400416
// function warms its cache: connect now, hold a temporary consumer slot
401417
// so refcounting doesn't close it before navigation renders. When the

‎test/data/live-query.spec.ts‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { vi } from "vitest";
22
import { flush } from "solid-js";
3+
import {
4+
createServerReference,
5+
getServerFunctionMetadata,
6+
live
7+
} from "@solidjs/web/server-functions";
38
import { liveQuery } from "../../src/data/liveQuery.js";
49
import { deliverFlightData, revalidate } from "../../src/data/query.js";
510

@@ -563,3 +568,83 @@ describe("liveQuery through solid's reactive consumption", () => {
563568
});
564569
});
565570
});
571+
572+
describe("liveQuery over a server function", () => {
573+
// vitest's browser conditions load the client entry, whose
574+
// createServerReference takes an id (the types resolve the server ABI).
575+
const reference = (id: string) => (createServerReference as unknown as (id: string) => any)(id);
576+
577+
async function withFetch(run: (seen: string[]) => Promise<void>) {
578+
const seen: string[] = [];
579+
const originalFetch = globalThis.fetch;
580+
globalThis.fetch = (async (url: any) => {
581+
seen.push(String(url));
582+
// BodyFormat.String: a one-value answer
583+
return new Response("v1", {
584+
headers: { "X-Server-Function-Format": "1", "Content-Type": "text/plain" }
585+
});
586+
}) as typeof fetch;
587+
try {
588+
await run(seen);
589+
} finally {
590+
globalThis.fetch = originalFetch;
591+
}
592+
}
593+
594+
test("implies live(): the channel connects at the live address", async () => {
595+
const ref = reference("lq-sf-0");
596+
await withFetch(async seen => {
597+
const lq = liveQuery(ref, uniqueName());
598+
const it = lq()[Symbol.asyncIterator]();
599+
expect((await it.next()).value).toBe("v1");
600+
await it.return!();
601+
expect(seen).toHaveLength(1);
602+
expect(seen[0]).toContain("/_server/live/lq-sf-0");
603+
});
604+
// the declaration is the wrapper's; the reference itself is untouched
605+
expect(getServerFunctionMetadata(ref)?.live).toBeUndefined();
606+
});
607+
608+
test("an explicit live() declaration passes through untouched", async () => {
609+
await withFetch(async seen => {
610+
const lq = liveQuery(live(reference("lq-sf-1")), uniqueName());
611+
const it = lq()[Symbol.asyncIterator]();
612+
expect((await it.next()).value).toBe("v1");
613+
await it.return!();
614+
expect(seen).toHaveLength(1);
615+
expect(seen[0]).toContain("/_server/live/lq-sf-1");
616+
});
617+
});
618+
});
619+
620+
describe("hydration takeover resume", () => {
621+
const RESUME = Symbol.for("solid.LiveResumeFrom");
622+
623+
test("the hydrated value is forwarded onto the first connection's iterable", async () => {
624+
const stamped: any[] = [];
625+
const fn = () => {
626+
const source: any = {
627+
[Symbol.asyncIterator]() {
628+
stamped.push(source[RESUME]);
629+
let sent = false;
630+
return {
631+
next: async () =>
632+
sent
633+
? new Promise<never>(() => {})
634+
: ((sent = true), { done: false, value: "fresh" }),
635+
return: async () => ({ done: true, value: undefined })
636+
};
637+
}
638+
};
639+
return source;
640+
};
641+
const lq = liveQuery(fn, uniqueName());
642+
const iterable: any = lq();
643+
// what solid's takeover run does to the iterable its compute returned
644+
iterable[RESUME] = "served";
645+
const it = iterable[Symbol.asyncIterator]();
646+
expect((await it.next()).value).toBe("fresh");
647+
expect(stamped).toEqual(["served"]);
648+
await it.return!();
649+
});
650+
});

0 commit comments

Comments
 (0)