@indietabletop/appkit 3.4.0 → 3.6.0-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/lib/ClientContext/ClientContext.tsx +25 -0
  2. package/lib/InfoPage/index.tsx +46 -0
  3. package/lib/InfoPage/pages.tsx +36 -0
  4. package/lib/InfoPage/style.css.ts +36 -0
  5. package/lib/Letterhead/index.tsx +3 -3
  6. package/lib/LetterheadForm/index.tsx +1 -1
  7. package/lib/LoginPage/LoginPage.stories.tsx +107 -0
  8. package/lib/LoginPage/LoginPage.tsx +204 -0
  9. package/lib/LoginPage/style.css.ts +17 -0
  10. package/lib/ModernIDB/Cursor.ts +91 -0
  11. package/lib/ModernIDB/ModernIDB.ts +337 -0
  12. package/lib/ModernIDB/ModernIDBError.ts +9 -0
  13. package/lib/ModernIDB/ObjectStore.ts +195 -0
  14. package/lib/ModernIDB/ObjectStoreIndex.ts +102 -0
  15. package/lib/ModernIDB/README.md +9 -0
  16. package/lib/ModernIDB/Transaction.ts +40 -0
  17. package/lib/ModernIDB/VersionChangeManager.ts +57 -0
  18. package/lib/ModernIDB/bindings/factory.tsx +160 -0
  19. package/lib/ModernIDB/bindings/index.ts +2 -0
  20. package/lib/ModernIDB/bindings/types.ts +56 -0
  21. package/lib/ModernIDB/bindings/utils.tsx +32 -0
  22. package/lib/ModernIDB/index.ts +10 -0
  23. package/lib/ModernIDB/types.ts +77 -0
  24. package/lib/ModernIDB/utils.ts +51 -0
  25. package/lib/ReleaseInfo/index.tsx +29 -0
  26. package/lib/RulesetResolver.ts +214 -0
  27. package/lib/SubscribeCard/SubscribeCard.stories.tsx +133 -0
  28. package/lib/SubscribeCard/SubscribeCard.tsx +107 -0
  29. package/lib/SubscribeCard/style.css.ts +14 -0
  30. package/lib/Title/index.tsx +4 -0
  31. package/lib/append-copy-to-text.ts +1 -1
  32. package/lib/async-op.ts +8 -0
  33. package/lib/client.ts +37 -2
  34. package/lib/copyrightRange.ts +6 -0
  35. package/lib/groupBy.ts +25 -0
  36. package/lib/ids.ts +6 -0
  37. package/lib/index.ts +13 -0
  38. package/lib/random.ts +12 -0
  39. package/lib/result/swr.ts +18 -0
  40. package/lib/structs.ts +10 -0
  41. package/lib/typeguards.ts +12 -0
  42. package/lib/types.ts +3 -1
  43. package/lib/unique.test.ts +22 -0
  44. package/lib/unique.ts +24 -0
  45. package/lib/useIsVisible.ts +27 -0
  46. package/package.json +16 -6
@@ -0,0 +1,57 @@
1
+ import { VersionChangeObjectStore } from "./ObjectStore.ts";
2
+ import { VersionChangeTransaction } from "./Transaction.ts";
3
+ import type { KeyPath, ModernIDBSchema } from "./types.ts";
4
+
5
+ export class VersionChangeManager<
6
+ Schema extends ModernIDBSchema,
7
+ IndexNames extends {
8
+ [K in keyof Schema]?: string;
9
+ } = never,
10
+ > {
11
+ readonly idbDatabase: IDBDatabase;
12
+ readonly event: IDBVersionChangeEvent;
13
+ readonly transaction: VersionChangeTransaction<Schema, IndexNames>;
14
+
15
+ constructor(props: {
16
+ idbDatabase: IDBDatabase;
17
+ event: IDBVersionChangeEvent;
18
+ idbTransaction: IDBTransaction;
19
+ }) {
20
+ this.idbDatabase = props.idbDatabase;
21
+ this.event = props.event;
22
+ this.transaction = new VersionChangeTransaction({
23
+ transaction: props.idbTransaction,
24
+ });
25
+ }
26
+
27
+ /**
28
+ * Creates a new object store with the given name and options and returns a new IDBObjectStore.
29
+ *
30
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/IDBDatabase/createObjectStore)
31
+ */
32
+ createObjectStore<StoreName extends string & keyof Schema>(
33
+ name: StoreName,
34
+ options?: {
35
+ autoIncrement?: boolean;
36
+ keyPath?:
37
+ | KeyPath<Schema[StoreName]>
38
+ | KeyPath<Schema[StoreName]>[]
39
+ | null;
40
+ },
41
+ ) {
42
+ const objectStore = this.idbDatabase.createObjectStore(name, options);
43
+ return new VersionChangeObjectStore<
44
+ Schema[StoreName],
45
+ IndexNames[StoreName] extends string ? IndexNames[StoreName] : never
46
+ >(objectStore);
47
+ }
48
+
49
+ /**
50
+ * Deletes the object store with the given name.
51
+ *
52
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/IDBDatabase/deleteObjectStore)
53
+ */
54
+ deleteObjectStore(name: string): void {
55
+ this.idbDatabase.deleteObjectStore(name);
56
+ }
57
+ }
@@ -0,0 +1,160 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useState,
6
+ type ReactNode,
7
+ } from "react";
8
+ import { Failure, isAsyncOp, Pending, Success } from "../../async-op.ts";
9
+ import { caughtValueToString } from "../../caught-value.ts";
10
+ import { useAsyncOp } from "../../use-async-op.ts";
11
+ import type { AnyModernIDB } from "../types.ts";
12
+ import type {
13
+ DatabaseOpenRequestOp,
14
+ FlattenSuccessOps,
15
+ InaccessibleDatabaseError,
16
+ QueryOp,
17
+ } from "./types.ts";
18
+ import { toKnownError } from "./utils.tsx";
19
+
20
+ export function createDatabaseBindings<T extends AnyModernIDB>(db: T) {
21
+ const DatabaseContext = createContext(db);
22
+
23
+ const DatabaseOpenRequest = createContext<DatabaseOpenRequestOp<T>>(
24
+ new Pending(),
25
+ );
26
+
27
+ const cache = new Map<string, unknown>();
28
+
29
+ /**
30
+ * Performs an IndexedDB query and watches for changes.
31
+ *
32
+ * Note that queries will only be executed once IndexedDB is open.
33
+ *
34
+ * If query output is an AsyncOp, it will be flattened. In other words, you
35
+ * will only get one 'layer' of async ops.
36
+ *
37
+ * Provide a cache key if you want to make sure that data is synchronously
38
+ * available during mounts/unmounts.
39
+ */
40
+ function useQuery<Output>(
41
+ /**
42
+ * `query` must have stable identity.
43
+ *
44
+ * Make sure you use `useCallback` or a module-scoped function.
45
+ */
46
+ query: (db: T) => Promise<Output>,
47
+
48
+ cacheKey?: string,
49
+ ): FlattenSuccessOps<QueryOp<Output>> {
50
+ const openRequest = useDatabaseOpenRequest();
51
+ const [queryOp, setOp] = useState<QueryOp<Output>>(new Pending());
52
+
53
+ useEffect(() => {
54
+ async function runQuery(): Promise<void> {
55
+ if (openRequest.isSuccess) {
56
+ try {
57
+ const data = await query(openRequest.value);
58
+ const success = new Success(data);
59
+ setOp(success);
60
+
61
+ if (cacheKey) {
62
+ cache.set(cacheKey, success);
63
+ }
64
+ } catch (error) {
65
+ const failure = new Failure({
66
+ type: "QUERY_ERROR" as const,
67
+ error: caughtValueToString(error),
68
+ });
69
+
70
+ setOp(failure);
71
+ if (cacheKey) {
72
+ cache.set(cacheKey, failure);
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ db.addEventListener("readwrite", runQuery);
79
+ window.addEventListener("focus", runQuery);
80
+ void runQuery();
81
+
82
+ return () => {
83
+ db.removeEventListener("readwrite", runQuery);
84
+ window.removeEventListener("focus", runQuery);
85
+ };
86
+ }, [cacheKey, openRequest, query]);
87
+
88
+ return openRequest.flatMap(() => {
89
+ const flattenOutput = (output: Output) => {
90
+ if (isAsyncOp(output)) {
91
+ return output as FlattenSuccessOps<QueryOp<Output>>;
92
+ }
93
+
94
+ return new Success(output) as FlattenSuccessOps<QueryOp<Output>>;
95
+ };
96
+
97
+ if (cacheKey) {
98
+ const lastResult = cache.get(cacheKey) as QueryOp<Output> | undefined;
99
+
100
+ if (lastResult && queryOp.isPending) {
101
+ return lastResult.flatMap(flattenOutput);
102
+ }
103
+ }
104
+
105
+ return queryOp.flatMap(flattenOutput);
106
+ });
107
+ }
108
+
109
+ function useDatabase() {
110
+ return useContext(DatabaseContext);
111
+ }
112
+
113
+ function useDatabaseOpenRequest() {
114
+ return useContext(DatabaseOpenRequest);
115
+ }
116
+
117
+ function DatabaseProvider(props: { children: ReactNode }) {
118
+ const { op, setSuccess, setFailure } = useAsyncOp<
119
+ T,
120
+ InaccessibleDatabaseError
121
+ >();
122
+
123
+ useEffect(() => {
124
+ db.open({
125
+ onBlocking() {
126
+ setFailure({ type: "CLOSED_FOR_UPGRADE" });
127
+ db.close();
128
+ console.info("Database closed due to version upgrade.");
129
+ },
130
+ }).then(
131
+ () => {
132
+ setSuccess(db);
133
+ console.info("Database open.");
134
+ },
135
+ (error: unknown) => {
136
+ setFailure(toKnownError(error));
137
+ console.warn(`Request to open database failed.`);
138
+ },
139
+ );
140
+
141
+ return () => {
142
+ db.close();
143
+ console.info("Database closed.");
144
+ };
145
+ }, [setFailure, setSuccess]);
146
+
147
+ return (
148
+ <DatabaseOpenRequest.Provider value={op}>
149
+ {props.children}
150
+ </DatabaseOpenRequest.Provider>
151
+ );
152
+ }
153
+
154
+ return {
155
+ useQuery,
156
+ useDatabase,
157
+ useDatabaseOpenRequest,
158
+ DatabaseProvider,
159
+ };
160
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./factory.tsx";
2
+ export * from "./types.ts";
@@ -0,0 +1,56 @@
1
+ import type { AsyncOp, Success } from "../../async-op.ts";
2
+ import type { AnyModernIDB } from "../types.ts";
3
+
4
+ export type DatabaseQueryOpFailure = QueryError | InaccessibleDatabaseError;
5
+
6
+ export type QueryOp<Output> = AsyncOp<Output, DatabaseQueryOpFailure>;
7
+
8
+ export type FlattenSuccessOps<T> = T extends Success<
9
+ infer S extends AsyncOp<unknown, unknown>
10
+ >
11
+ ? S
12
+ : T;
13
+
14
+ export type QueryError = {
15
+ type: "QUERY_ERROR";
16
+ error: string;
17
+ };
18
+
19
+ /**
20
+ * The stored database version is higher than the version requested.
21
+ */
22
+ export type VersionHigherState = {
23
+ type: "DB_VERSION_HIGHER_THAN_REQUESTED";
24
+ message: string;
25
+ };
26
+
27
+ /**
28
+ * Database could not be opened for an unrecognized reason.
29
+ */
30
+ export type UnexpectedErrorState = {
31
+ type: "UNKNOWN_ERROR";
32
+ message: string;
33
+ };
34
+
35
+ /**
36
+ * This happens if the database needs an upgrade due to version change, but there are connections open to it from other tabs.
37
+ */
38
+ export type UpgradeBlockedState = {
39
+ type: "UPGRADE_BLOCKED";
40
+ };
41
+
42
+ /**
43
+ * The DB has been closed to allow for version change.
44
+ */
45
+ export type ClosedForUpgradeState = {
46
+ type: "CLOSED_FOR_UPGRADE";
47
+ };
48
+
49
+ export type InaccessibleDatabaseError =
50
+ | UpgradeBlockedState
51
+ | ClosedForUpgradeState
52
+ | VersionHigherState
53
+ | UnexpectedErrorState;
54
+
55
+ export type DatabaseOpenRequestOp<T extends AnyModernIDB = AnyModernIDB> =
56
+ AsyncOp<T, InaccessibleDatabaseError>;
@@ -0,0 +1,32 @@
1
+ import type { InaccessibleDatabaseError } from "./types.ts";
2
+
3
+ export function toKnownError(error: unknown): InaccessibleDatabaseError {
4
+ if (error instanceof Error) {
5
+ switch (error.name) {
6
+ case "OpenRequestBlockedError": {
7
+ return {
8
+ type: "UPGRADE_BLOCKED",
9
+ };
10
+ }
11
+
12
+ case "VersionError": {
13
+ return {
14
+ type: "DB_VERSION_HIGHER_THAN_REQUESTED",
15
+ message: error.message,
16
+ };
17
+ }
18
+
19
+ default: {
20
+ return {
21
+ type: "UNKNOWN_ERROR",
22
+ message: error.message,
23
+ };
24
+ }
25
+ }
26
+ }
27
+
28
+ return {
29
+ type: "UNKNOWN_ERROR",
30
+ message: "A non-error object was thrown.",
31
+ };
32
+ }
@@ -0,0 +1,10 @@
1
+ export * from "./bindings/index.ts";
2
+ export * from "./Cursor.ts";
3
+ export * from "./ModernIDB.ts";
4
+ export * from "./ModernIDBError.ts";
5
+ export * from "./ObjectStore.ts";
6
+ export * from "./ObjectStoreIndex.ts";
7
+ export * from "./Transaction.ts";
8
+ export * from "./types.ts";
9
+ export * from "./utils.ts";
10
+ export * from "./VersionChangeManager.ts";
@@ -0,0 +1,77 @@
1
+ import { ModernIDB } from "./ModernIDB.ts";
2
+ import { VersionChangeManager } from "./VersionChangeManager.ts";
3
+
4
+ /**
5
+ * IDB supports "readonly", "readwrite", and "versionchange" transaction modes,
6
+ * but "versionchange" can only be initiated automatically during DB upgrade.
7
+ */
8
+ export type TransactionMode = "readonly" | "readwrite";
9
+
10
+ export type ModernIDBState = "open" | "opening" | "closed";
11
+
12
+ export type ModernIDBSchema = {
13
+ [key: string]: unknown;
14
+ };
15
+
16
+ export type VersionChangeHandler<
17
+ Schema extends ModernIDBSchema,
18
+ IndexNames extends {
19
+ [K in keyof Schema]?: string;
20
+ },
21
+ > = (props: {
22
+ event: IDBVersionChangeEvent;
23
+ manager: VersionChangeManager<Schema, IndexNames>;
24
+ db: ModernIDB<Schema, IndexNames>;
25
+ }) => void;
26
+
27
+ export type BlockingHandler<
28
+ Schema extends ModernIDBSchema,
29
+ IndexNames extends {
30
+ [K in keyof Schema]?: string;
31
+ },
32
+ > = (props: {
33
+ event: IDBVersionChangeEvent;
34
+ db: ModernIDB<Schema, IndexNames>;
35
+ }) => void;
36
+
37
+ export type OpenRequestHandlers<
38
+ Schema extends ModernIDBSchema,
39
+ IndexNames extends {
40
+ [K in keyof Schema]?: string;
41
+ },
42
+ > = {
43
+ /**
44
+ * If error is thrown inside the `onInit` handler, the version change
45
+ * transaction will be aborted.
46
+ */
47
+ onInit?: VersionChangeHandler<Schema, IndexNames>;
48
+
49
+ /**
50
+ * If error is thrown inside the `onUpgrade` handler, the version change
51
+ * transaction will be aborted.
52
+ */
53
+ onUpgrade?: VersionChangeHandler<Schema, IndexNames>;
54
+
55
+ /**
56
+ * If a new connection opens which requests a higher version number than
57
+ * is the current version number, a `versionchange` event will be dispatched
58
+ * on the IDBDatabase instance. This handler can specify desired behaviour.
59
+ * For example, you might want to close currently connected connections
60
+ * to allow the version change to proceed.
61
+ */
62
+ onBlocking?: BlockingHandler<Schema, IndexNames>;
63
+ };
64
+
65
+ type ValidKeyValue = string | number | Date;
66
+
67
+ export type KeyPath<T> = T extends { [key: string]: unknown }
68
+ ? {
69
+ [K in keyof T]: K extends string
70
+ ? T[K] extends ValidKeyValue
71
+ ? `${K}`
72
+ : `${K}.${KeyPath<T[K]>}`
73
+ : never;
74
+ }[keyof T]
75
+ : never;
76
+
77
+ export type AnyModernIDB = ModernIDB<any, any>;
@@ -0,0 +1,51 @@
1
+ export function transactionToPromise(
2
+ transaction: IDBTransaction,
3
+ ): Promise<Event> {
4
+ return new Promise<Event>((resolve, reject) => {
5
+ transaction.addEventListener("complete", resolve, { once: true });
6
+ transaction.addEventListener("error", reject, { once: true });
7
+ transaction.addEventListener("abort", reject, { once: true });
8
+ });
9
+ }
10
+
11
+ export function requestToPromise<T>(request: IDBRequest<T>) {
12
+ return new Promise<T>((resolve, reject) => {
13
+ const success = () => resolve(request.result);
14
+ const error = () => reject(request.error);
15
+ request.addEventListener("success", success, { once: true });
16
+ request.addEventListener("error", error, { once: true });
17
+ });
18
+ }
19
+
20
+ export function openRequestToPromise(request: IDBOpenDBRequest) {
21
+ return new Promise<IDBDatabase>((resolve, reject) => {
22
+ const success = () => resolve(request.result);
23
+ const error = () => reject(request.error);
24
+ const blocked = () =>
25
+ reject(
26
+ new Error(
27
+ "Operation blocked. An existing database connection is preventing this action.",
28
+ ),
29
+ );
30
+
31
+ request.addEventListener("success", success, { once: true });
32
+ request.addEventListener("error", error, { once: true });
33
+ request.addEventListener("blocked", blocked, { once: true });
34
+ });
35
+ }
36
+
37
+ export async function* requestToAsyncGenerator(
38
+ request: IDBRequest<IDBCursorWithValue | null>,
39
+ ) {
40
+ let cursor = await requestToPromise(request);
41
+
42
+ while (cursor) {
43
+ yield cursor;
44
+
45
+ if (request.readyState !== "pending") {
46
+ cursor.continue();
47
+ }
48
+
49
+ cursor = await requestToPromise(request);
50
+ }
51
+ }
@@ -0,0 +1,29 @@
1
+ export function ReleaseInfo(props: {
2
+ branch?: string;
3
+ shortcode?: string;
4
+ backgroundColor?: string;
5
+ }) {
6
+ if (!props.branch || !props.shortcode) {
7
+ return <>Release [local]</>;
8
+ }
9
+
10
+ return (
11
+ <>
12
+ {`Release `}
13
+ <code
14
+ style={{
15
+ fontSize: "0.875em",
16
+ backgroundColor: props.backgroundColor,
17
+ paddingInline: "0.25rem",
18
+ paddingBlock: "0.125rem",
19
+ borderRadius: "0.25rem",
20
+ fontFamily: "monospace",
21
+ }}
22
+ >
23
+ {props.shortcode}
24
+ </code>
25
+
26
+ {props.branch === "main" ? "" : ` (${props.branch})`}
27
+ </>
28
+ );
29
+ }
@@ -0,0 +1,214 @@
1
+ import { useCallback, useSyncExternalStore } from "react";
2
+ import { Failure, Pending, Success } from "./async-op.ts";
3
+ import { isNullish } from "./typeguards.ts";
4
+
5
+ type RulesetLike = { version: string };
6
+
7
+ type GetRemoteRuleset<R extends RulesetLike, F> = (
8
+ version: string,
9
+ ) => Promise<Success<R> | Failure<F>>;
10
+
11
+ type RulesetResolverProps<R extends RulesetLike, F> = {
12
+ /**
13
+ * The initial ruleset that will be available to all users immediately at
14
+ * app start.
15
+ */
16
+ initialRuleset: R;
17
+
18
+ /**
19
+ * A function that should return a Result with an instance of
20
+ * a Ruleset for the given game.
21
+ */
22
+ getRemoteRuleset: GetRemoteRuleset<R, F>;
23
+ };
24
+
25
+ /**
26
+ * Encapsulates ruleset caching and retrieval logic.
27
+ *
28
+ * Usually you want to instantiate this class and immediately pass it to
29
+ * {@link createRulesetResolverBindings} so that you can use it from within
30
+ * React components with proper re-renders.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * import { createRulesetResolverBindings, RulesetResolver } from "@indietabletop/appkit";
35
+ *
36
+ * // Instantiate a resolver and export it for any potential use
37
+ * export const resolver = new RulesetResolver({
38
+ * initialRuleset: new Ruleset({ ... }),
39
+ * async getRemoteRuleset(version) {
40
+ * // Get ruleset from a remote location somehow
41
+ * return new Ruleset({ ... });
42
+ * },
43
+ * });
44
+ *
45
+ * // Generate resolver-bound hooks for use within React components
46
+ * export const { useResolveRuleset, useLatestRuleset } =
47
+ * createRulesetResolverBindings(resolver);
48
+ * ```
49
+ */
50
+ export class RulesetResolver<R extends RulesetLike, F> {
51
+ getRemoteRuleset: GetRemoteRuleset<R, F>;
52
+ rulesets: Map<string, Success<R> | Failure<F>>;
53
+
54
+ constructor(props: RulesetResolverProps<R, F>) {
55
+ this.getRemoteRuleset = props.getRemoteRuleset;
56
+ this.rulesets = new Map([
57
+ [props.initialRuleset.version, new Success(props.initialRuleset)],
58
+ ]);
59
+ }
60
+
61
+ get latest() {
62
+ const sortedRulesets = Array.from(this.rulesets.values())
63
+ .filter((result) => result.isSuccess)
64
+ .map((result) => result.value)
65
+ .sort((left, right) => left.version.localeCompare(right.version));
66
+
67
+ if (sortedRulesets.length === 0) {
68
+ throw new Error(
69
+ `Could not resolve latest ruleset. Ruleset resolver doesn't include any succesfully resolved ruleset versions.`,
70
+ );
71
+ }
72
+
73
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
74
+ return sortedRulesets.at(-1)!;
75
+ }
76
+
77
+ requests = new Map<string, Promise<Success<R> | Failure<F>>>();
78
+
79
+ /**
80
+ * Resolves a ruleset version if it is currently in memory.
81
+ *
82
+ * Otherwise, initiates ruleset resolution from a remote source in the
83
+ * background and returns `null`. If you want to know when the freshly
84
+ * requested ruleset will be resolved, you must use the {@link subscribe}
85
+ * method (possibly using a hook returned from {@link createRulesetResolverBindings}).
86
+ */
87
+ resolve(version: string) {
88
+ const ruleset = this.rulesets.get(version);
89
+ if (ruleset) {
90
+ return ruleset;
91
+ }
92
+
93
+ void this.resolveFromRemote(version);
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * Resolves a ruleset from a remote source, deduplicating requests to
99
+ * identical rulesets.
100
+ */
101
+ async resolveFromRemote(version: string) {
102
+ const ongoingRequest = this.requests.get(version);
103
+ if (ongoingRequest) {
104
+ return await ongoingRequest;
105
+ }
106
+
107
+ const remoteRulesetPromise = this.getRemoteRuleset(version);
108
+ this.requests.set(version, remoteRulesetPromise);
109
+
110
+ const newRuleset = await remoteRulesetPromise;
111
+ this.rulesets.set(version, newRuleset);
112
+ this.notify();
113
+
114
+ // Clear requests cache to allow for potential retries
115
+ this.requests.delete(version);
116
+
117
+ return newRuleset;
118
+ }
119
+
120
+ listeners = new Set<() => void>();
121
+
122
+ subscribe(callback: () => void) {
123
+ this.listeners.add(callback);
124
+ }
125
+
126
+ unsubscribe(callback: () => void) {
127
+ this.listeners.delete(callback);
128
+ }
129
+
130
+ notify() {
131
+ for (const notifyListener of this.listeners) {
132
+ notifyListener();
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Given a RulesetResolver, creates bound React hooks that trigger re-renders
139
+ * when new rulesets are resolved by the resolver.
140
+ *
141
+ * Usually you will create these hooks in their own module and export them
142
+ * for further use.
143
+ *
144
+ * @example
145
+ * ```ts
146
+ * export const resolver = new RulesetResolver({ ... });
147
+ *
148
+ * export const { useResolveRuleset, useLatestRuleset } =
149
+ * createRulesetResolverHooks(resolver);
150
+ * ```
151
+ */
152
+ export function createRulesetResolverBindings<
153
+ Ruleset extends RulesetLike,
154
+ Failure,
155
+ >(resolver: RulesetResolver<Ruleset, Failure>) {
156
+ // Pending state with stable identity — necessary so that
157
+ // useSyncExternalStore doesn't enter an infinite loop.
158
+ const pending = new Pending();
159
+
160
+ /**
161
+ * Returns a ruleset result compatible with the provided version.
162
+ *
163
+ * If a ruleset is not currently in memory, it will be requested in the
164
+ * background and the component using this hook will be re-rendered once
165
+ * the request is completed.
166
+ *
167
+ * Note that requests are deduplicated, so it is safe to use this hook
168
+ * in loops/lists where multiple components might request identical
169
+ * ruleset verions.
170
+ */
171
+ function useResolveRuleset(version: string | null | undefined) {
172
+ const subscribe = useCallback((callback: () => void) => {
173
+ resolver.subscribe(callback);
174
+
175
+ return () => {
176
+ resolver.unsubscribe(callback);
177
+ };
178
+ }, []);
179
+
180
+ const getSnapshot = useCallback(() => {
181
+ if (!isNullish(version)) {
182
+ return resolver.resolve(version) ?? pending;
183
+ }
184
+
185
+ return pending;
186
+ }, [version]);
187
+
188
+ return useSyncExternalStore(subscribe, getSnapshot);
189
+ }
190
+
191
+ /**
192
+ * Returns the latest ruleset available in memory.
193
+ */
194
+ function useLatestRuleset() {
195
+ const subscribe = useCallback((callback: () => void) => {
196
+ resolver.subscribe(callback);
197
+
198
+ return () => {
199
+ resolver.unsubscribe(callback);
200
+ };
201
+ }, []);
202
+
203
+ const getSnapshot = useCallback(() => {
204
+ return resolver.latest;
205
+ }, []);
206
+
207
+ return useSyncExternalStore(subscribe, getSnapshot);
208
+ }
209
+
210
+ return {
211
+ useLatestRuleset,
212
+ useResolveRuleset,
213
+ };
214
+ }