Repository navigation
Bind on the Domains page sends only the domains still offered for binding - #1840
Merged
dawsontoth merged 7 commits intoOct 12, 2026
Conversation
Contributor
There was a problem hiding this comment.
Code Review
This pull request improves the domain binding and removal flow in the cluster domains management feature by handling race conditions between concurrent bind and delete actions. It introduces state checks to ensure only bindable domains are processed, disables controls while mutations are in flight, and optimistically updates the React Query cache upon domain deletion. The review feedback correctly points out that the global queryClient is still being used for query invalidation in Management.tsx and tableDefinition.tsx instead of the newly introduced activeQueryClient hook, which could lead to issues in test environments or custom configurations.
dawsontoth
added this pull request to stack #1912
October 11, 2026 22:28
The Bind selection was page state that outlived its rows. A ticked domain that was then removed, or bound to another cluster, was still counted and sent, and central manager refused every later Bind (404 not found, 400 already associated) until the page was left. Bind now counts and sends the selection intersected with the list's bindable rows, using the same predicate that renders the checkbox. A removal takes its row out of the cached list as soon as it succeeds, so the gap before the refetch lands cannot send it, and Bind waits while a removal is in flight, including a click queued before the controls disabled. Remove likewise refuses a click queued behind a Bind that is still sending its domain. Bind reads the list from the query client at click time, since a removal reaches the button a render later. Closes #1800 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e the guards read The bind, unbind and remove handlers read mutation state and the domain list from useQueryClient() but still invalidated through the module-level queryClient import. Production provides that same instance, so nothing changed there, but any other provider (every test here) never saw the refetch. Tests now pin that each change refetches the list on the page's own client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A domain change that reads the list before sending it (as the pre-send check stacked on this branch does) would satisfy a count taken at click time with no invalidation at all. Counting from the request makes only the post-success refetch pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n the page, and pre-check it
A refused PUT /Cluster/{id} (bind, unbind, or a cluster edit) produced only a toast that faded,
and the domain changes sent whole lists Studio had not checked. RUM showed 39 of 40 such PUTs
refused in a day on one cluster, with users retrying across three pages (#1686).
Domain changes now pass intent (add, remove). The mutation builds the list from a fresh read of
the cluster and the organization's domains rather than from what the page last saw, so a domain
another member bound meanwhile is no longer silently unbound. Before sending, it refuses what
central manager would refuse, mirroring its source:
- the cluster is not RUNNING, or an instance is FAILED or in TRANSITIONING_INSTANCE_STATES
(assertClusterMutable, sharedHelpers.js:587-606; STOPPED and UPDATED included);
- the cluster is self-managed (updateCluster.js:166);
- a domain the change touches is missing, unverified or bound to another cluster
(updateCluster.js:173-191), the cluster's existing ones included; those point at support.
It refuses rather than drops: central manager unbinds whatever the new list leaves out without
checking which cluster holds it (updateCluster.js:199-205).
Bind and unbind failures, refusals and central manager's own reasons alike, now stay on the
Domains page; a cluster edit's failure stays beside its submit button. Both mutations skip the
global toast and report real request failures to RUM at mutation level. #1801's cache write of
the accepted list is gone, since every change now re-reads the cluster.
Refs #1686
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ays why The tooltip's trigger slotted onto a fragment, which can take neither the trigger's props nor its ref, so React dropped every handler and logged an invalid-prop warning on each render. Slot it onto a wrapper around the button instead: a disabled button takes no pointer events, so the hover has to land on something around it. The domain name stays outside the trigger, so reading it does not pop a tooltip about deleting it. Closes #1843 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oard users get the tooltip A disabled button can't take focus, so the explanation for why Remove is off was hover-only. The wrapper the tooltip triggers on is now focusable exactly while the button is disabled; while it is enabled the button keeps the only tab stop and its focus still opens the tooltip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Radix puts aria-describedby on the trigger, and with the wrapper always the trigger an enabled Remove took focus while the wrapper held the description, so a screen reader never announced it. The button is now the trigger unless the domain is bound; only then does a focusable wrapper stand in, so an in-flight change no longer gives unbound rows a tab stop whose tip doesn't explain the disabled state. Tests check the focused element's aria-describedby against the tooltip, plus the pending case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dawsontoth
marked this pull request as ready for review
October 12, 2026 17:07
dawsontoth
force-pushed
the
claude/1800-bind-only-live-selection
branch
from
October 12, 2026 17:07
594707c to
e1c66ca
Compare
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.
Closes #1800
⊙ Problem
On a cluster's Domains page, the domains ticked for binding are page state that outlived their rows. Tick a domain, then click Remove Domain on it (enabled for any unbound domain, ticked or not): the row disappears, but Bind 1 Domain stays, and clicking it sends the deleted id. Central manager answers
404 Domain <id> not found, and since the selection only clears when a bind succeeds, every later Bind on that page fails the same way until the user leaves it. A ticked domain that gets bound to another cluster meanwhile fails the same way with a 400 (#1800).💡 Solution
Bind now counts and sends only the ticked domains that the current list still offers for binding: the selection intersected with the rows
canBindDomainaccepts, which is the same predicate that renders the checkbox. A domain that was removed, or bound elsewhere, drops out of the count and the request on its own, with no extra state to keep in sync.Three gaps around a removal are closed as well:
⚖️ Alternatives
🔧 Changes
Management.tsx: the Bind button's count and visibility come fromdomainIdsToBind; the click handler refuses while a delete is in flight and recomputes the ids from the query client, and Bind is disabled meanwhile.canBindDomain.ts(new): the bindable-row predicate, now shared by the checkbox and Bind, andbindableDomainIds, the selection filtered by it.tableDefinition.tsx: the checkbox usescanBindDomain; Remove refuses while a domain change is in flight; the delete call passes the organization id the cache write needs.deleteDomain.ts: the mutation takes a mutation key, removes the deleted row from the cached list on success, and exportsisDeletingDomain/useIsDeletingDomain.useQueryClient()the guards read, and the module-levelqueryClientimport is gone. Production provides that same instance, so this changes nothing at runtime, but a test's own client now sees the refetch.setDomainIdsOnCluster.ts: exportsisSettingDomainIds, the synchronous checkmutateWhenIdlealready made, so Remove can share it.src/features/cluster/DESIGN.md,DESIGN.md: a note on the Bind selection and the index line.✅ Verification
End-to-end route: not observable end-to-end here — it needs a cluster, verified custom domains and a central manager, and a Bind changes real infrastructure. Verified at the request boundary instead:
Management.test.tsxrenders the realDomainsManagementand table against a mockedapiClientand asserts the serialized PUT body.Management.test.tsx, new "a ticked domain the list no longer offers" block:Management.test.tsx, new "refetches the domain list through the page's own query client" block: once a bind, an unbind or a removal lands, the list is fetched again on the test's client. Fetches are counted from when the request goes out, so A refused domain change or cluster edit explains why on the page, and Studio checks a domain change before sending it #1846's pre-send read can't satisfy it. All three went red with the global client restored, and again with the invalidations removed, here and on A refused domain change or cluster edit explains why on the page, and Studio checks a domain change before sending it #1846's code.canBindDomain.test.ts(new): verified + unbound is offered; pending, bound (to any cluster) and self-managed are not;bindableDomainIdskeeps tick order and offers nothing before the list loads.Management.test.tsxtests fail against theorigin/claude/1630-challenge-cert-poll-backoffsources with the bug signatures (the label staysBind 2 Domainsafter a removal; the button stays for a domain bound elsewhere; Bind stays enabled during a removal; the queued Remove sends its DELETE; the render-lag click sends both ids). Each guard was mutation-checked: without the cache write, the intersection, the disabled state, either queued-click guard or the click-time read, its test goes red.npx vitest run397 files / 3,688 passed (exit 0),npx tsc -bexit 0,npx oxlint --format stylish .exit 0,npx dprint checkexit 0, and again by the pre-commit hook. At 594707c the pre-commit hook ran 397 files / 3,691 passed, andnpx tsc -b,oxlintanddprint checkexited 0.Pre-push review: three full rounds (codex graded, gemini). The Harper-domain adjudicator failed on auth every round and the Cursor legs failed on the 1Password SSH agent, so no round converged. Round 1 found the render-lag click (fixed: Bind reads the list from the query client); round 2 found the reverse race, a Remove queued behind a Bind (fixed: Remove shares
isSettingDomainIds). Round 3 returned COMMENTS with no defects. I rejected:useDeleteDomaincallers break on the neworganizationId" (gemini):tableDefinition.tsxis the only caller, andDeleteDomainSchemais only a type;mutateWhenIdlealready refuses a second domain change while one is in flight;A fourth full round at 3740ab4 (codex graded, gemini, Harper domain adjudicator, all ok) raised nothing against the query-client change. Its one surviving minor predates this PR. After a Bind's PUT settles, the domain row has no
clusterIduntil the[orgId]refetch lands, so for that round trip Remove is enabled on a domain the cluster now holds. Central manager refuses that DELETE with a 400, "Domain is currently assigned to a cluster. Please remove the domain from the cluster first." (src/resources/domain/Domain.js:124-129in central-manager). It writes the domain'sclusterIdbefore the PUT answers (updateCluster.js:194), so the delete cannot land and leave a dangling id. The adjudicator's other surviving notes were a one-tick stale closure in Unbind (also predates this PR), the comment nits above, and extensionless imports (the repo's convention).gemini-code-assist feedback: both comments, "invalidate through
activeQueryClient, not the global import", were right in substance: no runtime effect, but a test's client never saw the refetch. Fixed in 3740ab4, and the tests were tightened in 594707c.🤖 Generated with Claude Code (Claude Opus 5.5); posted via @dawsontoth.
Related PRs: #1801 overlaps (this PR stacks on it), #1802 overlaps (this PR's base), #1846 overlaps (stacked on this PR; it rebuilds the same domain-change handlers), 2 others independent
Complexity: easy
Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; blocked=cursor-composer(no-receipt),cursor-muse(no-receipt); declined=cursor-grok,cursor-kimi; rounds=4; full=1 @ 594707c
Review-Attention: read ~4m (decisions: settle-before-refetch, per-mutation-guards, silent-refusal, do-less-alternative) @ 594707c