diff --git a/.changeset/quiet-records-watch.md b/.changeset/quiet-records-watch.md
new file mode 100644
index 0000000..6a8098d
--- /dev/null
+++ b/.changeset/quiet-records-watch.md
@@ -0,0 +1,7 @@
+---
+"solid-relay": patch
+---
+
+feat: add `createSubscriptionToInvalidationState`
+
+Solid counterpart of React Relay's `useSubscribeToInvalidationState`. Subscribes a callback to the invalidation state of a set of data IDs, re-establishing the subscription when the IDs change and disposing it on cleanup.
diff --git a/docs/app.config.ts b/docs/app.config.ts
index 6abcf84..a0990c4 100644
--- a/docs/app.config.ts
+++ b/docs/app.config.ts
@@ -79,6 +79,10 @@ export default defineConfig(
title: "Subscriptions",
link: "/subscriptions",
},
+ {
+ title: "Store Invalidation",
+ link: "/invalidation",
+ },
],
},
],
diff --git a/docs/src/routes/guide/invalidation.mdx b/docs/src/routes/guide/invalidation.mdx
new file mode 100644
index 0000000..0208817
--- /dev/null
+++ b/docs/src/routes/guide/invalidation.mdx
@@ -0,0 +1,91 @@
+---
+title: Store Invalidation
+---
+
+# Store Invalidation
+
+Relay lets you mark records (or the whole store) as _stale_ so that the next time a query that
+reads them is rendered, it is refetched instead of being served from the cache. This guide covers
+how to react to invalidation from Solid components using `createSubscriptionToInvalidationState`.
+
+## Invalidating Data
+
+Invalidation happens inside an updater, for example in a mutation `updater` or via
+`commitLocalUpdate`:
+
+```tsx
+import { commitLocalUpdate } from "relay-runtime";
+
+// Invalidate a single record
+commitLocalUpdate(environment, (store) => {
+ store.get(userId)?.invalidateRecord();
+});
+
+// Invalidate the whole store
+commitLocalUpdate(environment, (store) => {
+ store.invalidateStore();
+});
+```
+
+See the [Relay docs on staleness](https://relay.dev/docs/guided-tour/reusing-cached-data/staleness-of-data/)
+for more details on how invalidation affects queries.
+
+## Subscribing with createSubscriptionToInvalidationState
+
+Use `createSubscriptionToInvalidationState` to run a callback whenever the invalidation state of
+a set of data IDs changes. This is useful for triggering a refetch of data that is displayed
+outside the regular query flow, or for showing a "stale" indicator:
+
+```tsx
+import { createSignal } from "solid-js";
+import { createSubscriptionToInvalidationState } from "solid-relay";
+
+function UserProfile(props: { userId: string }) {
+ const [isStale, setIsStale] = createSignal(false);
+
+ createSubscriptionToInvalidationState(
+ () => [props.userId],
+ () => setIsStale(true),
+ );
+
+ return (
+
+
+ This profile may be out of date.
+
+ {/* ... */}
+
+ );
+}
+```
+
+The first argument is either a static array of data IDs or an accessor returning one. When the
+accessor's result changes (compared by contents, so the order of the IDs does not matter), the
+subscription is re-established and the previous one is disposed. The subscription is also
+automatically disposed when the owner is cleaned up.
+
+The callback is invoked whenever any of the given records is invalidated, or when the whole store
+is invalidated. It can also be passed as an accessor, in which case the latest callback is read at
+invalidation time (without re-establishing the subscription):
+
+```tsx
+const [onInvalidate, setOnInvalidate] = createSignal<() => void>(() => {});
+
+createSubscriptionToInvalidationState(() => [props.userId], onInvalidate);
+```
+
+Note that a zero-argument callback and an accessor cannot be told apart at runtime, so the value
+is resolved by calling it: if the result is a function, that function is invoked as the callback.
+Avoid returning a function from a plain callback.
+
+## Disposing Early
+
+`createSubscriptionToInvalidationState` returns a `Disposable`, so you can stop listening before
+the owner is cleaned up:
+
+```tsx
+const disposable = createSubscriptionToInvalidationState(["4"], () => refetch());
+
+// Later...
+disposable.dispose();
+```
diff --git a/src/index.ts b/src/index.ts
index b56aa2e..7355510 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -7,5 +7,6 @@ export { createPreloadedQuery } from "./primitives/createPreloadedQuery";
export { createQueryLoader } from "./primitives/createQueryLoader";
export { createRefetchableFragment } from "./primitives/createRefetchableFragment";
export { createSubscription } from "./primitives/createSubscription";
+export { createSubscriptionToInvalidationState } from "./primitives/createSubscriptionToInvalidationState";
export { RelayEnvironmentProvider, useRelayEnvironment } from "./RelayEnvironment";
export type { DataStore } from "./utils/dataStore";
diff --git a/src/primitives/createSubscriptionToInvalidationState.ts b/src/primitives/createSubscriptionToInvalidationState.ts
new file mode 100644
index 0000000..a824a19
--- /dev/null
+++ b/src/primitives/createSubscriptionToInvalidationState.ts
@@ -0,0 +1,59 @@
+import type { DataID, Disposable } from "relay-runtime";
+import { createEffect, createMemo, onCleanup, untrack } from "solid-js";
+import { useRelayEnvironment } from "../RelayEnvironment";
+import { access, type MaybeAccessor } from "../utils/access";
+
+const sameDataIDs = (a: readonly DataID[], b: readonly DataID[]) =>
+ a.length === b.length && a.every((id, i) => id === b[i]);
+
+/**
+ * Subscribes a callback to the invalidation state of the given data IDs.
+ *
+ * Any time the invalidation state of the given data IDs changes (either one of the records
+ * or the whole store gets invalidated), the provided callback is called.
+ * When the data IDs (or the environment) change, the subscription is re-established and
+ * the previous one is disposed. The subscription is automatically disposed on cleanup.
+ *
+ * The callback may be given directly or through an accessor. Since a zero-argument callback and an
+ * accessor are indistinguishable at runtime, the value is resolved when an invalidation happens:
+ * `callback` is invoked, and if it returns a function, that function is invoked as the actual
+ * callback. The accessor is read untracked, so changing it never re-establishes the subscription.
+ *
+ * @param dataIDs - Data IDs to observe, or an accessor returning them.
+ * @param callback - Called whenever the invalidation state of the data IDs changes, or an accessor
+ * returning such a callback.
+ * @returns A disposable that can be used to stop the subscription early.
+ */
+export function createSubscriptionToInvalidationState(
+ dataIDs: MaybeAccessor,
+ callback: MaybeAccessor<() => void>,
+): Disposable {
+ const environment = useRelayEnvironment();
+ let disposable: Disposable | null = null;
+
+ const stableDataIDs = createMemo(() => [...access(dataIDs)].sort(), undefined, {
+ equals: sameDataIDs,
+ });
+
+ createEffect(() => {
+ const store = environment().getStore();
+ const invalidationState = store.lookupInvalidationState(stableDataIDs());
+ const current = store.subscribeToInvalidationState(invalidationState, () => {
+ const resolved = untrack(callback as () => unknown);
+ if (typeof resolved === "function") resolved();
+ });
+ disposable = current;
+
+ onCleanup(() => {
+ current.dispose();
+ if (disposable === current) disposable = null;
+ });
+ });
+
+ return {
+ dispose: () => {
+ disposable?.dispose();
+ disposable = null;
+ },
+ };
+}
diff --git a/tests/createSubscriptionToInvalidationState.test.tsx b/tests/createSubscriptionToInvalidationState.test.tsx
new file mode 100644
index 0000000..26f70cb
--- /dev/null
+++ b/tests/createSubscriptionToInvalidationState.test.tsx
@@ -0,0 +1,197 @@
+import { commitLocalUpdate, type DataID, type Disposable } from "relay-runtime";
+import { createMockEnvironment, type MockEnvironment } from "relay-test-utils";
+import { createSignal, type JSXElement } from "solid-js";
+import { createSubscriptionToInvalidationState, RelayEnvironmentProvider } from "solid-relay";
+import { page } from "vitest/browser";
+import { renderToBody, wait } from "./utils";
+
+let environment: MockEnvironment;
+
+const View = (props: { children: JSXElement }) => (
+ {props.children}
+);
+
+const Owner = (props: {
+ dataIDs: readonly DataID[] | (() => readonly DataID[]);
+ callback: (() => void) | (() => () => void);
+ onDisposable?: (disposable: Disposable) => void;
+}) => {
+ const disposable = createSubscriptionToInvalidationState(props.dataIDs, props.callback);
+ props.onDisposable?.(disposable);
+ return Owner
;
+};
+
+const createUsers = (...ids: string[]) =>
+ commitLocalUpdate(environment, (store) => {
+ for (const id of ids) store.create(id, "User").setValue(id, "id");
+ });
+
+const invalidateRecord = (id: string) =>
+ commitLocalUpdate(environment, (store) => store.get(id)?.invalidateRecord());
+
+const invalidateStore = () => commitLocalUpdate(environment, (store) => store.invalidateStore());
+
+describe("createSubscriptionToInvalidationState", () => {
+ beforeEach(() => {
+ environment = createMockEnvironment();
+ createUsers("1", "2", "3");
+ });
+
+ it("calls the callback when a subscribed record is invalidated", async () => {
+ const callback = vi.fn();
+
+ renderToBody(() => (
+
+
+
+ ));
+ await wait(2);
+
+ await expect.element(page.getByTestId("invalidation-owner")).toHaveTextContent("Owner");
+ expect(callback).not.toHaveBeenCalled();
+
+ invalidateRecord("1");
+ expect(callback).toHaveBeenCalledTimes(1);
+
+ invalidateRecord("2");
+ expect(callback).toHaveBeenCalledTimes(2);
+ });
+
+ it("calls the callback when the whole store is invalidated", async () => {
+ const callback = vi.fn();
+
+ renderToBody(() => (
+
+
+
+ ));
+ await wait(2);
+
+ invalidateStore();
+ expect(callback).toHaveBeenCalledTimes(1);
+ });
+
+ it("ignores invalidation of unrelated records", async () => {
+ const callback = vi.fn();
+
+ renderToBody(() => (
+
+
+
+ ));
+ await wait(2);
+
+ invalidateRecord("2");
+ expect(callback).not.toHaveBeenCalled();
+ });
+
+ it("re-establishes the subscription when the data IDs change", async () => {
+ const callback = vi.fn();
+ const [ids, setIds] = createSignal(["1"]);
+ const store = environment.getStore();
+ const subscribe = vi.spyOn(store, "subscribeToInvalidationState");
+
+ renderToBody(() => (
+
+
+
+ ));
+ await wait(2);
+ expect(subscribe).toHaveBeenCalledTimes(1);
+
+ setIds(["2"]);
+ await wait(2);
+ expect(subscribe).toHaveBeenCalledTimes(2);
+
+ invalidateRecord("1");
+ expect(callback).not.toHaveBeenCalled();
+
+ invalidateRecord("2");
+ expect(callback).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not re-subscribe when the data IDs are equivalent", async () => {
+ const callback = vi.fn();
+ const [ids, setIds] = createSignal(["1", "2"]);
+ const subscribe = vi.spyOn(environment.getStore(), "subscribeToInvalidationState");
+
+ renderToBody(() => (
+
+
+
+ ));
+ await wait(2);
+
+ setIds(["2", "1"]);
+ await wait(2);
+ setIds(["1", "2"]);
+ await wait(2);
+
+ expect(subscribe).toHaveBeenCalledTimes(1);
+
+ invalidateRecord("1");
+ expect(callback).toHaveBeenCalledTimes(1);
+ });
+
+ it("reads the latest callback from an accessor without re-subscribing", async () => {
+ const first = vi.fn();
+ const second = vi.fn();
+ const [callback, setCallback] = createSignal<() => void>(first);
+ const subscribe = vi.spyOn(environment.getStore(), "subscribeToInvalidationState");
+
+ renderToBody(() => (
+
+
+
+ ));
+ await wait(2);
+
+ invalidateRecord("1");
+ expect(first).toHaveBeenCalledTimes(1);
+ expect(second).not.toHaveBeenCalled();
+
+ setCallback(() => second);
+ await wait(2);
+
+ invalidateRecord("1");
+ expect(first).toHaveBeenCalledTimes(1);
+ expect(second).toHaveBeenCalledTimes(1);
+ expect(subscribe).toHaveBeenCalledTimes(1);
+ });
+
+ it("disposes the subscription on unmount", async () => {
+ const callback = vi.fn();
+ const [show, setShow] = createSignal(true);
+
+ renderToBody(() => {show() && });
+ await wait(2);
+
+ setShow(false);
+ await wait(2);
+ await expect.element(page.getByTestId("invalidation-owner")).not.toBeInTheDocument();
+
+ invalidateRecord("1");
+ invalidateStore();
+ expect(callback).not.toHaveBeenCalled();
+ });
+
+ it("stops calling the callback after the returned disposable is disposed", async () => {
+ const callback = vi.fn();
+ let disposable: Disposable | undefined;
+
+ renderToBody(() => (
+
+ (disposable = d)} />
+
+ ));
+ await wait(2);
+
+ invalidateRecord("1");
+ expect(callback).toHaveBeenCalledTimes(1);
+
+ disposable?.dispose();
+ invalidateRecord("1");
+ invalidateStore();
+ expect(callback).toHaveBeenCalledTimes(1);
+ });
+});