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

> Learn how Instatic's dynamic node detection auto-generates lazy-loaded holes for efficient content hydration at request-time. Understand the publishing pipeline.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: deep-dive
- Published: 2026-07-30

---

**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`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/dynamicDetection.ts) and is invoked from [`src/core/publisher/render.ts`](https://github.com/CoreBunch/Instatic/blob/main/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:

```typescript
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:

```typescript
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`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/render.ts):

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

```

This set is passed down through `RenderConfig` to `renderNode` in [`src/core/publisher/renderNode.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/publisher/renderNode.ts). When `renderNode` encounters a node ID present in the dynamic set, it emits a placeholder instead of recursing:

```typescript
// 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`](https://github.com/CoreBunch/Instatic/blob/main//_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**

```tsx
// 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:

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

```

**Static Loop with Dynamic Child**

```json
{
  "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**

```typescript
// 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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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.