diff --git a/engine/core/mod.test.ts b/engine/core/mod.test.ts index d48422ef9..c4c75712d 100644 --- a/engine/core/mod.test.ts +++ b/engine/core/mod.test.ts @@ -6,9 +6,50 @@ import { resolve, type ResolverMap, } from "../../engine/core/resolver.ts"; +import { ReleaseResolver } from "../../engine/core/mod.ts"; +import { fromJSON } from "../../engine/decofile/fetcher.ts"; import defaults from "../manifest/fresh.ts"; import { RequestContext } from "../../deco.ts"; +Deno.test(".with({ release }) does not inherit stale resolve hints", async () => { + // Hints are cached by resolveType (block id) and derived from the release's + // resolvables. Fast Preview binds a request-scoped draft via + // `resolver.with({ release: draftProvider })`. If `.with` inherits the base + // resolver's hints, a block whose SHAPE changed between the published release + // and the draft (same id, different content) resolves against the stale shape + // and silently drops everything the old hints don't cover — the exact failure + // that blanked a page whose `sections` went from a plain array (published) to + // a `multivariate` flag (draft). + const resolvers = { + passthrough: (props: unknown) => props, + } as unknown as ResolverMap; + + // Published: block "page" has a plain `content` (no nested resolvable). + const published = fromJSON({ + page: { __resolveType: "passthrough", content: "plain" }, + }); + // Draft: SAME id "page", but `content` is now a nested resolvable — the draft + // needs a hint at `content` that the published shape never produced. + const draft = fromJSON({ + page: { __resolveType: "passthrough", content: { __resolveType: "inner" } }, + inner: { __resolveType: "passthrough", value: "from-draft" }, + }); + + const base = new ReleaseResolver({ release: published, resolvers }); + + // Resolve against the published release first — this populates (poisons) the + // base resolver's hint cache for "page" with the plain-content shape. + assertEquals(await base.resolve<{ content: unknown }>("page", {}), { + content: "plain", + }); + + // Swap the release for the draft, as Fast Preview does. + const drafted = base.with({ release: draft }); + assertEquals(await drafted.resolve<{ content: unknown }>("page", {}), { + content: { value: "from-draft" }, + }); +}); + Deno.test("resolve", async (t) => { const context: BaseContext = { revision: "", diff --git a/engine/core/mod.ts b/engine/core/mod.ts index 64d9c596d..6fe3d6b85 100644 --- a/engine/core/mod.ts +++ b/engine/core/mod.ts @@ -113,19 +113,30 @@ export class ReleaseResolver { { resolvers, resolvables, release, danglingRecover }: ExtensionOptions< TContext >, - ): ReleaseResolver => - new ReleaseResolver( + ): ReleaseResolver => { + // Hints are derived from the release's resolvables and cached by resolveType + // (block id). When the release is swapped — as Fast Preview does to bind a + // request-scoped draft (`resolver.with({ release: draftProvider })`) — the + // base hints are stale: the SAME block id can now resolve to different + // content (e.g. a page whose `sections` was a plain array in the published + // release but a `multivariate` flag in the draft). Inheriting them makes the + // draft resolve against the published shape and silently drop everything the + // old hints don't cover. This mirrors the `release.onChange` invariant in the + // constructor, which already clears hints whenever the release changes. + const releaseChanged = release !== undefined && release !== this.release; + return new ReleaseResolver( { release: release ?? this.release, danglingRecover: danglingRecover ?? this.danglingRecover, resolvables: { ...this.resolvables, ...resolvables }, resolvers: { ...this.resolvers, ...resolvers }, }, - { ...this.resolveHints }, + releaseChanged ? {} : { ...this.resolveHints }, { ...this.runOncePerRelease, }, ); + }; public getResolvers(): ResolverMap { return this._cachedResolvers ??= {