@abloatai/humans 0.63.1 → 0.64.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 (60) hide show
  1. package/dist/Ablo.d.ts +4 -0
  2. package/dist/client.d.ts +11 -31
  3. package/dist/local/client/reactiveEngine.js +4 -5
  4. package/dist/{react/useSyncStatus.d.ts → local/client/status.d.ts} +4 -3
  5. package/dist/local/client/status.js +14 -0
  6. package/dist/local/client/storeLifecycle.js +2 -2
  7. package/dist/local/storeContract.d.ts +3 -4
  8. package/dist/presence/index.js +6 -2
  9. package/dist/react/AbloProvider.d.ts +57 -121
  10. package/dist/react/AbloProvider.js +49 -154
  11. package/dist/react/createAbloReact.d.ts +10 -32
  12. package/dist/react/createAbloReact.js +10 -54
  13. package/dist/react/internalContext.d.ts +3 -20
  14. package/dist/react/snapshot.d.ts +4 -0
  15. package/dist/react/snapshot.js +75 -0
  16. package/dist/react/useAblo.d.ts +27 -44
  17. package/dist/react/useAblo.js +39 -74
  18. package/dist/react/useMutators.d.ts +20 -17
  19. package/dist/react/usePresence.js +7 -6
  20. package/dist/react/useReactive.d.ts +12 -0
  21. package/dist/react/useReactive.js +57 -0
  22. package/dist/react/useUndoScope.d.ts +15 -29
  23. package/dist/react/useUndoScope.js +17 -0
  24. package/dist/react.d.ts +7 -19
  25. package/dist/react.js +7 -15
  26. package/package.json +4 -3
  27. package/src/Ablo.ts +5 -0
  28. package/src/client.ts +12 -31
  29. package/src/local/client/reactiveEngine.ts +4 -5
  30. package/src/local/client/status.ts +20 -0
  31. package/src/local/client/storeLifecycle.ts +2 -2
  32. package/src/local/storeContract.ts +3 -4
  33. package/src/presence/index.ts +6 -2
  34. package/src/react/AbloProvider.tsx +80 -255
  35. package/src/react/createAbloReact.ts +18 -99
  36. package/src/react/internalContext.ts +3 -20
  37. package/src/react/snapshot.ts +67 -0
  38. package/src/react/useAblo.ts +70 -136
  39. package/src/react/useMutators.ts +30 -26
  40. package/src/react/usePresence.ts +7 -12
  41. package/src/react/useReactive.ts +56 -0
  42. package/src/react/useUndoScope.ts +18 -14
  43. package/src/react.ts +7 -69
  44. package/dist/react/ClientSideSuspense.d.ts +0 -36
  45. package/dist/react/ClientSideSuspense.js +0 -17
  46. package/dist/react/useCurrentUserId.d.ts +0 -2
  47. package/dist/react/useCurrentUserId.js +0 -12
  48. package/dist/react/useErrorListener.d.ts +0 -2
  49. package/dist/react/useErrorListener.js +0 -14
  50. package/dist/react/useMutationFailureListener.d.ts +0 -8
  51. package/dist/react/useMutationFailureListener.js +0 -19
  52. package/dist/react/useSyncStatus.js +0 -48
  53. package/dist/useReactive.d.ts +0 -6
  54. package/dist/useReactive.js +0 -41
  55. package/src/react/ClientSideSuspense.tsx +0 -57
  56. package/src/react/useCurrentUserId.ts +0 -17
  57. package/src/react/useErrorListener.ts +0 -22
  58. package/src/react/useMutationFailureListener.ts +0 -34
  59. package/src/react/useSyncStatus.ts +0 -53
  60. package/src/useReactive.ts +0 -49
package/src/react.ts CHANGED
@@ -1,69 +1,7 @@
1
- /** React bindings for the optional human-facing local-state package. */
2
- export { useReactive } from './useReactive.js';
3
- export { useCurrentUserId } from './react/useCurrentUserId.js';
4
- export { useErrorListener } from './react/useErrorListener.js';
5
- export { useSyncStatus, type SyncStatusSnapshot } from './react/useSyncStatus.js';
6
- export {
7
- usePresence,
8
- type PresenceModelSelector,
9
- } from './react/usePresence.js';
10
- export {
11
- useMutationFailureListener,
12
- type MutationFailurePayload,
13
- } from './react/useMutationFailureListener.js';
14
-
15
- export {
16
- AbloProvider,
17
- usePeers,
18
- useSync,
19
- useSyncStore,
20
- type AbloProviderProps,
21
- type GroupScope,
22
- } from './react/AbloProvider.js';
23
-
24
- export {
25
- ClientSideSuspense,
26
- type ClientSideSuspenseProps,
27
- } from './react/ClientSideSuspense.js';
28
-
29
- export { DefaultFallback } from './react/DefaultFallback.js';
30
-
31
- export {
32
- createAbloReact,
33
- type AbloReactBinding,
34
- } from './react/createAbloReact.js';
35
-
36
- export {
37
- useAblo,
38
- type UseAbloHydratedModelResult,
39
- type UseAbloModelOptions,
40
- type UseAbloModelResult,
41
- } from './react/useAblo.js';
42
-
43
- export {
44
- useMutators,
45
- type InvokerFor,
46
- type MutatorInvokers,
47
- type UseMutatorsOptions,
48
- } from './react/useMutators.js';
49
-
50
- export {
51
- useUndoScope,
52
- type UseUndoScopeResult,
53
- } from './react/useUndoScope.js';
54
-
55
- export type {
56
- DefaultSyncShape,
57
- ResolveSchema,
58
- ResolveUserMeta,
59
- ResolveClaimMeta,
60
- ResolveModelKey,
61
- } from '@abloatai/transaction/types/global';
62
-
63
- export { ModelScope } from '@abloatai/transaction/types';
64
- export type { SyncStoreContract } from './react/context.js';
65
- export type { MutateActions } from './local/mutators/mutateActions.js';
66
- export type {
67
- ReaderActions,
68
- ReaderFindOptions,
69
- } from './local/mutators/readerActions.js';
1
+ /** React owns context, subscriptions and component lifetimes over core Ablo. */
2
+ export { AbloProvider } from './react/AbloProvider.js';
3
+ export { createAbloReact } from './react/createAbloReact.js';
4
+ export { useAblo } from './react/useAblo.js';
5
+ export { usePresence } from './react/usePresence.js';
6
+ export { useMutators } from './react/useMutators.js';
7
+ export { useUndoScope } from './react/useUndoScope.js';
@@ -1,36 +0,0 @@
1
- import { type ReactNode } from 'react';
2
- /**
3
- * Nested bootstrap gate for a subtree. `<AbloProvider>` already ships
4
- * its own built-in gate (via its `fallback` prop) that handles the
5
- * common "wait for first bootstrap" case. Use `ClientSideSuspense`
6
- * only when you need a SEPARATE gate inside an already-ready provider —
7
- * for example, rendering app chrome immediately while gating a single
8
- * heavy product surface on its own query resolving.
9
- *
10
- * Like the provider-level gate, this component latches open on the
11
- * first `connected` / `reconnecting` / `disconnected` transition and
12
- * stays open. Subsequent transient `connecting` states (hard reconnect
13
- * after offline) do NOT re-show the fallback — the app has already
14
- * rendered once and its own reconnect UI should take over.
15
- *
16
- * v0.3.x implementation is non-Suspense: reads `useSyncStatus()` and
17
- * conditionally renders. v0.3.x+ will ship a
18
- * the React suspense entry point where `useQuery` / `useOne`
19
- * actually throw Promises; this component becomes a thin wrapper around
20
- * React's real `<Suspense>` at that point.
21
- *
22
- * @example
23
- * <AbloProvider fallback={<AppSkeleton />}>
24
- * <AppChrome />
25
- * <ClientSideSuspense fallback={<CanvasSkeleton />}>
26
- * <HeavyCanvas />
27
- * </ClientSideSuspense>
28
- * </AbloProvider>
29
- */
30
- export interface ClientSideSuspenseProps {
31
- /** What to render while the nested subtree is waiting for first bootstrap. */
32
- fallback: ReactNode;
33
- /** What to render once the subtree is cleared to render. */
34
- children: ReactNode;
35
- }
36
- export declare function ClientSideSuspense({ fallback, children }: ClientSideSuspenseProps): import("react").JSX.Element;
@@ -1,17 +0,0 @@
1
- 'use client';
2
- import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
3
- import { useEffect, useState } from 'react';
4
- import { useSyncStatus } from './useSyncStatus.js';
5
- export function ClientSideSuspense({ fallback, children }) {
6
- const status = useSyncStatus();
7
- const [everConnected, setEverConnected] = useState(false);
8
- useEffect(() => {
9
- if (status.name === 'connected' ||
10
- status.name === 'reconnecting' ||
11
- status.name === 'disconnected') {
12
- setEverConnected(true);
13
- }
14
- }, [status.name]);
15
- const showFallback = !everConnected && status.name === 'connecting';
16
- return _jsx(_Fragment, { children: showFallback ? fallback : children });
17
- }
@@ -1,2 +0,0 @@
1
- /** Returns the application user id supplied to the nearest AbloProvider. */
2
- export declare function useCurrentUserId(): string | null;
@@ -1,12 +0,0 @@
1
- 'use client';
2
- import { useContext } from 'react';
3
- import { AbloInternalContext } from './internalContext.js';
4
- import { AbloValidationError } from '@abloatai/transaction/errors';
5
- /** Returns the application user id supplied to the nearest AbloProvider. */
6
- export function useCurrentUserId() {
7
- const context = useContext(AbloInternalContext);
8
- if (!context) {
9
- throw new AbloValidationError('useCurrentUserId: no <AbloProvider> mounted above this component.', { code: 'no_ablo_provider' });
10
- }
11
- return context.currentUserId;
12
- }
@@ -1,2 +0,0 @@
1
- /** Subscribe to provider-level errors without causing component re-renders. */
2
- export declare function useErrorListener(listener: (error: Error) => void): void;
@@ -1,14 +0,0 @@
1
- 'use client';
2
- import { useContext, useEffect, useRef } from 'react';
3
- import { AbloInternalContext } from './internalContext.js';
4
- import { AbloValidationError } from '@abloatai/transaction/errors';
5
- /** Subscribe to provider-level errors without causing component re-renders. */
6
- export function useErrorListener(listener) {
7
- const context = useContext(AbloInternalContext);
8
- if (!context) {
9
- throw new AbloValidationError('useErrorListener: no <AbloProvider> mounted above this component.', { code: 'no_ablo_provider' });
10
- }
11
- const listenerRef = useRef(listener);
12
- listenerRef.current = listener;
13
- useEffect(() => context.subscribeError((error) => { listenerRef.current(error); }), [context]);
14
- }
@@ -1,8 +0,0 @@
1
- import { type QueuedMutation } from '../reactRuntime.js';
2
- export interface MutationFailurePayload {
3
- transaction: QueuedMutation;
4
- error: Error;
5
- permanent?: boolean;
6
- }
7
- /** Subscribe to optimistic mutation rollbacks and permanent write failures. */
8
- export declare function useMutationFailureListener(listener: (payload: MutationFailurePayload) => void): void;
@@ -1,19 +0,0 @@
1
- 'use client';
2
- import { useContext, useEffect, useRef } from 'react';
3
- import { AbloInternalContext, } from '../reactRuntime.js';
4
- import { AbloValidationError } from '@abloatai/transaction/errors';
5
- /** Subscribe to optimistic mutation rollbacks and permanent write failures. */
6
- export function useMutationFailureListener(listener) {
7
- const context = useContext(AbloInternalContext);
8
- if (!context) {
9
- throw new AbloValidationError('useMutationFailureListener: no <AbloProvider> mounted above this component.', { code: 'no_ablo_provider' });
10
- }
11
- const listenerRef = useRef(listener);
12
- listenerRef.current = listener;
13
- useEffect(() => {
14
- const engine = context.engine;
15
- if (!engine)
16
- return;
17
- return engine.onMutationFailure((payload) => { listenerRef.current(payload); });
18
- }, [context, context.engine]);
19
- }
@@ -1,48 +0,0 @@
1
- 'use client';
2
- import { useCallback, useContext } from 'react';
3
- import { SyncContext, } from './context.js';
4
- import { AbloInternalContext } from './internalContext.js';
5
- import { AbloValidationError } from '@abloatai/transaction/errors';
6
- import { useReactive } from '../useReactive.js';
7
- /** Reactively exposes the local store's connection and confirmation status. */
8
- export function useSyncStatus() {
9
- const provider = useContext(AbloInternalContext);
10
- const sync = useContext(SyncContext);
11
- // Status does not require authenticated row scope. The client exists before
12
- // ready() resolves, including inside passthrough children and custom fallbacks.
13
- const store = provider?.engine?._store ?? sync?.store;
14
- if (!store) {
15
- throw new AbloValidationError('Sync hooks must be used within an <AbloProvider>.', {
16
- code: 'sync_context_missing_provider',
17
- });
18
- }
19
- const compute = useCallback(() => deriveStatus(store), [store]);
20
- return useReactive(compute, sameSnapshot);
21
- }
22
- function deriveStatus(store) {
23
- const { state, progress, pendingChanges, isSessionError, error } = store.syncStatus;
24
- if (isSessionError)
25
- return { name: 'needs-auth' };
26
- if (state === 'reconnecting')
27
- return { name: 'reconnecting', reason: error?.message };
28
- if (state === 'offline')
29
- return { name: 'disconnected', reason: 'offline' };
30
- if (state === 'error')
31
- return { name: 'disconnected', reason: error?.message };
32
- if (store.isReady)
33
- return { name: 'connected', hasUnsyncedChanges: pendingChanges > 0 };
34
- if (state === 'idle' || state === 'syncing')
35
- return { name: 'connecting', progress };
36
- return { name: 'initial' };
37
- }
38
- function sameSnapshot(a, b) {
39
- if (a.name !== b.name)
40
- return false;
41
- if (a.name === 'initial' || a.name === 'needs-auth')
42
- return true;
43
- if (a.name === 'connecting')
44
- return b.name === 'connecting' && a.progress === b.progress;
45
- if (a.name === 'connected')
46
- return b.name === 'connected' && a.hasUnsyncedChanges === b.hasUnsyncedChanges;
47
- return (b.name === 'reconnecting' || b.name === 'disconnected') && a.reason === b.reason;
48
- }
@@ -1,6 +0,0 @@
1
- /**
2
- * Subscribe a React component to a MobX computation with a cached snapshot.
3
- * This is the framework-level reactive primitive owned by @abloatai/humans;
4
- * store/model hooks are layered above it.
5
- */
6
- export declare function useReactive<T>(compute: () => T, equals?: (a: T, b: T) => boolean): T;
@@ -1,41 +0,0 @@
1
- 'use client';
2
- import { useCallback, useRef } from 'react';
3
- import { useSyncExternalStore } from 'react';
4
- import { reaction } from 'mobx';
5
- /**
6
- * Subscribe a React component to a MobX computation with a cached snapshot.
7
- * This is the framework-level reactive primitive owned by @abloatai/humans;
8
- * store/model hooks are layered above it.
9
- */
10
- export function useReactive(compute, equals = defaultEquals) {
11
- const computeRef = useRef(compute);
12
- const equalsRef = useRef(equals);
13
- const snapshotRef = useRef(null);
14
- equalsRef.current = equals;
15
- if (snapshotRef.current === null) {
16
- snapshotRef.current = { value: compute() };
17
- }
18
- else if (computeRef.current !== compute) {
19
- const next = compute();
20
- if (!equals(snapshotRef.current.value, next)) {
21
- snapshotRef.current = { value: next };
22
- }
23
- }
24
- computeRef.current = compute;
25
- const subscribe = useCallback((onChange) => reaction(() => computeRef.current(), (next) => {
26
- const current = snapshotRef.current.value;
27
- if (!equalsRef.current(current, next)) {
28
- snapshotRef.current = { value: next };
29
- onChange();
30
- }
31
- }), [compute]);
32
- const getSnapshot = useCallback(() => snapshotRef.current.value, []);
33
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
34
- }
35
- function defaultEquals(a, b) {
36
- if (Object.is(a, b))
37
- return true;
38
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
39
- return false;
40
- return a.every((value, index) => Object.is(value, b[index]));
41
- }
@@ -1,57 +0,0 @@
1
- 'use client';
2
-
3
- import { useEffect, useState, type ReactNode } from 'react';
4
- import { useSyncStatus } from './useSyncStatus.js';
5
-
6
- /**
7
- * Nested bootstrap gate for a subtree. `<AbloProvider>` already ships
8
- * its own built-in gate (via its `fallback` prop) that handles the
9
- * common "wait for first bootstrap" case. Use `ClientSideSuspense`
10
- * only when you need a SEPARATE gate inside an already-ready provider —
11
- * for example, rendering app chrome immediately while gating a single
12
- * heavy product surface on its own query resolving.
13
- *
14
- * Like the provider-level gate, this component latches open on the
15
- * first `connected` / `reconnecting` / `disconnected` transition and
16
- * stays open. Subsequent transient `connecting` states (hard reconnect
17
- * after offline) do NOT re-show the fallback — the app has already
18
- * rendered once and its own reconnect UI should take over.
19
- *
20
- * v0.3.x implementation is non-Suspense: reads `useSyncStatus()` and
21
- * conditionally renders. v0.3.x+ will ship a
22
- * the React suspense entry point where `useQuery` / `useOne`
23
- * actually throw Promises; this component becomes a thin wrapper around
24
- * React's real `<Suspense>` at that point.
25
- *
26
- * @example
27
- * <AbloProvider fallback={<AppSkeleton />}>
28
- * <AppChrome />
29
- * <ClientSideSuspense fallback={<CanvasSkeleton />}>
30
- * <HeavyCanvas />
31
- * </ClientSideSuspense>
32
- * </AbloProvider>
33
- */
34
- export interface ClientSideSuspenseProps {
35
- /** What to render while the nested subtree is waiting for first bootstrap. */
36
- fallback: ReactNode;
37
- /** What to render once the subtree is cleared to render. */
38
- children: ReactNode;
39
- }
40
-
41
- export function ClientSideSuspense({ fallback, children }: ClientSideSuspenseProps) {
42
- const status = useSyncStatus();
43
- const [everConnected, setEverConnected] = useState(false);
44
-
45
- useEffect(() => {
46
- if (
47
- status.name === 'connected' ||
48
- status.name === 'reconnecting' ||
49
- status.name === 'disconnected'
50
- ) {
51
- setEverConnected(true);
52
- }
53
- }, [status.name]);
54
-
55
- const showFallback = !everConnected && status.name === 'connecting';
56
- return <>{showFallback ? fallback : children}</>;
57
- }
@@ -1,17 +0,0 @@
1
- 'use client';
2
-
3
- import { useContext } from 'react';
4
- import { AbloInternalContext } from './internalContext.js';
5
- import { AbloValidationError } from '@abloatai/transaction/errors';
6
-
7
- /** Returns the application user id supplied to the nearest AbloProvider. */
8
- export function useCurrentUserId(): string | null {
9
- const context = useContext(AbloInternalContext);
10
- if (!context) {
11
- throw new AbloValidationError(
12
- 'useCurrentUserId: no <AbloProvider> mounted above this component.',
13
- { code: 'no_ablo_provider' },
14
- );
15
- }
16
- return context.currentUserId;
17
- }
@@ -1,22 +0,0 @@
1
- 'use client';
2
-
3
- import { useContext, useEffect, useRef } from 'react';
4
- import { AbloInternalContext } from './internalContext.js';
5
- import { AbloValidationError } from '@abloatai/transaction/errors';
6
-
7
- /** Subscribe to provider-level errors without causing component re-renders. */
8
- export function useErrorListener(listener: (error: Error) => void): void {
9
- const context = useContext(AbloInternalContext);
10
- if (!context) {
11
- throw new AbloValidationError(
12
- 'useErrorListener: no <AbloProvider> mounted above this component.',
13
- { code: 'no_ablo_provider' },
14
- );
15
- }
16
- const listenerRef = useRef(listener);
17
- listenerRef.current = listener;
18
- useEffect(
19
- () => context.subscribeError((error) => { listenerRef.current(error); }),
20
- [context],
21
- );
22
- }
@@ -1,34 +0,0 @@
1
- 'use client';
2
-
3
- import { useContext, useEffect, useRef } from 'react';
4
- import {
5
- AbloInternalContext,
6
- type QueuedMutation,
7
- } from '../reactRuntime.js';
8
- import { AbloValidationError } from '@abloatai/transaction/errors';
9
-
10
- export interface MutationFailurePayload {
11
- transaction: QueuedMutation;
12
- error: Error;
13
- permanent?: boolean;
14
- }
15
-
16
- /** Subscribe to optimistic mutation rollbacks and permanent write failures. */
17
- export function useMutationFailureListener(
18
- listener: (payload: MutationFailurePayload) => void,
19
- ): void {
20
- const context = useContext(AbloInternalContext);
21
- if (!context) {
22
- throw new AbloValidationError(
23
- 'useMutationFailureListener: no <AbloProvider> mounted above this component.',
24
- { code: 'no_ablo_provider' },
25
- );
26
- }
27
- const listenerRef = useRef(listener);
28
- listenerRef.current = listener;
29
- useEffect(() => {
30
- const engine = context.engine;
31
- if (!engine) return;
32
- return engine.onMutationFailure((payload) => { listenerRef.current(payload); });
33
- }, [context, context.engine]);
34
- }
@@ -1,53 +0,0 @@
1
- 'use client';
2
-
3
- import { useCallback, useContext } from 'react';
4
- import {
5
- SyncContext,
6
- type SyncStoreContract,
7
- } from './context.js';
8
- import { AbloInternalContext } from './internalContext.js';
9
- import { AbloValidationError } from '@abloatai/transaction/errors';
10
- import { useReactive } from '../useReactive.js';
11
-
12
- export type SyncStatusSnapshot =
13
- | { readonly name: 'initial' }
14
- | { readonly name: 'connecting'; readonly progress: number }
15
- | { readonly name: 'connected'; readonly hasUnsyncedChanges: boolean }
16
- | { readonly name: 'reconnecting'; readonly reason?: string }
17
- | { readonly name: 'disconnected'; readonly reason?: string }
18
- | { readonly name: 'needs-auth' };
19
-
20
- /** Reactively exposes the local store's connection and confirmation status. */
21
- export function useSyncStatus(): SyncStatusSnapshot {
22
- const provider = useContext(AbloInternalContext);
23
- const sync = useContext(SyncContext);
24
- // Status does not require authenticated row scope. The client exists before
25
- // ready() resolves, including inside passthrough children and custom fallbacks.
26
- const store = provider?.engine?._store ?? sync?.store;
27
- if (!store) {
28
- throw new AbloValidationError('Sync hooks must be used within an <AbloProvider>.', {
29
- code: 'sync_context_missing_provider',
30
- });
31
- }
32
- const compute = useCallback(() => deriveStatus(store), [store]);
33
- return useReactive(compute, sameSnapshot);
34
- }
35
-
36
- function deriveStatus(store: SyncStoreContract): SyncStatusSnapshot {
37
- const { state, progress, pendingChanges, isSessionError, error } = store.syncStatus;
38
- if (isSessionError) return { name: 'needs-auth' };
39
- if (state === 'reconnecting') return { name: 'reconnecting', reason: error?.message };
40
- if (state === 'offline') return { name: 'disconnected', reason: 'offline' };
41
- if (state === 'error') return { name: 'disconnected', reason: error?.message };
42
- if (store.isReady) return { name: 'connected', hasUnsyncedChanges: pendingChanges > 0 };
43
- if (state === 'idle' || state === 'syncing') return { name: 'connecting', progress };
44
- return { name: 'initial' };
45
- }
46
-
47
- function sameSnapshot(a: SyncStatusSnapshot, b: SyncStatusSnapshot): boolean {
48
- if (a.name !== b.name) return false;
49
- if (a.name === 'initial' || a.name === 'needs-auth') return true;
50
- if (a.name === 'connecting') return b.name === 'connecting' && a.progress === b.progress;
51
- if (a.name === 'connected') return b.name === 'connected' && a.hasUnsyncedChanges === b.hasUnsyncedChanges;
52
- return (b.name === 'reconnecting' || b.name === 'disconnected') && a.reason === b.reason;
53
- }
@@ -1,49 +0,0 @@
1
- 'use client';
2
-
3
- import { useCallback, useRef } from 'react';
4
- import { useSyncExternalStore } from 'react';
5
- import { reaction } from 'mobx';
6
-
7
- /**
8
- * Subscribe a React component to a MobX computation with a cached snapshot.
9
- * This is the framework-level reactive primitive owned by @abloatai/humans;
10
- * store/model hooks are layered above it.
11
- */
12
- export function useReactive<T>(
13
- compute: () => T,
14
- equals: (a: T, b: T) => boolean = defaultEquals,
15
- ): T {
16
- const computeRef = useRef(compute);
17
- const equalsRef = useRef(equals);
18
- const snapshotRef = useRef<{ value: T } | null>(null);
19
-
20
- equalsRef.current = equals;
21
- if (snapshotRef.current === null) {
22
- snapshotRef.current = { value: compute() };
23
- } else if (computeRef.current !== compute) {
24
- const next = compute();
25
- if (!equals(snapshotRef.current.value, next)) {
26
- snapshotRef.current = { value: next };
27
- }
28
- }
29
- computeRef.current = compute;
30
-
31
- const subscribe = useCallback((onChange: () => void) => reaction(
32
- () => computeRef.current(),
33
- (next) => {
34
- const current = snapshotRef.current!.value;
35
- if (!equalsRef.current(current, next)) {
36
- snapshotRef.current = { value: next };
37
- onChange();
38
- }
39
- },
40
- ), [compute]);
41
- const getSnapshot = useCallback(() => snapshotRef.current!.value, []);
42
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
43
- }
44
-
45
- function defaultEquals<T>(a: T, b: T): boolean {
46
- if (Object.is(a, b)) return true;
47
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
48
- return a.every((value, index) => Object.is(value, b[index]));
49
- }