7aa9d2bbb445
add dismissal lock, fill-success lock (form-WeakSet + 3s page grace), submit-button focusin exclusion, SPA URL-change reset to inline popup
- time
- 2026-04-26 18:12 UTC · 6 months ago
- machine
- koh2
- parent
- fc0e2449c05b
↓ peach-browser-7aa9d2bb.tar.gzatlas at this save
files changed1 file
src/content/index.ts+119 −29src/content/index.ts+119 −29
@@ -34,24 +34,77 @@3434 // Prevents two concurrent handleFieldInteraction calls for the same field3535 const pendingInteractions = new WeakSet<HTMLInputElement>();3636 // Tracks the last field the user explicitly dismissed (Escape / click-outside / Tab).37-// Suppresses spurious reopens until focus moves to a different field.37+// Suppresses spurious reopens until focus moves to a different field. Layer 138+// of the dismissal model — see Projects.md §4.12.3839 let lastDismissedField: HTMLInputElement | null = null;3940 40-// Fields that were just autofilled — suppress popup reopens on them briefly.41-// Some sites refocus an input on form submit (validation flows, animation42-// blur/focus, framework handlers), which would otherwise re-trigger the43-// selector right as the user clicks the login button. Cleared by explicit44-// user-summon (icon click, keyboard shortcut) or after AUTOFILL_SUPPRESS_MS.45-const recentlyAutofilledFields = new WeakSet<HTMLInputElement>();46-const AUTOFILL_SUPPRESS_MS = 5000;47-function markAutofilled(...fields: (HTMLInputElement | null | undefined)[]): void {41+// ── Dismissal lock (Layer 2) ────────────────────────────────────────42+// After any user-initiated dismiss, briefly block ALL reopens regardless of43+// which field is focused. Catches the case where a page rapidly moves focus44+// between fields after Escape — without this, focus moving to a SECOND field45+// in <100ms would re-trigger the popup before the user finished dismissing.46+const DISMISS_LOCK_MS = 800;47+let dismissLock = false;48+let dismissLockTimer: ReturnType<typeof setTimeout> | null = null;49+function startDismissLock(): void {50+ dismissLock = true;51+ if (dismissLockTimer) clearTimeout(dismissLockTimer);52+ dismissLockTimer = setTimeout(() => { dismissLock = false; }, DISMISS_LOCK_MS);53+}54+function canOpen(): boolean { return !dismissLock; }55+56+// ── Fill-success lock ───────────────────────────────────────────────57+// After Peach autofills a form, the popup must NOT reappear on the filled58+// fields, on the submit button (some sites refocus it briefly), or on any59+// field in that form — even when host-page DOM mutations or the form's own60+// validation flow refocus an input. Implemented as a WeakSet of forms plus61+// a page-level timestamp:62+// - filledForms: every form Peach has filled this page session. Survives63+// until the form leaves the DOM (WeakSet GC).64+// - pageFilledAt: timestamp of last fill. Used as a 3s belt-and-suspenders65+// window for fields that aren't inside a <form> element (some SPAs).66+// SPA navigation resets pageFilledAt via the urlObserver below; filledForms67+// self-empties as old form elements get GC'd.68+const filledForms = new WeakSet<HTMLFormElement>();69+let pageFilledAt: number | null = null;70+const PAGE_FILL_GRACE_MS = 3000;71+72+function recordFillSuccess(...fields: (HTMLInputElement | null | undefined)[]): void {4873 for (const f of fields) {4974 if (!f) continue;50- recentlyAutofilledFields.add(f);51- setTimeout(() => recentlyAutofilledFields.delete(f), AUTOFILL_SUPPRESS_MS);75+ const form = f.closest("form");76+ if (form) filledForms.add(form);5277 }78+ pageFilledAt = Date.now();5379 }5480 81+function wasAlreadyFilled(field: HTMLInputElement): boolean {82+ const form = field.closest("form");83+ if (form && filledForms.has(form)) return true;84+ if (pageFilledAt !== null && Date.now() - pageFilledAt < PAGE_FILL_GRACE_MS) return true;85+ return false;86+}87+88+function clearFillStateForExplicitSummon(field: HTMLInputElement): void {89+ const form = field.closest("form");90+ if (form) filledForms.delete(form);91+ pageFilledAt = null;92+}93+94+// Some sites briefly focus the submit button when the user clicks it (visual95+// feedback, blur/focus dance for validation animations). Catch those at the96+// focusin layer so we don't re-trigger handleFieldInteraction for an event97+// target that was never going to be a credential field anyway.98+function isSubmitButton(el: EventTarget | null): boolean {99+ if (!(el instanceof HTMLElement)) return false;100+ if (el instanceof HTMLButtonElement && el.type !== "button") return true;101+ if (el instanceof HTMLInputElement && el.type === "submit") return true;102+ if (el.getAttribute("type") === "submit") return true;103+ const text = (el.textContent?.trim() ?? el.getAttribute("value") ?? "").toLowerCase();104+ if (!text || text.length > 24) return false;105+ return /^(submit|sign[ -]?in|log[ -]?in|continue|next|go)$/i.test(text);106+}107+55108 function debounce<T extends (...args: unknown[]) => void>(56109 fn: T,57110 ms: number@@ -361,7 +414,7 @@361414 const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;362415 if (entry) {363416 await handleAutofill(anchorField, usernameEl, passwordEl, entry);364- markAutofilled(anchorField, usernameEl, passwordEl);417+ recordFillSuccess(anchorField, usernameEl, passwordEl);365418 // Offer to remember the current site for this entry only if none of366419 // its known domains (primary + additional) match — otherwise the367420 // user already mapped it and the prompt would be noise.@@ -380,7 +433,7 @@380433 {381434 domain,382435 searchableEntries,383- onDismiss: () => { lastDismissedField = anchorField; },436+ onDismiss: () => { lastDismissedField = anchorField; startDismissLock(); },384437 }385438 );386439 }@@ -405,8 +458,9 @@405458 onFillFields: (username, password) => {406459 if (username) setNativeValue(usernameEl, username);407460 if (password && passwordEl) setNativeValue(passwordEl, password);461+ recordFillSuccess(usernameEl, passwordEl);408462 },409- onDismiss: () => { lastDismissedField = usernameEl; },463+ onDismiss: () => { lastDismissedField = usernameEl; startDismissLock(); },410464 }411465 );412466 }@@ -417,19 +471,25 @@417471 if (!field) return;418472 if (!isVisible(field)) return;419473 474+ // Layer 2: post-dismiss global lock. Catches rapid focus moves between475+ // fields immediately after Escape/click-outside.476+ if (!canOpen()) return;477+420478 // If a selector is already open for this exact field, don't flicker it.421479 if (currentDismiss && currentAnchorField === field) return;422480 423- // If focus moved to a different field, the previous dismiss lock no longer applies.481+ // If focus moved to a different field, the previous per-field dismiss lock482+ // no longer applies.424483 if (field !== lastDismissedField) lastDismissedField = null;425- // The user explicitly dismissed this field's selector — don't reopen it until426- // focus has genuinely moved away and come back.484+ // Layer 1: the user explicitly dismissed this field's selector — don't485+ // reopen it until focus has genuinely moved away and come back.427486 if (field === lastDismissedField) return;428487 429- // Field was just autofilled — sites often refocus an input on submit click,430- // which would otherwise re-trigger the selector right when the user is431- // trying to log in. Bail unless the user explicitly summoned us.432- if (recentlyAutofilledFields.has(field)) return;488+ // Fill-success lock: Peach already filled this form (or fired a fill489+ // anywhere on the page within the grace window). Sites often refocus an490+ // input on submit click, on validation, or on a transition to a success491+ // state — none of those should re-open the popup.492+ if (wasAlreadyFilled(field)) return;433493 434494 // Prevent two concurrent calls for the same field (e.g. focusin + icon click435495 // firing simultaneously — the second call would dismiss the first's selector).@@ -480,7 +540,7 @@480540 field,481541 [],482542 () => {},483- { domain, locked: true, onDismiss: () => { lastDismissedField = field; } }543+ { domain, locked: true, onDismiss: () => { lastDismissedField = field; startDismissLock(); } }484544 );485545 return;486546 }@@ -508,7 +568,13 @@508568 }509569 }510570 511-async function onFocusIn(): Promise<void> {571+async function onFocusIn(e: Event): Promise<void> {572+ // Submit-button focus: catch the brief refocus some sites do when the573+ // user clicks the login/submit button. Without this guard, that focus574+ // event reaches handleFieldInteraction → getDeepActiveInput, and on575+ // pages where the button is also a labelled <input> we briefly attempt576+ // to open the popup over the very button the user just clicked.577+ if (isSubmitButton(e.target)) return;512578 await handleFieldInteraction();513579 }514580 @@ -526,8 +592,10 @@526592 const cleanup = injectFieldIcon(el, () => {527593 currentDismiss?.();528594 currentDismiss = null;529- lastDismissedField = null; // icon click is always an explicit user open — clear the dismiss lock530- recentlyAutofilledFields.delete(el); // explicit summon also clears autofill suppression595+ lastDismissedField = null; // icon click is an explicit user open — clear per-field lock596+ dismissLock = false; // ...and the global lock597+ if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }598+ clearFillStateForExplicitSummon(el);531599 el.focus({ preventScroll: true });532600 void handleFieldInteraction(el);533601 });@@ -583,8 +651,10 @@583651 currentDismiss?.();584652 currentDismiss = null;585653 lastDismissedField = null;586- recentlyAutofilledFields.delete(el);587- el.focus({ preventScroll: true });654+ dismissLock = false;655+ if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }656+ clearFillStateForExplicitSummon(el);657+ el.focus({ preventScroll: true });588658 void handleFieldInteraction(el);589659 });590660 processedInputs.set(el, cleanup);@@ -598,6 +668,21 @@598668 setupMutationObserver();599669 setupSaveDetection();600670 671+// SPA navigation reset: the content script does not reload across in-app672+// route changes (history.pushState, hash navigation, framework routers).673+// When the URL changes we clear the page-level fill timestamp so the next674+// page works normally; filledForms (a WeakSet) self-empties as old form675+// elements leave the DOM. subtree:false because route swaps reliably676+// mutate document.body's direct children — subtree:true would fire on677+// every internal DOM update and cost us a re-check per mutation.678+let lastObservedUrl = location.href;679+new MutationObserver(() => {680+ if (location.href !== lastObservedUrl) {681+ lastObservedUrl = location.href;682+ pageFilledAt = null;683+ }684+}).observe(document.body, { childList: true, subtree: false });685+601686 // Async init: check recall map before running the initial scans so we can602687 // apply the right aggression level for domains the user has needed before.603688 (async () => {@@ -644,8 +729,13 @@644729 : (fields.find(f => f.type === "password")?.el645730 ?? fields.find(f => f.type === "username" || f.type === "email")?.el);646731 if (target) {647- recentlyAutofilledFields.delete(target); // explicit summon clears suppression648- void handleFieldInteraction(target);732+ // Explicit user summon (shortcut or extension button) — bypass all733+ // suppression layers. The user is asking for the popup right now.734+ clearFillStateForExplicitSummon(target);735+ dismissLock = false;736+ if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }737+ lastDismissedField = null;738+ void handleFieldInteraction(target);649739 }650740 } else if (isShortcut) {651741 // Shortcut on a page with no login form — show clipboard-copy search mode.