fix(engine): don't inherit stale resolve hints on release swap (Fast Preview blank page) - #1232
Conversation
Resolve hints are cached by resolveType (block id) and derived from the
release's resolvables (`context.resolveHints[resolveType] ??= traverseAny(...)`
in engine/core/resolver.ts). Fast Preview binds a request-scoped draft by
swapping the release via `resolver.with({ release: draftProvider })`, but
`.with` copied the base resolver's hints (`{ ...this.resolveHints }`).
On a long-running server that serves the published release, the hint cache for
a block gets populated with the published shape. When the draft is then
resolved, it reuses those stale hints — so a block whose shape changed between
releases (e.g. a page whose `sections` was a plain array when published but a
`website/flags/multivariate` flag in the draft) resolves against the old shape
and silently drops everything the old hints don't cover. Symptom: the draft
renders a blank page (only globally-injected sections survive; the whole page
body, footer included, is gone), while a fresh process that saw the draft first
renders correctly.
Fix: when `.with` swaps the release, start from empty hints instead of
inheriting them. Hints belong to a release's resolvables, so a different
release invalidates them — this mirrors the `release.onChange` invariant in the
constructor, which already clears hints on every release change. Only the two
preview/release-swap call sites are affected (Fast Preview draft, block
preview); normal traffic and the setup path (which pass no `release`) are
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tagging OptionsShould a new tag be published when this PR is merged?
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review. 📝 WalkthroughWalkthrough
ChangesRelease hint reset
Estimated code review effort: 2 (Simple) | ~10 minutes Merge Risk: 🔵 Low · up to The change fixes blank Fast Preview pages caused by stale release-specific resolution hints, but another release-derived cache is still carried across release swaps and could cause some draft content to reuse results from the published release. The PR is mergeable with explicit owner awareness and follow-up to invalidate that cache consistently. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Full details: Docstring CoverageExplanation No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2 files. ✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
1 issue found across 2 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="engine/core/mod.ts">
<violation number="1" location="engine/core/mod.ts:134">
P2: The fix clears `resolveHints` on release swap but deliberately leaves `runOncePerRelease` inherited, even though the `release.onChange` invariant this comment claims to mirror clears both. `runOnce` entries like `resolveTypeSelector_*`/`blockSelector` in `engine/manifest/defaults.ts` are built from the *previous* release's `resolvables` and stay cached via the shared `SyncOnce` instances, so a draft bound with `.with({ release })` can still resolve against stale published selectors — the same class of bug this PR addresses. Clear `runOncePerRelease` when `releaseChanged` too.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| resolvers: { ...this.resolvers, ...resolvers }, | ||
| }, | ||
| { ...this.resolveHints }, | ||
| releaseChanged ? {} : { ...this.resolveHints }, |
There was a problem hiding this comment.
P2: The fix clears resolveHints on release swap but deliberately leaves runOncePerRelease inherited, even though the release.onChange invariant this comment claims to mirror clears both. runOnce entries like resolveTypeSelector_*/blockSelector in engine/manifest/defaults.ts are built from the previous release's resolvables and stay cached via the shared SyncOnce instances, so a draft bound with .with({ release }) can still resolve against stale published selectors — the same class of bug this PR addresses. Clear runOncePerRelease when releaseChanged too.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At engine/core/mod.ts, line 134:
<comment>The fix clears `resolveHints` on release swap but deliberately leaves `runOncePerRelease` inherited, even though the `release.onChange` invariant this comment claims to mirror clears both. `runOnce` entries like `resolveTypeSelector_*`/`blockSelector` in `engine/manifest/defaults.ts` are built from the *previous* release's `resolvables` and stay cached via the shared `SyncOnce` instances, so a draft bound with `.with({ release })` can still resolve against stale published selectors — the same class of bug this PR addresses. Clear `runOncePerRelease` when `releaseChanged` too.</comment>
<file context>
@@ -113,19 +113,30 @@ export class ReleaseResolver<TContext extends BaseContext = BaseContext> {
resolvers: { ...this.resolvers, ...resolvers },
},
- { ...this.resolveHints },
+ releaseChanged ? {} : { ...this.resolveHints },
{
...this.runOncePerRelease,
</file context>
Problem
Fast Preview (
?__draft=) renders a blank page on a production server for pages whose block shape changed between the published release and the draft — e.g. a page whosesectionswas a plain array when published but became awebsite/flags/multivariateflag in the draft. The whole page body disappears (header → content → footer); only globally-injected sections survive. It fails fast (a resolution no-op, not a timeout), and only on long-running servers — a fresh process that resolves the draft first renders it fine.Root cause
Resolve hints are cached by
resolveType(block id) and derived from the release's resolvables:Fast Preview binds a request-scoped draft by swapping the release via
resolver.with({ release: draftProvider })(runtime/mod.ts). But.withcopied the base resolver's hints:A server that serves the published release populates
resolveHints["<page>"]with the published shape (sections.0,sections.1, …). When the draft is then resolved, it reuses those stale hints. The draft'ssectionsis now amultivariateflag, whose variant values live atsections.variants.*.value.*— paths the stale hints don't cover — so nothing under them resolves.@sectionsresolves in ~0ms to empty and the page renders blank.This was reproduced against a real deployment:
server-timing: <page>@sections;dur=0on the affected server vsdur=2688(resolvingvariants.0.value.*) on a fresh process — identical code, identical draft.Fix
When
.withswaps the release, start from empty hints instead of inheriting them. Hints belong to a release's resolvables, so a different release invalidates them. This mirrors the existingrelease.onChangeinvariant in the constructor, which already clearsresolveHintson every release change —.with({ release })was the one path that bypassed it.Tradeoffs / blast radius
runtime/mod.ts) and block preview (runtime/routes/blockPreview.tsx). Both are exactly the preview scenario this bug breaks..withcalls that pass norelease(the app-install/setup path inengine/manifest/manifest.ts, which passesresolvers/resolvables) keep inheriting hints — unchanged.no-store/uncached and low-volume, andtraverseAnyis cheap next to loader fetches..with({ resolvables })that overrides a block's content without swapping the release would still inherit hints for that block. Not hit by the preview paths; left for a follow-up if needed.Test
engine/core/mod.test.ts→.with({ release }) does not inherit stale resolve hints: resolves a block against a "published" release (plain content), then.with({ release: draft })where the same block id has a nested resolvable. Fails without the fix (nested value left unresolved), passes with it. Full engine suite green.🤖 Generated with Claude Code
Summary by cubic
Fixes Fast Preview (
?__draft=) rendering blank pages on production servers when a block's shape changed between the published release and the draft.resolver.with({ release })now starts from empty resolve hints instead of inheriting the base resolver's, which were derived from the published release's resolvables.Only the release-swap paths (Fast Preview draft and block preview) are affected; normal traffic and the setup path that pass no
releasekeep inheriting hints unchanged. Draft and preview renders now recompute hints lazily per block, a negligible cost for low-volume, uncached renders.Written for commit 7bb7c77. Summary will update on new commits.
Summary by CodeRabbit