Layer 1 — predict, from source
crust walks the route’s server component tree, finds<Suspense> boundaries, marks components that
touch a dynamic API or an uncached read, and propagates that up to the nearest enclosing boundary.
This is the only layer that can name a cause:
next.config:
A
<Suspense> boundary is not automatically a hole. If everything under it is cached, the
build prerenders straight through the boundary. Treating every boundary as a hole would report a
regression on every route that uses Suspense correctly.Layer 2 — verify, against the build output
The prediction is checked against the shell HTML the build actually wrote to.next/server/app/**. React’s markers make this plain-text work:
<!--$?--> marks a boundary still showing its fallback, and the fallback sits exactly where its
hole is. Boundary ids are assigned in document order.
A route built with generateStaticParams emits both products/[slug].html — the unparameterized
fallback, which is zero bytes — and products/alpha.html, the real shell. crust diffs against a
parameterized shell; diffing the empty one would report that the whole shell vanished on every
build.
Layer 3 — observe, at runtime
The runtime collector records when each hole actually filled, by watching for React’s inline completion scripts:B:0 ids are the same ones layer 2 read out of the prerendered shell, so the join needs no
source access at all.
These completions are not in the
__next_f flight payload — that carries the RSC data. A
collector that watches only __next_f finds the chunks and never the swaps.