How Page Tree Mutations Function with Instatic's NodeTree Primitive
Page tree mutations in Instatic are handled by pure, Mutative-compatible helper functions that directly modify a flat NodeTree<PageNode> structure, enabling O(1) parent lookups and cycle-safe structural changes via Zustand's draft-based state management.
Instatic represents every page and visual component as a NodeTree<PageNode>—a flat data structure defined in src/core/page-tree/treeSchema.ts. All structural modifications, whether triggered by the visual editor, plugins, or server-side logic, flow through a centralized set of mutation helpers in src/core/page-tree/mutations.ts. These functions provide the only mechanism for altering the page tree, ensuring consistent behavior and maintaining structural integrity across the application.
The NodeTree Primitive Architecture
The NodeTree primitive eschews traditional nested trees in favor of a flat map design. Each tree maintains a nodes field—typed as Record<string, PageNode>—that maps unique node IDs to their corresponding PageNode objects, alongside a rootNodeId string that anchors the hierarchy.
This flat architecture enables constant-time lookups. Instead of recursive traversal to find parents or siblings, operations leverage the Record structure for O(1) access. The NodeTree shape in src/core/page-tree/treeSchema.ts serves as the single source of truth for the entire page structure.
Core Mutation Helpers
All structural changes are performed by pure helper functions defined in src/core/page-tree/mutations.ts. Each function receives the draft NodeTree (typically from Zustand's Mutative middleware) and applies changes directly. These same functions can operate on structuredClone copies for functional-style pure calls when needed.
Creating and Inserting Nodes
The foundation of tree manipulation begins with node creation and placement. The createNode function generates a fresh PageNode with a nano-generated ID, empty children array, and no parent assignment.
import { createNode, insertNode } from '@core/page-tree';
// Generate a new hero module node
const newNode = createNode('module:hero', { title: 'Welcome' });
// Append it to the root node
insertNode(pageTree, newNode, pageTree.rootNodeId);
The insertNode helper in src/core/page-tree/mutations.ts handles parent linking automatically. When inserting at a specific index (or appending when omitted), it stamps the parentId on the child node and updates the parent's children array.
Deleting and Moving Nodes
Structural reorganization relies on safe deletion and reparenting logic. The deleteNode function recursively removes a node and its entire subtree, unlinking it from its parent while cleaning up the nodes map.
For reparenting, moveNode guards against cycles by first checking ancestry relationships. It utilizes selector utilities from src/core/page-tree/selectors.ts—specifically getParent for O(1) parent retrieval and isAncestor for depth-based cycle detection.
import { moveNode, deleteNode } from '@core/page-tree';
// Move node to new parent at index 1
moveNode(pageTree, 'node-id', 'new-parent-id', 1);
// Remove entire subtree
deleteNode(pageTree, 'node-id');
Updating Properties and Metadata
Node content and visibility are managed through targeted update functions. The updateNodeProps helper shallow-merges patch objects into a node's props field, while specialized functions handle metadata:
renameNodeupdates thelabelfieldtoggleNodeLockedflips thelockedbooleantoggleNodeHiddenflips thehiddenbooleansetBreakpointOverrideandclearBreakpointOverridemanage per-breakpoint prop overrides stored inbreakpointOverrides
import { updateNodeProps, renameNode, setBreakpointOverride } from '@core/page-tree';
// Update props
updateNodeProps(pageTree, nodeId, { subtitle: 'Hello world' });
// Rename the node
renameNode(pageTree, nodeId, 'Hero Banner');
// Apply responsive override
setBreakpointOverride(pageTree, nodeId, 'mobile', { fontSize: 14 });
Advanced Operations: Duplication, Wrapping, and Pasting
Complex editing workflows require batch operations that preserve tree integrity. The duplicateNode function deep-clones an entire subtree, generating new IDs for every cloned node before inserting the duplicate adjacent to the original.
For clipboard operations, pasteSubtree remaps IDs from foreign subtrees and optionally filters class IDs before insertion. The wrapNode and wrapNodes functions surround selections with new container modules, preserving child order and updating parent references.
import { duplicateNode, wrapNode, pasteSubtree } from '@core/page-tree';
// Clone a node and its children
const cloneId = duplicateNode(pageTree, originalId);
// Wrap in a section container
const wrapperId = wrapNode(pageTree, nodeId, 'module:section', {});
// Paste clipboard content
pasteSubtree(pageTree, clipboardData, targetParentId);
The Operation Dispatcher
A single entry point unifies all mutations for external consumers. The applyTreeOperation function in src/core/page-tree/mutations.ts accepts a discriminated-union TreeOperation (defined in src/core/page-tree/operationSchema.ts) and routes it to the appropriate helper.
This dispatcher returns both the mutated tree and an array of affectedNodeIds, enabling precise cache invalidation and UI updates. It serves as the bridge between the visual editor, plugin VM boundary, and core mutation logic.
import { applyTreeOperation } from '@core/page-tree';
const operation = {
kind: 'renameNode',
nodeId: 'abc-123',
name: 'Updated Label'
};
const result = applyTreeOperation(pageTree, operation);
// result.affectedNodeIds contains ['abc-123']
Integration with Zustand and Mutative
The mutation helpers are designed for compatibility with Zustand's Mutative middleware. When invoked inside mutateActiveTree actions (located in src/admin/pages/site/store/slices/site/helpers.ts), they operate on drafts without returning new objects. The middleware records changes and produces immutable snapshots for the React component tree.
Alternatively, the same functions accept deep-cloned trees (via structuredClone) for pure functional operations outside the store. This dual compatibility supports both the editor's interactive state management and server-side tree transformations.
Summary
- Page tree mutations in Instatic operate on a flat
NodeTree<PageNode>structure defined insrc/core/page-tree/treeSchema.ts, usingRecord-based storage for O(1) node access. - All changes flow through pure helper functions in
src/core/page-tree/mutations.ts, includingcreateNode,insertNode,moveNode, andduplicateNode. - Selector utilities in
src/core/page-tree/selectors.tsprovide efficient parent lookup and ancestry checking to prevent cycles. - The
applyTreeOperationdispatcher unifies mutations via aTreeOperationunion type, returning affected node IDs for cache management. - Mutative compatibility allows these functions to work seamlessly with Zustand drafts or
structuredClonecopies, supporting both interactive editing and functional programming patterns.
Frequently Asked Questions
How does Instatic prevent circular references when moving nodes?
The moveNode function prevents cycles by utilizing the isAncestor selector from src/core/page-tree/selectors.ts before reparenting. This check traverses upward from the target parent to verify the moved node is not already an ancestor, ensuring the tree remains a directed acyclic graph.
What is the difference between insertNode and pasteSubtree?
While insertNode adds a single existing node to a parent at a specified index, pasteSubtree handles foreign subtrees—such as those from the clipboard—by remapping all node IDs to prevent collisions and optionally filtering class IDs. pasteSubtree is designed for cross-document operations, whereas insertNode manages intra-tree restructuring.
Can these mutation functions be used outside of the Zustand store?
Yes. Although optimized for Zustand's Mutative middleware, all mutation helpers in src/core/page-tree/mutations.ts are pure functions that can operate on any NodeTree instance. Developers can pass a structuredClone of the tree for functional-style mutations, making them suitable for server-side rendering or background workers.
Where are breakpoint-specific property overrides stored in the node tree?
Breakpoint overrides are stored in the breakpointOverrides field of each PageNode. The setBreakpointOverride and clearBreakpointOverride functions in src/core/page-tree/mutations.ts manage these values, allowing responsive prop values to persist within the flat tree structure without affecting the base props object.
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 →