@volynets/reflex-dom
v0.0.2
Published
DOM renderer for Reflex
Downloads
313
Maintainers
Readme
reflex-dom
reflex-dom is the DOM renderer for Reflex.
It turns JSX and renderable values into real DOM, but the important detail is that it does so with an explicit ownership tree. DOM nodes, reactive effects, event listeners, refs, and dynamic branch cleanups are all tied to lifecycle scopes, so mount, update, replace, and dispose stay deterministic.
Documentation
- English architecture overview:
README.md - Russian developer onboarding:
docs/ONBOARDING.ru.md
What This Package Is Responsible For
| Area | Files | Responsibility |
| --- | --- | --- |
| Public runtime API | src/runtime.ts, src/render.ts | Create renderers, mount roots, replace previous roots, expose render() / mount() / JSX runtime |
| Render dispatch | src/mount/append.ts | Walk renderable values and route them to element, component, operator, or text mounting |
| Mount architecture | src/mount/* | Keep element binding, renderable classification, and slot primitives as explicit seams instead of burying them inside tree walkers |
| DOM host writes | src/host/*, src/bindings/* | Apply props, styles, events, refs, namespaces, and reactive prop bindings |
| Dynamic regions | src/structure/content-slot.ts, src/mount/reactive-slot.ts | Keep slots replaceable without leaking nested effects |
| Structural operators | src/mount/show.ts, src/mount/switch.ts, src/mount/for.ts | Mount conditional and keyed-list branches |
| List reconciliation | src/reconcile/* | Hold keyed and unkeyed diff logic outside operator mounting so list behavior is easier to reason about and extend |
| Execution policies | src/runtime/policies.ts | Describe renderer scheduling intent and map it onto the current Reflex runtime options |
| Ownership and cleanup | reflex-framework/ownership + DOM mount sites | Track who owns which subtree and dispose it in a predictable order |
Mental Model
Think of reflex-dom as building two trees at the same time:
- The DOM tree that the browser sees.
- The ownership tree that Reflex uses for lifecycle and cleanup.
The DOM tree answers:
- What is currently mounted?
- Where should nodes be inserted or moved?
The ownership tree answers:
- Who owns this effect?
- Which cleanups belong to this component?
- What should be disposed when a branch is replaced?
That second tree is what keeps dynamic rendering safe.
Visual Architecture
JSX / renderable value
|
v
runtime.ts
- createDOMRenderer()
- jsx / jsxs / Fragment
|
v
render.ts
- resolve previous root on container
- dispose old root if present
- create new root scope
|
v
runInOwnershipScope(root)
|
v
mount/append.ts
|- element -> mount/element.ts -> mount/element-binder.ts -> host/*
|- component -> mount/component.ts
|- accessor -> mount/reactive-slot.ts
|- Show/Switch/For -> mount/show.ts / switch.ts / for.ts
| \-> reconcile/keyed.ts
\- primitives -> text nodes
|
v
reflex-framework/ownership/*
- scopes
- context
- cleanup registration
- subtree disposal
- reactive bridge used by DOM mountsEnd-to-End Lifecycle
1. Root render
render(input, container) eventually calls renderWithRenderer().
The root transaction is:
- Ensure the underlying Reflex runtime exists.
- Read the mounted root scope from the container.
- Dispose the previous root scope if the container is already mounted.
- Create a fresh root scope.
- Clear only the renderer-managed root range.
- Mount the new tree inside
runInOwnershipScope(rootScope, ...). - Store the new root scope on the container.
- Return an idempotent dispose function.
This makes every root render a clean ownership boundary.
2. Mounting a subtree
Inside the root scope, mount/append.ts dispatches by value shape:
- Element renderables create real DOM elements and bind props/children.
- Component renderables allocate a child ownership scope and mount the component output inside it.
- Accessors become dynamic ranges backed by a slot.
Show,Switch, andForallocate replaceable branch regions.- Strings, numbers, and
Nodeinstances are mounted directly.
3. Reactive updates
Reactive bindings are registered through useEffect().
That effect helper does two important things:
- Captures the owner scope that was active during mount.
- Restores that same owner during later reruns.
As a result, updates still know which scope owns any nested work they trigger.
Plain Reflex effects created during mount are captured by the current ownership
scope because DOM mounts enter the tree through runInOwnershipScope().
onEffectStart() is used to skip DOM writes on the first effect pass when the
initial DOM was already produced during mount. Later reruns are allowed to patch
the DOM.
4. Branch replacement
Dynamic regions are isolated through ContentSlot and child scopes.
When a branch changes:
- The current slot state is disposed.
- DOM between the slot markers is cleared.
- A new subtree is mounted into a fresh scope.
- That new scope becomes the active state for the slot.
This lets a branch be replaced without touching unrelated siblings.
5. Disposal
Disposal is always subtree-based and inside-out:
- Walk to the deepest mounted child scope.
- Run that node's cleanups in reverse registration order.
- Detach the node from the ownership tree.
- Continue with its next sibling or parent.
- Finish at the original root.
The result:
- children clean up before parents
- repeated
dispose()calls are safe - cleanup failures are isolated and logged without aborting the rest
Why Ownership Exists
Without ownership, a renderer eventually loses track of which effects and subscriptions belong to which DOM branch.
Typical failures look like this:
- A conditional branch disappears, but its effect keeps running.
- A component is replaced, but its event listener cleanup is forgotten.
- A root render is replaced, but stale reactive bindings still observe signals.
Ownership solves all three by making lifecycle explicit.
Why There Is No WeakMap For Mounted Roots
Mounted root state is stored directly on the container through a private
Symbol, not in a renderer-local WeakMap.
That matters for two reasons:
- The container itself is the source of truth for what is mounted there.
- Different renderer instances can still see and dispose the previous root on the same container.
This is especially important for handoffs such as:
rendererA.render(...) -> container owns root scope A
rendererB.render(...) -> rendererB sees scope A on container and disposes itWith a renderer-local WeakMap, that cross-renderer handoff would be much
harder to reason about.
Managed Root Ranges
Mounted root state is not just a scope anymore. It is a managed render range:
- a start anchor
- an end anchor
- a scope that owns everything between them
That range model is what enables:
- renderer handoff on the same container
- non-destructive root replacement
- basic
hydrate()andresume() - coexistence with foreign DOM inside the same host container
The important invariant is:
reflex-dommay clear its own rangereflex-dommust not blindly clear the whole container
For the Russian walkthrough of that model, see docs/ONBOARDING.ru.md.
SSR, Hydration, Resume, and Portals
The package now exposes four platform-facing entry points in addition to normal client rendering:
render()hydrate()resume()renderToString()
And one structural operator for out-of-tree mounting:
Portal
Current intent:
renderToString()produces baseline SSR HTML and marks dynamic slot regions for hydration.hydrate()tries to adopt matching DOM without recreating it.resume()adopts an existing DOM subtree under renderer ownership without rebuilding it.Portalmounts children into another DOM target while keeping cleanup tied to the original ownership tree.
These are still intentionally basic, but they already share the same ownership and managed-range model as client rendering.
Ownership Deep Dive
reflex-dom now consumes ownership from reflex-framework.
The renderer is responsible for choosing where DOM mounts enter ownership scopes, but the ownership tree itself belongs to the platform-agnostic core.
Example Trace
For the render below:
render(
<App>
{() => show() ? <Panel value={count} /> : null}
</App>,
container,
);The lifecycle looks like this:
root scope
\- App component scope
\- dynamic slot scope
\- Panel component scope
|- reactive prop effect
\- reactive text/range effectWhen show() becomes false, only the slot branch is disposed:
dispose(slot scope)
-> dispose(Panel component scope)
-> cleanup reactive text/range effect
-> cleanup reactive prop effect
-> remove DOM between slot markersThe rest of the app remains mounted.
Extension Guidelines
If you add a new renderer feature, ask two questions first:
- Does this feature create work that must stop when a subtree disappears?
- Can this feature mount a nested subtree that should be independently replaceable?
If the answer is yes:
- register cleanup in the current owner scope
- create a child scope for independently replaceable subtrees
If the answer is no:
- prefer plain DOM work with no extra scope allocation
That is why content-slot allocates scopes for fallback subtrees, but not for
plain text or borrowed DOM nodes.
Recent Architecture Lift
The experimental dom/ folder introduced a more explicit layered design. The
working src/ implementation now carries the portable parts of that design:
src/mount/renderable.tscentralizes renderable classification.src/mount/element-binder.tsseparates element setup from child mounting.src/reconcile/keyed.tsowns keyed list diffing instead of keeping it insideFor.src/runtime/policies.tsgives renderer scheduling a named policy surface.
That keeps the stable renderer behavior intact while making future features like alternate hosts, richer list strategies, or batched scheduling easier to add in one place.
Status
The current implementation is covered by package tests for:
- root replacement
- nested component disposal order
- effect cleanup on branch removal
- dynamic operator behavior
- renderer handoff on the same container
For concrete lifecycle cases, see test/render.lifecycle.test.tsx.
