Skip to content

fix(TWO-25554): make the billing and shipping capture panels independent - #412

Closed
dgjlindsay wants to merge 30 commits into
stagingfrom
doug/TWO-25554-panel-integration
Closed

fix(TWO-25554): make the billing and shipping capture panels independent#412
dgjlindsay wants to merge 30 commits into
stagingfrom
doug/TWO-25554-panel-integration

Conversation

@dgjlindsay

@dgjlindsay dgjlindsay commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

DO NOT MERGE — one blocking defect, and it needs a product decision, not another patch

Two predicates disagree about which panel is authoritative, and the disagreement discards a real capture.

billingIsDistinct() is the active "same as shipping" box unchecked AND the quote holding a billing address distinct from shipping. The billing panel's MOUNT predicate is separately is the billing company field visible. On a saved-shipping-address, non-virtual, search-enabled checkout the billing panel mounts, which blocks the tile, so the shipping panel has no mount at all — the billing panel is the only company control on the page. A buyer picks a company, billing captures it, but the quote has not yet diverged from shipping, so billingIsDistinct() is false and the resolver returns shipping's empty identity. The only capture on the page is discarded. Reproduced empirically, not inferred.

This is identity erasure rather than leakage, and it is a regression from the two rounds that moved this predicate off a DOM read to fix the opposite error (a third-party re-render detaching the fieldset made the old answer transiently false). "Which panel is authoritative" has now been answered four different ways in four rounds, so the next answer should be a deliberate ruling rather than a fifth iteration.

Two candidate rulings: arbitrate the mount on billingIsDistinct(), so the billing panel does not appear until the quote diverges; or make billingIsDistinct() true when the active box is unchecked and the billing field is a live mount, with the quote as fallback only where no box exists. The second keeps the panel visible when the buyer has asked for it and treats the lagging quote as the thing that is behind.

Supersedes #410 and #411, carrying their commits plus the review rounds since.

Goal

Billing and shipping company-capture panels are independent. Selecting a company, adopting a sole trader or changing country in one has no effect on the other's field, identity, notices or DOM, and neither panel reads the other's field or DOM.

The only interaction is read-selection: company-source-resolver.js reads both identities into a third resolved identity that order-intent and order placement consume. Its code is byte-identical to staging — the only difference is comments.

What holds, and how it was checked

Independence holds for fields, form DOM and notices, each verified by mutation rather than by reading: 37 mutations were applied across the branch and the ones that matter are killed by tests.

Every panel-scoped operation takes an explicit root, identity or scope as a required parameter — a call without one is refused, not silently widened. Each panel's DOM reads are ceilinged by its own form root, so an ancestor walk cannot reach the other panel's form. The address write record and the rate-limit suspension are keyed on the calling panel's identity, which no other panel holds a reference to, and which survives a checkout replacing the form element. Chrome and notices are rendered per panel from that panel's own identity, hosted beside its own field, and looked up among that host's direct children only.

Mount arbitration keeps one control per field: the shipping panel does not take the tile mount while the billing panel is mounted, and a mount that moves or is lost takes that panel's chrome, popover, and combobox ARIA attributes with it.

Behaviour removed

The shipping-to-billing address mirror and its pin are gone, country included — the last mechanism by which one panel mutated the other. Nothing propagates company or organization between address forms.

Every suppression gate added while chasing individual symptoms is deleted rather than extended: billingOwnsCompanyField, billingCaptured, billingRoleFormRoot, tileOwnsCompanyField, billingSourced, mirrorWriteDepth/isMirrorWriting, mirrorCountryToSecondaryAddresses, secondaryAddressIsPinned and the mirror-record machinery around them. snapshot() carries only the company name, number and their source, so no panel's UI state travels through the resolver.

Four guards shaped around a test double's deficiency are deleted, along with the comments that claimed a double required them.

Billing panel parity

The billing panel gains a company-number label, a "select a different sole trader" link, and its country selector ordered above its company field — the country decides which national registry the search queries, so it has to come first. The first two are rendered by CompanyCaptureComponent from each panel's own identity, so one implementation serves both panels and the payment tile's duplicates are deleted. No option was added to HOST_CONTRACT, which hard-throws; a host supplying no form-root selector reads nothing rather than failing.

Billing forms are found by walking the payment subtree and matching each node's own dataScopePrefix, covering both Display Billing Address On settings, every method code, and third-party billing components or containers without a hardcoded path.

Verified in the browser, before the change

On Amasty and Luma: billing's container carried no company_id field and no number label, and its country sat below its company field (company at child index 2, country at 4; shipping is country 2, company 3). A billing country change blanked shipping's street, city and postcode. A shipping country change reset billing's country select and cleared its address. Billing's own country change did not clear its own company, while shipping's did.

Tests

53 suites / 941 JS tests; PHP 1215 tests / 2325 assertions. Every commit green on its own.

Discrimination is measured against the immediately preceding branch state, not against staging — the harness doubles no longer model staging's API, so a staging run is dominated by missing-function errors and measures nothing. Behavioural assertion failures are counted separately from API-shape errors throughout.

Three vacuity classes were found and fixed during review: negative assertions that ran before the setTimeout(0) identity publish they denied; a seed reusing the value an identity already held, so no notify fired and a stale mirror satisfied the assertion; and a harness stub returning a constant empty string, which had made every fallback-country assertion vacuous.

Still owed

Real nb/nl/sv translations for one new string, currently echoing its English source at i18n/nb_NO.csv:314, nl_NL.csv:310, sv_SE.csv:311.

PHPStan and the coding-standard jobs have not run: composer is unavailable in this environment. CI must run them.

No post-change browser verification. The staging shop runs staging, so this cannot be exercised there until it lands.

The Fire Checkout width rule for billing's country select was written without a live Fire page. If the narrow box is the wrapper rather than the select, it is a no-op.

dgjlindsay and others added 30 commits September 1, 2026 16:45
The ancestor walk had no ceiling, so a panel whose own form carries no
company_id read the other panel's — a cross-panel DOM read the panel split
exists to prevent. The walk now cannot leave the form the field belongs to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The number label and sole-trader link sat inside `.two-company-field-wrap`,
which the popover is positioned against, so chrome in its flow pushed the
open dropdown off the field. They are siblings after the wrapper, and a test
pins that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The injection matched only core's own billing-address component, so a
checkout substituting its own — or wrapping core's in a container — got no
company-number field and the feature silently did nothing there. Core's
`billingAddress` scope naming is accepted as well.

Also assert the payment subtree in the neither-container-present row, which
previously asserted nothing about its own subject.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…answer

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…dentity

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…re diff

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tives

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ess form

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e harness's divergences

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…related one

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e quote

The resolver and the quote-driven seed each had their own answer, and a
checkout holding a distinct billing address while rendering no billing
company field stranded the buyer's saved company on a panel nothing reads.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… failure diff

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ount moves

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The mirror carries the company fields alone, so no panel's UI state
travels into the identity the tile and order-intent read.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…only

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ding

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…xport

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…carries

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The jQuery stub defines length, is, find and closest, so the missing-method
cases the guards claimed to cover cannot occur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d row description

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ding

Each fails under a mutation of the guard it names: the two identity guards in
company-search, the quote's no-billing-address branch, _chromeNode's
sibling-only scoping, and the billing panel's own-root country fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_ownFormRoot's ceiling is the panel's own address form, and the layout
processor injects exactly one company_id field per form, so no ancestor
inside that ceiling can hold two.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@dgjlindsay
dgjlindsay marked this pull request as draft September 1, 2026 17:24
@dgjlindsay

Copy link
Copy Markdown
Contributor Author

Superseded by #414/#413 — this branch predates and duplicates already-merged #410/#411, and the specific fix it attempted is being carried forward there instead. Closing, no code lost (already merged work stays on staging via #410/#411).

@dgjlindsay dgjlindsay closed this Sep 2, 2026
@dgjlindsay
dgjlindsay deleted the doug/TWO-25554-panel-integration branch September 2, 2026 22:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant