# How Tree-Agnostic Mutations Work in Instatic's Page Tree System

> Discover how tree-agnostic mutations in Instatic's page tree system offer flexibility and efficiency. Learn about pure functions and a single dispatcher for diverse operations.

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

---

**Instatic implements tree-agnostic mutations as pure functions in [`src/core/page-tree/mutations.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/page-tree/mutations.ts) that operate on a flat `NodeTree<PageNode>` map, enabling 11 distinct operations through a single `applyTreeOperation` dispatcher used by both the Zustand-based editor and the plugin sandbox.**

Instatic treats every page and visual component as a node in a flattened data structure where each element tracks its own `parentId` and `children` array. This architecture decouples the tree logic from any specific UI framework, allowing the same mutation helpers to power the visual editor, server-side rendering, and third-party plugin scripts. All modifications flow through a type-safe dispatcher that returns affected node IDs for cache invalidation, ensuring consistent state across the entire system.

## The Flat Node Architecture

Instead of nested JSON objects, Instatic stores the page tree as a flat map where every `PageNode` maintains bidirectional references.

Each node contains a unique `id` generated by `nanoid()`, a `parentId` pointing to its container, and a `children` array of IDs. This structure lives in the `NodeTree<PageNode>` type defined across the core page-tree modules. The flat design enables **O(1) parent lookups** via the selector utilities in [`src/core/page-tree/selectors.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/page-tree/selectors.ts), eliminating recursive traversal during complex operations like moves or deletions.

When the visual editor or a plugin needs to modify the tree, they do not mutate the original structure directly. Instead, they invoke the dispatcher with a description of the change, receiving a result object containing the modified tree and a list of affected node IDs.

## The 11 Core Mutation Operations

The [`mutations.ts`](https://github.com/CoreBunch/Instatic/blob/main/mutations.ts) file exports pure functions covering the complete lifecycle of node management. These helpers are **Mutative-compatible**, meaning they work with Immer or similar libraries for immutable updates, though they perform mutations on plain objects for performance.

### Node Creation and Insertion

**`createNode(moduleId, defaults?)`** generates a fresh node with a unique ID and `null` parentId (lines 48-62). This factory is tree-agnostic—it creates the data structure without knowing its eventual position.

**`insertNode(tree, node, parentId, index?)`** places the node into the flat map, stamps the `parentId`, and updates the parent's `children` array at the specified index (lines 72-92). If no index is provided, the node appends to the end.

### Updates and Metadata

Property mutations modify the shallow `props` map or breakpoint-specific overrides:

- **`updateNodeProps`** – Updates reactive properties on a node (lines 13-38)
- **`setBreakpointOverride`** / **`clearBreakpointOverride`** – Manages responsive design overrides per breakpoint
- **`renameNode`**, **`toggleNodeLocked`**, **`toggleNodeHidden`** – Mutate UI-specific metadata flags (lines 54-70)

### Structural Modifications

**`deleteNode(tree, nodeId)`** removes a node and all descendants via `deleteSubtree`, cleaning up the parent's `children` reference (lines 98-107).

**`moveNode`** safely relocates nodes while guarding against cycles using the `isAncestor` selector. It detaches the node from its old parent, inserts it into the new parent at `newIndex`, and updates the `parentId` (lines 82-112).

**`duplicateNode`** performs a deep clone of a subtree, generating fresh IDs with `nanoid()`, optionally remapping class IDs, and re-linking `parentId` references before inserting the clone adjacent to the source (lines 33-85).

**`pasteSubtree`** handles importing foreign trees by building a fresh ID map, cloning nodes with optional class-ID filtering, and inserting the new root under a target parent (lines 90-78).

**`wrapNode`** creates a container node, swaps it into the original node's position, and reparents the original as its child. The plural variant **`wrapNodes`** handles multi-selection by reducing to top-level nodes and finding the closest common ancestor before wrapping (lines 88-126).

**`moveNodes`** performs batch relocations by reducing selections to top-level IDs, validating against descendant moves, detaching each node, and inserting them consecutively into the new parent (lines 136-99).

## The Dispatcher Pattern

All mutations route through **`applyTreeOperation(tree, op)`**, a dispatcher function at the bottom of [`mutations.ts`](https://github.com/CoreBunch/Instatic/blob/main/mutations.ts) that consumes a discriminated union defined in [`src/core/page-tree/operationSchema.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/page-tree/operationSchema.ts).

The `TreeOperation` type tags each mutation with a `kind` property, enabling exhaustive switch-case handling:

```ts
type TreeOperation =
  | { kind: 'insertNode'; parentId: string; index?: number; node: PageNode }
  | { kind: 'deleteNode'; nodeId: string }
  | { kind: 'moveNode'; nodeId: string; newParentId: string; newIndex: number }
  | { kind: 'duplicateNode'; nodeId: string }
  // ... additional operations

```

The dispatcher returns an object containing the modified tree and an `affectedNodeIds` array:

```ts
{
  tree: NodeTree<PageNode>;
  affectedNodeIds: string[];
}

```

This return signature enables callers to precisely invalidate UI caches or trigger re-renders only for changed subtrees. Because `applyTreeOperation` is a pure function that mutates the input tree directly, callers requiring immutable snapshots must `structuredClone` the tree before invocation.

## Safety Mechanisms and Selectors

The mutation system relies on helper functions in [`src/core/page-tree/selectors.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/page-tree/selectors.ts) to maintain tree integrity.

**`getParent(tree, nodeId)`** provides constant-time parent retrieval by looking up the `parentId` in the flat map. **`isAncestor(tree, potentialAncestor, nodeId)`** prevents cycles during move operations by walking parent references upward. **`collectSubtreeIds`** gathers all descendant IDs for deletion or duplication operations.

These selectors ensure that operations like `moveNode` cannot create circular references that would corrupt the tree structure.

## Integration Across the Stack

### Zustand Store Integration

The admin interface in [`src/admin/pages/site/store/slices/site/helpers.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/helpers.ts) wraps mutation calls using the `mutateActiveTree` helper. This utility manages the Zustand state transition while preserving the pure mutation interface:

```ts
mutateActiveTree((tree) => insertNode(tree, node, parentId));

```

The store actions provide thin wrappers that craft the appropriate `TreeOperation` objects and feed them to the dispatcher, keeping UI logic decoupled from mutation implementation details.

### Plugin Sandbox Communication

Plugins running in the QuickJS VM communicate with the core through a MCP API. When a plugin requests tree modifications, the system deserializes the request into `TreeOperation` objects and applies them via the same `applyTreeOperation` dispatcher used by the editor.

```ts
await api.cms.content.tree.mutate([
  { kind: 'renameNode', nodeId: 'abcd1234', name: 'Header' },
  { kind: 'setBreakpointOverride', nodeId: 'abcd1234', breakpoint: 'mobile', props: { color: '#f00' } },
]);

```

This unified dispatch layer guarantees that plugin-generated mutations follow identical validation paths and safety checks as user-driven edits in the visual interface.

## Complete Implementation Example

The following example demonstrates direct mutation application suitable for server-side scripts or testing:

```ts
import { applyTreeOperation, createNode } from '@core/page-tree';
import type { TreeOperation } from '@core/page-tree';
import { parsePageNodeTree } from '@core/page-tree';

// Parse existing tree from database
const rawTree = /* JSON from DB */;
const tree = parsePageNodeTree(rawTree);

// Create a new text node
const newNode = createNode('core.text', { text: 'Dynamic Content' });

const op: TreeOperation = {
  kind: 'insertNode',
  parentId: tree.rootNodeId,
  index: 0,
  node: newNode,
};

const { tree: updatedTree, affectedNodeIds } = applyTreeOperation(tree, op);
console.log('Modified nodes:', affectedNodeIds);

```

For React components using the admin store:

```ts
import { useSiteStore } from '@admin/store';
import { createNode } from '@core/page-tree';

function addTextBlock(parentId: string) {
  const node = createNode('core.text', { text: 'New block' });
  useSiteStore.getState().insertNode(node, parentId);
}

```

## Summary

- **Flat Architecture**: Instatic uses a `NodeTree<PageNode>` flat map with `parentId` references rather than nested objects, enabling O(1) lookups via [`src/core/page-tree/selectors.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/page-tree/selectors.ts).
- **Pure Mutations**: Eleven tree-agnostic operations in [`src/core/page-tree/mutations.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/page-tree/mutations.ts) handle creation, insertion, deletion, movement, duplication, wrapping, and property updates as pure functions.
- **Unified Dispatch**: The `applyTreeOperation` dispatcher in [`mutations.ts`](https://github.com/CoreBunch/Instatic/blob/main/mutations.ts) processes discriminated `TreeOperation` unions from [`operationSchema.ts`](https://github.com/CoreBunch/Instatic/blob/main/operationSchema.ts), returning affected node IDs for precise cache invalidation.
- **Cycle Safety**: Selector utilities like `isAncestor` prevent invalid move operations that would create circular references.
- **Cross-Environment**: The same mutation logic powers the Zustand-based visual editor, server-side rendering, and QuickJS plugin sandbox through identical dispatch patterns.

## Frequently Asked Questions

### What makes Instatic's mutations "tree-agnostic"?

**Tree-agnostic** means the mutation functions in [`mutations.ts`](https://github.com/CoreBunch/Instatic/blob/main/mutations.ts) operate purely on the data structure without dependencies on React, Vue, or any specific rendering layer. They accept a `NodeTree` and parameters, then return the modified state, making them usable in the browser, server, or plugin sandbox without modification.

### How does Instatic prevent circular references when moving nodes?

The `moveNode` function utilizes the `isAncestor` selector from [`src/core/page-tree/selectors.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/page-tree/selectors.ts) to verify that the target parent is not a descendant of the node being moved. This check runs before any references are updated, ensuring the tree remains a valid directed acyclic graph.

### Why does `applyTreeOperation` mutate the tree directly instead of returning a new copy?

The dispatcher mutates the input tree for performance, as these operations may run frequently during drag-and-drop interactions. Callers requiring immutable snapshots—such as the Zustand store—wrap calls in `structuredClone` or use Immer's `mutate` utility. This design gives consumers control over the immutability boundary while keeping the core mutations lightweight.

### Can plugins perform batch mutations atomically?

Yes. The plugin API accepts an array of `TreeOperation` objects via `api.cms.content.tree.mutate()`, applying each sequentially through `applyTreeOperation`. While individual operations execute separately, the API layer can wrap the sequence in a transaction boundary, ensuring that validation failures in later operations prevent partial state updates.