The per-slot booking page renders the visitor's own basket into the HTML and declares no cacheability for it, so the dynamic page cache stores one copy per language and hands it to every other anonymous visitor. A booker holding four places on a tariff has that four served to the next person who opens the page, and a stepper pressed from there reconciles the second visitor's order against a quantity that was never theirs.

Where it comes from

SlotBookingForm::buildForm() reads the session's basket into every field: $held = $this->cart->heldQuantities($yoyaku_slot); becomes the #default_value of each quantity control. The only cacheability the form declares is $form['#cache']['contexts'][] = 'yoyaku_booking_channel';. Nothing there names the visitor, so one render is stored per route, language and channel, then replayed to anybody who asks.

Core does not cover this. In FormBuilder::prepareForm() the form_token element, and with it the max-age = 0 that keeps a form out of every cache, is added only when the visitor is authenticated. An anonymous visitor receives a form carrying no per-session element at all, and anonymous visitors are the entire audience for this page.

This has been true since the form was written. It stayed invisible while the page was exercised logged in, where the token forces the page out of every cache and the user.roles:authenticated context keeps the two audiences apart.

A second defect behind the same missing metadata

The same build calls BookingManagerInterface::availabilityMap() to print what is left on each tariff, and that number is cached with neither the availability cache tags nor yoyaku.settings.availability_max_age. The counts on this page are therefore stale for everybody until some unrelated entity tag happens to clear the entry. Declaring the visitor does not fix this: two faults share one cause.

Steps to reproduce

  1. Offer a resource with at least one tariff, and a slot in the future.
  2. As an anonymous visitor, open the slot booking page and step one tariff up to a non-zero quantity.
  3. From a client carrying no cookies at all, request the same booking page.
  4. The quantity control comes back holding the first visitor's number, and the response reports a dynamic page cache hit.

Proposed resolution

Two steps, because the first one is small enough to land against a leak that is live and the second one is the design the seat map already runs.

Declare the visitor, and bound the counts. The form gains the session cache context, so a render is never replayed to another basket, and the availability half gains the collected availability cache tags plus yoyaku.settings.availability_max_age, so what is left stops being believed forever. This closes the disclosure and costs the page its shareability, which the next step gives back.

Take both per-request things out of the HTML. The quantities are filled client-side from the yoyaku_held stamp and the copy kept beside it, exactly as the seat map fills a visitor's own places today, and what is left comes from the slot availability document, which already carries pooled counts and tariff prices and would gain per-tariff remaining. The page is then config only: labels, prices, the per-order ceiling and the controls. It goes back to one shared copy per language and channel, invalidated by entity and config tags alone, and the session context added above is deleted in the same commit rather than kept as a fallback.

The controls ship disabled and are enabled only once that fill succeeds, so a number that never arrived cannot be submitted. The disabled attribute has to be set through #attributes and never through #disabled: core ignores submitted input for a #disabled element whatever the markup says, as anti-tampering, so a control disabled that way and enabled in JavaScript would post a quantity that is then silently discarded. A fill that fails leaves the controls inert with a message, the same shape the seat map already uses when its documents do not arrive.

Related: #3615940: Share one stepper widget, and coalesce a booker's clicks into one request, which moves the stepper into a shared widget and batches what it posts.

Two neighbours to settle in the same change

CartForm declares no cacheability whatsoever, not one #cache line, while rendering the cart. The internal page cache refuses to store a response while a session is open, so a populated cart cannot land there, but that is a request policy protecting the code rather than the code being right, and the dynamic page cache applies no such rule.

CalendarBookingForm declares user.permissions and nothing else, which says nothing about whose basket is in the render.

Issue fork yoyaku-3615951

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

mably created an issue. See original summary.

  • mably committed 085e75de on 1.x
    fix: #3615951 The slot booking page is cached with one visitor's...
mably’s picture

Status: Active » 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.