Skip to content

feat(network-activity-plugin): XML response renderer + JSON Raw view#277

Merged
V3RON merged 6 commits into
callstackincubator:mainfrom
burczu:feat/network-activity-plugin-xml-tree-json-raw
May 19, 2026
Merged

feat(network-activity-plugin): XML response renderer + JSON Raw view#277
V3RON merged 6 commits into
callstackincubator:mainfrom
burczu:feat/network-activity-plugin-xml-tree-json-raw

Conversation

@burczu
Copy link
Copy Markdown
Contributor

@burczu burczu commented May 18, 2026

Part of #244 — "Improve response viewing in Network Activity." Two complementary additions to the response renderer registry: a custom XML tree viewer for application/xml / text/xml / */foo+xml responses, and a Raw view alongside the existing JSON tree.

Summary

  • xmlRenderer matches XML content-types via a new isXmlContentType helper — application/xml, text/xml, and any */foo+xml per RFC 7303. The structural suffix rule sweeps in Atom, RSS, SOAP, XHTML, XSLT, and any future composite without an enumeration to maintain. Case-insensitive and parameter-tolerant via the existing normalizeContentType helper. image/svg+xml also matches the suffix but svgRenderer's earlier registry position keeps SVG claiming it first.
  • XmlTree component — custom, ~160 lines, DOMParser-based. Renders Element nodes as <tag attr="value"> with a chevron toggle, attributes inline, non-whitespace text nodes in-place, CDATA sections with explicit <![CDATA[ … ]]> markers (purple), and recursive children. Whitespace-only text nodes (DOM-pretty-print noise) are filtered. Namespaces render via nodeName so the prefix is visible (atom:entry, media:thumbnail). Comments / processing instructions / DOCTYPE are intentionally not rendered in the tree — they're rare in API responses and the Raw view shows them verbatim.
  • Collapse via display: none, not unmount — each <XmlNode> owns its own useState<boolean>(true) for expand state, and toggling sets style.display rather than conditionally rendering children. Nested collapse choices survive a parent collapse/expand cycle; matches DevTools' XML/HTML inspector behavior.
  • Copy affordances mirror JsonTreeCopyableItem. Element open tag copies the entire subtree via XMLSerializer; text and CDATA leaves copy their literal value.
  • Cross-engine parse-error detectionDOMParser doesn't throw on malformed XML; instead Chrome/Safari return a Document whose documentElement IS the <parsererror> element, while Firefox nests it in a dedicated namespace. The hasParseError helper covers both paths in three lines. On parse failure the renderer falls back to source + "Failed to parse as XML" warning, mirroring jsonRenderer's catch branch.
  • JSON Raw viewjsonRenderer extends views to ['preview', 'raw']. Preview keeps the existing tree; Raw shows the body pretty-printed via JSON.stringify(parsed, null, 2). APIs commonly ship minified JSON, and a literal Raw view of minified JSON is unreadable; re-serializing with 2-space indent matches Chrome DevTools' Response panel convention. The Preview/Raw toggle is now visible on every JSON response for the first time.
  • Capture-side fix — discovered during smoke testing that getResponseBody in http/http-utils.ts only routed text-shaped blobs to the text branch for text/*, JSON, and image/svg+xml. application/xml and +xml composite types fell through to null and never crossed the bridge. Added isXmlContentType to the text branch; the now-subsumed explicit image/svg+xml check was removed since isXmlContentType covers it via the +xml suffix.
  • Playground — new "XML" main-tab on NetworkTestScreen with two demos: httpbin.org/xml (sample application/xml, ~522 B) and github.com/callstackincubator/rozenite/commits/main.atom (Atom feed with namespaces, nested entries, CDATA in <content>).

Test plan

  • pnpm --filter @rozenite/network-activity-plugin test — all tests pass (29 new across the new test files + dispatch / content-type additions).
  • 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 → XML tab:
    • Sample XML — Preview renders the parsed <slideshow> tree (title, date, author attributes visible in amber/green; nested <slide> elements with chevrons). Toggle to Raw — CodeBlock with the XML source. Toggle back — tree.
    • Atom feed — Preview renders the feed with namespaces (xmlns, xmlns:media attributes on <feed>), multiple <entry> blocks, CDATA-wrapped <content> rendered with purple <![CDATA[ … ]]> markers. Copy on an <entry> header (hover) serializes the whole subtree.
  • Collapse-state preservation — collapse a nested element, then collapse its parent, then re-expand the parent; the nested element should STILL be collapsed (proves display: none rather than unmount).
  • JSON Raw view — open any captured JSON response (HTTP Test → any of the user/posts/todos buttons). Preview/Raw toggle is now visible on JSON for the first time. Raw shows pretty-printed 2-space-indented JSON regardless of how the wire response was formatted.
  • Sticky toggle — Raw on Sample XML → click Atom feed → still Raw. Toggle to Preview on Atom → click a JSON response → also Preview.
  • Malformed XML — no built-in demo, but the unit tests cover both Chrome-style and Firefox-style parser errors; the warning fallback renders source + ⚠️ message.

New test surface

  • utils/__tests__/getContentTypeMimeType.test.ts (+4 cases) — isXmlContentType positives (plain, parameterized, RFC 7303 composites including Atom, RSS, SOAP, XHTML, SVG-via-suffix), case-insensitive matching, and negatives (text/html, JSON, application/xmlfoo, null/undefined/empty).
  • components/__tests__/XmlTree.test.tsx (10 tests) — tag/attr/text rendering, CDATA marker wrapping, whitespace text filtering, recursive nesting, display: none collapse mechanism (asserted at DOM level), chevron aria-label toggle, self-closing form, closing-tag rendering, namespace-prefix preservation (media:thumbnail).
  • response-renderers/__tests__/xml.test.tsx (6 tests) — renderer attributes, Preview tree vs Raw source, malformed XML fallback for both Chrome-style (parsererror as documentElement) and Firefox-style (parsererror in the FF namespace), application/xhtml+xml rendering as a tree.
  • response-renderers/__tests__/json.test.tsx (new file, 7 tests) — renderer attributes (this renderer previously had no test file of its own — only dispatcher coverage), Preview tree contains parsed values not source, Raw produces multi-line output with exact 2-space indent at top and 4-space at nested levels and round-trips through JSON.parse, malformed JSON falls back in both Preview and Raw views.
  • response-renderers/__tests__/dispatch.test.ts (+2 it blocks) — XML routing matrix (application/xml, text/xml, atom+xml, rss+xml, soap+xml, xhtml+xml), image/svg+xml-still-wins regression guard, and order invariants svg < xml and xml < text-fallback.
  • react-native/http/__tests__/http-utils.test.ts (+3 cases) — capture path returns the body verbatim for application/xml, text/xml; charset=utf-8, and application/atom+xml blobs.

Followups (deliberately not in this PR)

  • JSON tree default-expand tuning (depth-2-default rather than all-expanded). Cross-tree UX concern; should be applied to both JsonTree and XmlTree together in a separate PR so the trees stay consistent.
  • Search / filter inside trees (cross-cutting feature).
  • Syntax highlighting in the XML / JSON Raw views.
  • Comments / processing instructions in the XML tree — currently skipped (rare in API responses; Raw view still shows them).

Coordination note

A small mechanical mdx conflict is possible with #275 (HTML renderer, currently draft) if that one lands second — both PRs edit the "Response Viewing" bullet list in website/src/docs/official-plugins/network-activity.mdx. Additive bullets only; trivial rebase.

@burczu burczu marked this pull request as ready for review May 18, 2026 08:49
@burczu burczu force-pushed the feat/network-activity-plugin-xml-tree-json-raw branch from 4eef05a to aa9cc98 Compare May 19, 2026 07:45
burczu added 5 commits May 19, 2026 10:06
`xmlRenderer` matches XML content-types via `isXmlContentType` —
`application/xml`, `text/xml`, and any `*/foo+xml` per RFC 7303,
covering Atom, RSS, SOAP, XHTML, XSLT, and any future composite
type without an enumeration to maintain. The matcher is case-
insensitive and parameter-tolerant via the existing
`normalizeContentType` helper. `image/svg+xml` also matches the
suffix rule but `svgRenderer`'s earlier registry slot keeps it
claiming SVG first.

Preview renders the parsed body as a collapsible tree via a new
`XmlTree` component. The tree is a recursive `XmlNode` switching
on `nodeType`: elements render as `<tag attr="value">` with a
chevron toggle and a copy button that serializes the subtree via
`XMLSerializer`; non-whitespace text nodes render in-place;
whitespace-only text nodes (DOM-pretty-print noise between
sibling elements) are filtered; CDATA sections render with
explicit `<![CDATA[ … ]]>` markers and a copy button on their
value. Comments, processing instructions, and DOCTYPE are
deliberately not rendered in the tree — they're rare in API
responses and the Raw view shows them verbatim.

Collapse uses `style={{ display: 'none' }}` rather than
conditional unmounting so a nested node's per-element `useState`
survives a parent collapse/expand cycle — collapse a sub-element,
collapse its parent, re-expand the parent, the sub-element is
still collapsed. Cost is hidden DOM, acceptable for typical XML.

Raw renders the body verbatim via the existing `CodeBlock` —
same shape as text-fallback and the SVG Raw view.

Parse-error detection covers cross-engine variance: Chrome / Safari
return a `Document` whose `documentElement` IS the `<parsererror>`
element; Firefox nests it under the
`http://www.mozilla.org/newlayout/xml/parsererror.xml` namespace.
`hasParseError` checks both paths in three lines. On parse failure
the renderer falls back to source + a "Failed to parse as XML"
warning — same fallback shape as `jsonRenderer`'s `catch` branch.

`views` stays static `['preview', 'raw']`. On malformed input the
fallback ignores the active view and renders source-with-warning;
the toggle stays visible but no-ops. Making `views` dynamic would
require a `ResponseRenderer` type change; this trade matches the
existing `jsonRenderer` behavior.

The tree's color palette uses Tailwind utility classes inline (no
`react-json-tree` wrapper): blue for tag names matching the
existing Content-Type label color, amber for attribute names,
green for attribute values, purple for CDATA wrapper markers,
muted gray for punctuation. Element-header copy reuses the
existing `JsonTreeCopyableItem` so the hover-revealed icon
behavior matches the JSON tree.

Tests:
- `XmlTree.test.tsx` covers tag-name rendering, attribute
  inline-ness, text and CDATA rendering, whitespace filtering,
  recursive nesting, `display: none` collapse mechanism, chevron
  aria-label toggling, self-closing form, and namespace-prefix
  preservation (`media:thumbnail` survives the walk).
- `xml.test.tsx` covers renderer attributes, Preview vs Raw
  branching, malformed XML fallback for both the Chrome / Safari
  shape (parsererror as `documentElement`) and a Firefox-like
  shape (parsererror in the FF namespace), and `application/xhtml+xml`
  rendering as a tree.
- `getContentTypeMimeType.test.ts` adds `isXmlContentType`
  positive cases (plain, charset-parameterized, atom / rss / soap
  / xhtml / svg via +xml) and negative cases (text/html, JSON,
  `application/xmlfoo`, null / undefined / empty).
- `dispatch.test.ts` flips `application/xml` from `text-fallback`
  to `xml`, adds `text/xml`, atom, rss, soap, xhtml routing
  assertions, an SVG-still-wins regression guard, and order
  invariants `svg < xml` and `xml < text-fallback`.
`jsonRenderer` grows a Raw view alongside the existing tree
Preview. The Preview/Raw toggle is now visible on every JSON
response — it was previously hidden because `views` declared
only `['preview']`.

Raw shows the body pretty-printed via `JSON.stringify(parsed,
null, 2)`, not the literal wire string. APIs commonly ship
minified JSON, and a Raw view of minified JSON is unreadable;
re-serializing with 2-space indent matches the convention in
Chrome DevTools' Response panel. The small byte-fidelity loss
versus the source is the correct trade for a debugging surface
— anyone needing the exact bytes can copy from the override
editor or hit the response directly.

Implementation parses the body once at the top of `render` into
a `let parsed`, then branches on `view`. Previously the body
was parsed inside the Preview branch only; with two branches
needing the same parsed value, extracting it is both shorter
and avoids parsing twice on toggle.

Malformed-JSON behavior is unchanged: when `JSON.parse` throws,
the renderer falls back to source plus a warning regardless of
the active view, just as before. `views` stays static
`['preview', 'raw']` even on malformed input — the toggle is
visible but no-ops because the fallback path returns the
warning instead of branching. Same trade-off as `xmlRenderer`.

Adds `__tests__/json.test.tsx` (this renderer had no test file
before — it was covered only by the dispatcher routing test).
Coverage: renderer attributes, Preview tree contains parsed
values not the source, Raw produces multi-line pretty-printed
output with exact 2-space indent at nested levels and
round-trips through `JSON.parse` to the original value,
malformed JSON falls back in both Preview and Raw views.
The XHR capture path in getResponseBody only routed text-shaped
blobs to the text branch when the content-type was text/*, JSON
(via isJsonContentType), or image/svg+xml. Anything
application/xml or a +xml composite type (application/atom+xml,
application/rss+xml, application/soap+xml, ...) fell through to
the image-binary check, didn't match there either, and ended up
returning null. The captured request reached the panel with no
body, surfacing as "No response body available for this request"
in the response tab — even though the body was right there on
the wire.

Add isXmlContentType to the text branch alongside isJsonContentType.
The predicate uses the RFC 7303 structural rule (mimeType ===
'application/xml' || mimeType === 'text/xml' || mimeType.endsWith(
'+xml')), which sweeps in every XML composite type with no
enumeration to maintain.

The previously-explicit image/svg+xml startsWith check is removed
because isXmlContentType now covers it (svg's content-type ends
with +xml). The comment is updated so the reader sees that XML
composite types and SVG all flow through this branch for the same
reason — they're strings, not bytes, and the renderer parses them
client-side.

Tests in http-utils.test.ts cover the three new cases: plain
application/xml, text/xml with a charset parameter, and
application/atom+xml as a representative +xml composite type.
A new "XML" main-tab on NetworkTestScreen, modeled after the
existing Images tab. Two buttons each fetch a known XML endpoint
so the captured response exercises a different surface of the
renderer:

- "Sample XML" — https://httpbin.org/xml (200 OK, ~522 B
  application/xml slideshow). Exercises basic tree rendering,
  attributes, and nested elements.
- "Atom feed" — https://github.com/callstackincubator/rozenite/
  commits/main.atom (200 OK, application/atom+xml; charset=utf-8,
  ~52 KB). Exercises namespaces (xmlns, xmlns:media), multiple
  nested entry blocks, and CDATA-wrapped content fields — a
  representative real-world feed.
Minor bump for @rozenite/network-activity-plugin. The Response
Viewing section in network-activity.mdx gains a new XML bullet
enumerating the matched content-types (application/xml, text/xml,
and RFC 7303 +xml composites including xhtml+xml) and describing
the tree / Raw / malformed-fallback shape. The JSON bullet is
extended to mention the new Raw view (pretty-printed with
2-space indent). The "Text and other source formats" bullet is
tightened — the broad text/* glob is replaced with concrete
examples and application/xml is dropped from the list because
the xml renderer claims it now.
@burczu burczu force-pushed the feat/network-activity-plugin-xml-tree-json-raw branch from aa9cc98 to 9b76285 Compare May 19, 2026 08:10
…mo copy

The HTML response test's narrative still mentioned the status banner
that was removed before callstackincubator#275 merged. The renderer hasn't shown a
banner on error responses for a while now — the demo description
referenced a feature that no longer exists.

Updated the card body and the 404 hint to reflect what the demo
actually exercises: the renderer handling an error response in
addition to 2xx ones.
@V3RON V3RON merged commit e82fa22 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