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
1 file src/content/index.ts+119 −29
src/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.

view file