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 resolution –
runActiveTreeRecipecallsresolveActiveTreeTargetto get the active tree - Mutation execution – The caller-provided
fnruns against the resolved tree - VC slot synchronization – In VC mode, if slot-outlet names changed,
syncAllVCRefSlotInstancespropagates 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:
resolveActiveTreeTargetinhelpers.tsis the sole location that distinguishes page from VC trees - Unified mutation API:
mutateActiveTreehandles 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:
runHistoricMutationensures 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →