Problem/Motivation

BigPipeHooks::pageAttachments() attaches the no-JS detection <noscript><meta http-equiv="Refresh"> tag whenever a session exists and the big_pipe_nojs cookie is absent. The refresh URL is built from the current request:

'content' => '0; URL=' . Url::fromRoute('big_pipe.nojs', [], [
  'query' => \Drupal::service('redirect.destination')->getAsArray(),
])->toString(),

`RedirectDestination::getAsArray()` returns the request's `?destination=` query argument when present (falling back to the current path and query). So on `/user/login?destination=/foo` the emitted tag points at `/big_pipe/no-js?destination=/foo`.

The only cache contexts the hook adds are `session.exists` and `cookies:big_pipe_nojs`. Dynamic Page Cache varies its entries by the `route` and `request_format` contexts plus whatever the response bubbles; `route` captures the path and route parameters but deliberately not query arguments. The big_pipe attachment embeds the destination but bubbles no query-args context. Dynamic Page Cache is not a declared dependency of big_pipe (big_pipe.info.yml declares none), but the Standard profile installs the two together, so the interaction is the default configuration in practice.

Consequence: on any route where nothing else happens to bubble a `url.query_args` context into the page shell, the first sessioned no-cookie request bakes *its* `destination` value into the cached shell. Every subsequent sessioned no-cookie request to that path — with a *different* `?destination=` — is served the stale tag (`X-Drupal-Dynamic-Cache: HIT`). A browser with JavaScript disabled follows the meta refresh, and `BigPipeController::setNoJsCookie()` then faithfully redirects to the stale destination. The visitor lands on a page someone else (or an earlier request) was headed to, instead of the page they asked for; the `destination` they arrived with is lost.

The bug class — request-derived markup baked into a shared Dynamic Page Cache entry — is well known, and core has sorted out a lot of cases of it:

- Form actions (current path + query string, destination included) are placeholdered via `form_action_p_...` with a lazy builder declaring `url.path` + `url.query_args` (#2504139: Blocks containing a form include the form action in the cache, so they always submit to the first URL the form was viewed at, #2561775: Forms without $form['#action'] set get their action automatically generated based on current path + query args: cacheability metadata is missing, #2562341: Add non-HTML placeholder generation to PlaceholderGenerator or a new service; `FormBuilder::prepareForm()` / `buildFormAction()`).
- `UserLoginBlock` — the near-exact analog: destination-bearing markup on cacheable pages — was converted to a lazy builder in #2867703: Change #action in UserLoginBlock to a #lazy_builder to avoid the route cache context .
- `ConfirmFormHelper::buildCancelLink()` adds `url.query_args:destination` when it embeds the request's destination.
- `CommentManager::forbiddenMessage()` deliberately refuses `redirect.destination` on cacheable listing pages and derives a stable per-entity destination instead.

Steps to reproduce

On pages that also render some other destination-aware component (a user login block, a confirm-form cancel link), that component's `url.query_args:destination` context bubbles up and incidentally varies the whole Dynamic Page Cache entry, masking this bug. It reproduces on pages without such a component.

With big_pipe and dynamic_page_cache enabled, a sessioned user (no `big_pipe_nojs` cookie) GETs the same cacheable route twice with different `?destination=` values; the second response is a `X-Drupal-Dynamic-Cache: HIT` whose noscript meta refresh still carries the *first* request's destination. A no-JS client following that tag is redirected to the first requester's destination.

Proposed resolution

Options, roughly in order of preference:

1. Placeholder the tag following the form-action pattern (#2562341: Add non-HTML placeholder generation to PlaceholderGenerator or a new service): a token substituted during response processing, carrying `url.query_args` cacheability, keeping Dynamic Page Cache unfragmented. Implementation caution: a naive *render* placeholder will not work here — `BigPipeStrategy::doProcessPlaceholders()` would convert it into a JS-replaced placeholder for exactly the population whose JS support is still unknown, breaking no-JS detection itself. The form-action-style response-processing substitution avoids that trap. (Supporting observation from the attached test output: on a Dynamic Page Cache HIT, `drupalSettings.currentQuery` is fresh while the cached meta tag is stale — per-request substitution machinery exists in the response pipeline.)
2. Bubble `url.query_args` on the attachment as-is (the full context, not `url.query_args:destination` — `RedirectDestination::get()` falls back to the *full current path and query string* when no `?destination=` argument is present, so the embedded value varies by the whole query string), matching what `UserLoginBlock`'s placeholdered action declares. Simplest and correct, but fragments the dynamic page cache by query string for every sessioned page view.

Remaining tasks

Agree on solution.

User interface changes

None.

Introduced terminology

None.

API changes

Data model changes

Release notes snippet

Issue fork drupal-3612992

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

jonathanshaw created an issue. See original summary.

jonathanshaw’s picture

Issue summary: View changes

jonathanshaw’s picture

The MR has a failing test demonstrating the bug.