Skip to content

feat(web): @solidjs/web/data — router-agnostic query()/action() and a single-flight query channel - #2960

Closed
brenelz wants to merge 1 commit into
solidjs:nextfrom
brenelz:feat/web-data-subpath
Closed

feat(web): @solidjs/web/data — router-agnostic query()/action() and a single-flight query channel#2960
brenelz wants to merge 1 commit into
solidjs:nextfrom
brenelz:feat/web-data-subpath

Conversation

@brenelz

@brenelz brenelz commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

What

A new @solidjs/web/data subpath: router-agnostic query() and action() (ported from @solidjs/router's next branch), plus a single-flight channel that ships post-mutation query values on the mutation response.

The transport already carries most of an actions/data story — stable .urls on server-function references (documented for form actions), no-JS form-post handling, and the single-flight wire protocol (SINGLE_FLIGHT_HEADER, collectFlightData, subscribeFlightData). This fills in the data layer that rides those rails, with the same split the flight protocol itself uses: the package standardizes the mechanism, the router/host decides what data loading means.

API

@solidjs/web/data (isomorphic)

  • query(fn, name) — request-deduped async cache. The 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>, so there's no resource machinery. Cache is per-request in event.locals on the server, module-level on the client with solid-router's freshness semantics (fresh while observed, short window otherwise, GC'd). Server functions are auto-declared GET via transport metadata — reads stay cacheable and off the single-flight path. Plus revalidate (prefix match), query.get/set/delete/clear, keyFor/key.
  • action(fn) — form-bindable mutations: toString() renders the reference's real .url into <form action={...}> (no-JS posts hit the endpoint natively); a single document-level submit listener intercepts matching forms (including formaction overrides) and calls the RPC stub.
  • installQueryFlightConsumer() — registers the flight-data consumer that applies single-flight payloads via query.set (version-signal bump → reading memos re-run, no refetch), guarded against stale locations (navigated/redirected mid-flight → revalidate()).
  • collectQueries() / seedQueries() — explicit SSR seeding channel for hosts that warm the cache outside the render tree (e.g. TanStack Router's dehydrate/hydrate; its serializer streams the un-awaited promises). Not needed when queries are only read inside components — Solid serializes the reading memo itself.

@solidjs/web/data/server

  • createQueryFlightCollector(warmQueries) — produces the collectFlightData hook: re-establishes the request-event scope, derives the client's post-mutation location (redirect Location, else Referer), calls the host's warmQueries(href, outcome), then resolves this request's query cache into the payload. Separate subpath because it pulls in @solidjs/web/storage (node:async_hooks).

Provenance & testing

Extracted from two app experiments where these exact files ran byte-identical across hosts — TanStack Solid Router on Cloudflare workerd, and @solidjs/router 2.0.0-next on nitro/node — with only warmQueries differing (~6 lines each: TanStack builds a router at the location and load()s it; @solidjs/router delegates to its createFlightDataCollector, whose flight event shares locals with the request).

Verified in both apps against this build (linked into their node_modules):

  • dedupe: one server-function invocation per page render (loader + render-time memo share the promise)
  • form interception: submit → single POST, no navigation
  • single-flight: post-mutation values ride the mutation response; DOM updates with no follow-up read
  • pnpm build (tsc + vite) green in both; nitro prod verified end-to-end on the built server

In-repo: data/test/query.spec.ts covers dedupe by key, argument hashing, revalidate retriggering tracking scopes, and query.set seeding without a refetch. Full solid-web default suite: 334 passed / 0 failed.

Not yet ported from solid-router

  • submission state (useSubmission/useSubmissions) and .with() argument binding
  • redirect/Response handling in query (cookie forwarding, Location following)
  • preload intent semantics (preload/navigate/native)

Naming, placement (/data vs folding into /server-functions), and whether the freshness constants should be options — all judgment calls I'm happy to revisit.

🤖 Generated with Claude Code

…nd a single-flight query channel

Ports @solidjs/router's query()/action() (next branch) onto the
server-function transport with no router dependency, plus the single-flight
channel that ships post-mutation query values on the mutation response.

- query(fn, name): request-deduped async cache. The 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>. Cache is
  per-request in event.locals on the server, module-level with
  observed/timed freshness on the client. Server functions are auto-declared
  GET via transport metadata (keeps reads cacheable and off the single-flight
  path). revalidate, query.get/set/delete/clear, and collectQueries/
  seedQueries (explicit SSR seeding for hosts that warm the cache outside
  the render tree) included.
- action(fn): form-bindable mutations. toString() renders the server
  function's stable .url into <form action={...}> — no-JS posts work
  natively; a document-level submit listener intercepts matching forms and
  calls the RPC stub.
- flight: installQueryFlightConsumer() (client) applies post-mutation query
  values via query.set, guarded against stale locations;
  createQueryFlightCollector(warmQueries) on /server produces the
  collectFlightData hook — the host supplies only warmQueries(href, outcome).
  The /server split keeps @solidjs/web/storage (node:async_hooks) out of
  client bundles.

Extracted from two app experiments where these files ran byte-identical on
TanStack Solid Router (workerd) and @solidjs/router (nitro/node) hosts; only
warmQueries differed. Verified in both apps against this build: dedupe (one
server call per page render), form interception, and single-flight (one POST,
no follow-up read, DOM updates from the response payload).

Not yet ported from solid-router: submission state (useSubmission),
redirect/Response handling, preload intent semantics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fb6f046

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/web Major
@solidjs/element Major
@solidjs/h Major
@solidjs/html Major
test-integration Patch
hackernews-spa-example Patch
hackernews-example Patch
solid-js Major
babel-preset-solid Major
@solidjs/universal Major
@solidjs/signals Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codspeed-hq

codspeed-hq Bot commented Jul 30, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 7 untouched benchmarks
⏩ 117 skipped benchmarks1


Comparing brenelz:feat/web-data-subpath (fb6f046) with next (24018d1)

Open in CodSpeed

Footnotes

  1. 117 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@ryansolid

Copy link
Copy Markdown
Member

I think this is interesting and it is super tempting from a turn-key point of view but I think it is too strong of a position to take core. We should consider if this is main library worthy but core keeps it clean that our contract is around communication protocol, not application logic. I think caching falls into application logic as it needs to make a decision. The benefit of being able to streamline the protocol is definitely there but we need to think carefully where this belongs. I'm just pretty confident it is not here.

@ryansolid ryansolid closed this Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants