Skip to content

fix(engine): don't inherit stale resolve hints on release swap (Fast Preview blank page) - #1232

Merged
guitavano merged 1 commit into
mainfrom
fix/fast-preview-stale-resolve-hints
Aug 28, 2026
Merged

fix(engine): don't inherit stale resolve hints on release swap (Fast Preview blank page)#1232
guitavano merged 1 commit into
mainfrom
fix/fast-preview-stale-resolve-hints

Conversation

@guitavano

@guitavano guitavano commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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 whose sections was a plain array when published but became a website/flags/multivariate flag 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:

// engine/core/resolver.ts
const hints = context.resolveHints[resolveType] ??= traverseAny(resolvableObj) ?? {};

Fast Preview binds a request-scoped draft by swapping the release via resolver.with({ release: draftProvider }) (runtime/mod.ts). But .with copied the base resolver's hints:

new ReleaseResolver(/* ... */, { ...this.resolveHints }, /* ... */)

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's sections is now a multivariate flag, whose variant values live at sections.variants.*.value.* — paths the stale hints don't cover — so nothing under them resolves. @sections resolves in ~0ms to empty and the page renders blank.

This was reproduced against a real deployment: server-timing: <page>@sections;dur=0 on the affected server vs dur=2688 (resolving variants.0.value.*) on a fresh process — identical code, identical draft.

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 existing release.onChange invariant in the constructor, which already clears resolveHints on every release change — .with({ release }) was the one path that bypassed it.

Tradeoffs / blast radius

  • Only the two release-swap call sites are affected: Fast Preview draft (runtime/mod.ts) and block preview (runtime/routes/blockPreview.tsx). Both are exactly the preview scenario this bug breaks.
  • .with calls that pass no release (the app-install/setup path in engine/manifest/manifest.ts, which passes resolvers/resolvables) keep inheriting hints — unchanged.
  • Normal request traffic is unaffected (no release swap).
  • Cost: preview/draft renders recompute hints lazily per block instead of reusing the base's. Negligible — draft renders are no-store/uncached and low-volume, and traverseAny is cheap next to loader fetches.
  • Out of scope: .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 release keep 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.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes
    • Fixed release switching so content resolves using the active release’s structure.
    • Prevented stale resolution information from a previous release affecting draft or preview content.
  • Tests
    • Added coverage verifying correct resolution when switching between published and draft releases.

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>
@github-actions

Copy link
Copy Markdown
Contributor

Tagging Options

Should a new tag be published when this PR is merged?

  • 👍 for Patch 1.209.1 update
  • 🎉 for Minor 1.210.0 update
  • 🚀 for Major 2.0.0 update

@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 14793ec9-59d8-4321-8766-3a4981d8b996

📥 Commits

Reviewing files that changed from the base of the PR and between bf53329 and 7bb7c77.

📒 Files selected for processing (2)
  • engine/core/mod.test.ts
  • engine/core/mod.ts

Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

ReleaseResolver.with now clears cached resolve hints when it receives a different release. A test verifies that a draft release resolves using its own nested content shape instead of stale hints from a published release.

Changes

Release hint reset

Layer / File(s) Summary
Reset hints when changing releases
engine/core/mod.ts, engine/core/mod.test.ts
ReleaseResolver.with clears resolve hints when the release changes and preserves them when it does not. The regression test verifies resolution against the new release shape.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: 🔵 Low · up to 7bb7c

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)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing stale resolve hints during release swaps that caused a Fast Preview blank page.
Docstring Coverage ✅ Passed 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…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

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 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/fast-preview-stale-resolve-hints

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread engine/core/mod.ts
resolvers: { ...this.resolvers, ...resolvers },
},
{ ...this.resolveHints },
releaseChanged ? {} : { ...this.resolveHints },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@guitavano
guitavano merged commit e6e8f51 into main Aug 28, 2026
6 of 8 checks passed
@guitavano
guitavano deleted the fix/fast-preview-stale-resolve-hints branch August 28, 2026 17:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant