Instatic Automatic Detection and Lazy-Loading of Dynamic Content Holes
Instatic automatically detects dynamic content holes by analyzing the page tree for request-dependent data sources, then emits placeholder elements that lazy-load via IntersectionObserver when they enter the viewport.
The CoreBunch/Instatic repository implements a three-layer publishing architecture (A‑C) that separates static site generation from request-time rendering. At the heart of this system lies the automatic detection and lazy-loading of dynamic content holes—subtrees that cannot be fully rendered at publish time because they depend on runtime data such as route.query parameters or per-visitor loop sources. This ensures the initial HTML remains cacheable and lightweight while deferring only the necessary fragments to the browser.
Detecting Dynamic Nodes in the Publishing Pipeline
The single source of truth for dynamic node classification resides in src/core/publisher/dynamicDetection.ts. The exported function findDynamicNodeIds walks the entire page tree and returns a Set<string> of node IDs that must be deferred to request time.
findDynamicNodeIds(page: Page, site: SiteDocument, registry: IModuleRegistry)
This function applies four distinct rules (lines 62‑68) to determine if a node constitutes a dynamic hole.
The Four Detection Rules
Instatic marks a node as dynamic when any of the following conditions are met:
- Module-level dynamic flag — The module definition has
dynamic: true(lines 88‑92). - Request-dependent dynamic bindings — The node contains a
dynamicBindingsentry whose source relies onroute.query.*or other request-time data. - Inline token interpolation — A string prop contains
{source.field}tokens where the source is request-dependent (handled bycheckInlineTokensat lines 22‑33). - Per-request loop sources — The node has a
base.loopwhose source is markedrequestDependentorperVisitor(checked bycheckLoopSourceat lines 43‑52). - Recursive Visual Component inspection — A
base.visual-component-refwhose definition tree contains any dynamic node, making the entire VC reference a hole boundary (guarded by aseenVcsset to prevent cycles).
The detection logic is consumed by two critical layers: Layer A (src/core/publisher/render.ts) uses it to emit placeholders during static generation, while Layer C (server/handlers/cms/hole.ts) uses the same set to render fragments on demand.
Emitting Lazy-Loading Placeholders
During the static generation phase, publishPage invokes renderNode from src/core/publisher/renderNode.ts. When the walker encounters a node ID present in the holeNodeIds set, it emits an <instatic-hole> placeholder instead of recursing into the subtree (lines 202‑314):
<instatic-hole id="hole-{safeId}"
data-instatic-hole="{safeId}"
data-instatic-version="{version}"
style="display:contents">
</instatic-hole>
If the page contains at least one dynamic hole, the system automatically injects the hole-runtime script into the document <head> (see render.ts lines 370‑378).
The Hole Runtime and Server-Side Rendering
The lazy-loading mechanism consists of a lightweight client-side script and a server-side fragment endpoint that cooperate to populate placeholders with actual content.
Client-Side IntersectionObserver
The runtime script, defined as HOLE_RUNTIME_JS in server/publish/holeRuntime.ts, is served at the fixed path /_instatic/hole-runtime.js. The script:
- Queries all
<instatic-hole>elements in the DOM. - Registers an
IntersectionObserveron each element. - Fetches the fragment from
/_instatic/hole/<nodeId>?v=<publishVersion>&u=<pageUrl>when the element enters the viewport.
Server-Side Fragment Rendering
The handleHoleRequest function in server/handlers/cms/hole.ts processes these requests. It renders the requested subtree fully (without further hole emission) using the same publishing pipeline but omitting the dynamicNodeIds check (lines 44‑46). The endpoint also stamps a form token (lines 53‑56) to ensure CMS forms inside holes can submit correctly.
Version validation occurs at lines 84‑96: if the requested v parameter does not match the current publish version, the endpoint returns a stale sentinel:
<instatic-hole-stale data-instatic-stale="true">
This prompts the client to reload the entire page on the next navigation, preventing content drift.
Caching Strategies
Instatic differentiates between shared and personalized content:
- Shared holes — For sources that are not
perVisitor, the rendered fragment is cached in Layer B keyed by(nodeId, page-query, version). Subsequent visitors receive the cached HTML without invoking the rendering pipeline. - Per-visitor holes — When the loop source is marked
perVisitor, the handler bypasses Layer B and setsCache-Control: no-store(lines 101‑106), ensuring each request re-renders the fragment for the specific visitor.
Code Examples
Detecting dynamic nodes in a custom script:
import { findDynamicNodeIds } from '@core/publisher/dynamicDetection';
import { registry } from '@core/module-engine';
async function logDynamicNodes(page, site) {
const dynIds = findDynamicNodeIds(page, site, registry);
console.log('Dynamic node IDs:', [...dynIds]);
}
Manually triggering a hole fetch for testing:
async function fetchHole(nodeId: string, pageUrl: string, version: number) {
const resp = await fetch(
`/_instatic/hole/${encodeURIComponent(nodeId)}?v=${version}&u=${encodeURIComponent(pageUrl)}`
);
const html = await resp.text();
console.log('Hole fragment:', html);
}
Customizing the hole-runtime script with debug logging:
// In a plugin bootstrap file
import { HOLE_RUNTIME_JS } from '../../publish/holeRuntime';
const debugRuntime = HOLE_RUNTIME_JS.replace(
'const observer = new IntersectionObserver(',
'console.log("hole runtime active"); const observer = new IntersectionObserver('
);
export const HOLE_RUNTIME_JS = debugRuntime;
Summary
- Dynamic detection is centralized in
src/core/publisher/dynamicDetection.ts, wherefindDynamicNodeIdsapplies four rules to identify request-dependent subtrees. - Placeholder emission occurs in
src/core/publisher/renderNode.ts, which outputs<instatic-hole>elements for each dynamic node ID. - Lazy-loading is handled by the
HOLE_RUNTIME_JSscript, which usesIntersectionObserverto fetch fragments only when they become visible. - Server-side rendering of holes happens in
server/handlers/cms/hole.ts, with built-in version validation and form-token stamping. - Caching is split between shared fragments (cached in Layer B) and per-visitor content (Cache-Control: no-store).
Frequently Asked Questions
What triggers a node to be marked as dynamic in Instatic?
A node becomes dynamic when it meets any of the four criteria defined in dynamicDetection.ts: the module declares dynamic: true, it contains bindings to route.query or other request-dependent sources, it uses tokenized strings requiring runtime interpolation, or it references a Visual Component whose definition tree contains dynamic nodes. Loop sources marked perVisitor also trigger dynamic classification.
How does the lazy-loading mechanism work for dynamic content holes?
The system emits an <instatic-hole> placeholder during static generation. The browser loads a lightweight runtime script (/_instatic/hole-runtime.js) that registers an IntersectionObserver on these placeholders. When a placeholder enters the viewport, the script fetches the rendered HTML fragment from the /_instatic/hole/<nodeId> endpoint and replaces the placeholder with the actual content.
What happens if a user visits a page with an outdated publish version?
The hole endpoint validates the v (version) query parameter against the current publish version. If they mismatch, the server returns an <instatic-hole-stale> element instead of the fragment. The client-side runtime interprets this as a signal to reload the entire page on the next navigation, ensuring the user receives fresh, consistent content.
How does Instatic handle caching for personalized content?
For holes with perVisitor loop sources, the handleHoleRequest function in server/handlers/cms/hole.ts sets Cache-Control: no-store and bypasses Layer B caching entirely. For all other dynamic holes, the system caches the rendered fragment in Layer B using a key composed of the node ID, page query parameters, and publish version, allowing efficient reuse across visitors.
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 →