Skip to content

✨ DOM-backed terminal rendering - #37

Merged
cowboyd merged 1 commit into
mainfrom
dom
Oct 19, 2026
Merged

cowboyd merged 1 commit into
mainfrom
dom

Conversation

@cowboyd

@cowboyd cowboyd commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Reboot the concept of a host environment from the very outside in by adding the minimal machinery to render using a very dom shaped api. We start with the minimum:

document.body.appendChild(document.createTextNode("Hello World!");

And ensure that it renders to the screen using https://termless.dev to write the tests.

Layout

There is no render function proper, only that mutations to the dom are batched up into a single render frame, and then the entire tree is walked synchronously (caching can come in later) and every element is converted into operations which are then written out to TTY. My thinking is that we can use stylesheets to compute the actual properties of the box.

Type of change

  • Bug fix
  • Feature
  • Refactor (no behavior change)
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • All tests pass (pnpm test)
  • Files are formatted (pnpm format)
  • I have added/updated tests for my changes (if applicable)
  • I have added a changeset

AI-generated code disclosure

  • This PR includes AI-generated code

@socket-security

socket-security Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​termless/​test@​0.10.2841007391100
Added@​termless/​core@​0.10.28610010093100
Addednode-pty@​1.2.0-beta.159210010091100

View full report

@cowboyd
cowboyd force-pushed the dom branch 2 times, most recently from 254e000 to d1db425 Compare October 15, 2026 03:36
@pkg-pr-new

pkg-pr-new Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/bombshell-dev/ui/@clack/ui@37

commit: 6bdc13f

@cowboyd
cowboyd force-pushed the dom branch 5 times, most recently from 0cbbd95 to ab3c6cf Compare October 15, 2026 04:08
@cowboyd
cowboyd requested review from jbolda and natemoo-re and removed request for jbolda and natemoo-re October 15, 2026 14:51
@43081j

43081j commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

how do you think we will have multiple "documents"? (that's the only reason DOM has an owning document)

is the body just so a "document" has the same shape as a DOM Document? so it doesn't give us anything other than same naming?

have you made the "implementation" a parameter purely for testing? it looks like its actually the base class but isn't just so we can pass a mock base in during tests.

overall i'm not sure we should copy the exact shape of the DOM because some of it doesn't give us anything. i'm yet again disagreeing with yet another attempt at the same thing though, i'm aware. so do as you wish

@jbolda

jbolda commented Oct 15, 2026

Copy link
Copy Markdown
Member

Did we not all agree that there should be at least some vague shape of a DOM so that our bindings behave? That components have the vague shape of working with vanillajs so we can compose them in a similar manner to the browser and also allow an opt-in browser compat mode?

The only parts that are the same from previous attempts are the parts I had thought we agreed on. @43081j, what is your understanding of our agreed shape here?

@43081j

43081j commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

some vague shape of a DOM

how vague? here lies the difference in vision i suppose.

i'm just going to brain dump what i would do and you can agree or disagree between yourselves, whatever you and the rest of the team feel.


Mostly pseudo-code ahead.

const box = new BoxElement();
const input = new InputElement();
box.appendChild(input);

// then for strings, either a special-case or a text element:
box.appendChild("foo"); // special case it
box.appendChild(new TextElement("foo")); // or make it an element

// children exist
box.children; // [InputElement, TextElement]

// event dispatch
input.dispatchEvent(ev); // built-in EventTarget#dispatchEvent, doesn't bubble
input.dispatchEvent(bubblingEvent); // does a basic traversal upwards via `.parent` and dispatches on each

// event listeners
input.addEventListener(name, fn);
box.addEventListener(name, fn); // would work for the bubbling case, `ev.currentTarget` is the input

internally there's no concept of a "node" or a "component", there's one type: an element (i think i'd call it a component in fact but either works):

// maybe inherit some base Element that gives us lifecycle stuff. not sure if necessary yet
class InputElement extends Element {
  constructor() {
    // this is a global event, some root term/container logic emits keydowns against the currently focused
    // element.
    this.addEventListener('keydown', this.#onKeyDown);
  }

  #onKeyDown(ev) {
    // either also implement a global `input` event that accumulates strings, or do it ourselves in here
    this.value = newValueUserTypedIn;

    // either explicitly trigger a re-render
    this.requestUpdate();
    // or do it by "magic" by each public property being a setter that calls req update internally
  }

  render() {
    // yield some raw operations
    // now yield your children:
    yield* renderChildren(this.children); // still yields to a bunch of raw ops recursively
  }
}

somewhere you then have this root thing that takes an element and knows how to render it:

render(input); // literally does ONE render of this input, NOT a render loop

some other thing composes this to create a render lifecycle, like maybe the base class i mentioned:

class Element {
  appendChild(child) {
    this.children.push(child);
    this.requestUpdate();
  }

  requestUpdate() {
    // optimise it some day by debouncing/queueing renders so multiple prop changes at once only trigger one render
    if (propsChanged || childrenChanged) {
      render(this.render());
    }
  }
}

note that, at this point, this'd mean each child tries to render itself. that works in DOM since we can do targeted updates, but not in terminals really. so i suppose we'd actually need render(this.host, this.render()) such that every element in a tree has a ref to the root host (the thing which should render as a whole).

also note there's no "render loop" here at the minute. so we'd still need some way of "stopping". i.e. things can just keep calling render against the host and thats the equivalent of a loop (no actual loop needed), but something eventually needs to tear the host down (stop rendering). we have to decide what owns that...

so yeah, in terms of "DOM like", we're just adopting the naming of the bits we already need, we're not trying to imitate the entire DOM. in these examples:

  • child manipulation (appendChild, removeChild, insertBefore, children)
  • events (dispatchEvent, addEventListener, removeEventListener)

@jbolda

jbolda commented Oct 16, 2026

Copy link
Copy Markdown
Member

internally there's no concept of a "node" or a "component", there's one type: an element (i think i'd call it a component in fact but either works):

How do you account for the text node equivalent in the browser? We also need some stable identity for the frontend frameworks to reconcile against; how do you do that with the concept of a "node"?

also note there's no "render loop" here at the minute. so we'd still need some way of "stopping". i.e. things can just keep calling render against the host and thats the equivalent of a loop (no actual loop needed), but something eventually needs to tear the host down (stop rendering). we have to decide what owns that...

Can you define what your mental model is for a "render loop" and what you are trying to avoid with it? From the sounds of it, you think we have some thing that owns the render api. I presume it would take repeated requests to render but counting as one to handle: // optimise it some day by debouncing/queueing renders so multiple prop changes at once only trigger one render.

so yeah, in terms of "DOM like", we're just adopting the naming of the bits we already need, we're not trying to imitate the entire DOM. in these examples:

  • child manipulation (appendChild, removeChild, insertBefore, children)
  • events (dispatchEvent, addEventListener, removeEventListener)

This PR doesn't wire up any events yet so we can consider that in the future. These child manipulation items we all are aligned on being a requirement.

@cowboyd
cowboyd force-pushed the dom branch 2 times, most recently from 90890a2 to 07d4868 Compare October 16, 2026 20:18
@cowboyd
cowboyd added this pull request to stack #39 October 16, 2026 20:44
@cowboyd

cowboyd commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor Author

how do you think we will have multiple "documents"? (that's the only reason DOM has an owning document)

The only reason that Nodes have a reference to the ownerDocument is to get a handle on the implementation.

is the body just so a "document" has the same shape as a DOM Document? so it doesn't give us anything other than same naming?

The body exists for familiarity and nothing more. It is just an alias for documentElement. We can remove it.

have you made the "implementation" a parameter purely for testing? it looks like its actually the base class but isn't just so we can pass a mock base in during

As it exists, the implementation instance is mostly where book keeping stuff stays. Right now its responsibilities are to hold:

  1. id map assigns stable ids to retained nodes so that TTY (which is immediate mode) will recognize the same node through subsequent passes.
  2. the render api by which elements notify that a layout is required

It could definitely be organized differently.

overall i'm not sure we should copy the exact shape of the DOM because some of it doesn't give us anything. i'm yet again disagreeing with yet another attempt at the same thing though, i'm aware. so do as you wish

I must admit that this is not the shape that I had imagined at first either to be quite honest, but based on our discussion with the group on Wednesday, I was under the impression that we were shooting for a "less is more" approach and that since the dom is the lingua franca of all things JavaScript UI, we would just do as the DOM does as a default. Much like an assembly language: Nobody uses it, but everybody has a higher level construct that targets it.

Same thing with EventTarget. The more compatible we are with EventTarget, Element, Style, and friends, the more we can avail ourselves of wider swaths of the eco-system. If this wasn't the direction we'd agreed upon (I know you had to drop off early), then happy to revisit.

Having said that, I am starting to think there might be a lot of potential with this approach, and that it is worth exploring. I implemented a rudimentary style system and it feels already like the styling is going to be very flexible. It's not much code, but it's already at the point where not much more is required to land React, Vue, Solid and friends on top of it.

@natemoo-re natemoo-re left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall i'm not sure we should copy the exact shape of the DOM because some of it doesn't give us anything. i'm yet again disagreeing with yet another attempt at the same thing though, i'm aware. so do as you wish

I must admit that this is not the shape that I had imagined at first either to be quite honest, but based on our discussion with the group on Wednesday, I was under the impression that we were shooting for a "less is more" approach and that since the dom is the lingua franca of all things JavaScript UI, we would just do as the DOM does as a default. Much like an assembly language: Nobody uses it, but everybody has a higher level construct that targets it.

I'm happy to own this as a pretty strong directional push from me. My opinion is that the DOM is notably wonky, but everything already targets it. There's a world where we implement only a modern subset of DOM concepts, similar to https://github.com/WebReflection/linkedom, or even make it possible for framework integrations to add their own Node behavior when needed for compatibility. If we keep to the contract we'll have many fewer surprises than trying to backport the DOM environment frameworks expect from our own conceptual model.

Excited to get this rolling! I know it won't be perfect, but let's figure out how to optimize once we have stuff running and can feel the rough edges.

Comment thread packages/ui/src/with-resolvers.ts Resolved
Comment thread packages/ui/src/dom.ts Resolved
@cowboyd

cowboyd commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor Author

@natemoo-re Sounds good.

I'm also not sold on Document.implementation either. The fact that literally everybody that looked at it was like 🤨 is probably a good indicator that it needs to disappear. It's basically the most rudimentary dependency injection mechanism imaginable, but it gets you a long way before you need much else. But the name is bad, and maybe we can just put that right on Document and get rid of it entirely.

@43081j On the brain dump of pseudo specifically, I actually don't think we're far off directionally from what you sketched out.

  • No components, just dom nodes (the concept of Node from the other PR stack is out).
  • Event, EventListener, EventTarget (not implemented here, but that's the public API implied by the "default to the DOM" priniciple)

Differences:

  • separate api for text nodes: Annoying, but at the same time worse is better because almost every framework expects this and targets that distinction.
  • render frames: requested and coalesced by a render api stored on the "implementation" object

Not exactly, but very close.

@cowboyd
cowboyd force-pushed the dom branch 3 times, most recently from 0bb0996 to ec40edd Compare October 17, 2026 05:00
@43081j

43081j commented Oct 17, 2026

Copy link
Copy Markdown
Contributor

How do you account for the text node equivalent in the browser? We also need some stable identity for the frontend frameworks to reconcile against; how do you do that with the concept of a "node"?

why do you need an equivalent of the browser's text node?

on your second point, if JSX adapters or similar need an object representation of text, make a TextElement or something, whatever works. In our world it doesn't need to be distinguished from everything else - browsers need "nodes" and "elements", we don't. we have elements and, under the hood, operations.

Can you define what your mental model is for a "render loop" and what you are trying to avoid with it? From the sounds of it, you think we have some thing that owns the render api. I

i'm not trying to avoid anything, i'm just trying to describe how the render lifecycle would work.

in a game, for example, you have a render loop: just loop forever outputting frames constantly. that doesn't need to be a concept here and isn't in browsers. here, we can render reactively. i was just comparing to known concepts.

render, then when some state changes, render again, etc.

the bit im unsure of is how you stop that. i suppose in an imaginary TUI if we had a X button, that'd actually call root.close or something... some method or function to "finish".

but if you struggle with that model, do a loop. its fine

The body exists for familiarity and nothing more. It is just an alias for documentElement. We can remove it.

i would.

As it exists, the implementation instance is mostly where book keeping stuff stays. Right now its responsibilities are to hold:

  1. id map assigns stable ids to retained nodes so that TTY (which is immediate mode) will recognize the same node through subsequent passes.
  2. the render api by which elements notify that a layout is required

It could definitely be organized differently.

im not asking or caring too much about what the "implementation" is. i'm questioning where you've positioned it.

i think its meant to be a base class and its only been made a property/parameter here because your head's in DI land or because you want to be able to pass a mock "implementation" in within tests. either way, i'd make it a base class.


overall i don't believe we should be imitating the DOM to this extent. i think we should try name our similar behaviours the same way, but we shouldn't introduce noise just to make it look similar.

so:

  • appendChild - yes, we need this either way. so we may as well name it and shape it the same as DOM
  • body - no, it purely exists to imitate DOM and gives us nothing
  • document - between the two, i wouldn't have it because it leads us down then trying to define a DOM-like Document, which we don't need

i would define the ones we need and name those similar or the same as DOM.

@43081j

43081j commented Oct 17, 2026

Copy link
Copy Markdown
Contributor
  • separate api for text nodes: Annoying, but at the same time worse is better because almost every framework expects this and targets that distinction.

i still wouldn't bother. we don't need it. that can be the job of the "adapters" we build.

  • render frames: requested and coalesced by a render api stored on the "implementation" object

i think this can be a lot simpler the way i described since root.render() naturally just recursively renders children until you have a flat list of ops.

then you can do what you wish with those. pass it to tty, assert it in tests, whatever. no separate abstraction.

@cowboyd

cowboyd commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor Author

I think this is a key architectural difference of the dom-ish way. There is no point at which the tree is not fully rendered. The advantage is that Instead of polymorphic layout which can be impacted by internal private state, it must be expressed via a fully rendered and externalized style. Transcription into tty ops then becomes rote and easy to aggressively cache and transform with stylesheets.

@natemoo-re

Copy link
Copy Markdown
Member

I'm strongly in favor of merging this and keeping the work moving—we'll have a better sense of rendering ergonomics once we get a feel for what works or doesn't. I don't think anything landing from this PR means the API is set in stone; we are very much in the rough sketch phase and this is about as stripped down as it could possibly be.

+1 to merging

@cowboyd

cowboyd commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

in a game, for example, you have a render loop: just loop forever outputting frames constantly. that doesn't need to be a concept here and isn't in browsers. here, we can render reactively. i was just comparing to known concepts.

render, then when some state changes, render again, etc.

FWIW, this second model is how the current prototype works. I.e. the loop is idle when nothing is happening. Each state change requests a turn of the render loop, with changes in the same NodeJS tick all coalesced into the same turn. Furthermore, changes requested while the loop is turning are also coalesced into the next turn.

for await (const _ of this.#frames) {
  const ops = [...this.#layout.layout()];
  const result = this.#term.render(ops);
  await write(this.#output, result.output);
}

Among other things, this exerts a natural back pressure on the render loop so that it will not produce frames faster than they can be flushed to stdout

the bit im unsure of is how you stop that. i suppose in an imaginary TUI if we had a X button, that'd actually call root.close or something... some method or function to "finish".

In this particular setup, the frame generator listens to an abort signal and emits { done: true } for its next value so that the loop stops turning. The UI object holds the top level abort signal and triggers it when disposed.

@cowboyd

cowboyd commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Also, based on feedback, I've made the following simplifications:

  • remove document.body
  • remove DOMImplementation completely
  • remove Node.ownerDocument
  • remove public "stable Id" api, and make it a (lazy) private concern of layout.

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.

4 participants