@noya-app/noya-multiplayer-react 0.1.66 → 0.1.67

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.
@@ -14,11 +14,16 @@ const asset1Id = "2717f281-307d-4e9e-aa3b-68d29d4a1723";
14
14
  const asset1DataUrl = `data:application/octet-stream;base64,${Base64.encode(
15
15
  asset1Data
16
16
  )}`;
17
- const asset1 = {
17
+ const asset1: Asset = {
18
18
  id: asset1Id,
19
+ stableId: "stable-id",
19
20
  url: asset1DataUrl,
20
21
  createdAt: new Date().toISOString(),
21
22
  size: asset1Data.byteLength,
23
+ width: 100,
24
+ height: 100,
25
+ userId: "123",
26
+ contentType: "image/png",
22
27
  };
23
28
  const assets: Asset[] = [asset1];
24
29
  const assetMap = {
@@ -61,7 +66,7 @@ it("should import and export the noya manager state", async () => {
61
66
  const initialState: Static<typeof schema> = {
62
67
  name: "John",
63
68
  age: 30,
64
- assetRef: asset1Id,
69
+ assetRef: asset1.stableId,
65
70
  };
66
71
 
67
72
  const noyaManager = new NoyaManager(initialState, {
@@ -107,10 +112,7 @@ it("should import and export the noya manager state", async () => {
107
112
  }
108
113
 
109
114
  expect(newNoyaManager.multiplayerStateManager.optimisticState$.get()).toEqual(
110
- {
111
- ...initialState,
112
- assetRef: firstAsset.id,
113
- }
115
+ initialState
114
116
  );
115
117
 
116
118
  expect(firstAsset).toMatchObject({
package/src/hooks.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  "use client";
2
2
 
3
- import { GetAtPath } from "@noya-app/observable";
4
3
  import {
5
4
  ActionHandler,
6
5
  NoyaManager,
@@ -26,30 +25,6 @@ import { StateInspectorOptions } from "./inspector/StateInspector";
26
25
  import { useStateInspector } from "./inspector/useStateInspector";
27
26
  import { useObservable } from "./useObservable";
28
27
 
29
- export function useSyncStateManager<S, M>(stateManager: StateManager<S, M>): S;
30
- export function useSyncStateManager<S, M, P extends string>(
31
- stateManager: StateManager<S, M>,
32
- path: P
33
- ): GetAtPath<S, P>;
34
- export function useSyncStateManager<S, M>(
35
- stateManager: StateManager<S, M>,
36
- path?: string
37
- ) {
38
- const getSnapshot = useCallback(
39
- () =>
40
- typeof path === "string"
41
- ? stateManager.getState(path)
42
- : stateManager.getState(),
43
- [stateManager, path]
44
- );
45
-
46
- return useSyncExternalStore(
47
- stateManager.addListener,
48
- getSnapshot,
49
- getSnapshot
50
- );
51
- }
52
-
53
28
  export function useManagedState<S, M = void>(
54
29
  createInitialState: () => S,
55
30
  options?: StateManagerOptions<S, M>
@@ -58,7 +33,15 @@ export function useManagedState<S, M = void>(
58
33
  () => new StateManager<S, M>(createInitialState(), options)
59
34
  );
60
35
 
61
- const state = useSyncStateManager(stateManager);
36
+ const getSnapshot = useCallback((): S => {
37
+ return stateManager.getState();
38
+ }, [stateManager]);
39
+
40
+ const state = useSyncExternalStore(
41
+ stateManager.addListener,
42
+ getSnapshot,
43
+ getSnapshot
44
+ );
62
45
 
63
46
  const setState = useCallback(
64
47
  (
@@ -130,30 +113,19 @@ export type UseMultiplayerStateOptions<
130
113
  inspector?: boolean | StateInspectorOptions;
131
114
  };
132
115
 
133
- export function useMultiplayerState<
116
+ // Bind an existing NoyaManager to React and wire up extras, inspector, and sync
117
+ export function useBindNoyaManager<
134
118
  S,
135
119
  M extends object = object,
136
120
  E extends object = object,
137
121
  MenuT extends string = string,
138
122
  >(
139
- initialState: S | (() => S),
140
- options?: UseMultiplayerStateOptions<S, M, E, MenuT>
123
+ noyaManager: NoyaManager<S, M, E, MenuT>,
124
+ options?: Pick<
125
+ UseMultiplayerStateOptions<S, M, E, MenuT>,
126
+ "inspector" | "sync"
127
+ >
141
128
  ) {
142
- const {
143
- sync = stubSync<
144
- unknown,
145
- object,
146
- object,
147
- string
148
- >() as unknown as SyncAdapter<S, M, E, MenuT>,
149
- inspector,
150
- ...rest
151
- } = options ?? {};
152
-
153
- const [noyaManager] = useState(
154
- () => new NoyaManager<S, M, E, MenuT>(initialState, rest)
155
- );
156
-
157
129
  const state = useObservable(
158
130
  noyaManager.multiplayerStateManager.optimisticState$
159
131
  );
@@ -197,7 +169,7 @@ export function useMultiplayerState<
197
169
  };
198
170
  }, [noyaManager, assets]);
199
171
 
200
- const syncRef = useRef(sync);
172
+ const syncRef = useRef(options?.sync);
201
173
 
202
174
  useEffect(() => {
203
175
  if (syncRef.current) {
@@ -209,13 +181,40 @@ export function useMultiplayerState<
209
181
 
210
182
  useStateInspector<S, M, E, MenuT>({
211
183
  noyaManager,
212
- disabled: !inspector,
213
- ...(typeof inspector === "object" && inspector),
184
+ disabled: !options?.inspector,
185
+ ...(typeof options?.inspector === "object" && options?.inspector),
214
186
  });
215
187
 
216
188
  return [state, noyaManager.multiplayerStateManager.setState, extras] as const;
217
189
  }
218
190
 
191
+ export function useMultiplayerState<
192
+ S,
193
+ M extends object = object,
194
+ E extends object = object,
195
+ MenuT extends string = string,
196
+ >(
197
+ initialState: S | (() => S),
198
+ options?: UseMultiplayerStateOptions<S, M, E, MenuT>
199
+ ) {
200
+ const {
201
+ sync = stubSync<
202
+ unknown,
203
+ object,
204
+ object,
205
+ string
206
+ >() as unknown as SyncAdapter<S, M, E, MenuT>,
207
+ inspector,
208
+ ...rest
209
+ } = options ?? {};
210
+
211
+ const [noyaManager] = useState(
212
+ () => new NoyaManager<S, M, E, MenuT>(initialState, rest)
213
+ );
214
+
215
+ return useBindNoyaManager<S, M, E, MenuT>(noyaManager, { sync, inspector });
216
+ }
217
+
219
218
  function useErrorOverlay<MenuT extends string = string>(
220
219
  noyaManager: NoyaManager<any, any, any, MenuT>
221
220
  ) {
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from "./inspector/StateInspector";
7
7
  export * from "./inspector/useStateInspector";
8
8
  export * from "./noyaApp";
9
9
  export * from "./NoyaStateContext";
10
+ export * from "./singleton";
10
11
  export * from "./useObservable";
11
12
  export { WebSocketConnection } from "./WebSocketConnection";
12
13
  export type { ConnectionEvent, ConnectionOptions } from "./WebSocketConnection";
@@ -2,9 +2,9 @@
2
2
  "use client";
3
3
 
4
4
  import { Input, OutputTransform } from "@noya-app/noya-schemas";
5
- import { Base64, uuid } from "@noya-app/noya-utils";
5
+ import { Base64, memoize, uuid } from "@noya-app/noya-utils";
6
6
  import { downloadBlob, memoGeneric } from "@noya-app/react-utils";
7
- import { NoyaManager } from "@noya-app/state-manager";
7
+ import { NoyaAsset, NoyaManager } from "@noya-app/state-manager";
8
8
  import React, {
9
9
  CSSProperties,
10
10
  ComponentPropsWithoutRef,
@@ -432,7 +432,11 @@ export const StateInspector = memoGeneric(function StateInspector<
432
432
  <StateInspectorDisclosureRowInner>
433
433
  {assets.map((asset) => (
434
434
  <StateInspectorRow key={asset.id} colorScheme={colorScheme}>
435
- <ObjectInspector name={asset.id} data={asset} theme={theme} />
435
+ <ObjectInspector
436
+ name={asset.id}
437
+ data={truncateAsset(asset)}
438
+ theme={theme}
439
+ />
436
440
  <StateInspectorButton
437
441
  theme={theme}
438
442
  onClick={() => assetManager.delete(asset.id)}
@@ -462,10 +466,8 @@ export const StateInspector = memoGeneric(function StateInspector<
462
466
  "Are you sure you want to delete all resources?"
463
467
  );
464
468
  if (!ok) return;
465
- await Promise.all(
466
- resources.map((resource) =>
467
- resourceManager.deleteResource({ id: resource.id })
468
- )
469
+ await resourceManager.deleteResource(
470
+ resources.map((resource) => ({ id: resource.id }))
469
471
  );
470
472
  }}
471
473
  >
@@ -706,3 +708,10 @@ export const StateInspector = memoGeneric(function StateInspector<
706
708
  </div>
707
709
  );
708
710
  });
711
+
712
+ const truncateAsset = memoize((asset: NoyaAsset) => {
713
+ if (asset.url.startsWith("data:") || asset.url.startsWith("blob:")) {
714
+ return { ...asset, url: ellipsis(asset.url, 40) };
715
+ }
716
+ return asset;
717
+ });
@@ -96,28 +96,22 @@ export async function importAll(
96
96
  }
97
97
 
98
98
  if (assets.length) {
99
- // Delete all existing assets
100
99
  await Promise.all(
101
100
  noyaManager.assetManager.assets$
102
101
  .get()
103
- .map((asset) => noyaManager.assetManager.delete(asset.id))
102
+ .map((asset) => noyaManager.assetManager.delete(asset.stableId))
104
103
  );
105
104
 
106
- // Add new assets
107
- const idPairs = await Promise.all(
108
- assets.map(async (asset): Promise<[string, string]> => {
109
- const newAsset = await noyaManager.assetManager.create({
105
+ // Add new assets, preserving stableIds
106
+ await Promise.all(
107
+ assets.map(async (asset) => {
108
+ await noyaManager.assetManager.create({
110
109
  data: assetMap[asset.id],
111
110
  contentType: asset.contentType,
111
+ stableId: asset.stableId,
112
112
  });
113
-
114
- return [asset.id, newAsset.id];
115
113
  })
116
114
  );
117
-
118
- const replacementMap = Object.fromEntries(idPairs);
119
-
120
- state = replaceDeep(state, replacementMap);
121
115
  }
122
116
 
123
117
  // if (schema) {
@@ -17,6 +17,19 @@ function createShadowRootElement() {
17
17
  return { host, container };
18
18
  }
19
19
 
20
+ export function createStateInspectorRoot() {
21
+ const { host, container } = createShadowRootElement();
22
+ const root = createRoot(container);
23
+ document.body.appendChild(host);
24
+
25
+ return {
26
+ root,
27
+ unmount: () => {
28
+ document.body.removeChild(host);
29
+ },
30
+ };
31
+ }
32
+
20
33
  export function useStateInspector<
21
34
  S,
22
35
  M extends object,
@@ -36,16 +49,11 @@ export function useStateInspector<
36
49
  useIsomorphicLayoutEffect(() => {
37
50
  if (disabled) return;
38
51
 
39
- const { host, container } = createShadowRootElement();
40
- const root = createRoot(container);
41
-
42
- document.body.appendChild(host);
52
+ const { root, unmount } = createStateInspectorRoot();
43
53
 
44
54
  setRoot(root);
45
55
 
46
- return () => {
47
- document.body.removeChild(host);
48
- };
56
+ return unmount;
49
57
  }, [disabled]);
50
58
 
51
59
  useIsomorphicLayoutEffect(() => {
package/src/noyaApp.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  createOrCastValue,
6
6
  isEmbeddedApp,
7
7
  localStorageSync,
8
+ NoyaManager,
8
9
  parentFrameSync,
9
10
  Static,
10
11
  stubSync,
@@ -14,7 +15,7 @@ import {
14
15
  webSocketSync,
15
16
  } from "@noya-app/state-manager";
16
17
  import { useMemo, useState } from "react";
17
- import { useMultiplayerState, UseMultiplayerStateOptions } from "./hooks";
18
+ import { useBindNoyaManager, UseMultiplayerStateOptions } from "./hooks";
18
19
  import { StateInspectorOptions } from "./inspector/StateInspector";
19
20
 
20
21
  export type AppViewType = "editable" | "readOnly" | "preview";
@@ -96,6 +97,27 @@ export function getAppData<State>(
96
97
  return appData as AppData<State>;
97
98
  }
98
99
 
100
+ export function getSyncAdapter<
101
+ S,
102
+ M extends object = object,
103
+ E extends object = object,
104
+ MenuT extends string = string,
105
+ >(params?: {
106
+ multiplayerUrl?: string;
107
+ offlineStorageKey?: string;
108
+ debug?: boolean;
109
+ }): SyncAdapter<S, M, E, MenuT> {
110
+ const { multiplayerUrl, offlineStorageKey, debug } = params ?? {};
111
+
112
+ return isEmbeddedApp()
113
+ ? parentFrameSync<S, M, E, MenuT>({ debug })
114
+ : multiplayerUrl
115
+ ? webSocketSync<S, M, E, MenuT>({ url: multiplayerUrl, debug })
116
+ : offlineStorageKey
117
+ ? localStorageSync<S, M, E, MenuT>({ key: offlineStorageKey })
118
+ : stubSync<S, M, E, MenuT>();
119
+ }
120
+
99
121
  export type UseNoyaStateOptions<
100
122
  S,
101
123
  M extends object = object,
@@ -111,9 +133,9 @@ export type UseNoyaStateResult<
111
133
  E extends object = object,
112
134
  MenuT extends string = string,
113
135
  > = [
114
- ReturnType<typeof useMultiplayerState<S, M, E, MenuT>>[0],
115
- ReturnType<typeof useMultiplayerState<S, M, E, MenuT>>[1],
116
- ReturnType<typeof useMultiplayerState<S, M, E, MenuT>>[2] & {
136
+ ReturnType<typeof useBindNoyaManager<S, M, E, MenuT>>[0],
137
+ ReturnType<typeof useBindNoyaManager<S, M, E, MenuT>>[1],
138
+ ReturnType<typeof useBindNoyaManager<S, M, E, MenuT>>[2] & {
117
139
  theme: AppTheme;
118
140
  viewType: AppViewType;
119
141
  },
@@ -128,7 +150,7 @@ export type UseNoyaStateResult<
128
150
  * 2. Pass an initial state and a schema: `useNoyaState(initialState, { schema })`
129
151
  * 3. Pass only a schema, and a conforming initial state is created automatically: `useNoyaState({ schema })`
130
152
  */
131
- export function useNoyaState<
153
+ export function useNoyaStateInternal<
132
154
  S,
133
155
  M extends object = object,
134
156
  E extends object = object,
@@ -178,20 +200,18 @@ export function useNoyaState<
178
200
  });
179
201
 
180
202
  const sync = useMemo((): SyncAdapter<S, M, E, MenuT> => {
181
- return isEmbeddedApp()
182
- ? parentFrameSync<S, M, E, MenuT>({ debug: options?.debug })
183
- : multiplayerUrl
184
- ? webSocketSync<S, M, E, MenuT>({
185
- url: multiplayerUrl,
186
- debug: options?.debug,
187
- })
188
- : options?.offlineStorageKey
189
- ? localStorageSync<S, M, E, MenuT>({ key: options.offlineStorageKey })
190
- : stubSync<S, M, E, MenuT>();
203
+ return getSyncAdapter<S, M, E, MenuT>({
204
+ multiplayerUrl,
205
+ offlineStorageKey: options?.offlineStorageKey,
206
+ debug: options?.debug,
207
+ });
191
208
  }, [multiplayerUrl, options?.offlineStorageKey, options?.debug]);
192
209
 
193
- const result = useMultiplayerState(noyaAppInitialState as S, {
194
- ...options,
210
+ const [noyaManager] = useState(
211
+ () => new NoyaManager<S, M, E, MenuT>(noyaAppInitialState as S, options)
212
+ );
213
+
214
+ const result = useBindNoyaManager<S, M, E, MenuT>(noyaManager, {
195
215
  inspector: options?.inspector ?? inspector,
196
216
  sync: options?.sync ?? sync,
197
217
  });
@@ -0,0 +1,140 @@
1
+ "use client";
2
+
3
+ import {
4
+ NoyaManager,
5
+ NoyaManagerOptions,
6
+ type SyncAdapter,
7
+ } from "@noya-app/state-manager";
8
+ import React from "react";
9
+ import type { StateInspectorOptions } from "./inspector/StateInspector";
10
+ import { StateInspector } from "./inspector/StateInspector";
11
+ import { createStateInspectorRoot } from "./inspector/useStateInspector";
12
+ import {
13
+ type AppTheme,
14
+ type AppViewType,
15
+ getAppData,
16
+ getSyncAdapter,
17
+ } from "./noyaApp";
18
+
19
+ let noyaManagerSingleton: NoyaManager<any, any, any, any> | undefined;
20
+ let noyaSingletonBindings: AnyNoyaSingletonBindings | undefined;
21
+
22
+ export type AnyNoyaSingletonBindings = {
23
+ sync?: SyncAdapter<any, any, any, any>;
24
+ inspector?: boolean | StateInspectorOptions;
25
+ theme: AppTheme;
26
+ viewType: AppViewType;
27
+ };
28
+
29
+ export type AnyNoyaSingletonOptions<
30
+ S,
31
+ M extends object,
32
+ E extends object,
33
+ MenuT extends string,
34
+ > = NoyaManagerOptions<S, M> & {
35
+ initialState?: S | (() => S);
36
+ sync?: SyncAdapter<S, M, E, MenuT>;
37
+ inspector?: boolean | StateInspectorOptions;
38
+ theme?: AppTheme;
39
+ viewType?: AppViewType;
40
+ offlineStorageKey?: string;
41
+ multiplayerUrl?: string;
42
+ };
43
+
44
+ export function initializeNoyaSingleton<
45
+ S,
46
+ M extends object,
47
+ E extends object,
48
+ MenuT extends string,
49
+ >(options?: AnyNoyaSingletonOptions<S, M, E, MenuT>) {
50
+ if (noyaManagerSingleton) {
51
+ throw new Error(
52
+ "Noya has already been initialized. Call `useNoyaState` instead."
53
+ );
54
+ }
55
+
56
+ const {
57
+ initialState,
58
+ sync,
59
+ inspector,
60
+ theme,
61
+ viewType,
62
+ offlineStorageKey,
63
+ multiplayerUrl,
64
+ ...managerOptions
65
+ } = (options ?? {}) as AnyNoyaSingletonOptions<S, M, E, MenuT>;
66
+
67
+ const appData = getAppData<S>(
68
+ (initialState ?? null) as S | (() => S),
69
+ managerOptions?.schema,
70
+ { overrideExistingState: managerOptions?.overrideExistingState }
71
+ );
72
+
73
+ noyaManagerSingleton = new NoyaManager<S, M, E, MenuT>(
74
+ appData.initialState as S,
75
+ managerOptions
76
+ );
77
+
78
+ const resolvedSync =
79
+ sync ??
80
+ getSyncAdapter<S, M, E, MenuT>({
81
+ multiplayerUrl: multiplayerUrl ?? appData.multiplayerUrl,
82
+ offlineStorageKey,
83
+ debug: managerOptions?.debug,
84
+ });
85
+
86
+ // Activate sync immediately for the singleton
87
+ try {
88
+ resolvedSync({ noyaManager: noyaManagerSingleton });
89
+ } catch (error) {
90
+ console.warn("Failed to activate sync", error);
91
+ }
92
+
93
+ const resolvedInspector = inspector ?? appData.inspector;
94
+ const resolvedTheme = theme ?? appData.theme ?? "light";
95
+ const resolvedViewType = viewType ?? appData.viewType ?? "editable";
96
+
97
+ if (resolvedInspector) {
98
+ const { root } = createStateInspectorRoot();
99
+
100
+ root.render(
101
+ <StateInspector
102
+ noyaManager={noyaManagerSingleton}
103
+ colorScheme={
104
+ resolvedInspector === true
105
+ ? resolvedTheme
106
+ : resolvedInspector.colorScheme
107
+ }
108
+ anchor={
109
+ resolvedInspector === true ? "bottom right" : resolvedInspector.anchor
110
+ }
111
+ />
112
+ );
113
+ }
114
+
115
+ noyaSingletonBindings = {
116
+ sync: resolvedSync,
117
+ inspector: resolvedInspector,
118
+ theme: resolvedTheme,
119
+ viewType: resolvedViewType,
120
+ };
121
+ }
122
+
123
+ export function getNoyaManagerSingleton() {
124
+ if (!noyaManagerSingleton || !noyaSingletonBindings) {
125
+ throw new Error(
126
+ "Noya has not been initialized. Call initializeNoya once before using Noya hooks."
127
+ );
128
+ }
129
+
130
+ return noyaManagerSingleton;
131
+ }
132
+
133
+ export function getNoyaSingletonBindings() {
134
+ return noyaSingletonBindings;
135
+ }
136
+
137
+ export function deleteNoyaSingleton() {
138
+ noyaManagerSingleton = undefined;
139
+ noyaSingletonBindings = undefined;
140
+ }