The draft preview that showed you the published page
An editor opens a page in Drupal, clicks Preview, and gets back the version already live on the site. Not an error. Not a blank screen. Not a redirect loop. Just the wrong content, rendered perfectly.
This is the worst shape a bug can take. There is nothing to grep for. The logs are clean, the HTTP status is 200, and the only person who can detect the failure is an editor who remembers what they typed thirty seconds ago and notices it is missing.
Every layer said yes
Decoupled preview in Drupal and Next.js is a chain, and ours was almost entirely correct. The next module minted a signed preview URL. The iframe loaded. Our /api/draft route validated the signature server-side and called draftMode().enable(). The browser got the draft cookie. /preview/<slug> rendered through the normal components. The draft banner even appeared at the top of the page, announcing that you were looking at a draft.
You were not looking at a draft.
The line that read a flag and ignored it
Here is the preview route as it stood, reduced to the part that matters:
const draft = await draftMode()
const node = await getNodeByPath(slug, locale)
if (!node) {
notFound()
}
// Outside draft mode, show published content only.
if (!draft.isEnabled && node.status === false) {
notFound()
}
Read it closely. draft.isEnabled is used — on the second-to-last line. It decides whether an unpublished node should 404. What it never does is change what gets fetched. There is exactly one loader, getNodeByPath, and it runs whether you are previewing or not.
This is why the bug survived review, TypeScript, and a linter. An unused variable gets flagged. A variable used for the wrong purpose looks like working code. The route consulted draft mode for a permission check and then loaded the same data it always loads.
Why the underlying query could never work
The loader asked for the node through GraphQL's route field:
query NodeByPath($path: String!) {
route(path: $path) { ... }
}
route resolves a path alias to its entity and returns the default revision. That is the correct and desirable behavior for a public site — it is what makes the front page show published content. But in Drupal, a draft of an already-published node is not the default revision. It is a forward revision: newer, non-default, deliberately not what anonymous traffic should see.
So the query was working exactly as designed. It returned the default revision, which for any published node is the published copy. The preview machinery was complete except for the one capability it existed to provide: the ability to fetch a draft at all.
The failure mode differed by node state, which muddied the diagnosis:
- Published node with a draft — preview renders the published version. Looks fine. Is wrong.
- Never-published node — the default revision is unpublished, the public client's role cannot see it, the query returns null, and the route 404s.
One bug, two symptoms, neither of them an error message.
The fix: a resolver that loads a revision, and access checked on that revision
We added a GraphQL field, wlDraftPreview(path:, resourceVersion:), backed by a data producer that does what route deliberately does not:
- Resolve the alias to an internal
/node/Npath. - Load the latest revision — preferring the latest translation-affected revision, so a Spanish draft does not silently resolve to the English working copy.
- Honor an explicit
id:<vid>when Drupal asks for a specific historical revision, which is what thenextmodule sends when an editor previews an old revision rather than the working copy. - Check
access('view')on the loaded revision object, not on the node.
That last point is the load-bearing one. Access is evaluated against the thing you actually intend to render. An unpublished draft passes only if the caller holds content_moderation's view any unpublished content permission. There is no separate "is this allowed" branch to keep in sync with the loading logic — the object being returned is the object being authorized.
The part that is still designed to be quiet
The new loader falls back to the published node when the preview client is not configured. That is deliberate: a build container without preview credentials should render the site, not crash. But it means the failure signature never changed. If preview breaks again, it will again look like a page that works.
So the acceptance test has to be written to catch a success, not an error. Ours is one sentence: open a draft of an already-published node and confirm you see your unpublished edits. Not "confirm preview loads." Not "confirm no errors." Those both pass while broken. The only question worth asking is whether the bytes on screen are the ones that are not live yet.
The general lesson is cheap to state and expensive to learn. When a system's degraded mode is "serve something reasonable," the absence of an error tells you nothing at all. Test for the thing that is only true when it works.