Skip to content

Commit 80942f6

Browse files
authored
ENG-6500: Add vite-plugin-safari-cachebust to smooth out HMR issues (#5513)
* ENG-6500: Add vite-plugin-safari-cachebust to smooth out HMR issues Safari with react-router has a fairly long standing issue where key modules are aggressively cached, which results in code mismatch during hot reload that cannot be resolved even by refreshing the browser. It requires a Shift+Refresh to bypass the cache, which isn't a great DX. This plugin rewrites the resulting HTML in dev mode to add timestamp query parameter to modulepreload links in an attempt to get Safari to reload stuff from the network. Not needed in prod mode because those files already contain a content hash in the filename. * Simplify logic of rewriteModuleImports Generate an array of pattern, replacement pairs, and then reduce those over the original html at the end. * Convert plugin to JS with jsdoc * console log when the plugin is first activated * rephrase log message * rich markup escape streamed process output
1 parent 33de56b commit 80942f6

File tree

3 files changed

+164
-2
lines changed

3 files changed

+164
-2
lines changed
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/* vite-plugin-safari-cachebust.js
2+
*
3+
* Rewrite modulepreload <link> tags and ESM imports to include a cache-busting
4+
* query parameter for Safari browser.
5+
*
6+
* https://github.com/remix-run/react-router/issues/12761
7+
*
8+
* The issue seems to be Safari over-aggressive caching of ESM imports (and modulepreload)
9+
* which does not respect the cache-control headers sent by the server. This approach
10+
* allows hot reload to work in Safari when adding routes or changing dependencies.
11+
*
12+
* No equivalent transformation is needed for production builds, as the
13+
* output already contains the file hash in the name.
14+
*/
15+
16+
/**
17+
* @typedef {import('vite').Plugin} Plugin
18+
* @typedef {import('vite').ViteDevServer} ViteDevServer
19+
* @typedef {import('http').IncomingMessage} IncomingMessage
20+
* @typedef {import('http').ServerResponse} ServerResponse
21+
* @typedef {import('connect').NextHandleFunction} NextHandleFunction
22+
*/
23+
24+
const pluginName = "vite-plugin-safari-cachebust";
25+
26+
/**
27+
* Creates a Vite plugin that adds cache-busting for Safari browsers
28+
* @returns {Plugin} The Vite plugin
29+
*/
30+
export default function safariCacheBustPlugin() {
31+
return {
32+
name: pluginName,
33+
/**
34+
* Configure the dev server with the Safari middleware
35+
* @param {ViteDevServer} server - The Vite dev server instance
36+
*/
37+
configureServer(server) {
38+
server.middlewares.use(createSafariMiddleware());
39+
},
40+
};
41+
}
42+
43+
/**
44+
* Determines if the user agent is Safari
45+
* @param {string} ua - The user agent string
46+
* @returns {boolean} True if the browser is Safari
47+
*/
48+
function isSafari(ua) {
49+
return /Safari/.test(ua) && !/Chrome/.test(ua);
50+
}
51+
52+
/**
53+
* Creates a middleware that adds cache-busting for Safari browsers
54+
* @returns {NextHandleFunction} The middleware function
55+
*/
56+
function createSafariMiddleware() {
57+
// Set when a log message for rewriting n links has been emitted.
58+
let _have_logged_n = -1;
59+
60+
/**
61+
* Rewrites module import links in HTML content with cache-busting parameters
62+
* @param {string} html - The HTML content to process
63+
* @returns {string} The processed HTML content
64+
*/
65+
function rewriteModuleImports(html) {
66+
const currentTimestamp = new Date().getTime();
67+
const parts = html.split(/(<link\s+rel="modulepreload"[^>]*>)/g);
68+
/** @type {[string, string][]} */
69+
const replacements = parts
70+
.map((chunk) => {
71+
const match = chunk.match(
72+
/<link\s+rel="modulepreload"\s+href="([^"]+)"(.*?)\/?>/,
73+
);
74+
if (!match) return;
75+
76+
const [fullMatch, href, rest] = match;
77+
if (/^(https?:)?\/\//.test(href)) return;
78+
79+
try {
80+
const newHref = href.includes("?")
81+
? `${href}&__reflex_ts=${currentTimestamp}`
82+
: `${href}?__reflex_ts=${currentTimestamp}`;
83+
return [href, newHref];
84+
} catch {
85+
// no worries;
86+
}
87+
})
88+
.filter(Boolean);
89+
if (replacements.length && _have_logged_n !== replacements.length) {
90+
_have_logged_n = replacements.length;
91+
console.debug(
92+
`[${pluginName}] Rewrote ${replacements.length} modulepreload links with __reflex_ts param.`,
93+
);
94+
}
95+
return replacements.reduce((accumulator, [target, replacement]) => {
96+
return accumulator.split(target).join(replacement);
97+
}, html);
98+
}
99+
100+
/**
101+
* Middleware function to handle Safari cache busting
102+
* @param {IncomingMessage} req - The incoming request
103+
* @param {ServerResponse} res - The server response
104+
* @param {(err?: any) => void} next - The next middleware function
105+
* @returns {void}
106+
*/
107+
return function safariCacheBustMiddleware(req, res, next) {
108+
const ua = req.headers["user-agent"] || "";
109+
// Remove our special cache bust query param to avoid affecting lower middleware layers.
110+
if (
111+
req.url &&
112+
(req.url.includes("?__reflex_ts=") || req.url.includes("&__reflex_ts="))
113+
) {
114+
req.url = req.url.replace(/(\?|&)__reflex_ts=\d+/, "");
115+
return next();
116+
}
117+
118+
// Only apply this middleware for Safari browsers.
119+
if (!isSafari(ua)) return next();
120+
121+
// Only transform requests that want HTML.
122+
const header_accept = req.headers["accept"] || "";
123+
if (
124+
typeof header_accept !== "string" ||
125+
!header_accept.includes("text/html")
126+
) {
127+
return next();
128+
}
129+
130+
let buffer = "";
131+
const _end = res.end.bind(res);
132+
133+
res.setHeader("x-modified-by", "vite-plugin-safari-cachebust");
134+
/**
135+
* Overridden write method to collect chunks
136+
* @param {any} chunk - The chunk to write
137+
* @param {...any} args - Additional arguments
138+
* @returns {boolean} Result of the write operation
139+
*/
140+
res.write = function (chunk, ...args) {
141+
buffer += chunk instanceof Buffer ? chunk.toString("utf-8") : chunk;
142+
return true;
143+
};
144+
145+
/**
146+
* Overridden end method to process and send the final response
147+
* @param {any} chunk - The final chunk to write
148+
* @param {...any} args - Additional arguments
149+
* @returns {ServerResponse<IncomingMessage>} The server response
150+
*/
151+
res.end = function (chunk, ...args) {
152+
if (chunk) {
153+
buffer += chunk instanceof Buffer ? chunk.toString("utf-8") : chunk;
154+
}
155+
buffer = rewriteModuleImports(buffer);
156+
return _end(buffer, ...args);
157+
};
158+
return next();
159+
};
160+
}

reflex/.templates/web/vite.config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { fileURLToPath, URL } from "url";
22
import { reactRouter } from "@react-router/dev/vite";
33
import { defineConfig } from "vite";
4+
import safariCacheBustPlugin from "./vite-plugin-safari-cachebust";
45

56
export default defineConfig((config) => ({
6-
plugins: [reactRouter()],
7+
plugins: [reactRouter(), safariCacheBustPlugin()],
78
build: {
89
rollupOptions: {
910
jsx: {},

reflex/utils/processes.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from typing import Any, Literal, overload
1616

1717
import click
18+
import rich.markup
1819
from redis.exceptions import RedisError
1920
from rich.progress import Progress
2021

@@ -281,7 +282,7 @@ def stream_logs(
281282
return
282283
try:
283284
for line in process.stdout:
284-
console.debug(line, end="", progress=progress)
285+
console.debug(rich.markup.escape(line), end="", progress=progress)
285286
logs.append(line)
286287
yield line
287288
except ValueError:

0 commit comments

Comments
 (0)