diff --git a/.changeset/live-query-implies-live.md b/.changeset/live-query-implies-live.md new file mode 100644 index 00000000..68d0d4e7 --- /dev/null +++ b/.changeset/live-query-implies-live.md @@ -0,0 +1,5 @@ +--- +"@solidjs/router": patch +--- + +`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. diff --git a/package.json b/package.json index 719879fc..c2f3b6a9 100644 --- a/package.json +++ b/package.json @@ -48,22 +48,22 @@ "@rollup/plugin-node-resolve": "15.3.0", "@rollup/plugin-terser": "0.4.4", "@solidjs/vite-plugin": "3.0.0-next.50", - "@solidjs/web": "^2.0.0-rc.15", + "@solidjs/web": "^2.0.0-rc.17", "@types/jest": "^29.5.14", "@types/node": "^22.10.0", "babel-preset-solid": "^2.0.0-rc.2", "jsdom": "^25.0.1", "prettier": "^3.4.1", "rollup": "^4.27.4", - "solid-js": "^2.0.0-rc.15", + "solid-js": "^2.0.0-rc.17", "typescript": "^5.7.2", "vite": "^8.2.2", "vitest": "^4.1.11" }, "peerDependencies": { - "@solidjs/web": "^2.0.0-rc.15", + "@solidjs/web": "^2.0.0-rc.17", "filesystem-routing": ">=0.4.0", - "solid-js": "^2.0.0-rc.15" + "solid-js": "^2.0.0-rc.17" }, "peerDependenciesMeta": { "filesystem-routing": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 679a5b03..1a1222b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,10 +32,10 @@ importers: version: 0.4.4(rollup@4.27.4) '@solidjs/vite-plugin': specifier: 3.0.0-next.50 - version: 3.0.0-next.50(@solidjs/web@2.0.0-rc.15(solid-js@2.0.0-rc.15))(solid-js@2.0.0-rc.15)(vite@8.2.2(@types/node@22.10.0)(terser@5.36.0)) + version: 3.0.0-next.50(@solidjs/web@2.0.0-rc.17(solid-js@2.0.0-rc.17))(solid-js@2.0.0-rc.17)(vite@8.2.2(@types/node@22.10.0)(terser@5.36.0)) '@solidjs/web': - specifier: ^2.0.0-rc.15 - version: 2.0.0-rc.15(solid-js@2.0.0-rc.15) + specifier: ^2.0.0-rc.17 + version: 2.0.0-rc.17(solid-js@2.0.0-rc.17) '@types/jest': specifier: ^29.5.14 version: 29.5.14 @@ -44,7 +44,7 @@ importers: version: 22.10.0 babel-preset-solid: specifier: ^2.0.0-rc.2 - version: 2.0.0-rc.2(@babel/core@7.26.0)(solid-js@2.0.0-rc.15) + version: 2.0.0-rc.2(@babel/core@7.26.0)(solid-js@2.0.0-rc.17) jsdom: specifier: ^25.0.1 version: 25.0.1 @@ -55,8 +55,8 @@ importers: specifier: ^4.27.4 version: 4.27.4 solid-js: - specifier: ^2.0.0-rc.15 - version: 2.0.0-rc.15 + specifier: ^2.0.0-rc.17 + version: 2.0.0-rc.17 typescript: specifier: ^5.7.2 version: 5.7.2 @@ -884,8 +884,8 @@ packages: '@solidjs/compiler@2.0.0-rc.15': resolution: {integrity: sha512-DrtzyGsnOl6Ad6XpHFMrGX9LjBix6MLGbQFcF2UGlL1KgYoUvBpUfDv/a7fTlccL9+CzBd35E9Rq97Uvjq64zg==} - '@solidjs/signals@2.0.0-rc.15': - resolution: {integrity: sha512-rMhLaYnBvpb9sB3NJGPsqIxjgr0XKU2nvBS7jhWX2fKx+K/m5O0VoAuCgYuTie3BMBZU9vX08NdnFXg508pcew==} + '@solidjs/signals@2.0.0-rc.17': + resolution: {integrity: sha512-GXXWBlJsiZeBCTw3a941Y/HVBYfwupCfctPnwNjFJV+u3tZnRe/SL+NeStI1247zfxlvkH4p9PWzdZK/yX1I1Q==} engines: {node: '>=22.12.0'} '@solidjs/vite-plugin@3.0.0-next.50': @@ -903,11 +903,11 @@ packages: '@testing-library/jest-dom': optional: true - '@solidjs/web@2.0.0-rc.15': - resolution: {integrity: sha512-xwt4rNZ9CXTdMraFqmVn/AXuHIXQJ3jnrKyzEZ4ZFdRCBml0VxL8pA5D3YZHSjHoF7okNAmiVtyRCMNz15jQCg==} + '@solidjs/web@2.0.0-rc.17': + resolution: {integrity: sha512-cJ+8GzVYWBi+/glbgWt6cVgRTxvVa4TG+rShaEuQnPHjUZQTsHh8hvR89KVdFB+5XQJnAajWDNpxz7LkD4MkDg==} engines: {node: '>=22.12.0'} peerDependencies: - solid-js: ^2.0.0-rc.15 + solid-js: ^2.0.0-rc.17 '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1744,8 +1744,8 @@ packages: smob@1.4.1: resolution: {integrity: sha512-9LK+E7Hv5R9u4g4C3p+jjLstaLe11MDsL21UpYaCNmapvMkYhqCV4A/f/3gyH8QjMyh6l68q9xC85vihY9ahMQ==} - solid-js@2.0.0-rc.15: - resolution: {integrity: sha512-HXiWAEG5Rjp5C7reElUlvdriIadcbpwWwVdztTf0wuxeirw49rGuIzVbSRn06p6GjlINwi5d24Z46b478hNdvw==} + solid-js@2.0.0-rc.17: + resolution: {integrity: sha512-ym3x2kzC+vzgD9VNqhqExn3qWzkrpJco3vane0wTFHCVPS9HBXI3GgSp+ieZ1h72zcb2z27PXFSbMsi8Ot+Hxw==} engines: {node: '>=22.12.0'} source-map-js@1.2.1: @@ -2914,29 +2914,29 @@ snapshots: '@solidjs/compiler-wasm32-wasi': 2.0.0-rc.15 '@solidjs/compiler-win32-x64-msvc': 2.0.0-rc.15 - '@solidjs/signals@2.0.0-rc.15': {} + '@solidjs/signals@2.0.0-rc.17': {} - '@solidjs/vite-plugin@3.0.0-next.50(@solidjs/web@2.0.0-rc.15(solid-js@2.0.0-rc.15))(solid-js@2.0.0-rc.15)(vite@8.2.2(@types/node@22.10.0)(terser@5.36.0))': + '@solidjs/vite-plugin@3.0.0-next.50(@solidjs/web@2.0.0-rc.17(solid-js@2.0.0-rc.17))(solid-js@2.0.0-rc.17)(vite@8.2.2(@types/node@22.10.0)(terser@5.36.0))': dependencies: '@ampproject/remapping': 2.3.0 '@babel/core': 7.29.7 '@solidjs/babel-plugin': 2.0.0-rc.15(@babel/core@7.29.7) '@solidjs/compiler': 2.0.0-rc.15 - '@solidjs/web': 2.0.0-rc.15(solid-js@2.0.0-rc.15) + '@solidjs/web': 2.0.0-rc.17(solid-js@2.0.0-rc.17) '@types/babel__core': 7.20.5 merge-anything: 5.1.7 - solid-js: 2.0.0-rc.15 + solid-js: 2.0.0-rc.17 vite: 8.2.2(@types/node@22.10.0)(terser@5.36.0) vitefu: 1.0.4(vite@8.2.2(@types/node@22.10.0)(terser@5.36.0)) transitivePeerDependencies: - '@tsrx/core' - supports-color - '@solidjs/web@2.0.0-rc.15(solid-js@2.0.0-rc.15)': + '@solidjs/web@2.0.0-rc.17(solid-js@2.0.0-rc.17)': dependencies: seroval: 1.6.7 seroval-plugins: 1.6.7(seroval@1.6.7) - solid-js: 2.0.0-rc.15 + solid-js: 2.0.0-rc.17 '@standard-schema/spec@1.1.0': {} @@ -3095,12 +3095,12 @@ snapshots: asynckit@0.4.0: {} - babel-preset-solid@2.0.0-rc.2(@babel/core@7.26.0)(solid-js@2.0.0-rc.15): + babel-preset-solid@2.0.0-rc.2(@babel/core@7.26.0)(solid-js@2.0.0-rc.17): dependencies: '@babel/core': 7.26.0 '@dom-expressions/babel-plugin-jsx': 0.50.0-next.44(@babel/core@7.26.0) optionalDependencies: - solid-js: 2.0.0-rc.15 + solid-js: 2.0.0-rc.17 better-path-resolve@1.0.0: dependencies: @@ -3761,9 +3761,9 @@ snapshots: smob@1.4.1: {} - solid-js@2.0.0-rc.15: + solid-js@2.0.0-rc.17: dependencies: - '@solidjs/signals': 2.0.0-rc.15 + '@solidjs/signals': 2.0.0-rc.17 csstype: 3.2.3 seroval: 1.6.7 seroval-plugins: 1.6.7(seroval@1.6.7) diff --git a/src/data/liveQuery.ts b/src/data/liveQuery.ts index 6c3d76de..40c5544e 100644 --- a/src/data/liveQuery.ts +++ b/src/data/liveQuery.ts @@ -2,10 +2,10 @@ import { createSignal, type Signal } from "solid-js"; import { getRequestEvent, getServerFunctionMetadata, - getServerFunctionRPC, isServer, isServerFunction } from "@solidjs/web"; +import { live } from "@solidjs/web/server-functions"; import { getIntent } from "../routing.js"; import { hashKey, matchKey, registerFlightDataHook, registerRevalidateHook } from "./query.js"; @@ -17,9 +17,16 @@ const PRELOAD_TIMEOUT = 5000; // computations meeting a branded iterable apply live SSR policy (document // face takes the first value; hydration re-runs the compute after adoption // to reconnect). liveQuery writes it on both faces itself — the server-side -// resolved iterable and the client-side multicast iterable — so a producer -// needs no separate live() declaration to be live here. +// resolved iterable and the client-side multicast iterable. A server function +// producer is declared live() by liveQuery (below); a plain producer needs no +// declaration at all. const LIVE_SOURCE = Symbol.for("solid.LiveSource"); +// Stamped by hydration's takeover on the iterable the compute returned (the +// subscriber iterable below): the value the page was served with. Forwarded +// onto the channel's first live() iterable, which yields it first and names +// it as its position, so a takeover whose value is unchanged costs nothing +// on the wire. +const LIVE_RESUME_FROM = Symbol.for("solid.LiveResumeFrom"); export type LiveQueryStatus = "idle" | "connecting" | "connected" | "reconnecting" | "closed"; @@ -47,6 +54,8 @@ type Channel = { /** keep the record (latest value) past teardown — request-scoped channels * replay it to later consumers of the same render for value consistency */ retain: boolean; + /** the hydrated value the first connection resumes from (see LIVE_RESUME_FROM) */ + resume?: any; }; const channelMap = new Map(); @@ -96,6 +105,10 @@ async function connect(ch: Channel) { }; } catch {} } + if (ch.resume !== undefined && result !== null && typeof result === "object") { + result[LIVE_RESUME_FROM] = ch.resume; + ch.resume = undefined; + } const it = result !== null && typeof result === "object" && result[Symbol.asyncIterator] ? result[Symbol.asyncIterator]() @@ -179,7 +192,8 @@ function openChannel( key: string, call: () => any, report: boolean, - retain: boolean + retain: boolean, + resume?: any ): Channel { let ch = map.get(key); if (!ch) { @@ -196,7 +210,8 @@ function openChannel( close: () => {}, call, report, - retain + retain, + resume }; map.set(key, ch); connect(ch); @@ -208,11 +223,11 @@ function openChannel( // channel lazily (first pull connects — a hydration trace or speculative // call opens nothing), replay the latest value immediately, then follow // with latest-wins delivery. -function subscriberIterable(open: () => Channel) { - return { +function subscriberIterable(open: (resume?: any) => Channel) { + const iterable: any = { [LIVE_SOURCE]: true, [Symbol.asyncIterator]() { - const ch = open(); + const ch = open(iterable[LIVE_RESUME_FROM]); ch.count++; let seen = 0; let released = false; @@ -248,6 +263,7 @@ function subscriberIterable(open: () => Channel) { }; } }; + return iterable; } function reconnectChannel(ch: Channel) { @@ -334,7 +350,8 @@ export type LiveFunction any> = T extends (...args: * definite rejection (4xx: the transport stamps HTTP statuses onto * failures) ends the channel and surfaces the error to consumers, as does * a first-connect failure. - * - Server functions are declared GET at creation (like `query`). Live keys + * - Server functions are declared `live()` (which implies GET) at creation, + * so the connection gets the transport's live lifecycle. Live keys * are neither collected for single-flight nor swept after a mutation: the * stream is the freshness mechanism, and the mutation reaches a live * query through its producer. (The flight-data hook below still adopts a @@ -359,13 +376,11 @@ export type LiveFunction any> = T extends (...args: */ export function liveQuery any>(fn: T, name: string): LiveFunction { hookRevalidate(); - // liveQuery implies GET, exactly as query does: reads belong on the GET - // transport (cacheable URLs, no single-flight enveloping). An explicit - // GET(fn) or live(GET(fn)) declaration passes through untouched. - if (isServerFunction(fn) && !getServerFunctionMetadata(fn)?.method) { - const rpc = getServerFunctionRPC(); - if (rpc) fn = rpc.GET(fn) as unknown as T; - } + // liveQuery implies live (which implies GET): live() brings the wire + // lifecycle — the live address, Last-Event-ID resume, response-lifetime + // deaths, Retry-After — so the channel multicasts a live connection. An + // explicit live() declaration passes through untouched. + if (isServerFunction(fn) && !getServerFunctionMetadata(fn)?.live) fn = live(fn) as unknown as T; const liveFn = ((...args: Parameters) => { const key = name + hashKey(args); if (isServer) { @@ -395,7 +410,8 @@ export function liveQuery any>(fn: T, name: string): openChannel(channels, key, () => fn(...(args as any)), false, true) ); } - const open = () => openChannel(channelMap, key, () => fn(...(args as any)), true, false); + const open = (resume?: any) => + openChannel(channelMap, key, () => fn(...(args as any)), true, false, resume); // Preload warms the channel, exactly as calling a query in a preload // function warms its cache: connect now, hold a temporary consumer slot // so refcounting doesn't close it before navigation renders. When the diff --git a/test/data/live-query.spec.ts b/test/data/live-query.spec.ts index c2539f66..71fe78af 100644 --- a/test/data/live-query.spec.ts +++ b/test/data/live-query.spec.ts @@ -1,5 +1,10 @@ import { vi } from "vitest"; import { flush } from "solid-js"; +import { + createServerReference, + getServerFunctionMetadata, + live +} from "@solidjs/web/server-functions"; import { liveQuery } from "../../src/data/liveQuery.js"; import { deliverFlightData, revalidate } from "../../src/data/query.js"; @@ -563,3 +568,83 @@ describe("liveQuery through solid's reactive consumption", () => { }); }); }); + +describe("liveQuery over a server function", () => { + // vitest's browser conditions load the client entry, whose + // createServerReference takes an id (the types resolve the server ABI). + const reference = (id: string) => (createServerReference as unknown as (id: string) => any)(id); + + async function withFetch(run: (seen: string[]) => Promise) { + const seen: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: any) => { + seen.push(String(url)); + // BodyFormat.String: a one-value answer + return new Response("v1", { + headers: { "X-Server-Function-Format": "1", "Content-Type": "text/plain" } + }); + }) as typeof fetch; + try { + await run(seen); + } finally { + globalThis.fetch = originalFetch; + } + } + + test("implies live(): the channel connects at the live address", async () => { + const ref = reference("lq-sf-0"); + await withFetch(async seen => { + const lq = liveQuery(ref, uniqueName()); + const it = lq()[Symbol.asyncIterator](); + expect((await it.next()).value).toBe("v1"); + await it.return!(); + expect(seen).toHaveLength(1); + expect(seen[0]).toContain("/_server/live/lq-sf-0"); + }); + // the declaration is the wrapper's; the reference itself is untouched + expect(getServerFunctionMetadata(ref)?.live).toBeUndefined(); + }); + + test("an explicit live() declaration passes through untouched", async () => { + await withFetch(async seen => { + const lq = liveQuery(live(reference("lq-sf-1")), uniqueName()); + const it = lq()[Symbol.asyncIterator](); + expect((await it.next()).value).toBe("v1"); + await it.return!(); + expect(seen).toHaveLength(1); + expect(seen[0]).toContain("/_server/live/lq-sf-1"); + }); + }); +}); + +describe("hydration takeover resume", () => { + const RESUME = Symbol.for("solid.LiveResumeFrom"); + + test("the hydrated value is forwarded onto the first connection's iterable", async () => { + const stamped: any[] = []; + const fn = () => { + const source: any = { + [Symbol.asyncIterator]() { + stamped.push(source[RESUME]); + let sent = false; + return { + next: async () => + sent + ? new Promise(() => {}) + : ((sent = true), { done: false, value: "fresh" }), + return: async () => ({ done: true, value: undefined }) + }; + } + }; + return source; + }; + const lq = liveQuery(fn, uniqueName()); + const iterable: any = lq(); + // what solid's takeover run does to the iterable its compute returned + iterable[RESUME] = "served"; + const it = iterable[Symbol.asyncIterator](); + expect((await it.next()).value).toBe("fresh"); + expect(stamped).toEqual(["served"]); + await it.return!(); + }); +}); diff --git a/test/fs.spec.tsx b/test/fs.spec.tsx index 45a1eeac..ac0fd6b3 100644 --- a/test/fs.spec.tsx +++ b/test/fs.spec.tsx @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { createServerReference } from "@solidjs/web/server-functions"; import { liveQuery } from "../src/data/liveQuery.js"; import { defineFileRoute, fileRoutes } from "../src/fs.js"; @@ -192,6 +193,10 @@ describe("fileRoutes", () => { * is the shipped one (`serverRoutes: true`, aliased in vitest.config.ts). */ const SERVER_FUNCTION_METADATA = Symbol.for("solid.ServerFunctionMetadata"); +// Any app with a server page holds a real reference, which fills the +// transport's RPC seam that `live()` (implied by liveQuery) reaches GET +// through; the hand-branded stubs below don't. +(createServerReference as unknown as (id: string) => unknown)("fs-spec-seam"); /** Brand `fn` the way compiled `"use server"` output does. */ function serverFunction any>(fn: F): F { (fn as any)[SERVER_FUNCTION_METADATA] = {};