npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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/context

Peer dependencieslit ≥ 3.0, @lit/context ≥ 1.0, relay-runtime ≥ 16.0.

You still need relay-compiler in 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 undocumented dist/ 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/experimental and 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 = true so Lit triggers controller lifecycle during server rendering
  • Mounts a ContextProvider on globalThis.litServerRoot so context-consuming elements receive the Environment

serializeRelayStore:

  • Serializes the store's RecordSource to 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, respond

The 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 ContextProvider on globalThis.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-relay
  • lit-relay/testing
  • lit-relay/ssr
  • lit-relay/ssr/server
  • lit-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 before 1.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:

  • observeFragment
  • waitForFragmentData

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