fix(TWO-25554): make the billing and shipping capture panels independent - #412
Closed
dgjlindsay wants to merge 30 commits into
Closed
fix(TWO-25554): make the billing and shipping capture panels independent#412dgjlindsay wants to merge 30 commits into
dgjlindsay wants to merge 30 commits into
Conversation
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
marked this pull request as draft
September 1, 2026 17:24
Contributor
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, sobillingIsDistinct()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 makebillingIsDistinct()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.jsreads both identities into a third resolved identity that order-intent and order placement consume. Its code is byte-identical tostaging— 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
companyororganizationbetween address forms.Every suppression gate added while chasing individual symptoms is deleted rather than extended:
billingOwnsCompanyField,billingCaptured,billingRoleFormRoot,tileOwnsCompanyField,billingSourced,mirrorWriteDepth/isMirrorWriting,mirrorCountryToSecondaryAddresses,secondaryAddressIsPinnedand 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
CompanyCaptureComponentfrom each panel's own identity, so one implementation serves both panels and the payment tile's duplicates are deleted. No option was added toHOST_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_idfield 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:
composeris 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.