What Is the NodeTree Primitive and How Do Tree‑Agnostic Mutations Work in Instatic?
Instatic's NodeTree is a flat-map data structure that represents any hierarchical content as a dictionary of nodes keyed by UUID, enabling O(1) lookups and mutations that work identically across pages, visual components, and slot-fill fragments without knowing what the tree represents.
Every tree-based operation in Instatic—whether in the visual editor, plugin sandbox, or server-side code—relies on a single, generic abstraction. The NodeTree primitive eliminates redundant traversal logic and provides a unified mutation API that scales from simple prop updates to complex subtree operations.
The NodeTree Primitive Explained
The NodeTree interface is defined in src/core/page-tree/treeSchema.ts and serves as the universal container for hierarchical data:
// src/core/page-tree/treeSchema.ts
export interface NodeTree<TNode extends BaseNode = BaseNode> {
nodes: Record<string, TNode> // O(1) lookup of any node by its id
rootNodeId: string // entry point for traversals
}
Key design characteristics
- Flat map storage — The
nodesrecord holds every node (pages, visual components, slot instances) by UUID. Parent-child relationships are maintained throughparentIdreferences rather than nested objects. - Explicit root entry —
rootNodeIddesignates where traversal begins, allowing multiple independent trees in the same store if needed. - Generic type parameter —
TNodelets consumers work with specialized node types (e.g.,PageNodewithdynamicBindings) while runtime storage remains compatible withBaseNode.
The NodeTreeSchema validator enforces these constraints at persistence boundaries, ensuring any stored tree conforms to the expected shape.
Why flat maps beat nested trees
Traditional tree structures require recursive traversal for most operations. NodeTree inverts this:
| Nested tree approach | NodeTree flat map |
|---|---|
| O(depth) parent lookup | O(1) direct access via nodes[id] |
| Mutation requires path reconstruction | Direct reference update |
| Serialization preserves nesting | Flattened, database-friendly structure |
This design enables pure-mutative operations: functions receive a draft NodeTree<TNode> (via Zustand's Mutative middleware), modify it in place, and return nothing.
How Tree‑Agnostic Mutations Work
All mutation helpers live in src/core/page-tree/mutations.ts. They operate exclusively on the NodeTree<TNode> shape without inspecting node semantics—making the same code path valid for page editing, VC editing, and slot-fill handling.
Core mutation functions
// src/core/page-tree/mutations.ts
export function createNode(moduleId: string, defaults?: Partial<PageNode>): PageNode
export function insertNode<TNode extends BaseNode>(
tree: NodeTree<TNode>,
node: TNode,
parentId: string,
index?: number
): void
export function deleteNode<TNode extends BaseNode>(
tree: NodeTree<TNode>,
nodeId: string
): string[] // returns deleted ids
export function moveNode<TNode extends BaseNode>(
tree: NodeTree<TNode>,
nodeId: string,
newParentId: string,
newIndex?: number
): void
export function duplicateNode<TNode extends BaseNode>(
tree: NodeTree<TNode>,
nodeId: string,
options?: DuplicateOptions
): string // returns new root id
export function wrapNode<TNode extends BaseNode>(
tree: NodeTree<TNode>,
nodeId: string,
wrapperModuleId: string
): string // returns wrapper id
Mutation categories and use cases
| Category | Function | Purpose |
|---|---|---|
| Lifecycle | createNode |
Instantiate new nodes with nano-id generation |
| Structure | insertNode, deleteNode |
Add or remove nodes with automatic parentId sync |
| Reorganization | moveNode, moveNodes |
Reparent nodes with cycle detection |
| Cloning | duplicateNode, pasteSubtree |
Deep-copy subtrees with fresh UUIDs |
| Composition | wrapNode, wrapNodes |
Enclose nodes in container modules |
| Properties | updateNodeProps |
Shallow-merge prop patches |
| Styling | setBreakpointOverride, clearBreakpointOverride |
Manage responsive style overrides |
| Metadata | renameNode, toggleNodeLocked, toggleNodeHidden |
Simple flag mutations |
The dispatcher pattern
All mutations route through a single entry point:
// src/core/page-tree/mutations.ts
export function applyTreeOperation<TNode extends BaseNode>(
tree: NodeTree<TNode>,
op: TreeOperation
): string[]
The TreeOperation type (defined in src/core/page-tree/operationSchema.ts) is a tagged union covering all 11 mutation variants. This dispatcher enables:
- Editor store integration — Zustand actions call
applyTreeOperationdirectly - Plugin VM execution — Sandboxed plugins emit operations that execute through the same path
- Audit logging — Operations serialize cleanly for undo/redo stacks
Practical Code Examples
Creating and inserting a node
import { createNode, insertNode } from '@core/page-tree';
import type { NodeTree, PageNode } from '@core/page-tree';
// 1. Create a paragraph node with default props
const paragraph = createNode('core.paragraph', {
text: 'Hello world',
align: 'left'
});
// 2. Insert as last child of the page root
insertNode(
pageTree as NodeTree<PageNode>,
paragraph,
pageTree.rootNodeId
// index omitted: appends to end
);
Moving and wrapping nodes
import { moveNode, wrapNode } from '@core/page-tree';
// Move paragraph to first position in a column
moveNode(
pageTree as NodeTree<PageNode>,
paragraph.id,
columnNodeId,
0 // newIndex: insert at start
);
// Wrap the paragraph in a section container
const sectionId = wrapNode(
pageTree as NodeTree<PageNode>,
paragraph.id,
'core.section'
);
Working with the operation dispatcher
import { applyTreeOperation } from '@core/page-tree';
// Equivalent to moveNode, but serializable
applyTreeOperation(pageTree, {
type: 'move',
nodeId: paragraph.id,
newParentId: columnNodeId,
newIndex: 0
});
Performance and Safety Guarantees
Tree-agnostic mutations achieve efficiency through targeted subtree operations rather than whole-tree reconstruction:
linkChildrenParents— Re-linksparentIdreferences for cloned subtrees in O(subtree size) time- Cycle prevention —
isAncestorcheck (fromsrc/core/page-tree/selectors.ts) blocks illegal moves before mutation - Existence validation — Parent lookups fail fast with clear errors
- Root protection —
deleteNodeguards against removingrootNodeId
Because mutations only touch affected nodes, operations on large trees remain performant. The parentId link is maintained lazily—no eager index rebuilding required.
Key Source Files
| File | Responsibility |
|---|---|
src/core/page-tree/treeSchema.ts |
NodeTree interface, NodeTreeSchema validation, generic type definitions |
src/core/page-tree/mutations.ts |
All tree-agnostic mutation implementations |
src/core/page-tree/baseNode.ts |
BaseNode interface extended by all concrete node types |
src/core/page-tree/selectors.ts |
Navigation helpers (getParent, isAncestor, collectSubtreeIds) |
src/core/page-tree/operationSchema.ts |
TreeOperation tagged union for dispatcher routing |
Summary
NodeTreeis a flat-map abstraction (Record<string, TNode>) with O(1) node access and explicitrootNodeIdtraversal start- Tree-agnostic mutations in
src/core/page-tree/mutations.tsmanipulate structure without semantic awareness, enabling reuse across pages, VCs, and slots - Pure-mutative design integrates with Zustand's Mutative middleware for immutable-friendly updates
applyTreeOperationprovides a serializable, auditable dispatcher used by both editor and plugin VM- Performance comes from targeted subtree operations; safety from validation helpers in
selectors.ts
Frequently Asked Questions
What makes NodeTree "flat" compared to a nested JSON tree?
Traditional trees nest children inside parent objects ({ id: 'a', children: [{ id: 'b' }] }). NodeTree stores all nodes at one level—nodes: { a: {...}, b: {...} }—with parentId and children: string[] references linking them. This eliminates deep traversal and enables direct lookup by ID.
Can tree-agnostic mutations handle any node type without modification?
Yes. The TNode extends BaseNode constraint ensures all nodes have required fields (id, parentId, children, moduleId). Specific mutations like updateNodeProps operate on shared properties; type-specific logic belongs in consuming code, not the mutation layer.
How does the plugin VM safely execute tree mutations?
Plugins emit TreeOperation objects rather than calling functions directly. The host's applyTreeOperation validates and executes these, maintaining sandbox isolation. The same serialization enables operation logging, undo/redo, and cross-window synchronization.
Why separate selectors from mutations in selectors.ts?
Navigation helpers (getParent, isAncestor) are pure reads that multiple mutations need. Extracting them prevents duplication and allows independent testing. Mutations import selectors; selectors never import mutations, preserving clean dependency direction.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →