1import { Controller } from "@hotwired/stimulus" 2 3// Budgets survive Turbo visits/remounts within this document. A full page load 4// can retry pending delivery with the same durable per-user transaction ID. 5const dispatches = new Map() 6const POLL_INTERVAL = 15000 7const CALLBACK_RETRY_DELAY = 30000 8const MAX_TRACKING_TIME = 5 * 60 * 1000 9const MAX_DISPATCH_ATTEMPTS = 3 10const MAX_ACKNOWLEDGMENT_ATTEMPTS = 3 11 12export default class extends Controller { 13 static values = { url: String, transactionId: String } 14 15 connect() { 16 if (document.documentElement.hasAttribute("data-turbo-preview")) return 17 this.checking = false 18 this.finished = false 19 if (!dispatches.has(this.transactionIdValue)) { 20 dispatches.set(this.transactionIdValue, { 21 expiresAt: Date.now() + MAX_TRACKING_TIME, 22 dispatchAttempts: 0, 23 acknowledgmentAttempts: 0, 24 callbackReceived: false 25 }) 26 } 27 this.delivery = dispatches.get(this.transactionIdValue) 28 this.deadline = setTimeout(() => this.finish(), Math.max(0, this.delivery.expiresAt - Date.now())) 29 this.onVisible = () => this.check() 30 document.addEventListener("visibilitychange", this.onVisible) 31 this.timer = setInterval(() => this.check(), POLL_INTERVAL) 32 this.check() 33 } 34 35 disconnect() { 36 clearInterval(this.timer) 37 clearTimeout(this.deadline) 38 document.removeEventListener("visibilitychange", this.onVisible) 39 } 40 41 async check() { 42 if (this.finished || this.checking || document.visibilityState !== "visible") return 43 if (Date.now() >= this.delivery.expiresAt || 44 this.delivery.acknowledgmentAttempts >= MAX_ACKNOWLEDGMENT_ATTEMPTS || 45 (!this.delivery.callbackReceived && this.delivery.dispatchAttempts >= MAX_DISPATCH_ATTEMPTS)) return this.finish() 46 this.checking = true 47 48 try { 49 const response = await this.request() 50 if (response.status === 401) return this.finish() 51 if (!response.ok) return 52 const result = await response.json() 53 if (this.finished || !this.element.isConnected || document.visibilityState !== "visible") return 54 // A different tab may have switched the signed-in account. 55 if (result.transaction_id !== this.transactionIdValue) return this.finish() 56 if (["dispatched", "legacy", "excluded"].includes(result.status) || !result.tracking_needed) return this.finish() 57 if (result.status !== "pending" || !result.conversion) return 58 59 if (this.delivery.callbackReceived) return await this.acknowledge() 60 if (this.delivery.dispatchAttempts && Date.now() - this.delivery.startedAt < CALLBACK_RETRY_DELAY) return 61 if (typeof window.gtag !== "function") return 62 63 this.delivery.startedAt = Date.now() 64 this.delivery.dispatchAttempts += 1 65 window.gtag("event", "conversion", { 66 ...result.conversion, 67 transport_type: "beacon", 68 event_callback: () => { 69 // This confirms dispatch through the tag, not receipt/attribution 70 // by Google. If acknowledgment fails, retry it on the next check. 71 this.delivery.callbackReceived = true 72 this.acknowledge() 73 } 74 }) 75 } catch { 76 // Analytics must never interrupt recording. Reconcile on the next poll. 77 } finally { 78 this.checking = false 79 } 80 } 81 82 async acknowledge() { 83 const delivery = this.delivery 84 if (delivery.acknowledging || delivery.acknowledged) return 85 if (delivery.acknowledgmentAttempts >= MAX_ACKNOWLEDGMENT_ATTEMPTS) return this.finish() 86 delivery.acknowledging = true 87 delivery.acknowledgmentAttempts += 1 88 try { 89 const response = await this.request({ 90 method: "POST", 91 headers: { 92 "Content-Type": "application/json", 93 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content || "" 94 }, 95 body: JSON.stringify({ transaction_id: this.transactionIdValue }) 96 }) 97 if (response.ok) { 98 delivery.acknowledged = true 99 this.finish() 100 } 101 } catch { 102 // Keep callbackReceived so a later poll retries only acknowledgment. 103 } finally { 104 delivery.acknowledging = false 105 if (delivery.acknowledgmentAttempts >= MAX_ACKNOWLEDGMENT_ATTEMPTS) this.finish() 106 } 107 } 108 109 request(options = {}) { 110 return fetch(this.urlValue, { 111 credentials: "same-origin", 112 cache: "no-store", 113 redirect: "error", 114 signal: AbortSignal.timeout(10000), 115 ...options, 116 headers: { Accept: "application/json", ...options.headers } 117 }) 118 } 119 120 finish() { 121 this.finished = true 122 clearInterval(this.timer) 123 clearTimeout(this.deadline) 124 } 125}
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.