From ebf8490c2b80faa99f9bfe4828b255140bd0ff02 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 3 Nov 2025 14:46:10 +0100 Subject: [PATCH] Use Turbo.fetch to prevent page refreshes bounces when using rails/requests --- config/importmap.rb | 2 +- vendor/javascript/@rails--request.js | 4 - vendor/javascript/rails-request.js | 308 +++++++++++++++++++++++++++ 3 files changed, 309 insertions(+), 5 deletions(-) delete mode 100644 vendor/javascript/@rails--request.js create mode 100644 vendor/javascript/rails-request.js diff --git a/config/importmap.rb b/config/importmap.rb index f1da52737..698815341 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -4,7 +4,7 @@ pin "application" pin "@hotwired/turbo-rails", to: "turbo.min.js" pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" -pin "@rails/request.js", to: "@rails--request.js" # @0.0.11 +pin "@rails/request.js", to: "rails-request.js" # @0.0.11 pin_all_from "app/javascript/controllers", under: "controllers" pin_all_from "app/javascript/helpers", under: "helpers" diff --git a/vendor/javascript/@rails--request.js b/vendor/javascript/@rails--request.js deleted file mode 100644 index b85d5c78a..000000000 --- a/vendor/javascript/@rails--request.js +++ /dev/null @@ -1,4 +0,0 @@ -// @rails/request.js@0.0.11 downloaded from https://ga.jspm.io/npm:@rails/request.js@0.0.11/src/index.js - -class FetchResponse{constructor(t){this.response=t}get statusCode(){return this.response.status}get redirected(){return this.response.redirected}get ok(){return this.response.ok}get unauthenticated(){return this.statusCode===401}get unprocessableEntity(){return this.statusCode===422}get authenticationURL(){return this.response.headers.get("WWW-Authenticate")}get contentType(){const t=this.response.headers.get("Content-Type")||"";return t.replace(/;.*$/,"")}get headers(){return this.response.headers}get html(){return this.contentType.match(/^(application|text)\/(html|xhtml\+xml)$/)?this.text:Promise.reject(new Error(`Expected an HTML response but got "${this.contentType}" instead`))}get json(){return this.contentType.match(/^application\/.*json$/)?this.responseJson||(this.responseJson=this.response.json()):Promise.reject(new Error(`Expected a JSON response but got "${this.contentType}" instead`))}get text(){return this.responseText||(this.responseText=this.response.text())}get isTurboStream(){return this.contentType.match(/^text\/vnd\.turbo-stream\.html/)}get isScript(){return this.contentType.match(/\b(?:java|ecma)script\b/)}async renderTurboStream(){if(!this.isTurboStream)return Promise.reject(new Error(`Expected a Turbo Stream response but got "${this.contentType}" instead`));window.Turbo?await window.Turbo.renderStreamMessage(await this.text):console.warn("You must set `window.Turbo = Turbo` to automatically process Turbo Stream events with request.js")}async activeScript(){if(!this.isScript)return Promise.reject(new Error(`Expected a Script response but got "${this.contentType}" instead`));{const t=document.createElement("script");const e=document.querySelector("meta[name=csp-nonce]");const n=e&&e.content;n&&t.setAttribute("nonce",n);t.innerHTML=await this.text;document.body.appendChild(t)}}}class RequestInterceptor{static register(t){this.interceptor=t}static get(){return this.interceptor}static reset(){this.interceptor=void 0}}function getCookie(t){const e=document.cookie?document.cookie.split("; "):[];const n=`${encodeURIComponent(t)}=`;const s=e.find((t=>t.startsWith(n)));if(s){const t=s.split("=").slice(1).join("=");if(t)return decodeURIComponent(t)}}function compact(t){const e={};for(const n in t){const s=t[n];s!==void 0&&(e[n]=s)}return e}function metaContent(t){const e=document.head.querySelector(`meta[name="${t}"]`);return e&&e.content}function stringEntriesFromFormData(t){return[...t].reduce(((t,[e,n])=>t.concat(typeof n==="string"?[[e,n]]:[])),[])}function mergeEntries(t,e){for(const[n,s]of e)if(!(s instanceof window.File))if(t.has(n)&&!n.includes("[]")){t.delete(n);t.set(n,s)}else t.append(n,s)}class FetchRequest{constructor(t,e,n={}){this.method=t;this.options=n;this.originalUrl=e.toString()}async perform(){try{const t=RequestInterceptor.get();t&&await t(this)}catch(t){console.error(t)}const t=this.responseKind==="turbo-stream"&&window.Turbo?window.Turbo.fetch:window.fetch;const e=new FetchResponse(await t(this.url,this.fetchOptions));if(e.unauthenticated&&e.authenticationURL)return Promise.reject(window.location.href=e.authenticationURL);e.isScript&&await e.activeScript();const n=e.ok||e.unprocessableEntity;n&&e.isTurboStream&&await e.renderTurboStream();return e}addHeader(t,e){const n=this.additionalHeaders;n[t]=e;this.options.headers=n}sameHostname(){if(!this.originalUrl.startsWith("http:"))return true;try{return new URL(this.originalUrl).hostname===window.location.hostname}catch(t){return true}}get fetchOptions(){return{method:this.method.toUpperCase(),headers:this.headers,body:this.formattedBody,signal:this.signal,credentials:this.credentials,redirect:this.redirect}}get headers(){const t={"X-Requested-With":"XMLHttpRequest","Content-Type":this.contentType,Accept:this.accept};this.sameHostname()&&(t["X-CSRF-Token"]=this.csrfToken);return compact(Object.assign(t,this.additionalHeaders))}get csrfToken(){return getCookie(metaContent("csrf-param"))||metaContent("csrf-token")}get contentType(){return this.options.contentType?this.options.contentType:this.body==null||this.body instanceof window.FormData?void 0:this.body instanceof window.File?this.body.type:"application/json"}get accept(){switch(this.responseKind){case"html":return"text/html, application/xhtml+xml";case"turbo-stream":return"text/vnd.turbo-stream.html, text/html, application/xhtml+xml";case"json":return"application/json, application/vnd.api+json";case"script":return"text/javascript, application/javascript";default:return"*/*"}}get body(){return this.options.body}get query(){const t=(this.originalUrl.split("?")[1]||"").split("#")[0];const e=new URLSearchParams(t);let n=this.options.query;n=n instanceof window.FormData?stringEntriesFromFormData(n):n instanceof window.URLSearchParams?n.entries():Object.entries(n||{});mergeEntries(e,n);const s=e.toString();return s.length>0?`?${s}`:""}get url(){return this.originalUrl.split("?")[0].split("#")[0]+this.query}get responseKind(){return this.options.responseKind||"html"}get signal(){return this.options.signal}get redirect(){return this.options.redirect||"follow"}get credentials(){return this.options.credentials||"same-origin"}get additionalHeaders(){return this.options.headers||{}}get formattedBody(){const t=Object.prototype.toString.call(this.body)==="[object String]";const e=this.headers["Content-Type"]==="application/json";return e&&!t?JSON.stringify(this.body):this.body}}async function get(t,e){const n=new FetchRequest("get",t,e);return n.perform()}async function post(t,e){const n=new FetchRequest("post",t,e);return n.perform()}async function put(t,e){const n=new FetchRequest("put",t,e);return n.perform()}async function patch(t,e){const n=new FetchRequest("patch",t,e);return n.perform()}async function destroy(t,e){const n=new FetchRequest("delete",t,e);return n.perform()}export{FetchRequest,FetchResponse,RequestInterceptor,destroy,get,patch,post,put}; - diff --git a/vendor/javascript/rails-request.js b/vendor/javascript/rails-request.js new file mode 100644 index 000000000..4b8d648ce --- /dev/null +++ b/vendor/javascript/rails-request.js @@ -0,0 +1,308 @@ +// Patched to use Turbo.fetch. @TODO Upstream this. +class FetchResponse { + constructor(response) { + this.response = response; + } + get statusCode() { + return this.response.status; + } + get redirected() { + return this.response.redirected; + } + get ok() { + return this.response.ok; + } + get unauthenticated() { + return this.statusCode === 401; + } + get unprocessableEntity() { + return this.statusCode === 422; + } + get authenticationURL() { + return this.response.headers.get("WWW-Authenticate"); + } + get contentType() { + const contentType = this.response.headers.get("Content-Type") || ""; + return contentType.replace(/;.*$/, ""); + } + get headers() { + return this.response.headers; + } + get html() { + if (this.contentType.match(/^(application|text)\/(html|xhtml\+xml)$/)) { + return this.text; + } + return Promise.reject(new Error(`Expected an HTML response but got "${this.contentType}" instead`)); + } + get json() { + if (this.contentType.match(/^application\/.*json$/)) { + return this.responseJson || (this.responseJson = this.response.json()); + } + return Promise.reject(new Error(`Expected a JSON response but got "${this.contentType}" instead`)); + } + get text() { + return this.responseText || (this.responseText = this.response.text()); + } + get isTurboStream() { + return this.contentType.match(/^text\/vnd\.turbo-stream\.html/); + } + get isScript() { + return this.contentType.match(/\b(?:java|ecma)script\b/); + } + async renderTurboStream() { + if (this.isTurboStream) { + if (window.Turbo) { + await window.Turbo.renderStreamMessage(await this.text); + } else { + console.warn("You must set `window.Turbo = Turbo` to automatically process Turbo Stream events with request.js"); + } + } else { + return Promise.reject(new Error(`Expected a Turbo Stream response but got "${this.contentType}" instead`)); + } + } + async activeScript() { + if (this.isScript) { + const script = document.createElement("script"); + const metaTag = document.querySelector("meta[name=csp-nonce]"); + if (metaTag) { + const nonce = metaTag.nonce === "" ? metaTag.content : metaTag.nonce; + if (nonce) { + script.setAttribute("nonce", nonce); + } + } + script.innerHTML = await this.text; + document.body.appendChild(script); + } else { + return Promise.reject(new Error(`Expected a Script response but got "${this.contentType}" instead`)); + } + } +} + +class RequestInterceptor { + static register(interceptor) { + this.interceptor = interceptor; + } + static get() { + return this.interceptor; + } + static reset() { + this.interceptor = undefined; + } +} + +function getCookie(name) { + const cookies = document.cookie ? document.cookie.split("; ") : []; + const prefix = `${encodeURIComponent(name)}=`; + const cookie = cookies.find((cookie => cookie.startsWith(prefix))); + if (cookie) { + const value = cookie.split("=").slice(1).join("="); + if (value) { + return decodeURIComponent(value); + } + } +} + +function compact(object) { + const result = {}; + for (const key in object) { + const value = object[key]; + if (value !== undefined) { + result[key] = value; + } + } + return result; +} + +function metaContent(name) { + const element = document.head.querySelector(`meta[name="${name}"]`); + return element && element.content; +} + +function stringEntriesFromFormData(formData) { + return [ ...formData ].reduce(((entries, [name, value]) => entries.concat(typeof value === "string" ? [ [ name, value ] ] : [])), []); +} + +function mergeEntries(searchParams, entries) { + for (const [name, value] of entries) { + if (value instanceof window.File) continue; + if (searchParams.has(name) && !name.includes("[]")) { + searchParams.delete(name); + searchParams.set(name, value); + } else { + searchParams.append(name, value); + } + } +} + +class FetchRequest { + constructor(method, url, options = {}) { + this.method = method; + this.options = options; + this.originalUrl = url.toString(); + } + async perform() { + try { + const requestInterceptor = RequestInterceptor.get(); + if (requestInterceptor) { + await requestInterceptor(this); + } + } catch (error) { + console.error(error); + } + const fetch = this.responseKind === "turbo-stream" && window.Turbo ? window.Turbo.fetch : window.fetch; + const response = new FetchResponse(await Turbo.fetch(this.url, this.fetchOptions)); + if (response.unauthenticated && response.authenticationURL) { + return Promise.reject(window.location.href = response.authenticationURL); + } + if (response.isScript) { + await response.activeScript(); + } + const responseStatusIsTurboStreamable = response.ok || response.unprocessableEntity; + if (responseStatusIsTurboStreamable && response.isTurboStream) { + await response.renderTurboStream(); + } + return response; + } + addHeader(key, value) { + const headers = this.additionalHeaders; + headers[key] = value; + this.options.headers = headers; + } + sameHostname() { + if (!this.originalUrl.startsWith("http:") && !this.originalUrl.startsWith("https:")) { + return true; + } + try { + return new URL(this.originalUrl).hostname === window.location.hostname; + } catch (_) { + return true; + } + } + get fetchOptions() { + return { + method: this.method.toUpperCase(), + headers: this.headers, + body: this.formattedBody, + signal: this.signal, + credentials: this.credentials, + redirect: this.redirect, + keepalive: this.keepalive + }; + } + get headers() { + const baseHeaders = { + "X-Requested-With": "XMLHttpRequest", + "Content-Type": this.contentType, + Accept: this.accept + }; + if (this.sameHostname()) { + baseHeaders["X-CSRF-Token"] = this.csrfToken; + } + return compact(Object.assign(baseHeaders, this.additionalHeaders)); + } + get csrfToken() { + return getCookie(metaContent("csrf-param")) || metaContent("csrf-token"); + } + get contentType() { + if (this.options.contentType) { + return this.options.contentType; + } else if (this.body == null || this.body instanceof window.FormData) { + return undefined; + } else if (this.body instanceof window.File) { + return this.body.type; + } + return "application/json"; + } + get accept() { + switch (this.responseKind) { + case "html": + return "text/html, application/xhtml+xml"; + + case "turbo-stream": + return "text/vnd.turbo-stream.html, text/html, application/xhtml+xml"; + + case "json": + return "application/json, application/vnd.api+json"; + + case "script": + return "text/javascript, application/javascript"; + + default: + return "*/*"; + } + } + get body() { + return this.options.body; + } + get query() { + const originalQuery = (this.originalUrl.split("?")[1] || "").split("#")[0]; + const params = new URLSearchParams(originalQuery); + let requestQuery = this.options.query; + if (requestQuery instanceof window.FormData) { + requestQuery = stringEntriesFromFormData(requestQuery); + } else if (requestQuery instanceof window.URLSearchParams) { + requestQuery = requestQuery.entries(); + } else { + requestQuery = Object.entries(requestQuery || {}); + } + mergeEntries(params, requestQuery); + const query = params.toString(); + return query.length > 0 ? `?${query}` : ""; + } + get url() { + return this.originalUrl.split("?")[0].split("#")[0] + this.query; + } + get responseKind() { + return this.options.responseKind || "html"; + } + get signal() { + return this.options.signal; + } + get redirect() { + return this.options.redirect || "follow"; + } + get credentials() { + return this.options.credentials || "same-origin"; + } + get keepalive() { + return this.options.keepalive || false; + } + get additionalHeaders() { + return this.options.headers || {}; + } + get formattedBody() { + const bodyIsAString = Object.prototype.toString.call(this.body) === "[object String]"; + const contentTypeIsJson = this.headers["Content-Type"] === "application/json"; + if (contentTypeIsJson && !bodyIsAString) { + return JSON.stringify(this.body); + } + return this.body; + } +} + +async function get(url, options) { + const request = new FetchRequest("get", url, options); + return request.perform(); +} + +async function post(url, options) { + const request = new FetchRequest("post", url, options); + return request.perform(); +} + +async function put(url, options) { + const request = new FetchRequest("put", url, options); + return request.perform(); +} + +async function patch(url, options) { + const request = new FetchRequest("patch", url, options); + return request.perform(); +} + +async function destroy(url, options) { + const request = new FetchRequest("delete", url, options); + return request.perform(); +} + +export { FetchRequest, FetchResponse, RequestInterceptor, destroy, get, patch, post, put };