From 012e77b61057482adf89345d73dcdc86510a2887 Mon Sep 17 00:00:00 2001 From: Iha Shin Date: Wed, 26 Aug 2026 12:43:18 +0900 Subject: [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. Both the data IDs and the callback accept a MaybeAccessor. Co-Authored-By: Claude Fable 5 --- .changeset/quiet-records-watch.md | 7 + docs/app.config.ts | 4 + docs/src/routes/guide/invalidation.mdx | 91 ++++++++ src/index.ts | 1 + .../createSubscriptionToInvalidationState.ts | 59 ++++++ ...teSubscriptionToInvalidationState.test.tsx | 197 ++++++++++++++++++ 6 files changed, 359 insertions(+) create mode 100644 .changeset/quiet-records-watch.md create mode 100644 docs/src/routes/guide/invalidation.mdx create mode 100644 src/primitives/createSubscriptionToInvalidationState.ts create mode 100644 tests/createSubscriptionToInvalidationState.test.tsx 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); + }); +});