@buoy-gg/jotai 7.0.30 → 7.0.32

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 (46) hide show
  1. package/lib/commonjs/index.js +7 -0
  2. package/lib/commonjs/jotai/components/JotaiAtomBrowser.js +30 -119
  3. package/lib/commonjs/jotai/components/JotaiAtomChangeItem.js +19 -7
  4. package/lib/commonjs/jotai/components/JotaiAtomDetailContent.js +41 -5
  5. package/lib/commonjs/jotai/components/JotaiAtomValueDetailContent.js +444 -0
  6. package/lib/commonjs/jotai/components/JotaiEventFilterView.js +15 -0
  7. package/lib/commonjs/jotai/components/JotaiModal.js +159 -23
  8. package/lib/commonjs/jotai/utils/jotaiStateStore.js +9 -4
  9. package/lib/commonjs/jotai/utils/testIDs.js +90 -0
  10. package/lib/commonjs/jotai/utils/watchAtoms.js +8 -2
  11. package/lib/commonjs/jotai/utils/writeSemantics.js +47 -0
  12. package/lib/module/index.js +1 -0
  13. package/lib/module/jotai/components/JotaiAtomBrowser.js +33 -122
  14. package/lib/module/jotai/components/JotaiAtomChangeItem.js +19 -7
  15. package/lib/module/jotai/components/JotaiAtomDetailContent.js +41 -5
  16. package/lib/module/jotai/components/JotaiAtomValueDetailContent.js +442 -0
  17. package/lib/module/jotai/components/JotaiEventFilterView.js +15 -0
  18. package/lib/module/jotai/components/JotaiModal.js +160 -24
  19. package/lib/module/jotai/utils/jotaiStateStore.js +9 -4
  20. package/lib/module/jotai/utils/testIDs.js +82 -0
  21. package/lib/module/jotai/utils/watchAtoms.js +8 -2
  22. package/lib/module/jotai/utils/writeSemantics.js +43 -0
  23. package/lib/typescript/index.d.ts +1 -0
  24. package/lib/typescript/index.d.ts.map +1 -1
  25. package/lib/typescript/jotai/components/JotaiAtomBrowser.d.ts +6 -5
  26. package/lib/typescript/jotai/components/JotaiAtomBrowser.d.ts.map +1 -1
  27. package/lib/typescript/jotai/components/JotaiAtomChangeItem.d.ts +5 -0
  28. package/lib/typescript/jotai/components/JotaiAtomChangeItem.d.ts.map +1 -1
  29. package/lib/typescript/jotai/components/JotaiAtomDetailContent.d.ts.map +1 -1
  30. package/lib/typescript/jotai/components/JotaiAtomValueDetailContent.d.ts +40 -0
  31. package/lib/typescript/jotai/components/JotaiAtomValueDetailContent.d.ts.map +1 -0
  32. package/lib/typescript/jotai/components/JotaiEventFilterView.d.ts.map +1 -1
  33. package/lib/typescript/jotai/components/JotaiModal.d.ts.map +1 -1
  34. package/lib/typescript/jotai/sync/jotaiSyncAdapter.d.ts +1 -0
  35. package/lib/typescript/jotai/sync/jotaiSyncAdapter.d.ts.map +1 -1
  36. package/lib/typescript/jotai/types/index.d.ts +12 -0
  37. package/lib/typescript/jotai/types/index.d.ts.map +1 -1
  38. package/lib/typescript/jotai/utils/jotaiStateStore.d.ts +1 -1
  39. package/lib/typescript/jotai/utils/jotaiStateStore.d.ts.map +1 -1
  40. package/lib/typescript/jotai/utils/testIDs.d.ts +62 -0
  41. package/lib/typescript/jotai/utils/testIDs.d.ts.map +1 -0
  42. package/lib/typescript/jotai/utils/watchAtoms.d.ts +8 -2
  43. package/lib/typescript/jotai/utils/watchAtoms.d.ts.map +1 -1
  44. package/lib/typescript/jotai/utils/writeSemantics.d.ts +25 -0
  45. package/lib/typescript/jotai/utils/writeSemantics.d.ts.map +1 -0
  46. package/package.json +3 -3
@@ -13,7 +13,9 @@ var _useJotaiAtomChanges = require("../hooks/useJotaiAtomChanges");
13
13
  var _JotaiAtomChangeItem = require("./JotaiAtomChangeItem");
14
14
  var _JotaiAtomDetailContent = require("./JotaiAtomDetailContent");
15
15
  var _JotaiAtomBrowser = require("./JotaiAtomBrowser");
16
+ var _JotaiAtomValueDetailContent = require("./JotaiAtomValueDetailContent");
16
17
  var _JotaiEventFilterView = require("./JotaiEventFilterView");
18
+ var _testIDs = require("../utils/testIDs");
17
19
  var _jsxRuntime = require("react/jsx-runtime");
18
20
  /**
19
21
  * Main Jotai DevTools modal
@@ -23,19 +25,32 @@ var _jsxRuntime = require("react/jsx-runtime");
23
25
  * - Events tab: Live atom change monitoring with diffs
24
26
  */
25
27
 
28
+ /**
29
+ * Cell clipping, off in e2e bundles only.
30
+ *
31
+ * `removeClippedSubviews` unmounts the views of rows scrolled out of the
32
+ * window, which is what makes a long change list cheap — and which makes
33
+ * Detox report a perfectly rendered row as un-hittable. The env var is
34
+ * inlined at bundle time, so a bundler that does NOT inline it leaves the
35
+ * production behaviour exactly as it was.
36
+ */
37
+ const CLIP_OFFSCREEN_CELLS = !(typeof process !== "undefined" && process.env?.EXPO_PUBLIC_E2E);
26
38
  function EventsEmptyState({
27
39
  isEnabled
28
40
  }) {
29
41
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
30
42
  style: styles.emptyState,
43
+ testID: `${_testIDs.JOTAI_TEST_ID}-empty`,
31
44
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Box, {
32
45
  size: 32,
33
46
  color: _sharedUi.night.color.textTertiary
34
47
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
35
48
  style: styles.emptyTitle,
49
+ testID: `${_testIDs.JOTAI_TEST_ID}-empty-title`,
36
50
  children: "No atom changes"
37
51
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
38
52
  style: styles.emptyText,
53
+ testID: `${_testIDs.JOTAI_TEST_ID}-empty-text`,
39
54
  children: isEnabled ? "Atom changes will appear here as values update." : "Enable capture to start recording atom changes"
40
55
  })]
41
56
  });
@@ -58,6 +73,9 @@ function JotaiModal({
58
73
  const [showProModal, setShowProModal] = (0, _react.useState)(false);
59
74
  const [activeTab, setActiveTab] = (0, _react.useState)(initialTab ?? "atoms");
60
75
  const [selectedHistoryAtom, setSelectedHistoryAtom] = (0, _react.useState)(null);
76
+ // The atom whose DETAIL SCREEN (live editor) is open — tapping a card in
77
+ // the atoms tab navigates here, mirroring ZustandModal's selectedStoreName.
78
+ const [selectedAtomLabel, setSelectedAtomLabel] = (0, _react.useState)(null);
61
79
  const [showFilters, setShowFilters] = (0, _react.useState)(false);
62
80
  const [ignoredPatterns, setIgnoredPatterns] = (0, _react.useState)(new Set());
63
81
  const {
@@ -142,6 +160,16 @@ function JotaiModal({
142
160
  }));
143
161
  }
144
162
  };
163
+
164
+ /** Drop the query AND put the header back. The X, and only the X. */
165
+ const closeSearch = (0, _react.useCallback)(() => {
166
+ setSearchText("");
167
+ setFilter(prev => ({
168
+ ...prev,
169
+ searchText: ""
170
+ }));
171
+ setIsSearchActive(false);
172
+ }, [setFilter]);
145
173
  const handleChangePress = (0, _react.useCallback)(change => {
146
174
  setSelectedChangeId(change.id);
147
175
  }, []);
@@ -163,6 +191,16 @@ function JotaiModal({
163
191
  setSelectedHistoryAtom(null);
164
192
  setSelectedChangeId(null);
165
193
  }, []);
194
+ const handleOpenAtom = (0, _react.useCallback)(label => {
195
+ setSelectedAtomLabel(label);
196
+ // The desktop mirror hydrates an atom's full value on demand — this is
197
+ // the moment it becomes needed (was fired on card-expand before the
198
+ // detail screen replaced expansion).
199
+ onAtomExpand?.(label);
200
+ }, [onAtomExpand]);
201
+ const handleBackFromAtom = (0, _react.useCallback)(() => {
202
+ setSelectedAtomLabel(null);
203
+ }, []);
166
204
  const getAtomsSnapshot = (0, _react.useCallback)(() => {
167
205
  return displayedAtoms.reduce((acc, atom) => {
168
206
  try {
@@ -221,12 +259,19 @@ function JotaiModal({
221
259
  setIsSearchActive(false);
222
260
  }, [searchText, setFilter]);
223
261
  const keyExtractor = (0, _react.useCallback)(item => item.id, []);
262
+
263
+ // Built for the whole list rather than per row: the ordinal a row carries is
264
+ // a fact about the list around it. See ../utils/testIDs.ts for why an atom
265
+ // label alone cannot address a change row — and why the ordinal is counted
266
+ // per atom when one write can move several.
267
+ const rowTestIDs = (0, _react.useMemo)(() => (0, _testIDs.buildChangeRowTestIDs)(activeChanges), [activeChanges]);
224
268
  const renderItem = (0, _react.useCallback)(({
225
269
  item
226
270
  }) => /*#__PURE__*/(0, _jsxRuntime.jsx)(_JotaiAtomChangeItem.JotaiAtomChangeItem, {
227
271
  change: item,
228
- onPress: handleChangePress
229
- }), [handleChangePress]);
272
+ onPress: handleChangePress,
273
+ testID: rowTestIDs.get(item.id)
274
+ }), [handleChangePress, rowTestIDs]);
230
275
 
231
276
  // ---- Header ----
232
277
 
@@ -238,7 +283,8 @@ function JotaiModal({
238
283
  onBack: () => setShowFilters(false)
239
284
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Content, {
240
285
  title: "Filters",
241
- centered: true
286
+ centered: true,
287
+ testID: `${_testIDs.JOTAI_TEST_ID}-filters-title`
242
288
  })]
243
289
  });
244
290
  }
@@ -248,7 +294,8 @@ function JotaiModal({
248
294
  onBack: handleBack
249
295
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Content, {
250
296
  title: `${selectedChange.atomLabel}/${selectedChange.category}`,
251
- centered: true
297
+ centered: true,
298
+ testID: `${_testIDs.JOTAI_TEST_ID}-detail-title`
252
299
  })]
253
300
  });
254
301
  }
@@ -258,16 +305,33 @@ function JotaiModal({
258
305
  onBack: handleBackFromHistory
259
306
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Content, {
260
307
  title: `${selectedHistoryAtom} History`,
261
- centered: true
308
+ centered: true,
309
+ testID: `${_testIDs.JOTAI_TEST_ID}-history-title`
262
310
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Actions, {
263
311
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ToolbarCopyButton, {
264
312
  value: getHistorySnapshot,
265
313
  disabled: activeChanges.length === 0,
266
- buttonStyle: styles.headerActionButton
314
+ buttonStyle: styles.headerActionButton,
315
+ testID: `${_testIDs.JOTAI_TEST_ID}-copy`
267
316
  })
268
317
  })]
269
318
  });
270
319
  }
320
+
321
+ // Atom detail header — the live editor screen. AFTER history: history is
322
+ // reached FROM the detail screen and stacks over it, so when both are set
323
+ // history owns the header, and its back lands you back on the detail.
324
+ if (selectedAtomLabel) {
325
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader, {
326
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Navigation, {
327
+ onBack: handleBackFromAtom
328
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Content, {
329
+ title: selectedAtomLabel,
330
+ centered: true,
331
+ testID: `${_testIDs.JOTAI_TEST_ID}-atom-detail-title`
332
+ })]
333
+ });
334
+ }
271
335
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader, {
272
336
  children: [onBack && /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Navigation, {
273
337
  onBack: onBack
@@ -275,27 +339,42 @@ function JotaiModal({
275
339
  title: "",
276
340
  children: isSearchActive ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
277
341
  style: styles.headerSearchContainer,
342
+ testID: `${_testIDs.JOTAI_TEST_ID}-search`,
278
343
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Search, {
279
344
  size: 14,
280
345
  color: _sharedUi.night.color.textSecondary
281
346
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TextInput, {
282
347
  ref: searchInputRef,
348
+ testID: `${_testIDs.JOTAI_TEST_ID}-search-field`,
283
349
  style: styles.headerSearchInput,
284
350
  placeholder: activeTab === "atoms" ? "Search atoms..." : "Search events...",
285
351
  placeholderTextColor: _sharedUi.night.color.placeholder,
286
352
  value: searchText,
287
353
  onChangeText: handleSearch,
288
- onSubmitEditing: () => setIsSearchActive(false),
289
- onBlur: () => setIsSearchActive(false),
354
+ onSubmitEditing: () => searchInputRef.current?.blur()
355
+ // Stay open while the query is still narrowing the list.
356
+ //
357
+ // Collapsing on blur hid the reason rows were missing: the
358
+ // header went back to its TABS — whose counts are of the
359
+ // WHOLE capture — while the list stayed filtered, so the tool
360
+ // showed "Atoms (8)" above a single row and offered nothing to
361
+ // explain the gap. The only way back was to reopen a field the
362
+ // user had no reason to think was still armed. Same shape, and
363
+ // the same fix, as the Redux, Events and Zustand tools.
364
+ ,
365
+ onBlur: () => {
366
+ if (searchText.length === 0) setIsSearchActive(false);
367
+ },
368
+ accessibilityLabel: "Search Jotai atoms and events",
290
369
  autoCapitalize: "none",
291
370
  autoCorrect: false,
292
371
  returnKeyType: "search"
293
- }), searchText.length > 0 && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
294
- onPress: () => {
295
- handleSearch("");
296
- setIsSearchActive(false);
297
- },
372
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
373
+ onPress: closeSearch,
298
374
  style: styles.headerSearchClear,
375
+ testID: `${_testIDs.JOTAI_TEST_ID}-search-clear`,
376
+ accessibilityRole: "button",
377
+ accessibilityLabel: "Close search",
299
378
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.X, {
300
379
  size: 14,
301
380
  color: _sharedUi.night.color.textSecondary
@@ -310,12 +389,16 @@ function JotaiModal({
310
389
  label: `Events${displayedChanges.length > 0 ? ` (${displayedChanges.length})` : ""}`
311
390
  }],
312
391
  activeTab: activeTab,
313
- onTabChange: handleTabChange
392
+ onTabChange: handleTabChange,
393
+ testIDPrefix: `${_testIDs.JOTAI_TEST_ID}-tab`
314
394
  })
315
395
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader.Actions, {
316
396
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
317
397
  onPress: () => setIsSearchActive(true),
318
398
  style: styles.headerActionButton,
399
+ testID: `${_testIDs.JOTAI_TEST_ID}-search-open`,
400
+ accessibilityRole: "button",
401
+ accessibilityLabel: "Search",
319
402
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Search, {
320
403
  size: 14,
321
404
  color: _sharedUi.night.color.textSecondary
@@ -323,6 +406,9 @@ function JotaiModal({
323
406
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
324
407
  onPress: () => setShowFilters(true),
325
408
  style: [styles.headerActionButton, hasActiveFilters && styles.headerActionButtonActive],
409
+ testID: `${_testIDs.JOTAI_TEST_ID}-filters-open`,
410
+ accessibilityRole: "button",
411
+ accessibilityLabel: "Filters",
326
412
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Filter, {
327
413
  size: 14,
328
414
  color: hasActiveFilters ? _sharedUi.night.color.accent : _sharedUi.night.color.textSecondary
@@ -330,20 +416,24 @@ function JotaiModal({
330
416
  }), activeTab === "atoms" && /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ToolbarCopyButton, {
331
417
  value: getAtomsSnapshot,
332
418
  disabled: displayedAtoms.length === 0,
333
- buttonStyle: styles.headerActionButton
419
+ buttonStyle: styles.headerActionButton,
420
+ testID: `${_testIDs.JOTAI_TEST_ID}-copy`
334
421
  }), activeTab === "events" && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
335
422
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ToolbarCopyButton, {
336
423
  value: getEventsSnapshot,
337
424
  disabled: displayedChanges.length === 0,
338
- buttonStyle: styles.headerActionButton
425
+ buttonStyle: styles.headerActionButton,
426
+ testID: `${_testIDs.JOTAI_TEST_ID}-copy`
339
427
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.PowerToggleButton, {
340
428
  isEnabled: isEnabled,
341
429
  onToggle: toggleCapture,
342
- accessibilityLabel: "Toggle Jotai atom capture"
430
+ accessibilityLabel: "Toggle Jotai atom capture",
431
+ testID: `${_testIDs.JOTAI_TEST_ID}-power`
343
432
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ToolbarClearButton, {
344
433
  onPress: clearChanges,
345
434
  disabled: displayedChanges.length === 0,
346
- buttonStyle: styles.headerActionButton
435
+ buttonStyle: styles.headerActionButton,
436
+ testID: `${_testIDs.JOTAI_TEST_ID}-clear`
347
437
  })]
348
438
  })]
349
439
  })]
@@ -373,18 +463,20 @@ function JotaiModal({
373
463
  }
374
464
  if (selectedHistoryAtom) {
375
465
  return activeChanges.length > 0 ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.FlatList, {
466
+ testID: `${_testIDs.JOTAI_TEST_ID}-history-list`,
376
467
  data: activeChanges,
377
468
  renderItem: renderItem,
378
469
  keyExtractor: keyExtractor,
379
470
  contentContainerStyle: styles.listContent,
380
471
  showsVerticalScrollIndicator: true,
381
- removeClippedSubviews: true,
472
+ removeClippedSubviews: CLIP_OFFSCREEN_CELLS,
382
473
  initialNumToRender: 12,
383
474
  maxToRenderPerBatch: 8,
384
475
  windowSize: 5,
385
476
  updateCellsBatchingPeriod: 50
386
477
  }) : /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
387
478
  style: styles.emptyState,
479
+ testID: `${_testIDs.JOTAI_TEST_ID}-history-empty`,
388
480
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Box, {
389
481
  size: 32,
390
482
  color: _sharedUi.night.color.textTertiary
@@ -397,12 +489,39 @@ function JotaiModal({
397
489
  })]
398
490
  });
399
491
  }
492
+
493
+ // Atom detail — live editor + details + read-only tree. After history in
494
+ // this chain for the same reason as the header: history stacks OVER the
495
+ // detail screen it was opened from.
496
+ if (selectedAtomLabel) {
497
+ const selectedAtom = atoms.find(a => a.label === selectedAtomLabel) ?? null;
498
+ return selectedAtom ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_JotaiAtomValueDetailContent.JotaiAtomValueDetailContent, {
499
+ atom: selectedAtom,
500
+ onViewHistory: handleViewHistory
501
+ }) :
502
+ /*#__PURE__*/
503
+ // The atom fell out of the registry mid-look (a remote mirror
504
+ // rebuilding, a dev reload). Say so rather than rendering nothing.
505
+ (0, _jsxRuntime.jsxs)(_reactNative.View, {
506
+ style: styles.emptyState,
507
+ testID: `${_testIDs.JOTAI_TEST_ID}-atom-detail-gone`,
508
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Box, {
509
+ size: 32,
510
+ color: _sharedUi.night.color.textTertiary
511
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
512
+ style: styles.emptyTitle,
513
+ children: "Atom no longer registered"
514
+ }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Text, {
515
+ style: styles.emptyText,
516
+ children: [selectedAtomLabel, " is not in the registry any more."]
517
+ })]
518
+ });
519
+ }
400
520
  if (activeTab === "atoms") {
401
521
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_JotaiAtomBrowser.JotaiAtomBrowser, {
402
522
  atoms: displayedAtoms,
403
523
  searchQuery: searchText,
404
- onViewHistory: handleViewHistory,
405
- onAtomExpand: onAtomExpand
524
+ onOpenAtom: handleOpenAtom
406
525
  });
407
526
  }
408
527
 
@@ -410,6 +529,7 @@ function JotaiModal({
410
529
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
411
530
  children: [!isEnabled && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
412
531
  style: styles.disabledBanner,
532
+ testID: `${_testIDs.JOTAI_TEST_ID}-banner-disabled`,
413
533
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Power, {
414
534
  size: 14,
415
535
  color: _sharedUi.night.color.warning
@@ -421,8 +541,11 @@ function JotaiModal({
421
541
  style: styles.lockedBanner,
422
542
  onPress: handleUpgradePress,
423
543
  activeOpacity: 0.8,
544
+ testID: `${_testIDs.JOTAI_TEST_ID}-locked`,
545
+ accessibilityRole: "button",
424
546
  children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Text, {
425
547
  style: styles.lockedText,
548
+ testID: `${_testIDs.JOTAI_TEST_ID}-locked-text`,
426
549
  children: [lockedChangeCount, " older", " ", lockedChangeCount === 1 ? "change" : "changes", " locked"]
427
550
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
428
551
  style: styles.upgradeBadge,
@@ -433,12 +556,13 @@ function JotaiModal({
433
556
  })]
434
557
  }), visibleChanges.length > 0 ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.FlatList, {
435
558
  ref: flatListRef,
559
+ testID: `${_testIDs.JOTAI_TEST_ID}-list`,
436
560
  data: visibleChanges,
437
561
  renderItem: renderItem,
438
562
  keyExtractor: keyExtractor,
439
563
  contentContainerStyle: styles.listContent,
440
564
  showsVerticalScrollIndicator: true,
441
- removeClippedSubviews: true,
565
+ removeClippedSubviews: CLIP_OFFSCREEN_CELLS,
442
566
  initialNumToRender: 12,
443
567
  maxToRenderPerBatch: 8,
444
568
  windowSize: 5,
@@ -460,7 +584,18 @@ function JotaiModal({
460
584
 
461
585
  // The change lists own their scrolling so FlatList virtualization works
462
586
  // (see ZustandModal — same pattern, same tool-bench evidence).
463
- const listOwnsScroll = !showFilters && !(selectedChange && selectedChangeIndex !== null) && (selectedHistoryAtom != null || activeTab === "events");
587
+ //
588
+ // The ATOM BROWSER owns its scrolling too, which is not just symmetry. Its
589
+ // root is a `flex: 1` ScrollView, and a `flex: 1` box inside JsModal's
590
+ // wrapper (`contentContainerStyle: { flexGrow: 1 }`) resolves against the
591
+ // CONTENT rather than the viewport — so nested, the browser's scroller grows
592
+ // to the full height of its rows, never scrolls, and the wrapper scrolls
593
+ // instead. A person cannot tell the difference; automation can, because the
594
+ // scroller Detox is handed reports a frame taller than the sheet and every
595
+ // scroll start point it computes lands in the clipped part ("view is not
596
+ // scrollable at the given start point"). Owning the scroll gives the browser
597
+ // a bounded height and one scroller with a handle on it.
598
+ const listOwnsScroll = !showFilters && !(selectedChange && selectedChangeIndex !== null) && (selectedHistoryAtom != null || activeTab === "events" || activeTab === "atoms");
464
599
  const body = /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
465
600
  style: styles.container,
466
601
  children: renderContent()
@@ -479,6 +614,7 @@ function JotaiModal({
479
614
  style: styles.inlineRoot,
480
615
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
481
616
  style: styles.inlineHeader,
617
+ ..._sharedUi.windowDragProps,
482
618
  children: renderHeaderContent()
483
619
  }), body, footerNode]
484
620
  }), upgradeModal]
@@ -277,7 +277,8 @@ class JotaiStateStore {
277
277
  label: atom.label,
278
278
  changeCount: atom.changeCount,
279
279
  color: atom.color,
280
- currentValue
280
+ currentValue,
281
+ writeKind: atom.writeKind
281
282
  };
282
283
  });
283
284
  }
@@ -332,7 +333,10 @@ class JotaiStateStore {
332
333
  label: snapshot.label,
333
334
  changeCount: snapshot.changeCount,
334
335
  color: snapshot.color,
335
- getValue: () => currentValue
336
+ getValue: () => currentValue,
337
+ // No setValue: the mirror has no wire back to the device from
338
+ // this entry (yet) — the detail screen renders read-only there.
339
+ writeKind: snapshot.writeKind
336
340
  }];
337
341
  }));
338
342
  this.notifyListeners();
@@ -369,14 +373,15 @@ class JotaiStateStore {
369
373
 
370
374
  // ---- Atom Registry ----
371
375
 
372
- registerAtom(label, getValue, setValue) {
376
+ registerAtom(label, getValue, setValue, writeKind) {
373
377
  if (this.atoms.has(label)) return;
374
378
  const atomInfo = {
375
379
  label,
376
380
  changeCount: 0,
377
381
  color: getAtomColor(label),
378
382
  getValue,
379
- setValue
383
+ setValue,
384
+ writeKind
380
385
  };
381
386
  this.atoms.set(label, atomInfo);
382
387
  this.notifyAtomListeners();
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.JOTAI_TEST_ID = void 0;
7
+ exports.atomRowTestID = atomRowTestID;
8
+ exports.buildChangeRowTestIDs = buildChangeRowTestIDs;
9
+ exports.changeRowTestID = changeRowTestID;
10
+ exports.slugForTestID = slugForTestID;
11
+ /**
12
+ * Jotai tool test handles.
13
+ *
14
+ * Every tappable and every value the e2e suite reads back is addressed
15
+ * `buoy-jotai-<thing>`. Two rules need explaining, and the second is the one
16
+ * this tool does not share with any other.
17
+ *
18
+ * **A change row's handle carries an ORDINAL, and it counts per ATOM.** Same
19
+ * reason as the Zustand tool: an atom a screen writes to fifty times produces
20
+ * fifty separate, equally real rows, and addressing them by label alone would
21
+ * give fifty views one handle — Detox fails a *matcher* that hits more than
22
+ * one element, so the suite would break on `toExist()` before any assertion,
23
+ * with an error that says nothing about duplicate changes. The handle is
24
+ * `buoy-jotai-row-<slug(atomLabel)>-<n>`, n counting back from the newest row
25
+ * for that atom (1 = newest). Deliberately relative: an atom change has no
26
+ * identity a host could know in advance, and what a test CAN know is "the
27
+ * write I just fired is the newest change to that atom". Read and tap in one
28
+ * beat — a row's ordinal moves the instant another change lands.
29
+ *
30
+ * **The ordinal is per ATOM, and one write can move several atoms.** This is
31
+ * where jotai differs from every store-shaped tool. Atoms form a dependency
32
+ * graph, and this tool watches NODES: a single `set` on a primitive atom
33
+ * notifies every mounted derived atom whose value actually moved, so one
34
+ * press can add a row under three different labels — and add nothing at all
35
+ * under a fourth whose recomputed value happened to be unchanged. Counting
36
+ * ordinals across the whole list would make a row's handle depend on how many
37
+ * *other* atoms the write happened to touch, which is exactly the thing a
38
+ * test of a graph must not assume.
39
+ *
40
+ * **An atom row's handle does NOT carry an ordinal.** An atom is registered
41
+ * once under a label that is unique by construction
42
+ * (`jotaiStateStore.registerAtom` returns early on a label it already holds,
43
+ * and `watchAtoms` marks the atom with a per-label symbol), so
44
+ * `buoy-jotai-atom-<slug(label)>` is as stable as the registration itself —
45
+ * and unlike a change, it is a thing the host knows the name of before the
46
+ * app has booted.
47
+ *
48
+ * The suite mirrors these rules in CommonJS (`example-dev-build/utils/
49
+ * jotaiBench.js`) and asserts the two agree — Detox runs in Node on the host
50
+ * and cannot import TypeScript.
51
+ */
52
+
53
+ /** Prefix every handle in this tool shares. */
54
+ const JOTAI_TEST_ID = exports.JOTAI_TEST_ID = "buoy-jotai";
55
+
56
+ /** `jbenchCount` → `jbenchcount`. Shared with every Buoy tool. */
57
+ function slugForTestID(value) {
58
+ return String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
59
+ }
60
+
61
+ /**
62
+ * Handle for one change row: `buoy-jotai-row-jbenchcount-1`.
63
+ *
64
+ * `ordinal` is 1-based and counts back from the newest row for this atom.
65
+ */
66
+ function changeRowTestID(atomLabel, ordinal) {
67
+ return `${JOTAI_TEST_ID}-row-${slugForTestID(atomLabel)}-${ordinal}`;
68
+ }
69
+
70
+ /** Handle for one atom row in the browser: `buoy-jotai-atom-jbenchcount`. */
71
+ function atomRowTestID(atomLabel) {
72
+ return `${JOTAI_TEST_ID}-atom-${slugForTestID(atomLabel)}`;
73
+ }
74
+
75
+ /**
76
+ * Row handle per change id for one rendered list, newest-first.
77
+ *
78
+ * Built once for the whole list rather than per row, because an ordinal is a
79
+ * fact about the list and a row cannot compute its own.
80
+ */
81
+ function buildChangeRowTestIDs(changes) {
82
+ const seen = new Map();
83
+ const handles = new Map();
84
+ for (const change of changes) {
85
+ const ordinal = (seen.get(change.atomLabel) ?? 0) + 1;
86
+ seen.set(change.atomLabel, ordinal);
87
+ handles.set(change.id, changeRowTestID(change.atomLabel, ordinal));
88
+ }
89
+ return handles;
90
+ }
@@ -7,6 +7,7 @@ exports.isAtomWatched = isAtomWatched;
7
7
  exports.watchAtoms = watchAtoms;
8
8
  exports.watchDefaultStoreAtoms = watchDefaultStoreAtoms;
9
9
  var _jotaiStateStore = require("./jotaiStateStore");
10
+ var _writeSemantics = require("./writeSemantics");
10
11
  /**
11
12
  * Buoy Jotai DevTools — Atom instrumentation
12
13
  *
@@ -65,7 +66,8 @@ function watchAtoms(store, atoms, options) {
65
66
  // function — primitive atoms get a default one, read-only derived atoms
66
67
  // have none). Deriveds used to get a setter that threw inside jotai at
67
68
  // set time; now they're correctly reported as read-only up front.
68
- const setter = store.set && typeof atom.write === "function" ? value => {
69
+ const isWritable = typeof atom.write === "function";
70
+ const setter = store.set && isWritable ? value => {
69
71
  store.set(atom, value);
70
72
  } : undefined;
71
73
  _jotaiStateStore.jotaiStateStore.registerAtom(label, () => {
@@ -74,7 +76,11 @@ function watchAtoms(store, atoms, options) {
74
76
  } catch {
75
77
  return undefined;
76
78
  }
77
- }, setter);
79
+ }, setter,
80
+ // "value" = jotai's shared defaultWrite (a primitive atom: store.set
81
+ // stores the value). "action" = a custom write receiving the value as
82
+ // its argument — still writable, but the editor says so.
83
+ isWritable ? (0, _writeSemantics.classifyAtomWrite)(atom) : undefined);
78
84
 
79
85
  // Record initial value
80
86
  if (enabled) {
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.classifyAtomWrite = classifyAtomWrite;
7
+ var _vanilla = require("jotai/vanilla");
8
+ /**
9
+ * What does `store.set(atom, x)` DO with x?
10
+ *
11
+ * jotai's `atom(initialValue)` gives every primitive atom the SAME shared
12
+ * `defaultWrite` function (see jotai/src/vanilla/atom.ts): it stores x as the
13
+ * atom's new value (or calls it with the previous value when x is a
14
+ * function). Any other `write` is app code — `atomWithReducer` receives
15
+ * ACTIONS, `atomWithStorage` persists as a side effect, a custom write can do
16
+ * anything — so a raw value handed to `store.set` may not mean "set to this".
17
+ *
18
+ * The tool's editor needs that distinction to be honest: a "value" atom edits
19
+ * like plain data; an "action" atom is still writable (the MCP `setAtom` and
20
+ * Time Machine restore already write to them) but the editor says what the
21
+ * write actually does.
22
+ *
23
+ * Detection: a probe primitive atom exposes this jotai instance's
24
+ * `defaultWrite`; identity match is exact. The `toString` fallback covers an
25
+ * app that somehow bundles a second jotai copy (its atoms carry a different —
26
+ * but textually identical — defaultWrite instance). An unrecognized write
27
+ * classifies as "action", which only makes the editor more cautious.
28
+ */
29
+
30
+ const probe = (0, _vanilla.atom)(null);
31
+ const DEFAULT_WRITE = probe.write;
32
+ const DEFAULT_WRITE_SOURCE = typeof DEFAULT_WRITE === "function" ? String(DEFAULT_WRITE) : null;
33
+
34
+ /** Classify a writable atom's write. Call only when `atom.write` exists. */
35
+ function classifyAtomWrite(candidate) {
36
+ const atomLike = candidate;
37
+ const write = atomLike?.write;
38
+ if (typeof write !== "function") return "action";
39
+ if (write === DEFAULT_WRITE) return "value";
40
+ // Dual-copy fallback: a primitive atom from another jotai instance holds
41
+ // its own value (`init` present) and its write's source is the library's
42
+ // defaultWrite verbatim.
43
+ if (atomLike !== null && "init" in atomLike && DEFAULT_WRITE_SOURCE !== null && String(write) === DEFAULT_WRITE_SOURCE) {
44
+ return "value";
45
+ }
46
+ return "action";
47
+ }
@@ -66,6 +66,7 @@ export { useJotaiAtomChanges } from "./jotai/hooks/useJotaiAtomChanges";
66
66
  export { JotaiModal, JotaiToolView } from "./jotai/components/JotaiModal";
67
67
  export { JotaiAtomChangeItem } from "./jotai/components/JotaiAtomChangeItem";
68
68
  export { JotaiAtomDetailContent, JotaiAtomDetailFooter } from "./jotai/components/JotaiAtomDetailContent";
69
+ export { JotaiAtomValueDetailContent } from "./jotai/components/JotaiAtomValueDetailContent";
69
70
  export { JotaiIcon, JOTAI_ICON_COLOR } from "./jotai/components/JotaiIcon";
70
71
 
71
72
  // =============================================================================