Skip to content
Closed
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
11 changes: 11 additions & 0 deletions .changeset/add-web-data-subpath.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@solidjs/web": minor
---

New `@solidjs/web/data` subpath: router-agnostic `query()` and `action()` (ported from @solidjs/router's next branch) plus a single-flight query channel, extracted from app experiments on TanStack Router and @solidjs/router hosts where the same files ran unchanged.

- `query(fn, name)` — request-deduped async cache. Same key (name + stable argument hash) in a route loader/preload and a component memo returns the same promise; Solid 2 memos unwrap it into the nearest `<Loading>`. Per-request cache in `event.locals` on the server, module-level with observed/timed freshness on the client. Server functions are auto-declared `GET`. `revalidate`, `query.get/set/delete/clear`, and `collectQueries`/`seedQueries` (an explicit SSR seeding channel for hosts that warm the cache outside the render tree) included.
- `action(fn)` — form-bindable mutations: `toString()` renders the server function's real `.url` into `<form action={...}>` (no-JS posts work natively); a document-level submit listener intercepts matching forms and calls the RPC stub.
- Single-flight: `installQueryFlightConsumer()` (client) applies post-mutation query values from the mutation response via `query.set`; `createQueryFlightCollector(warmQueries)` on `@solidjs/web/data/server` produces the `collectFlightData` hook — the host supplies only `warmQueries(href, outcome)`, re-running that location's data loading (TanStack: build a router and `load()`; @solidjs/router: delegate to `createFlightDataCollector`). The `/server` split keeps `@solidjs/web/storage` (node:async_hooks) out of client bundles.

Not yet ported from solid-router: submission state (`useSubmission`), redirect/Response handling, and preload intent semantics.
30 changes: 30 additions & 0 deletions packages/solid-web/data/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@solidjs/web/data",
"main": "./dist/data.cjs",
"module": "./dist/data.js",
"types": "./types/index.d.ts",
"type": "module",
"sideEffects": false,
"exports": {
".": {
"import": {
"types": "./types/index.d.ts",
"default": "./dist/data.js"
},
"require": {
"types": "./types-cjs/index.d.cts",
"default": "./dist/data.cjs"
}
},
"./server": {
"import": {
"types": "./types/server.d.ts",
"default": "./dist/server.js"
},
"require": {
"types": "./types-cjs/server.d.cts",
"default": "./dist/server.cjs"
}
}
}
}
49 changes: 49 additions & 0 deletions packages/solid-web/data/src/action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* `action()` — form-bindable mutations, after @solidjs/router's `action()`.
*
* Server function references carry a stable `.url`, so `toString()` renders
* the real endpoint into `<form action={...}>` — without JS the browser POSTs
* there natively (the transport handles no-JS form posts). With JS, a single
* document-level submit listener intercepts matching forms and calls the RPC
* stub instead; non-GET calls opt into single-flight automatically while a
* flight-data consumer is subscribed, so post-mutation query data rides back
* on the response (see ./flight.ts).
*
* The function receives the form's FormData when it declares a parameter.
* TODO: submission state (useSubmission) and redirect handling.
*/
import { isServer } from "@solidjs/web";
import type { JSX } from "@solidjs/web";
import { isServerFunction } from "@solidjs/web/server-functions";

export type Action<A extends any[], R> = ((...args: A) => Promise<R>) &
JSX.SerializableAttributeValue & { url: string };

const actions = new Map<string, (formData: FormData) => Promise<unknown>>();

if (!isServer) {
document.addEventListener("submit", event => {
const form = event.target as HTMLFormElement;
const submitter = event.submitter;
const url = submitter?.getAttribute("formaction")
? (submitter as HTMLButtonElement).formAction
: form.action;
const handler = actions.get(url);
if (!handler) return;
event.preventDefault();
void handler(new FormData(form, submitter));
});
}

export function action<A extends any[], R>(fn: (...args: A) => Promise<R>): Action<A, R> {
if (!isServerFunction(fn)) throw new Error("action() expects a server function");
const wrapper = ((...args: A) => fn(...args)) as Action<A, R>;
wrapper.url = fn.url;
wrapper.toString = () => fn.url;
// key by resolved URL — form.action reads back absolute
if (!isServer)
actions.set(new URL(fn.url, window.location.href).href, formData =>
fn.length > 0 ? fn(...([formData] as unknown as A)) : (fn as () => Promise<R>)()
);
return wrapper;
}
49 changes: 49 additions & 0 deletions packages/solid-web/data/src/flight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Single-flight mutations, client half + shared contract.
*
* The mutation's response carries the post-mutation values for the queries the
* page is showing, so the UI updates without a follow-up read. The server half
* (`createQueryFlightCollector`) lives on the `/server` subpath — it pulls in
* `provideRequestEvent` (node:async_hooks), which doesn't belong in client
* bundles.
*/
// Bare specifier on purpose: the compiled server-function references import
// '@solidjs/web/server-functions', and bundlers give each specifier its own
// module instance — the '/client' subpath would register the consumer in a copy
// the transport never reads. It also keeps this module importable from an SSR
// graph: the server build exports `subscribeFlightData` too.
import { subscribeFlightData } from "@solidjs/web/server-functions";
import { isServer } from "@solidjs/web";
import { query, revalidate } from "./query.js";

export interface QueryFlightData {
href: string;
/** Post-mutation query results, resolved on the server: key -> value. */
queries: Record<string, unknown>;
}

/**
* Registers the flight-data consumer that applies single-flight query payloads.
* Call once on the client, before mutations can run (the client entry is the
* natural place). Subscribing IS the single-flight opt-in: the transport only
* sends the request-leg header, and the server only collects, while a consumer
* is registered — and only one can be active, so register it after any other
* integration that would claim the slot.
*/
export function installQueryFlightConsumer() {
if (isServer) return;

subscribeFlightData<QueryFlightData>(data => {
if (!data?.queries) return;
// The payload describes the location the mutation ran against; if the
// user navigated (or the mutation redirected) while it was in flight,
// seeding would write another page's data — refetch instead.
if (data.href !== window.location.href) return revalidate();

// `query.set` bumps each entry's version signal, so memos reading the
// query re-run with the fresh value — no client refetch.
for (const [key, value] of Object.entries(data.queries)) {
query.set(key, value as never);
}
});
}
6 changes: 6 additions & 0 deletions packages/solid-web/data/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { query, revalidate, cacheKeyOp, collectQueries, seedQueries, hashKey } from "./query.js";
export type { CachedFunction } from "./query.js";
export { action } from "./action.js";
export type { Action } from "./action.js";
export { installQueryFlightConsumer } from "./flight.js";
export type { QueryFlightData } from "./flight.js";
219 changes: 219 additions & 0 deletions packages/solid-web/data/src/query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/**
* `query()` — request-deduped async cache for Solid 2, a port of
* @solidjs/router's `query()` (next branch) with no router dependency.
*
* Deduping is by key (`name` + a stable hash of the arguments): calling the
* wrapped function in a route loader/preload and again in a component memo
* returns the SAME promise. Solid 2 memos unwrap promises, so
* `createMemo(() => hello())` suspends into the nearest `<Loading>` boundary —
* no extra machinery needed.
*
* On the server the cache lives per-request in `getRequestEvent().locals`
* (never module state — an SSR server shares module scope across concurrent
* requests). On the client it is module-level: an entry is fresh while it is
* actively observed (a tracking scope read it and hasn't been cleaned up) or
* within a short window after creation; stale or invalidated entries refetch
* on the next read.
*
* SSR -> hydration usually needs no wiring: the component memo that reads the
* query is serialized by Solid itself, so the client adopts the server's value
* without refetching. `collectQueries`/`seedQueries` exist for hosts that warm
* the cache outside the render tree and need an explicit channel — e.g.
* TanStack Router, where they wire into its `dehydrate`/`hydrate` options.
*
* Deviations from solid-router: server functions are declared GET through
* transport metadata (compiled references carry no `.GET` property), and
* routing intent/preload semantics plus Response/redirect handling are not
* ported (TODO).
*/
import { createSignal, getObserver, onCleanup } from "solid-js";
import { getRequestEvent, isServer } from "@solidjs/web";
// Bare specifier on purpose: the compiled server-function references import
// '@solidjs/web/server-functions', and bundlers give each specifier its own
// module instance — the '/client' subpath would attach metadata in a copy the
// transport never reads.
import { GET, getServerFunctionMetadata, isServerFunction } from "@solidjs/web/server-functions";

const PRELOAD_TIMEOUT = 5000;
const CACHE_TIMEOUT = 180000;
// [ts, promise, value, versionSignal] — solid-router's layout minus the
// intent slot. ts === 0 marks an invalidated entry.
type CacheEntry = [number, any, any, [() => number, (v: number) => void] & { count: number }];
let cacheMap = new Map<string, CacheEntry>();

// cleanup forward/back cache
if (!isServer) {
setInterval(() => {
const now = Date.now();
for (let [k, v] of cacheMap.entries()) {
if (!v[3].count && now - v[0] > CACHE_TIMEOUT) {
cacheMap.delete(k);
}
}
}, 300000);
}

function getCache() {
if (!isServer) return cacheMap;
const req = getRequestEvent();
if (!req) throw new Error("Cannot find cache context");
return ((req.locals.queryCache as Map<string, CacheEntry>) ||
(req.locals.queryCache = new Map())) as Map<string, CacheEntry>;
}

/**
* Revalidates the given cache entry/entries (prefix match; omit to
* revalidate everything).
*/
export function revalidate(key?: string | string[] | void, force = true) {
force && cacheKeyOp(key, entry => (entry[0] = 0));
cacheKeyOp(key, entry => entry[3][1](Date.now())); // retrigger live signals
}

export function cacheKeyOp(key: string | string[] | void, fn: (entry: CacheEntry) => void) {
key && !Array.isArray(key) && (key = [key]);
const cache = getCache();
for (let k of cache.keys()) {
if (key === undefined || matchKey(k, key as string[])) fn(cache.get(k)!);
}
}

export type CachedFunction<T extends (...args: any) => any> = T & {
keyFor: (...args: Parameters<T>) => string;
key: string;
};

function createEntry(ts: number, res: any): CacheEntry {
const entry: CacheEntry = [ts, res, undefined, createSignal(ts) as any];
entry[3].count = 0;
res && typeof res.then === "function"
? res.then(
(v: any) => entry[1] === res && (entry[2] = v),
() => {}
)
: (entry[2] = res);
return entry;
}

export function query<T extends (...args: any) => any>(fn: T, name: string): CachedFunction<T> {
// a query is a read: declare server functions GET (keeps them cacheable
// and off the single-flight path, which only opts in non-GET calls)
if (isServerFunction(fn) && getServerFunctionMetadata(fn)?.method !== "GET")
fn = GET(fn) as unknown as T;
const cachedFn = ((...args: Parameters<T>) => {
const cache = getCache();
const now = Date.now();
const key = name + hashKey(args);
let cached = cache.get(key);
let tracking;
if (getObserver() && !isServer) {
tracking = true;
onCleanup(() => cached![3].count--);
}

if (cached && cached[0] && (isServer || cached[3].count || now - cached[0] < PRELOAD_TIMEOUT)) {
if (tracking) {
cached[3].count++;
cached[3][0](); // track
}
return cached[1];
}

const res = fn(...(args as any));
if (cached) {
cached[0] = now;
cached[1] = res;
res && typeof res.then === "function"
? res.then(
(v: any) => cached![1] === res && (cached![2] = v),
() => {}
)
: (cached[2] = res);
} else {
cache.set(key, (cached = createEntry(now, res)));
}
if (tracking) {
cached[3].count++;
cached[3][0](); // track
}
return res;
}) as unknown as CachedFunction<T>;
cachedFn.keyFor = (...args: Parameters<T>) => name + hashKey(args);
cachedFn.key = name;
return cachedFn;
}

query.get = (key: string) => getCache().get(key)?.[2];

query.set = <T>(key: string, value: T extends Promise<any> ? never : T) => {
const cache = getCache();
const now = Date.now();
let cached = cache.get(key);
if (cached) {
cached[0] = now;
cached[1] = Promise.resolve(value);
cached[2] = value;
cached[3][1](now); // notify observers
} else {
cache.set(key, (cached = createEntry(now, Promise.resolve(value))));
cached[2] = value;
}
};

query.delete = (key: string) => getCache().delete(key);

query.clear = () => getCache().clear();

/**
* Server-only: snapshot the per-request cache's promises, un-awaited. For
* hosts that warm the cache outside the render tree and need an explicit
* SSR -> client channel — e.g. TanStack Router's
* `dehydrate: () => ({ queries: collectQueries() })`, whose serializer streams
* promise resolutions. Not needed when queries are only read inside components:
* Solid serializes the reading memo itself.
*/
export function collectQueries(): Record<string, Promise<any>> {
const queries: Record<string, Promise<any>> = {};
if (!isServer) return queries;
for (const [k, entry] of getCache()) queries[k] = entry[1];
return queries;
}

/** Client-only counterpart to `collectQueries` — install before hydration renders. */
export function seedQueries(queries?: Record<string, unknown>) {
if (isServer || !queries) return;
for (const [key, res] of Object.entries(queries)) {
if (!cacheMap.has(key)) cacheMap.set(key, createEntry(Date.now(), res));
}
}

function matchKey(key: string, keys: string[]) {
for (let k of keys) {
if (k && key.startsWith(k)) return true;
}
return false;
}

// Modified from the amazing TanStack Query library (MIT)
// https://github.com/TanStack/query/blob/main/packages/query-core/src/utils.ts#L168
export function hashKey<T extends Array<any>>(args: T): string {
return JSON.stringify(args, (_, val) =>
isPlainObject(val)
? Object.keys(val)
.sort()
.reduce((result, key) => {
result[key] = val[key];
return result;
}, {} as any)
: val
);
}

function isPlainObject(obj: object) {
let proto;
return (
obj != null &&
typeof obj === "object" &&
(!(proto = Object.getPrototypeOf(obj)) || proto === Object.prototype)
);
}
Loading
Loading