1import { Turbo } from "@hotwired/turbo-rails" 2 3// Ask the `data-turbo-confirm` question on the pages where Turbo Drive is off (systemsdev-17315). 4// 5// â THE DEFECT. The account/admin entry (account.js) loads Turbo with `Turbo.session.drive = false` 6// â deliberately, for the reasons that file states. Turbo then handles a submission only when 7// `submissionIsNavigatable()` says so, which with Drive off means "inside a `data-turbo="true"` 8// container or inside a <turbo-frame>". A form that declares neither submits NATIVELY, and 9// `data-turbo-confirm` on it is a string nobody reads: the action runs on the first click, with 10// no dialog. Support's coverage round 4 met it under an `accept_confirm` that had every reason 11// to work (docs/testing/support-journeys-4-evidence.md §1a). Counted over the whole tree â 12// `ror-session-tools/b17315-confirm-inventory.py`, which also reaches the account area and the 13// desk views that scan did not â **30 of the 32 confirm sites in app/views asked nobody**, 14// from "Run the tuning loop ⦠This spends LLM budget (capped)" to the account area's own 15// "This permanently deletes your account. Continue?". The two Downloads bulk forms pass 16// `data: { turbo: true }` on the FORM and were the only two confirms in the tree that appeared. 17// 18// â THE MECHANISM. One `submit` listener and one `click` listener, both on `document` in the 19// CAPTURE phase, both reading the same attribute Turbo reads and asking through the same 20// confirm method Turbo would use. Drive stays off; no view declares `data-turbo="true"` to buy 21// its dialog back; a confirm added to any future view works because it is spelled the way Rails 22// spells it. 23// 24// Why CAPTURE and not bubble: `<body data-action="submit->feedback#submit">` disables the submit 25// button and swaps its label to "Deletingâ¦" the moment a submission starts (feedback_controller.js). 26// On a NATIVE submit that is invisible, because the page navigates away. If we cancelled the 27// submission in the bubble phase, feedback would already have fired, and a client who answered 28// "Cancel" would be left looking at a dead, spinning "Deletingâ¦" button on a page that is not 29// going anywhere. A capture-phase listener sees the event before <body> does, so a cancelled 30// submission is stopped (`stopPropagation`) before anything downstream reacts to it. 31// 32// â WHAT WE NEVER ASK ABOUT â four skips, each one mechanical rather than a list of names. 33// 34// 1. Submissions Turbo handles itself (`turboAsksAlready` below, a mirror of Turbo 8's 35// Session#submissionIsNavigatable / #elementIsNavigatable). Those already get the dialog from 36// Turbo; asking here too would show it twice. This is the ONLY place a Turbo internal is 37// mirrored, so the mirror names its source and the static gate pins the shape it depends on. 38// 39// 2. The element we are ourselves re-dispatching after a "yes" (`answered`, below). A confirm 40// method is asynchronous by contract, so the only way to ask before a native submission is to 41// cancel it, wait for the answer, and raise it again â and the raised one must not be asked 42// about a second time. 43// 44// â `isTrusted` is NOT the way to tell the two apart, though it reads like it should be. 45// `form.requestSubmit()` fires its `submit` event from the USER AGENT, so `isTrusted` is 46// **true** on it, exactly as on a submission a hand started (`element.click()` is the 47// opposite â its click is explicitly marked not trusted). Keying on it cost this lane a run: 48// every accepted confirm re-asked itself, forever, and a page stuck behind its second dialog 49// looks exactly like a page that ignored the first. 50// 51// 3. Elements something else owns the question for: `[data-confirm-handled]`, on the element or 52// any ancestor. A Stimulus controller that asks its own way (`remove_site_controller.js` and 53// `remove_end_user_controller.js` each open a modal and then submit the row's form) marks its 54// trigger with it rather than being named here. Neither needs it today â both dropped
55// `turbo_confirm` from their markup when they took the question over, and this module reads 56// nothing else â but a controller that wants to own an element which DOES carry the attribute 57// has one attribute to add, and `test/system/account/orphaned_transfer_demo_test.rb` counts 58// the dialogs on that path so the claim is measured rather than remembered. 59// 60// 4. Events something ahead of us already cancelled (`event.defaultPrevented`). 61// 62// â THE ANSWER IS ASKED THE WAY TURBO ASKS IT. `Turbo.config.forms.confirm` when the app has set 63// one, otherwise `window.confirm` wrapped in a promise â byte for byte what Turbo 8's 64// FormSubmission does. That is what keeps Capybara's `accept_confirm` / `dismiss_confirm` (and 65// any future custom dialog) working on these pages: the question is asked through the one hook 66// the rest of the system already knows about. 67 68const CONFIRM_ATTRIBUTE = "data-turbo-confirm" 69const HANDLED_ATTRIBUTE = "data-confirm-handled" 70 71// The forms and links whose question is answered and which we are raising again. A WeakSet, so a 72// removed element is collected with the page rather than held alive by this module. 73const answered = new WeakSet() 74 75// Turbo 8 (turbo-rails 2.0.23), Session#elementIsNavigatable: 76// const container = closest(element, "[data-turbo]"), frame = closest(element, "turbo-frame") 77// return (config.drive.enabled || frame) ? (!container || container.dataset.turbo != "false") 78// : (!!container && container.dataset.turbo == "true") 79function turboWouldNavigate(element) { 80 if (!(element instanceof Element)) return false 81 const container = element.closest("[data-turbo]") 82 const frame = element.closest("turbo-frame") 83 if (Turbo.config.drive.enabled || frame) { 84 return !container || container.getAttribute("data-turbo") !== "false" 85 } 86 return !!container && container.getAttribute("data-turbo") === "true" 87} 88 89// Turbo 8, Session#submissionIsNavigatable. `forms.mode` is "on" by default; the app sets none, 90// but the mirror reads it rather than assuming, because a future `Turbo.config.forms.mode = "optin"` 91// would otherwise silently make this module ask about forms Turbo also asks about. 92function turboAsksAlready(form, submitter) { 93 const mode = Turbo.config.forms.mode 94 if (mode === "off") return false 95 const submitterOk = !submitter || turboWouldNavigate(submitter) 96 if (mode === "optin") return submitterOk && form.closest('[data-turbo="true"]') !== null 97 return submitterOk && turboWouldNavigate(form) 98} 99 100// Turbo reads the message off the submitter first, then off the form (`data-turbo-confirm` on a 101// `button_to` lands on the BUTTON; on a `form_with` it lands on the form). Same order here. 102function confirmMessage(...elements) { 103 for (const element of elements) { 104 if (element instanceof Element && element.hasAttribute(CONFIRM_ATTRIBUTE)) { 105 return element.getAttribute(CONFIRM_ATTRIBUTE) 106 } 107 } 108 return null 109} 110 111// â THE RE-DISPATCH MUST START A NEW TASK, and this is not a style choice. 112// 113// `window.confirm` is synchronous: it blocks, and the promise wrapped around it therefore settles 114// in a MICROTASK, which runs as soon as our listener returns â while the browser is still inside 115// "fire an event" for the submit it is dispatching. HTML's form-submission algorithm begins with 116// "if form's firing submission events is true, then return", so `form.requestSubmit()` called 117// there does NOTHING AT ALL: no submission, no error, no rejected promise. The page simply sits 118// on the answered dialog looking exactly like a page that ignored it. Measured on this lane: two 119// full browser runs, three scenarios red on "the action never happened", with the fix in place and 120// the dialog appearing correctly. 121// 122// A zero timeout puts the re-dispatch in the next task, after the flag has cleared. (The same 123// reason feedback_controller.js defers its own button disable by a zero timeout â a submission in 124// flight is not a moment to touch the form.) An asynchronous custom confirm method would settle in 125// a later task anyway; this keeps the synchronous one honest too. 126function inTheNextTask(action) { 127 setTimeout(action, 0) 128} 129 130// Turbo 8, FormSubmission#start: `typeof config.forms.confirm == "function" ? config.forms.confirm 131// : FormSubmission.confirmMethod`, whose body is `Promise.resolve(confirm(message))`. 132function ask(message, element, submitter) { 133 const configured = Turbo.config.forms.confirm 134 const method = typeof configured === "function" 135 ? configured 136 : (text) => Promise.resolve(window.confirm(text)) 137 return Promise.resolve(method(message, element, submitter)) 138} 139 140// Something else owns this question: the element, or anything it sits inside, says so. 141function someoneElseAsks(...elements) { 142 return elements.some((element) => element instanceof Element && element.closest(`[${HANDLED_ATTRIBUTE}]`)) 143} 144 145// The one we raised ourselves after a "yes" â let it through, and forget it again immediately, so 146// the NEXT press of the same button is a fresh question. 147function isOurOwnRedispatch(element) { 148 if (!answered.has(element)) return false 149 answered.delete(element) 150 return true 151} 152 153function onSubmit(event) { 154 const form = event.target 155 if (!(form instanceof HTMLFormElement)) return 156 if (isOurOwnRedispatch(form)) return 157 if (event.defaultPrevented) return 158 const submitter = event.submitter || undefined 159 const message = confirmMessage(submitter, form) 160 if (message === null) return 161 if (someoneElseAsks(submitter, form)) return 162 if (turboAsksAlready(form, submitter)) return 163 164 // Cancelled until answered: preventDefault stops the native submission, stopPropagation keeps 165 // every downstream listener (feedback_controller's spinner, Turbo's own bubble handler) from 166 // reacting to a submission that may never happen. 167 event.preventDefault() 168 event.stopPropagation()
169 ask(message, form, submitter).then((yes) => { 170 if (!yes) return 171 inTheNextTask(() => { 172 answered.add(form) 173 form.requestSubmit(submitter) 174 }) 175 }) 176} 177 178// Links, for completeness of the contract: `link_to â¦, data: { turbo_method: :delete, 179// turbo_confirm: "â¦" }` renders an <a> that Turbo, with Drive off, also leaves alone. No view in 180// the tree writes one today (every confirm site is a form â see the inventory in 181// ror-session-tools/b17315-confirm-inventory.py), so this half is the promise for the next one 182// rather than a fix for a page that exists. 183function onClick(event) { 184 if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return 185 const target = event.target 186 if (!(target instanceof Element)) return 187 const link = target.closest(`a[${CONFIRM_ATTRIBUTE}]`) 188 if (!link) return 189 if (isOurOwnRedispatch(link)) return 190 if (event.defaultPrevented) return 191 const message = link.getAttribute(CONFIRM_ATTRIBUTE) 192 if (someoneElseAsks(link)) return 193 if (turboWouldNavigate(link)) return 194 195 event.preventDefault() 196 event.stopPropagation() 197 ask(message, link, undefined).then((yes) => { 198 if (!yes) return 199 inTheNextTask(() => { 200 answered.add(link) 201 link.click() 202 }) 203 }) 204} 205 206export function enableConfirmWithoutDrive(root = document) { 207 root.addEventListener("submit", onSubmit, true) 208 root.addEventListener("click", onClick, true) 209}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.