@buoy-gg/network 7.0.12 → 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 (31) hide show
  1. package/lib/commonjs/index.js +13 -0
  2. package/lib/commonjs/network/components/NetworkDetailStepper.js +26 -14
  3. package/lib/commonjs/network/components/NetworkEventDetailView.js +56 -11
  4. package/lib/commonjs/network/components/NetworkHeaderLive.js +6 -1
  5. package/lib/commonjs/network/components/NetworkListScreen.js +9 -4
  6. package/lib/commonjs/network/components/NetworkModal.js +64 -24
  7. package/lib/commonjs/network/hooks/useNetworkSelection.js +53 -0
  8. package/lib/commonjs/network/utils/networkSelectionStore.js +193 -0
  9. package/lib/module/index.js +4 -0
  10. package/lib/module/network/components/NetworkDetailStepper.js +27 -15
  11. package/lib/module/network/components/NetworkEventDetailView.js +56 -11
  12. package/lib/module/network/components/NetworkHeaderLive.js +6 -1
  13. package/lib/module/network/components/NetworkListScreen.js +9 -4
  14. package/lib/module/network/components/NetworkModal.js +64 -24
  15. package/lib/module/network/hooks/useNetworkSelection.js +46 -0
  16. package/lib/module/network/utils/networkSelectionStore.js +188 -0
  17. package/lib/typescript/index.d.ts +1 -0
  18. package/lib/typescript/index.d.ts.map +1 -1
  19. package/lib/typescript/network/components/NetworkDetailStepper.d.ts +8 -5
  20. package/lib/typescript/network/components/NetworkDetailStepper.d.ts.map +1 -1
  21. package/lib/typescript/network/components/NetworkEventDetailView.d.ts +36 -1
  22. package/lib/typescript/network/components/NetworkEventDetailView.d.ts.map +1 -1
  23. package/lib/typescript/network/components/NetworkHeaderLive.d.ts +5 -2
  24. package/lib/typescript/network/components/NetworkHeaderLive.d.ts.map +1 -1
  25. package/lib/typescript/network/components/NetworkListScreen.d.ts.map +1 -1
  26. package/lib/typescript/network/components/NetworkModal.d.ts.map +1 -1
  27. package/lib/typescript/network/hooks/useNetworkSelection.d.ts +26 -0
  28. package/lib/typescript/network/hooks/useNetworkSelection.d.ts.map +1 -0
  29. package/lib/typescript/network/utils/networkSelectionStore.d.ts +117 -0
  30. package/lib/typescript/network/utils/networkSelectionStore.d.ts.map +1 -0
  31. package/package.json +2 -2
@@ -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,24 @@ 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,
43
52
  applySafeAreaInset
44
53
  }) {
45
54
  const {
@@ -67,7 +76,16 @@ const NetworkDetailStepper = exports.NetworkDetailStepper = /*#__PURE__*/(0, _re
67
76
  } = (0, _withPinnedFirst.splitPinned)(visibleEvents, pinnedEvents, pinnedLiveIds);
68
77
  return pinned.length === 0 ? rest : [...pinned, ...rest];
69
78
  }, [fromSaved, savedRecords, savedSearch, isPro, visibleEvents, pinnedEvents, pinnedLiveIds]);
70
- 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)();
71
89
 
72
90
  // Not in the list at all: the filter changed under it while it was open, it
73
91
  // aged past the 500-request cap, or it is a saved snapshot with no live twin.
@@ -79,16 +97,10 @@ const NetworkDetailStepper = exports.NetworkDetailStepper = /*#__PURE__*/(0, _re
79
97
  // the same direction as scrolling up, which is what the arrow means here.
80
98
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.EventStepperFooter, {
81
99
  currentIndex: index,
82
- totalItems: ordered.length,
100
+ totalItems: total,
83
101
  itemLabel: "Request",
84
102
  applySafeAreaInset: applySafeAreaInset,
85
- onPrevious: () => {
86
- const target = ordered[index - 1];
87
- if (target) onSelect(target.id);
88
- },
89
- onNext: () => {
90
- const target = ordered[index + 1];
91
- if (target) onSelect(target.id);
92
- }
103
+ onPrevious: stepPrevious,
104
+ onNext: stepNext
93
105
  });
94
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
@@ -18,6 +18,8 @@ var _NetworkSavedScreen = require("./NetworkSavedScreen");
18
18
  var _NetworkDetailStepper = require("./NetworkDetailStepper");
19
19
  var _useNetworkEventList = require("../hooks/useNetworkEventList");
20
20
  var _useLiveNetworkEvent = require("../hooks/useLiveNetworkEvent");
21
+ var _useNetworkSelection = require("../hooks/useNetworkSelection");
22
+ var _networkSelectionStore = require("../utils/networkSelectionStore");
21
23
  var _networkSavedStore = require("../utils/networkSavedStore");
22
24
  var _jsxRuntime = require("react/jsx-runtime");
23
25
  // ============================================================================
@@ -29,7 +31,6 @@ var _jsxRuntime = require("react/jsx-runtime");
29
31
 
30
32
  function NetworkDetailScreen({
31
33
  visible,
32
- eventId,
33
34
  paused,
34
35
  ignoredPatternValues,
35
36
  onTogglePattern,
@@ -38,34 +39,66 @@ function NetworkDetailScreen({
38
39
  ignoredPatterns,
39
40
  fromSaved,
40
41
  savedSearch,
41
- onSelectEvent,
42
42
  applySafeAreaInset
43
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;
44
64
  // Resolve the ID to the live event so a request opened while pending picks
45
65
  // up its response/error in place instead of freezing on the snapshot taken
46
66
  // when the row was tapped.
47
67
  const event = (0, _useLiveNetworkEvent.useLiveNetworkEvent)(eventId, {
48
68
  paused: paused || !visible
49
69
  });
50
- if (!visible || !event) return null;
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;
51
81
  // Column: the detail body scrolls, the stepper stays pinned under it. The
52
82
  // body owns its scroll here (see `bodyOwnsScroll`) — inside JsModal's
53
- // wrapper the footer would scroll away with the content.
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).
54
86
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
55
87
  style: styles.detailRoot,
56
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_NetworkEventDetailView.NetworkEventDetailView, {
88
+ children: [event ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_NetworkEventDetailView.NetworkEventDetailView, {
57
89
  event: event,
58
90
  ignoredPatterns: ignoredPatternValues,
59
- onTogglePattern: onTogglePattern
91
+ onTogglePattern: onTogglePattern,
92
+ showBodies: heavyReady
93
+ }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
94
+ style: styles.detailBodyFallback
60
95
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_NetworkDetailStepper.NetworkDetailStepper, {
61
- eventId: event.id,
62
96
  paused: paused,
63
97
  filter: filter,
64
98
  ignoredPatterns: ignoredPatterns,
65
99
  isPro: isPro,
66
100
  fromSaved: fromSaved,
67
101
  savedSearch: savedSearch,
68
- onSelect: onSelectEvent,
69
102
  applySafeAreaInset: applySafeAreaInset
70
103
  })]
71
104
  });
@@ -160,9 +193,15 @@ function NetworkModalInner({
160
193
  if (controlledPause) onPausedChange(value);else setPausedState(value);
161
194
  };
162
195
  const [filter, setFilter] = (0, _react.useState)({});
163
- // The SELECTION is UI state (an ID); the event object it points at is read
164
- // live from the store by the detail screen — see useLiveNetworkEvent.
165
- 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)();
166
205
  const [showFilterView, setShowFilterView] = (0, _react.useState)(false);
167
206
  const [showSavedView, setShowSavedView] = (0, _react.useState)(false);
168
207
  const [activeTab, setActiveTab] = (0, _react.useState)("filters");
@@ -236,12 +275,12 @@ function NetworkModalInner({
236
275
 
237
276
  // Simple handlers - no useCallback needed per rule2
238
277
  const handleBack = () => {
239
- setSelectedEventId(null);
278
+ _networkSelectionStore.networkSelectionStore.clear();
240
279
  };
241
280
 
242
281
  // Stable identity: this is passed down to the memoized list rows.
243
282
  const handleEventPress = (0, _react.useCallback)(event => {
244
- setSelectedEventId(event.id);
283
+ _networkSelectionStore.networkSelectionStore.select(event.id);
245
284
  }, []);
246
285
  const handleSearch = text => {
247
286
  setSearchText(text);
@@ -299,7 +338,7 @@ function NetworkModalInner({
299
338
  }
300
339
 
301
340
  // Event detail view header
302
- if (selectedEventId) {
341
+ if (hasSelection) {
303
342
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader, {
304
343
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Navigation, {
305
344
  onBack: handleBack
@@ -308,7 +347,6 @@ function NetworkModalInner({
308
347
  centered: true
309
348
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Actions, {
310
349
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_NetworkHeaderLive.NetworkDetailPinActions, {
311
- eventId: selectedEventId,
312
350
  isPro: isPro,
313
351
  onUpgradePress: () => showUpgrade("saved")
314
352
  })
@@ -422,13 +460,12 @@ function NetworkModalInner({
422
460
  // same deal. The DETAIL screen owns it as well, so its prev/next footer
423
461
  // stays pinned instead of scrolling away with the body. Filter/copy still
424
462
  // use the wrapper.
425
- const bodyOwnsScroll = selectedEventId != null || !showFilterView;
463
+ const bodyOwnsScroll = hasSelection || !showFilterView;
426
464
  const body = /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
427
465
  children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
428
466
  style: styles.container,
429
467
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(NetworkDetailScreen, {
430
- visible: selectedEventId != null,
431
- eventId: selectedEventId,
468
+ visible: hasSelection,
432
469
  paused: paused,
433
470
  ignoredPatternValues: ignoredPatternValues,
434
471
  onTogglePattern: toggleIgnoredPattern,
@@ -439,13 +476,12 @@ function NetworkModalInner({
439
476
  // the user came from — and therefore which one to step through.
440
477
  ,
441
478
  fromSaved: showSavedView,
442
- savedSearch: savedSearch,
443
- onSelectEvent: setSelectedEventId
479
+ savedSearch: savedSearch
444
480
  // Inline (desktop panel) has no home indicator to clear.
445
481
  ,
446
482
  applySafeAreaInset: !inline && modalMode === "bottomSheet"
447
483
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(NetworkFilterScreen, {
448
- visible: showFilterView && activeTab === "filters" && !selectedEventId,
484
+ visible: showFilterView && activeTab === "filters" && !hasSelection,
449
485
  paused: paused,
450
486
  isPro: isPro,
451
487
  filter: filter,
@@ -455,7 +491,7 @@ function NetworkModalInner({
455
491
  onAddPattern: addIgnoredPattern,
456
492
  onTogglePatternMode: toggleIgnoredPatternMode
457
493
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(NetworkCopyScreen, {
458
- visible: showFilterView && activeTab === "copy" && !selectedEventId,
494
+ visible: showFilterView && activeTab === "copy" && !hasSelection,
459
495
  paused: paused,
460
496
  isPro: isPro,
461
497
  filter: filter,
@@ -463,7 +499,7 @@ function NetworkModalInner({
463
499
  settings: copySettings,
464
500
  onSettingsChange: setCopySettings
465
501
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_NetworkSavedScreen.NetworkSavedScreen, {
466
- visible: showSavedView && selectedEventId == null && !showFilterView,
502
+ visible: showSavedView && !hasSelection && !showFilterView,
467
503
  isPro: isPro,
468
504
  copySettings: copySettings,
469
505
  search: savedSearch,
@@ -471,7 +507,7 @@ function NetworkModalInner({
471
507
  onEventPress: handleEventPress,
472
508
  onUpgradePress: () => showUpgrade("saved")
473
509
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_NetworkListScreen.NetworkListScreen, {
474
- visible: selectedEventId == null && !showFilterView && !showSavedView,
510
+ visible: !hasSelection && !showFilterView && !showSavedView,
475
511
  paused: paused,
476
512
  filter: filter,
477
513
  ignoredPatterns: ignoredPatterns,
@@ -529,6 +565,10 @@ const styles = _reactNative.StyleSheet.create({
529
565
  flex: 1,
530
566
  minHeight: 0
531
567
  },
568
+ /** Holds the footer down when the selected ID resolves to no event. */
569
+ detailBodyFallback: {
570
+ flex: 1
571
+ },
532
572
  /** Inline shell — fills the host panel; `minHeight: 0` so the list's own
533
573
  * FlatList can scroll instead of stretching the column. */
534
574
  inlineRoot: {
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.useHasNetworkSelection = useHasNetworkSelection;
7
+ exports.useNetworkBodyEventId = useNetworkBodyEventId;
8
+ exports.useNetworkCursor = useNetworkCursor;
9
+ exports.useSelectedNetworkEventId = useSelectedNetworkEventId;
10
+ var _react = require("react");
11
+ var _networkSelectionStore = require("../utils/networkSelectionStore");
12
+ /**
13
+ * Narrow subscriptions to the prev/next cursor (`networkSelectionStore`).
14
+ *
15
+ * One hook per consumer, each returning the SMALLEST value that consumer
16
+ * renders — that is the whole point of moving the cursor into a store. The
17
+ * modal root takes `useHasNetworkSelection()` (a boolean that flips twice a
18
+ * session), so a step no longer re-renders `JsModal`, the header and all five
19
+ * screens; the footer and the body take the parts that actually change.
20
+ */
21
+
22
+ const subscribe = onChange => _networkSelectionStore.networkSelectionStore.subscribe(onChange);
23
+ const getState = () => _networkSelectionStore.networkSelectionStore.getState();
24
+
25
+ /**
26
+ * Is ANYTHING selected — i.e. is the detail screen up?
27
+ *
28
+ * `useSyncExternalStore` compares the selector's result, so the root only
29
+ * re-renders when this boolean flips, not on every step.
30
+ */
31
+ function useHasNetworkSelection() {
32
+ return (0, _react.useSyncExternalStore)(subscribe, () => getState().selectedId != null, () => getState().selectedId != null);
33
+ }
34
+
35
+ /** Where the USER is. Moves synchronously on tap — footer, header pin actions. */
36
+ function useSelectedNetworkEventId() {
37
+ return (0, _react.useSyncExternalStore)(subscribe, () => getState().selectedId, () => getState().selectedId);
38
+ }
39
+
40
+ /** What the BODY may render. Lags `selectedId` — see networkSelectionStore. */
41
+ function useNetworkBodyEventId() {
42
+ return (0, _react.useSyncExternalStore)(subscribe, () => getState().bodyId, () => getState().bodyId);
43
+ }
44
+
45
+ /** Cursor position + list length, for the "REQUEST n OF m" counter. */
46
+ function useNetworkCursor() {
47
+ const index = (0, _react.useSyncExternalStore)(subscribe, () => getState().index, () => getState().index);
48
+ const total = (0, _react.useSyncExternalStore)(subscribe, () => getState().ids.length, () => getState().ids.length);
49
+ return {
50
+ index,
51
+ total
52
+ };
53
+ }