LearnNewsExamplesServices
Frontmatter
id13018
titleMarkdown VDOM component: streaming-first parsing, no innerHTML
stateClosed
labels
enhancementaiarchitecturecore
assigneesneo-fable-clio
createdAtJun 12, 2026, 11:02 PM
updatedAtJun 13, 2026, 1:47 AM
githubUrlhttps://github.com/neomjs/neo/issues/13018
authorneo-fable-clio
commentsCount0
parentIssue13012
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtJun 13, 2026, 1:47 AM

Markdown VDOM component: streaming-first parsing, no innerHTML

Closed v13.1.0/archive-v13-1-0-chunk-1 enhancementaiarchitecturecore
neo-fable-clio
neo-fable-clio commented on Jun 12, 2026, 11:02 PM

Context

The markdown-VDOM lane from Epic #13012's plan-of-record (lane claimed via A2A 2026-06-12T20:57Z after the targeted lead-role request; operator named this peer for deep involvement). Resurrects the #8920 premise (Neo.component.markdown.VDom — VDOM-native parsing; closed stale/not_planned 2026-05-16, never implemented) as a fresh Fat Ticket per the prevent-reopen discipline — the harness transcript surface makes the premise load-bearing: agent/LLM responses are streaming markdown, and the renderer is on the hot path of every conversation turn.

First consumer: the harness transcript/activity surfaces (#13012 PoC→MVP staging names "markdown component v1 landing for activity/log surfaces"). Framework-level primitive — serves portal/docs later, but transcript-first scoping governs v1.

Release classification: post-release (harness product line; no v13.x blocking work).

The Problem

The current src/component/Markdown.mjs is structurally wrong for streaming, in three independent ways:

  1. Full re-parse + whole-blob replacement per value change (afterSetValuedestroyComponents() + render()marked.parse(content)me.set({html})): every appended chunk re-parses the entire document and replaces the entire rendered tree. For an LLM response streaming N chunks this is O(N²) parse work and full DOM trashing per chunk — #8920's "avoids full DOM trashing on every character append" benefit, inverted.
  2. HTML-string rendering (the html config = innerHTML semantics): the output never exists as a vdom tree, so the delta engine cannot do fine-grained patching — and the renderer is an XSS surface. The existing beforeSetValue guard only rejects accidental full HTML documents (404 pages), not hostile fragments. Acceptable for repo-authored docs; wrong default for transcripts rendering untrusted agent/LLM output (the hostile-content class the swarm has already been hit with, and the trust-tier boundary vega's containment work draws).
  3. Identity instability: even if the blob were diffed, nothing assigns stable identities to rendered blocks — the differ would see wholesale change. Stable per-block vdom ids are what make streaming cheap: the differ accumulates in-place changes into updateNode and sequential appends ride DeltaUpdates' insertNode-batching optimization (sequential parentId+index appends batch natively).

The Architectural Reality

  • VDOM primitives: vnode trees are plain {tag/nodeName, id, cn, vtype} structures (src/vdom/VNode.mjs: vtype: 'vnode' | 'text'); a parser emitting these is renderer-agnostic by construction.
  • The differ rewards id stability: unchanged subtrees with identical ids produce zero deltas; in-place changes accumulate into action-less updateNode; src/main/DeltaUpdates.mjs#update() batches sequential same-parent inserts. A streaming append with a stable settled-prefix therefore costs exactly one insert-only tail batch.
  • The delta-contract instruments exist NOW (the #12986 arc): test/playwright/util/DeltaCapture.mjs (merged) gives epoch-windowed batch capture + kernel-vocabulary op counts — the executable form of this ticket's streaming ACs. The coherence registry (PR #13016, in review) flags two-nodes-one-id / retired-target incoherence live: a streaming incremental parser with id-reuse logic is exactly the producer class that motivated it, so this lane develops with useDeltaCoherenceRegistry: true as its dev harness and dogfoods the contract layer.
  • The existing pipeline is load-bearing elsewhere: Markdown.mjs's multi-pass embedded-block machinery (live-preview, mermaid, neo-component JSON blocks, HighlightJs <pre> injection) carries the portal/docs surfaces. It must keep working untouched — the migration boundary below is deliberate.
  • Realm-pure parser = SSG-consumable: a parser with no Neo globals and no DOM access (the src/vdom/util/DeltaGrammar.mjs plain-module discipline) is importable from the App Worker, Node unit tests, AND the Node-side SSR/SSG pipeline — markdown→vdom prerendering becomes available to the middleware for free.

The Fix

New component family at src/component/markdown/ (structural pre-flight, Stage-1 fast-path: src/component/ subfolder precedent exists — mwc/, wrapper/; #8920's own namespace was Neo.component.markdown.*; implementer re-runs the 30-second check at branch time):

  1. src/component/markdown/Parser.mjs — plain module (no class lifecycle, no Neo globals; the DeltaGrammar.mjs discipline). Line-based block segmentation (paragraphs, headings, fenced code, lists, blockquotes, tables, thematic breaks) → per-block vdom subtrees; per-block inline pass (strong/em/inline-code/links) → cn arrays with vtype: 'text' leaves. No HTML string at any stage.
  2. Stable block identity: component-scoped block ids (<componentId>__md-<blockSeq>) assigned at block birth and memoized against the block's source slice — an unchanged settled block re-emits its cached subtree (identical ids → differ no-ops). Block sequence numbers never recycle within one value lifetime (no two-nodes-one-id by construction).
  3. Streaming surface: reactive value_ with append detection (newValue.startsWith(oldValue) → incremental path: only the open tail block — possibly an unclosed fence/paragraph — re-parses; anything else → full reset with fresh block sequence). The component stays a drop-in value-driven component; no bespoke streaming API in v1 (the append-detection heuristic IS the API — falsifiable in the spec).
  4. Security policy (transcript-grade by default):
    • Raw HTML blocks/inline HTML render as escaped text (visible, inert) — no sanitized-subset pass in v1 (sanitizers are a follow-up leaf with its own threat-model review).
    • Link protocol allowlist: http(s), mailto, # fragments; javascript:/data:/unknown schemes render as plain text.
    • Image sources: same allowlist; no inline data: images in v1.
  5. Code blocks v1 = plain <pre><code> with a language class for CSS — explicitly NO HighlightJs in v1 (it returns HTML strings; piping it in would reintroduce the innerHTML surface this ticket exists to remove — see Avoided Traps). Highlighting is a follow-up leaf (vdom-emitting highlighter or CSS-only).

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
src/component/markdown/Parser.mjs (NEW) #8920 premise + Epic #13012 lane map Plain-module markdown→vdom: block segmentation, stable memoized ids, inline pass, security policy n/a (new surface) Module JSDoc = parser spec incl. id-stability contract VNode.mjs vtype shapes verified; plain-module precedent src/vdom/util/DeltaGrammar.mjs
src/component/markdown/Component.mjs (NEW) Epic #13012 transcript staging value_-reactive component consuming the parser; append-detection incremental path n/a (new surface) Class JSDoc + streaming-model note Subfolder precedent src/component/{mwc,wrapper}/ verified
src/component/Markdown.mjs (EXISTING — untouched) Portal/docs surfaces Zero changes in this ticket n/a n/a Migration boundary, Out of Scope

Decision Record impact: none (component-layer primitive; epic-graduated direction).

Acceptance Criteria

  • Streaming append: each appended chunk produces an insert-only tail batch (no structural deltas targeting settled-prefix ids) — asserted via DeltaCapture epoch windows + opsIn per chunk.
  • Settled-prefix stability: re-parsing with an unchanged prefix re-emits identical ids for settled blocks (differ produces zero deltas for them) — asserted at parser level (id equality) and capture level (zero structural ops).
  • In-place tail mutation (the open paragraph growing) is updateNode-shaped, never remove+reinsert of the tail block.
  • Non-append value change falls back to full reset correctly (fresh block sequence; no id reuse across the reset — coherence-registry-clean).
  • Observe-mode coherence run: a multi-chunk streaming session under useDeltaCoherenceRegistry: true produces zero findings (documented in the PR).
  • Injection ACs: raw HTML block → escaped text; javascript: link → plain text; data: image → plain text; fenced code containing </pre><script> → inert text inside the code block.
  • Unclosed-fence streaming: a fence that opens in chunk N and closes in chunk N+k renders progressively as code and settles without churn on close.
  • Parser is realm-pure: spec imports it directly without the Neo setup helper (the DeltaGrammar.spec.mjs precedent pins this).
  • Existing Markdown.mjs surfaces (portal/docs) byte-for-byte unaffected.

Out of Scope

  • Migrating src/component/Markdown.mjs or any portal/docs surface (later leaf, possibly never — the docs pipeline's embedded-block features are a different product).
  • Syntax highlighting (follow-up leaf; the hljs-html-string trap is documented below).
  • Sanitized raw-HTML subset (follow-up leaf with its own threat model).
  • GFM completeness beyond the transcript set (task lists, footnotes, autolinks etc. — organic follow-ups).
  • The transcript APP surface itself (a #13015/#13012 leaf consuming this component).

Avoided Traps

  • HighlightJs piping (innerHTML reintroduction): HighlightJs.highlight() returns HTML strings; embedding them would silently reopen the XSS surface and break vdom-purity. v1 ships plain code blocks on purpose.
  • Full-document re-parse per chunk — the O(N²) streaming trap the current component embodies.
  • Id-per-render (fresh ids each parse): destroys the differ's no-op path and false-positives any coherence instrumentation; the memoized stable-id scheme is the load-bearing design element.
  • Block-id recycling after reset: reusing ids across a value reset births two-nodes-one-id across batches — the exact #12986 defect family; fresh sequence per reset.
  • marked dependency for the new path: string-coupled output contradicts the vdom-native premise; the lightweight block parser is the point (transcript markdown is a bounded dialect).
  • Absorbing the docs pipeline in v1: 652 lines of load-bearing portal machinery would block the harness-critical core behind feature parity.

Related

Parent: Epic #13012 (cross-pillar framework leaf per its decomposition shape). Premise: #8920 (closed stale; this is the fresh-ticket resurrection). Instruments: #12986 / #12991 / PR #13014 (DeltaCapture, merged-pending), #13011 / PR #13016 (coherence registry, in review). Security adjacency: #10291 / #12995 (trust tiers), the hostile-content quarantine class. Transcript consumer staging: #13015.

Live latest-open sweep: checked latest 20 open issues at 2026-06-12T21:01Z; no equivalent found. A2A in-flight sweep: clean (only my own [lane-claim] 20:57Z on this scope).

Origin Session ID: e7e5274c-6486-45ec-9c5d-0933ca3f123a

Retrieval Hint: "markdown vdom streaming parser stable block ids transcript no innerHTML append-only tail batch"