@buoy-gg/network 7.0.11 → 7.0.13

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 (36) hide show
  1. package/README.md +1 -0
  2. package/lib/commonjs/index.js +13 -0
  3. package/lib/commonjs/network/components/NetworkDetailStepper.js +28 -14
  4. package/lib/commonjs/network/components/NetworkEventDetailView.js +56 -11
  5. package/lib/commonjs/network/components/NetworkHeaderLive.js +6 -1
  6. package/lib/commonjs/network/components/NetworkListScreen.js +9 -4
  7. package/lib/commonjs/network/components/NetworkModal.js +121 -26
  8. package/lib/commonjs/network/components/NetworkSavedScreen.js +12 -15
  9. package/lib/commonjs/network/hooks/useNetworkSelection.js +53 -0
  10. package/lib/commonjs/network/utils/networkSelectionStore.js +193 -0
  11. package/lib/module/index.js +4 -0
  12. package/lib/module/network/components/NetworkDetailStepper.js +29 -15
  13. package/lib/module/network/components/NetworkEventDetailView.js +56 -11
  14. package/lib/module/network/components/NetworkHeaderLive.js +6 -1
  15. package/lib/module/network/components/NetworkListScreen.js +9 -4
  16. package/lib/module/network/components/NetworkModal.js +121 -26
  17. package/lib/module/network/components/NetworkSavedScreen.js +13 -16
  18. package/lib/module/network/hooks/useNetworkSelection.js +46 -0
  19. package/lib/module/network/utils/networkSelectionStore.js +188 -0
  20. package/lib/typescript/index.d.ts +1 -0
  21. package/lib/typescript/index.d.ts.map +1 -1
  22. package/lib/typescript/network/components/NetworkDetailStepper.d.ts +10 -5
  23. package/lib/typescript/network/components/NetworkDetailStepper.d.ts.map +1 -1
  24. package/lib/typescript/network/components/NetworkEventDetailView.d.ts +36 -1
  25. package/lib/typescript/network/components/NetworkEventDetailView.d.ts.map +1 -1
  26. package/lib/typescript/network/components/NetworkHeaderLive.d.ts +5 -2
  27. package/lib/typescript/network/components/NetworkHeaderLive.d.ts.map +1 -1
  28. package/lib/typescript/network/components/NetworkListScreen.d.ts.map +1 -1
  29. package/lib/typescript/network/components/NetworkModal.d.ts.map +1 -1
  30. package/lib/typescript/network/components/NetworkSavedScreen.d.ts +12 -1
  31. package/lib/typescript/network/components/NetworkSavedScreen.d.ts.map +1 -1
  32. package/lib/typescript/network/hooks/useNetworkSelection.d.ts +26 -0
  33. package/lib/typescript/network/hooks/useNetworkSelection.d.ts.map +1 -0
  34. package/lib/typescript/network/utils/networkSelectionStore.d.ts +117 -0
  35. package/lib/typescript/network/utils/networkSelectionStore.d.ts.map +1 -0
  36. package/package.json +2 -2
@@ -10,8 +10,11 @@ import { NetworkCopySettingsView, DEFAULT_COPY_SETTINGS } from "./NetworkCopySet
10
10
  import { NetworkHeaderBadges, NetworkClearButton, NetworkDetailPinActions, NetworkHeaderCopyButton, NetworkSavedButton } from "./NetworkHeaderLive";
11
11
  import { NetworkListScreen } from "./NetworkListScreen";
12
12
  import { NetworkSavedScreen } from "./NetworkSavedScreen";
13
+ import { NetworkDetailStepper } from "./NetworkDetailStepper";
13
14
  import { useNetworkEventList } from "../hooks/useNetworkEventList";
14
15
  import { useLiveNetworkEvent } from "../hooks/useLiveNetworkEvent";
16
+ import { useHasNetworkSelection, useNetworkBodyEventId, useSelectedNetworkEventId } from "../hooks/useNetworkSelection";
17
+ import { networkSelectionStore } from "../utils/networkSelectionStore";
15
18
  import { FREE_TIER_SAVED_LIMIT, MAX_SAVED } from "../utils/networkSavedStore";
16
19
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
17
20
  // ============================================================================
@@ -23,22 +26,76 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
23
26
 
24
27
  function NetworkDetailScreen({
25
28
  visible,
26
- eventId,
27
29
  paused,
28
30
  ignoredPatternValues,
29
- onTogglePattern
31
+ onTogglePattern,
32
+ isPro,
33
+ filter,
34
+ ignoredPatterns,
35
+ fromSaved,
36
+ savedSearch,
37
+ applySafeAreaInset
30
38
  }) {
39
+ /**
40
+ * The detail renders at the IMMEDIATE selection — every step, no delay.
41
+ * Measured, 100 steps at a 150ms tap interval over 64KB payloads:
42
+ *
43
+ * whole detail, bodies included ... 647ms/step
44
+ * detail with no bodies at all .... 43ms/step (over the 150ms tap gap)
45
+ *
46
+ * So ~93% of a step is the request/response BODY trees, and everything else
47
+ * — status, URL breakdown, timing, header counts — is affordable at any tap
48
+ * rate. Deferring the whole detail (which is what this used to do) therefore
49
+ * bought nothing a user could feel: it made the cheap 43ms wait alongside
50
+ * the expensive 450ms instead of splitting them.
51
+ *
52
+ * So only the expensive part waits. `bodyId` trails `selectedId` by the
53
+ * settle window, so `heavyReady` is false exactly while the cursor is still
54
+ * moving — the JSON trees are skipped during a burst and mount once you land.
55
+ */
56
+ const eventId = useSelectedNetworkEventId();
57
+ const settledId = useNetworkBodyEventId();
58
+ const heavyReady = eventId != null && eventId === settledId;
31
59
  // Resolve the ID to the live event so a request opened while pending picks
32
60
  // up its response/error in place instead of freezing on the snapshot taken
33
61
  // when the row was tapped.
34
62
  const event = useLiveNetworkEvent(eventId, {
35
63
  paused: paused || !visible
36
64
  });
37
- if (!visible || !event) return null;
38
- return /*#__PURE__*/_jsx(NetworkEventDetailView, {
39
- event: event,
40
- ignoredPatterns: ignoredPatternValues,
41
- onTogglePattern: onTogglePattern
65
+
66
+ // Instrumentation for the benchmark harness. Gated on `heavyReady` on
67
+ // purpose: the cheap chrome now renders on every step, so reporting here
68
+ // unconditionally would make `bodyMs` a duplicate of `cursorMs`. What the
69
+ // harness needs to know is when the expensive TREES landed.
70
+ useEffect(() => {
71
+ if (visible && event && heavyReady) {
72
+ networkSelectionStore.reportRendered(event.id);
73
+ }
74
+ }, [visible, event, heavyReady]);
75
+ if (!visible) return null;
76
+ // Column: the detail body scrolls, the stepper stays pinned under it. The
77
+ // body owns its scroll here (see `bodyOwnsScroll`) — inside JsModal's
78
+ // wrapper the footer would scroll away with the content. The spacer keeps
79
+ // the footer at the bottom on the rare frame where the ID resolves to
80
+ // nothing (cleared, aged out, and not saved).
81
+ return /*#__PURE__*/_jsxs(View, {
82
+ style: styles.detailRoot,
83
+ children: [event ? /*#__PURE__*/_jsx(NetworkEventDetailView, {
84
+ event: event,
85
+ ignoredPatterns: ignoredPatternValues,
86
+ onTogglePattern: onTogglePattern,
87
+ showBodies: heavyReady
88
+ }) : /*#__PURE__*/_jsx(View, {
89
+ style: styles.detailBodyFallback
90
+ }), /*#__PURE__*/_jsx(NetworkDetailStepper, {
91
+ paused: paused,
92
+ filter: filter,
93
+ ignoredPatterns: ignoredPatterns,
94
+ isPro: isPro,
95
+ fromSaved: fromSaved,
96
+ savedSearch: savedSearch,
97
+ applySafeAreaInset: applySafeAreaInset
98
+ })]
42
99
  });
43
100
  }
44
101
  function NetworkFilterScreen({
@@ -131,14 +188,24 @@ function NetworkModalInner({
131
188
  if (controlledPause) onPausedChange(value);else setPausedState(value);
132
189
  };
133
190
  const [filter, setFilter] = useState({});
134
- // The SELECTION is UI state (an ID); the event object it points at is read
135
- // live from the store by the detail screen — see useLiveNetworkEvent.
136
- const [selectedEventId, setSelectedEventId] = useState(null);
191
+ /**
192
+ * The cursor lives in `networkSelectionStore`, and the root subscribes to
193
+ * the BOOLEAN only. That is the whole point: this component re-renders
194
+ * `JsModal` (which can never bail — its `header` prop is a fresh object
195
+ * every render), the entire header, and all five screens. None of them care
196
+ * WHICH request is selected, only THAT one is, and this flips twice a
197
+ * session instead of once per tap.
198
+ */
199
+ const hasSelection = useHasNetworkSelection();
137
200
  const [showFilterView, setShowFilterView] = useState(false);
138
201
  const [showSavedView, setShowSavedView] = useState(false);
139
202
  const [activeTab, setActiveTab] = useState("filters");
140
203
  const [searchText, setSearchText] = useState("");
141
204
  const [isSearchActive, setIsSearchActive] = useState(false);
205
+ // The Saved screen's search box lives HERE, not in that screen, because the
206
+ // detail footer steps through the same list the user was just looking at —
207
+ // and the Saved screen is unmounted while a detail is open.
208
+ const [savedSearch, setSavedSearch] = useState("");
142
209
  const searchInputRef = useRef(null);
143
210
  // Ignored domains/URL patterns live in a shared, persisted singleton store
144
211
  // (@buoy-gg/shared-ui) so the Network tool and the Events tool stay in sync.
@@ -203,12 +270,12 @@ function NetworkModalInner({
203
270
 
204
271
  // Simple handlers - no useCallback needed per rule2
205
272
  const handleBack = () => {
206
- setSelectedEventId(null);
273
+ networkSelectionStore.clear();
207
274
  };
208
275
 
209
276
  // Stable identity: this is passed down to the memoized list rows.
210
277
  const handleEventPress = useCallback(event => {
211
- setSelectedEventId(event.id);
278
+ networkSelectionStore.select(event.id);
212
279
  }, []);
213
280
  const handleSearch = text => {
214
281
  setSearchText(text);
@@ -223,8 +290,13 @@ function NetworkModalInner({
223
290
  status: prev.status === status ? undefined : status
224
291
  }));
225
292
  };
226
- const handleModeChange = _mode => {
227
- // Mode changes handled by JsModal
293
+
294
+ // JsModal owns the mode; the only thing this modal needs it for is the
295
+ // detail footer's bottom inset — a bottom sheet sits on the home indicator,
296
+ // a floating window does not (same call StorageModalWithTabs makes).
297
+ const [modalMode, setModalMode] = useState("bottomSheet");
298
+ const handleModeChange = mode => {
299
+ setModalMode(mode);
228
300
  };
229
301
  useEffect(() => {
230
302
  if (isSearchActive) {
@@ -261,7 +333,7 @@ function NetworkModalInner({
261
333
  }
262
334
 
263
335
  // Event detail view header
264
- if (selectedEventId) {
336
+ if (hasSelection) {
265
337
  return /*#__PURE__*/_jsxs(ModalHeader, {
266
338
  children: [/*#__PURE__*/_jsx(ModalHeader.Navigation, {
267
339
  onBack: handleBack
@@ -270,7 +342,6 @@ function NetworkModalInner({
270
342
  centered: true
271
343
  }), /*#__PURE__*/_jsx(ModalHeader.Actions, {
272
344
  children: /*#__PURE__*/_jsx(NetworkDetailPinActions, {
273
- eventId: selectedEventId,
274
345
  isPro: isPro,
275
346
  onUpgradePress: () => showUpgrade("saved")
276
347
  })
@@ -381,19 +452,31 @@ function NetworkModalInner({
381
452
  // it — inside JsModal's ScrollView wrapper every row mounts, which at
382
453
  // the 500-event cap measured 17 JS FPS / 133% CPU / 1.4GB; see
383
454
  // NetworkListScreen). The Saved screen is a FlatList too, so it takes the
384
- // same deal. Detail/filter/copy still use the wrapper.
385
- const listOwnsScroll = selectedEventId == null && !showFilterView;
455
+ // same deal. The DETAIL screen owns it as well, so its prev/next footer
456
+ // stays pinned instead of scrolling away with the body. Filter/copy still
457
+ // use the wrapper.
458
+ const bodyOwnsScroll = hasSelection || !showFilterView;
386
459
  const body = /*#__PURE__*/_jsxs(_Fragment, {
387
460
  children: [/*#__PURE__*/_jsxs(View, {
388
461
  style: styles.container,
389
462
  children: [/*#__PURE__*/_jsx(NetworkDetailScreen, {
390
- visible: selectedEventId != null,
391
- eventId: selectedEventId,
463
+ visible: hasSelection,
392
464
  paused: paused,
393
465
  ignoredPatternValues: ignoredPatternValues,
394
- onTogglePattern: toggleIgnoredPattern
466
+ onTogglePattern: toggleIgnoredPattern,
467
+ isPro: isPro,
468
+ filter: filter,
469
+ ignoredPatterns: ignoredPatterns
470
+ // `showSavedView` is what Back returns to, so it is also which list
471
+ // the user came from — and therefore which one to step through.
472
+ ,
473
+ fromSaved: showSavedView,
474
+ savedSearch: savedSearch
475
+ // Inline (desktop panel) has no home indicator to clear.
476
+ ,
477
+ applySafeAreaInset: !inline && modalMode === "bottomSheet"
395
478
  }), /*#__PURE__*/_jsx(NetworkFilterScreen, {
396
- visible: showFilterView && activeTab === "filters" && !selectedEventId,
479
+ visible: showFilterView && activeTab === "filters" && !hasSelection,
397
480
  paused: paused,
398
481
  isPro: isPro,
399
482
  filter: filter,
@@ -403,7 +486,7 @@ function NetworkModalInner({
403
486
  onAddPattern: addIgnoredPattern,
404
487
  onTogglePatternMode: toggleIgnoredPatternMode
405
488
  }), /*#__PURE__*/_jsx(NetworkCopyScreen, {
406
- visible: showFilterView && activeTab === "copy" && !selectedEventId,
489
+ visible: showFilterView && activeTab === "copy" && !hasSelection,
407
490
  paused: paused,
408
491
  isPro: isPro,
409
492
  filter: filter,
@@ -411,13 +494,15 @@ function NetworkModalInner({
411
494
  settings: copySettings,
412
495
  onSettingsChange: setCopySettings
413
496
  }), /*#__PURE__*/_jsx(NetworkSavedScreen, {
414
- visible: showSavedView && selectedEventId == null && !showFilterView,
497
+ visible: showSavedView && !hasSelection && !showFilterView,
415
498
  isPro: isPro,
416
499
  copySettings: copySettings,
500
+ search: savedSearch,
501
+ onSearchChange: setSavedSearch,
417
502
  onEventPress: handleEventPress,
418
503
  onUpgradePress: () => showUpgrade("saved")
419
504
  }), /*#__PURE__*/_jsx(NetworkListScreen, {
420
- visible: selectedEventId == null && !showFilterView && !showSavedView,
505
+ visible: !hasSelection && !showFilterView && !showSavedView,
421
506
  paused: paused,
422
507
  filter: filter,
423
508
  ignoredPatterns: ignoredPatterns,
@@ -459,7 +544,7 @@ function NetworkModalInner({
459
544
  enablePersistence: true,
460
545
  initialMode: "bottomSheet",
461
546
  enableGlitchEffects: true,
462
- disableScrollWrapper: listOwnsScroll,
547
+ disableScrollWrapper: bodyOwnsScroll,
463
548
  styles: {},
464
549
  children: body
465
550
  });
@@ -469,6 +554,16 @@ const styles = StyleSheet.create({
469
554
  flex: 1,
470
555
  backgroundColor: macOSColors.background.base
471
556
  },
557
+ /** Detail = scrolling body + pinned stepper footer. `minHeight: 0` so the
558
+ * body's ScrollView scrolls instead of stretching the column. */
559
+ detailRoot: {
560
+ flex: 1,
561
+ minHeight: 0
562
+ },
563
+ /** Holds the footer down when the selected ID resolves to no event. */
564
+ detailBodyFallback: {
565
+ flex: 1
566
+ },
472
567
  /** Inline shell — fills the host panel; `minHeight: 0` so the list's own
473
568
  * FlatList can scroll instead of stretching the column. */
474
569
  inlineRoot: {
@@ -23,9 +23,9 @@ import { Bookmark, CopyButton, Search, macOSColors } from "@buoy-gg/shared-ui";
23
23
  import { NetworkEventItemCompact } from "./NetworkEventItemCompact";
24
24
  import { NetworkUpgradeBanner } from "./NetworkUpgradeBanner";
25
25
  import { generateNetworkCopyText } from "../utils/generateNetworkCopyText";
26
- import { filterNetworkEvents } from "../utils/filterNetworkEvents";
26
+ import { selectSavedEvents } from "../utils/selectSavedEvents";
27
27
  import { useNetworkSaved } from "../hooks/useNetworkSaved";
28
- import { FREE_TIER_SAVED_LIMIT, MAX_SAVED } from "../utils/networkSavedStore";
28
+ import { FREE_TIER_SAVED_LIMIT } from "../utils/networkSavedStore";
29
29
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
30
30
  const keyExtractor = item => item.id;
31
31
  function EmptyState() {
@@ -47,6 +47,8 @@ export function NetworkSavedScreen({
47
47
  visible,
48
48
  isPro,
49
49
  copySettings,
50
+ search: searchProp,
51
+ onSearchChange,
50
52
  onEventPress,
51
53
  onUpgradePress
52
54
  }) {
@@ -56,22 +58,17 @@ export function NetworkSavedScreen({
56
58
  toggleSave,
57
59
  clearSaved
58
60
  } = useNetworkSaved();
59
- const [search, setSearch] = useState("");
61
+ const [searchState, setSearchState] = useState("");
62
+ const controlled = searchProp != null && onSearchChange != null;
63
+ const search = controlled ? searchProp : searchState;
64
+ const setSearch = controlled ? onSearchChange : setSearchState;
60
65
  if (!visible) return null;
61
66
 
62
- /**
63
- * A downgrade (or a limit change) can leave a free user holding more saves
64
- * than the free tier allows. Rather than deleting their data, the overflow
65
- * is LOCKED — oldest first, so the newest saves stay reachable — and the
66
- * banner offers the upgrade that unlocks it.
67
- */
68
- const limit = isPro ? MAX_SAVED : FREE_TIER_SAVED_LIMIT;
69
- const unlocked = savedRecords.slice(0, limit);
70
- const lockedCount = savedRecords.length - unlocked.length;
71
- const events = unlocked.map(record => record.event);
72
- const filtered = search ? filterNetworkEvents(events, {
73
- searchText: search
74
- }) : events;
67
+ // Shared with the detail footer, so "Next" can't disagree with the row below.
68
+ const {
69
+ events: filtered,
70
+ lockedCount
71
+ } = selectSavedEvents(savedRecords, isPro, search);
75
72
  const handleLongPress = event => {
76
73
  toggleSave(event);
77
74
  };
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Narrow subscriptions to the prev/next cursor (`networkSelectionStore`).
5
+ *
6
+ * One hook per consumer, each returning the SMALLEST value that consumer
7
+ * renders — that is the whole point of moving the cursor into a store. The
8
+ * modal root takes `useHasNetworkSelection()` (a boolean that flips twice a
9
+ * session), so a step no longer re-renders `JsModal`, the header and all five
10
+ * screens; the footer and the body take the parts that actually change.
11
+ */
12
+
13
+ import { useSyncExternalStore } from "react";
14
+ import { networkSelectionStore } from "../utils/networkSelectionStore";
15
+ const subscribe = onChange => networkSelectionStore.subscribe(onChange);
16
+ const getState = () => networkSelectionStore.getState();
17
+
18
+ /**
19
+ * Is ANYTHING selected — i.e. is the detail screen up?
20
+ *
21
+ * `useSyncExternalStore` compares the selector's result, so the root only
22
+ * re-renders when this boolean flips, not on every step.
23
+ */
24
+ export function useHasNetworkSelection() {
25
+ return useSyncExternalStore(subscribe, () => getState().selectedId != null, () => getState().selectedId != null);
26
+ }
27
+
28
+ /** Where the USER is. Moves synchronously on tap — footer, header pin actions. */
29
+ export function useSelectedNetworkEventId() {
30
+ return useSyncExternalStore(subscribe, () => getState().selectedId, () => getState().selectedId);
31
+ }
32
+
33
+ /** What the BODY may render. Lags `selectedId` — see networkSelectionStore. */
34
+ export function useNetworkBodyEventId() {
35
+ return useSyncExternalStore(subscribe, () => getState().bodyId, () => getState().bodyId);
36
+ }
37
+
38
+ /** Cursor position + list length, for the "REQUEST n OF m" counter. */
39
+ export function useNetworkCursor() {
40
+ const index = useSyncExternalStore(subscribe, () => getState().index, () => getState().index);
41
+ const total = useSyncExternalStore(subscribe, () => getState().ids.length, () => getState().ids.length);
42
+ return {
43
+ index,
44
+ total
45
+ };
46
+ }
@@ -0,0 +1,188 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Which request the detail view is showing, and where it sits in the list —
5
+ * the prev/next CURSOR.
6
+ *
7
+ * ## Why this is a store and not `useState` in the modal
8
+ *
9
+ * It used to be `useState` in `NetworkModalInner`. Every tap on Previous/Next
10
+ * therefore dirtied the modal ROOT, which re-rendered `JsModal` (1833 lines,
11
+ * nine useStates, animations/gestures/persistence — and it can never bail out,
12
+ * because its `header` prop is a fresh object literal every render), the whole
13
+ * header, and all five screens. None of them care which request is selected;
14
+ * they only care THAT one is.
15
+ *
16
+ * This is the same argument `NetworkModal`'s own header comment already makes
17
+ * about network events ("with the subscription here, 15 events re-rendered
18
+ * JsModal + every header button 15×"). Stepping is that same class of
19
+ * high-frequency churn, and it was sitting in the same wrong place. Now the
20
+ * root subscribes to `hasSelection` — a boolean that flips twice a session —
21
+ * while the footer, the body and the header's pin actions subscribe to the
22
+ * parts they actually render.
23
+ *
24
+ * ## The three cursors
25
+ *
26
+ * `selectedId` is where the USER is. It moves on the tap, synchronously.
27
+ *
28
+ * `bodyId` is what the detail body is allowed to render, and it deliberately
29
+ * LAGS: drawing a request means handing its payload to the virtualized data
30
+ * viewer to flatten, and that has no business blocking the next tap. It
31
+ * catches up only once the selection has held still for `settleMs`, so a
32
+ * burst of taps renders the request you land on and none of the ones you flew
33
+ * past. (`useDeferredValue` was tried here first and is the wrong tool — it
34
+ * lowers the priority of that work without preventing it, so every settled
35
+ * step still queued a flatten and each one delayed the touch behind it.)
36
+ *
37
+ * `renderedId` is what actually made it to the screen, reported by the detail
38
+ * screen after commit. Nothing in the product reads it; it exists so the
39
+ * benchmark harness can measure "time until request N was really painted"
40
+ * separately from "time until the cursor reached N".
41
+ */
42
+
43
+ /**
44
+ * How long the selection must hold still before the body renders it. Short
45
+ * enough that a single step reads as immediate (the footer and header have
46
+ * already moved), long enough that a burst collapses to one render.
47
+ */
48
+ export const DEFAULT_SETTLE_MS = 120;
49
+ class NetworkSelectionStore {
50
+ ids = [];
51
+ index = -1;
52
+ selectedId = null;
53
+ bodyId = null;
54
+ renderedId = null;
55
+ settleMs = DEFAULT_SETTLE_MS;
56
+ settleTimer = null;
57
+ subscribers = new Set();
58
+ snapshot = this.buildSnapshot();
59
+ buildSnapshot() {
60
+ return {
61
+ ids: this.ids,
62
+ index: this.index,
63
+ selectedId: this.selectedId,
64
+ bodyId: this.bodyId,
65
+ renderedId: this.renderedId
66
+ };
67
+ }
68
+
69
+ /**
70
+ * One immutable snapshot per mutation, so subscribers can identity-compare
71
+ * and `useSyncExternalStore` stays happy.
72
+ */
73
+ emit() {
74
+ this.snapshot = this.buildSnapshot();
75
+ for (const subscriber of this.subscribers) subscriber(this.snapshot);
76
+ }
77
+ getState() {
78
+ return this.snapshot;
79
+ }
80
+ subscribe(subscriber) {
81
+ this.subscribers.add(subscriber);
82
+ return () => {
83
+ this.subscribers.delete(subscriber);
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Publish the navigable order. Called by the stepper whenever its derived
89
+ * list changes (filter, search, pins, new requests arriving).
90
+ *
91
+ * The cursor is re-anchored BY ID, not by position: the live list is
92
+ * newest-first and new requests are prepended, so holding an index would
93
+ * silently slide the selection onto a different request every time one
94
+ * landed.
95
+ */
96
+ setIds(ids) {
97
+ const sameIds = ids.length === this.ids.length && ids.every((id, i) => id === this.ids[i]);
98
+ if (sameIds) return;
99
+ this.ids = ids;
100
+ this.index = this.selectedId == null ? -1 : ids.indexOf(this.selectedId);
101
+ this.emit();
102
+ }
103
+ select(id) {
104
+ if (id === this.selectedId) return;
105
+ this.selectedId = id;
106
+ this.index = id == null ? -1 : this.ids.indexOf(id);
107
+ this.scheduleBody(id);
108
+ this.emit();
109
+ }
110
+ clear() {
111
+ this.select(null);
112
+ }
113
+
114
+ /**
115
+ * Move the cursor. O(1) — the whole reason `index` is stored rather than
116
+ * re-derived with a `findIndex` on every render.
117
+ *
118
+ * Returns the id it landed on, or null if the move was out of bounds (the
119
+ * footer disables its arrows at the ends, but the benchmark driver calls
120
+ * this directly and needs to know when it has run off the end).
121
+ */
122
+ step(delta) {
123
+ if (this.index < 0) return null;
124
+ const next = this.index + delta;
125
+ if (next < 0 || next >= this.ids.length) return null;
126
+ const id = this.ids[next];
127
+ this.selectedId = id;
128
+ this.index = next;
129
+ this.scheduleBody(id);
130
+ this.emit();
131
+ return id;
132
+ }
133
+
134
+ /**
135
+ * Opening and closing skip the settle delay: there is no in-flight render to
136
+ * protect on a null↔id transition, and waiting there just reads as a hang.
137
+ * Only id → id steps debounce.
138
+ */
139
+ scheduleBody(id) {
140
+ if (this.settleTimer != null) {
141
+ clearTimeout(this.settleTimer);
142
+ this.settleTimer = null;
143
+ }
144
+ if (id == null || this.bodyId == null || this.settleMs <= 0) {
145
+ this.bodyId = id;
146
+ return;
147
+ }
148
+ this.settleTimer = setTimeout(() => {
149
+ this.settleTimer = null;
150
+ if (this.bodyId === this.selectedId) return;
151
+ this.bodyId = this.selectedId;
152
+ this.emit();
153
+ }, this.settleMs);
154
+ }
155
+
156
+ /** Benchmark toggle — `?settle=0` measures the gate's contribution. */
157
+ setSettleMs(ms) {
158
+ this.settleMs = Math.max(0, ms);
159
+ }
160
+ getSettleMs() {
161
+ return this.settleMs;
162
+ }
163
+
164
+ /** Reported by the detail screen after commit. Instrumentation only. */
165
+ reportRendered(id) {
166
+ if (id === this.renderedId) return;
167
+ this.renderedId = id;
168
+ this.emit();
169
+ }
170
+
171
+ /** Test/bench helper — drop all cursor state without touching the events. */
172
+ reset() {
173
+ if (this.settleTimer != null) clearTimeout(this.settleTimer);
174
+ this.settleTimer = null;
175
+ this.ids = [];
176
+ this.index = -1;
177
+ this.selectedId = null;
178
+ this.bodyId = null;
179
+ this.renderedId = null;
180
+ this.emit();
181
+ }
182
+ }
183
+ export const networkSelectionStore = new NetworkSelectionStore();
184
+
185
+ /** Convenience for publishing an event list as the navigable order. */
186
+ export function toSelectionIds(events) {
187
+ return events.map(event => event.id);
188
+ }
@@ -20,6 +20,7 @@ export { useLiveNetworkEvent } from "./network/hooks/useLiveNetworkEvent";
20
20
  export { useNetworkBadgeCounts } from "./network/hooks/useNetworkBadgeCounts";
21
21
  export { useNetworkSaved, type UseNetworkSavedResult, } from "./network/hooks/useNetworkSaved";
22
22
  export { splitPinned, type PinnedSplit } from "./network/utils/withPinnedFirst";
23
+ export { networkSelectionStore, DEFAULT_SETTLE_MS, type NetworkSelectionState, } from "./network/utils/networkSelectionStore";
23
24
  export { TickProvider, useTickEveryMinute } from "./network/hooks/useTickEveryMinute";
24
25
  export { formatBytes, formatDuration, formatHttpStatus, } from "./network/utils/formatting";
25
26
  export type { NetworkEvent, NetworkStats, NetworkFilter, NetworkEventStatus, NetworkInsight, IgnoredPattern, IgnoredPatternMatchMode, } from "./network/types";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.tsx"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAOhE,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AAClF,YAAY,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,OAAO,EAAE,sBAAsB,EAAE,MAAM,6CAA6C,CAAC;AACrF,OAAO,EAAE,uBAAuB,EAAE,MAAM,8CAA8C,CAAC;AACvF,OAAO,EAAE,kBAAkB,EAAE,MAAM,yCAAyC,CAAC;AAC7E,OAAO,EACL,kBAAkB,EAClB,uBAAuB,GACxB,MAAM,wCAAwC,CAAC;AAKhD,OAAO,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AAIpE,OAAO,EAAE,mBAAmB,EAAE,MAAM,qCAAqC,CAAC;AAC1E,OAAO,EAAE,mBAAmB,EAAE,MAAM,qCAAqC,CAAC;AAC1E,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAI9E,OAAO,EACL,eAAe,EACf,KAAK,qBAAqB,GAC3B,MAAM,iCAAiC,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,KAAK,WAAW,EAAE,MAAM,iCAAiC,CAAC;AAChF,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAKtF,OAAO,EACL,WAAW,EACX,cAAc,EACd,gBAAgB,GACjB,MAAM,4BAA4B,CAAC;AAKpC,YAAY,EACV,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,kBAAkB,EAClB,cAAc,EACd,cAAc,EACd,uBAAuB,GACxB,MAAM,iBAAiB,CAAC;AAQzB,OAAO,EACL,kBAAkB,EAClB,oBAAoB,EACpB,wBAAwB,EACxB,YAAY,GACb,MAAM,oBAAoB,CAAC;AAK5B,OAAO,EAAE,kBAAkB,EAAE,MAAM,mCAAmC,CAAC;AACvE,OAAO,EACL,2BAA2B,EAC3B,KAAK,mBAAmB,EACxB,KAAK,mBAAmB,GACzB,MAAM,oCAAoC,CAAC;AAO5C,gBAAgB;AAChB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AACtE;;;GAGG;AACH,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,qBAAqB,EACrB,UAAU,EACV,SAAS,EACT,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,EACtB,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,GACvB,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAC5D,gBAAgB;AAChB,OAAO,EACL,eAAe,EACf,kBAAkB,EAClB,yBAAyB,GAC1B,MAAM,iCAAiC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.tsx"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAOhE,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AAClF,YAAY,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,OAAO,EAAE,sBAAsB,EAAE,MAAM,6CAA6C,CAAC;AACrF,OAAO,EAAE,uBAAuB,EAAE,MAAM,8CAA8C,CAAC;AACvF,OAAO,EAAE,kBAAkB,EAAE,MAAM,yCAAyC,CAAC;AAC7E,OAAO,EACL,kBAAkB,EAClB,uBAAuB,GACxB,MAAM,wCAAwC,CAAC;AAKhD,OAAO,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AAIpE,OAAO,EAAE,mBAAmB,EAAE,MAAM,qCAAqC,CAAC;AAC1E,OAAO,EAAE,mBAAmB,EAAE,MAAM,qCAAqC,CAAC;AAC1E,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAI9E,OAAO,EACL,eAAe,EACf,KAAK,qBAAqB,GAC3B,MAAM,iCAAiC,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,KAAK,WAAW,EAAE,MAAM,iCAAiC,CAAC;AAIhF,OAAO,EACL,qBAAqB,EACrB,iBAAiB,EACjB,KAAK,qBAAqB,GAC3B,MAAM,uCAAuC,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAKtF,OAAO,EACL,WAAW,EACX,cAAc,EACd,gBAAgB,GACjB,MAAM,4BAA4B,CAAC;AAKpC,YAAY,EACV,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,kBAAkB,EAClB,cAAc,EACd,cAAc,EACd,uBAAuB,GACxB,MAAM,iBAAiB,CAAC;AAQzB,OAAO,EACL,kBAAkB,EAClB,oBAAoB,EACpB,wBAAwB,EACxB,YAAY,GACb,MAAM,oBAAoB,CAAC;AAK5B,OAAO,EAAE,kBAAkB,EAAE,MAAM,mCAAmC,CAAC;AACvE,OAAO,EACL,2BAA2B,EAC3B,KAAK,mBAAmB,EACxB,KAAK,mBAAmB,GACzB,MAAM,oCAAoC,CAAC;AAO5C,gBAAgB;AAChB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AACtE;;;GAGG;AACH,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,qBAAqB,EACrB,UAAU,EACV,SAAS,EACT,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,EACtB,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,GACvB,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAC5D,gBAAgB;AAChB,OAAO,EACL,eAAe,EACf,kBAAkB,EAClB,yBAAyB,GAC1B,MAAM,iCAAiC,CAAC"}
@@ -5,8 +5,8 @@
5
5
  * It is its OWN leaf (rule3) because it is the only part of the detail screen
6
6
  * that needs the event LIST. Subscribing here keeps the request stream out of
7
7
  * the detail body's render path, which is the expensive subtree (headers,
8
- * bodies, JSON viewers, diff views): a request arriving re-renders this
9
- * ~30-node footer and nothing else.
8
+ * bodies, JSON viewers): a request arriving re-renders this ~30-node footer
9
+ * and nothing else.
10
10
  *
11
11
  * The order it steps through is EXACTLY what the list screen renders —
12
12
  * `[...pinned, ...rest]` off the same `useNetworkEventList` + `splitPinned`
@@ -16,12 +16,16 @@
16
16
  * "request N of <what the list is showing>", never "N of everything ever
17
17
  * captured". Detail opened from the Saved screen steps through the SAVED
18
18
  * list instead (with its own search applied), for the same reason.
19
+ *
20
+ * This component OWNS the navigable order but not the cursor: it publishes
21
+ * the ordered ids into `networkSelectionStore` and reads the cursor back.
22
+ * That split is what makes stepping O(1) (the store keeps the index, so
23
+ * there is no `findIndex` per render) and what lets the benchmark harness
24
+ * drive `step(+1)` directly without going through the UI.
19
25
  */
20
26
  import { type IgnoredPattern } from "@buoy-gg/shared-ui";
21
27
  import type { NetworkFilter } from "../types";
22
28
  interface NetworkDetailStepperProps {
23
- /** The request currently on screen. */
24
- eventId: string;
25
29
  paused: boolean;
26
30
  filter: NetworkFilter;
27
31
  ignoredPatterns: readonly IgnoredPattern[];
@@ -30,7 +34,8 @@ interface NetworkDetailStepperProps {
30
34
  fromSaved: boolean;
31
35
  /** The Saved screen's search box (lifted to the modal root so both agree). */
32
36
  savedSearch: string;
33
- onSelect: (eventId: string) => void;
37
+ /** Only a bottom sheet sits on the home indicator — see NetworkModal. */
38
+ applySafeAreaInset: boolean;
34
39
  }
35
40
  export declare const NetworkDetailStepper: import("react").NamedExoticComponent<NetworkDetailStepperProps>;
36
41
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"NetworkDetailStepper.d.ts","sourceRoot":"","sources":["../../../../src/network/components/NetworkDetailStepper.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAC7E,OAAO,KAAK,EAAgB,aAAa,EAAE,MAAM,UAAU,CAAC;AAM5D,UAAU,yBAAyB;IACjC,uCAAuC;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,aAAa,CAAC;IACtB,eAAe,EAAE,SAAS,cAAc,EAAE,CAAC;IAC3C,KAAK,EAAE,OAAO,CAAC;IACf,wEAAwE;IACxE,SAAS,EAAE,OAAO,CAAC;IACnB,8EAA8E;IAC9E,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACrC;AAED,eAAO,MAAM,oBAAoB,iEAiE/B,CAAC"}
1
+ {"version":3,"file":"NetworkDetailStepper.d.ts","sourceRoot":"","sources":["../../../../src/network/components/NetworkDetailStepper.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAGH,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAC7E,OAAO,KAAK,EAAgB,aAAa,EAAE,MAAM,UAAU,CAAC;AAW5D,UAAU,yBAAyB;IACjC,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,aAAa,CAAC;IACtB,eAAe,EAAE,SAAS,cAAc,EAAE,CAAC;IAC3C,KAAK,EAAE,OAAO,CAAC;IACf,wEAAwE;IACxE,SAAS,EAAE,OAAO,CAAC;IACnB,8EAA8E;IAC9E,WAAW,EAAE,MAAM,CAAC;IACpB,yEAAyE;IACzE,kBAAkB,EAAE,OAAO,CAAC;CAC7B;AAMD,eAAO,MAAM,oBAAoB,iEAiE/B,CAAC"}
@@ -3,11 +3,46 @@ interface NetworkEventDetailViewProps {
3
3
  event: NetworkEvent;
4
4
  ignoredPatterns?: Set<string>;
5
5
  onTogglePattern?: (pattern: string) => void;
6
+ /**
7
+ * Mount the request/response BODY trees.
8
+ *
9
+ * False while the prev/next cursor is still moving. The body trees are ~93%
10
+ * of a step's cost (measured: 647ms/step with them, 43ms without, at a 150ms
11
+ * tap interval over 64KB payloads), and they are the only part of this view
12
+ * that can't keep up with a thumb. Everything else renders on every step.
13
+ *
14
+ * Note the mechanism: this doesn't skip the tree so much as ensure it gets
15
+ * unmounted by the next tap before its chunked flatten completes. Stepping
16
+ * therefore costs ~14ms/step instead of ~500ms, and exactly one tree
17
+ * flattens for real — the one you stop on.
18
+ *
19
+ * Defaults true so the Events tool — which renders this same view without a
20
+ * cursor — is unaffected.
21
+ */
22
+ showBodies?: boolean;
6
23
  }
7
24
  /**
8
25
  * Detailed inspector rendering request/response metadata for a single network event. Designed to
9
26
  * mimic Sentry’s layout with collapsible sections, diff views, and quick copy affordances.
27
+ *
28
+ * MEMOIZED with profiling evidence, not by reflex. Two render paths hit this
29
+ * subtree with unchanged props:
30
+ *
31
+ * 1. `useLiveNetworkEvent` re-subscribes when the selected ID changes, and
32
+ * `subscribeToEvents` replays immediately — so every step through the
33
+ * prev/next footer committed the SAME event object twice, rendering this
34
+ * whole tree (expanded DataViewer included) a second time for nothing.
35
+ * 2. Any modal-root state change (header search, mode toggle) re-renders the
36
+ * shell, and the detail body has nothing to do with it.
37
+ *
38
+ * Both props besides `event` are stable by construction — `values` is memoized
39
+ * in `useIgnoredPatterns` and `toggle` is module-scope there — so the bail-out
40
+ * actually fires.
41
+ *
42
+ * Measured baseline (js-top, example-dev-build, 5 steps over 109–324KB
43
+ * pokeapi responses): 307ms of React work across 5 steps ≈ 61ms of JS per
44
+ * step. Case 1 alone is half of that, since it re-ran the whole body render.
10
45
  */
11
- export declare function NetworkEventDetailView({ event, ignoredPatterns, onTogglePattern, }: NetworkEventDetailViewProps): import("react").JSX.Element;
46
+ export declare const NetworkEventDetailView: import("react").NamedExoticComponent<NetworkEventDetailViewProps>;
12
47
  export {};
13
48
  //# sourceMappingURL=NetworkEventDetailView.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"NetworkEventDetailView.d.ts","sourceRoot":"","sources":["../../../../src/network/components/NetworkEventDetailView.tsx"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,UAAU,CAAC;AAqB7D,UAAU,2BAA2B;IACnC,KAAK,EAAE,YAAY,CAAC;IACpB,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,eAAe,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7C;AAmQD;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,EACrC,KAAK,EACL,eAA2B,EAC3B,eAA0B,GAC3B,EAAE,2BAA2B,+BAse7B"}
1
+ {"version":3,"file":"NetworkEventDetailView.d.ts","sourceRoot":"","sources":["../../../../src/network/components/NetworkEventDetailView.tsx"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,UAAU,CAAC;AA0B7D,UAAU,2BAA2B;IACnC,KAAK,EAAE,YAAY,CAAC;IACpB,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,eAAe,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5C;;;;;;;;;;;;;;;OAeG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AA4QD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,sBAAsB,mEA6fjC,CAAC"}