@pluv/react 0.0.0-experimental-20250527040415

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.
@@ -0,0 +1,120 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import fastDeepEqual from "fast-deep-equal";
3
+
4
+ export type UseDeepAsyncMemoResult<T extends unknown> =
5
+ | {
6
+ error: null;
7
+ isInitialized: true;
8
+ isLoading: false;
9
+ value: T;
10
+ }
11
+ | {
12
+ error: null;
13
+ isInitialized: boolean;
14
+ value: undefined;
15
+ isLoading: true;
16
+ }
17
+ | {
18
+ error: Error;
19
+ isInitialized: boolean;
20
+ value: undefined;
21
+ isLoading: false;
22
+ };
23
+
24
+ export const useDeepAsyncMemo = <T>(
25
+ factory: () => Promise<T>,
26
+ isEqual: (a: T, b: T) => boolean = fastDeepEqual,
27
+ ): UseDeepAsyncMemoResult<T> => {
28
+ // Store the current async state (loading, value, error, etc.)
29
+ const [state, setState] = useState<UseDeepAsyncMemoResult<T>>({
30
+ isInitialized: false,
31
+ isLoading: true,
32
+ value: undefined,
33
+ error: null,
34
+ });
35
+
36
+ // Keep track of the last resolved value to compare with new ones
37
+ const lastValueRef = useRef<T | undefined>(undefined);
38
+ const hasValueRef = useRef(false); // Tracks whether we've ever resolved a value
39
+
40
+ // Tracks the current in-flight promise to guard against race conditions
41
+ const inflightRef = useRef<Promise<T> | null>(null);
42
+
43
+ // Used to prevent state updates after the component unmounts
44
+ const isMountedRef = useRef(true);
45
+
46
+ useEffect(() => {
47
+ isMountedRef.current = true;
48
+
49
+ // Trigger the async factory
50
+ const promise = factory();
51
+ inflightRef.current = promise;
52
+
53
+ // Set loading state
54
+ setState((prev) => {
55
+ return {
56
+ ...prev,
57
+ isLoading: true,
58
+ error: null,
59
+ } as UseDeepAsyncMemoResult<T>;
60
+ });
61
+
62
+ promise
63
+ .then((result) => {
64
+ // Ignore if component unmounted or if this promise is stale
65
+ if (!isMountedRef.current || inflightRef.current !== promise) return;
66
+
67
+ // Skip update if value hasn't changed
68
+ const isSame = hasValueRef.current && isEqual(lastValueRef.current as T, result);
69
+ if (isSame) {
70
+ setState((prev) => {
71
+ return {
72
+ ...prev,
73
+ isInitialized: true,
74
+ isLoading: false,
75
+ } as UseDeepAsyncMemoResult<T>;
76
+ });
77
+ return;
78
+ }
79
+
80
+ // Save the new value
81
+ lastValueRef.current = result;
82
+ hasValueRef.current = true;
83
+
84
+ setState({
85
+ isInitialized: true,
86
+ isLoading: false,
87
+ value: result,
88
+ error: null,
89
+ });
90
+ })
91
+ .catch((err) => {
92
+ // Ignore if component unmounted or if this promise is stale
93
+ if (!isMountedRef.current || inflightRef.current !== promise) return;
94
+
95
+ setState({
96
+ isInitialized: hasValueRef.current,
97
+ isLoading: false,
98
+ value: undefined,
99
+ error: err instanceof Error ? err : new Error("Unknown error"),
100
+ });
101
+ });
102
+
103
+ // Cleanup: prevent state updates after unmount
104
+ return () => {
105
+ isMountedRef.current = false;
106
+ };
107
+ // eslint-disable-next-line react-hooks/exhaustive-deps
108
+ }, [
109
+ /**
110
+ * !HACK
111
+ * @description We're only going to use the `isEqual` function to determine if this hook
112
+ * should re-run. This is basically our hook's version of a dependency array
113
+ * @date April 13, 2025
114
+ */
115
+ // factory,
116
+ isEqual,
117
+ ]);
118
+
119
+ return state;
120
+ };
@@ -0,0 +1,22 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ export type NoSsrFunction = <T = null>(value: T | (() => T), fallback?: T) => T;
4
+
5
+ export const useNoSsr = (): NoSsrFunction => {
6
+ const [didMount, setDidMount] = useState<boolean>(false);
7
+
8
+ useEffect(() => setDidMount(typeof window !== "undefined"), []);
9
+
10
+ const noSsr = useCallback(
11
+ <T = null>(value: T | (() => T), fallback: T = null as T): T => {
12
+ return didMount
13
+ ? typeof value !== "function"
14
+ ? value
15
+ : (value as () => T)()
16
+ : fallback;
17
+ },
18
+ [didMount],
19
+ );
20
+
21
+ return noSsr;
22
+ };
@@ -0,0 +1,9 @@
1
+ import { useCallback, useState } from "react";
2
+
3
+ export const useRerender = (): (() => void) => {
4
+ const [, setCount] = useState(0);
5
+
6
+ return useCallback(() => {
7
+ setCount((oldCount) => (oldCount + 1) % Number.MAX_SAFE_INTEGER);
8
+ }, []);
9
+ };
@@ -0,0 +1,56 @@
1
+ import { useRef, useSyncExternalStore } from "react";
2
+ import { shallowEqual } from "../utils";
3
+ import { useNoSsr } from "./useNoSsr";
4
+
5
+ export const useSyncExternalStoreWithSelector = <TSnapshot, TSelection>(
6
+ subscribe: (onStoreChange: () => void) => () => void,
7
+ getClientSnapshot: () => TSnapshot,
8
+ getServerSnapshot: undefined | null | (() => TSnapshot),
9
+ selector: (snapshot: TSnapshot) => TSelection,
10
+ isEqual?: (a: TSelection, b: TSelection) => boolean,
11
+ ): TSelection => {
12
+ const noSsr = useNoSsr();
13
+ const cacheRef = useRef<TSelection | null>(null);
14
+
15
+ const _getServerSnapshot = getServerSnapshot
16
+ ? () => {
17
+ const data = getServerSnapshot();
18
+ const snapshot = selector(data);
19
+ const compare = isEqual ?? shallowEqual;
20
+ const cached = cacheRef.current;
21
+
22
+ const isUnchanged = cached === null ? snapshot === null : compare(cached, snapshot);
23
+
24
+ /**
25
+ * !HACK
26
+ * @description Memoize only the last snapshot so that we can only rerender on changes
27
+ * @date June 22, 2024
28
+ */
29
+ cacheRef.current = isUnchanged ? (cached ?? snapshot) : snapshot;
30
+
31
+ return cacheRef.current;
32
+ }
33
+ : undefined;
34
+
35
+ const _getClientSnapshot = () => {
36
+ const data = getClientSnapshot();
37
+ const snapshot = selector(data);
38
+ const compare = isEqual ?? shallowEqual;
39
+ const cached = cacheRef.current;
40
+
41
+ const isUnchanged = cached === null ? snapshot === null : compare(cached, snapshot);
42
+
43
+ /**
44
+ * !HACK
45
+ * @description Memoize only the last snapshot so that we can only rerender on changes
46
+ * @date June 22, 2024
47
+ */
48
+ cacheRef.current = isUnchanged ? (cached ?? snapshot) : snapshot;
49
+
50
+ return cacheRef.current;
51
+ };
52
+
53
+ const result = useSyncExternalStore(subscribe, _getClientSnapshot, _getServerSnapshot);
54
+
55
+ return typeof window !== "undefined" ? noSsr(result, _getServerSnapshot?.()) : result;
56
+ };
@@ -0,0 +1,7 @@
1
+ export {
2
+ useAsyncQueue,
3
+ useDeepAsyncMemo,
4
+ useRerender,
5
+ useSyncExternalStoreWithSelector,
6
+ } from "./hooks";
7
+ export { identity, shallowArrayEqual } from "./utils";
@@ -0,0 +1 @@
1
+ export const identity = <T extends unknown>(x: T): T => x;
@@ -0,0 +1,3 @@
1
+ export { identity } from "./identity";
2
+ export { shallowArrayEqual } from "./shallowArrayEqual";
3
+ export { shallowEqual } from "./shallowEqual";
@@ -0,0 +1,6 @@
1
+ export const shallowArrayEqual = <T extends unknown>(
2
+ a: readonly T[] | T[],
3
+ b: readonly T[] | T[],
4
+ ): boolean => {
5
+ return a.length === b.length && a.every((item, i) => item === b[i]);
6
+ };
@@ -0,0 +1 @@
1
+ export const shallowEqual = (a: any, b: any): boolean => a === b;
package/src/types.ts ADDED
@@ -0,0 +1,151 @@
1
+ import type {
2
+ MergeEvents,
3
+ MockedRoomEvents,
4
+ PluvClient,
5
+ PluvRoomDebug,
6
+ PluvRouterEventConfig,
7
+ UserInfo,
8
+ WebSocketConnection,
9
+ } from "@pluv/client";
10
+ import type {
11
+ AbstractCrdtDocFactory,
12
+ InferCrdtJson,
13
+ InferDoc,
14
+ InferInitialStorageFn,
15
+ InferStorage,
16
+ NoopCrdtDocFactory,
17
+ } from "@pluv/crdt";
18
+ import type {
19
+ CrdtDocLike,
20
+ Id,
21
+ InferIOInput,
22
+ InferIOOutput,
23
+ IOEventMessage,
24
+ IOLike,
25
+ JsonObject,
26
+ MaybePromise,
27
+ RoomLike,
28
+ UpdateMyPresenceAction,
29
+ } from "@pluv/types";
30
+ import type { Dispatch, FC, ReactNode } from "react";
31
+
32
+ export interface PluvProviderProps {
33
+ children?: ReactNode;
34
+ }
35
+
36
+ type BaseRoomProviderProps<
37
+ TPresence extends Record<string, any>,
38
+ TCrdt extends AbstractCrdtDocFactory<any, any>,
39
+ > = {
40
+ children?: ReactNode;
41
+ initialStorage?: keyof InferStorage<TCrdt> extends never ? never : InferInitialStorageFn<TCrdt>;
42
+ room: string;
43
+ } & (keyof TPresence extends never ? { initialPresence?: never } : { initialPresence: TPresence });
44
+
45
+ export type MockedRoomProviderProps<
46
+ TIO extends IOLike,
47
+ TPresence extends Record<string, any>,
48
+ TCrdt extends AbstractCrdtDocFactory<any, any>,
49
+ TEvents extends PluvRouterEventConfig<TIO, TPresence, InferStorage<TCrdt>> = {},
50
+ > = BaseRoomProviderProps<TPresence, TCrdt> & {
51
+ events?: MockedRoomEvents<MergeEvents<TEvents, TIO>>;
52
+ };
53
+
54
+ export type BroadcastProxy<TIO extends IOLike> = (<TEvent extends keyof InferIOInput<TIO>>(
55
+ event: TEvent,
56
+ data: Id<InferIOInput<TIO>[TEvent]>,
57
+ ) => void) & {
58
+ [event in keyof InferIOInput<TIO>]: (input: Id<InferIOInput<TIO>[event]>) => void;
59
+ };
60
+
61
+ export type EventProxy<TIO extends IOLike> = {
62
+ [event in keyof InferIOOutput<TIO>]: {
63
+ useEvent: (callback: (data: Id<IOEventMessage<TIO, event>>) => void) => void;
64
+ };
65
+ };
66
+
67
+ export type MetadataGetter<TMetadata extends JsonObject> =
68
+ | TMetadata
69
+ | (() => MaybePromise<TMetadata>);
70
+
71
+ export type PluvRoomProviderProps<
72
+ TIO extends IOLike<any, any>,
73
+ TMetadata extends JsonObject,
74
+ TPresence extends Record<string, any>,
75
+ TCrdt extends AbstractCrdtDocFactory<any, any>,
76
+ > = BaseRoomProviderProps<TPresence, TCrdt> & {
77
+ connect?: boolean;
78
+ debug?: boolean | PluvRoomDebug<TIO>;
79
+ onAuthorizationFail?: (error: Error) => void;
80
+ } & (keyof TMetadata extends never
81
+ ? { metadata?: undefined }
82
+ : { metadata: MetadataGetter<TMetadata> });
83
+
84
+ export interface SubscriptionHookOptions<T extends unknown> {
85
+ isEqual?: (a: T, b: T) => boolean;
86
+ }
87
+
88
+ export interface CreateBundle<
89
+ TIO extends IOLike<any>,
90
+ TMetadata extends JsonObject,
91
+ TPresence extends Record<string, any> = {},
92
+ TCrdt extends AbstractCrdtDocFactory<any, any> = NoopCrdtDocFactory,
93
+ TEvents extends PluvRouterEventConfig<TIO, TPresence, InferStorage<TCrdt>> = {},
94
+ > {
95
+ // components
96
+ MockedRoomProvider: FC<MockedRoomProviderProps<TIO, TPresence, TCrdt>>;
97
+ PluvProvider: FC<PluvProviderProps>;
98
+ PluvRoomProvider: FC<PluvRoomProviderProps<TIO, TMetadata, TPresence, TCrdt>>;
99
+
100
+ // proxies
101
+ event: EventProxy<MergeEvents<TEvents, TIO>>;
102
+
103
+ // hooks
104
+ useBroadcast: () => BroadcastProxy<MergeEvents<TEvents, TIO>>;
105
+ useCanRedo: () => boolean;
106
+ useCanUndo: () => boolean;
107
+ useClient: () => PluvClient<TIO, TPresence, TCrdt, TMetadata>;
108
+ useConnection: <T extends unknown = WebSocketConnection>(
109
+ selector?: (connection: WebSocketConnection) => T,
110
+ options?: SubscriptionHookOptions<Id<T>>,
111
+ ) => Id<T>;
112
+ useDoc: () => CrdtDocLike<InferDoc<TCrdt>, InferStorage<TCrdt>>;
113
+ useEvent: <TType extends keyof InferIOOutput<MergeEvents<TEvents, TIO>>>(
114
+ type: TType,
115
+ callback: (data: Id<IOEventMessage<MergeEvents<TEvents, TIO>, TType>>) => void,
116
+ ) => void;
117
+ useMyPresence: <T extends unknown = TPresence>(
118
+ selector?: (myPresence: TPresence) => T,
119
+ options?: SubscriptionHookOptions<Id<T> | null>,
120
+ ) => [myPresence: Id<T>, updateMyPresence: Dispatch<UpdateMyPresenceAction<TPresence>>];
121
+ useMyself: <T extends unknown = UserInfo<TIO, TPresence>>(
122
+ selector?: (myself: Id<UserInfo<TIO, TPresence>>) => T,
123
+ options?: SubscriptionHookOptions<Id<T> | null>,
124
+ ) => Id<T> | null;
125
+ useOther: <T extends unknown = UserInfo<TIO, TPresence>>(
126
+ connectionId: string,
127
+ selector?: (other: UserInfo<TIO, TPresence>) => T,
128
+ options?: SubscriptionHookOptions<T | null>,
129
+ ) => T | null;
130
+ useOthers: <T extends unknown = readonly UserInfo<TIO, TPresence>[]>(
131
+ selector?: (other: readonly Id<UserInfo<TIO, TPresence>>[]) => T,
132
+ options?: SubscriptionHookOptions<T>,
133
+ ) => T;
134
+ useRedo: () => () => void;
135
+ useRoom: () => RoomLike<TIO, InferDoc<TCrdt>, TPresence, InferStorage<TCrdt>, TEvents>;
136
+ useStorage: <
137
+ TKey extends keyof InferStorage<TCrdt>,
138
+ TData extends unknown = InferCrdtJson<InferStorage<TCrdt>[TKey]>,
139
+ >(
140
+ key: TKey,
141
+ selector?: (data: InferCrdtJson<InferStorage<TCrdt>[TKey]>) => TData,
142
+ options?: SubscriptionHookOptions<TData | null>,
143
+ ) => [data: TData | null, sharedType: InferStorage<TCrdt>[TKey] | null];
144
+ useTransact: () => (fn: (storage: InferStorage<TCrdt>) => void, origin?: string) => void;
145
+ useUndo: () => () => void;
146
+ }
147
+
148
+ export type InferBundleRoom<TBundle extends CreateBundle<any, any, any, any, any>> =
149
+ TBundle extends CreateBundle<infer IIO, any, infer IPresence, infer ICrdt, infer IEvents>
150
+ ? RoomLike<IIO, InferDoc<ICrdt>, IPresence, InferStorage<ICrdt>, IEvents>
151
+ : never;
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "@pluv/tsconfig/react-library.json",
3
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
4
+ "exclude": ["node_modules"],
5
+ "compilerOptions": {
6
+ "lib": ["dom", "esnext"]
7
+ }
8
+ }