Last updated:
@lunora/solid is the SolidJS adapter, an idiomatic layer over
@lunora/client. Solid's fine-grained signals map
directly onto Lunora's per-subscription deltas, so a live query is a signal
the socket writes to: only the components that read the accessor re-render.
This page is the task-oriented guide; for the full reference see
@lunora/solid.
Preview. The Solid adapter exposes the same hydrate-then-subscribe handoff as React, but it is preview maturity: not yet proven end-to-end against a running app. Solid lands first after React because its signals map most directly onto Lunora deltas. See Bring your framework.
Install
pnpm add @lunora/solidSolid 1.x and Solid 2.0
@lunora/solid peers on solid-js@^1.9.0 || ^2.0.0-rc.0 and ships one build
for both majors — the adapter's public surface (LunoraProvider,
createQuery, createMutation, hydratePreloaded, …) is identical on either.
Where the two Solids diverge internally, the adapter picks the right primitive
at runtime, so nothing about your usage changes.
What does change is the code around it. The snippets on this page are Solid 1.x; on Solid 2.0 the imports and a few APIs move:
| Solid 1.x | Solid 2.0 |
|---|---|
solid-js/web | @solidjs/web (also jsxImportSource) |
solid-js/store | solid-js — stores moved into core |
onMount | onSettled, which returns its own cleanup |
createEffect(fn) / on() | createEffect(compute, apply) — two arguments |
<Suspense> / <Index> | <Loading> / <For keyed={false}> |
<ErrorBoundary> | <Errored> |
mergeProps / splitProps | merge / omit |
classList={{ … }} | class={{ … }} |
<Ctx.Provider value={…}> | <Ctx value={…}> — the context is the provider |
Two behaviour changes have no syntactic tell, so they surprise people: signal
reads do not update until the microtask flushes (call flush() when a test
asserts right after a write), and writing a signal from a component body throws
in dev. Built-in DOM attributes are also lowercase now (novalidate,
autocomplete, tabindex), though event handlers stay camelCase.
lunora init app -t solid-v2 scaffolds a project already wired for 2.0 (the
@solidjs/web renderer, jsxImportSource, and vite-plugin-solid 3.x).
lunora init app --vite solid stays on the stable Solid 1.x line.
lunora add auth-ui splits the same way. The copy-in auth screens are source
you own, so the two majors cannot share one payload: auth-ui-solid is the 1.x
port and auth-ui-solid-v2 the 2.0 one, and the CLI picks from your installed
solid-js. The framework-agnostic controllers under lunora/auth-ui/core are
identical in both.
Provide the client
Wrap the tree in <LunoraProvider client={...}> (required at the root). Read it
anywhere below with useLunora().
// Solid 2.0: `import { render } from "@solidjs/web"`.
import { LunoraClient } from "lunorash/client";
import { LunoraProvider } from "@lunora/solid";
import { render } from "solid-js/web";
import App from "./App";
const client = new LunoraClient({ url: window.location.origin });
render(
() => (
<LunoraProvider client={client}>
<App />
</LunoraProvider>
),
document.getElementById("root")!,
);Live queries
createQuery(fn, args) subscribes and returns a reactive accessor
(undefined until the first frame, then updated on every delta). Pass args as
a plain value or an accessor; an accessor re-subscribes when it changes. Pass
"skip" to short-circuit.
import { createQuery } from "@lunora/solid";
import { For, createSignal } from "solid-js";
import { api } from "./lunora/_generated/api";
function Messages() {
const [channelId, setChannelId] = createSignal("general");
// Accessor args: changing `channelId()` re-subscribes.
const messages = createQuery(api.messages.list, () => ({ channelId: channelId() }));
return <For each={messages() ?? []}>{(m) => <li>{m.text}</li>}</For>;
}Pass { shardKey } in options to target a specific shard when the function is
.shardBy(...)-partitioned. The subscription tears down when the owning reactive
scope is disposed.
Mutations
createMutation(fn) returns a MutationHandle of accessors:
{ data, error, pending, mutate, reset }. pending is ref-counted, and the
mutation engages the client's offline queue when the socket is down. You pass
optimistic updates per call.
import { createMutation } from "@lunora/solid";
import { api } from "./lunora/_generated/api";
function Composer(props: { channelId: string }) {
const send = createMutation(api.messages.send);
return (
<button
disabled={send.pending()}
onClick={() =>
void send.mutate(
{ channelId: props.channelId, text: "hi" },
{
optimisticUpdate: (store) => {
const current = store.getQuery(api.messages.list, { channelId: props.channelId }) ?? [];
store.setQuery(api.messages.list, { channelId: props.channelId }, [...current, { _id: "tmp", text: "hi" }]);
},
},
)
}
>
Send
</button>
);
}Reactive loaders
Run the query on the server with the socket-free @lunora/solid/server entry
inside a SolidStart route loader, hand the serializable Preloaded token to the
client, and hydratePreloaded seeds the accessor synchronously. The first
read returns the server value with no loading flash and no Suspense fallback,
then the live subscription attaches after mount.
import { createServerClient, preloadQuery } from "@lunora/solid/server";
import { api } from "./lunora/_generated/api";
// Per request — never reuse a client across requests (token leakage).
const client = createServerClient({ url: process.env.LUNORA_URL!, token });
const preloaded = await preloadQuery(client, api.posts.list, {});import { hydratePreloaded } from "@lunora/solid";
import type { Preloaded } from "@lunora/solid";
import { For } from "solid-js";
function Posts(props: { preloaded: Preloaded<Array<{ _id: string; title: string }>> }) {
// Seeded from SSR on the first read, then live.
const posts = hydratePreloaded(props.preloaded);
return <For each={posts()}>{(p) => <li>{p.title}</li>}</For>;
}Effects do not run during SSR, so the subscription is strictly client-side: the seed is the only value the server render sees. See Reactive loaders for the full handoff.