1import { Controller } from '@hotwired/stimulus' 2 3/* global setTimeout, clearTimeout, document, Event */ 4 5// Connects to data-controller='register--retry' 6// 7// Every step submits through Turbo, so a throttled or restarting server is a response to 8// take over rather than an error page the rider lands on with their filled-out step 9// behind them. Retry behind the submit spinner; when a retry won't help, say so and give 10// the button back - Turbo can't, since rack_attack answers text/plain, which it renders 11// as nothing. 12export default class extends Controller { 13 static values = { 14 retries: { type: Number, default: 2 }, 15 delay: { type: Number, default: 500 }, 16 // Past this a spinner is worse than saying so - throttles ask for tens of seconds 17 maxDelay: { type: Number, default: 3000 } 18 } 19 20 connect () { 21 this.retried = 0 22 this.element.addEventListener('turbo:before-fetch-response', this.retryTransient) 23 this.element.addEventListener('turbo:submit-end', this.holdSubmit) 24 } 25 26 disconnect () { 27 this.element.removeEventListener('turbo:before-fetch-response', this.retryTransient) 28 this.element.removeEventListener('turbo:submit-end', this.holdSubmit) 29 clearTimeout(this.timer) 30 } 31 32 // Only what a second try could answer differently: a throttle and the 5xxs - a step 33 // re-renders itself 422 when what was entered is the problem 34 retryTransient = (event) => { 35 // It bubbles, so it carries what Turbo fetches for elements inside the form too - 36 // the combobox's paginated options frame is nobody's submission 37 if (event.target !== this.element) return 38 39 const response = event.detail.fetchResponse?.response 40 const status = response?.status 41 if (!(status === 429 || (status >= 500 && status < 600))) return 42 43 event.preventDefault() // ours to handle now, so Turbo leaves the page alone 44 const delay = this.delayFor(response) 45 if (delay === null) return this.giveUp() 46 47 this.retried += 1 48 this.retrying = true 49 this.timer = setTimeout(() => { 50 this.retrying = false 51 this.element.requestSubmit() // no submitter, so the held button doesn't hold this back 52 }, delay) 53 } 54 55 // null when another try is pointless: the attempts are spent, or the wait asked for is 56 // longer than we'll hold them for 57 delayFor (response) { 58 if (this.retried >= this.retriesValue) return null 59 60 const retryAfter = Number(response?.headers?.get('retry-after')) * 1000 61 if (!retryAfter) return this.delayValue * (this.retried + 1) 62 63 return (retryAfter > this.maxDelayValue) ? null : retryAfter 64 } 65 66 giveUp () { 67 this.retrying = false 68 // The notice asks for another try, so that try gets a full budget of its own 69 this.retried = 0 70 // The notice lives on the page shell, which can be a screen above the button they 71 // pressed - unscrolled, the failure looks like a spinner that stopped 72 const notice = document.querySelector('[data-register-retry-notice]') 73 notice?.removeAttribute('hidden') 74 notice?.scrollIntoView({ behavior: 'smooth', block: 'center' }) 75 this.submitButtons.forEach((button) => button.dispatchEvent(new Event('spinner:reset'))) 76 } 77 78 // Turbo re-enables the submitter once the submission finishes - but the submit hasn't, 79 // and a second click during the wait would submit the step twice 80 holdSubmit = () => { 81 if (!this.retrying) return 82 83 this.submitButtons.forEach((button) => { button.disabled = true }) 84 } 85 86 get submitButtons () { 87 return this.element.querySelectorAll('button[type="submit"]') 88 } 89};
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.