How the Visual Editor Routes Mutations Between Page and Component Trees in Instatic

The visual editor uses a single mutateActiveTree helper that delegates to resolveActiveTreeTarget to determine whether to mutate the active page tree or visual-component tree, centralizing all routing logic in one location.

All tree mutations in the Instatic visual editor flow through a unified pipeline. Whether you're editing a page or a visual component (VC), the same mutation primitives apply—only the target tree changes. This design eliminates duplicate code paths and ensures consistent undo/redo behavior across both document types.

The Core Routing Mechanism: resolveActiveTreeTarget

The routing decision happens in resolveActiveTreeTarget in src/admin/pages/site/store/slices/site/helpers.ts. This function is the only location in the codebase that checks activeDocument.kind === 'visualComponent', a constraint enforced by the architecture test no-vc-mode-branches-in-mutations.test.ts.

// src/admin/pages/site/store/slices/site/helpers.ts
export function resolveActiveTreeTarget(state) {
  const { site, activeDocument } = state;
  if (activeDocument?.kind === 'visualComponent') {
    const vc = site.visualComponents.find(v => v.id === activeDocument.vcId);
    return { tree: vc!.tree as NodeTree<PageNode>, vc };
  }
  const pageId = activeDocument?.kind === 'page' ? activeDocument.pageId : state.activePageId;
  const page = site.pages.find(p => p.id === pageId);
  return page ? { tree: page, vc: null } : null;
}

The function returns both the target tree and, for VCs, the component object itself. For pages, vc is null. This simple branch handles all routing between page and component trees.

Mutation Execution Pipeline

Once the target is resolved, mutateActiveTree(fn) orchestrates the actual mutation:

// src/admin/pages/site/store/slices/site/helpers.ts
function mutateActiveTree(fn, opts) {
  return runHistoricMutation(
    draft => runActiveTreeRecipe(draft, fn),
    opts?.coalesceKey ?? null,
  );
}

The pipeline works in three stages:

  • Tree resolutionrunActiveTreeRecipe calls resolveActiveTreeTarget to get the active tree
  • Mutation execution – The caller-provided fn runs against the resolved tree
  • VC slot synchronization – In VC mode, if slot-outlet names changed, syncAllVCRefSlotInstances propagates updates across all referencing pages

Store Actions: Thin Wrappers Around the Router

The 11 named mutation actions never inspect activeDocument.kind. Each delegates directly to mutateActiveTree in src/admin/pages/site/store/slices/site/nodeActions.ts:

// src/admin/pages/site/store/slices/site/nodeActions.ts
export const insertNode = (node, parentId, index) => {
  const { mutateActiveTree } = helpers;
  mutateActiveTree(tree => insertNode(tree, node, parentId, index));
};

Other actions like deleteNode, moveNode, duplicateNode, and updateNodeProps follow the same pattern. The routing transparency means new mutation types require no additional branching logic.

Undo/Redo and Historic Mutations

The runHistoricMutation wrapper in helpers.ts captures Mutative patches and applies only site-scoped patches to the undo history. This guarantees that a mutation step on either tree type is recorded exactly once, maintaining a consistent undo stack regardless of which document type is active.

Practical Code Examples

Insert a node in the active tree

import { useSiteHelpers } from '@site/store/hooks';

function addParagraph() {
  const { mutateActiveTree } = useSiteHelpers();

  mutateActiveTree(tree => {
    const newNode = createNode({ type: 'paragraph', props: { text: 'Hello' } });
    insertNode(tree, newNode, tree.rootNodeId, 0);
  });
}

Move a node with automatic VC slot propagation

mutateActiveTree(tree => {
  moveNode(tree, nodeId, newParentId, 2);
});

When the active document is a VC, runActiveTreeRecipe detects slot-outlet reordering and invokes syncAllVCRefSlotInstances so every referencing page receives the updated outlet order.

Combined tree and site mutation

For operations needing both tree and site-level changes, use mutateActiveTreeAndSite:

mutateActiveTreeAndSite((tree, site) => {
  const dupResult = duplicateNode(tree, nodeId);
  if (dupResult) {
    addStyleRule(site, dupResult.newClassRule);
  }
  return true;
});

Key Implementation Files

File Purpose
src/admin/pages/site/store/slices/site/helpers.ts Core routing: resolveActiveTreeTarget, mutateActiveTree, historic mutation engine
src/admin/pages/site/store/slices/site/nodeActions.ts 11 thin action wrappers delegating to mutateActiveTree
src/core/page-tree/mutations.ts Tree-agnostic primitives: insertNode, moveNode, deleteNode, etc.
src/core/visualComponents/slotSync.ts Slot-instance synchronization for VC trees
docs/reference/page-tree.md Architectural documentation of mutation routing
docs/editor.md Editor store overview and mutateActiveTree usage

Summary

  • Single routing point: resolveActiveTreeTarget in helpers.ts is the sole location that distinguishes page from VC trees
  • Unified mutation API: mutateActiveTree handles both tree types transparently
  • No action-level branching: All 11 store actions delegate to the same helper without inspecting document kind
  • Automatic VC propagation: Slot-outlet changes in VC mode trigger site-wide synchronization
  • Consistent history: runHistoricMutation ensures undo/redo works identically for pages and components

Frequently Asked Questions

What happens if activeDocument is null?

resolveActiveTreeTarget falls back to state.activePageId and resolves the corresponding page tree. This default behavior ensures the editor always has a valid mutation target.

Why is there an architecture test for VC mode branches?

The test no-vc-mode-branches-in-mutations.test.ts enforces that resolveActiveTreeTarget contains the only kind === 'visualComponent' check. This prevents mutation logic from scattering across multiple files, maintaining the centralized routing design.

How does slot synchronization work in practice?

When a VC mutation reorders slot-outlet definitions, runActiveTreeRecipe compares outlet names before and after the mutation. If they differ, it calls syncAllVCRefSlotInstances from src/core/visualComponents/slotSync.ts to update every page that references the VC, ensuring outlet order consistency across the site.

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 →