Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/quiet-records-watch.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions docs/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ export default defineConfig(
title: "Subscriptions",
link: "/subscriptions",
},
{
title: "Store Invalidation",
link: "/invalidation",
},
],
},
],
Expand Down
91 changes: 91 additions & 0 deletions docs/src/routes/guide/invalidation.mdx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<Show when={isStale()}>
<p>This profile may be out of date.</p>
</Show>
{/* ... */}
</div>
);
}
```

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();
```
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
59 changes: 59 additions & 0 deletions src/primitives/createSubscriptionToInvalidationState.ts
Original file line number Diff line number Diff line change
@@ -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<readonly DataID[]>,
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;
Comment on lines +54 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep disposal latched across effect executions

If dispose() is called before the effect's initial execution, or if the dataIDs/environment changes afterward, this only disposes the current handle and leaves the effect active, so it subsequently installs a new subscription and invokes callbacks despite the caller having stopped listening. Track a permanently disposed state and prevent later effect executions from subscribing.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, but I'd accept the hole

},
};
}
197 changes: 197 additions & 0 deletions tests/createSubscriptionToInvalidationState.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<RelayEnvironmentProvider environment={environment}>{props.children}</RelayEnvironmentProvider>
);

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 <h1 data-testid="invalidation-owner">Owner</h1>;
};

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(() => (
<View>
<Owner dataIDs={["1", "2"]} callback={callback} />
</View>
));
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(() => (
<View>
<Owner dataIDs={["1"]} callback={callback} />
</View>
));
await wait(2);

invalidateStore();
expect(callback).toHaveBeenCalledTimes(1);
});

it("ignores invalidation of unrelated records", async () => {
const callback = vi.fn();

renderToBody(() => (
<View>
<Owner dataIDs={["1"]} callback={callback} />
</View>
));
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<readonly DataID[]>(["1"]);
const store = environment.getStore();
const subscribe = vi.spyOn(store, "subscribeToInvalidationState");

renderToBody(() => (
<View>
<Owner dataIDs={ids} callback={callback} />
</View>
));
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<readonly DataID[]>(["1", "2"]);
const subscribe = vi.spyOn(environment.getStore(), "subscribeToInvalidationState");

renderToBody(() => (
<View>
<Owner dataIDs={ids} callback={callback} />
</View>
));
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(() => (
<View>
<Owner dataIDs={["1"]} callback={callback} />
</View>
));
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(() => <View>{show() && <Owner dataIDs={["1"]} callback={callback} />}</View>);
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(() => (
<View>
<Owner dataIDs={["1"]} callback={callback} onDisposable={(d) => (disposable = d)} />
</View>
));
await wait(2);

invalidateRecord("1");
expect(callback).toHaveBeenCalledTimes(1);

disposable?.dispose();
invalidateRecord("1");
invalidateStore();
expect(callback).toHaveBeenCalledTimes(1);
});
});
Loading