@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
package/README.md CHANGED
@@ -40,6 +40,7 @@ Want a different label or color? Build the tool with `createNetworkTool({ name,
40
40
  - **Full request detail** — request/response headers, payloads, HTTP status, and duration for every call, with status-colored rows.
41
41
  - **Filter and search** — narrow by status code, HTTP method, or URL pattern; search captured events; pause/resume capture; clear history.
42
42
  - **Pin the one that failed** — pin a request to hold it at the top of the list (ignoring your filters), or save it to a separate favorites list. Both keep a full snapshot, so it survives Clear, the 500-request cap, and an app restart. Long-press a row, or use the buttons in the request detail header.
43
+ - **Step between requests** — the detail view has a Previous/Next footer that walks exactly what the list was showing (same pins, filters, and search — or your Saved list), so comparing two calls doesn't mean going back and finding your place.
43
44
  - **Ignore the noise** — a shared ignored-domains/URL filter (`useIgnoredPatterns`) hides endpoints you never want to see, shared with the Events tool.
44
45
  - **Hooks for custom UIs** — `useNetworkEvents`, `useNetworkEventList`, `useNetworkBadgeCounts`, and `useNetworkSaved` expose the capture stream with narrow per-consumer subscriptions.
45
46
 
@@ -3,6 +3,12 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
+ Object.defineProperty(exports, "DEFAULT_SETTLE_MS", {
7
+ enumerable: true,
8
+ get: function () {
9
+ return _networkSelectionStore.DEFAULT_SETTLE_MS;
10
+ }
11
+ });
6
12
  Object.defineProperty(exports, "FREE_TIER_SAVED_LIMIT", {
7
13
  enumerable: true,
8
14
  get: function () {
@@ -147,6 +153,12 @@ Object.defineProperty(exports, "networkSavedStore", {
147
153
  return _networkSavedStore.networkSavedStore;
148
154
  }
149
155
  });
156
+ Object.defineProperty(exports, "networkSelectionStore", {
157
+ enumerable: true,
158
+ get: function () {
159
+ return _networkSelectionStore.networkSelectionStore;
160
+ }
161
+ });
150
162
  Object.defineProperty(exports, "networkSyncAdapter", {
151
163
  enumerable: true,
152
164
  get: function () {
@@ -231,6 +243,7 @@ var _useLiveNetworkEvent = require("./network/hooks/useLiveNetworkEvent");
231
243
  var _useNetworkBadgeCounts = require("./network/hooks/useNetworkBadgeCounts");
232
244
  var _useNetworkSaved = require("./network/hooks/useNetworkSaved");
233
245
  var _withPinnedFirst = require("./network/utils/withPinnedFirst");
246
+ var _networkSelectionStore = require("./network/utils/networkSelectionStore");
234
247
  var _useTickEveryMinute = require("./network/hooks/useTickEveryMinute");
235
248
  var _formatting = require("./network/utils/formatting");
236
249
  var _sharedUi = require("@buoy-gg/shared-ui");
@@ -8,6 +8,8 @@ var _react = require("react");
8
8
  var _sharedUi = require("@buoy-gg/shared-ui");
9
9
  var _useNetworkEventList = require("../hooks/useNetworkEventList");
10
10
  var _useNetworkSaved = require("../hooks/useNetworkSaved");
11
+ var _useNetworkSelection = require("../hooks/useNetworkSelection");
12
+ var _networkSelectionStore = require("../utils/networkSelectionStore");
11
13
  var _withPinnedFirst = require("../utils/withPinnedFirst");
12
14
  var _selectSavedEvents = require("../utils/selectSavedEvents");
13
15
  var _jsxRuntime = require("react/jsx-runtime");
@@ -18,8 +20,8 @@ var _jsxRuntime = require("react/jsx-runtime");
18
20
  * It is its OWN leaf (rule3) because it is the only part of the detail screen
19
21
  * that needs the event LIST. Subscribing here keeps the request stream out of
20
22
  * the detail body's render path, which is the expensive subtree (headers,
21
- * bodies, JSON viewers, diff views): a request arriving re-renders this
22
- * ~30-node footer and nothing else.
23
+ * bodies, JSON viewers): a request arriving re-renders this ~30-node footer
24
+ * and nothing else.
23
25
  *
24
26
  * The order it steps through is EXACTLY what the list screen renders —
25
27
  * `[...pinned, ...rest]` off the same `useNetworkEventList` + `splitPinned`
@@ -29,17 +31,25 @@ var _jsxRuntime = require("react/jsx-runtime");
29
31
  * "request N of <what the list is showing>", never "N of everything ever
30
32
  * captured". Detail opened from the Saved screen steps through the SAVED
31
33
  * list instead (with its own search applied), for the same reason.
34
+ *
35
+ * This component OWNS the navigable order but not the cursor: it publishes
36
+ * the ordered ids into `networkSelectionStore` and reads the cursor back.
37
+ * That split is what makes stepping O(1) (the store keeps the index, so
38
+ * there is no `findIndex` per render) and what lets the benchmark harness
39
+ * drive `step(+1)` directly without going through the UI.
32
40
  */
33
41
 
42
+ /** Stable identities — the store is a singleton, so these never need to change. */
43
+ const stepPrevious = () => _networkSelectionStore.networkSelectionStore.step(-1);
44
+ const stepNext = () => _networkSelectionStore.networkSelectionStore.step(1);
34
45
  const NetworkDetailStepper = exports.NetworkDetailStepper = /*#__PURE__*/(0, _react.memo)(function NetworkDetailStepper({
35
- eventId,
36
46
  paused,
37
47
  filter,
38
48
  ignoredPatterns,
39
49
  isPro,
40
50
  fromSaved,
41
51
  savedSearch,
42
- onSelect
52
+ applySafeAreaInset
43
53
  }) {
44
54
  const {
45
55
  visibleEvents
@@ -66,7 +76,16 @@ const NetworkDetailStepper = exports.NetworkDetailStepper = /*#__PURE__*/(0, _re
66
76
  } = (0, _withPinnedFirst.splitPinned)(visibleEvents, pinnedEvents, pinnedLiveIds);
67
77
  return pinned.length === 0 ? rest : [...pinned, ...rest];
68
78
  }, [fromSaved, savedRecords, savedSearch, isPro, visibleEvents, pinnedEvents, pinnedLiveIds]);
69
- const index = ordered.findIndex(event => event.id === eventId);
79
+
80
+ // Publish the navigable order. `setIds` re-anchors the cursor BY ID and
81
+ // no-ops when the ids are unchanged, so this is safe to run every render.
82
+ (0, _react.useEffect)(() => {
83
+ _networkSelectionStore.networkSelectionStore.setIds((0, _networkSelectionStore.toSelectionIds)(ordered));
84
+ }, [ordered]);
85
+ const {
86
+ index,
87
+ total
88
+ } = (0, _useNetworkSelection.useNetworkCursor)();
70
89
 
71
90
  // Not in the list at all: the filter changed under it while it was open, it
72
91
  // aged past the 500-request cap, or it is a saved snapshot with no live twin.
@@ -78,15 +97,10 @@ const NetworkDetailStepper = exports.NetworkDetailStepper = /*#__PURE__*/(0, _re
78
97
  // the same direction as scrolling up, which is what the arrow means here.
79
98
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.EventStepperFooter, {
80
99
  currentIndex: index,
81
- totalItems: ordered.length,
100
+ totalItems: total,
82
101
  itemLabel: "Request",
83
- onPrevious: () => {
84
- const target = ordered[index - 1];
85
- if (target) onSelect(target.id);
86
- },
87
- onNext: () => {
88
- const target = ordered[index + 1];
89
- if (target) onSelect(target.id);
90
- }
102
+ applySafeAreaInset: applySafeAreaInset,
103
+ onPrevious: stepPrevious,
104
+ onNext: stepNext
91
105
  });
92
106
  });
@@ -3,7 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.NetworkEventDetailView = NetworkEventDetailView;
6
+ exports.NetworkEventDetailView = void 0;
7
7
  var _react = require("react");
8
8
  var _reactNative = require("react-native");
9
9
  var _sharedUi = require("@buoy-gg/shared-ui");
@@ -17,6 +17,10 @@ var _jsxRuntime = require("react/jsx-runtime");
17
17
  * body glyphs). The Lock/Unlock pair keeps its colour because
18
18
  * secure-vs-insecure is a genuine signal, as does the status pill.
19
19
  */const NEUTRAL_ICON = _sharedUi.macOSColors.semantic.success;
20
+
21
+ /** Hoisted so the defaults don't mint a new Set/function per render. */
22
+ const EMPTY_PATTERNS = new Set();
23
+ const noop = () => {};
20
24
  /**
21
25
  * The measurable slice of a request's lifetime, as proportional bars.
22
26
  *
@@ -82,6 +86,17 @@ const TimingPhases = ({
82
86
  });
83
87
  };
84
88
 
89
+ /**
90
+ * Stand-in for a body tree while the prev/next cursor is still moving.
91
+ *
92
+ * Deliberately a fixed-height nothing: it must cost ~0 to render (that is the
93
+ * entire point) and it must not resize the scroll view as the real tree swaps
94
+ * in, or fast stepping would make the page jump under the reader.
95
+ */
96
+ const BodyDeferred = () => /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
97
+ style: styles.bodyDeferred
98
+ });
99
+
85
100
  // Component for collapsible sections matching Sentry style
86
101
  const CollapsibleSection = ({
87
102
  title,
@@ -261,11 +276,30 @@ const UrlBreakdown = ({
261
276
  /**
262
277
  * Detailed inspector rendering request/response metadata for a single network event. Designed to
263
278
  * mimic Sentry’s layout with collapsible sections, diff views, and quick copy affordances.
279
+ *
280
+ * MEMOIZED with profiling evidence, not by reflex. Two render paths hit this
281
+ * subtree with unchanged props:
282
+ *
283
+ * 1. `useLiveNetworkEvent` re-subscribes when the selected ID changes, and
284
+ * `subscribeToEvents` replays immediately — so every step through the
285
+ * prev/next footer committed the SAME event object twice, rendering this
286
+ * whole tree (expanded DataViewer included) a second time for nothing.
287
+ * 2. Any modal-root state change (header search, mode toggle) re-renders the
288
+ * shell, and the detail body has nothing to do with it.
289
+ *
290
+ * Both props besides `event` are stable by construction — `values` is memoized
291
+ * in `useIgnoredPatterns` and `toggle` is module-scope there — so the bail-out
292
+ * actually fires.
293
+ *
294
+ * Measured baseline (js-top, example-dev-build, 5 steps over 109–324KB
295
+ * pokeapi responses): 307ms of React work across 5 steps ≈ 61ms of JS per
296
+ * step. Case 1 alone is half of that, since it re-ran the whole body render.
264
297
  */
265
- function NetworkEventDetailView({
298
+ const NetworkEventDetailView = exports.NetworkEventDetailView = /*#__PURE__*/(0, _react.memo)(function NetworkEventDetailView({
266
299
  event,
267
- ignoredPatterns = new Set(),
268
- onTogglePattern = () => {}
300
+ ignoredPatterns = EMPTY_PATTERNS,
301
+ onTogglePattern = noop,
302
+ showBodies = true
269
303
  }) {
270
304
  const status = event.status ? (0, _formatting.formatHttpStatus)(event.status) : null;
271
305
  const isPending = !event.status && !event.error;
@@ -409,8 +443,15 @@ ${JSON.stringify(requestDetails.responseData, null, 2)}
409
443
  style: styles.httpDurationText,
410
444
  children: (0, _formatting.formatDuration)(event.duration)
411
445
  })]
412
- }) : null, isPro ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.InlineCopyButton, {
413
- value: getFullRequestDetails(),
446
+ }) : null, isPro ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.InlineCopyButton
447
+ // LAZY, not `getFullRequestDetails()`. CopyButton resolves a
448
+ // function value at press time; calling it here instead
449
+ // pretty-printed the headers AND both bodies on every render —
450
+ // four JSON.stringify passes over the full payload just to have
451
+ // a string nobody asked for yet. Same shape NetworkSavedScreen
452
+ // already uses for its copy button.
453
+ , {
454
+ value: getFullRequestDetails,
414
455
  buttonStyle: styles.copyFullButton
415
456
  }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
416
457
  style: styles.copyFullButton,
@@ -544,14 +585,14 @@ ${JSON.stringify(requestDetails.responseData, null, 2)}
544
585
  defaultOpen: false,
545
586
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
546
587
  style: styles.dataViewerContainer,
547
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_dataViewer.DataViewer, {
588
+ children: showBodies ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_dataViewer.DataViewer, {
548
589
  title: "",
549
590
  data: requestData,
550
591
  showTypeFilter: true,
551
592
  rawMode: true,
552
593
  initialExpanded: true,
553
594
  disableCopy: !isPro
554
- })
595
+ }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(BodyDeferred, {})
555
596
  })
556
597
  }) : requestBodyOmitted ? /*#__PURE__*/(0, _jsxRuntime.jsx)(CollapsibleSection, {
557
598
  title: "Request Body",
@@ -576,14 +617,14 @@ ${JSON.stringify(requestDetails.responseData, null, 2)}
576
617
  defaultOpen: true,
577
618
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
578
619
  style: styles.dataViewerContainer,
579
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_dataViewer.DataViewer, {
620
+ children: showBodies ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_dataViewer.DataViewer, {
580
621
  title: "",
581
622
  data: responseData,
582
623
  showTypeFilter: true,
583
624
  rawMode: true,
584
625
  initialExpanded: true,
585
626
  disableCopy: !isPro
586
- })
627
+ }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(BodyDeferred, {})
587
628
  })
588
629
  }) : responseBodyOmitted ? /*#__PURE__*/(0, _jsxRuntime.jsx)(CollapsibleSection, {
589
630
  title: "Response Body",
@@ -688,7 +729,7 @@ ${JSON.stringify(requestDetails.responseData, null, 2)}
688
729
  benefits: ["Copy full request details including headers and body", "Export to JSON, Markdown, or plain text", "Share request data with teammates", "Feed data to AI assistants for debugging"]
689
730
  })]
690
731
  });
691
- }
732
+ });
692
733
  const styles = _reactNative.StyleSheet.create({
693
734
  container: {
694
735
  flex: 1,
@@ -964,6 +1005,10 @@ const styles = _reactNative.StyleSheet.create({
964
1005
  marginHorizontal: -12,
965
1006
  marginBottom: -12
966
1007
  },
1008
+ /** Placeholder for a not-yet-mounted body tree. See BodyDeferred. */
1009
+ bodyDeferred: {
1010
+ height: 120
1011
+ },
967
1012
  // Error box
968
1013
  errorBox: {
969
1014
  flexDirection: "row",
@@ -13,6 +13,7 @@ var _sharedUi = require("@buoy-gg/shared-ui");
13
13
  var _networkEventStore = require("../utils/networkEventStore");
14
14
  var _networkSavedStore = require("../utils/networkSavedStore");
15
15
  var _useNetworkSaved = require("../hooks/useNetworkSaved");
16
+ var _useNetworkSelection = require("../hooks/useNetworkSelection");
16
17
  var _useNetworkBadgeCounts = require("../hooks/useNetworkBadgeCounts");
17
18
  var _useNetworkHasMatches = require("../hooks/useNetworkHasMatches");
18
19
  var _generateNetworkCopyText = require("../utils/generateNetworkCopyText");
@@ -149,12 +150,16 @@ function NetworkSavedButton({
149
150
  * The event object is resolved at PRESS time rather than held as a prop: the
150
151
  * live store has the fresh copy while the request is in flight, and the saved
151
152
  * store is the only copy once it has been cleared or aged out.
153
+ *
154
+ * The selected id is SUBSCRIBED here rather than passed down, so stepping
155
+ * through requests doesn't have to re-render the modal root just to rebuild
156
+ * the header around a new prop.
152
157
  */
153
158
  function NetworkDetailPinActions({
154
- eventId,
155
159
  isPro,
156
160
  onUpgradePress
157
161
  }) {
162
+ const eventId = (0, _useNetworkSelection.useSelectedNetworkEventId)();
158
163
  const state = (0, _useNetworkSaved.useNetworkSaved)();
159
164
  const {
160
165
  pinned,
@@ -121,16 +121,21 @@ function NetworkListScreen({
121
121
  togglePin,
122
122
  clearPinned
123
123
  } = (0, _useNetworkSaved.useNetworkSaved)();
124
- const {
125
- pinned,
126
- rest
127
- } = (0, _withPinnedFirst.splitPinned)(visibleEvents, pinnedEvents, pinnedLiveIds);
128
124
 
129
125
  // Stable identity: handed to memoized rows.
130
126
  const handleLongPress = (0, _react.useCallback)(event => {
131
127
  togglePin(event);
132
128
  }, [togglePin]);
129
+
130
+ // Everything above is a hook and must run unconditionally; everything below
131
+ // is work. `splitPinned` is an O(n) pass over the whole list, and this screen
132
+ // re-renders on every modal-root state change — including every prev/next
133
+ // step, where it is hidden and the result is thrown away.
133
134
  if (!visible) return null;
135
+ const {
136
+ pinned,
137
+ rest
138
+ } = (0, _withPinnedFirst.splitPinned)(visibleEvents, pinnedEvents, pinnedLiveIds);
134
139
  const isEnabled = !paused;
135
140
  const renderItem = ({
136
141
  item
@@ -15,8 +15,11 @@ var _NetworkCopySettingsView = require("./NetworkCopySettingsView");
15
15
  var _NetworkHeaderLive = require("./NetworkHeaderLive");
16
16
  var _NetworkListScreen = require("./NetworkListScreen");
17
17
  var _NetworkSavedScreen = require("./NetworkSavedScreen");
18
+ var _NetworkDetailStepper = require("./NetworkDetailStepper");
18
19
  var _useNetworkEventList = require("../hooks/useNetworkEventList");
19
20
  var _useLiveNetworkEvent = require("../hooks/useLiveNetworkEvent");
21
+ var _useNetworkSelection = require("../hooks/useNetworkSelection");
22
+ var _networkSelectionStore = require("../utils/networkSelectionStore");
20
23
  var _networkSavedStore = require("../utils/networkSavedStore");
21
24
  var _jsxRuntime = require("react/jsx-runtime");
22
25
  // ============================================================================
@@ -28,22 +31,76 @@ var _jsxRuntime = require("react/jsx-runtime");
28
31
 
29
32
  function NetworkDetailScreen({
30
33
  visible,
31
- eventId,
32
34
  paused,
33
35
  ignoredPatternValues,
34
- onTogglePattern
36
+ onTogglePattern,
37
+ isPro,
38
+ filter,
39
+ ignoredPatterns,
40
+ fromSaved,
41
+ savedSearch,
42
+ applySafeAreaInset
35
43
  }) {
44
+ /**
45
+ * The detail renders at the IMMEDIATE selection — every step, no delay.
46
+ * Measured, 100 steps at a 150ms tap interval over 64KB payloads:
47
+ *
48
+ * whole detail, bodies included ... 647ms/step
49
+ * detail with no bodies at all .... 43ms/step (over the 150ms tap gap)
50
+ *
51
+ * So ~93% of a step is the request/response BODY trees, and everything else
52
+ * — status, URL breakdown, timing, header counts — is affordable at any tap
53
+ * rate. Deferring the whole detail (which is what this used to do) therefore
54
+ * bought nothing a user could feel: it made the cheap 43ms wait alongside
55
+ * the expensive 450ms instead of splitting them.
56
+ *
57
+ * So only the expensive part waits. `bodyId` trails `selectedId` by the
58
+ * settle window, so `heavyReady` is false exactly while the cursor is still
59
+ * moving — the JSON trees are skipped during a burst and mount once you land.
60
+ */
61
+ const eventId = (0, _useNetworkSelection.useSelectedNetworkEventId)();
62
+ const settledId = (0, _useNetworkSelection.useNetworkBodyEventId)();
63
+ const heavyReady = eventId != null && eventId === settledId;
36
64
  // Resolve the ID to the live event so a request opened while pending picks
37
65
  // up its response/error in place instead of freezing on the snapshot taken
38
66
  // when the row was tapped.
39
67
  const event = (0, _useLiveNetworkEvent.useLiveNetworkEvent)(eventId, {
40
68
  paused: paused || !visible
41
69
  });
42
- if (!visible || !event) return null;
43
- return /*#__PURE__*/(0, _jsxRuntime.jsx)(_NetworkEventDetailView.NetworkEventDetailView, {
44
- event: event,
45
- ignoredPatterns: ignoredPatternValues,
46
- onTogglePattern: onTogglePattern
70
+
71
+ // Instrumentation for the benchmark harness. Gated on `heavyReady` on
72
+ // purpose: the cheap chrome now renders on every step, so reporting here
73
+ // unconditionally would make `bodyMs` a duplicate of `cursorMs`. What the
74
+ // harness needs to know is when the expensive TREES landed.
75
+ (0, _react.useEffect)(() => {
76
+ if (visible && event && heavyReady) {
77
+ _networkSelectionStore.networkSelectionStore.reportRendered(event.id);
78
+ }
79
+ }, [visible, event, heavyReady]);
80
+ if (!visible) return null;
81
+ // Column: the detail body scrolls, the stepper stays pinned under it. The
82
+ // body owns its scroll here (see `bodyOwnsScroll`) — inside JsModal's
83
+ // wrapper the footer would scroll away with the content. The spacer keeps
84
+ // the footer at the bottom on the rare frame where the ID resolves to
85
+ // nothing (cleared, aged out, and not saved).
86
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
87
+ style: styles.detailRoot,
88
+ children: [event ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_NetworkEventDetailView.NetworkEventDetailView, {
89
+ event: event,
90
+ ignoredPatterns: ignoredPatternValues,
91
+ onTogglePattern: onTogglePattern,
92
+ showBodies: heavyReady
93
+ }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
94
+ style: styles.detailBodyFallback
95
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_NetworkDetailStepper.NetworkDetailStepper, {
96
+ paused: paused,
97
+ filter: filter,
98
+ ignoredPatterns: ignoredPatterns,
99
+ isPro: isPro,
100
+ fromSaved: fromSaved,
101
+ savedSearch: savedSearch,
102
+ applySafeAreaInset: applySafeAreaInset
103
+ })]
47
104
  });
48
105
  }
49
106
  function NetworkFilterScreen({
@@ -136,14 +193,24 @@ function NetworkModalInner({
136
193
  if (controlledPause) onPausedChange(value);else setPausedState(value);
137
194
  };
138
195
  const [filter, setFilter] = (0, _react.useState)({});
139
- // The SELECTION is UI state (an ID); the event object it points at is read
140
- // live from the store by the detail screen — see useLiveNetworkEvent.
141
- const [selectedEventId, setSelectedEventId] = (0, _react.useState)(null);
196
+ /**
197
+ * The cursor lives in `networkSelectionStore`, and the root subscribes to
198
+ * the BOOLEAN only. That is the whole point: this component re-renders
199
+ * `JsModal` (which can never bail — its `header` prop is a fresh object
200
+ * every render), the entire header, and all five screens. None of them care
201
+ * WHICH request is selected, only THAT one is, and this flips twice a
202
+ * session instead of once per tap.
203
+ */
204
+ const hasSelection = (0, _useNetworkSelection.useHasNetworkSelection)();
142
205
  const [showFilterView, setShowFilterView] = (0, _react.useState)(false);
143
206
  const [showSavedView, setShowSavedView] = (0, _react.useState)(false);
144
207
  const [activeTab, setActiveTab] = (0, _react.useState)("filters");
145
208
  const [searchText, setSearchText] = (0, _react.useState)("");
146
209
  const [isSearchActive, setIsSearchActive] = (0, _react.useState)(false);
210
+ // The Saved screen's search box lives HERE, not in that screen, because the
211
+ // detail footer steps through the same list the user was just looking at —
212
+ // and the Saved screen is unmounted while a detail is open.
213
+ const [savedSearch, setSavedSearch] = (0, _react.useState)("");
147
214
  const searchInputRef = (0, _react.useRef)(null);
148
215
  // Ignored domains/URL patterns live in a shared, persisted singleton store
149
216
  // (@buoy-gg/shared-ui) so the Network tool and the Events tool stay in sync.
@@ -208,12 +275,12 @@ function NetworkModalInner({
208
275
 
209
276
  // Simple handlers - no useCallback needed per rule2
210
277
  const handleBack = () => {
211
- setSelectedEventId(null);
278
+ _networkSelectionStore.networkSelectionStore.clear();
212
279
  };
213
280
 
214
281
  // Stable identity: this is passed down to the memoized list rows.
215
282
  const handleEventPress = (0, _react.useCallback)(event => {
216
- setSelectedEventId(event.id);
283
+ _networkSelectionStore.networkSelectionStore.select(event.id);
217
284
  }, []);
218
285
  const handleSearch = text => {
219
286
  setSearchText(text);
@@ -228,8 +295,13 @@ function NetworkModalInner({
228
295
  status: prev.status === status ? undefined : status
229
296
  }));
230
297
  };
231
- const handleModeChange = _mode => {
232
- // Mode changes handled by JsModal
298
+
299
+ // JsModal owns the mode; the only thing this modal needs it for is the
300
+ // detail footer's bottom inset — a bottom sheet sits on the home indicator,
301
+ // a floating window does not (same call StorageModalWithTabs makes).
302
+ const [modalMode, setModalMode] = (0, _react.useState)("bottomSheet");
303
+ const handleModeChange = mode => {
304
+ setModalMode(mode);
233
305
  };
234
306
  (0, _react.useEffect)(() => {
235
307
  if (isSearchActive) {
@@ -266,7 +338,7 @@ function NetworkModalInner({
266
338
  }
267
339
 
268
340
  // Event detail view header
269
- if (selectedEventId) {
341
+ if (hasSelection) {
270
342
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader, {
271
343
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Navigation, {
272
344
  onBack: handleBack
@@ -275,7 +347,6 @@ function NetworkModalInner({
275
347
  centered: true
276
348
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Actions, {
277
349
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_NetworkHeaderLive.NetworkDetailPinActions, {
278
- eventId: selectedEventId,
279
350
  isPro: isPro,
280
351
  onUpgradePress: () => showUpgrade("saved")
281
352
  })
@@ -386,19 +457,31 @@ function NetworkModalInner({
386
457
  // it — inside JsModal's ScrollView wrapper every row mounts, which at
387
458
  // the 500-event cap measured 17 JS FPS / 133% CPU / 1.4GB; see
388
459
  // NetworkListScreen). The Saved screen is a FlatList too, so it takes the
389
- // same deal. Detail/filter/copy still use the wrapper.
390
- const listOwnsScroll = selectedEventId == null && !showFilterView;
460
+ // same deal. The DETAIL screen owns it as well, so its prev/next footer
461
+ // stays pinned instead of scrolling away with the body. Filter/copy still
462
+ // use the wrapper.
463
+ const bodyOwnsScroll = hasSelection || !showFilterView;
391
464
  const body = /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
392
465
  children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
393
466
  style: styles.container,
394
467
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(NetworkDetailScreen, {
395
- visible: selectedEventId != null,
396
- eventId: selectedEventId,
468
+ visible: hasSelection,
397
469
  paused: paused,
398
470
  ignoredPatternValues: ignoredPatternValues,
399
- onTogglePattern: toggleIgnoredPattern
471
+ onTogglePattern: toggleIgnoredPattern,
472
+ isPro: isPro,
473
+ filter: filter,
474
+ ignoredPatterns: ignoredPatterns
475
+ // `showSavedView` is what Back returns to, so it is also which list
476
+ // the user came from — and therefore which one to step through.
477
+ ,
478
+ fromSaved: showSavedView,
479
+ savedSearch: savedSearch
480
+ // Inline (desktop panel) has no home indicator to clear.
481
+ ,
482
+ applySafeAreaInset: !inline && modalMode === "bottomSheet"
400
483
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(NetworkFilterScreen, {
401
- visible: showFilterView && activeTab === "filters" && !selectedEventId,
484
+ visible: showFilterView && activeTab === "filters" && !hasSelection,
402
485
  paused: paused,
403
486
  isPro: isPro,
404
487
  filter: filter,
@@ -408,7 +491,7 @@ function NetworkModalInner({
408
491
  onAddPattern: addIgnoredPattern,
409
492
  onTogglePatternMode: toggleIgnoredPatternMode
410
493
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(NetworkCopyScreen, {
411
- visible: showFilterView && activeTab === "copy" && !selectedEventId,
494
+ visible: showFilterView && activeTab === "copy" && !hasSelection,
412
495
  paused: paused,
413
496
  isPro: isPro,
414
497
  filter: filter,
@@ -416,13 +499,15 @@ function NetworkModalInner({
416
499
  settings: copySettings,
417
500
  onSettingsChange: setCopySettings
418
501
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_NetworkSavedScreen.NetworkSavedScreen, {
419
- visible: showSavedView && selectedEventId == null && !showFilterView,
502
+ visible: showSavedView && !hasSelection && !showFilterView,
420
503
  isPro: isPro,
421
504
  copySettings: copySettings,
505
+ search: savedSearch,
506
+ onSearchChange: setSavedSearch,
422
507
  onEventPress: handleEventPress,
423
508
  onUpgradePress: () => showUpgrade("saved")
424
509
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_NetworkListScreen.NetworkListScreen, {
425
- visible: selectedEventId == null && !showFilterView && !showSavedView,
510
+ visible: !hasSelection && !showFilterView && !showSavedView,
426
511
  paused: paused,
427
512
  filter: filter,
428
513
  ignoredPatterns: ignoredPatterns,
@@ -464,7 +549,7 @@ function NetworkModalInner({
464
549
  enablePersistence: true,
465
550
  initialMode: "bottomSheet",
466
551
  enableGlitchEffects: true,
467
- disableScrollWrapper: listOwnsScroll,
552
+ disableScrollWrapper: bodyOwnsScroll,
468
553
  styles: {},
469
554
  children: body
470
555
  });
@@ -474,6 +559,16 @@ const styles = _reactNative.StyleSheet.create({
474
559
  flex: 1,
475
560
  backgroundColor: _sharedUi.macOSColors.background.base
476
561
  },
562
+ /** Detail = scrolling body + pinned stepper footer. `minHeight: 0` so the
563
+ * body's ScrollView scrolls instead of stretching the column. */
564
+ detailRoot: {
565
+ flex: 1,
566
+ minHeight: 0
567
+ },
568
+ /** Holds the footer down when the selected ID resolves to no event. */
569
+ detailBodyFallback: {
570
+ flex: 1
571
+ },
477
572
  /** Inline shell — fills the host panel; `minHeight: 0` so the list's own
478
573
  * FlatList can scroll instead of stretching the column. */
479
574
  inlineRoot: {
@@ -10,7 +10,7 @@ var _sharedUi = require("@buoy-gg/shared-ui");
10
10
  var _NetworkEventItemCompact = require("./NetworkEventItemCompact");
11
11
  var _NetworkUpgradeBanner = require("./NetworkUpgradeBanner");
12
12
  var _generateNetworkCopyText = require("../utils/generateNetworkCopyText");
13
- var _filterNetworkEvents = require("../utils/filterNetworkEvents");
13
+ var _selectSavedEvents = require("../utils/selectSavedEvents");
14
14
  var _useNetworkSaved = require("../hooks/useNetworkSaved");
15
15
  var _networkSavedStore = require("../utils/networkSavedStore");
16
16
  var _jsxRuntime = require("react/jsx-runtime");
@@ -51,6 +51,8 @@ function NetworkSavedScreen({
51
51
  visible,
52
52
  isPro,
53
53
  copySettings,
54
+ search: searchProp,
55
+ onSearchChange,
54
56
  onEventPress,
55
57
  onUpgradePress
56
58
  }) {
@@ -60,22 +62,17 @@ function NetworkSavedScreen({
60
62
  toggleSave,
61
63
  clearSaved
62
64
  } = (0, _useNetworkSaved.useNetworkSaved)();
63
- const [search, setSearch] = (0, _react.useState)("");
65
+ const [searchState, setSearchState] = (0, _react.useState)("");
66
+ const controlled = searchProp != null && onSearchChange != null;
67
+ const search = controlled ? searchProp : searchState;
68
+ const setSearch = controlled ? onSearchChange : setSearchState;
64
69
  if (!visible) return null;
65
70
 
66
- /**
67
- * A downgrade (or a limit change) can leave a free user holding more saves
68
- * than the free tier allows. Rather than deleting their data, the overflow
69
- * is LOCKED — oldest first, so the newest saves stay reachable — and the
70
- * banner offers the upgrade that unlocks it.
71
- */
72
- const limit = isPro ? _networkSavedStore.MAX_SAVED : _networkSavedStore.FREE_TIER_SAVED_LIMIT;
73
- const unlocked = savedRecords.slice(0, limit);
74
- const lockedCount = savedRecords.length - unlocked.length;
75
- const events = unlocked.map(record => record.event);
76
- const filtered = search ? (0, _filterNetworkEvents.filterNetworkEvents)(events, {
77
- searchText: search
78
- }) : events;
71
+ // Shared with the detail footer, so "Next" can't disagree with the row below.
72
+ const {
73
+ events: filtered,
74
+ lockedCount
75
+ } = (0, _selectSavedEvents.selectSavedEvents)(savedRecords, isPro, search);
79
76
  const handleLongPress = event => {
80
77
  toggleSave(event);
81
78
  };