Fix Passkey buttons not triggering sometimes

This commit is contained in:
Stanko K.R.
2026-03-24 14:36:52 +01:00
parent 7632802f18
commit 134a533e28
+37 -32
View File
@@ -23,12 +23,19 @@
import { register, authenticate } from "lib/action_pack/webauthn" import { register, authenticate } from "lib/action_pack/webauthn"
let listeners const mediated = new WeakSet()
let currentDocument const mediationSelector = 'form[data-passkey-mediation="conditional"]'
document.addEventListener("DOMContentLoaded", setup) // Delegate click events for passkey buttons. Walks up from the click target
document.addEventListener("turbo:load", setup) // to find the nearest [data-passkey] element, like Rails UJS does.
document.addEventListener("turbo:before-cache", teardown) document.addEventListener("click", (event) => {
const button = event.target.closest("[data-passkey]")
if (button) {
if (button.dataset.passkey === "create") createPasskey(button)
else if (button.dataset.passkey === "sign_in") signInWithPasskey(button)
}
})
// Set error state on the nearest [data-passkey-errors] container. // Set error state on the nearest [data-passkey-errors] container.
// The app's CSS is responsible for showing/hiding children based on // The app's CSS is responsible for showing/hiding children based on
@@ -41,39 +48,37 @@ document.addEventListener("passkey:error", ({ target, detail: { cancelled } }) =
} }
}) })
// Bind click handlers to passkey buttons and attempt conditional mediation. // Clean up transient DOM state before Turbo caches the page snapshot.
// Guards against duplicate setup. document.addEventListener("turbo:before-cache", () => {
function setup() { for (const button of document.querySelectorAll("[data-passkey][disabled]")) {
if (currentDocument !== document.documentElement) {
currentDocument = document.documentElement
listeners?.abort()
listeners = new AbortController()
for (const button of document.querySelectorAll('[data-passkey="create"]')) {
button.addEventListener("click", () => createPasskey(button), { signal: listeners.signal })
}
for (const button of document.querySelectorAll('[data-passkey="sign_in"]')) {
button.addEventListener("click", () => signInWithPasskey(button), { signal: listeners.signal })
}
attemptConditionalMediation()
}
}
// Reset transient DOM state and unbind event handlers to prevent leaks and duplicate handlers.
function teardown() {
currentDocument = null
listeners?.abort()
for (const button of document.querySelectorAll('[data-passkey][disabled]')) {
button.disabled = false button.disabled = false
} }
for (const container of document.querySelectorAll("[data-passkey-errors]")) { for (const container of document.querySelectorAll("[data-passkey-errors]")) {
delete container.dataset.passkeyErrorState delete container.dataset.passkeyErrorState
} }
})
// Attempt conditional mediation when a form with [data-passkey-mediation="conditional"]
// appears in the DOM. Uses a MutationObserver so it works regardless of how the form
// is inserted (initial page load, Turbo Drive, Turbo Streams, or plain JS).
new MutationObserver((mutations) => {
for (const { addedNodes } of mutations) {
for (const node of addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) mediateIn(node)
}
}
}).observe(document.documentElement, { childList: true, subtree: true })
mediateIn(document)
function mediateIn(root) {
const form = root.matches?.(mediationSelector) ? root : root.querySelector?.(mediationSelector)
if (form && !mediated.has(form)) {
mediated.add(form)
attemptConditionalMediation()
}
} }
// Run the WebAuthn registration ceremony: refresh the challenge, prompt the // Run the WebAuthn registration ceremony: refresh the challenge, prompt the