How Dynamic Node Detection Auto-Generates Lazy-Loaded Holes in Instatic

Instatic’s publishing pipeline performs a single source-of-truth tree walk using the findDynamicNodesWithReasons function to classify nodes as static or dynamic, then emits <instatic-hole> placeholders for dynamic content that gets hydrated at request-time by the hole runtime.

CoreBunch/Instatic uses a three-layer dynamic node detection system to decide whether a page can be fully rendered at publish-time or requires a "shell" with lazy-loaded fragments. The implementation lives in src/core/publisher/dynamicDetection.ts and is invoked from src/core/publisher/render.ts before the rendering pass begins.

The Single-Walk Architecture

Instatic divides publishing into layers. Layer A determines if a page is fully static or needs holes, while Layer C emits the actual <instatic-hole> tags. Both layers rely on one shared walk that classifies every node as static or dynamic.

The entry point is the findDynamicNodesWithReasons function:

function findDynamicNodesWithReasons(
  page: Page,
  site: SiteDocument,
  registry: IModuleRegistry,
): WalkResult {
  // Returns dynamicPageNodeIds (Set) and reasons (diagnostics)
}

The walk executes two sequential passes to handle special cases without duplicating logic.

The Two-Pass Detection Algorithm

The algorithm ensures that complex nesting scenarios, like static loops containing dynamic children, are handled efficiently.

Pre-Pass: Static Loop Promotion (Rule 3.5)

Before the main classification, the walker identifies static base.loop modules whose bodies contain dynamic nodes. Instead of generating holes for every loop iteration, the entire loop is promoted to a single hole. The inner dynamic node IDs are suppressed so they do not generate extra placeholders.

This optimization lives in the pre-pass logic (lines 18-22 of the detection module) and prevents the explosion of hole counts inside list components.

Main Pass: Node Classification

The main pass traverses the page tree, running the classifyNode predicate on every node that was not suppressed by the pre-pass. It collects IDs that evaluate as dynamic into the dynamicPageNodeIds set used by the renderer.

The classifyNode Predicate and Four Auto-Detection Rules

Both passes delegate to classifyNode, ensuring rule changes affect the entire pipeline consistently:

function classifyNode(
  node: AnalysisNode,
  site: SiteDocument,
  registry: IModuleRegistry,
  seenVcs: ReadonlySet<string>,
): { dynamic: boolean; reason: string | null } {
  // Evaluates rules 1-4 in order
}

The function evaluates four auto-detection rules in order, returning immediately upon the first match:

Rule 1: Explicitly Dynamic Modules If the module definition in the registry has dynamic: true, the node is automatically dynamic. This allows module authors to opt-out of static rendering regardless of props.

Rule 2: Request-Dependent Bindings Nodes containing dynamicBindings whose source is request-dependent (e.g., route.query) are marked dynamic. The helper isBindingSourceRequestDependent (lines 64-71) currently identifies route.query.* as the only request-dependent source, though plugins can extend this.

Rule 2b: Inline Token References String props containing inline tokens like {source.field} are scanned by checkInlineTokens. If the token source is request-dependent, the node becomes dynamic.

Rule 3: Loop Sources base.loop nodes whose loopSourceRegistry is requestDependent or perVisitor trigger dynamic classification via checkLoopSource.

Rule 4: Recursive Visual Component References base.visual-component-ref nodes trigger a recursive depth-first search of the referenced visual component’s definition tree. If any node within that tree is dynamic, the reference itself is dynamic. The seenVcs Set (lines 214-218) guards against infinite cycles by tracking visited visual component IDs; cycles are treated as dynamic to be safe.

Generating Lazy-Loaded Placeholders

During publishing, the renderer first obtains the dynamic ID set at line 97 in src/core/publisher/render.ts:

const dynamicNodeIds = findDynamicNodeIds(page, site, registry);

This set is passed down through RenderConfig to renderNode in src/core/publisher/renderNode.ts. When renderNode encounters a node ID present in the dynamic set, it emits a placeholder instead of recursing:

// src/core/publisher/renderNode.ts (simplified)
if (config.dynamicNodeIds?.has(nodeId)) {
  return `<instatic-hole data-instatic-node="${nodeId}" data-instatic-version="${config.publishVersion}"></instatic-hole>`;
}

The hole runtime script (/_instatic/hole-runtime.js) hydrates these placeholders using an IntersectionObserver to fetch fragments on demand. The script is only injected when at least one hole exists, controlled by buildRuntimeAssetsBlock (lines 73-78).

Practical Examples

Request-Dependent Token in a Component

// src/modules/text/Text.tsx
export default function Text({ content }: { content: string }) {
  // content prop: "Hello {route.query.name}!"
}

checkInlineTokens detects the {route.query.name} token, marks the node dynamic, and publishing emits:

<instatic-hole data-instatic-node="n3" data-instatic-version="2"></instatic-hole>

Static Loop with Dynamic Child

{
  "moduleId": "base.loop",
  "props": { "sourceId": "latestPosts" },
  "children": ["vc1"]
}

If vc1 contains a dynamic binding, the pre-pass promotes the loop node itself to a single hole and suppresses vc1 to avoid redundant holes.

Explicitly Registered Dynamic Module

// src/module-engine/registry.ts
registry.register({
  id: "my.special.widget",
  dynamic: true,
  // ...
});

Any instance of my.special.widget is classified as dynamic by Rule 1.

Why a Single Walk Matters

  • Consistency: Both the loop pre-pass and main pass use classifyNode, ensuring rule changes cannot accidentally affect only one layer.
  • Performance: The walk is linear O(n) over the tree. VC-ref recursion is cycle-guarded by seenVcs to prevent infinite loops.
  • Diagnostics: The reasons array populated during the walk powers the "dynamic node diagnostics" UI, showing authors exactly which prop or binding triggered the hole.

Extending Detection for New Data Sources

To support new request-dependent sources like session.user.id:

  1. Extend isBindingSourceRequestDependent with a new case returning true for the source pattern.
  2. Optionally expose a registration API for plugins to add sources without editing core files.

All callers (findDynamicNodeIds, renderNode, and the loop pre-pass) immediately respect the new rule because they delegate to classifyNode.

Summary

  • Dynamic node detection in CoreBunch/Instatic uses a unified tree walk in src/core/publisher/dynamicDetection.ts to classify nodes before rendering.
  • The classifyNode predicate evaluates four ordered rules: explicit dynamic flags, request-dependent bindings, inline tokens, loop sources, and recursive visual component checks.
  • A pre-pass promotes static loops containing dynamic content to single holes, suppressing inner nodes to optimize placeholder count.
  • Render substitution occurs in src/core/publisher/renderNode.ts, emitting <instatic-hole> tags that the runtime hydrates using IntersectionObserver.
  • The single-walk architecture ensures consistent rule application, linear performance, and rich diagnostics for content authors.

Frequently Asked Questions

What triggers a node to be marked as dynamic in Instatic?

A node becomes dynamic if it meets any of four criteria implemented in classifyNode: the module is explicitly flagged dynamic: true in the registry; it uses a request-dependent binding source like route.query; it contains an inline token referencing request data; or it is a base.loop with a request-dependent source. Visual component references recursively inherit dynamic status if their definition trees contain dynamic nodes.

How does Instatic handle dynamic content inside static loops?

The detection algorithm runs a pre-pass (Rule 3.5) that identifies static base.loop modules containing dynamic children. Instead of generating separate holes for every iteration, the entire loop is promoted to a single dynamic node. The inner node IDs are added to a suppression set so the main pass ignores them, ensuring only one <instatic-hole> is emitted for the entire list.

What happens if a visual component reference creates a circular dependency?

The classifyNode function accepts a seenVcs Set parameter that tracks visited visual component IDs during recursive traversal (lines 214-218). If a cycle is detected, the algorithm treats the node as dynamic to ensure safety. This prevents infinite recursion while guaranteeing that circularly referenced components are always rendered at request-time.

How can I register a custom request-dependent data source?

Extend the isBindingSourceRequestDependent helper in src/core/publisher/dynamicDetection.ts (currently lines 64-71) to return true for your custom source prefix, such as session.user. Because all detection passes delegate to classifyNode, which calls this helper, your new source will automatically trigger dynamic classification across the entire publishing pipeline without modifying rule logic elsewhere.

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 →