How Instatic's Page Tree Mutation System Works: Architecture and Implementation

Instatic's page tree mutation system operates on a flat map of nodes (NodeTree<PageNode>) using 11 pure mutation functions centralized in src/core/page-tree/mutations.ts, all dispatched through a single applyTreeOperation entry point that serves both the Zustand-based visual editor and the QuickJS plugin sandbox.

Instatic represents every page and visual component as a normalized tree structure rather than nested JSON objects. This architecture enables constant-time parent lookups and safe structural modifications while keeping the UI, server-side renderer, and untrusted plugin code perfectly synchronized through a unified mutation dispatcher.

Flat Node Tree Architecture

At the core of Instatic's page tree mutation system lies a flat node map where relationships are maintained through string references rather than object nesting. The NodeTree<PageNode> structure (validated by src/core/page-tree/treeSchema.ts) consists of:

  • A nodes dictionary keyed by unique IDs generated via nanoid()
  • A rootNodeId pointing to the tree's entry point
  • Each PageNode containing parentId, children (an array of child IDs), props, and metadata flags

This design allows O(1) parent lookups via getParent in src/core/page-tree/selectors.ts and simplifies cycle detection when reorganizing nodes.

The 11 Core Mutation Operations

All tree modifications flow through pure functions in src/core/page-tree/mutations.ts. These operations are categorized by their structural impact:

Node Creation and Insertion

createNode(moduleId, defaults?) (mutations.ts#L48-L62) generates a fresh node with a unique nanoid() identifier and a null parentId.

insertNode(tree, node, parentId, index?) (mutations.ts#L72-L92) adds the node to the flat tree.nodes map, stamps the parentId, and splices the node's ID into the parent's children array at the specified index.

Property and Metadata Updates

Three functions handle reactive property changes without structural modification:

  • updateNodeProps (mutations.ts#L13-L38) merges changes into the shallow props map
  • setBreakpointOverride and clearBreakpointOverride manage responsive design variants

UI-state mutations include renameNode, toggleNodeLocked, and toggleNodeHidden (mutations.ts#L54-L70), which modify editor-specific flags without affecting the render output.

Deletion and Duplication

deleteNode(tree, nodeId) (mutations.ts#L98-L107) removes a node and all its descendants via deleteSubtree, automatically cleaning up the parent's children reference to prevent orphaned IDs.

duplicateNode (mutations.ts#L33-L85) performs a deep clone of a subtree, generating fresh IDs with nanoid(), remapping class IDs to prevent style collisions, and inserting the clone immediately after the source node.

pasteSubtree (mutations.ts#L90-L78) handles foreign tree insertion (e.g., from clipboard) by building a fresh ID map (buildSubtreeNodeIdMap), cloning nodes with optional class filtering, and relinking parent references under the target parent.

Structural Reorganization

moveNode (mutations.ts#L82-L112) safely relocates nodes while guarding against cycles using the isAncestor selector. It detaches the node from its old parent, updates parentId, and inserts at newIndex.

moveNodes (mutations.ts#L136-L99) batches moves by reducing selections to top-level IDs, preventing descendants from being orphaned, and inserting consecutive nodes into the new parent.

wrapNode and wrapNodes (mutations.ts#L88-L126) create container nodes. Single-node wrapping swaps the container into the original node's position; multi-selection wrapping finds the closest common ancestor before performing the insertion.

The Dispatcher: applyTreeOperation

The applyTreeOperation(tree, op) function at the bottom of src/core/page-tree/mutations.ts serves as the single entry point for all mutations. It receives a discriminated union TreeOperation (defined in src/core/page-tree/operationSchema.ts) and switches on op.kind to route to the appropriate helper.

The function returns:

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

The affectedNodeIds array enables precise UI invalidation and cache updates. Because applyTreeOperation is a pure function that mutates the tree in place (for Mutative compatibility), callers requiring immutable snapshots must structuredClone the tree before invocation.

Safety Mechanisms and Selectors

Mutation safety relies on src/core/page-tree/selectors.ts, which provides:

  • getParent: O(1) parent retrieval via the flat map
  • isAncestor: Cycle detection by traversing parent chains
  • collectSubtreeIds: Recursive collection for bulk deletion

These utilities ensure that moveNode cannot create circular parent references and that deleteNode properly garbage collects descendant nodes.

Integration with Editor and Plugin Sandbox

Zustand Store Integration

The visual editor invokes mutations through mutateActiveTree in src/admin/pages/site/store/slices/site/helpers.ts, which wraps the dispatcher in Zustand's Mutative middleware:

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);
}

Under the hood, insertNode calls mutateActiveTree((tree) => insertNode(tree, node, parentId)), ensuring state updates remain immutable while the mutation logic stays pure.

Plugin Sandbox Integration

Plugins running in the QuickJS VM serialize TreeOperation objects across the sandbox boundary. The MCP API accepts mutation batches:

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

These operations deserialize to the same TreeOperation types consumed by applyTreeOperation, keeping plugin code synchronized with the core editor state.

Direct Mutation Usage

For server-side scripts or tests, apply mutations directly:

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

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

const op: TreeOperation = {
  kind: 'insertNode',
  parentId: tree.rootNodeId,
  index: 0,
  node: {
    id: '', // ID generated internally
    moduleId: 'core.text',
    props: { text: 'Hello' },
    breakpointOverrides: {},
    children: [],
    classIds: [],
    parentId: null,
  },
};

const result = applyTreeOperation(tree, op);
console.log(result.affectedNodeIds); // ['<rootId>', '<newNodeId>']

Summary

  • Instatic uses a flat node map (NodeTree<PageNode>) with parentId references rather than nested objects, enabling O(1) lookups
  • All mutations live in src/core/page-tree/mutations.ts as 11 pure functions compatible with the Mutative library
  • The applyTreeOperation dispatcher in the same file routes discriminated union operations from src/core/page-tree/operationSchema.ts
  • Safety selectors in src/core/page-tree/selectors.ts prevent cycles and manage subtree operations
  • Both the Zustand store (mutateActiveTree) and plugin VM use the same dispatcher, ensuring consistency across the editor, server, and sandbox
  • Mutations return affectedNodeIds for targeted UI updates while maintaining purity through structuredClone when immutability is required

Frequently Asked Questions

How does Instatic prevent circular references when moving nodes?

The moveNode function uses the isAncestor utility 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 parentId updates occur, ensuring the tree remains acyclic while maintaining O(1) lookup performance.

What is the difference between duplicateNode and pasteSubtree?

duplicateNode clones an existing node within the same tree, generating fresh IDs and inserting the copy adjacent to the source. pasteSubtree handles foreign tree insertion (e.g., from clipboard), rebuilding the entire ID map via buildSubtreeNodeIdMap and optionally filtering class IDs to prevent style leakage between disconnected components.

Why does applyTreeOperation return affectedNodeIds instead of a new tree?

The function mutates the tree in place for performance (Mutative compatibility), returning the modified tree reference and an affectedNodeIds array. This allows callers to perform selective UI updates or cache invalidation without deep equality checks on the entire node map, while callers needing immutability can wrap the call with structuredClone.

How do plugins safely mutate the page tree without direct state access?

Plugins running in the QuickJS sandbox serialize TreeOperation objects through the MCP API. These operations deserialize to the same discriminated union consumed by applyTreeOperation, ensuring plugins execute identical mutation logic as the visual editor while remaining isolated from the host environment's memory space.

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 →