Skip to content

feat: add request-level preview context (X-GraphQL-Preview header / extensions.preview)#3969

Open
jasonbahl wants to merge 12 commits into
mainfrom
feat/preview-context-extensions
Open

feat: add request-level preview context (X-GraphQL-Preview header / extensions.preview)#3969
jasonbahl wants to merge 12 commits into
mainfrom
feat/preview-context-extensions

Conversation

@jasonbahl

@jasonbahl jasonbahl commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

What

Adds a request-level preview context as the headless way to preview a post, mirroring the query params WordPress core uses for front-end previews (preview_id, _thumbnail_id, preview_nonce). The primary transport is an X-GraphQL-Preview request header (a headers panel exists in every GraphQL IDE); the same object may instead be sent as a preview entry in the request extensions as a fallback. The header takes precedence when both are present:

X-GraphQL-Preview: database_id=123, featured_image_database_id=456, nonce="45d5b05f1b"

The header value is an RFC 8941 Structured Field dictionary (the HTTP standard for structured header values), with lowercase keys. The extensions.preview fallback is a JSON object with the camelCase equivalents — each transport uses its native structured form.

How it works

When the targeted post is resolved for an authenticated user who can edit it, the previewable fields are overlaid from the current user's autosave (mirroring WordPress core's wp_get_post_autosave) while the node keeps its published identity. id and databaseId stay the published post's, exactly like WordPress core keeps postid-43 while overlaying the content and featured image. Because identity is preserved, the overlay also works for a previewed post that appears inside a connection (e.g. previewing how an edit looks in a list).

Previewing is opt-in per field via field config:

  • isPreviewable => true resolves the field against the revision.
  • previewResolve => callable( $source, $args, $context, $info, $preview ) supplies a request-derived value (used for the featured image, which core passes as a request param and never stores on the revision).

Core marks title, content, and excerpt previewable. Unmarked fields (including identity/structural fields like id, databaseId, slug, uri, status, parent) resolve from the published post, so the default is safe: forgetting to opt a field in shows the current value, never a broken one.

The central overlay runs on the graphql_pre_resolve_field hook with a fast no-op for any request that does not carry preview context.

Authorization and safety

  • Capability based: the request must be authenticated and the viewer must be able to edit the post being previewed.
  • Invalid or unauthorized preview input is treated as if it were never provided, with no error, so it cannot be used to probe for posts a user cannot access.
  • Preview requests are authenticated and therefore not cached.

asPreview (deprecated)

The asPreview argument keeps its legacy whole-node swap for requests that do not carry preview context, and is now marked deprecated in the schema. When both are provided, the preview context wins and asPreview is ignored (with a GRAPHQL_DEBUG notice). The two are separate mechanisms and should not be combined; the docs show how to migrate.

Closes #2876 too

#2876 asks for a way to get the published ("parent") post id when previewing a non-hierarchical post. That problem only exists because the legacy asPreview swap makes databaseId the revision's id. The identity-preserving overlay keeps databaseId as the published post's, so a toolbar/editor can identify the post directly. Tested in testPreviewExtensionExposesPublishedIdForNonHierarchicalPost, which also contrasts the legacy swap.

Also closes #3260

Revisioned post meta (keys registered with revisions_enabled, or added via the wp_post_revision_meta_keys filter, such as core's footnotes) now resolves from the revision's own value instead of the parent, mirroring core's _wp_preview_meta_filter. It lives in the same Utils/Preview class this PR already touches and composes with the field overlay (a previewable meta-backed field resolved against the revision flows through this filter). It applies to both the preview flow and revisions queried directly via the revisions connection, and the graphql_resolve_revision_meta_from_parent opt-out is still respected. Tested in testRevisionedMetaResolvesFromRevisionInPreview and testRevisionedMetaResolvesFromRevisionInRevisionsConnection.

Tests

PreviewTest covers: identity preserved, content overlay, featured-image overlay, the header transport and header-over-extensions.preview precedence, overlay-from-autosave (not the latest revision), asPreview precedence/deprecation, connection-node overlay, unauthenticated/invalid-id no-op, the #2876 published-id case, and the #3260 revisioned-meta cases. The legacy asPreview tests stay green. Full WPUnit suite green (1081 tests, 6899 assertions); PHPStan level 8 and PHPCS clean. New docs page at docs/previews.md.

Future / RFC notes

This is the first concrete use of request-level context (header-first, with extensions.preview as a body-level fallback), and informs a broader RFC:

  • Header-first, extension-fallback looks like a reusable pattern for other request-level context (locale, A/B variant, and so on), not just preview, since a headers panel exists in every GraphQL IDE.
  • A request-context registry could let WPGraphQL declare the context signals it accepts (e.g. X-GraphQL-Preview) and their value shapes, so the WPGraphQL IDE's headers panel can progressively enhance into validation/typeahead, while generic IDEs still work with a plain header.
  • Connection previews are delivered here. The identity-preserving, per-field overlay (rather than a node swap or a Loader/Model change) is what makes that possible without breaking cursors.

In action

Edit a post: update the title, content, and featured image, but do not save or publish. Then click Preview. WordPress generates the preview link with preview_id, preview_nonce, and _thumbnail_id query params, which we map into the X-GraphQL-Preview header.

CleanShot 2026-06-19 at 22 28 59@2x

As a public request (header present, but unauthenticated), we get the published content. The unsaved changes are not exposed, and no error is returned:

CleanShot 2026-06-19 at 22 52 28@2x

As an authenticated editor sending the same X-GraphQL-Preview header, we get the unsaved title, content, and featured image, while databaseId stays the published post's (43):

CleanShot 2026-06-19 at 22 52 17@2x

Because identity is preserved, this also works when the previewed post appears inside a connection (the homepage, a blogroll, and so on), not just when queried directly.

Public:

CleanShot 2026-06-19 at 22 50 38@2x

Authenticated:

CleanShot 2026-06-19 at 22 50 31@2x

Introduce a `preview` envelope in the request `extensions` as the headless way to
preview a post, mirroring the query params WordPress core uses for front-end
previews (preview_id, _thumbnail_id, preview_nonce):

    "extensions": { "preview": { "id": 123, "thumbnailId": 456, "nonce": "..." } }

When the targeted post is resolved for an authenticated user who can edit it, the
previewable fields are overlaid from the post's latest revision while the node's
published identity is preserved (id and databaseId stay the published post's, just
like WordPress core keeps `postid-43` while overlaying content and the featured
image). Because identity is preserved, the overlay also works for a previewed post
appearing inside a connection.

Previewing is opt-in per field via field config: `isPreviewable => true` resolves a
field against the revision, and `previewResolve => callable` supplies a
request-derived value (used for the featured image, which core passes as a request
param and never stores on the revision). Core marks title, content, and excerpt
previewable. Unmarked fields resolve from the published post, so the default is
safe.

Authorization is capability based (the request must be authenticated and the viewer
must be able to edit the post). Invalid or unauthorized preview input is treated as
if it were never provided, with no error, so it cannot be used to probe for
inaccessible content. Preview requests are authenticated and therefore not cached.

The deprecated `asPreview` argument keeps its legacy whole-node swap for requests
that do not carry a `preview` extension, and is now marked deprecated in the schema.
When both are provided, the extension wins and `asPreview` is ignored, with a debug
notice.

Closes #2664
Closes #2876
@vercel

vercel Bot commented Jun 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
wpgraphql-com Skipped Skipped Jun 20, 2026 5:05am

@codecov

codecov Bot commented Jun 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.81119% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.5%. Comparing base (f51aaa9) to head (4c8b61b).

Files with missing lines Patch % Lines
plugins/wp-graphql/src/WPGraphQL.php 0.0% 7 Missing ⚠️
plugins/wp-graphql/src/Request.php 92.6% 4 Missing ⚠️
...ugins/wp-graphql/src/Type/ObjectType/RootQuery.php 85.0% 3 Missing ⚠️
plugins/wp-graphql/src/Utils/Preview.php 92.6% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff            @@
##              main   #3969     +/-   ##
=========================================
- Coverage     83.6%   83.5%   -0.0%     
- Complexity    5330    5386     +56     
=========================================
  Files          286     286             
  Lines        22866   22989    +123     
=========================================
+ Hits         19106   19207    +101     
- Misses        3760    3782     +22     
Flag Coverage Δ
wp-graphql-acf-wpunit-twentytwentyfive-single 77.5% <ø> (ø)
wp-graphql-wpunit-twentytwentyfive-single 84.6% <88.8%> (-<0.1%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
plugins/wp-graphql/src/AppContext.php 71.2% <ø> (ø)
plugins/wp-graphql/src/Router.php 51.7% <100.0%> (+0.3%) ⬆️
...l/src/Type/InterfaceType/NodeWithContentEditor.php 100.0% <100.0%> (ø)
...graphql/src/Type/InterfaceType/NodeWithExcerpt.php 100.0% <100.0%> (ø)
...l/src/Type/InterfaceType/NodeWithFeaturedImage.php 100.0% <100.0%> (ø)
...p-graphql/src/Type/InterfaceType/NodeWithTitle.php 100.0% <100.0%> (ø)
plugins/wp-graphql/src/Utils/Preview.php 95.2% <92.6%> (-4.8%) ⬇️
...ugins/wp-graphql/src/Type/ObjectType/RootQuery.php 95.9% <85.0%> (-0.3%) ⬇️
plugins/wp-graphql/src/Request.php 64.7% <92.6%> (+7.0%) ⬆️
plugins/wp-graphql/src/WPGraphQL.php 56.7% <0.0%> (-1.3%) ⬇️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…evisions

WPGraphQL resolved all revision meta from the parent post, so meta keys that
WordPress revisions (registered with `revisions_enabled`, or added via the
`wp_post_revision_meta_keys` filter, such as core's `footnotes`) lost their
revision-specific value. Consult `wp_post_revision_meta_keys()` and resolve those
keys from the revision's own value, mirroring core's `_wp_preview_meta_filter`.

This applies both to the preview flow and to revisions queried directly through the
`revisions` connection. The `graphql_resolve_revision_meta_from_parent` filter is
still respected, and non-revisioned keys continue to resolve from the parent.

Closes #3260
The preview overlay resolved from the latest revision by any author, which diverges
from how WordPress core previews a post. Core's _set_preview() reads the current
user's autosave (wp_get_post_autosave), the per-user `{id}-autosave-v1` revision that
holds the in-progress unsaved edits the Preview button shows. The latest revision can
instead be a saved snapshot, or another editor's draft.

Resolve the overlay source via wp_get_post_autosave( databaseId, current_user ) so a
headless preview reproduces the native click-Preview experience: the client passes the
post id (preview_id) and the server resolves the authenticated user's autosave. When no
autosave exists (e.g. a draft saved directly) nothing is overlaid.

Adds a regression test asserting a newer regular revision is not used as the preview
source, and documents the end-to-end preview flow.
…llback

The `extensions.preview` object remains the primary source for request-level preview
context. Add an `X-GraphQL-Preview` request header (the same JSON object, encoded) as a
fallback for clients or intermediaries (gateways, CDNs, persisted-query middleware) where
request `extensions` may not survive to the server. `extensions.preview` takes precedence
when both are present.

The header is added to Access-Control-Allow-Headers so cross-origin headless clients can
send it. Regression tests cover the header path and the extensions-over-header precedence.
…ransport

The X-GraphQL-Preview request header is now the primary, documented way to supply
preview context, with extensions.preview as the fallback. A headers panel exists in every
GraphQL IDE, so the header is the most broadly usable transport; the extensions object
keeps the context inside the operation body for clients that prefer that. The header takes
precedence when both are present.

Flips the resolution precedence (header first, then extensions.preview), updates the
precedence regression test, the debug notices, and the docs.
@jasonbahl jasonbahl changed the title feat: add request-level preview context via extensions.preview feat: add request-level preview context (X-GraphQL-Preview header / extensions.preview) Jun 20, 2026
…tionary

The X-GraphQL-Preview header now uses the HTTP standard for structured header values
(RFC 8941 structured fields) instead of stringified JSON, which is not idiomatic for an
HTTP header. The value is a dictionary of comma-separated key=value members with lowercase
keys (integers bare, strings double-quoted):

    X-GraphQL-Preview: database_id=43, featured_image_database_id=47, nonce="abc"

The keys are lowercase snake_case per the structured-fields grammar, and are mapped to the
JSON extensions.preview object's camelCase keys so both transports normalize identically.
Each transport uses its native structured form: structured fields in the header, a JSON
object in extensions. The header still takes precedence when both are present.
Revisioned post meta relies on the meta revisions framework added in WordPress 6.4
(`revisions_enabled` and `wp_post_revision_meta_keys()`). The two #3260 tests register
meta with `revisions_enabled` and assert the revision's own value is returned, which only
holds on 6.4+. Skip them on older versions (the CI matrix runs WP 6.1+).

The preview overlay code already degrades gracefully on older WordPress via a
`function_exists( 'wp_post_revision_meta_keys' )` guard, and the rest of the preview
feature (content/title/excerpt overlay, featured image, identity preservation) works on
all supported versions. Document the 6.4+ requirement for revisioned meta in the previews
doc.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant