@assistant-ui/core 0.3.15 → 0.3.16

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 (251) hide show
  1. package/dist/internal.d.ts +7 -2
  2. package/dist/internal.js +8 -3
  3. package/dist/model-context/frame/host.d.ts.map +1 -1
  4. package/dist/model-context/frame/host.js +3 -2
  5. package/dist/model-context/frame/host.js.map +1 -1
  6. package/dist/model-context/frame/provider.d.ts +2 -0
  7. package/dist/model-context/frame/provider.d.ts.map +1 -1
  8. package/dist/model-context/frame/provider.js +88 -25
  9. package/dist/model-context/frame/provider.js.map +1 -1
  10. package/dist/model-context/interactable-composer-metadata.d.ts +1 -1
  11. package/dist/model-context/interactable-composer-metadata.js +1 -1
  12. package/dist/model-context/interactable-composer-metadata.js.map +1 -1
  13. package/dist/model-context/registry.d.ts +2 -2
  14. package/dist/model-context/registry.d.ts.map +1 -1
  15. package/dist/model-context/registry.js +9 -28
  16. package/dist/model-context/registry.js.map +1 -1
  17. package/dist/react/client/InMemoryThreadList.d.ts.map +1 -1
  18. package/dist/react/client/InMemoryThreadList.js +24 -20
  19. package/dist/react/client/InMemoryThreadList.js.map +1 -1
  20. package/dist/react/client/Interactables.d.ts.map +1 -1
  21. package/dist/react/client/Interactables.js +59 -177
  22. package/dist/react/client/Interactables.js.map +1 -1
  23. package/dist/react/client/RemoteThreadList.d.ts.map +1 -1
  24. package/dist/react/client/RemoteThreadList.js +23 -56
  25. package/dist/react/client/RemoteThreadList.js.map +1 -1
  26. package/dist/react/index.d.ts +2 -1
  27. package/dist/react/interactables-legacy/Interactables.d.ts.map +1 -1
  28. package/dist/react/interactables-legacy/Interactables.js +56 -174
  29. package/dist/react/interactables-legacy/Interactables.js.map +1 -1
  30. package/dist/react/interactables-shared/useInteractablePersistenceQueue.d.ts +24 -0
  31. package/dist/react/interactables-shared/useInteractablePersistenceQueue.d.ts.map +1 -0
  32. package/dist/react/interactables-shared/useInteractablePersistenceQueue.js +138 -0
  33. package/dist/react/interactables-shared/useInteractablePersistenceQueue.js.map +1 -0
  34. package/dist/react/primitives/composer/ComposerIf.js.map +1 -1
  35. package/dist/react/primitives/composer/ComposerQueue.d.ts +2 -1
  36. package/dist/react/primitives/composer/ComposerQueue.d.ts.map +1 -1
  37. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  38. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +1 -1
  39. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +1 -1
  40. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  41. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +23 -55
  42. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  43. package/dist/react/runtimes/assistant-transport/runManager.d.ts.map +1 -1
  44. package/dist/react/runtimes/assistant-transport/runManager.js +2 -12
  45. package/dist/react/runtimes/assistant-transport/runManager.js.map +1 -1
  46. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.d.ts +3 -10
  47. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.d.ts.map +1 -1
  48. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js +20 -67
  49. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js.map +1 -1
  50. package/dist/react/runtimes/cloud/createCloudThreadListAdapterCreateFallback.d.ts +12 -0
  51. package/dist/react/runtimes/cloud/createCloudThreadListAdapterCreateFallback.d.ts.map +1 -0
  52. package/dist/react/runtimes/cloud/createCloudThreadListAdapterCreateFallback.js +10 -0
  53. package/dist/react/runtimes/cloud/createCloudThreadListAdapterCreateFallback.js.map +1 -0
  54. package/dist/react/runtimes/cloud/useCloudThreadListRuntime.js.map +1 -1
  55. package/dist/react/runtimes/createMessageConverter.d.ts +2 -1
  56. package/dist/react/runtimes/createMessageConverter.d.ts.map +1 -1
  57. package/dist/react/runtimes/external-message-converter.d.ts +6 -25
  58. package/dist/react/runtimes/external-message-converter.d.ts.map +1 -1
  59. package/dist/react/runtimes/external-message-converter.js +33 -233
  60. package/dist/react/runtimes/external-message-converter.js.map +1 -1
  61. package/dist/runtime/api/composer-runtime.d.ts +1 -1
  62. package/dist/runtime/api/composer-runtime.js.map +1 -1
  63. package/dist/runtime/api/thread-runtime.d.ts +1 -2
  64. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  65. package/dist/runtime/base/base-composer-runtime-core.d.ts +1 -1
  66. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  67. package/dist/runtime/base/base-composer-runtime-core.js +14 -29
  68. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  69. package/dist/runtime/base/base-thread-runtime-core.d.ts +2 -4
  70. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  71. package/dist/runtime/base/base-thread-runtime-core.js +3 -9
  72. package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
  73. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts +1 -1
  74. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  75. package/dist/runtime/base/default-thread-composer-runtime-core.js +1 -1
  76. package/dist/runtime/base/default-thread-composer-runtime-core.js.map +1 -1
  77. package/dist/runtime/interfaces/composer-runtime-core.d.ts +1 -1
  78. package/dist/runtime/interfaces/thread-runtime-core.d.ts +1 -1
  79. package/dist/runtime/internal.d.ts +2 -1
  80. package/dist/runtime/internal.js +2 -1
  81. package/dist/runtime/queue/external-thread-queue-adapter.d.ts +1 -1
  82. package/dist/runtime/queue/message-queue.d.ts.map +1 -1
  83. package/dist/runtime/queue/message-queue.js +1 -1
  84. package/dist/runtime/queue/message-queue.js.map +1 -1
  85. package/dist/runtime/queue/queue-item.d.ts +12 -0
  86. package/dist/runtime/queue/queue-item.d.ts.map +1 -0
  87. package/dist/runtime/queue/queue-item.js +6 -0
  88. package/dist/runtime/queue/queue-item.js.map +1 -0
  89. package/dist/runtime/utils/abortable-thread-load.d.ts +15 -0
  90. package/dist/runtime/utils/abortable-thread-load.d.ts.map +1 -0
  91. package/dist/runtime/utils/abortable-thread-load.js +35 -0
  92. package/dist/runtime/utils/abortable-thread-load.js.map +1 -0
  93. package/dist/runtime/utils/attachment-add-operations.d.ts +18 -0
  94. package/dist/runtime/utils/attachment-add-operations.d.ts.map +1 -0
  95. package/dist/runtime/utils/attachment-add-operations.js +43 -0
  96. package/dist/runtime/utils/attachment-add-operations.js.map +1 -0
  97. package/dist/runtime/utils/auto-status.d.ts +6 -1
  98. package/dist/runtime/utils/auto-status.d.ts.map +1 -1
  99. package/dist/runtime/utils/auto-status.js +7 -1
  100. package/dist/runtime/utils/auto-status.js.map +1 -1
  101. package/dist/runtime/utils/external-message-conversion.d.ts +47 -0
  102. package/dist/runtime/utils/external-message-conversion.d.ts.map +1 -0
  103. package/dist/runtime/utils/external-message-conversion.js +221 -0
  104. package/dist/runtime/utils/external-message-conversion.js.map +1 -0
  105. package/dist/runtime/utils/external-store-message.d.ts +2 -1
  106. package/dist/runtime/utils/external-store-message.d.ts.map +1 -1
  107. package/dist/runtime/utils/external-store-message.js +2 -1
  108. package/dist/runtime/utils/external-store-message.js.map +1 -1
  109. package/dist/runtime/utils/message-repository-session.d.ts +30 -0
  110. package/dist/runtime/utils/message-repository-session.d.ts.map +1 -0
  111. package/dist/runtime/utils/message-repository-session.js +130 -0
  112. package/dist/runtime/utils/message-repository-session.js.map +1 -0
  113. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  114. package/dist/runtime/utils/message-repository.js +3 -4
  115. package/dist/runtime/utils/message-repository.js.map +1 -1
  116. package/dist/runtime/utils/pending-tool-calls.d.ts +20 -0
  117. package/dist/runtime/utils/pending-tool-calls.d.ts.map +1 -0
  118. package/dist/runtime/utils/pending-tool-calls.js +22 -0
  119. package/dist/runtime/utils/pending-tool-calls.js.map +1 -0
  120. package/dist/runtime/utils/thread-message-like.d.ts +2 -1
  121. package/dist/runtime/utils/thread-message-like.d.ts.map +1 -1
  122. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  123. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts +2 -5
  124. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
  125. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js +3 -10
  126. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js.map +1 -1
  127. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +19 -1
  128. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  129. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +122 -20
  130. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  131. package/dist/runtimes/local/local-thread-runtime-core.d.ts +3 -1
  132. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  133. package/dist/runtimes/local/local-thread-runtime-core.js +20 -2
  134. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  135. package/dist/runtimes/remote-thread-list/remote-thread-state.d.ts +12 -1
  136. package/dist/runtimes/remote-thread-list/remote-thread-state.d.ts.map +1 -1
  137. package/dist/runtimes/remote-thread-list/remote-thread-state.js +77 -1
  138. package/dist/runtimes/remote-thread-list/remote-thread-state.js.map +1 -1
  139. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts.map +1 -1
  140. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js +2 -0
  141. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js.map +1 -1
  142. package/dist/store/clients/external-thread.d.ts.map +1 -1
  143. package/dist/store/clients/external-thread.js +74 -99
  144. package/dist/store/clients/external-thread.js.map +1 -1
  145. package/dist/store/clients/single-thread-list.d.ts.map +1 -1
  146. package/dist/store/clients/single-thread-list.js +1 -1
  147. package/dist/store/clients/single-thread-list.js.map +1 -1
  148. package/dist/store/clients/suggestions.d.ts.map +1 -1
  149. package/dist/store/clients/suggestions.js +16 -7
  150. package/dist/store/clients/suggestions.js.map +1 -1
  151. package/dist/store/index.d.ts +4 -2
  152. package/dist/store/scope-registration.d.ts +1 -1
  153. package/dist/store/scopes/composer.d.ts +2 -1
  154. package/dist/store/scopes/composer.d.ts.map +1 -1
  155. package/dist/store/scopes/message.d.ts +1 -1
  156. package/dist/store/scopes/queue-item.d.ts +2 -9
  157. package/dist/store/scopes/queue-item.d.ts.map +1 -1
  158. package/dist/store/scopes/queue-item.js +0 -6
  159. package/dist/types/message.d.ts +7 -3
  160. package/dist/types/message.d.ts.map +1 -1
  161. package/dist/types/message.js.map +1 -1
  162. package/dist/utils/data-url.d.ts +14 -1
  163. package/dist/utils/data-url.d.ts.map +1 -1
  164. package/dist/utils/data-url.js +13 -1
  165. package/dist/utils/data-url.js.map +1 -1
  166. package/dist/utils/invoke-user-callback.d.ts +5 -0
  167. package/dist/utils/invoke-user-callback.d.ts.map +1 -0
  168. package/dist/utils/invoke-user-callback.js +19 -0
  169. package/dist/utils/invoke-user-callback.js.map +1 -0
  170. package/dist/utils/normalizePartStatus.js.map +1 -1
  171. package/package.json +10 -10
  172. package/src/internal.ts +12 -0
  173. package/src/model-context/frame/host.test.ts +91 -7
  174. package/src/model-context/frame/host.ts +7 -2
  175. package/src/model-context/frame/provider.test.ts +267 -12
  176. package/src/model-context/frame/provider.ts +113 -31
  177. package/src/model-context/interactable-composer-metadata.ts +1 -1
  178. package/src/model-context/registry.test.ts +44 -0
  179. package/src/model-context/registry.ts +13 -35
  180. package/src/react/client/InMemoryThreadList.test.tsx +49 -0
  181. package/src/react/client/InMemoryThreadList.ts +7 -1
  182. package/src/react/client/Interactables.ts +19 -178
  183. package/src/react/client/RemoteThreadList.test.ts +96 -0
  184. package/src/react/client/RemoteThreadList.ts +27 -59
  185. package/src/react/interactables-legacy/Interactables.ts +20 -177
  186. package/src/react/interactables-shared/useInteractablePersistenceQueue.test.tsx +161 -0
  187. package/src/react/interactables-shared/useInteractablePersistenceQueue.ts +206 -0
  188. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +26 -56
  189. package/src/react/runtimes/assistant-transport/runManager.ts +4 -17
  190. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.test.tsx +92 -0
  191. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.ts +40 -141
  192. package/src/react/runtimes/cloud/createCloudThreadListAdapterCreateFallback.test.ts +37 -0
  193. package/src/react/runtimes/cloud/createCloudThreadListAdapterCreateFallback.ts +18 -0
  194. package/src/react/runtimes/external-message-converter.test.ts +0 -14
  195. package/src/react/runtimes/external-message-converter.test.tsx +77 -0
  196. package/src/react/runtimes/external-message-converter.ts +58 -481
  197. package/src/runtime/api/composer-runtime.ts +1 -1
  198. package/src/runtime/api/message-runtime.test.ts +28 -1
  199. package/src/runtime/base/base-composer-runtime-core.ts +20 -40
  200. package/src/runtime/base/base-thread-runtime-core.test.ts +16 -0
  201. package/src/runtime/base/base-thread-runtime-core.ts +6 -11
  202. package/src/runtime/base/default-thread-composer-runtime-core.ts +1 -4
  203. package/src/runtime/interfaces/composer-runtime-core.ts +1 -1
  204. package/src/runtime/interfaces/thread-runtime-core.ts +1 -1
  205. package/src/runtime/internal.ts +5 -0
  206. package/src/runtime/queue/external-thread-queue-adapter.ts +1 -1
  207. package/src/runtime/queue/message-queue.ts +1 -4
  208. package/src/runtime/queue/queue-item.ts +10 -0
  209. package/src/runtime/utils/abortable-thread-load.test.ts +87 -0
  210. package/src/runtime/utils/abortable-thread-load.ts +53 -0
  211. package/src/runtime/utils/attachment-add-operations.ts +59 -0
  212. package/src/runtime/utils/auto-status.ts +33 -0
  213. package/src/runtime/utils/external-message-conversion.test.ts +113 -0
  214. package/src/runtime/utils/external-message-conversion.ts +494 -0
  215. package/src/runtime/utils/external-store-message.ts +2 -0
  216. package/src/runtime/utils/message-repository-session.test.ts +195 -0
  217. package/src/runtime/utils/message-repository-session.ts +176 -0
  218. package/src/runtime/utils/message-repository.ts +7 -4
  219. package/src/runtime/utils/pending-tool-calls.test.ts +57 -0
  220. package/src/runtime/utils/pending-tool-calls.ts +35 -0
  221. package/src/runtime/utils/thread-message-like.ts +2 -0
  222. package/src/runtimes/external-store/external-store-thread-list-runtime-core.ts +6 -14
  223. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +175 -28
  224. package/src/runtimes/local/local-thread-runtime-core.test.ts +172 -1
  225. package/src/runtimes/local/local-thread-runtime-core.ts +48 -3
  226. package/src/runtimes/remote-thread-list/remote-thread-state.test.ts +37 -0
  227. package/src/runtimes/remote-thread-list/remote-thread-state.ts +123 -0
  228. package/src/runtimes/tool-invocations/EDGE_CASES.md +17 -0
  229. package/src/runtimes/tool-invocations/ToolInvocationTracker.test.ts +140 -0
  230. package/src/runtimes/tool-invocations/ToolInvocationTracker.ts +3 -0
  231. package/src/store/clients/external-thread.ts +16 -61
  232. package/src/store/clients/single-thread-list.ts +4 -1
  233. package/src/store/clients/suggestions.test.ts +31 -0
  234. package/src/store/clients/suggestions.ts +18 -19
  235. package/src/store/scopes/message.ts +1 -1
  236. package/src/store/scopes/queue-item.ts +2 -9
  237. package/src/tests/MessageRepository.test.ts +77 -0
  238. package/src/tests/RemoteThreadListThreadListRuntimeCore-load-race.test.ts +194 -0
  239. package/src/tests/RemoteThreadListThreadListRuntimeCore-title-race.test.ts +158 -0
  240. package/src/tests/external-store-thread-runtime-core-adapter.test.ts +164 -0
  241. package/src/tests/external-store-thread-runtime-core.test.ts +642 -0
  242. package/src/tests/external-thread-attachments.test.tsx +173 -0
  243. package/src/tests/single-thread-list-item.test.tsx +47 -0
  244. package/src/tests/thread-message-like.test.ts +39 -1
  245. package/src/types/message.ts +8 -3
  246. package/src/utils/data-url.test.ts +52 -1
  247. package/src/utils/data-url.ts +21 -0
  248. package/src/utils/invoke-user-callback.test.ts +82 -0
  249. package/src/utils/invoke-user-callback.ts +31 -0
  250. package/src/utils/normalizePartStatus.ts +1 -1
  251. package/dist/store/scopes/queue-item.js.map +0 -1
@@ -17,8 +17,7 @@ import { toJSONSchema, toPartialJSONSchema } from "assistant-stream";
17
17
  import { ModelContext } from "../../store";
18
18
  import { buildInteractableModelContext } from "./interactable-model-context";
19
19
  import { notifySubscribers as notifyStateSubscribers } from "../../subscribable/subscribable";
20
-
21
- const PERSISTENCE_DEBOUNCE_MS = 500;
20
+ import { useInteractablePersistenceQueue } from "../interactables-shared/useInteractablePersistenceQueue";
22
21
 
23
22
  const useInteractables = (): ClientOutput<"interactables"> => {
24
23
  const [state, setState] = useState<InteractablesState>(() => ({
@@ -48,26 +47,6 @@ const useInteractables = (): ClientOutput<"interactables"> => {
48
47
  const adapterRef = useRef<InteractablePersistenceAdapter | undefined>(
49
48
  undefined,
50
49
  );
51
- const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
52
- undefined,
53
- );
54
- const syncSeqRef = useRef(0);
55
- const latestSyncSeqByIdRef = useRef(new Map<string, number>());
56
- const inFlightPersistenceRef = useRef(0);
57
- const flushResolversRef = useRef<Array<() => void>>([]);
58
- const dirtyIdsRef = useRef(new Set<string>());
59
-
60
- type PersistenceBatch = {
61
- adapter: InteractablePersistenceAdapter;
62
- payload: InteractablePersistedState;
63
- dirtyIds: Set<string>;
64
- seq: number;
65
- };
66
-
67
- const outgoingQueueRef = useRef<PersistenceBatch[]>([]);
68
- const runPersistenceRef = useRef<(batch?: PersistenceBatch) => void>(
69
- () => {},
70
- );
71
50
 
72
51
  const exportState = useCallback((): InteractablePersistedState => {
73
52
  const result: InteractablePersistedState = {};
@@ -77,139 +56,28 @@ const useInteractables = (): ClientOutput<"interactables"> => {
77
56
  return result;
78
57
  }, []);
79
58
 
80
- const takeDirtyBatch = useCallback(
81
- (adapter: InteractablePersistenceAdapter): PersistenceBatch | undefined => {
82
- if (dirtyIdsRef.current.size === 0) return;
83
- const dirtyIds = new Set(dirtyIdsRef.current);
84
- dirtyIdsRef.current.clear();
85
- const seq = ++syncSeqRef.current;
86
- for (const id of dirtyIds) latestSyncSeqByIdRef.current.set(id, seq);
87
- return { adapter, payload: exportState(), dirtyIds, seq };
88
- },
89
- [exportState],
90
- );
91
-
92
- const enqueuePersistence = useCallback(
93
- (adapter: InteractablePersistenceAdapter) => {
94
- const batch = takeDirtyBatch(adapter);
95
- if (!batch) return;
96
- if (inFlightPersistenceRef.current === 0) {
97
- runPersistenceRef.current(batch);
98
- } else {
99
- outgoingQueueRef.current.push(batch);
100
- }
101
- },
102
- [takeDirtyBatch],
103
- );
104
-
105
- const runPersistence = useCallback(
106
- async (batch?: PersistenceBatch) => {
107
- const resolved =
108
- batch ??
109
- (adapterRef.current ? takeDirtyBatch(adapterRef.current) : undefined);
110
- if (!resolved) {
111
- if (inFlightPersistenceRef.current === 0) {
112
- for (const resolve of flushResolversRef.current) resolve();
113
- flushResolversRef.current = [];
114
- }
115
- return;
116
- }
117
-
118
- const { adapter, payload, dirtyIds, seq } = resolved;
119
- inFlightPersistenceRef.current += 1;
120
-
121
- setStateAndRef((prev) => ({
122
- ...prev,
123
- persistence: {
124
- ...prev.persistence,
125
- ...Object.fromEntries(
126
- [...dirtyIds].map((id) => [
127
- id,
128
- { isPending: true, error: undefined },
129
- ]),
130
- ),
131
- },
132
- }));
133
-
134
- try {
135
- await adapter.save(payload);
136
- setStateAndRef((prev) => {
137
- let changed = false;
138
- const persistence = { ...prev.persistence };
139
- for (const id of dirtyIds) {
140
- if (
141
- latestSyncSeqByIdRef.current.get(id) !== seq ||
142
- dirtyIdsRef.current.has(id)
143
- )
144
- continue;
145
- latestSyncSeqByIdRef.current.delete(id);
146
- delete persistence[id];
147
- changed = true;
148
- }
149
- return changed ? { ...prev, persistence } : prev;
150
- });
151
- } catch (e) {
152
- setStateAndRef((prev) => {
153
- let changed = false;
154
- const persistence = { ...prev.persistence };
155
- for (const id of dirtyIds) {
156
- if (
157
- latestSyncSeqByIdRef.current.get(id) !== seq ||
158
- dirtyIdsRef.current.has(id)
159
- )
160
- continue;
161
- latestSyncSeqByIdRef.current.delete(id);
162
- persistence[id] = { isPending: false, error: e };
163
- changed = true;
164
- }
165
- return changed ? { ...prev, persistence } : prev;
166
- });
167
- } finally {
168
- inFlightPersistenceRef.current -= 1;
169
- const next =
170
- outgoingQueueRef.current.shift() ??
171
- (adapterRef.current && dirtyIdsRef.current.size > 0
172
- ? takeDirtyBatch(adapterRef.current)
173
- : undefined);
174
- if (next) {
175
- if (debounceTimerRef.current !== undefined) {
176
- clearTimeout(debounceTimerRef.current);
177
- debounceTimerRef.current = undefined;
178
- }
179
- runPersistenceRef.current(next);
180
- } else if (inFlightPersistenceRef.current === 0) {
181
- for (const resolve of flushResolversRef.current) resolve();
182
- flushResolversRef.current = [];
183
- }
184
- }
59
+ const updatePersistenceStatus = useCallback(
60
+ (
61
+ updater: (
62
+ prev: InteractablesState["persistence"],
63
+ ) => InteractablesState["persistence"],
64
+ ) => {
65
+ setStateAndRef((prev) => {
66
+ const persistence = updater(prev.persistence);
67
+ return persistence === prev.persistence
68
+ ? prev
69
+ : { ...prev, persistence };
70
+ });
185
71
  },
186
- [setStateAndRef, takeDirtyBatch],
72
+ [setStateAndRef],
187
73
  );
188
- runPersistenceRef.current = (nextBatch) => {
189
- void runPersistence(nextBatch);
190
- };
191
74
 
192
- const schedulePersistence = useCallback(
193
- (id: string) => {
194
- if (!adapterRef.current) return;
195
- dirtyIdsRef.current.add(id);
196
- if (debounceTimerRef.current !== undefined) {
197
- clearTimeout(debounceTimerRef.current);
198
- }
199
- debounceTimerRef.current = setTimeout(() => {
200
- debounceTimerRef.current = undefined;
201
- if (inFlightPersistenceRef.current === 0 && adapterRef.current) {
202
- enqueuePersistence(adapterRef.current);
203
- } else {
204
- debounceTimerRef.current = setTimeout(() => {
205
- debounceTimerRef.current = undefined;
206
- if (adapterRef.current) enqueuePersistence(adapterRef.current);
207
- }, PERSISTENCE_DEBOUNCE_MS);
208
- }
209
- }, PERSISTENCE_DEBOUNCE_MS);
210
- },
211
- [enqueuePersistence],
212
- );
75
+ const { flushIfPending, schedulePersistence, flush } =
76
+ useInteractablePersistenceQueue({
77
+ adapterRef,
78
+ snapshot: exportState,
79
+ updatePersistenceStatus,
80
+ });
213
81
 
214
82
  const importState = useCallback(
215
83
  (saved: InteractablePersistedState) => {
@@ -231,31 +99,6 @@ const useInteractables = (): ClientOutput<"interactables"> => {
231
99
  [setStateAndRef],
232
100
  );
233
101
 
234
- const flushIfPending = useCallback(() => {
235
- if (debounceTimerRef.current !== undefined) {
236
- clearTimeout(debounceTimerRef.current);
237
- debounceTimerRef.current = undefined;
238
- }
239
- if (adapterRef.current) enqueuePersistence(adapterRef.current);
240
- }, [enqueuePersistence]);
241
-
242
- const flush = useCallback(async () => {
243
- if (debounceTimerRef.current !== undefined) {
244
- clearTimeout(debounceTimerRef.current);
245
- debounceTimerRef.current = undefined;
246
- }
247
- const hasWork =
248
- inFlightPersistenceRef.current > 0 ||
249
- dirtyIdsRef.current.size > 0 ||
250
- outgoingQueueRef.current.length > 0;
251
- if (!hasWork) return;
252
- const p = new Promise<void>((resolve) => {
253
- flushResolversRef.current.push(resolve);
254
- });
255
- if (adapterRef.current) enqueuePersistence(adapterRef.current);
256
- return p;
257
- }, [enqueuePersistence]);
258
-
259
102
  const setPersistenceAdapter = useCallback(
260
103
  (adapter: InteractablePersistenceAdapter | undefined) => {
261
104
  if (adapterRef.current !== adapter) flushIfPending();
@@ -0,0 +1,161 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, cleanup, renderHook } from "@testing-library/react";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { useInteractablePersistenceQueue } from "./useInteractablePersistenceQueue";
6
+
7
+ type TestState = Record<string, number>;
8
+
9
+ type PersistenceStatus = {
10
+ isPending: boolean;
11
+ error: unknown;
12
+ };
13
+
14
+ type PersistenceStatusMap = Record<string, PersistenceStatus>;
15
+
16
+ const createDeferred = () => {
17
+ let resolve!: () => void;
18
+ let reject!: (reason: unknown) => void;
19
+ const promise = new Promise<void>((res, rej) => {
20
+ resolve = res;
21
+ reject = rej;
22
+ });
23
+ return { promise, resolve, reject };
24
+ };
25
+
26
+ const renderQueue = (save: (state: TestState) => void | Promise<void>) => {
27
+ let snapshot: TestState = {};
28
+ let persistence: PersistenceStatusMap = {};
29
+ const adapterRef = { current: { save } };
30
+ const updatePersistenceStatus = (
31
+ updater: (prev: PersistenceStatusMap) => PersistenceStatusMap,
32
+ ) => {
33
+ persistence = updater(persistence);
34
+ };
35
+ const hook = renderHook(() =>
36
+ useInteractablePersistenceQueue({
37
+ adapterRef,
38
+ snapshot: () => snapshot,
39
+ updatePersistenceStatus,
40
+ }),
41
+ );
42
+
43
+ return {
44
+ ...hook,
45
+ setState(id: string, value: number) {
46
+ snapshot = { ...snapshot, [id]: value };
47
+ },
48
+ removeStatus(id: string) {
49
+ const { [id]: _, ...rest } = persistence;
50
+ persistence = rest;
51
+ },
52
+ getStatus() {
53
+ return persistence;
54
+ },
55
+ };
56
+ };
57
+
58
+ const flushMicrotasks = () => vi.advanceTimersByTimeAsync(0);
59
+
60
+ beforeEach(() => {
61
+ vi.useFakeTimers();
62
+ });
63
+
64
+ afterEach(() => {
65
+ cleanup();
66
+ vi.useRealTimers();
67
+ vi.restoreAllMocks();
68
+ });
69
+
70
+ describe("useInteractablePersistenceQueue", () => {
71
+ it("coalesces dirty marks inside the debounce window into one save", async () => {
72
+ const save = vi.fn<(state: TestState) => void>();
73
+ const queue = renderQueue(save);
74
+
75
+ queue.setState("a", 1);
76
+ act(() => queue.result.current.schedulePersistence("a"));
77
+ await act(() => vi.advanceTimersByTimeAsync(200));
78
+
79
+ queue.setState("b", 2);
80
+ act(() => queue.result.current.schedulePersistence("b"));
81
+ await act(() => vi.advanceTimersByTimeAsync(200));
82
+
83
+ queue.setState("a", 3);
84
+ act(() => queue.result.current.schedulePersistence("a"));
85
+ await act(() => vi.advanceTimersByTimeAsync(499));
86
+
87
+ expect(save).not.toHaveBeenCalled();
88
+
89
+ await act(() => vi.advanceTimersByTimeAsync(1));
90
+
91
+ expect(save).toHaveBeenCalledOnce();
92
+ expect(save).toHaveBeenCalledWith({ a: 3, b: 2 });
93
+ });
94
+
95
+ it("attributes errors by batch sequence and lets a newer save supersede an older error", async () => {
96
+ const first = createDeferred();
97
+ const second = createDeferred();
98
+ const firstError = new Error("first save failed");
99
+ const secondError = new Error("second save failed");
100
+ const save = vi
101
+ .fn<(state: TestState) => Promise<void>>()
102
+ .mockImplementationOnce(() => first.promise)
103
+ .mockImplementationOnce(() => second.promise);
104
+ const queue = renderQueue(save);
105
+
106
+ queue.setState("a", 1);
107
+ queue.setState("b", 1);
108
+ act(() => {
109
+ queue.result.current.schedulePersistence("a");
110
+ queue.result.current.schedulePersistence("b");
111
+ });
112
+ await act(() => vi.advanceTimersByTimeAsync(500));
113
+
114
+ queue.setState("a", 2);
115
+ act(() => queue.result.current.schedulePersistence("a"));
116
+ let flushPromise!: Promise<void>;
117
+ act(() => {
118
+ flushPromise = queue.result.current.flush();
119
+ });
120
+
121
+ first.reject(firstError);
122
+ await act(flushMicrotasks);
123
+
124
+ expect(save).toHaveBeenCalledTimes(2);
125
+ expect(queue.getStatus()).toEqual({
126
+ a: { isPending: true, error: undefined },
127
+ b: { isPending: false, error: firstError },
128
+ });
129
+
130
+ second.reject(secondError);
131
+ await act(flushMicrotasks);
132
+ await flushPromise;
133
+
134
+ expect(queue.getStatus()).toEqual({
135
+ a: { isPending: false, error: secondError },
136
+ b: { isPending: false, error: firstError },
137
+ });
138
+ });
139
+
140
+ it("does not recreate a removed status when an in-flight save rejects", async () => {
141
+ const pending = createDeferred();
142
+ const save = vi
143
+ .fn<(state: TestState) => Promise<void>>()
144
+ .mockImplementationOnce(() => pending.promise);
145
+ const queue = renderQueue(save);
146
+
147
+ queue.setState("removed", 1);
148
+ act(() => queue.result.current.schedulePersistence("removed"));
149
+ await act(() => vi.advanceTimersByTimeAsync(500));
150
+
151
+ expect(queue.getStatus()).toEqual({
152
+ removed: { isPending: true, error: undefined },
153
+ });
154
+
155
+ queue.removeStatus("removed");
156
+ pending.reject(new Error("save failed"));
157
+ await act(flushMicrotasks);
158
+
159
+ expect(queue.getStatus()).toEqual({});
160
+ });
161
+ });
@@ -0,0 +1,206 @@
1
+ import { useCallback, useRef, type RefObject } from "react";
2
+
3
+ const PERSISTENCE_DEBOUNCE_MS = 500;
4
+
5
+ type PersistenceAdapter<State> = {
6
+ save(state: State): void | Promise<void>;
7
+ };
8
+
9
+ type PersistenceStatus = {
10
+ isPending: boolean;
11
+ error: unknown;
12
+ };
13
+
14
+ type PersistenceStatusMap = Record<string, PersistenceStatus>;
15
+
16
+ type PersistenceStatusUpdater = (
17
+ updater: (prev: PersistenceStatusMap) => PersistenceStatusMap,
18
+ ) => void;
19
+
20
+ type UseInteractablePersistenceQueueOptions<State> = {
21
+ adapterRef: RefObject<PersistenceAdapter<State> | undefined>;
22
+ snapshot: () => State;
23
+ updatePersistenceStatus: PersistenceStatusUpdater;
24
+ };
25
+
26
+ export const useInteractablePersistenceQueue = <State>({
27
+ adapterRef,
28
+ snapshot,
29
+ updatePersistenceStatus,
30
+ }: UseInteractablePersistenceQueueOptions<State>) => {
31
+ const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
32
+ undefined,
33
+ );
34
+ const syncSeqRef = useRef(0);
35
+ const latestSyncSeqByIdRef = useRef(new Map<string, number>());
36
+ const inFlightPersistenceRef = useRef(0);
37
+ const flushResolversRef = useRef<Array<() => void>>([]);
38
+ const dirtyIdsRef = useRef(new Set<string>());
39
+
40
+ type PersistenceBatch = {
41
+ adapter: PersistenceAdapter<State>;
42
+ payload: State;
43
+ dirtyIds: Set<string>;
44
+ seq: number;
45
+ };
46
+
47
+ const outgoingQueueRef = useRef<PersistenceBatch[]>([]);
48
+ const runPersistenceRef = useRef<(batch?: PersistenceBatch) => void>(
49
+ () => {},
50
+ );
51
+
52
+ const takeDirtyBatch = useCallback(
53
+ (adapter: PersistenceAdapter<State>): PersistenceBatch | undefined => {
54
+ if (dirtyIdsRef.current.size === 0) return;
55
+ const dirtyIds = new Set(dirtyIdsRef.current);
56
+ dirtyIdsRef.current.clear();
57
+ const seq = ++syncSeqRef.current;
58
+ for (const id of dirtyIds) latestSyncSeqByIdRef.current.set(id, seq);
59
+ return { adapter, payload: snapshot(), dirtyIds, seq };
60
+ },
61
+ [snapshot],
62
+ );
63
+
64
+ const enqueuePersistence = useCallback(
65
+ (adapter: PersistenceAdapter<State>) => {
66
+ const batch = takeDirtyBatch(adapter);
67
+ if (!batch) return;
68
+ if (inFlightPersistenceRef.current === 0) {
69
+ runPersistenceRef.current(batch);
70
+ } else {
71
+ outgoingQueueRef.current.push(batch);
72
+ }
73
+ },
74
+ [takeDirtyBatch],
75
+ );
76
+
77
+ const runPersistence = useCallback(
78
+ async (batch?: PersistenceBatch) => {
79
+ const resolved =
80
+ batch ??
81
+ (adapterRef.current ? takeDirtyBatch(adapterRef.current) : undefined);
82
+ if (!resolved) {
83
+ if (inFlightPersistenceRef.current === 0) {
84
+ for (const resolve of flushResolversRef.current) resolve();
85
+ flushResolversRef.current = [];
86
+ }
87
+ return;
88
+ }
89
+
90
+ const { adapter, payload, dirtyIds, seq } = resolved;
91
+ inFlightPersistenceRef.current += 1;
92
+
93
+ updatePersistenceStatus((prev) => ({
94
+ ...prev,
95
+ ...Object.fromEntries(
96
+ [...dirtyIds].map((id) => [
97
+ id,
98
+ { isPending: true, error: undefined },
99
+ ]),
100
+ ),
101
+ }));
102
+
103
+ const settleBatch = (status: PersistenceStatus | undefined) => {
104
+ const settledIds: string[] = [];
105
+ for (const id of dirtyIds) {
106
+ if (
107
+ latestSyncSeqByIdRef.current.get(id) !== seq ||
108
+ dirtyIdsRef.current.has(id)
109
+ )
110
+ continue;
111
+ latestSyncSeqByIdRef.current.delete(id);
112
+ settledIds.push(id);
113
+ }
114
+ if (settledIds.length === 0) return;
115
+ updatePersistenceStatus((prev) => {
116
+ let changed = false;
117
+ const persistence = { ...prev };
118
+ for (const id of settledIds) {
119
+ if (prev[id] === undefined) continue;
120
+ if (status === undefined) delete persistence[id];
121
+ else persistence[id] = status;
122
+ changed = true;
123
+ }
124
+ return changed ? persistence : prev;
125
+ });
126
+ };
127
+
128
+ try {
129
+ await adapter.save(payload);
130
+ settleBatch(undefined);
131
+ } catch (e) {
132
+ settleBatch({ isPending: false, error: e });
133
+ } finally {
134
+ inFlightPersistenceRef.current -= 1;
135
+ const next =
136
+ outgoingQueueRef.current.shift() ??
137
+ (adapterRef.current && dirtyIdsRef.current.size > 0
138
+ ? takeDirtyBatch(adapterRef.current)
139
+ : undefined);
140
+ if (next) {
141
+ if (debounceTimerRef.current !== undefined) {
142
+ clearTimeout(debounceTimerRef.current);
143
+ debounceTimerRef.current = undefined;
144
+ }
145
+ runPersistenceRef.current(next);
146
+ } else if (inFlightPersistenceRef.current === 0) {
147
+ for (const resolve of flushResolversRef.current) resolve();
148
+ flushResolversRef.current = [];
149
+ }
150
+ }
151
+ },
152
+ [adapterRef, takeDirtyBatch, updatePersistenceStatus],
153
+ );
154
+ runPersistenceRef.current = (nextBatch) => {
155
+ void runPersistence(nextBatch);
156
+ };
157
+
158
+ const flushIfPending = useCallback(() => {
159
+ if (debounceTimerRef.current !== undefined) {
160
+ clearTimeout(debounceTimerRef.current);
161
+ debounceTimerRef.current = undefined;
162
+ }
163
+ if (adapterRef.current) enqueuePersistence(adapterRef.current);
164
+ }, [adapterRef, enqueuePersistence]);
165
+
166
+ const schedulePersistence = useCallback(
167
+ (id: string) => {
168
+ if (!adapterRef.current) return;
169
+ dirtyIdsRef.current.add(id);
170
+ if (debounceTimerRef.current !== undefined) {
171
+ clearTimeout(debounceTimerRef.current);
172
+ }
173
+ debounceTimerRef.current = setTimeout(() => {
174
+ debounceTimerRef.current = undefined;
175
+ if (inFlightPersistenceRef.current === 0 && adapterRef.current) {
176
+ enqueuePersistence(adapterRef.current);
177
+ } else {
178
+ debounceTimerRef.current = setTimeout(() => {
179
+ debounceTimerRef.current = undefined;
180
+ if (adapterRef.current) enqueuePersistence(adapterRef.current);
181
+ }, PERSISTENCE_DEBOUNCE_MS);
182
+ }
183
+ }, PERSISTENCE_DEBOUNCE_MS);
184
+ },
185
+ [adapterRef, enqueuePersistence],
186
+ );
187
+
188
+ const flush = useCallback(async () => {
189
+ if (debounceTimerRef.current !== undefined) {
190
+ clearTimeout(debounceTimerRef.current);
191
+ debounceTimerRef.current = undefined;
192
+ }
193
+ const hasWork =
194
+ inFlightPersistenceRef.current > 0 ||
195
+ dirtyIdsRef.current.size > 0 ||
196
+ outgoingQueueRef.current.length > 0;
197
+ if (!hasWork) return;
198
+ const p = new Promise<void>((resolve) => {
199
+ flushResolversRef.current.push(resolve);
200
+ });
201
+ if (adapterRef.current) enqueuePersistence(adapterRef.current);
202
+ return p;
203
+ }, [adapterRef, enqueuePersistence]);
204
+
205
+ return { flushIfPending, schedulePersistence, flush };
206
+ };