lit-relay
v0.1.0
Published
Relay bindings for Web Components via Lit's ReactiveController pattern
Readme
lit-relay
Relay bindings for Web Components via Lit's ReactiveController pattern. Each controller maps a single Relay API (useFragment, useLazyLoadQuery, useMutation, etc.) onto Lit's component lifecycle — subscribe on connect, clean up on disconnect, request re-render on store change.
Installation
bun add lit-relay relay-runtime lit @lit/contextPeer dependencies —
lit ≥ 3.0,@lit/context ≥ 1.0,relay-runtime ≥ 16.0.You still need
relay-compilerin your build pipeline to generate query artifacts.
Quick Start
1. Provide the Relay Environment
Wrap your app in <relay-environment>. This uses the W3C Context Protocol (@lit/context) to propagate the environment to all descendant controllers.
import { Environment, Network, RecordSource, Store } from "relay-runtime";
import "lit-relay"; // registers <relay-environment>
const environment = new Environment({
network: Network.create(fetchGraphQL),
store: new Store(new RecordSource()),
});
const root = document.querySelector("relay-environment")!;
root.environment = environment;<relay-environment .environment=${environment}>
<app-shell></app-shell>
</relay-environment>2. Use a Query Controller
import { LitElement, html } from "lit";
import { RelayQueryController } from "lit-relay";
import type { UserQuery } from "./__generated__/UserQuery.graphql";
import UserQueryNode from "./__generated__/UserQuery.graphql";
class UserProfile extends LitElement {
private _query = new RelayQueryController<UserQuery>(
this,
UserQueryNode,
() => ({ id: this.userId }),
);
userId = "";
render() {
const { data, error, isLoading } = this._query;
if (isLoading) return html`<p>Loading…</p>`;
if (error) return html`<p>Error: ${error.message}</p>`;
return html`<h1>${data?.user?.name}</h1>`;
}
}Every controller follows the same pattern: construct in the class body (or constructor), read its public state properties in render(). The controller handles environment lookup, store subscription, fetch lifecycle, and cleanup automatically.
3. Preload Outside a Router
For render-as-you-fetch flows that start before a component connects, use createQueryLoader(). It manages a single retained preloaded query reference and automatically disposes the previous one when a new load replaces it.
import { createQueryLoader } from "lit-relay";
import type { UserQuery } from "./__generated__/UserQuery.graphql";
const userQueryLoader = createQueryLoader<UserQuery>(environment, UserQueryNode);
// Start fetching before the target component connects
userQueryLoader.load({ id: "1" });
// Later, replace the active preload with new variables
userQueryLoader.load({ id: "2" });
// When done with the preload lifecycle
userQueryLoader.dispose();This is the non-router ergonomic companion to loadQuery(): use loadQuery() when you want the raw disposable reference directly, and createQueryLoader() when you want one small object to manage replacement and disposal for you.
The stable public entrypoints are also compile-checked through consumer fixtures during package validation, so changes to the main, SSR, testing, and query-loader surfaces must remain type-safe for external callers.
Migrating from react-relay
| react-relay | lit-relay | Notes |
|---|---|---|
| useLazyLoadQuery | RelayQueryController | Constructor accepts static vars or reactive closure |
| useFragment | RelayFragmentController | Same fragment key pattern |
| useRefetchableFragment | RelayRefetchableFragmentController | refetch() method instead of hook return |
| usePaginationFragment | RelayPaginationFragmentController | loadNext(n) / loadPrevious(n) methods |
| useMutation | RelayMutationController | commit() returns Promise |
| useSubscription | RelaySubscriptionController | Client-only, skipped during SSR |
| useSubscribeToInvalidationState | RelayInvalidationController | reset() method |
| <RelayEnvironmentProvider> | <relay-environment> | Same context pattern, W3C Context Protocol |
| Suspense boundary | RelayStateMixin + CSS :state() | Per-component, not cross-component |
| fetchQuery / loadQuery | Same -- re-exported from lit-relay | Direct re-exports |
The main difference is lifecycle: React hooks re-run on render; Lit controllers use hostConnected / hostUpdate / hostDisconnected. Variables passed as closures (() => ({ id: this.userId })) enable reactive change detection.
Published Entrypoints
Use only the documented package entrypoints below in application code:
| Entrypoint | Purpose | Example |
|---|---|---|
| lit-relay | Main runtime surface: controllers, provider, mixins, query helpers, stable utilities | import { RelayQueryController } from "lit-relay"; |
| lit-relay/testing | Test-only helpers for real Relay environments with mock networks | import { createTestEnvironment } from "lit-relay/testing"; |
| lit-relay/ssr | SSR types, hydration, and streaming SSR utilities | import { hydrateRelayStore, createStreamingStoreHydrator } from "lit-relay/ssr"; |
| lit-relay/ssr/server | Server-side SSR installation and store serialization | import { installRelaySSR } from "lit-relay/ssr/server"; |
| lit-relay/ssr/hydration | Narrow hydration-only client entrypoint | import hydrateRelayStore from "lit-relay/ssr/hydration"; |
Everything else should be treated as internal implementation detail.
Release-Readiness Notes
- the published entrypoints above are compile-checked through consumer-contract fixtures during package validation
- public controller behavior is covered by integration tests and direct seam tests
- SSR, hydration, testing, and query-loader surfaces are all validated as supported consumer imports
- deep imports into
src/or undocumenteddist/paths are not part of the supported contract
Controllers Reference
| Controller | Relay equivalent | Key state |
|---|---|---|
| RelayQueryController | useLazyLoadQuery | data, error, isLoading |
| RelayFragmentController | useFragment | data |
| RelayRefetchableFragmentController | useRefetchableFragment | data, isLoading, error |
| RelayPaginationFragmentController | usePaginationFragment | data, hasNext, hasPrevious, isLoadingNext, isLoadingPrevious, error |
| RelayMutationController | useMutation | isInFlight, error |
| RelaySubscriptionController | useSubscription | latestPayload, error, isActive |
| RelayInvalidationController | useSubscribeToInvalidationState | isInvalidated |
RelayQueryController
Fetches a query, reads its data from the store, subscribes to changes, and retains against garbage collection.
import { RelayQueryController } from "lit-relay";
import type { UserQuery } from "./__generated__/UserQuery.graphql";
class MyEl extends LitElement {
query = new RelayQueryController<UserQuery>(
this,
UserQueryNode,
() => ({ id: "1" }),
{ fetchPolicy: "store-or-network" }, // optional
);
}Constructor:
new RelayQueryController<TQuery>(host, query, variablesFn, options?)| Param | Type | Description |
|---|---|---|
| host | ReactiveControllerHost & HTMLElement | The Lit element |
| query | GraphQLTaggedNode | Relay compiler artifact |
| variablesFn | () => TQuery["variables"] | Called each update cycle; shallow-compared to detect changes |
| options.fetchPolicy | "store-or-network" | "store-and-network" | "network-only" | "store-only" | Default: "store-or-network" |
State:
| Property | Type | Description |
|---|---|---|
| data | TQuery["response"] \| undefined | Resolved query data |
| error | Error \| undefined | Network or store error |
| isLoading | boolean | true until first data or error |
Methods:
| Method | Description |
|---|---|
| refetch(vars?) | Clean up and re-execute with merged variables |
Variable change detection: The controller calls variablesFn() on every Lit update cycle and shallow-compares with the previous value. If variables changed, it disposes old subscriptions and re-executes.
RelayFragmentController
Reads a slice of data already fetched by an ancestor's query. Does not fetch — this is Relay's primary composition primitive.
import { RelayFragmentController } from "lit-relay";
import type { UserAvatar_user$key } from "./__generated__/UserAvatar_user.graphql";
class UserAvatar extends LitElement {
userRef?: UserAvatar_user$key;
private _fragment = new RelayFragmentController(
this,
UserAvatarFragment,
() => this.userRef,
);
render() {
const user = this._fragment.data;
return html`<img src=${user?.avatarUrl} />`;
}
}Constructor:
new RelayFragmentController<TKey>(host, fragment, keyFn)| Param | Type | Description |
|---|---|---|
| host | ReactiveControllerHost & HTMLElement | The Lit element |
| fragment | GraphQLTaggedNode | Fragment artifact |
| keyFn | () => TKey \| null \| undefined | Returns the fragment ref; null/undefined yields data = undefined |
State:
| Property | Type |
|---|---|
| data | TKey[" $data"] \| undefined |
Supports both singular and plural fragment refs (arrays of keys).
RelayRefetchableFragmentController
Reads fragment data from the store and supports refetching via the @refetchable generated query.
import { RelayRefetchableFragmentController } from "lit-relay";
class SearchResults extends LitElement {
queryRef?: SearchResults_query$key;
private _fragment = new RelayRefetchableFragmentController(
this,
SearchResultsFragment,
() => this.queryRef,
);
handleSearch(term: string) {
this._fragment.refetch({ searchTerm: term });
}
render() {
const { data, isLoading } = this._fragment;
if (isLoading) return html`<p>Searching…</p>`;
return html`${data?.results?.map(r => html`<p>${r.title}</p>`)}`;
}
}Constructor:
new RelayRefetchableFragmentController<TQuery, TKey>(host, fragment, keyFn)State: data, isLoading, error
Methods: refetch(newVars) — fetches the @refetchable query with merged variables.
RelayPaginationFragmentController
Cursor-based pagination for fragments with @connection. Internally reads fragment data and fetches pages using the generated pagination query.
import { RelayPaginationFragmentController } from "lit-relay";
class FriendsList extends LitElement {
userRef?: FriendsList_user$key;
private _pagination = new RelayPaginationFragmentController(
this,
FriendsListFragment,
() => this.userRef,
);
render() {
const { data, hasNext, isLoadingNext } = this._pagination;
return html`
<ul>
${data?.friends?.edges?.map(e =>
html`<li>${e.node.name}</li>`
)}
</ul>
${hasNext
? html`<button
?disabled=${isLoadingNext}
@click=${() => this._pagination.loadNext(10)}
>Load more</button>`
: null}
`;
}
}Constructor:
new RelayPaginationFragmentController<TQuery, TKey>(host, fragment, keyFn)State:
| Property | Type | Description |
|---|---|---|
| data | TKey[" $data"] \| undefined | Current page data |
| hasNext | boolean | Derived from pageInfo.hasNextPage |
| hasPrevious | boolean | Derived from pageInfo.hasPreviousPage |
| isLoadingNext | boolean | true while loadNext() is in-flight |
| isLoadingPrevious | boolean | true while loadPrevious() is in-flight |
| error | Error \| undefined | Pagination fetch error |
Methods:
| Method | Returns | Description |
|---|---|---|
| loadNext(count) | Disposable | Fetch next page; no-op if !hasNext |
| loadPrevious(count) | Disposable | Fetch previous page; no-op if !hasPrevious |
| refetch(newVars) | void | Reset pagination with new variables |
RelayMutationController
Wraps commitMutation with imperative lifecycle and isInFlight state.
import { RelayMutationController } from "lit-relay";
import type { UpdateUserMutation } from "./__generated__/UpdateUserMutation.graphql";
class UserEditor extends LitElement {
private _mutation = new RelayMutationController<UpdateUserMutation>(
this,
UpdateUserMutationNode,
{
optimisticResponse: { updateUser: { id: "1", name: "Optimistic" } },
},
);
async save() {
try {
const result = await this._mutation.commit(
{ input: { userId: "1", name: "New Name" } },
);
console.log("Saved:", result);
} catch (err) {
console.error("Failed:", this._mutation.error);
}
}
}Constructor:
new RelayMutationController<TMutation>(host, mutation, config?)| Config option | Type | Description |
|---|---|---|
| optimisticResponse | TMutation["rawResponse"] | Optimistic store update |
| optimisticUpdater | SelectorStoreUpdater | Updater for optimistic phase |
| updater | SelectorStoreUpdater | Updater for server response |
| configs | DeclarativeMutationConfig[] | Declarative mutation configs |
State: isInFlight, error
Methods: commit(variables, overrides?) — returns Promise<TMutation["response"]>.
RelaySubscriptionController
Long-lived subscription tied to the connected lifecycle. Opens on connect, closes on disconnect. Client-only — skipped during SSR.
import { RelaySubscriptionController } from "lit-relay";
import type { NotificationSub } from "./__generated__/NotificationSub.graphql";
class Notifications extends LitElement {
private _sub = new RelaySubscriptionController<NotificationSub>(
this,
NotificationSubNode,
() => ({ userId: this.userId }),
{ updater: (store) => { /* update store */ } },
);
render() {
const { latestPayload, isActive, error } = this._sub;
if (error) return html`<p>Subscription error: ${error.message}</p>`;
return html`
<span class="dot ${isActive ? 'connected' : 'disconnected'}"></span>
<p>${latestPayload?.notification?.message ?? "Waiting…"}</p>
`;
}
}State: latestPayload, error, isActive
RelayInvalidationController
Subscribes to store invalidation for a set of DataIDs. Exposes an isInvalidated flag and an optional callback.
import { RelayInvalidationController } from "lit-relay";
class UserCard extends LitElement {
userId = "";
private _invalidation = new RelayInvalidationController(
this,
() => [this.userId],
{
onInvalidated: () => {
// Trigger a refetch on a sibling controller
this._query.refetch();
},
},
);
}State: isInvalidated
Methods: reset() — clears the flag, re-captures invalidation state, and re-subscribes.
Environment
<relay-environment>
Provider element that makes a Relay Environment available to all descendant controllers via the W3C Context Protocol.
<relay-environment .environment=${myEnv}>
<app-shell></app-shell>
</relay-environment>Renders in light DOM so context-request events propagate without shadow boundary complications. The environment property is set via JavaScript (not an HTML attribute).
relayEnvironmentContext
The @lit/context context key. Useful if you need to build custom providers or consumers:
import { relayEnvironmentContext } from "lit-relay";
import { ContextConsumer } from "@lit/context";
const consumer = new ContextConsumer(this, {
context: relayEnvironmentContext,
subscribe: true,
});RelayStateMixin
Optional mixin that exposes data-fetching state as CSS pseudo-classes via ElementInternals.states (the CustomStateSet API).
import { RelayStateMixin, RelayQueryController } from "lit-relay";
class UserProfile extends RelayStateMixin(LitElement) {
private _query = new RelayQueryController(this, UserQueryNode, () => ({}));
}/* Style based on aggregate data-fetching state */
user-profile:state(loading) { opacity: 0.5; }
user-profile:state(error) { border-left: 4px solid red; }
user-profile:state(mutating) { pointer-events: none; }
user-profile:state(ready) { opacity: 1; }States
| CSS pseudo-class | Condition |
|---|---|
| :state(loading) | Any registered controller has isLoading, isLoadingNext, or isLoadingPrevious |
| :state(mutating) | Any registered controller has isInFlight |
| :state(error) | Any registered controller has error |
| :state(ready) | None of the above |
Priority: error > mutating > loading > ready. Only one state is active at a time.
RelayStateMixin intentionally does not expose extra CSS states for active subscriptions, invalidation, or pagination direction. Those signals remain available through controller properties and internal typed projection, but the public CSS surface stays limited to the four host-level shell states above.
When to use
The mixin is convenience, not correctness — a CSS styling surface for host-level shell states (dimming while loading, error chrome, busy indicators). Use it for:
- Shell loading states (dim the component while any query is in-flight)
- Mutation feedback (disable interactions during save)
- Error chrome (red border on any error)
- Design-system wrappers that need a uniform loading treatment
For fine-grained per-controller state, read controller properties directly in render().
Registration
Controllers auto-register with RelayStateMixin hosts via a symbol-keyed handshake (RELAY_REGISTER). No manual wiring needed — constructing a controller on a mixin host is sufficient.
Advanced Patterns
Client-Only Queries
Use fetchPolicy: 'store-only' for queries backed entirely by Relay Resolvers or client schema extensions. This is the equivalent of react-relay's useClientQuery.
const controller = new RelayQueryController(
this,
ClientOnlyQueryNode,
() => ({}),
{ fetchPolicy: "store-only" },
);No network request is made. The controller reads directly from the store and subscribes to updates.
Local Store Updates
Use commitLocalUpdate for writing client-local state or initializing client schema extension fields:
import { commitLocalUpdate } from "lit-relay";
commitLocalUpdate(environment, (store) => {
const record = store.get("user:1");
record?.setValue(true, "isSelected");
});Manual Query Preloading
Use loadQuery for non-router render-as-you-fetch patterns — tab panels, modals, intersection observers:
import { loadQuery } from "lit-relay";
import type { UserQuery } from "./__generated__/UserQuery.graphql";
// Start fetching before the component renders
const preloadedRef = loadQuery<UserQuery>(environment, UserQueryNode, { id: "1" });
// Later, the controller reads from the already-populated store
class UserPanel extends LitElement {
query = new RelayQueryController<UserQuery>(
this,
UserQueryNode,
() => ({ id: "1" }),
{ fetchPolicy: "store-or-network" }, // hits cache from preload
);
}
// Don't forget to dispose when no longer needed
preloadedRef.dispose();Imperative Data Access
Use fetchQuery for one-shot imperative data access outside of components:
import { fetchQuery } from "lit-relay";
const data = await fetchQuery(environment, UserQueryNode, { id: "1" }).toPromise();Inline Data Reads
Use readInlineData for synchronous fragment reads in event handlers or utility functions:
import { readInlineData } from "lit-relay";
function getUserName(userRef: UserInline_user$key): string {
const data = readInlineData(UserInlineFragment, userRef);
return data.name;
}Fragment Observation Outside Controllers
From relay-runtime/experimental — use when you need fragment data outside the normal controller lifecycle:
import { waitForFragmentData, observeFragment } from "lit-relay";
// One-shot Promise-based read
const data = await waitForFragmentData(environment, UserFragment, userRef);
// Ongoing stream-based observation
const observable = observeFragment(UserFragment, userRef);Note: These are re-exported from
relay-runtime/experimentaland are subject to Relay's experimental API stability policy.
SSR
lit-relay supports server-side rendering via @lit-labs/ssr. The SSR utilities are exported from dedicated lit-relay/ssr* subpaths so the server-facing contract stays explicit.
String (batch) SSR
The default SSR model renders the full page in one pass and serializes the entire store as a single blob.
Server:
import { installRelaySSR, serializeRelayStore } from "lit-relay/ssr/server";
// Before calling Lit SSR render()
installRelaySSR(environment);
// After rendering, serialize the store for client hydration
const storeJson = serializeRelayStore(environment);installRelaySSR:
- Sets
globalThis.litSsrCallConnectedCallback = trueso Lit triggers controller lifecycle during server rendering - Mounts a
ContextProvideronglobalThis.litServerRootso context-consuming elements receive the Environment
serializeRelayStore:
- Serializes the store's
RecordSourceto JSON - Embed the result in the HTML response:
<script type="application/relay-store">${storeJson}</script>Client:
import { hydrateRelayStore } from "lit-relay/ssr/hydration";
const environment = hydrateRelayStore(fetchGraphQL);
// Pass to <relay-environment .environment=${environment}>hydrateRelayStore looks for a <script type="application/relay-store"> element, parses the JSON, validates it as a Relay record map (each entry must have __id and __typename fields), creates a fully populated Environment, and removes the script tag. Throws TypeError if the snapshot is malformed.
SSR Types
import type {
SSRRouteConfig,
InstallRelaySSROptions,
StreamingStoreCollector,
StreamingStoreHydrator,
StreamingSSRPipelineOptions,
} from "lit-relay/ssr";Authenticated SSR with HTTP-only cookies
When the GraphQL API uses HTTP-only cookies for authentication, the server must forward cookies from the incoming request to the GraphQL endpoint. Build a per-request Environment using relay-runtime-network middleware:
import { Environment, Network, RecordSource, Store } from "relay-runtime";
import {
authMiddleware,
createFetchPipeline,
headersMiddleware,
httpExecutor,
urlMiddleware,
} from "relay-runtime-network";
import { installRelaySSR, serializeRelayStore } from "lit-relay/ssr/server";
function createSSREnvironment(request: Request): Environment {
const fetchGraphQL = createFetchPipeline({
executor: httpExecutor(),
middlewares: [
urlMiddleware({ url: new URL("/graphql", request.url).toString() }),
headersMiddleware({
headers: {
cookie: request.headers.get("cookie") ?? "",
},
}),
authMiddleware({
strategy: "cookie-session-only",
credentials: "include",
}),
],
});
return new Environment({
network: Network.create(fetchGraphQL),
store: new Store(new RecordSource()),
});
}
// In your request handler:
const environment = createSSREnvironment(request);
installRelaySSR(environment);
// ... render, serialize, respondThe authMiddleware with strategy: "cookie-session-only" skips the Authorization header and relies on the forwarded cookies. If the token expires mid-request, the middleware's reactive refresh (Phase 4) catches 401/403 responses automatically.
On the client, set credentials: "include" so the browser includes HTTP-only cookies in all GraphQL requests:
const fetchGraphQL = createFetchPipeline({
executor: httpExecutor(),
middlewares: [
urlMiddleware({ url: "/graphql", credentials: "include" }),
authMiddleware({
strategy: "cookie-session-only",
credentials: "include",
}),
],
});No session hint serialization is needed between server and client. The browser manages HTTP-only cookie lifecycle automatically. If the client's access token expires, the authMiddleware detects the 401 and triggers a reactive refresh — one extra round-trip on the first expired request.
For proactive refresh (avoiding that single extra round-trip), add a sessionHintStore on the client. The backend's refresh endpoint should return timing metadata (expiresIn) so the client knows when to refresh:
import { createMemorySessionHintStore } from "relay-runtime-network";
authMiddleware({
strategy: "access-token-with-refresh-cookie",
credentials: "include",
sessionHintStore: createMemorySessionHintStore(),
refresh: {
execute: async () => {
const res = await fetch("/auth/refresh", {
method: "POST",
credentials: "include",
});
const { accessToken, expiresIn } = await res.json();
return {
accessToken,
sessionHint: {
issuedAt: Date.now(),
expiresAt: Date.now() + expiresIn * 1000,
refreshAt: Date.now() + (expiresIn - 60) * 1000,
},
};
},
},
});See relay-runtime-network README for the full SSR request-scoped recipe.
Streaming SSR (experimental)
Streaming SSR delivers store records incrementally as queries resolve, rather than waiting for all data before sending HTML.
Server — flush store chunks as data arrives:
import { installRelaySSR, serializeRelayStore } from "lit-relay/ssr/server";
import { createStreamingSSRPipeline } from "lit-relay/ssr";
// 1. Set up SSR environment (see "Authenticated SSR" recipe above)
const environment = createSSREnvironment(request);
installRelaySSR(environment);
// 2. Render initial HTML with whatever data is already in the store
const initialHtml = render(html`
<relay-environment .environment=${environment}>
<app-shell></app-shell>
</relay-environment>
`);
for (const chunk of initialHtml) {
res.write(chunk);
}
// 3. Embed the initial store snapshot
res.write(
`<script type="application/relay-store">${serializeRelayStore(environment)}</script>`,
);
// 4. Stream deferred data as it arrives
const pipeline = createStreamingSSRPipeline(environment, {
operations: [
{ query: FeedQueryNode, variables: { userId: "1" } },
{ query: RecommendationsQueryNode, variables: {} },
],
timeout: 10_000,
});
for await (const scriptTag of pipeline) {
res.write(scriptTag);
}
res.end("</body></html>");Client — hydrate incrementally:
import { hydrateRelayStore, createStreamingStoreHydrator } from "lit-relay/ssr";
// 1. Hydrate the initial store snapshot (batch)
const environment = hydrateRelayStore(fetchGraphQL);
// 2. Start streaming hydration for incremental chunks
const hydrator = createStreamingStoreHydrator(environment);
hydrator.connect();
// 3. Controllers automatically re-render as store records arrive
// via Relay's subscription machinery
// 4. Optionally wait for all chunks
await hydrator.complete;
hydrator.disconnect();The server emits <script type="application/relay-store-chunk"> tags as query results resolve. The client's createStreamingStoreHydrator detects these via MutationObserver, merges records into the live RecordSource, and calls store.notify() — which triggers Relay's subscription machinery and causes connected controllers to re-render with new data.
The final chunk carries data-final="true", which resolves the hydrator's complete promise and stops observation.
Controller complete Promise (experimental)
Query controllers expose a complete promise that resolves when data first becomes available:
class UserProfile extends LitElement {
private _query = new RelayQueryController(this, UserQueryNode, () => ({
id: this.userId,
}));
async connectedCallback() {
super.connectedCallback();
await this._query.complete;
console.log("Data loaded:", this._query.data);
}
}Works with Lit's until() directive for template-level progressive disclosure:
import { until } from "lit/directives/until.js";
render() {
return html`${until(
this._query.complete.then(() =>
html`<h1>${this._query.data?.user?.name}</h1>`
),
html`<loading-skeleton></loading-skeleton>`
)}`;
}The promise resets on refetch() or variable change. Available on RelayQueryController, RelayRefetchableFragmentController, and RelayPaginationFragmentController.
Testing
lit-relay ships testing utilities in the lit-relay/testing subpath export. These create real Relay Environment instances backed by mock networks — no mocking of relay-runtime internals needed.
createTestEnvironment
Creates an Environment with a synchronous mock network. Operations whose name appears in the response map resolve immediately; unmocked operations error.
import { createTestEnvironment } from "lit-relay/testing";
const env = createTestEnvironment(
new Map([
["UserQuery", { user: { id: "1", name: "Alice" } }],
["UpdateUserMutation", { updateUser: { id: "1", name: "Bob" } }],
]),
);Use with a <relay-environment> provider in your test DOM:
const provider = document.createElement("relay-environment");
provider.environment = env;
provider.appendChild(myComponent);
document.body.appendChild(provider);createSSRTestEnvironment
Creates an Environment configured for Lit SSR testing. Supports pre-populating the store so controllers find data synchronously (matching the real SSR server flow).
import { createSSRTestEnvironment } from "lit-relay/testing";
globalThis.litServerRoot = document.createElement("div");
const env = await createSSRTestEnvironment({
responses: new Map([["HeroQuery", heroData]]),
preload: [{ query: HeroQueryNode, variables: { id: "1" } }],
});
// The store is pre-populated — controllers reading HeroQuery
// will find data immediately without a network fetch.Features:
- Mock network from response map (same as
createTestEnvironment) - Pre-fetches queries into the store via
config.preload - Sets
globalThis.litSsrCallConnectedCallback = true - Mounts a
ContextProvideronglobalThis.litServerRoot
SSRTestConfig
interface SSRTestConfig {
responses: Map<string, unknown>;
preload?: Array<{
query: GraphQLTaggedNode;
variables: Record<string, unknown>;
}>;
}Stability and Compatibility Policy
Supported consumer imports are limited to the published package entrypoints:
lit-relaylit-relay/testinglit-relay/ssrlit-relay/ssr/serverlit-relay/ssr/hydration
Policy:
- All documented exports from those entrypoints are treated as the supported consumer surface.
- Exports explicitly marked experimental are not covered by the same stability expectation.
- Deep imports into unexported paths such as
lit-relay/src/*,lit-relay/dist/*, or package-internal modules are unsupported. - The package is still
0.x, so even supported APIs may evolve before1.0; however, documented stable exports are intended to avoid unnecessary consumer churn. - Experimental exports may change shape, behavior, or location without waiting for a stable major release.
Current experimental runtime exports:
observeFragmentwaitForFragmentData
Everything else documented in the published entrypoints above is part of the current stable consumer surface.
API Reference
Main Export (lit-relay)
Unless explicitly marked otherwise below, these runtime exports are part of the stable consumer surface.
| Export | Kind | Description |
|---|---|---|
| RelayQueryController | class | Query fetch + store read controller |
| RelayFragmentController | class | Fragment store read controller |
| RelayRefetchableFragmentController | class | Fragment + refetch controller |
| RelayPaginationFragmentController | class | Fragment + cursor pagination controller |
| RelayMutationController | class | Mutation commit controller |
| RelaySubscriptionController | class | GraphQL subscription controller |
| RelayInvalidationController | class | Store invalidation observer controller |
| RelayEnvironmentProvider | class | <relay-environment> custom element |
| relayEnvironmentContext | Context | @lit/context key for environment |
| RelayStateMixin | function | CSS :state() mixin factory |
| RELAY_REGISTER | symbol | Registration key for mixin ↔ controller |
| loadQuery | function | Manual query preloading |
| shallowEqual | function | Shallow comparison utility |
| graphql | re-export | relay-runtime tagged template |
| fetchQuery | re-export | relay-runtime imperative fetch |
| commitLocalUpdate | re-export | relay-runtime local store update |
| readInlineData | re-export | relay-runtime inline fragment read |
| waitForFragmentData | re-export | Experimental relay-runtime/experimental async fragment helper |
| observeFragment | re-export | Experimental relay-runtime/experimental fragment stream helper |
Types (from main export)
| Type | Description |
|---|---|
| RelayControllerHost | ReactiveControllerHost & HTMLElement |
| RelayControllerLike | Structural type for mixin integration |
| RelayQueryControllerOptions | Options for RelayQueryController |
| RelayQueryControllerInterface<T> | Controller public shape |
| RelayFragmentControllerInterface<T> | Controller public shape |
| RelayRefetchableFragmentControllerInterface<T, K> | Controller public shape |
| RelayPaginationFragmentControllerInterface<T, K> | Controller public shape |
| RelayMutationControllerInterface<T> | Controller public shape |
| RelayMutationControllerConfig<T> | Mutation config options |
| RelaySubscriptionControllerInterface<T> | Controller public shape |
| RelaySubscriptionControllerConfig<T> | Subscription config options |
| RelayInvalidationControllerInterface | Controller public shape |
| RelayInvalidationControllerOptions | Invalidation options |
| RelayEnvironmentContextValue | Environment \| undefined |
| RelayEnvironmentProviderInterface | Provider element shape |
| PreloadedQueryReference<T> | Preloaded query reference |
| FragmentKeyType | { readonly " $data"?: unknown } |
| Constructor<T> | Mixin constructor constraint |
| RelayState | "loading" \| "ready" \| "error" \| "mutating" |
| RelayStateMixinConstructor | Mixin return type |
Testing Export (lit-relay/testing)
This subpath is part of the supported testing surface for consumers.
| Export | Kind | Description |
|---|---|---|
| createTestEnvironment | function | Mock environment for integration tests |
| createSSRTestEnvironment | function | Mock environment for SSR tests |
| SSRTestConfig | type | Configuration for SSR test environment |
SSR Exports (lit-relay/ssr/*)
These subpaths are the supported SSR surface. Prefer them over deep imports.
| Export | Subpath | Description |
|---|---|---|
| installRelaySSR | ssr/server | Mount environment for Lit SSR |
| serializeRelayStore | ssr/server | Serialize store to JSON |
| hydrateRelayStore | ssr/hydration | Client-side store rehydration |
| SSRRouteConfig | ssr | Route config type |
| InstallRelaySSROptions | ssr | Options type |
License
MIT
