Skip to content

feat(network-activity-plugin): binary hex viewer + metadata card#274

Merged
V3RON merged 4 commits into
callstackincubator:mainfrom
burczu:feat/network-activity-plugin-binary-hex-viewer
May 19, 2026
Merged

feat(network-activity-plugin): binary hex viewer + metadata card#274
V3RON merged 4 commits into
callstackincubator:mainfrom
burczu:feat/network-activity-plugin-binary-hex-viewer

Conversation

@burczu
Copy link
Copy Markdown
Contributor

@burczu burczu commented May 15, 2026

Part of #244 — "Improve response viewing in Network Activity." Adds a real hex viewer + Download affordance for binary responses.

Summary

  • Capture widens to everything-not-text. XHR's getResponseBody now treats every non-text-friendly blob as binary, not just image/*. The same branch also handles responseType === 'arraybuffer' via a chunked String.fromCharCode encoder so multi-MB payloads don't blow the engine's argument limit. The 5 MB cap continues to apply uniformly. Invariant after this PR: body === null on the wire means only "we genuinely could not read the body."
  • Binary hex viewer. A new <HexView> component renders responses as the classic offset / hex / ASCII grid, virtualized via react-virtuoso from the first row. Native text selection covers what's currently on screen; for whole-payload extraction users reach for the Download button.
  • Metadata card. Shared between imageRenderer (Raw view, upgraded) and the new binaryRenderer. Shows decoded size, Content-Length header when present, derived filename, and a Download button that decodes the base64 to a Blob and triggers an anchor click. The filename uses a three-tier rule: Content-Disposition (RFC 5987 filename* preferred) → URL last path segment → response.<ext> from a small content-type → extension map (fallback .bin).
  • binaryRenderer matches body.kind === 'binary' && !contentType.startsWith('image/') — PDF / font / audio / video / zip / octet-stream / anything-else. Declares views: ['raw'] (the Preview / Raw toggle is hidden adaptively). Registered in the dispatcher between imageRenderer and the JSON / text-fallback renderers.
  • Image Raw view upgrade. What was a metadata-only card is now the full <MetadataCard /><HexView /> pair, matching binary entries. Image's Preview is unchanged.
  • RenderCtx grows two optional fieldsheaders and size — which ResponseTab pulls from selectedRequest.response.
  • nitro-fetch path unchanged at runtime. The native module still surfaces only text bodies; the wire type widened earlier covers the eventual binary case. A native-side change in react-native-nitro-fetch is tracked separately.
  • Playground. A new "Binary" main-tab on the Network Test screen with PDF / octet-stream / WOFF2 / MP3 / ZIP demo buttons (pre-verified URLs).

Test plan

  • pnpm --filter @rozenite/network-activity-plugin test100 tests pass (41 new in this PR).
  • pnpm --filter @rozenite/network-activity-plugin typecheck — clean.
  • pnpm --filter @rozenite/network-activity-plugin lint — clean.
  • Playground (pnpm start:playground:ios or :android) → Network Test → Binary tab:
    • PDF — Raw view shows the %PDF-1.x header in the hex ASCII column. Download saves a working PDF.
    • octet-stream — random bytes render correctly (mostly dots in the ASCII column).
    • WOFF2 — wOF2 magic visible at byte 0.
    • MP3 — ID3 magic visible if the source has an ID3v2 header.
    • ZIP — PK\x03\x04 magic visible. Download saves a working zip.
  • Network Test → Images tab → tap any image → Raw view now shows MetadataCard + HexView (was metadata-only).
  • Huge JPEG cap test still surfaces as "Response too large for preview" — Download button disabled with tooltip explaining the cap.
  • No Preview / Raw toggle on non-image binary entries (binaryRenderer declares only Raw, adaptive rule hides the toggle).
  • Switching between captured requests rapidly doesn't desync hex view contents.
  • DevTools console stays quiet — no react-virtuoso warnings, no ResizeObserver errors.

New test surface

  • utils/__tests__/hex.test.ts (11) — toHexPair / toAsciiChar / formatHexRow (full row + short row + offset + half-row gap) / rowCountForByteLength.
  • utils/__tests__/download.test.ts (13) — base64ToBytes, base64ToBlob (Blob.type fallback), readHeader (case-insensitive lookup, array-valued headers), deriveFilename (Content-Disposition incl. RFC 5987 filename*, URL last segment, content-type → extension fallback, parameter stripping).
  • components/__tests__/HexView.test.tsx (4) — empty-buffer placeholder, single-byte row, full row asserted with whitespace-preserving matcher.
  • components/__tests__/MetadataCard.test.tsx (8) — size / Content-Length / filename rendering, Download enable/disable semantics, jsdom-stubbed createObjectURL on click, binary-too-large variant.
  • response-renderers/__tests__/binary.test.tsx (7) — match predicate (claims non-image binary, declines image binary and strings), composed rendering of metadata + hex.
  • response-renderers/__tests__/dispatch.test.ts (+2) — binary routing matrix + priority assertion (image before binary, since both claim body.kind === 'binary').
  • react-native/http/__tests__/http-utils.test.ts (+5) — non-image-blob → binary union, arraybuffer capture, chunked 100 KB base64 round-trip, arraybuffer cap short-circuit, null payload + empty buffer guards.

Followups (deliberately not in this PR)

  • File magic-number detection ("first 4 bytes are %PDF — detected as PDF"). Could appear as a hint above the hex view.
  • Image override flow. Today binary responses don't expose Override; a future PR could add image upload / paste-base64 / swap-with-file.
  • Native-side change in react-native-nitro-fetch so nitro-captured image / binary responses also reach the panel as bytes. Tracked separately.

Tooling

  • react-virtuoso ^4.6.0 added to @rozenite/network-activity-plugin devDeps (was already a workspace dep via perfmon-plugin).
  • vitest.setup.ts replaces react-virtuoso's Virtuoso with a non-virtualized passthrough so RTL tests can assert on rendered row content (jsdom can't drive the ResizeObserver virtuoso needs).

burczu added 4 commits May 18, 2026 08:39
The XHR getResponseBody now widens its binary path past image
content-types. After the text / JSON / SVG carve-outs, any blob is
captured as base64 — application/pdf, application/zip, audio/*,
video/*, font/*, application/octet-stream, anything else a server
might serve. arraybuffer responseType gains its own branch that
reads request.response as an ArrayBuffer and base64-encodes the
bytes through a chunked helper so we don't hit the
String.fromCharCode.apply argument-limit on multi-megabyte buffers.
The 5 MB cap applies uniformly to both paths.

null payloads and zero-length arraybuffers short-circuit to null —
the existing semantics for "request did not produce a body."

Post-commit invariant: body === null at the UI means only "the
plugin genuinely could not read the body" — every other shape
arrives via the wire union.

The nitro-fetch capture path is unchanged at runtime — its native
module still surfaces only text bodies, and its type widened in PR1.
A native-side change in react-native-nitro-fetch is tracked
separately.

The test file's "PR2 widens this" placeholder for application/pdf
is flipped to assert the binary union output. New tests cover the
arraybuffer branch (basic capture, chunked 100 KB round-trip,
cap short-circuit, null payload, empty buffer).
Non-image binary responses (PDF, audio, video, fonts, application/
octet-stream, anything else) get a real first-class rendering: a
metadata card on top, a virtualized hex view underneath. The Raw
view of image responses is upgraded with the same pair, so the
"show me the bytes" affordance is consistent across every binary
shape.

New surfaces:

  - HexView (src/ui/components/HexView.tsx): react-virtuoso list of
    classic offset / hex / ASCII rows, native text selection over
    the rendered rows, no toolbar — when users need every byte they
    reach for the Download button on the metadata card.
  - MetadataCard (src/ui/components/MetadataCard.tsx): decoded size +
    Content-Length header (when present) + derived filename via a
    three-tier rule (Content-Disposition → URL last path segment →
    response.<ext> with a small content-type → extension map).
    Download button decodes the base64 to a Blob and triggers an
    anchor click. Disabled for the binary-too-large variant with a
    tooltip explaining the 5 MB cap.
  - utils/hex.ts: toHexPair, toAsciiChar, formatHexRow (preserves
    the post-byte-8 double-space gap so the column reads like xxd
    output), rowCountForByteLength.
  - utils/download.ts: base64ToBytes / base64ToBlob (uses an
    explicit ArrayBuffer allocation so the BlobPart type is happy),
    readHeader (case-insensitive lookup), deriveFilename with RFC
    5987 filename* preference, downloadBlob.
  - binaryRenderer (src/ui/response-renderers/binary.tsx): matches
    body.kind === 'binary' && !contentType.startsWith('image/');
    views: ['raw']; renders MetadataCard + HexView. Registered
    between imageRenderer and jsonRenderer in the dispatcher.

Modified surfaces:

  - imageRenderer Raw view: was a metadata-only card; now the same
    MetadataCard + HexView pair as binaryRenderer. The image's
    Preview is unchanged.
  - RenderCtx gains optional headers + size fields. ResponseTab
    populates them from selectedRequest.response.

Tooling:

  - react-virtuoso ^4.6.0 added to network-activity-plugin devDeps
    (was already a workspace dep via perfmon-plugin).
  - vitest.setup.ts replaces react-virtuoso's Virtuoso with a non-
    virtualized passthrough so RTL tests can assert on rendered row
    content (jsdom can't drive the ResizeObserver virtuoso needs).

Tests (100 passing across the plugin, 41 new in this commit):

  - utils/__tests__/hex.test.ts (11 cases) — byte/char helpers,
    full / short / offset / half-row formatting.
  - utils/__tests__/download.test.ts (13 cases) — base64 decode +
    Blob construction, case-insensitive header lookup, three-tier
    filename derivation with RFC 5987 preference.
  - components/__tests__/HexView.test.tsx (4 cases) — empty-buffer
    placeholder, single-byte row, fully-formatted row asserted with
    whitespace-preserving matcher.
  - components/__tests__/MetadataCard.test.tsx (8 cases) — size /
    Content-Length / filename rendering, Download enable/disable
    semantics, jsdom-stubbed createObjectURL on click.
  - response-renderers/__tests__/binary.test.tsx (7 cases) — match
    predicate (claims non-image binary, declines image binary and
    strings), composed rendering of metadata + hex.
  - response-renderers/__tests__/dispatch.test.ts (+2 cases) —
    binary routing matrix + new priority assertion (image before
    binary, since both claim body.kind === 'binary').
  - response-renderers/__tests__/image.test.tsx — Raw assertion
    updated to reflect the new MetadataCard + HexView composition.
A new "Binary" main-tab on the Network Test screen fires GETs at PDF /
octet-stream / WOFF2 / MP3 / ZIP endpoints so the panel's hex viewer
and metadata card can be exercised end-to-end. Same component pattern
as the existing Images tab — title + hint, button grid, last-result
label, error / loading states.

URL set was pre-verified during PR planning: stable hosts (W3C,
httpbin, jsdelivr, Kozco, GitHub codeload), small enough to stay
under the 5 MB capture cap, content-types that cover the
binary-renderer's discriminated families (application/pdf,
application/octet-stream, font/*, audio/*, application/zip).
Minor bump for @rozenite/network-activity-plugin. The "Response
Viewing" section in network-activity.mdx is extended to cover the
new binary case: image Raw view upgraded to metadata + hex, a
dedicated bullet for non-image binary (PDF / font / audio / video /
zip / octet-stream), an explanatory note on the size cap also
disabling Download, and a new "Downloading bytes" subsection that
documents the three-tier filename derivation rule.
@burczu burczu force-pushed the feat/network-activity-plugin-binary-hex-viewer branch from 943ef3f to a2ac73c Compare May 18, 2026 06:40
@burczu burczu marked this pull request as ready for review May 18, 2026 06:41
@V3RON V3RON self-requested a review May 19, 2026 05:23
@V3RON V3RON merged commit 032ff3e into callstackincubator:main May 19, 2026
1 check passed
V3RON pushed a commit that referenced this pull request May 19, 2026
Part of #244 — "Improve response viewing in Network Activity." Makes the
response panel's CodeBlock threshold-aware so multi-megabyte text bodies
don't pin hundreds of thousands of DOM nodes.

## Summary

- **`CodeBlock` becomes threshold-aware.** When `children` is a string
longer than 50,000 characters, the body renders inside a
`react-virtuoso` `<Virtuoso>` and only the rows currently visible inside
a 500 px window stay in the DOM. Below the threshold (and for any
non-string children — `<JsonTree>`, `<XmlTree>` wrapped in CodeBlock for
the monospace-on-dark frame), rendering is unchanged: a flat `<pre>`.
The dispatch is a runtime `typeof children === 'string'` branch — zero
changes required at any of the 11+ existing CodeBlock call sites (json /
xml / svg / text-fallback renderers, malformed-fallback paths,
RequestBody).
- **Virtuoso configuration.** Fixed `style={{ height: 500 }}` matches
the visual scale of HexView and the iframe elsewhere in the response
panel. `<div>` rows (Virtuoso positions rows absolutely and needs
block-level items). Per-row `whitespace-pre-wrap wrap-anywhere`
preserves today's wrapping UX so long URLs / base64 /
minified-on-one-line content wrap visually instead of forcing horizontal
scroll. Outer styling reuses the existing `codeBlockClassNames` constant
so the dark bg / monospace / border / padding are identical between the
two branches.
- **Known shape limitation.** A 50 KB body with no newlines virtualizes
to a single row containing the entire payload. Browser-level wrapping
keeps the layout sane but there's no real virtualization benefit for
that shape — the perf win shows up once the body has many lines.
- **UX trade-off worth knowing.** Today's `<pre>` expands to content
(height grows with body length). After this change, content above 50 KB
scrolls inside a 500 px window. Deliberate, for snappy rendering on
multi-megabyte responses.
- **Adds `react-virtuoso ^4.6.0`** to
`@rozenite/network-activity-plugin`'s `dependencies`.
- **Playground.** A new "Large text" main-tab on `NetworkTestScreen`
with two pre-verified URLs straddling the threshold so reviewers can see
both branches: Alice in Wonderland from Gutenberg (~174 KB, virtualizes)
and the GPL-3 license text (~35 KB, stays flat).

## Test plan

- [ ] `pnpm --filter @rozenite/network-activity-plugin test` — all tests
pass (8 new in `CodeBlock.test.tsx`).
- [ ] `pnpm --filter @rozenite/network-activity-plugin typecheck` —
clean.
- [ ] `pnpm --filter @rozenite/network-activity-plugin lint` — clean.
- [ ] `npx prettier --check` on changed files — clean.
- [ ] Playground (`pnpm --filter @rozenite/playground ios` or
`:android`) → Network Test → Large text tab:
- [ ] **Alice in Wonderland (~174 KB)** — Response body scrolls inside a
500 px window. NOT an unbounded `<pre>`. Scrolling is smooth at the end
of the ~3300-line file. Dark bg / monospace / border look identical to a
small `<pre>`.
- [ ] **GPL-3 license (~35 KB)** — Response body renders as a flat
`<pre>`, height grows with content, no internal scrolling. (Control:
confirms the threshold gating works.)
- [ ] **JSON tree responses unchanged** — open any JSON response (HTTP
Test → User-list). Renders as JsonTree wrapped in CodeBlock as before.
No virtualization (children is a React element, not a string).
- [ ] **SVG Raw view unchanged** — Images tab → SVG → toggle to Raw.
Renders as flat `<pre>` with SVG source (SVG bodies are well under
threshold).
- [ ] **Toggle behavior unchanged** — sticky Preview/Raw preference,
override button presence, content-type label all work as before.

## New test surface

`components/__tests__/CodeBlock.test.tsx` (8 tests):

- Small string content renders as `<pre>` with no virtuoso-mock present.
- Exactly 50,000 chars stays on the flat path (inclusive-boundary
semantics — the branch condition is `>`, not `>=`).
- 50,001 chars switches to the virtualized rendering.
- Content survives the threshold split (recognizable head and tail
tokens both visible in the DOM after virtualization).
- Newlines in virtualized content produce one row per line.
- React-element children stay on the `<pre>` path even when the wrapped
element contains a 50,001-char string internally (the `typeof children
=== 'string'` check takes precedence).
- `className` forwarding on both the flat and virtualized branches.

`vitest.setup.ts` adds a `vi.mock('react-virtuoso')` passthrough so
jsdom-based tests can assert content correctness. jsdom can't drive
Virtuoso's resize observation otherwise — without the stub any test
importing a virtualizing component fails to render rows. Production code
is unaffected.

## Followups (deliberately not in this PR)

- Search inside virtualized content.
- Truncation modes.
- Virtualizing trees (`JsonTree`, `XmlTree`) — would require forking
`react-json-tree` or rewriting from scratch.
- Per-row line numbers in the virtualized branch.
- Syntax highlighting (cross-cutting feature; would also benefit XML /
JSON / HTML Raw views).
- Programmatic line-wrapping for single-line content longer than the
threshold (would require choosing a column width).

## Coordination notes

- **`react-virtuoso` dep + `vi.mock('react-virtuoso')` test stub** are
also added by #274 (binary hex viewer, ready for review). The lines are
identical on both branches; whichever lands second will see a clean
auto-merge (or a one-line trivial conflict on the dependencies block).
- **`network-activity.mdx`** Response Viewing section is also edited by
#275 (HTML) and #277 (XML + JSON Raw). This PR appends a separate
paragraph at the end of the section rather than touching their bullets —
additive, no overlap.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants