Skip to content

Commit 40337e9

Browse files
committed
fix(webapp): keep copy button tabbable and stop timed-out alert seed writing late
Review feedback on #4359: the copy affordance sat inside a tooltip trigger that forced tabIndex -1, so keyboard users could not reach it; pass tabbable through. The default-alerts seed could also complete its write after the 5s timeout fired and overwrite a user's first alert edit; the timeout now aborts the seed before it writes. A fully atomic create-if-absent needs a platform API change and is out of scope here.
1 parent e991fa5 commit 40337e9

2 files changed

Lines changed: 24 additions & 4 deletions

File tree

apps/webapp/app/components/primitives/CopyableText.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,13 @@ export function CopyableText({
6666
iconButton
6767
) : (
6868
// asChild so the Radix trigger merges onto our button instead of nesting a button.
69+
// tabbable keeps the button in the tab order (the trigger sets tabIndex -1 otherwise).
6970
<SimpleTooltip
7071
button={iconButton}
7172
content={copied ? "Copied!" : "Copy"}
7273
className="font-sans"
7374
disableHoverableContent
75+
tabbable
7476
asChild
7577
/>
7678
)}

apps/webapp/app/models/organization.server.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,13 +146,19 @@ async function seedDefaultBillingAlerts(organizationId: string): Promise<void> {
146146
}
147147

148148
let timer: NodeJS.Timeout | undefined;
149+
const abort = new AbortController();
149150
const timeout = new Promise<never>((_, reject) => {
150-
timer = setTimeout(() => reject(new Error("Timed out")), SEED_ALERTS_TIMEOUT_MS);
151+
timer = setTimeout(() => {
152+
// Stop the seed from writing after we give up: by then the user may have
153+
// saved their own alerts, and a late default write would clobber them.
154+
abort.abort();
155+
reject(new Error("Timed out"));
156+
}, SEED_ALERTS_TIMEOUT_MS);
151157
});
152158

153159
const [error] = await tryCatch(
154-
Promise.race([writeDefaultBillingAlertsIfUnset(organizationId), timeout]).finally(() =>
155-
clearTimeout(timer)
160+
Promise.race([writeDefaultBillingAlertsIfUnset(organizationId, abort.signal), timeout]).finally(
161+
() => clearTimeout(timer)
156162
)
157163
);
158164
if (error) {
@@ -166,13 +172,25 @@ async function seedDefaultBillingAlerts(organizationId: string): Promise<void> {
166172
/**
167173
* Only writes defaults when the org has no alerts yet. A slow seed that finishes
168174
* after org creation returned would otherwise overwrite the user's first alert edit.
175+
*
176+
* The read-then-write isn't atomic: the platform API has no conditional write and
177+
* returns the same empty-levels shape for "no record" and "record cleared by the
178+
* user". Both only matter inside the seconds-long seed window right after org
179+
* creation; the abort check below keeps a timed-out seed from writing late.
169180
*/
170-
async function writeDefaultBillingAlertsIfUnset(organizationId: string): Promise<void> {
181+
async function writeDefaultBillingAlertsIfUnset(
182+
organizationId: string,
183+
signal: AbortSignal
184+
): Promise<void> {
171185
const existing = await getBillingAlerts(organizationId);
172186
if (existing && existing.alertLevels.length > 0) {
173187
return;
174188
}
175189

190+
if (signal.aborted) {
191+
return;
192+
}
193+
176194
await setBillingAlert(organizationId, buildDefaultBillingAlerts());
177195
}
178196

0 commit comments

Comments
 (0)