@rozenite/network-activity-plugin 1.8.1 → 1.10.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 (72) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/devtools/App.html +2 -2
  3. package/dist/devtools/assets/{App-m6xge0az.css → App-CUXU0mup.css} +152 -2
  4. package/dist/devtools/assets/{App-B3xlUjs6.js → App-DsimzJvx.js} +6833 -966
  5. package/dist/react-native/chunks/boot-recording.cjs +156 -28
  6. package/dist/react-native/chunks/boot-recording.js +156 -28
  7. package/dist/react-native/chunks/get-nitro-module.cjs +12 -0
  8. package/dist/react-native/chunks/get-nitro-module.js +13 -0
  9. package/dist/react-native/chunks/useNetworkActivityDevTools.require.cjs +20 -1
  10. package/dist/react-native/chunks/useNetworkActivityDevTools.require.js +20 -1
  11. package/dist/react-native/index.d.ts +39 -3
  12. package/dist/rozenite.json +1 -1
  13. package/dist/sdk/index.d.ts +37 -1
  14. package/package.json +12 -7
  15. package/src/react-native/agent/use-network-activity-agent-tools.ts +22 -4
  16. package/src/react-native/http/__tests__/http-utils.test.ts +228 -0
  17. package/src/react-native/http/http-utils.ts +209 -25
  18. package/src/react-native/network-inspector.ts +2 -2
  19. package/src/react-native/nitro-fetch/get-nitro-module.ts +13 -0
  20. package/src/react-native/nitro-fetch/nitro-network-inspector.ts +10 -11
  21. package/src/shared/http-events.ts +40 -1
  22. package/src/ui/components/CodeBlock.tsx +45 -1
  23. package/src/ui/components/FilterBar.tsx +366 -58
  24. package/src/ui/components/HexView.tsx +54 -0
  25. package/src/ui/components/MetadataCard.tsx +95 -0
  26. package/src/ui/components/RequestList.tsx +192 -34
  27. package/src/ui/components/SidePanel.tsx +42 -1
  28. package/src/ui/components/ViewToggle.tsx +44 -0
  29. package/src/ui/components/XmlTree.tsx +160 -0
  30. package/src/ui/components/__tests__/CodeBlock.test.tsx +89 -0
  31. package/src/ui/components/__tests__/HexView.test.tsx +41 -0
  32. package/src/ui/components/__tests__/MetadataCard.test.tsx +107 -0
  33. package/src/ui/components/__tests__/ViewToggle.test.tsx +80 -0
  34. package/src/ui/components/__tests__/XmlTree.test.tsx +149 -0
  35. package/src/ui/response-renderers/__tests__/binary-too-large.test.tsx +56 -0
  36. package/src/ui/response-renderers/__tests__/binary.test.tsx +96 -0
  37. package/src/ui/response-renderers/__tests__/dispatch.test.ts +124 -0
  38. package/src/ui/response-renderers/__tests__/html.test.tsx +101 -0
  39. package/src/ui/response-renderers/__tests__/image.test.tsx +73 -0
  40. package/src/ui/response-renderers/__tests__/json.test.tsx +95 -0
  41. package/src/ui/response-renderers/__tests__/svg.test.tsx +46 -0
  42. package/src/ui/response-renderers/__tests__/xml.test.tsx +100 -0
  43. package/src/ui/response-renderers/binary-too-large.tsx +36 -0
  44. package/src/ui/response-renderers/binary.tsx +31 -0
  45. package/src/ui/response-renderers/empty.tsx +14 -0
  46. package/src/ui/response-renderers/html.tsx +36 -0
  47. package/src/ui/response-renderers/image.tsx +37 -0
  48. package/src/ui/response-renderers/index.ts +55 -0
  49. package/src/ui/response-renderers/json.tsx +40 -0
  50. package/src/ui/response-renderers/svg.tsx +27 -0
  51. package/src/ui/response-renderers/text-fallback.tsx +14 -0
  52. package/src/ui/response-renderers/types.ts +38 -0
  53. package/src/ui/response-renderers/unknown.tsx +18 -0
  54. package/src/ui/response-renderers/xml.tsx +46 -0
  55. package/src/ui/state/derived.ts +12 -0
  56. package/src/ui/state/model.ts +6 -1
  57. package/src/ui/state/store.ts +39 -2
  58. package/src/ui/tabs/InitiatorTab.tsx +230 -0
  59. package/src/ui/tabs/ResponseTab.tsx +80 -96
  60. package/src/ui/tabs/__tests__/ResponseTab.test.tsx +102 -0
  61. package/src/ui/utils/__tests__/download.test.ts +115 -0
  62. package/src/ui/utils/__tests__/hex.test.ts +84 -0
  63. package/src/ui/utils/__tests__/symbolication.test.ts +207 -0
  64. package/src/ui/utils/download.ts +154 -0
  65. package/src/ui/utils/hex.ts +59 -0
  66. package/src/ui/utils/initiator.ts +136 -0
  67. package/src/ui/utils/symbolication.ts +248 -0
  68. package/src/ui/views/InspectorView.tsx +8 -5
  69. package/src/utils/__tests__/getContentTypeMimeType.test.ts +65 -0
  70. package/src/utils/getContentTypeMimeType.ts +28 -0
  71. package/vite.config.ts +9 -1
  72. package/vitest.setup.ts +31 -0
@@ -1,12 +1,19 @@
1
- import {
1
+ import type {
2
2
  XHRPostData,
3
3
  RequestPostData,
4
4
  RequestTextPostData,
5
5
  RequestBinaryPostData,
6
6
  RequestFormDataPostData,
7
+ ResponseBody,
8
+ Initiator,
9
+ InitiatorStackFrame,
7
10
  } from '../../shared/client';
8
11
  import { safeStringify } from '../../utils/safeStringify';
9
12
  import { getStringSizeInBytes } from '../../utils/getStringSizeInBytes';
13
+ import {
14
+ isJsonContentType,
15
+ isXmlContentType,
16
+ } from '../../utils/getContentTypeMimeType';
10
17
  import {
11
18
  isBlob,
12
19
  isArrayBuffer,
@@ -113,9 +120,48 @@ export const getResponseSize = (request: XMLHttpRequest): number | null => {
113
120
  }
114
121
  };
115
122
 
123
+ // Cap on binary capture. Above this, we ship a 'binary-too-large' variant
124
+ // with just the size — no bytes cross the bridge. 5MB comfortably covers
125
+ // debug-relevant images while keeping the bridge from choking on outliers.
126
+ export const BINARY_CAPTURE_SIZE_CAP = 5 * 1024 * 1024;
127
+
128
+ const readBlobAsText = (blob: Blob): Promise<string> =>
129
+ new Promise((resolve) => {
130
+ const reader = new FileReader();
131
+ reader.onload = () => resolve(reader.result as string);
132
+ reader.readAsText(blob);
133
+ });
134
+
135
+ const readBlobAsBase64 = (blob: Blob): Promise<string> =>
136
+ new Promise((resolve) => {
137
+ const reader = new FileReader();
138
+ reader.onload = () => {
139
+ // FileReader.readAsDataURL returns "data:<mime>;base64,<payload>".
140
+ // We only want the base64 payload.
141
+ const dataUrl = reader.result as string;
142
+ resolve(dataUrl.substring(dataUrl.indexOf(',') + 1));
143
+ };
144
+ reader.readAsDataURL(blob);
145
+ });
146
+
147
+ // String.fromCharCode.apply(null, hugeArray) blows up around 64K elements
148
+ // in some engines; chunk through 32K windows so we work uniformly across
149
+ // large arraybuffer responses.
150
+ const ARRAY_BUFFER_BASE64_CHUNK = 0x8000;
151
+
152
+ const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
153
+ const bytes = new Uint8Array(buffer);
154
+ let binary = '';
155
+ for (let i = 0; i < bytes.length; i += ARRAY_BUFFER_BASE64_CHUNK) {
156
+ const chunk = bytes.subarray(i, i + ARRAY_BUFFER_BASE64_CHUNK);
157
+ binary += String.fromCharCode.apply(null, chunk as unknown as number[]);
158
+ }
159
+ return btoa(binary);
160
+ };
161
+
116
162
  export const getResponseBody = async (
117
163
  request: XMLHttpRequest,
118
- ): Promise<string | null> => {
164
+ ): Promise<ResponseBody> => {
119
165
  const responseType = request.responseType;
120
166
 
121
167
  // Response type is empty in certain cases, like when using axios.
@@ -124,22 +170,39 @@ export const getResponseBody = async (
124
170
  }
125
171
 
126
172
  if (responseType === 'blob') {
127
- // This may be a text blob.
128
173
  const contentType = request.getResponseHeader('Content-Type') || '';
129
174
 
175
+ // Text-shaped content stays on the text path so the panel can
176
+ // show source in the Raw view without base64 round-tripping. XML
177
+ // composite types (application/xml, atom+xml, soap+xml, ...) and
178
+ // image/svg+xml are all covered by isXmlContentType per RFC 7303.
130
179
  if (
131
180
  contentType.startsWith('text/') ||
132
- contentType.startsWith('application/json')
181
+ isJsonContentType(contentType) ||
182
+ isXmlContentType(contentType)
133
183
  ) {
134
- // It looks like a text blob, let's read it and forward it to the client.
135
- return new Promise((resolve) => {
136
- const reader = new FileReader();
137
- reader.onload = () => {
138
- resolve(reader.result as string);
139
- };
140
- reader.readAsText(request.response);
141
- });
184
+ return readBlobAsText(request.response);
185
+ }
186
+
187
+ // Everything else is binary — image/*, application/pdf, audio/*,
188
+ // video/*, font/*, application/octet-stream, anything novel a
189
+ // server might serve.
190
+ const blob = request.response as Blob;
191
+ if (blob.size > BINARY_CAPTURE_SIZE_CAP) {
192
+ return { kind: 'binary-too-large', size: blob.size };
193
+ }
194
+ return { kind: 'binary', base64: await readBlobAsBase64(blob) };
195
+ }
196
+
197
+ if (responseType === 'arraybuffer') {
198
+ const buffer = request.response as ArrayBuffer | null;
199
+ if (!buffer || buffer.byteLength === 0) {
200
+ return null;
201
+ }
202
+ if (buffer.byteLength > BINARY_CAPTURE_SIZE_CAP) {
203
+ return { kind: 'binary-too-large', size: buffer.byteLength };
142
204
  }
205
+ return { kind: 'binary', base64: arrayBufferToBase64(buffer) };
143
206
  }
144
207
 
145
208
  if (responseType === 'json') {
@@ -149,26 +212,147 @@ export const getResponseBody = async (
149
212
  return null;
150
213
  };
151
214
 
152
- export const getInitiatorFromStack = (): {
153
- type: string;
154
- url?: string;
155
- lineNumber?: number;
156
- columnNumber?: number;
157
- } => {
215
+ const STACK_PREVIEW_FRAME_LIMIT = 8;
216
+ const INITIATOR_STACK_FRAME_OFFSET = 3;
217
+
218
+ const parseStackLocation = (
219
+ location: string,
220
+ ): Pick<InitiatorStackFrame, 'url' | 'lineNumber' | 'columnNumber'> | null => {
221
+ const match = location.match(/^(.*):(\d+):(\d+)$/);
222
+
223
+ if (!match) {
224
+ return null;
225
+ }
226
+
227
+ return {
228
+ url: match[1],
229
+ lineNumber: Number.parseInt(match[2], 10),
230
+ columnNumber: Number.parseInt(match[3], 10),
231
+ };
232
+ };
233
+
234
+ const normalizeFunctionName = (functionName?: string) => {
235
+ const trimmedFunctionName = functionName?.trim();
236
+
237
+ return trimmedFunctionName &&
238
+ trimmedFunctionName !== '<anonymous>' &&
239
+ trimmedFunctionName !== 'anonymous' &&
240
+ trimmedFunctionName !== '<unknown>'
241
+ ? trimmedFunctionName
242
+ : undefined;
243
+ };
244
+
245
+ const parseStackFrame = (line: string): InitiatorStackFrame | null => {
246
+ const trimmedLine = line.trim();
247
+
248
+ if (!trimmedLine) {
249
+ return null;
250
+ }
251
+
252
+ let functionName: string | undefined;
253
+ let location: string | undefined;
254
+
255
+ const v8FunctionFrame = trimmedLine.match(/^at\s+(.*?)\s+\((.*)\)$/);
256
+ if (v8FunctionFrame) {
257
+ functionName = v8FunctionFrame[1];
258
+ location = v8FunctionFrame[2];
259
+ } else {
260
+ const v8LocationFrame = trimmedLine.match(/^at\s+(.*)$/);
261
+ const jscFrame = trimmedLine.match(/^(.*?)@(.*)$/);
262
+
263
+ if (v8LocationFrame) {
264
+ location = v8LocationFrame[1];
265
+ } else if (jscFrame) {
266
+ functionName = jscFrame[1];
267
+ location = jscFrame[2];
268
+ }
269
+ }
270
+
271
+ if (!location) {
272
+ return null;
273
+ }
274
+
275
+ const parsedLocation = parseStackLocation(location);
276
+ if (!parsedLocation) {
277
+ return null;
278
+ }
279
+
280
+ return {
281
+ functionName: normalizeFunctionName(functionName),
282
+ ...parsedLocation,
283
+ };
284
+ };
285
+
286
+ const toGeneratedStackFrame = (
287
+ frame: InitiatorStackFrame,
288
+ ): InitiatorStackFrame => ({
289
+ functionName: frame.functionName,
290
+ generatedUrl: frame.url,
291
+ generatedLineNumber: frame.lineNumber,
292
+ generatedColumnNumber: frame.columnNumber,
293
+ });
294
+
295
+ const getGeneratedFrameLocation = (frame: InitiatorStackFrame) => ({
296
+ url: frame.generatedUrl ?? frame.url,
297
+ lineNumber: frame.generatedLineNumber ?? frame.lineNumber,
298
+ columnNumber: frame.generatedColumnNumber ?? frame.columnNumber,
299
+ });
300
+
301
+ const canSymbolicateStack = (stack?: InitiatorStackFrame[]) =>
302
+ stack?.some((frame) =>
303
+ getGeneratedFrameLocation(frame).url?.startsWith('http'),
304
+ ) ?? false;
305
+
306
+ const getStackPreview = (frames: InitiatorStackFrame[]) => {
307
+ // The first frames are this helper, the HTTP inspector callback and the XHR
308
+ // wrapper. The caller starts after that fixed interception boundary.
309
+ const callerFrames = frames.slice(INITIATOR_STACK_FRAME_OFFSET);
310
+
311
+ return (callerFrames.length > 0 ? callerFrames : frames).slice(
312
+ 0,
313
+ STACK_PREVIEW_FRAME_LIMIT,
314
+ );
315
+ };
316
+
317
+ export const getInitiatorFromStack = (): Initiator => {
158
318
  try {
159
319
  const stack = new Error().stack;
160
320
  if (!stack) {
161
321
  return { type: 'other' };
162
322
  }
163
323
 
164
- const line = stack.split('\n')[9];
165
- const match = line.match(/at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/);
166
- if (match) {
324
+ const parsedFrames = stack
325
+ .split('\n')
326
+ .map(parseStackFrame)
327
+ .filter((frame): frame is InitiatorStackFrame => frame !== null);
328
+
329
+ const stackPreview = getStackPreview(parsedFrames);
330
+ const initiatorFrame = stackPreview[0];
331
+ const generatedStackPreview = stackPreview.map(toGeneratedStackFrame);
332
+
333
+ if (initiatorFrame?.url) {
167
334
  return {
168
335
  type: 'script',
169
- url: match[2],
170
- lineNumber: parseInt(match[3]),
171
- columnNumber: parseInt(match[4]),
336
+ functionName: initiatorFrame.functionName,
337
+ generatedUrl: initiatorFrame.url,
338
+ generatedLineNumber: initiatorFrame.lineNumber,
339
+ generatedColumnNumber: initiatorFrame.columnNumber,
340
+ stack: generatedStackPreview,
341
+ symbolicationStatus: canSymbolicateStack(generatedStackPreview)
342
+ ? 'pending'
343
+ : 'unavailable',
344
+ };
345
+ }
346
+
347
+ if (parsedFrames.length > 0) {
348
+ const fallbackStack = stackPreview.map(toGeneratedStackFrame);
349
+
350
+ return {
351
+ type: 'other',
352
+ stack: fallbackStack,
353
+ symbolicationStatus: canSymbolicateStack(fallbackStack)
354
+ ? 'pending'
355
+ : 'unavailable',
172
356
  };
173
357
  }
174
358
  } catch {
@@ -195,7 +379,7 @@ export const setupRequestOverride = (
195
379
  Object.defineProperty(request, 'responseText', { writable: true });
196
380
 
197
381
  const contentType = getContentType(request);
198
- if (contentType === 'application/json') {
382
+ if (isJsonContentType(contentType)) {
199
383
  request.responseType = 'json';
200
384
  } else if (contentType === 'text/plain') {
201
385
  request.responseType = 'text';
@@ -15,7 +15,7 @@ import {
15
15
  NITRO_NETWORK_EVENTS,
16
16
  } from './nitro-fetch/nitro-network-inspector';
17
17
  import { EventsListener } from './events-listener';
18
- import { NetworkActivityEventMap } from '../shared/client';
18
+ import { NetworkActivityEventMap, ResponseBody } from '../shared/client';
19
19
  import type { InspectorsConfig } from './config';
20
20
  import { getResponseBody as getHTTPResponseBody } from './http/http-utils';
21
21
 
@@ -28,7 +28,7 @@ export type NetworkInspector = {
28
28
  enable: (config?: InspectorsConfig) => void;
29
29
  disable: () => void;
30
30
  dispose: () => void;
31
- getResponseBody: (requestId: string) => Promise<string | null>;
31
+ getResponseBody: (requestId: string) => Promise<ResponseBody>;
32
32
  };
33
33
 
34
34
  const createNetworkInspectorInstance = (): NetworkInspector => {
@@ -0,0 +1,13 @@
1
+ import type { NitroModule } from './nitro-network-inspector';
2
+
3
+ const nitroModule = (() => {
4
+ try {
5
+ return require('react-native-nitro-fetch') as NitroModule;
6
+ } catch {
7
+ return null;
8
+ }
9
+ })();
10
+
11
+ export const getNitroModule = (): NitroModule | null => {
12
+ return nitroModule;
13
+ };
@@ -4,9 +4,11 @@ import type {
4
4
  HttpHeaders,
5
5
  HttpMethod,
6
6
  RequestPostData,
7
+ ResponseBody,
7
8
  } from '../../shared/client';
8
9
  import type { WebSocketEventMap } from '../../shared/websocket-events';
9
10
  import type { Inspector } from '../inspector';
11
+ import { getNitroModule as loadNitroModule } from './get-nitro-module';
10
12
 
11
13
  type NitroHttpHeader = {
12
14
  key: string;
@@ -62,7 +64,7 @@ type NitroWebSocketEntry = {
62
64
 
63
65
  type NitroInspectorEntry = NitroHttpEntry | NitroWebSocketEntry;
64
66
 
65
- type NitroModule = {
67
+ export type NitroModule = {
66
68
  NetworkInspector: {
67
69
  enable: () => void;
68
70
  disable: () => void;
@@ -91,7 +93,12 @@ type NanoEventsMap = {
91
93
  };
92
94
 
93
95
  export type NitroNetworkInspector = Inspector<NitroNetworkEventMap> & {
94
- getResponseBody: (requestId: string) => string | null;
96
+ // Returns ResponseBody so the wire shape is consistent across capture
97
+ // paths. The nitro native module today only surfaces text response
98
+ // bodies, so at runtime this resolves to string | null — but typing it
99
+ // as ResponseBody lets future native support for binary slot in without
100
+ // a wire-format change.
101
+ getResponseBody: (requestId: string) => ResponseBody;
95
102
  };
96
103
 
97
104
  export const NITRO_NETWORK_EVENTS: (keyof NitroNetworkEventMap)[] = [
@@ -107,14 +114,6 @@ export const NITRO_NETWORK_EVENTS: (keyof NitroNetworkEventMap)[] = [
107
114
  'websocket-error',
108
115
  ];
109
116
 
110
- const loadNitroModule = (): NitroModule | null => {
111
- try {
112
- return require('react-native-nitro-fetch') as NitroModule;
113
- } catch {
114
- return null;
115
- }
116
- };
117
-
118
117
  const timestampOrigin =
119
118
  typeof performance !== 'undefined' &&
120
119
  typeof performance.timeOrigin === 'number'
@@ -170,7 +169,7 @@ export const createNitroNetworkInspector = (
170
169
  ): NitroNetworkInspector => {
171
170
  const eventEmitter = createNanoEvents<NanoEventsMap>();
172
171
  const previousEntries = new Map<string, NitroInspectorEntry>();
173
- const responseBodies = new Map<string, string | null>();
172
+ const responseBodies = new Map<string, ResponseBody>();
174
173
  let nitroModule: NitroModule | null = null;
175
174
  let unsubscribe: (() => void) | null = null;
176
175
 
@@ -1,6 +1,17 @@
1
1
  export type HttpHeaders = Record<string, string | string[]>;
2
2
  export type XHRHeaders = NonNullable<XMLHttpRequest['responseHeaders']>;
3
3
 
4
+ // Discriminated union for response bodies on the bridge.
5
+ // string → text body (today's path)
6
+ // { kind: 'binary' } → base64-encoded binary payload (e.g. images)
7
+ // { kind: 'binary-too-large' } → response exceeded the in-capture size cap; bytes not shipped
8
+ // null → body could not be read at all
9
+ export type ResponseBody =
10
+ | string
11
+ | { kind: 'binary'; base64: string }
12
+ | { kind: 'binary-too-large'; size: number }
13
+ | null;
14
+
4
15
  export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD';
5
16
 
6
17
  export type RequestId = string;
@@ -72,11 +83,39 @@ export type Response = {
72
83
  responseTime: Timestamp;
73
84
  };
74
85
 
86
+ export type InitiatorStackFrame = {
87
+ functionName?: string;
88
+ url?: string;
89
+ lineNumber?: number;
90
+ columnNumber?: number;
91
+ generatedUrl?: string;
92
+ generatedLineNumber?: number;
93
+ generatedColumnNumber?: number;
94
+ isCollapsed?: boolean;
95
+ };
96
+
97
+ export type InitiatorCodeFrame = {
98
+ content: string;
99
+ fileName: string;
100
+ location?: {
101
+ row: number;
102
+ column: number;
103
+ } | null;
104
+ };
105
+
75
106
  export type Initiator = {
76
107
  type: string;
108
+ symbolicationStatus?: 'pending' | 'complete' | 'failed' | 'unavailable';
109
+ symbolicationError?: string;
110
+ functionName?: string;
77
111
  url?: string;
78
112
  lineNumber?: number;
79
113
  columnNumber?: number;
114
+ generatedUrl?: string;
115
+ generatedLineNumber?: number;
116
+ generatedColumnNumber?: number;
117
+ codeFrame?: InitiatorCodeFrame | null;
118
+ stack?: InitiatorStackFrame[];
80
119
  };
81
120
 
82
121
  export type ResourceType = 'XHR' | 'Fetch' | 'Other';
@@ -137,7 +176,7 @@ export type HttpEventMap = {
137
176
 
138
177
  'response-body': {
139
178
  requestId: RequestId;
140
- body: string | null;
179
+ body: ResponseBody;
141
180
  };
142
181
 
143
182
  'set-overrides': {
@@ -1,8 +1,15 @@
1
- import { HTMLProps } from 'react';
1
+ import { HTMLProps, useMemo } from 'react';
2
+ import { Virtuoso } from 'react-virtuoso';
2
3
  import { cn } from '../utils/cn';
3
4
 
4
5
  export type CodeBlockProps = HTMLProps<HTMLPreElement>;
5
6
 
7
+ // Above this character count, string content renders through Virtuoso
8
+ // instead of a flat <pre>. Tuned so typical responses (<20KB) stay on
9
+ // the simple path, while pathological payloads (large pretty-printed
10
+ // JSON / minified bundles served as text / huge logs) virtualize.
11
+ const VIRTUALIZATION_THRESHOLD = 50_000;
12
+
6
13
  const codeBlockClassNames =
7
14
  'text-sm font-mono text-gray-300 whitespace-pre-wrap bg-gray-800 p-3 rounded-md border border-gray-700 overflow-x-auto wrap-anywhere';
8
15
 
@@ -11,9 +18,46 @@ export const CodeBlock = ({
11
18
  className,
12
19
  ...props
13
20
  }: CodeBlockProps) => {
21
+ // Only string children are eligible for virtualization. Component
22
+ // children (JsonTree / XmlTree / etc.) manage their own rendering;
23
+ // CodeBlock here just provides the monospace-on-dark frame.
24
+ if (
25
+ typeof children === 'string' &&
26
+ children.length > VIRTUALIZATION_THRESHOLD
27
+ ) {
28
+ return <VirtualizedCodeBlock text={children} className={className} />;
29
+ }
30
+
14
31
  return (
15
32
  <pre className={cn(codeBlockClassNames, className)} {...props}>
16
33
  {children}
17
34
  </pre>
18
35
  );
19
36
  };
37
+
38
+ type VirtualizedCodeBlockProps = {
39
+ text: string;
40
+ className?: string;
41
+ };
42
+
43
+ const VirtualizedCodeBlock = ({
44
+ text,
45
+ className,
46
+ }: VirtualizedCodeBlockProps) => {
47
+ const lines = useMemo(() => text.split('\n'), [text]);
48
+
49
+ // Content with no newlines collapses to a single row containing the
50
+ // entire payload. Browser wrapping (whitespace-pre-wrap, wrap-anywhere)
51
+ // still keeps layout sane, but there's no real virtualization benefit
52
+ // for that shape — the size win shows up once the body has many lines.
53
+ return (
54
+ <Virtuoso
55
+ style={{ height: 500 }}
56
+ totalCount={lines.length}
57
+ itemContent={(idx) => (
58
+ <div className="whitespace-pre-wrap wrap-anywhere">{lines[idx]}</div>
59
+ )}
60
+ className={cn(codeBlockClassNames, className)}
61
+ />
62
+ );
63
+ };