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

Instatic implements tree-agnostic mutations as pure functions in 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, 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 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 that consumes a discriminated union defined in src/core/page-tree/operationSchema.ts.

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

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:

{
  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 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 wraps mutation calls using the mutateActiveTree helper. This utility manages the Zustand state transition while preserving the pure mutation interface:

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.

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:

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:

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.
  • Pure Mutations: Eleven tree-agnostic operations in 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 processes discriminated TreeOperation unions from 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 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 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.

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 →