PageSourceSearch

https://app.wpml.org/assets/lib/confirm_without_drive-e3d50c2c.js

js wpml.org collected 2026-09-24 06:48:53 UTC 11,513 bytes, 209 lines download raw bytes

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.