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 nodes record holds every node (pages, visual components, slot instances) by UUID. Parent-child relationships are maintained through parentId references rather than nested objects.
  • Explicit root entryrootNodeId designates where traversal begins, allowing multiple independent trees in the same store if needed.
  • Generic type parameterTNode lets consumers work with specialized node types (e.g., PageNode with dynamicBindings) while runtime storage remains compatible with BaseNode.

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 applyTreeOperation directly
  • 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-links parentId references for cloned subtrees in O(subtree size) time
  • Cycle preventionisAncestor check (from src/core/page-tree/selectors.ts) blocks illegal moves before mutation
  • Existence validation — Parent lookups fail fast with clear errors
  • Root protectiondeleteNode guards against removing rootNodeId

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

  • NodeTree is a flat-map abstraction (Record<string, TNode>) with O(1) node access and explicit rootNodeId traversal start
  • Tree-agnostic mutations in src/core/page-tree/mutations.ts manipulate structure without semantic awareness, enabling reuse across pages, VCs, and slots
  • Pure-mutative design integrates with Zustand's Mutative middleware for immutable-friendly updates
  • applyTreeOperation provides 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →