Skip to content

✨ node data bag + Context accessors with prototype inheritance - #17

Merged
cowboyd merged 1 commit into
mainfrom
tree-node
Aug 22, 2026
Merged

cowboyd merged 1 commit into
mainfrom
tree-node

Conversation

@cowboyd

@cowboyd cowboyd commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Elements participate in several overlapping scopes: host ownership, layout behavior, focus state, event handling, and rendering policy. Passing every capability through every operation would make element authors and framework adapters carry plumbing that has nothing to do with their component logic. Nodes with inherited contexts provide ambient, subtree-scoped capabilities while still allowing a descendant to override any individual concern.

The tree at this layer records semantic attachment, not visual order. That keeps the primitive small and allows the ordered host tree introduced later to define presentation without turning every middleware concern into a layout concern.

import { Node, createContext } from './core.ts';

const root = new Node();
const child = new Node(root);
const color = createContext<string>('color');

color.set(root, 'cyan');
color.get(child); // => "cyan"

This PR adds the minimal Node and Context classes, parent references, child collections, and prototype-inherited context storage inside the UI core. createContext() remains as the ergonomic context factory, with tests covering inheritance, overrides, local ownership, defaults, and null-terminated lookup.

@pkg-pr-new

pkg-pr-new Bot commented Aug 7, 2026 •

Copy link
Copy Markdown

commit: ad4632c

Comment thread packages/ui/src/core/node.ts Outdated
@@ -0,0 +1,14 @@
export interface Node {
parent?: Node;
contexts: Record<string, unknown>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

FYI this pattern can get pretty slow since it means repeated property access in the hot path.

V8 shouldn't deopt it at least if every node has the same shape. But you are probably better off storing the state separately.

E.g. in a WeakMap<Node, Context>.

Similarly, it'll end up in dictionary mode in V8 given the context object will be inconsistently shaped. We really should use a Map instead of a plain object if we can.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

If we don't use an object here, then we'll have to implement the prototype chain ourselves in Context.get()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm a bit lost, can you explain more what you mean?

This is just a properties bag attached to the node right now. I'm suggesting don't attach it to the node, just relate them through a weak map instead.

If this is actually meant to be context: Context right now, the same applies. WeakMap<Node, Context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's so that contextual values are inherited from one node to the next in the tree.

https://github.com/bombshell-dev/ui/pull/17/changes#diff-5ccb295a69b0308bcf422c94e309ec4a592fe62277dc6b6702ea0bc8adda412eR81

We can use a Map as the underlying storage mechanism, but then we'd have to implement the lookup ourselves.

@43081j 43081j Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

it is clearer now that you moved to a class, you're talking about this inheritance:
https://github.com/bombshell-dev/ui/pull/17/changes#diff-3b2a6d756e64248f2ab7b374b867eadb4e3bae17231db7308752d43bd113372dR8

you can still have that without storing it on the node, though.

const contexts = new WeakMap<Node, Context>();

// elsewhere, in the Node constructor
const parentContext = parent ? contexts.get(parent) : null;
contexts.set(this, Object.create(parentContext, whatever));

// anywhere you need a context
contexts.get(node); // Context or undefined

it is more expensive to repeatedly be accessing properties of nodes during traversal than it is to look them up in a weak map, basically.

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.

i don't think the WeakMap buys us anything here. your idea still calls Object.create(parentContext). the inheritance mechanism is identical, it's just parked behind a WeakMap.get first. so it's the same prototype walk plus a hash lookup, not fewer property accesses.

same with Map it has no prototype chain, so Context.get would have to loop up parent by hand.

the one thing WeakMap does give us is that contexts stops being public on Node. if that's the concern I'm on board 👀

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You've misunderstood a bit because this wasn't about the inheritance at all. It was purely about the property access:

FYI this pattern can get pretty slow since it means repeated property access in the hot path

the WeakMap absolutely would have bought us a perf bump. however, since Node is now a class, i suspect it means v8 can optimise the shape into the same hidden class across the board.

happy to resolve this because of the move to using a class.

Comment thread packages/ui/src/core/context.ts Resolved
Comment thread packages/ui/src/core/context.ts Outdated
return value;
}
},
hasOwn: (node) => Object.hasOwn(node.contexts, name),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

re my other comment, this is a slow path that would be made a lot faster if it was just a map. this.contexts.has(name) etc

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.

a Map has no prototype chain, so you'd hand-roll the parent walk in Context.get which is O(depth) loop instead of one engine-native lookup. i don't think is worth it now.

@43081j 43081j Aug 16, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why would we hand roll any kind of walk?

assuming every node has a context, and the context is a Map, its just a plain old .get(name) or .has(name) like in my comment above.

in the current code and my proposed code there's no concept of a prototype chain. its a flat hasOwn of a single object.

EDIT: ok i understand now. this manual inheritance stuff is a pain. contexts isn't a regular object, its the prototype chain from before. so we can't replace it with a Map elsewhere (this usage absolutely could use a map) thanks to that... i need to think about this as its forcing us into obvious perf bottlenecks.

@cowboyd

cowboyd commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor Author

@43081j We can definitely give all those a try. I also tried to make the middleware (#18 ) class-based as well and use super.method(...args) instead of an explicit next(...args) parameter, but I couldn't get it quite to work so I moved on. (might be worth revisiting)

For performance concerns, we should get a perf suite set up so that we don't introduce any regressions. I don't know how folks are feeling about CodSpeed which we are using for tty?

@43081j

43081j commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

i wouldn't spend much time on setting up a benchmark suite yet as we're barely at the architecture level.

we're better off tackling that when we've actually settled on a design.

Base automatically changed from tree to main August 10, 2026 21:14
@cowboyd
cowboyd force-pushed the tree-node branch 2 times, most recently from 6152cfc to a572b5c Compare August 11, 2026 04:00
@dreyfus92

Copy link
Copy Markdown
Member

i agree with james, we shouldn't be worrying about perf issues rn.

@43081j

43081j commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

tree-node...tree-node-jamesified

here you go, also messaged it in discord 👍

@cowboyd
cowboyd requested review from 43081j, dreyfus92 and natemoo-re and removed request for 43081j and dreyfus92 August 22, 2026 05:19
Store contexts in lazily allocated, string-keyed maps and walk the node ancestry during lookup. Explicit undefined values continue to shadow inherited values.

Co-authored-by: James Garbutt <43081j@users.noreply.github.com>

@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.

Works for me! This seems to match @43081j's implementation quite literally. 💜

Comment thread packages/ui/src/core/node.ts Resolved

@dreyfus92 dreyfus92 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.

let's keep this rollin' bois 🤘🏻

@gameroman
gameroman requested a review from 43081j August 22, 2026 22:50
@cowboyd
cowboyd merged commit a6c39c1 into main Aug 22, 2026
4 checks passed
@cowboyd
cowboyd deleted the tree-node branch August 22, 2026 23:46
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