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
nodesdictionary keyed by unique IDs generated viananoid() - A
rootNodeIdpointing to the tree's entry point - Each
PageNodecontainingparentId,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 shallowpropsmapsetBreakpointOverrideandclearBreakpointOverridemanage 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 mapisAncestor: Cycle detection by traversing parent chainscollectSubtreeIds: 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>) withparentIdreferences rather than nested objects, enabling O(1) lookups - All mutations live in
src/core/page-tree/mutations.tsas 11 pure functions compatible with the Mutative library - The
applyTreeOperationdispatcher in the same file routes discriminated union operations fromsrc/core/page-tree/operationSchema.ts - Safety selectors in
src/core/page-tree/selectors.tsprevent 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
affectedNodeIdsfor targeted UI updates while maintaining purity throughstructuredClonewhen 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →