The rendering engine is iterative, not recursive. Work is described as typed frames pushed onto renderRuntime.renderStack (a plain SimpRenderFrame[]). A single processStack loop pops and dispatches them until the stack is empty.
This avoids JavaScript call-stack overflows for deep trees and makes it straightforward to interleave mount, patch, unmount, and DOM placement operations on the same pass.
| Kind constant | Value | Handler |
|---|---|---|
MOUNT_ENTER |
10 | mountEnter(frame) |
MOUNT_EXIT |
11 | mountExit(frame) |
MOUNT_CHILDREN_ENTER |
12 | mountChildren(frame) |
PATCH_ENTER |
20 | patchEnter(frame) |
PATCH_EXIT |
21 | patchExit(frame) |
UNMOUNT_ENTER |
30 | unmountEnter(frame) |
UNMOUNT_EXIT |
31 | unmountExit(frame) |
UNMOUNT_CHILDREN_ENTER |
32 | unmountChildren(frame) |
HOST_OPS_PLACE_ELEMENT_BEFORE_ANCHOR |
40 | placeElementBeforeAnchor |
HOST_OPS_REPLACE_CHILD |
42 | hostAdapter.replaceChild |
flowchart TD
START(["processStack(renderRuntime)"]) --> CHECK{stackCursor >= 0?}
CHECK -->|no| END(["sweep slots 0..stackPeak, return"])
CHECK -->|yes| POP["frame = stack[stackCursor--]"]
POP --> DISPATCH{frame.kind}
DISPATCH -->|MOUNT_ENTER 10| ME["mountEnter(frame)"]
DISPATCH -->|MOUNT_EXIT 11| MX["mountExit(frame)"]
DISPATCH -->|MOUNT_CHILDREN_ENTER 12| MC["mountChildren(frame)"]
DISPATCH -->|PATCH_ENTER 20| PE["patchEnter(frame)"]
DISPATCH -->|PATCH_EXIT 21| PX["patchExit(frame)"]
DISPATCH -->|UNMOUNT_ENTER 30| UE["unmountEnter(frame)"]
DISPATCH -->|UNMOUNT_EXIT 31| UX["unmountExit(frame)"]
DISPATCH -->|UNMOUNT_CHILDREN_ENTER 32| UC["unmountChildren(frame)"]
DISPATCH -->|PLACE 40| PL["placeElementBeforeAnchor"]
DISPATCH -->|REPLACE 42| RP["hostAdapter.replaceChild"]
ME --> DEC["stackCursor--\n(frame processed, loop continues)"]
MX --> DEC
MC --> DEC
PE --> DEC
PX --> DEC
UE --> DEC
UX --> DEC
UC --> DEC
PL --> DEC
RP --> DEC
DEC --> CHECK
The stack is popped by decrementing renderRuntime.stackCursor (no Array.pop), so the frame object itself is left in place at that slot — ready to be reused by a later acquireFrame call without reallocating. Once the whole processStack call drains (stackCursor back to -1), a bounded sweep nulls out the large fields (node, prevElement, children, subtreeRightBoundary, placeHolderElement) on every slot up to stackPeak, so a deep render's element references don't outlive the call.
Most element types use an enter/exit pair to bracket work that must happen before and after children are processed. The stack is LIFO, so pushing EXIT before CHILDREN means CHILDREN is processed first:
sequenceDiagram
participant S as renderStack
participant P as processStack loop
Note over S: initial state (empty)
S->>S: push MOUNT_EXIT
S->>S: push MOUNT_CHILDREN_ENTER
P->>S: pop → MOUNT_CHILDREN_ENTER
Note over P: mounts children (may push more frames)
P->>S: pop → MOUNT_EXIT
Note over P: mounts props, places element in DOM
This pattern is used identically by HOST, FC, PORTAL, and FRAGMENT elements for mount and unmount. Patch uses PATCH_ENTER / PATCH_EXIT the same way.
Frame objects are expensive to GC at high frequency. Unlike a separate pool keyed per frame kind, renderRuntime.renderStack is the pool: it's a single flat array indexed by stackCursor (the index of the top active frame; -1 when empty) and stackPeak (the highest index acquired during the current top-level call). acquireFrame increments stackCursor and reuses whatever frame object already sits at that slot, allocating a new one only the first time a slot is reached:
flowchart LR
ACQUIRE["acquireFrame(renderRuntime)"] --> INC["cursor = ++stackCursor"]
INC --> PEAK{cursor > stackPeak?}
PEAK -->|yes| BUMP["stackPeak = cursor"]
PEAK -->|no| SLOT
BUMP --> SLOT["stack[cursor] ??= createFrame()"]
SLOT --> FRAME["frame (caller overwrites all fields)"]
acquireFrame is the single constructor for every frame kind — there's no per-kind pool or per-kind acquire function. Callers set all fields on the returned frame themselves (nulling ones their kind doesn't use).
mount(), patch(), and unmount() all assert renderRuntime.stackCursor === -1 before pushing their initial frame. This prevents concurrent entry into processStack from the same runtime, which would produce undefined behavior (two interleaved stack walks over the same array).