Skip to main content

Html

A static HTML + CSS document, rendered into a react-x11 window: selectable text, real widgets for form controls, and seams for everything that would otherwise reach the outside world.

import { Html } from '@react-x11/components/html';

<box style={{ overflow: 'scroll', flexGrow: 1 }}>
<Html
source={html}
partial={false}
onLink={(href) => openInBrowser(href)}
onResource={(r) => (r.kind === 'image' ? readImage(r.url) : null)}
/>
</box>;

It registers one host element, <htmlview>, which owns the whole pipeline — parse, cascade, box tree, layout, paint — and draws the document itself. The form controls are the exception: those are core widgets mounted beside it.

Nothing here fetches or executes anything. See The seams.

Props

PropTypeWhat it does
sourcestringThe HTML. Required.
partialbooleanWhether more source may still arrive. Default true. While true, a source that extends the last one is written to the open parser as a delta — see Streaming.
selectablebooleanMouse selection, Ctrl+A / Ctrl+C, PRIMARY. Default true.
stylesheetstring | string[]Author stylesheets applied after the document's own, so a host can restyle a document it does not control.
onResource(r: ResourceRequest) => ResourceResult | nullAn <img>, a <link rel=stylesheet> or an @import wants loading. May return a promise. Absent, nothing loads.
onScript(s: ScriptRequest) => voidA <script> was found, handed over unparsed and unevaluated.
onLink(href, ev) => voidA link was activated. Absent, clicks do nothing — this never navigates by itself.
onDocument(document: Document) => voidThe parsed DOM, each time it is re-parsed.
onControlChange(element, value) => voidA form control changed. The element is the one in the DOM.
fontSizenumberBase text size. Default: theme fontSize, or 14.
fontFamilystringDefault 'sans-serif'.
monoFamilystringCode font. Default 'monospace' — there is no theme token for it.
selectionColorstringSelection band fill. Default: theme accent at 35% opacity.
styleStyle | Style[]The root <box>'s style.

The handle

useHtmlHandle() returns a ref to pass to <Html> plus the document:

const handle = useHtmlHandle();

<Html source={html} ref={handle.ref} />;

// later
const links =
handle.document && DomUtils.getElementsByTagName('a', handle.document);
links[0].attribs.href = '#changed';
handle.refresh();
MemberWhat it is
documentThe live DOM — domhandler's tree, which domutils speaks natively.
refresh()The DOM changed: restyle, re-lay-out, repaint.
elementAt(x, y)The element under a point, in the window's coordinates.
titleThe document's <title>, if it had one.

refresh() is explicit rather than observed, and that is a decision — see Manipulating the DOM.

The seams

onResource is the only way anything loads. This component has no network client and no filesystem access; it does not resolve URLs against a base, because it has no base and the host does. The request names the URL as the document wrote it, what kind of thing it is, and the element that asked:

onResource={async (request) => {
if (!allowed(request.url)) return null;
if (request.kind === 'stylesheet') {
return { kind: 'stylesheet', text: await readText(request.url) };
}
return { kind: 'image', bytes: await readBytes(request.url) };
}}

Return { kind: 'image', image, width, height } instead to hand over an image the host decoded itself. A declined or absent resource is an ordinary state: images draw as a frame at their attribute size, and linked stylesheets are skipped.

onScript never runs anything. It is handed the type, the src, the element and its text verbatim, and nothing in this package reads any of it — there is no parser, no sandbox and no partial evaluation, because a renderer that half-runs a script is one nobody can reason about. An application that wants scripting brings its own engine, and drives the result through the DOM handle.

Inline event attributes (onclick="…") are likewise left in the DOM as attributes and never invoked.

What renders

The subset is aimed at documents an application is handed — mail, release notes, help pages, exported reports, generated summaries — rather than at the open web.

Layout: block flow with margin collapsing, inline formatting with bidi and full shaping, inline-block, floats and clear, lists with their markers, tables (the auto algorithm and table-layout: fixed, with colspan and rowspan), position: relative | absolute | fixed, and display: flex.

Boxes: width/height with min-/max-, margin, padding, border (width, style, colour, radius), box-sizing, overflow, opacity, visibility, z-index.

Text: font and its longhands, line-height, text-align, text-indent, text-transform, letter-spacing, white-space (including pre and pre-wrap), direction, vertical-align, text-decoration in all five rule styles.

Selectors: everything css-select supports — combinators, attribute operators, :nth-child(an+b), :not() — plus :hover, which is answered from this renderer's own pointer state. @media width queries are evaluated; @import goes through the resource seam.

Not implemented: CSS grid (degrades to block stacking), transforms, animations and transitions, multi-column, shadows, gradients, and position: sticky (treated as relative). border-collapse: collapse is drawn as the separate model with zero spacing.

The decisions

It draws the document; it does not compose one. Every other document surface in this package — <Markdown>, <Code>, <TerminalOutput> — is a tree of <box> and <richtext> elements. This one is a single element that lays out and paints the whole document, for two reasons. A document of any size is thousands of elements, and reconciling them through React and laying them out through yoga per streamed chunk is the cost this exists to avoid. More importantly, CSS layout is not the host's layout: react-x11 lays out with yoga, which is flexbox, and block flow with margin collapsing, floats, an inline formatting context and table column sizing are not expressible in it. Composing would mean approximating the layout model.

What it reuses from <richtext> is everything that was not about the element: the TextRun vocabulary ntk's text layout takes, the per-run decoration painter, and the bidi-correct selection bands. See richtext.

Form controls are real widgets, not pictures of them. A <select> in a document drops the same menu as a <Select> in the window around it, because it is one; the same goes for <button>, checkboxes, radios and text fields. They mount as absolutely positioned siblings of the element, at the rectangles layout reserved for them — the escape hatch <Flow> opened for a node whose body is a form. A drawn control would take no focus, say nothing to a screen reader, and have to reimplement every keyboard convention the platform already has.

The application scrolls it, and height does not frighten it. The element sizes to its content; put it in a <box overflow="scroll">, the same shape <Markdown> uses. That keeps the mounted controls scrolling with the document for free, and core's scroller already blits. Tall is fine — a multi-hundred-thousand-pixel document renders correctly at any scroll position, because everything the paint path submits is bounded by the viewport: fills are clamped to the damage (X carries them as 16-bit numbers, so an unclamped one is a protocol error, not a clipped rectangle), long hard-broken text is laid out in chunks so no single glyph batch spans more than the Int16 envelope, and wide child lists and line arrays are searched, not scanned. What phase 2 adds is cheaper layout for such documents, not the ability to show them.

A fragment gets an implied body. <p>hi</p> has no <body> element, so the root box takes the style a <body> would have had: the user-agent margin, the font, and any author body { … } rule. Without it the same markup renders differently inside and outside <html><body>, which reads as a bug rather than as a missing element.

The user-agent stylesheet is themed. color, the link colour and every rule and border in it come from the react-x11 palette, so an unstyled document dropped into a dark application arrives dark rather than as a white rectangle. An author stylesheet still overrides all of it.

:hover costs nothing unless the document uses it. A pointer move only restyles when some selector in the document actually tests :hover, which is why the user-agent sheet deliberately has no a:hover rule.

Nesting is capped at 512, the way Blink's parser caps it. Everything after the box builder recurses on tree depth, so a degenerately nested document (fuzzer output, a runaway template) would otherwise be a stack overflow five phases from its cause. Content past the cap is dropped; documents this deep are not documents.

Streaming

partial works the way <Markdown partial> does, and rather better: the parser is a real streaming one, so a source that extends the last one is written as a delta. The nodes already parsed keep their object identity, which means their computed styles, their boxes and their laid-out lines survive; only the tail is new work. A source that is not an extension resets the parser, because a mid-document edit can change the tree arbitrarily.

Set partial={false} when the stream ends.

Manipulating the DOM

The document is domhandler's tree — plain, mutable objects that domutils operates on directly. This package re-exports the four splice operations that are easy to get wrong (appendChild, removeNode, replaceNode, and createHtmlElement/createText/parseHtmlFragment to build nodes), because a domhandler node carries parent, prev, next and children and a splice has to keep all four straight.

After mutating, call handle.refresh(). That is explicit on purpose: observing a plain object graph would cost a proxy per node and tax the static render this is built to make fast, in order to speed up the path it is not. Mutation is supported; it is not where the performance budget went.

Performance

The pipeline is staged so that the two things that happen most often cost the least:

What changedWhat re-runs
sourceparse (incrementally), style, box, layout, paint
a stylesheetstyle, box, layout, paint
the DOMbox, layout, paint
the widthlayout, paint
a @media bandstyle, box, layout, paint
an expose / scrollpaint, culled to the damage rect

Nothing in a computed style depends on the width — percentages and auto survive unresolved into layout — which is what makes a resize skip the cascade. Every box carries the ink bounds of everything it and its descendants draw, and past a size threshold a child list carries a sorted viewport index, so an expose of a 40-pixel strip in a very tall document finds the boxes that overlap it by binary search rather than by scanning the document. The selection walks prune the same way — by each subtree's document range, and by ink-bounds distance for hit testing — so a drag costs the paragraphs it crosses. And a paragraph is one glyph batch: ntk's text layout draws all of its lines in a single composite.

Types

Document, Element, AnyNode, ChildNode and ParentNode are domhandler's, re-exported. Through the barrel they are qualified — HtmlDocument, HtmlElement — because an application already has several things called Element.

Example

npm run examples:html

Needs a real $DISPLAY. It renders a document with headings, floats, tables, a flex row and a working form, and drives both seams for real: a resource loader that reads from a whitelist directory, and a script hook that reports what it was handed without running it.