Problem/Motivation

When I edit a Page Layout pinned to one concrete page,
I want the Preview panel to show that page's real content,
so I can judge my layout against what a visitor will actually see.

Acceptance:
given a created "homepage" layout, pinned to <front>,
when I open /admin/structure/page-layout/homepage/builder and toggle Preview,
then the main content region shows the front page's real content, not [Placeholder] Page content.

Editing homepage (display_builder_page_layout.page_layout.homepage.yml, request_path: '<front>'), the Preview panel renders every authored source for real, then drops a hatched marker where the page's own content belongs:

<div class="db-background db-preview-placeholder db-preview-placeholder-lg">
  [Placeholder] Page content
</div>

A page layout is mostly chrome around that content. Judging the header, the spacing and the grid against a fixed-height grey box is judging the wrong thing: the layout looks balanced against a 20rem marker and wrong against eight paragraphs and a hero image.

Why it happens

MainPageContentSource::getPropValue() returns [] — the source is a marker that the real page pipeline substitutes, not a renderable. On a real request PageLayoutPageVariant::replaceTitleAndContent() swaps it for the page variant's $this->mainContent. Nothing does that in a preview, so PreviewPanel::alterPreviewPlaceholder() marks it unresolvable, along with page_title, local_tasks_block and the Views sources.

This is a deliberate consequence of the single-endpoint work in #3542003: Responsive viewports with iframes. Before it, PageLayout::getLivePreviewUrl() returned the pinned path with ?display_builder_preview=_instance_id_, and PageLayoutPageVariant::getPreviewSources() served the draft state from that real page — so the substitution happened for free. That path was removed with ~400 other lines (8fc5255d, 3021c64a, 90bf775c, squashed into 2a655ad2).

This issue is not a request to revert #3542003: Responsive viewports with iframes. The removed mechanism put a query argument on the layout's own public page, so / gained a second rendering mode that served an editor's unsaved state. That was ruled out for page layouts deliberately, because a mistake in the cache contexts serves a cached draft to an anonymous visitor on the site's busiest page. That reasoning is sound and what follows does not touch the public route: the draft never leaves the admin preview request.

Scope

Page layouts pinned to exactly one concrete, non-wildcard, non-negated request_path condition. Every other layout has no unambiguous page to preview against and keeps today's placeholder. That is the same guard the removed PageLayout::getSinglePreviewPath() applied, and it was restored verbatim — it was already correct.

Proposed resolution

Keep display_builder.preview_island as the only preview URL. Resolve the real main content server side, inside that admin route, instead of pointing the iframe at a public URL.

  • PageLayout gains getPreviewPagePath(): ?string, the pinned-path derivation restored from 3021c64a. It does not regain getLivePreviewUrl(), and DisplayBuildableInterface does not regain LIVE_PREVIEW_QUERY. The base class returns NULL, so the other three buildables are untouched.
  • ApiPreviewController::getDisplayPreview(), for an instance whose buildable pins a path, issues an internal sub-request to that path (HttpKernelInterface::SUB_REQUEST) carrying a request attribute naming the instance — never a query argument, so the public URL is untouched and no shared cache entry can key on it. The attribute is DisplayBuildableInterface::PREVIEW_INSTANCE_ATTRIBUTE.
  • PageLayoutPageVariant reads that attribute and, when it names this layout's instance, serves the instance's draft state with max-age: 0. The attribute crosses a kernel boundary, so the draft is re-authorized there with $instance->('view') rather than trusting whoever set it. Because the attribute cannot appear on a real request, the url.query_args: cache context the old code needed is not needed here.
  • LivePreviewChrome::preprocessHtml() keys on the main request's route (RequestStack::getMainRequest()), not the current one, so chrome stripping survives the sub-request.
  • A RequestPolicyInterface tagged dynamic_page_cache_request_policy denies any request carrying the attribute — see "what the implementation turned up" below.

Result: the preview shows the pinned page rendered by the real pipeline — real main content, real title, real breadcrumb — and the public route behaves exactly as it does today for every visitor.

What the implementation turned up

Two things that were not in the plan, both worth knowing before reviewing the patch.

  • Dynamic Page Cache applies to sub-requests. DynamicPageCacheSubscriber::onRequest() has no main-request guard, so the sub-request was served the ordinary visitor's cached render of the pinned page: a preview that silently ignored every unsaved edit while looking entirely convincing. Fixed with DenyPreviewSubRequest, a request policy denying anything that carries the preview attribute. It also stops the draft render being written to a cache a visitor could later read. Internal Page Cache needs no policy — PageCache::handle() short-circuits on anything that is not the main request, so it never sees this request at all.
  • Services that memoize per-request state answer the sub-request with the outer request's answer. PathMatcher::isFrontPage() caches in a property with no reset, so anything that calls it before the controller runs fixes the answer for the sub-request too — contrib redirect does, on kernel.request. Consequence: a previewed front page gets path-node on the body instead of path-frontpage. Accepted rather than fixed: the alternative is decorating a core service site-wide to expose a reset, which is a lot of surface for one body class. Recorded in renderOnPinnedPage()'s docblock.

Recursion

Re-entrancy is guarded by the attribute itself, not by a list of routes: if the current request already carries it, the controller falls back to rendering the sources. A layout pinned to a builder screen therefore nests one level and stops, and one pinned to the preview route cannot sub-request itself forever. An earlier draft read the _display_builder_full_page_route option instead — that option exists for page variant selection, and leaning on it was borrowing an unrelated flag.

Alternative, if the sub-request proves too costly

Revert-shaped: reinstate getLivePreviewUrl() and the query-argument draft injection for page layouts, as it existed before 3021c64a (~80 lines, already written and shipped once). Cheap, proven, but against the decision above. Needs a maintainer decision to overrule it, not a silent reintroduction.

Risks and open questions

  • BigPipe. Checked, no problem observed: the rendered preview carries only the big_pipe/no-js bootstrap link, no unresolved placeholders, with core Navigation enabled. The sub-request's response is returned unchanged, so the outer kernel.response listeners run over it a second time; attachments processing is effectively idempotent, BigPipe's is not guarded the same way. Worth re-checking if a module adds no-JS BigPipe placeholders to the previewed page.
  • Return type. getDisplayPreview() now returns array|Response. Both are legal for a controller, but the two branches produce genuinely different documents: one bare, one a full themed page. That is the intent, and it means the preview iframe's contents differ structurally by buildable.
  • Cost per refresh. The preview refreshes on every edit (PreviewPanel::reloadWithGlobalData()). A full page render per refresh is much heavier than rendering the sources, and the cache policy means it can never be served from Dynamic Page Cache. Not measured yet; the double buffer in js/live_preview.js hides latency but does not remove it.
  • Two layouts matching one path. The variant picks by weight, so a preview can select a layout other than the one being edited. The attribute then does not match its instance and the saved display renders — safe, but confusing. Not handled.
  • page_title. The same substitution applies, and comes for free with this approach. local_tasks_block and the Views sources keep their placeholders.
  • The path-frontpage gap above is environment-dependent: it needs a module that calls isFrontPage() during the outer request. On a site without one, the previewed front page gets the right class.

Remaining tasks

  • DONE — sub-request inside the admin route, rather than the revert-shaped alternative.
  • DONE — getPreviewPagePath() on PageLayout, base class returning NULL.
  • DONE — sub-request wiring in ApiPreviewController, plus the re-entrancy guard.
  • DONE — draft-state branch in PageLayoutPageVariant, access-checked, max-age: 0.
  • DONE — LivePreviewChrome keyed on the main request.
  • DONE — Dynamic Page Cache denied for the preview sub-request.
  • DONE — kernel coverage for the path derivation, 9 cases: , one rooted path, blank-line padding, negated, wildcard, several paths, no leading slash, empty page list, no condition at all.
  • DONE — functional coverage: the pinned layout previews its real page with no placeholder; the draft reaches the preview and neither the authenticated nor the anonymous view of that page; an unpinned layout keeps the placeholder. Verified fail-first — stubbing getPreviewPagePath() to NULL fails two of the three.
  • DONE — BigPipe check with core Navigation enabled.
  • DONE — decision on the path.matcher memoization gap: accepted, documented in the code.
  • TODO — measure the render cost per preview refresh on a heavy page.
  • TODO (optional) — Playwright coverage of the iframe wiring. The PHPUnit tests already pin the behaviour; only worth adding if the iframe itself regresses.

User interface changes

The Preview panel of a page layout pinned to a single path shows that page's real content and title. No new control, no new setting.

API changes

  • New: DisplayBuildableInterface::getPreviewPagePath(): ?string, with a NULL default on DisplayBuildablePluginBase. A buildable outside this module that implements the interface directly rather than extending the base class has to add it.
  • New: DisplayBuildableInterface::PREVIEW_INSTANCE_ATTRIBUTE, the request attribute naming the instance a preview sub-request renders.
  • New: Drupal\display_builder\PageCache\DenyPreviewSubRequest, tagged dynamic_page_cache_request_policy.
  • ApiPreviewController::getDisplayPreview() return type widens from array to array|Response.
  • Nothing is re-added from before #3542003: Responsive viewports with iframes: no getLivePreviewUrl(), no LIVE_PREVIEW_QUERY.
Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

mogtofu33 created an issue. See original summary.

mogtofu33’s picture

Issue summary: View changes
mogtofu33’s picture

Issue summary: View changes
mogtofu33’s picture

Issue summary: View changes
mogtofu33’s picture

Issue summary: View changes
mogtofu33’s picture

Issue summary: View changes

mogtofu33’s picture

Status: Active » Needs review
mogtofu33’s picture

Assigned: Unassigned » pdureau
mogtofu33’s picture

Issue summary: View changes
pdureau’s picture

Assigned: pdureau » mogtofu33
Status: Needs review » Needs work

Scope: Page layouts pinned to exactly one concrete, non-wildcard, non-negated request_path condition. Every other layout has no unambiguous page to preview against and keeps today's placeholder.

Tested. It is cool to have "Page title" and "Main content" placeholders replaced by proper preview, even if it is only working for this very specific case.

However, I am wondering if it would not be simpler and more future-proof to work directly at PageTitleSource and MainPageContentSource plugins level.

Today, "page sources" don't get any proper context, and we are only able to pick them thanks to the UI Patterns specific context_requirements attribute. We will deprecate context_requirements soon to promote "normal" Drupal contexts instead: #3608162: Tidy source contexts

For "page sources" it will be a uri context named "page", but it is open to discussion: https://docs.google.com/spreadsheets/d/1-oAOLOEYaCaYhMPWKAuwNNTQ5YFeU_44...

If we inject this context from ...\Plugin\display_builder\Buildable\PageLayout::getRuntimeContexts(), we may be able to:

  • be ready for the removal of context_requirements
  • implements the preview feature directly in source plugins, without the need of DisplayBuildableInterface::PREVIEW_INSTANCE_ATTRIBUTEand DisplayBuildableInterface::getPreviewPagePath()
  • get rid of the specific PageLayoutPageVariant::replaceTitleAndContent() logic?

What do you think?

mogtofu33’s picture

Assigned: mogtofu33 » pdureau
Status: Needs work » Needs review

Ok, added 'real' context on sources and PageLayout::getRuntimeContexts(), so should be good when removing context_requirements.

For other 2 points better to have follow up as it seems to move stuff instead of removing:

The path comes from the request_path condition either way, uri context doesn't remove that task it just moves it, so getPreviewPagePath() will mostly be renamed and not deleted.

I think a source can't resolve a page from a URI, because HtmlRenderer renders the main content before the variant exists, so a URI-only source can only re-enter the pipeline it is already inside. Title is easier as core TitleResolver on the route do the work when the controller sets no #title, so a source would still have to match the route itself. Main content has no such fallback.

Advantage of sub request against source is that we have the full 'real' preview, so breadcrumb, conditions, anything on the route we have it. With only source I guess we do not have that.

replaceTitleAndContent() should go but the payload has to be what the variant already holds when build() runs: $this->mainContent, a cacheable render array wrapping already-rendered markup, and $this->title, derived from its #title. Core hands both over through setMainContent() and setTitle(). A URI give none of that so we'll have work, and isse #3615031: Enhance instances list for better navigation is a work for better ux, so better to evaluate after that.

pdureau’s picture

Assigned: pdureau » mogtofu33
Status: Needs review » Reviewed & tested by the community

Ok, added 'real' context on sources and PageLayout::getRuntimeContexts(), so should be good when removing context_requirements.

Thanks.

Main content has no such fallback.

That's sad bu that's life. so, let's keep it like that.

I may create a followup issue targeting post 1.0.0 to keep track of future opportunities.

  • mogtofu33 committed fdd4755d on 1.0.x
    feat: #3615152 implement live preview functionality for page layouts...
mogtofu33’s picture

Assigned: mogtofu33 » Unassigned
Status: Reviewed & tested by the community » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.