@buoy-gg/network 5.0.0 → 6.0.0

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 (40) hide show
  1. package/lib/commonjs/index.js +14 -0
  2. package/lib/commonjs/network/components/NetworkEventItemCompact.js +32 -18
  3. package/lib/commonjs/network/components/NetworkHeaderLive.js +199 -0
  4. package/lib/commonjs/network/components/NetworkListScreen.js +203 -0
  5. package/lib/commonjs/network/components/NetworkModal.js +153 -630
  6. package/lib/commonjs/network/hooks/useNetworkBadgeCounts.js +83 -0
  7. package/lib/commonjs/network/hooks/useNetworkEventList.js +93 -0
  8. package/lib/commonjs/network/hooks/useNetworkHasMatches.js +56 -0
  9. package/lib/commonjs/network/utils/filterNetworkEvents.js +96 -0
  10. package/lib/commonjs/network/utils/generateNetworkCopyText.js +139 -0
  11. package/lib/module/index.js +5 -0
  12. package/lib/module/network/components/NetworkEventItemCompact.js +32 -18
  13. package/lib/module/network/components/NetworkHeaderLive.js +193 -0
  14. package/lib/module/network/components/NetworkListScreen.js +199 -0
  15. package/lib/module/network/components/NetworkModal.js +157 -634
  16. package/lib/module/network/hooks/useNetworkBadgeCounts.js +79 -0
  17. package/lib/module/network/hooks/useNetworkEventList.js +89 -0
  18. package/lib/module/network/hooks/useNetworkHasMatches.js +52 -0
  19. package/lib/module/network/utils/filterNetworkEvents.js +92 -0
  20. package/lib/module/network/utils/generateNetworkCopyText.js +135 -0
  21. package/lib/typescript/index.d.ts +2 -0
  22. package/lib/typescript/index.d.ts.map +1 -1
  23. package/lib/typescript/network/components/NetworkEventItemCompact.d.ts +0 -4
  24. package/lib/typescript/network/components/NetworkEventItemCompact.d.ts.map +1 -1
  25. package/lib/typescript/network/components/NetworkHeaderLive.d.ts +52 -0
  26. package/lib/typescript/network/components/NetworkHeaderLive.d.ts.map +1 -0
  27. package/lib/typescript/network/components/NetworkListScreen.d.ts +27 -0
  28. package/lib/typescript/network/components/NetworkListScreen.d.ts.map +1 -0
  29. package/lib/typescript/network/components/NetworkModal.d.ts.map +1 -1
  30. package/lib/typescript/network/hooks/useNetworkBadgeCounts.d.ts +24 -0
  31. package/lib/typescript/network/hooks/useNetworkBadgeCounts.d.ts.map +1 -0
  32. package/lib/typescript/network/hooks/useNetworkEventList.d.ts +34 -0
  33. package/lib/typescript/network/hooks/useNetworkEventList.d.ts.map +1 -0
  34. package/lib/typescript/network/hooks/useNetworkHasMatches.d.ts +23 -0
  35. package/lib/typescript/network/hooks/useNetworkHasMatches.d.ts.map +1 -0
  36. package/lib/typescript/network/utils/filterNetworkEvents.d.ts +13 -0
  37. package/lib/typescript/network/utils/filterNetworkEvents.d.ts.map +1 -0
  38. package/lib/typescript/network/utils/generateNetworkCopyText.d.ts +11 -0
  39. package/lib/typescript/network/utils/generateNetworkCopyText.d.ts.map +1 -0
  40. package/package.json +2 -2
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.useNetworkBadgeCounts = useNetworkBadgeCounts;
7
+ var _react = require("react");
8
+ var _networkEventStore = require("../utils/networkEventStore");
9
+ var _filterNetworkEvents = require("../utils/filterNetworkEvents");
10
+ /**
11
+ * Narrow store subscription for the header's live counters — the ONLY
12
+ * header data that should update per network event (the success/error/
13
+ * pending chips and the clear button's has-events state).
14
+ *
15
+ * Holds derived COUNTS in state, not the events array, with an equality
16
+ * guard — so consumers re-render only when a count actually changes
17
+ * (e.g. an ignored-pattern-matched event arriving doesn't re-render the
18
+ * chips). rule2: external store, narrowest possible subscription.
19
+ */
20
+
21
+ function computeCounts(events, ignoredPatterns) {
22
+ let successful = 0;
23
+ let failed = 0;
24
+ let pending = 0;
25
+ for (const event of (0, _filterNetworkEvents.applyIgnoredPatterns)(events, ignoredPatterns)) {
26
+ if (event.status && event.status >= 200 && event.status < 300) {
27
+ successful++;
28
+ } else if (event.error || event.status && event.status >= 400) {
29
+ failed++;
30
+ } else if (!event.status && !event.error) {
31
+ pending++;
32
+ }
33
+ }
34
+ return {
35
+ successful,
36
+ failed,
37
+ pending,
38
+ total: events.length
39
+ };
40
+ }
41
+ function countsEqual(a, b) {
42
+ return a.successful === b.successful && a.failed === b.failed && a.pending === b.pending && a.total === b.total;
43
+ }
44
+ function useNetworkBadgeCounts(options) {
45
+ const {
46
+ ignoredPatterns,
47
+ paused
48
+ } = options;
49
+ const [counts, setCounts] = (0, _react.useState)(() => computeCounts(_networkEventStore.networkEventStore.getEvents(), ignoredPatterns));
50
+ (0, _react.useEffect)(() => {
51
+ if (paused) return undefined;
52
+ // Frame-coalesced like useNetworkEventList: a request burst updates
53
+ // the chips once per frame, not once per emission. subscribeToEvents
54
+ // replays immediately, which also recomputes when ignoredPatterns
55
+ // change (this effect re-runs).
56
+ let rafId = null;
57
+ let pending = null;
58
+ const unsubscribe = _networkEventStore.networkEventStore.subscribeToEvents(events => {
59
+ pending = events;
60
+ if (rafId != null) return;
61
+ rafId = requestAnimationFrame(() => {
62
+ rafId = null;
63
+ if (!pending) return;
64
+ const next = computeCounts(pending, ignoredPatterns);
65
+ pending = null;
66
+ setCounts(prev => countsEqual(prev, next) ? prev : next);
67
+ });
68
+ });
69
+ return () => {
70
+ unsubscribe();
71
+ if (rafId != null) cancelAnimationFrame(rafId);
72
+ };
73
+ }, [paused, ignoredPatterns]);
74
+ (0, _react.useEffect)(() => _networkEventStore.networkEventStore.onClear(() => {
75
+ setCounts({
76
+ successful: 0,
77
+ failed: 0,
78
+ pending: 0,
79
+ total: 0
80
+ });
81
+ }), []);
82
+ return counts;
83
+ }
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.useNetworkEventList = useNetworkEventList;
7
+ var _react = require("react");
8
+ var _networkEventStore = require("../utils/networkEventStore");
9
+ var _filterNetworkEvents = require("../utils/filterNetworkEvents");
10
+ var _useNetworkEvents = require("./useNetworkEvents");
11
+ /**
12
+ * Narrow store subscription for components that render the network event
13
+ * LIST (or need the filtered list). Decomposed from `useNetworkEvents`
14
+ * per rule2 ("external store + narrow subscriptions") so the subscription
15
+ * lives in the leaf that consumes it — the modal root no longer
16
+ * re-renders per network event.
17
+ *
18
+ * Profiling evidence (tool-bench, 2026-07-06): with the subscription at
19
+ * the modal root, 15 network events re-rendered the ENTIRE modal 15× —
20
+ * JsModalComponent/header buttons ×15, all visible rows ×105.
21
+ *
22
+ * `paused` gates the subscription (the power toggle's freeze-stream
23
+ * semantic, and screens pass `paused || !visible` so hidden screens don't
24
+ * subscribe at all). Store clears are honored even while paused so a
25
+ * frozen list can't show deleted events (parity with the old hook's
26
+ * clearEvents local reset).
27
+ */
28
+
29
+ function useNetworkEventList(options) {
30
+ const {
31
+ filter,
32
+ ignoredPatterns,
33
+ paused,
34
+ isPro
35
+ } = options;
36
+ const [events, setEvents] = (0, _react.useState)(() => _networkEventStore.networkEventStore.getEvents());
37
+ (0, _react.useEffect)(() => {
38
+ if (paused) return undefined;
39
+ // Coalesce store emissions to one commit per frame: a burst of N
40
+ // concurrent requests emits 2N times (add + response each), and
41
+ // uncoalesced each emission is its own commit re-rendering the
42
+ // changed row's whole subtree. Profiling (tool-bench 2026-07-06):
43
+ // a rapid 25-request session produced 70 commits at ~220 renders
44
+ // each. subscribeToEvents replays the current array immediately, so
45
+ // resuming from pause catches up on the next frame.
46
+ let rafId = null;
47
+ let pending = null;
48
+ const unsubscribe = _networkEventStore.networkEventStore.subscribeToEvents(next => {
49
+ pending = next;
50
+ if (rafId != null) return;
51
+ rafId = requestAnimationFrame(() => {
52
+ rafId = null;
53
+ if (pending) setEvents(pending);
54
+ pending = null;
55
+ });
56
+ });
57
+ return () => {
58
+ unsubscribe();
59
+ if (rafId != null) cancelAnimationFrame(rafId);
60
+ };
61
+ }, [paused]);
62
+ (0, _react.useEffect)(() => _networkEventStore.networkEventStore.onClear(() => {
63
+ setEvents([]);
64
+ }), []);
65
+
66
+ // Derivations are plain useMemo over the subscribed array — linear
67
+ // passes over ≤maxEvents items, recomputed only when inputs change.
68
+ const filteredEvents = (0, _react.useMemo)(() => {
69
+ return (0, _filterNetworkEvents.filterNetworkEvents)((0, _filterNetworkEvents.applyIgnoredPatterns)(events, ignoredPatterns), filter);
70
+ }, [events, ignoredPatterns, filter]);
71
+
72
+ // Free tier: events beyond the limit in the ORIGINAL order are locked,
73
+ // so locked events stay locked even when searching (ported verbatim).
74
+ const lockedEventIds = (0, _react.useMemo)(() => {
75
+ if (isPro) return new Set();
76
+ const lockedIds = new Set();
77
+ events.slice(_useNetworkEvents.FREE_TIER_REQUEST_LIMIT).forEach(event => {
78
+ lockedIds.add(event.id);
79
+ });
80
+ return lockedIds;
81
+ }, [events, isPro]);
82
+ const visibleEvents = (0, _react.useMemo)(() => {
83
+ if (lockedEventIds.size === 0) return filteredEvents;
84
+ return filteredEvents.filter(event => !lockedEventIds.has(event.id));
85
+ }, [filteredEvents, lockedEventIds]);
86
+ const lockedEventCount = filteredEvents.length - visibleEvents.length;
87
+ return {
88
+ events: filteredEvents,
89
+ visibleEvents,
90
+ hasLockedEvents: lockedEventCount > 0,
91
+ lockedEventCount
92
+ };
93
+ }
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.useNetworkHasEvents = useNetworkHasEvents;
7
+ exports.useNetworkHasMatches = useNetworkHasMatches;
8
+ var _react = require("react");
9
+ var _networkEventStore = require("../utils/networkEventStore");
10
+ var _filterNetworkEvents = require("../utils/filterNetworkEvents");
11
+ /**
12
+ * Narrowest possible subscription: a BOOLEAN that flips when the store
13
+ * goes between "has events matching this filter" and "empty". Used by
14
+ * header buttons (clear/copy) whose only live dependency is their
15
+ * enabled state — with the full counts hook they re-rendered on every
16
+ * count change (32× in a 25-request run, tool-bench 2026-07-06); with a
17
+ * flip-guarded boolean they render ~twice per session.
18
+ */
19
+
20
+ /** True while the store has ANY events (clear button's enabled state). */
21
+ function useNetworkHasEvents(options) {
22
+ const {
23
+ paused
24
+ } = options;
25
+ const [hasEvents, setHasEvents] = (0, _react.useState)(() => _networkEventStore.networkEventStore.getEvents().length > 0);
26
+ (0, _react.useEffect)(() => {
27
+ if (paused) return undefined;
28
+ return _networkEventStore.networkEventStore.subscribeToEvents(events => {
29
+ const next = events.length > 0;
30
+ setHasEvents(prev => prev === next ? prev : next);
31
+ });
32
+ }, [paused]);
33
+ (0, _react.useEffect)(() => _networkEventStore.networkEventStore.onClear(() => setHasEvents(false)), []);
34
+ return hasEvents;
35
+ }
36
+
37
+ /** True while the store has events matching the current filter (copy
38
+ * button's enabled state). The filter pass runs per emission but a
39
+ * re-render only happens when the boolean flips. */
40
+ function useNetworkHasMatches(options) {
41
+ const {
42
+ filter,
43
+ ignoredPatterns,
44
+ paused
45
+ } = options;
46
+ const [hasMatches, setHasMatches] = (0, _react.useState)(() => (0, _filterNetworkEvents.filterNetworkEvents)((0, _filterNetworkEvents.applyIgnoredPatterns)(_networkEventStore.networkEventStore.getEvents(), ignoredPatterns), filter).length > 0);
47
+ (0, _react.useEffect)(() => {
48
+ if (paused) return undefined;
49
+ return _networkEventStore.networkEventStore.subscribeToEvents(events => {
50
+ const next = (0, _filterNetworkEvents.filterNetworkEvents)((0, _filterNetworkEvents.applyIgnoredPatterns)(events, ignoredPatterns), filter).length > 0;
51
+ setHasMatches(prev => prev === next ? prev : next);
52
+ });
53
+ }, [paused, filter, ignoredPatterns]);
54
+ (0, _react.useEffect)(() => _networkEventStore.networkEventStore.onClear(() => setHasMatches(false)), []);
55
+ return hasMatches;
56
+ }
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.applyIgnoredPatterns = applyIgnoredPatterns;
7
+ exports.filterNetworkEvents = filterNetworkEvents;
8
+ var _sharedUi = require("@buoy-gg/shared-ui");
9
+ var _formatGraphQLVariables = require("./formatGraphQLVariables");
10
+ /**
11
+ * Pure network-event filtering shared by the list/copy/filter-screen hooks
12
+ * (rule3: reusable logic extracted to a pure function). Ported verbatim
13
+ * from the filtering previously inlined in `useNetworkEvents` so every
14
+ * subscriber applies identical semantics.
15
+ */
16
+
17
+ /** Apply the user's search/method/status/host/content-type filter. */
18
+ function filterNetworkEvents(events, filter) {
19
+ const searchLower = filter.searchText ? filter.searchText.toLowerCase() : null;
20
+ const methodSet = filter.method && filter.method.length > 0 ? new Set(filter.method) : null;
21
+ const contentTypeSet = filter.contentType && filter.contentType.length > 0 ? new Set(filter.contentType) : null;
22
+ let filtered = [...events];
23
+ if (methodSet) {
24
+ filtered = filtered.filter(e => methodSet.has(e.method));
25
+ }
26
+ if (filter.status && filter.status !== "all") {
27
+ switch (filter.status) {
28
+ case "success":
29
+ filtered = filtered.filter(e => e.status && e.status >= 200 && e.status < 300);
30
+ break;
31
+ case "error":
32
+ filtered = filtered.filter(e => e.error || e.status && e.status >= 400);
33
+ break;
34
+ case "pending":
35
+ filtered = filtered.filter(e => !e.status && !e.error);
36
+ break;
37
+ }
38
+ }
39
+ if (searchLower) {
40
+ filtered = filtered.filter(e => e.url.toLowerCase().includes(searchLower) || e.method.toLowerCase().includes(searchLower) || e.path?.toLowerCase().includes(searchLower) || e.host?.toLowerCase().includes(searchLower) || e.error && e.error.toLowerCase().includes(searchLower) ||
41
+ // Search by GraphQL operation name (e.g., "GetUser", "CreatePost")
42
+ e.operationName && e.operationName.toLowerCase().includes(searchLower) ||
43
+ // Search by GraphQL variable values (e.g., "Sandshrew", "123")
44
+ (0, _formatGraphQLVariables.searchGraphQLVariables)(e.graphqlVariables, searchLower));
45
+ }
46
+ if (filter.host) {
47
+ filtered = filtered.filter(e => e.host === filter.host);
48
+ }
49
+ if (contentTypeSet) {
50
+ filtered = filtered.filter(e => {
51
+ const headers = e.responseHeaders || e.requestHeaders;
52
+ const contentType = headers?.["content-type"] || headers?.["Content-Type"] || "";
53
+ for (const type of contentTypeSet) {
54
+ switch (type) {
55
+ case "JSON":
56
+ if (contentType.includes("json")) return true;
57
+ break;
58
+ case "XML":
59
+ if (contentType.includes("xml")) return true;
60
+ break;
61
+ case "HTML":
62
+ if (contentType.includes("html")) return true;
63
+ break;
64
+ case "TEXT":
65
+ if (contentType.includes("text")) return true;
66
+ break;
67
+ case "IMAGE":
68
+ if (contentType.includes("image")) return true;
69
+ break;
70
+ case "VIDEO":
71
+ if (contentType.includes("video")) return true;
72
+ break;
73
+ case "AUDIO":
74
+ if (contentType.includes("audio")) return true;
75
+ break;
76
+ case "FORM":
77
+ if (contentType.includes("form")) return true;
78
+ break;
79
+ case "OTHER":
80
+ if (!contentType || !contentType.includes("json") && !contentType.includes("xml") && !contentType.includes("html") && !contentType.includes("text") && !contentType.includes("image") && !contentType.includes("video") && !contentType.includes("audio") && !contentType.includes("form")) {
81
+ return true;
82
+ }
83
+ break;
84
+ }
85
+ }
86
+ return false;
87
+ });
88
+ }
89
+ return filtered;
90
+ }
91
+
92
+ /** Drop events matching the shared ignored-domain/URL patterns. */
93
+ function applyIgnoredPatterns(events, ignoredPatterns) {
94
+ if (ignoredPatterns.length === 0) return events;
95
+ return events.filter(event => !ignoredPatterns.some(pattern => (0, _sharedUi.urlMatchesIgnoredPattern)(event.url, pattern)));
96
+ }
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.generateNetworkCopyText = generateNetworkCopyText;
7
+ var _formatting = require("./formatting");
8
+ /**
9
+ * Pure copy-text generation for the network tool (rule3: reusable logic
10
+ * extracted from the modal). Ported verbatim from the `generateCopyText`
11
+ * callback previously inlined in NetworkModal — kept pure so the header
12
+ * CopyButton can resolve it LAZILY at copy time (CopyButton accepts a
13
+ * function value) instead of rebuilding the string on every event.
14
+ */
15
+
16
+ function shouldIncludePayload(data, bodySizeThresholdKb) {
17
+ if (bodySizeThresholdKb === -1) return true;
18
+ if (!data) return true;
19
+ try {
20
+ const size = JSON.stringify(data).length;
21
+ return size < bodySizeThresholdKb * 1024;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+ function generateNetworkCopyText(events, copySettings) {
27
+ let eventsToUse = events;
28
+
29
+ // Apply filter mode
30
+ if (copySettings.filterMode === "failed") {
31
+ eventsToUse = eventsToUse.filter(e => e.error || e.status && e.status >= 400);
32
+ } else if (copySettings.filterMode === "success") {
33
+ eventsToUse = eventsToUse.filter(e => e.status && e.status >= 200 && e.status < 300);
34
+ }
35
+ if (eventsToUse.length === 0) {
36
+ return "No requests to copy";
37
+ }
38
+
39
+ // Plain text format (URLs only)
40
+ if (copySettings.format === "plaintext") {
41
+ return eventsToUse.map(event => `${event.method} ${event.url}`).join("\n");
42
+ }
43
+
44
+ // JSON format
45
+ if (copySettings.format === "json") {
46
+ const requests = eventsToUse.map(event => {
47
+ const obj = {};
48
+ if (copySettings.includeMethod) {
49
+ obj.method = event.method;
50
+ obj.url = event.url;
51
+ }
52
+ if (copySettings.includeStatus) {
53
+ obj.status = event.status;
54
+ obj.statusText = event.statusText;
55
+ }
56
+ if (copySettings.includeDuration) obj.duration = event.duration;
57
+ if (copySettings.includeTimestamp) obj.timestamp = event.timestamp;
58
+ if (copySettings.includeClient) obj.requestClient = event.requestClient;
59
+ if (copySettings.includeSizes) {
60
+ obj.requestSize = event.requestSize;
61
+ obj.responseSize = event.responseSize;
62
+ }
63
+ if (copySettings.includeErrors && event.error) obj.error = event.error;
64
+ if (copySettings.includeRequestHeaders) {
65
+ obj.requestHeaders = event.requestHeaders;
66
+ }
67
+ if (copySettings.includeResponseHeaders) {
68
+ obj.responseHeaders = event.responseHeaders;
69
+ }
70
+ if (copySettings.includeRequestBody && shouldIncludePayload(event.requestData, copySettings.bodySizeThreshold)) {
71
+ obj.requestData = event.requestData;
72
+ }
73
+ if (copySettings.includeResponseBody && shouldIncludePayload(event.responseData, copySettings.bodySizeThreshold)) {
74
+ obj.responseData = event.responseData;
75
+ }
76
+ return obj;
77
+ });
78
+ return JSON.stringify(requests, null, 2);
79
+ }
80
+
81
+ // Markdown format
82
+ const requests = eventsToUse.map((event, index) => {
83
+ let md = `# Request ${index + 1}\n\n`;
84
+ if (copySettings.includeMethod) {
85
+ md += `**Method:** ${event.method}\n`;
86
+ md += `**URL:** ${event.url}\n`;
87
+ }
88
+ if (copySettings.includeStatus) {
89
+ const status = event.status || "Pending";
90
+ md += `**Status:** ${status}${event.statusText ? ` (${event.statusText})` : ""}\n`;
91
+ }
92
+ if (copySettings.includeClient && event.requestClient) {
93
+ md += `**Client:** ${event.requestClient}\n`;
94
+ }
95
+ if (copySettings.includeDuration && event.duration) {
96
+ md += `**Duration:** ${event.duration}ms\n`;
97
+ }
98
+ if (copySettings.includeTimestamp) {
99
+ md += `**Timestamp:** ${new Date(event.timestamp).toISOString()}\n`;
100
+ }
101
+ if (copySettings.includeSizes) {
102
+ const reqSize = event.requestSize ? (0, _formatting.formatBytes)(event.requestSize) : "N/A";
103
+ const resSize = event.responseSize ? (0, _formatting.formatBytes)(event.responseSize) : "N/A";
104
+ md += `**Request Size:** ${reqSize}\n`;
105
+ md += `**Response Size:** ${resSize}\n`;
106
+ }
107
+ if (copySettings.includeErrors && event.error) {
108
+ md += `**Error:** ${event.error}\n`;
109
+ }
110
+ if (copySettings.includeRequestHeaders && Object.keys(event.requestHeaders).length > 0) {
111
+ md += `\n## Request Headers\n\`\`\`json\n${JSON.stringify(event.requestHeaders, null, 2)}\n\`\`\`\n`;
112
+ }
113
+ if (copySettings.includeRequestBody) {
114
+ md += `\n## Request Data\n`;
115
+ if (shouldIncludePayload(event.requestData, copySettings.bodySizeThreshold)) {
116
+ md += `\`\`\`json\n${JSON.stringify(event.requestData, null, 2)}\n\`\`\`\n`;
117
+ } else {
118
+ const size = event.requestSize ? (0, _formatting.formatBytes)(event.requestSize) : "Unknown size";
119
+ md += `_Payload omitted (${size}). Exceeds ${copySettings.bodySizeThreshold}KB threshold._\n`;
120
+ }
121
+ }
122
+ if (copySettings.includeResponseHeaders && Object.keys(event.responseHeaders).length > 0) {
123
+ md += `\n## Response Headers\n\`\`\`json\n${JSON.stringify(event.responseHeaders, null, 2)}\n\`\`\`\n`;
124
+ }
125
+ if (copySettings.includeResponseBody) {
126
+ md += `\n## Response Data\n`;
127
+ if (shouldIncludePayload(event.responseData, copySettings.bodySizeThreshold)) {
128
+ md += `\`\`\`json\n${JSON.stringify(event.responseData, null, 2)}\n\`\`\`\n`;
129
+ } else {
130
+ const size = event.responseSize ? (0, _formatting.formatBytes)(event.responseSize) : "Unknown size";
131
+ md += `_Payload omitted (${size}). Exceeds ${copySettings.bodySizeThreshold}KB threshold._\n`;
132
+ }
133
+ }
134
+ md += `\n---\n`;
135
+ return md;
136
+ }).join("\n");
137
+ const header = `# Network Requests (${eventsToUse.length} total)\n\n`;
138
+ return header + requests;
139
+ }
@@ -26,6 +26,11 @@ export { NetworkEventItemCompact } from "./network/components/NetworkEventItemCo
26
26
  // HOOKS (For consuming network data in custom components)
27
27
  // =============================================================================
28
28
  export { useNetworkEvents } from "./network/hooks/useNetworkEvents";
29
+ // Narrow per-consumer subscriptions (what NetworkModal itself uses) — the
30
+ // list and the header counters subscribe independently so a host UI never
31
+ // re-renders wholesale per network event.
32
+ export { useNetworkEventList } from "./network/hooks/useNetworkEventList";
33
+ export { useNetworkBadgeCounts } from "./network/hooks/useNetworkBadgeCounts";
29
34
  export { TickProvider, useTickEveryMinute } from "./network/hooks/useTickEveryMinute";
30
35
 
31
36
  // =============================================================================
@@ -109,11 +109,33 @@ function SizeIndicators({
109
109
  * Compact list-row representation of a network event. Optimized for large lists with memoization
110
110
  * and periodic refresh via `useTickEveryMinute` to keep relative timestamps accurate.
111
111
  */
112
- export const NetworkEventItemCompact = /*#__PURE__*/memo(({
112
+ /**
113
+ * Sole tick-context consumer in the row (rule2: composition over memo) —
114
+ * the periodic relative-timestamp tick re-renders ONLY this Text, not the
115
+ * whole card. Profiling (tool-bench 2026-07-06): the tick accounted for
116
+ * ~30 of 65 row renders (plus their SVG-icon churn) in a 12s recording.
117
+ */
118
+ function BottomRightTime({
119
+ timestamp,
120
+ duration
121
+ }) {
122
+ const tick = useTickEveryMinute();
123
+ const relativeTime = formatRelativeTime(timestamp, tick);
124
+ return duration ? /*#__PURE__*/_jsxs(Text, {
125
+ style: styles.bottomRightText,
126
+ children: [formatDuration(duration), " ", relativeTime]
127
+ }) : /*#__PURE__*/_jsx(Text, {
128
+ style: styles.bottomRightText,
129
+ children: relativeTime
130
+ });
131
+ }
132
+ export const NetworkEventItemCompact = /*#__PURE__*/memo(
133
+ // Named function (not an arrow) so render-capture reports attribute row
134
+ // renders to this component instead of a compiled anonymous name.
135
+ function NetworkEventItemCompact({
113
136
  event,
114
137
  onPress
115
- }) => {
116
- const tick = useTickEveryMinute();
138
+ }) {
117
139
  const statusColor = getStatusColor(event.status, event.error);
118
140
  const isPending = !event.status && !event.error;
119
141
  const contentType = getContentTypeBadge(event.responseHeaders);
@@ -135,15 +157,6 @@ export const NetworkEventItemCompact = /*#__PURE__*/memo(({
135
157
  // For non-GraphQL requests with operation names (e.g., gRPC)
136
158
  displayUrl = `${displayUrl}\n(${event.operationName})`;
137
159
  }
138
-
139
- // Format time with both absolute and relative
140
- const timeString = new Date(event.timestamp).toLocaleTimeString("en-US", {
141
- hour: "numeric",
142
- minute: "2-digit",
143
- second: "2-digit",
144
- hour12: true
145
- });
146
- const relativeTime = formatRelativeTime(event.timestamp, tick);
147
160
  return /*#__PURE__*/_jsxs(DevToolsCard, {
148
161
  onPress: () => onPress(event),
149
162
  style: styles.cardWrapper,
@@ -194,16 +207,17 @@ export const NetworkEventItemCompact = /*#__PURE__*/memo(({
194
207
  })]
195
208
  }), /*#__PURE__*/_jsx(View, {
196
209
  style: styles.bottomRightContainer,
197
- children: event.duration ? /*#__PURE__*/_jsxs(Text, {
198
- style: styles.bottomRightText,
199
- children: [formatDuration(event.duration), " ", relativeTime]
200
- }) : /*#__PURE__*/_jsx(Text, {
201
- style: styles.bottomRightText,
202
- children: relativeTime
210
+ children: /*#__PURE__*/_jsx(BottomRightTime, {
211
+ timestamp: event.timestamp,
212
+ duration: event.duration
203
213
  })
204
214
  })]
205
215
  });
206
216
  });
217
+
218
+ // Without this, render-capture reports attribute row renders to the
219
+ // anonymous memo wrapper ("Memo"/"_c4") instead of the row.
220
+ NetworkEventItemCompact.displayName = "NetworkEventItemCompact";
207
221
  const styles = StyleSheet.create({
208
222
  cardWrapper: {
209
223
  marginHorizontal: 8