@buoy-gg/jotai 7.0.16 → 7.0.18

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 (34) hide show
  1. package/lib/commonjs/index.js +1 -0
  2. package/lib/commonjs/jotai/components/JotaiAtomBrowser.js +11 -3
  3. package/lib/commonjs/jotai/components/JotaiAtomDetailContent.js +4 -3
  4. package/lib/commonjs/jotai/components/JotaiModal.js +35 -4
  5. package/lib/commonjs/jotai/sync/jotaiSyncAdapter.js +139 -5
  6. package/lib/commonjs/jotai/utils/jotaiStateStore.js +73 -7
  7. package/lib/commonjs/jotai/utils/snapshotProvider.js +233 -0
  8. package/lib/commonjs/jotai/utils/watchAtoms.js +5 -3
  9. package/lib/module/index.js +3 -0
  10. package/lib/module/jotai/components/JotaiAtomBrowser.js +11 -3
  11. package/lib/module/jotai/components/JotaiAtomDetailContent.js +4 -2
  12. package/lib/module/jotai/components/JotaiModal.js +36 -5
  13. package/lib/module/jotai/sync/jotaiSyncAdapter.js +138 -4
  14. package/lib/module/jotai/utils/jotaiStateStore.js +73 -7
  15. package/lib/module/jotai/utils/snapshotProvider.js +232 -0
  16. package/lib/module/jotai/utils/watchAtoms.js +5 -3
  17. package/lib/typescript/index.d.ts +2 -1
  18. package/lib/typescript/index.d.ts.map +1 -1
  19. package/lib/typescript/jotai/components/JotaiAtomBrowser.d.ts +3 -1
  20. package/lib/typescript/jotai/components/JotaiAtomBrowser.d.ts.map +1 -1
  21. package/lib/typescript/jotai/components/JotaiAtomDetailContent.d.ts +7 -2
  22. package/lib/typescript/jotai/components/JotaiAtomDetailContent.d.ts.map +1 -1
  23. package/lib/typescript/jotai/components/JotaiModal.d.ts +1 -1
  24. package/lib/typescript/jotai/components/JotaiModal.d.ts.map +1 -1
  25. package/lib/typescript/jotai/sync/jotaiSyncAdapter.d.ts +54 -2
  26. package/lib/typescript/jotai/sync/jotaiSyncAdapter.d.ts.map +1 -1
  27. package/lib/typescript/jotai/types/index.d.ts +28 -0
  28. package/lib/typescript/jotai/types/index.d.ts.map +1 -1
  29. package/lib/typescript/jotai/utils/jotaiStateStore.d.ts +10 -0
  30. package/lib/typescript/jotai/utils/jotaiStateStore.d.ts.map +1 -1
  31. package/lib/typescript/jotai/utils/snapshotProvider.d.ts +2 -0
  32. package/lib/typescript/jotai/utils/snapshotProvider.d.ts.map +1 -0
  33. package/lib/typescript/jotai/utils/watchAtoms.d.ts.map +1 -1
  34. package/package.json +7 -4
@@ -93,6 +93,7 @@ Object.defineProperty(exports, "watchDefaultStoreAtoms", {
93
93
  return _watchAtoms.watchDefaultStoreAtoms;
94
94
  }
95
95
  });
96
+ require("./jotai/utils/snapshotProvider");
96
97
  var _preset = require("./preset");
97
98
  var _watchAtoms = require("./jotai/utils/watchAtoms");
98
99
  var _useJotaiAtomChanges = require("./jotai/hooks/useJotaiAtomChanges");
@@ -20,6 +20,9 @@ var _jsxRuntime = require("react/jsx-runtime");
20
20
  function getValuePreview(atom) {
21
21
  try {
22
22
  const value = atom.getValue();
23
+ if (value && typeof value === "object" && value.__buoyValueOnDevice === true) {
24
+ return "on device";
25
+ }
23
26
  if (value === undefined) return "undefined";
24
27
  if (value === null) return "null";
25
28
  if (Array.isArray(value)) {
@@ -125,7 +128,8 @@ function EmptyBrowserState() {
125
128
  function JotaiAtomBrowser({
126
129
  atoms,
127
130
  searchQuery,
128
- onViewHistory
131
+ onViewHistory,
132
+ onAtomExpand
129
133
  }) {
130
134
  const [expandedAtom, setExpandedAtom] = (0, _react.useState)(null);
131
135
  const filteredAtoms = (0, _react.useMemo)(() => {
@@ -134,8 +138,12 @@ function JotaiAtomBrowser({
134
138
  return atoms.filter(a => a.label.toLowerCase().includes(search));
135
139
  }, [atoms, searchQuery]);
136
140
  const handleAtomPress = (0, _react.useCallback)(atom => {
137
- setExpandedAtom(prev => prev === atom.label ? null : atom.label);
138
- }, []);
141
+ setExpandedAtom(prev => {
142
+ const next = prev === atom.label ? null : atom.label;
143
+ if (next) onAtomExpand?.(atom.label);
144
+ return next;
145
+ });
146
+ }, [onAtomExpand]);
139
147
  if (filteredAtoms.length === 0 && !searchQuery) {
140
148
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(EmptyBrowserState, {});
141
149
  }
@@ -25,7 +25,6 @@ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r
25
25
  */
26
26
 
27
27
  // ---- View Toggle ----
28
-
29
28
  const VIEW_CONFIGS = [{
30
29
  key: "change",
31
30
  label: "CHANGE",
@@ -277,9 +276,11 @@ function JotaiAtomDetailContent({
277
276
  changes,
278
277
  selectedIndex,
279
278
  onIndexChange,
280
- disableInternalFooter = false
279
+ disableInternalFooter = false,
280
+ initialView = "change"
281
281
  }) {
282
- const [activeView, setActiveView] = (0, _react.useState)("change");
282
+ // View state (initialView seeds it once; taps own it afterwards)
283
+ const [activeView, setActiveView] = (0, _react.useState)(initialView);
283
284
  const [diffMode, setDiffMode] = (0, _react.useState)("tree");
284
285
  const totalChanges = changes.length;
285
286
  const atomColor = _jotaiStateStore.jotaiStateStore.getAtomColor(change.atomLabel);
@@ -47,11 +47,16 @@ function JotaiModal({
47
47
  onBack,
48
48
  onMinimize,
49
49
  enableSharedModalDimensions = false,
50
- inline = false
50
+ inline = false,
51
+ initialTab,
52
+ initialSelectedChangeId,
53
+ initialDetailView,
54
+ onChangeSelect,
55
+ onAtomExpand
51
56
  }) {
52
57
  const isPro = (0, _license.useIsPro)();
53
58
  const [showProModal, setShowProModal] = (0, _react.useState)(false);
54
- const [activeTab, setActiveTab] = (0, _react.useState)("atoms");
59
+ const [activeTab, setActiveTab] = (0, _react.useState)(initialTab ?? "atoms");
55
60
  const [selectedHistoryAtom, setSelectedHistoryAtom] = (0, _react.useState)(null);
56
61
  const [showFilters, setShowFilters] = (0, _react.useState)(false);
57
62
  const [ignoredPatterns, setIgnoredPatterns] = (0, _react.useState)(new Set());
@@ -103,6 +108,30 @@ function JotaiModal({
103
108
  }, [selectedChangeId, activeChanges]);
104
109
  const selectedChange = selectedChangeIndex !== null ? activeChanges[selectedChangeIndex] : null;
105
110
 
111
+ // Notify the host on selection (keyed by change id so re-renders with the
112
+ // same selection don't re-fire; remote mirrors fetch atom values here).
113
+ const lastNotifiedSelectionRef = (0, _react.useRef)(null);
114
+ (0, _react.useEffect)(() => {
115
+ if (!onChangeSelect) return;
116
+ const id = selectedChange?.id ?? null;
117
+ if (id === null || id === lastNotifiedSelectionRef.current) return;
118
+ lastNotifiedSelectionRef.current = id;
119
+ onChangeSelect(selectedChange);
120
+ }, [selectedChange, onChangeSelect]);
121
+
122
+ // Deep link into a change's detail (see initialSelectedChangeId). Applied
123
+ // at most once per id, so the list re-rendering — or the user pressing Back
124
+ // — never re-opens it.
125
+ const appliedInitialSelectionRef = (0, _react.useRef)(null);
126
+ (0, _react.useEffect)(() => {
127
+ if (!initialSelectedChangeId) return;
128
+ if (appliedInitialSelectionRef.current === initialSelectedChangeId) return;
129
+ const exists = activeChanges.some(c => c.id === initialSelectedChangeId);
130
+ if (!exists) return;
131
+ appliedInitialSelectionRef.current = initialSelectedChangeId;
132
+ setSelectedChangeId(initialSelectedChangeId);
133
+ }, [initialSelectedChangeId, activeChanges]);
134
+
106
135
  // Search state
107
136
  const [searchText, setSearchText] = (0, _react.useState)("");
108
137
  const [isSearchActive, setIsSearchActive] = (0, _react.useState)(false);
@@ -343,7 +372,8 @@ function JotaiModal({
343
372
  changes: activeChanges,
344
373
  selectedIndex: selectedChangeIndex,
345
374
  onIndexChange: handleIndexChange,
346
- disableInternalFooter: true
375
+ disableInternalFooter: true,
376
+ initialView: initialDetailView
347
377
  });
348
378
  }
349
379
  if (selectedHistoryAtom) {
@@ -376,7 +406,8 @@ function JotaiModal({
376
406
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_JotaiAtomBrowser.JotaiAtomBrowser, {
377
407
  atoms: displayedAtoms,
378
408
  searchQuery: searchText,
379
- onViewHistory: handleViewHistory
409
+ onViewHistory: handleViewHistory,
410
+ onAtomExpand: onAtomExpand
380
411
  });
381
412
  }
382
413
 
@@ -3,8 +3,62 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.jotaiSyncAdapter = void 0;
6
+ exports.jotaiSyncAdapter = exports.VALUE_ON_DEVICE = void 0;
7
7
  var _jotaiStateStore = require("../utils/jotaiStateStore");
8
+ /**
9
+ * Sentinel used in WIRE-FORM snapshots in place of raw prevValue/nextValue.
10
+ * Same hazard as redux/zustand: up to 200 changes × 2 values on every
11
+ * snapshot. Dashboards fetch values via `getChangeDetail`.
12
+ */
13
+ const VALUE_ON_DEVICE = exports.VALUE_ON_DEVICE = Object.freeze({
14
+ __buoyValueOnDevice: true,
15
+ note: "Atom values stay on the device — fetched on demand via getChangeDetail / getAtomValue."
16
+ });
17
+ const MAX_CHANGE_DETAIL_BYTES = 8 * 1024 * 1024;
18
+ const MAX_WIRE_PAYLOAD_BYTES = 16 * 1024;
19
+ function jsonLongerThan(value, limit) {
20
+ let bytes = 0;
21
+ const stack = [value];
22
+ const seen = new Set();
23
+ while (stack.length > 0) {
24
+ if (bytes > limit) return true;
25
+ const v = stack.pop();
26
+ if (v === null || v === undefined) bytes += 4;else if (typeof v === "string") bytes += v.length + 2;else if (typeof v === "number") bytes += 8;else if (typeof v === "boolean") bytes += 5;else if (typeof v === "object") {
27
+ if (seen.has(v)) continue;
28
+ seen.add(v);
29
+ if (Array.isArray(v)) {
30
+ bytes += 2 + v.length;
31
+ for (let i = 0; i < v.length; i++) stack.push(v[i]);
32
+ } else {
33
+ bytes += 2;
34
+ for (const k in v) {
35
+ bytes += k.length + 4;
36
+ stack.push(v[k]);
37
+ }
38
+ }
39
+ } else bytes += 4;
40
+ }
41
+ return bytes > limit;
42
+ }
43
+ function toWireChange(change) {
44
+ return {
45
+ ...change,
46
+ prevValue: VALUE_ON_DEVICE,
47
+ nextValue: VALUE_ON_DEVICE
48
+ };
49
+ }
50
+ function toWireValue(value) {
51
+ try {
52
+ JSON.stringify(value);
53
+ } catch {
54
+ return VALUE_ON_DEVICE;
55
+ }
56
+ if (jsonLongerThan(value, MAX_WIRE_PAYLOAD_BYTES)) {
57
+ return VALUE_ON_DEVICE;
58
+ }
59
+ return value;
60
+ }
61
+
8
62
  /**
9
63
  * Sync adapter for the jotai tool, consumed by @buoy-gg/external-sync's
10
64
  * `useExternalSync` (structurally matches its ToolSyncAdapter interface so
@@ -15,12 +69,19 @@ var _jotaiStateStore = require("../utils/jotaiStateStore");
15
69
  * already records. The snapshot carries both the change timeline and the
16
70
  * atom registry (with each atom's current value) so the dashboard can render
17
71
  * the atom browser.
72
+ *
73
+ * v2: change-log prevValue/nextValue are replaced with on-device markers.
74
+ * Dashboards fetch values via `getChangeDetail`. Oversized / unserializable
75
+ * `atoms[].currentValue` uses the same marker and is fetched via `getAtomValue`.
18
76
  */
19
77
  const jotaiSyncAdapter = exports.jotaiSyncAdapter = {
20
- version: 1,
78
+ version: 2,
21
79
  getSnapshot: () => ({
22
- changes: _jotaiStateStore.jotaiStateStore.getAtomChanges(),
23
- atoms: _jotaiStateStore.jotaiStateStore.getAtomSnapshots()
80
+ changes: _jotaiStateStore.jotaiStateStore.getAtomChanges().map(toWireChange),
81
+ atoms: _jotaiStateStore.jotaiStateStore.getAtomSnapshots().map(atom => ({
82
+ ...atom,
83
+ currentValue: toWireValue(atom.currentValue)
84
+ }))
24
85
  }),
25
86
  subscribe: onChange => {
26
87
  const unsubscribeChanges = _jotaiStateStore.jotaiStateStore.subscribe(onChange);
@@ -34,6 +95,78 @@ const jotaiSyncAdapter = exports.jotaiSyncAdapter = {
34
95
  clearEvents: () => {
35
96
  _jotaiStateStore.jotaiStateStore.clearAtomChanges();
36
97
  },
98
+ /**
99
+ * On-demand detail: one change's real prevValue/nextValue.
100
+ * The per-snapshot stream stays value-free; this is the explicit,
101
+ * size-guarded channel for the detail pane.
102
+ */
103
+ getChangeDetail: params => {
104
+ const {
105
+ id
106
+ } = params ?? {};
107
+ if (!id) return {
108
+ found: false,
109
+ reason: "missing id"
110
+ };
111
+ const change = _jotaiStateStore.jotaiStateStore.getAtomChangeById(id);
112
+ if (!change) return {
113
+ found: false,
114
+ reason: "unknown id"
115
+ };
116
+ const cappedNote = {
117
+ __buoyTruncated: true,
118
+ note: `Exceeds the ${MAX_CHANGE_DETAIL_BYTES / (1024 * 1024)}MB detail limit — inspect it on-device.`
119
+ };
120
+ const cap = value => jsonLongerThan(value, MAX_CHANGE_DETAIL_BYTES) ? cappedNote : value;
121
+ return {
122
+ found: true,
123
+ id,
124
+ prevValue: cap(change.prevValue),
125
+ nextValue: cap(change.nextValue)
126
+ };
127
+ },
128
+ /**
129
+ * On-demand current value for one atom. Wire-form snapshots replace
130
+ * oversized / unserializable `currentValue` with VALUE_ON_DEVICE.
131
+ */
132
+ getAtomValue: params => {
133
+ const {
134
+ label
135
+ } = params ?? {};
136
+ if (!label) return {
137
+ found: false,
138
+ reason: "missing label"
139
+ };
140
+ const atom = _jotaiStateStore.jotaiStateStore.getAtom(label);
141
+ if (!atom) return {
142
+ found: false,
143
+ reason: "unknown label"
144
+ };
145
+ let currentValue;
146
+ try {
147
+ currentValue = atom.getValue();
148
+ } catch {
149
+ currentValue = undefined;
150
+ }
151
+ const cappedNote = {
152
+ __buoyTruncated: true,
153
+ note: `Exceeds the ${MAX_CHANGE_DETAIL_BYTES / (1024 * 1024)}MB detail limit — inspect it on-device.`
154
+ };
155
+ try {
156
+ JSON.stringify(currentValue);
157
+ } catch {
158
+ return {
159
+ found: true,
160
+ label,
161
+ currentValue: VALUE_ON_DEVICE
162
+ };
163
+ }
164
+ return {
165
+ found: true,
166
+ label,
167
+ currentValue: jsonLongerThan(currentValue, MAX_CHANGE_DETAIL_BYTES) ? cappedNote : currentValue
168
+ };
169
+ },
37
170
  /**
38
171
  * Compact current-value reader for a remote driver (MCP/LLM): registered
39
172
  * atoms with light metadata; each atom's `currentValue` stays on the device
@@ -77,7 +210,8 @@ const jotaiSyncAdapter = exports.jotaiSyncAdapter = {
77
210
  const {
78
211
  label,
79
212
  value
80
- } = params;
213
+ } = params ?? {};
214
+ if (!label) throw new Error("setAtom requires a `label`");
81
215
  _jotaiStateStore.jotaiStateStore.setAtomValue(label, value);
82
216
  }
83
217
  }
@@ -290,17 +290,83 @@ class JotaiStateStore {
290
290
  */
291
291
  replaceFromSnapshot(changes, atomSnapshots) {
292
292
  if (!this.isEnabled) return;
293
- this.atomChanges = changes.slice(0, this.maxChanges);
294
- this.atoms = new Map(atomSnapshots.map(snapshot => [snapshot.label, {
295
- label: snapshot.label,
296
- changeCount: snapshot.changeCount,
297
- color: snapshot.color,
298
- getValue: () => snapshot.currentValue
299
- }]));
293
+
294
+ // Preserve values a remote mirror already hydrated — wire-form snapshots
295
+ // always carry markers, and a later snapshot must not blank the detail pane.
296
+ const hydrated = new Map();
297
+ for (const existing of this.atomChanges) {
298
+ const prev = existing.prevValue;
299
+ if (!prev || prev.__buoyValueOnDevice !== true) {
300
+ hydrated.set(existing.id, existing);
301
+ }
302
+ }
303
+ this.atomChanges = changes.slice(0, this.maxChanges).map(change => {
304
+ const kept = hydrated.get(change.id);
305
+ const incoming = change.prevValue;
306
+ return kept && incoming?.__buoyValueOnDevice === true ? {
307
+ ...change,
308
+ prevValue: kept.prevValue,
309
+ nextValue: kept.nextValue
310
+ } : change;
311
+ });
312
+
313
+ // Preserve currentValue a remote mirror already hydrated — oversized
314
+ // atoms arrive as VALUE_ON_DEVICE markers on every snapshot.
315
+ const hydratedAtoms = new Map();
316
+ for (const [label, atom] of this.atoms) {
317
+ try {
318
+ const value = atom.getValue();
319
+ const marker = value;
320
+ if (!marker || marker.__buoyValueOnDevice !== true) {
321
+ hydratedAtoms.set(label, value);
322
+ }
323
+ } catch {
324
+ // ignore
325
+ }
326
+ }
327
+ this.atoms = new Map(atomSnapshots.map(snapshot => {
328
+ const incoming = snapshot.currentValue;
329
+ const kept = hydratedAtoms.get(snapshot.label);
330
+ const currentValue = kept !== undefined && incoming?.__buoyValueOnDevice === true ? kept : snapshot.currentValue;
331
+ return [snapshot.label, {
332
+ label: snapshot.label,
333
+ changeCount: snapshot.changeCount,
334
+ color: snapshot.color,
335
+ getValue: () => currentValue
336
+ }];
337
+ }));
300
338
  this.notifyListeners();
301
339
  this.notifyAtomListeners();
302
340
  }
303
341
 
342
+ /**
343
+ * Hydrate one mirrored change with real values fetched on demand from the
344
+ * device via the adapter's `getChangeDetail`. Remote-mirror (desktop) only.
345
+ */
346
+ hydrateChange(id, patch) {
347
+ const index = this.atomChanges.findIndex(c => c.id === id);
348
+ if (index === -1) return;
349
+ this.atomChanges = [...this.atomChanges.slice(0, index), {
350
+ ...this.atomChanges[index],
351
+ ...patch
352
+ }, ...this.atomChanges.slice(index + 1)];
353
+ this.notifyListeners();
354
+ }
355
+
356
+ /**
357
+ * Hydrate one mirrored atom's currentValue fetched on demand from the
358
+ * device via the adapter's `getAtomValue`. Remote-mirror (desktop) only.
359
+ */
360
+ hydrateAtomValue(label, currentValue) {
361
+ const atom = this.atoms.get(label);
362
+ if (!atom) return;
363
+ this.atoms.set(label, {
364
+ ...atom,
365
+ getValue: () => currentValue
366
+ });
367
+ this.notifyAtomListeners();
368
+ }
369
+
304
370
  // ---- Atom Registry ----
305
371
 
306
372
  registerAtom(label, getValue, setValue) {
@@ -0,0 +1,233 @@
1
+ "use strict";
2
+
3
+ var _sharedUi = require("@buoy-gg/shared-ui");
4
+ var _dataViewer = require("@buoy-gg/shared-ui/dataViewer");
5
+ var _jotaiStateStore = require("./jotaiStateStore");
6
+ /**
7
+ * Time Machine snapshot provider for Jotai.
8
+ *
9
+ * Registered into @buoy-gg/shared-ui's stateSnapshotRegistry at module init;
10
+ * consumed by @buoy-gg/time-machine.
11
+ *
12
+ * Coverage is opt-in by nature: only atoms the app passed to `watchAtoms` /
13
+ * `watchDefaultStoreAtoms` are visible. Derived (read-only) atoms are captured
14
+ * for inspection but never restored — they recompute from their sources.
15
+ * Caveat (documented): atoms with custom write functions (`atomWithReducer`)
16
+ * receive ACTIONS through `store.set`, so restoring a raw value can misbehave;
17
+ * nothing in the registry distinguishes them from plain atoms.
18
+ */
19
+
20
+ function status() {
21
+ const atoms = _jotaiStateStore.jotaiStateStore.getAtoms();
22
+ if (atoms.length === 0) {
23
+ return {
24
+ canCapture: false,
25
+ canRestore: false,
26
+ detail: "No Jotai atoms registered — pass your atoms to watchAtoms() / watchDefaultStoreAtoms()"
27
+ };
28
+ }
29
+ const writable = atoms.filter(a => a.setValue).length;
30
+ return {
31
+ canCapture: true,
32
+ canRestore: writable > 0,
33
+ detail: writable < atoms.length ? `${atoms.length - writable} derived atom(s) recompute automatically and are not restored` : undefined
34
+ };
35
+ }
36
+ async function capture() {
37
+ const warnings = [];
38
+ const atoms = {};
39
+ let derived = 0;
40
+ for (const info of _jotaiStateStore.jotaiStateStore.getAtoms()) {
41
+ try {
42
+ const value = info.getValue();
43
+ if (typeof value === "function") {
44
+ warnings.push(`atom "${info.label}": function value not capturable`);
45
+ continue;
46
+ }
47
+ if (!info.setValue) derived += 1;
48
+ atoms[info.label] = {
49
+ value,
50
+ writable: Boolean(info.setValue)
51
+ };
52
+ } catch (e) {
53
+ warnings.push(`atom "${info.label}": capture failed (${String(e)})`);
54
+ }
55
+ }
56
+ if (derived > 0) {
57
+ warnings.push(`${derived} derived atom(s) captured for inspection only (recompute on restore)`);
58
+ }
59
+ return {
60
+ data: {
61
+ atoms
62
+ },
63
+ warnings
64
+ };
65
+ }
66
+ async function restore(data, options) {
67
+ const snapshot = data;
68
+ const excludedSet = new Set(options?.excludeKeys ?? []);
69
+ const includedSet = options?.includeKeys ? new Set(options.includeKeys) : null;
70
+ const touch = key => !excludedSet.has(key) && (!includedSet || includedSet.has(key));
71
+ let excludedHits = 0;
72
+ const result = {
73
+ ok: true,
74
+ applied: 0,
75
+ skipped: [],
76
+ warnings: []
77
+ };
78
+ if (!snapshot?.atoms) {
79
+ return {
80
+ ok: false,
81
+ applied: 0,
82
+ skipped: [],
83
+ warnings: ["empty jotai snapshot"]
84
+ };
85
+ }
86
+ let derived = 0;
87
+ for (const [label, entry] of Object.entries(snapshot.atoms)) {
88
+ if (!touch(label)) {
89
+ excludedHits += 1;
90
+ continue;
91
+ }
92
+ if (!entry.writable) {
93
+ derived += 1;
94
+ continue;
95
+ }
96
+ const info = _jotaiStateStore.jotaiStateStore.getAtom(label);
97
+ if (!info) {
98
+ result.skipped.push({
99
+ id: label,
100
+ reason: "atom not registered (renamed, or unwatched since snapshot?)"
101
+ });
102
+ continue;
103
+ }
104
+ if (!info.setValue) {
105
+ result.skipped.push({
106
+ id: label,
107
+ reason: "atom is now read-only (derived)"
108
+ });
109
+ continue;
110
+ }
111
+ try {
112
+ info.setValue(entry.value);
113
+ result.applied += 1;
114
+ } catch (e) {
115
+ result.skipped.push({
116
+ id: label,
117
+ reason: String(e)
118
+ });
119
+ result.ok = false;
120
+ }
121
+ }
122
+ if (derived > 0) {
123
+ result.warnings.push(`${derived} derived atom(s) left to recompute`);
124
+ }
125
+ if (excludedHits > 0) {
126
+ result.warnings.push(`${excludedHits} atom(s) excluded by you`);
127
+ }
128
+ return result;
129
+ }
130
+
131
+ /** Per-atom restore preview. `base` omitted = compare against live atoms. */
132
+ async function previewItems(target, base) {
133
+ const targetAtoms = target?.atoms ?? {};
134
+ const liveMode = base === undefined;
135
+ const baseAtoms = {};
136
+ if (liveMode) {
137
+ for (const info of _jotaiStateStore.jotaiStateStore.getAtoms()) {
138
+ try {
139
+ baseAtoms[info.label] = {
140
+ value: info.getValue(),
141
+ writable: Boolean(info.setValue)
142
+ };
143
+ } catch {
144
+ // unreadable atom — treated as absent
145
+ }
146
+ }
147
+ } else {
148
+ Object.assign(baseAtoms, base?.atoms ?? {});
149
+ }
150
+ const items = [];
151
+ const notes = [];
152
+ const labels = new Set([...Object.keys(baseAtoms), ...Object.keys(targetAtoms)]);
153
+ for (const label of labels) {
154
+ const inTarget = label in targetAtoms;
155
+ if (!inTarget) {
156
+ if (liveMode) notes.push(`atom "${label}" not in snapshot — untouched`);else items.push({
157
+ key: label,
158
+ label,
159
+ verdict: "removed",
160
+ oldValue: baseAtoms[label]?.value
161
+ });
162
+ continue;
163
+ }
164
+ const entry = targetAtoms[label];
165
+ const equal = label in baseAtoms && (0, _dataViewer.boundedDeepEqual)(baseAtoms[label].value, entry.value);
166
+ if (!entry.writable) {
167
+ items.push({
168
+ key: label,
169
+ label,
170
+ verdict: equal ? "unchanged" : "recomputes",
171
+ reason: equal ? undefined : "derived atom — recomputes from its sources",
172
+ oldValue: baseAtoms[label]?.value,
173
+ newValue: entry.value
174
+ });
175
+ continue;
176
+ }
177
+ if (liveMode) {
178
+ const info = _jotaiStateStore.jotaiStateStore.getAtom(label);
179
+ if (!info) {
180
+ items.push({
181
+ key: label,
182
+ label,
183
+ verdict: "wont-apply",
184
+ reason: "atom not registered (renamed, or unwatched since snapshot?)"
185
+ });
186
+ continue;
187
+ }
188
+ if (!info.setValue) {
189
+ items.push({
190
+ key: label,
191
+ label,
192
+ verdict: "wont-apply",
193
+ reason: "atom is now read-only (derived)"
194
+ });
195
+ continue;
196
+ }
197
+ }
198
+ if (!(label in baseAtoms)) {
199
+ items.push({
200
+ key: label,
201
+ label,
202
+ verdict: "added",
203
+ newValue: entry.value
204
+ });
205
+ } else if (equal) {
206
+ items.push({
207
+ key: label,
208
+ label,
209
+ verdict: "unchanged"
210
+ });
211
+ } else {
212
+ items.push({
213
+ key: label,
214
+ label,
215
+ verdict: "changed",
216
+ oldValue: baseAtoms[label].value,
217
+ newValue: entry.value
218
+ });
219
+ }
220
+ }
221
+ return {
222
+ items,
223
+ notes
224
+ };
225
+ }
226
+ (0, _sharedUi.registerStateSnapshotProvider)({
227
+ id: "jotai",
228
+ label: "Jotai",
229
+ status,
230
+ capture,
231
+ restore,
232
+ previewItems
233
+ });
@@ -61,9 +61,11 @@ function watchAtoms(store, atoms, options) {
61
61
  }
62
62
 
63
63
  // Register the atom for the browser view. A setter is captured only when
64
- // the store supports `set` (writable atoms); read-only/derived atoms throw
65
- // at set time and are reported as read-only.
66
- const setter = store.set ? value => {
64
+ // the store supports `set` AND the atom itself is writable (has a `write`
65
+ // function — primitive atoms get a default one, read-only derived atoms
66
+ // have none). Deriveds used to get a setter that threw inside jotai at
67
+ // set time; now they're correctly reported as read-only up front.
68
+ const setter = store.set && typeof atom.write === "function" ? value => {
67
69
  store.set(atom, value);
68
70
  } : undefined;
69
71
  _jotaiStateStore.jotaiStateStore.registerAtom(label, () => {
@@ -32,6 +32,9 @@
32
32
  * ```
33
33
  */
34
34
 
35
+ // Side-effect: registers the Time Machine snapshot provider
36
+ import "./jotai/utils/snapshotProvider";
37
+
35
38
  // =============================================================================
36
39
  // PRESET (Primary entry point for FloatingDevTools)
37
40
  // =============================================================================
@@ -16,6 +16,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
16
16
  function getValuePreview(atom) {
17
17
  try {
18
18
  const value = atom.getValue();
19
+ if (value && typeof value === "object" && value.__buoyValueOnDevice === true) {
20
+ return "on device";
21
+ }
19
22
  if (value === undefined) return "undefined";
20
23
  if (value === null) return "null";
21
24
  if (Array.isArray(value)) {
@@ -121,7 +124,8 @@ function EmptyBrowserState() {
121
124
  export function JotaiAtomBrowser({
122
125
  atoms,
123
126
  searchQuery,
124
- onViewHistory
127
+ onViewHistory,
128
+ onAtomExpand
125
129
  }) {
126
130
  const [expandedAtom, setExpandedAtom] = useState(null);
127
131
  const filteredAtoms = useMemo(() => {
@@ -130,8 +134,12 @@ export function JotaiAtomBrowser({
130
134
  return atoms.filter(a => a.label.toLowerCase().includes(search));
131
135
  }, [atoms, searchQuery]);
132
136
  const handleAtomPress = useCallback(atom => {
133
- setExpandedAtom(prev => prev === atom.label ? null : atom.label);
134
- }, []);
137
+ setExpandedAtom(prev => {
138
+ const next = prev === atom.label ? null : atom.label;
139
+ if (next) onAtomExpand?.(atom.label);
140
+ return next;
141
+ });
142
+ }, [onAtomExpand]);
135
143
  if (filteredAtoms.length === 0 && !searchQuery) {
136
144
  return /*#__PURE__*/_jsx(EmptyBrowserState, {});
137
145
  }