# What Is the NodeTree Primitive and How Do Tree‑Agnostic Mutations Work in Instatic?

> Explore Instatic's NodeTree primitive and discover how tree-agnostic mutations enable O(1) lookups and efficient content manipulation across your application.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: internals
- Published: 2026-08-01

---

**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`](https://github.com/CoreBunch/Instatic/blob/main/src/core/page-tree/treeSchema.ts) and serves as the universal container for hierarchical data:

```ts
// 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 entry** — `rootNodeId` designates where traversal begins, allowing multiple independent trees in the same store if needed.
- **Generic type parameter** — `TNode` 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`](https://github.com/CoreBunch/Instatic/blob/main/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

```ts
// 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:

```ts
// 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`](https://github.com/CoreBunch/Instatic/blob/main/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

```ts
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

```ts
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

```ts
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 prevention** — `isAncestor` check (from [`src/core/page-tree/selectors.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/page-tree/selectors.ts)) blocks illegal moves before mutation
- **Existence validation** — Parent lookups fail fast with clear errors
- **Root protection** — `deleteNode` 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`](https://github.com/CoreBunch/Instatic/blob/main/src/core/page-tree/treeSchema.ts) | `NodeTree` interface, `NodeTreeSchema` validation, generic type definitions |
| [`src/core/page-tree/mutations.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/page-tree/mutations.ts) | All tree-agnostic mutation implementations |
| [`src/core/page-tree/baseNode.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/page-tree/baseNode.ts) | `BaseNode` interface extended by all concrete node types |
| [`src/core/page-tree/selectors.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/page-tree/selectors.ts) | Navigation helpers (`getParent`, `isAncestor`, `collectSubtreeIds`) |
| [`src/core/page-tree/operationSchema.ts`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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.