-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add createSubscriptionToInvalidationState #85
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| }, | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If
dispose()is called before the effect's initial execution, or if thedataIDs/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 👍 / 👎.
There was a problem hiding this comment.
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