From 78c5193fc522791b8f4400a19f0bdaa0978286d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 21:55:52 +0000 Subject: [PATCH 1/7] Fix Safari cachebust plugin for Uint8Array response chunks The dev server middleware buffered the SSR response by string-concatenating each written chunk, decoding only `Buffer` instances. `@react-router/node` 8 re-wraps every render chunk as a plain `Uint8Array`, which coerced to a comma-separated list of byte values, so Safari received "60,33,100,..." instead of the page. Collect the raw bytes of any string or ArrayBufferView chunk and decode once in `end`. This also fixes multibyte characters that straddle a chunk boundary, which per-chunk decoding turned into replacement characters. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017ThL29nvh7hzpWzXX63DWs --- .../+safari-cachebust-uint8array.bugfix.md | 1 + .../web/vite-plugin-safari-cachebust.js | 28 +++- tests/units/reflex_base/templates/__init__.py | 0 .../test_vite_plugin_safari_cachebust.py | 121 ++++++++++++++++++ 4 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 packages/reflex-base/news/+safari-cachebust-uint8array.bugfix.md create mode 100644 tests/units/reflex_base/templates/__init__.py create mode 100644 tests/units/reflex_base/templates/test_vite_plugin_safari_cachebust.py diff --git a/packages/reflex-base/news/+safari-cachebust-uint8array.bugfix.md b/packages/reflex-base/news/+safari-cachebust-uint8array.bugfix.md new file mode 100644 index 00000000000..0ee12ec3b65 --- /dev/null +++ b/packages/reflex-base/news/+safari-cachebust-uint8array.bugfix.md @@ -0,0 +1 @@ +Fix the Safari dev-server cache-busting plugin rendering pages as comma-separated byte values with React Router 8, and keep multibyte characters intact when they span response chunks. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js index 02b9f39555a..29d493051bc 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js @@ -49,6 +49,16 @@ function isSafari(ua) { return /Safari/.test(ua) && !/Chrome/.test(ua); } +/** + * Converts a response chunk (string, Buffer or any ArrayBufferView) to a Buffer + * @param {any} chunk - The chunk written to the response + * @returns {Buffer} The chunk as a Buffer sharing the original memory when possible + */ +function toBuffer(chunk) { + if (typeof chunk === "string") return Buffer.from(chunk); + return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength); +} + /** * Creates a middleware that adds cache-busting for Safari browsers * @returns {NextHandleFunction} The middleware function @@ -127,7 +137,11 @@ function createSafariMiddleware() { return next(); } - let buffer = ""; + // Collect raw bytes and decode once at the end: the dev server may write + // plain Uint8Array chunks (not Buffer), and a multibyte character may span + // two chunks, so per-chunk string conversion corrupts the output. + /** @type {Buffer[]} */ + const chunks = []; const _end = res.end.bind(res); res.setHeader("x-modified-by", "vite-plugin-safari-cachebust"); @@ -138,7 +152,7 @@ function createSafariMiddleware() { * @returns {boolean} Result of the write operation */ res.write = function (chunk, ...args) { - buffer += chunk instanceof Buffer ? chunk.toString("utf-8") : chunk; + if (chunk) chunks.push(toBuffer(chunk)); return true; }; @@ -149,11 +163,11 @@ function createSafariMiddleware() { * @returns {ServerResponse} The server response */ res.end = function (chunk, ...args) { - if (chunk) { - buffer += chunk instanceof Buffer ? chunk.toString("utf-8") : chunk; - } - buffer = rewriteModuleImports(buffer); - return _end(buffer, ...args); + // res.end(callback) is valid: the callback is not a chunk. + if (typeof chunk === "function") args.unshift(chunk); + else if (chunk) chunks.push(toBuffer(chunk)); + const body = Buffer.concat(chunks).toString("utf-8"); + return _end(rewriteModuleImports(body), ...args); }; return next(); }; diff --git a/tests/units/reflex_base/templates/__init__.py b/tests/units/reflex_base/templates/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/units/reflex_base/templates/test_vite_plugin_safari_cachebust.py b/tests/units/reflex_base/templates/test_vite_plugin_safari_cachebust.py new file mode 100644 index 00000000000..4930357f4de --- /dev/null +++ b/tests/units/reflex_base/templates/test_vite_plugin_safari_cachebust.py @@ -0,0 +1,121 @@ +"""Tests for the Safari cache-busting Vite plugin shipped in the web template.""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest +import reflex_base + +PLUGIN_PATH = ( + Path(reflex_base.__file__).parent + / ".templates" + / "web" + / "vite-plugin-safari-cachebust.js" +) + +SAFARI_UA = "Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 Safari/605.1.15" + +# Drives the plugin's middleware with a fake request/response and prints the +# body handed to the underlying ``res.end`` so the python side can inspect it. +DRIVER = """ +import { pathToFileURL } from "node:url"; +const plugin = (await import(pathToFileURL(process.argv[2]).href)).default; +const { chunks, userAgent } = JSON.parse(process.argv[3]); +console.debug = () => {}; +let middleware; +plugin().configureServer({ middlewares: { use: (m) => (middleware = m) } }); +const req = { url: "/", headers: { "user-agent": userAgent, accept: "text/html" } }; +let body; +const res = { setHeader() {}, write() {}, end(chunk) { body = chunk; } }; +middleware(req, res, () => { + for (const [kind, text] of chunks) { + const bytes = new TextEncoder().encode(text); + if (kind === "string") res.write(text); + else if (kind === "buffer") res.write(Buffer.from(bytes)); + else if (kind === "uint8array") res.write(bytes); + else if (kind === "bytes") res.write(Buffer.from(JSON.parse(text))); + } + res.end(); +}); +process.stdout.write(String(body)); +""" + + +def _run_plugin( + chunks: list[tuple[str, str]], user_agent: str = SAFARI_UA, tmp_path: Path = Path() +) -> str: + """Send the given chunks through the plugin middleware in node. + + Args: + chunks: ``(kind, text)`` pairs written to the response in order. + user_agent: The request user agent. + tmp_path: Directory used to write the driver script. + + Returns: + The response body as seen by the wrapped ``res.end``. + """ + driver = tmp_path / "driver.mjs" + driver.write_text(DRIVER) + return subprocess.run( + [ + "node", + str(driver), + str(PLUGIN_PATH), + json.dumps({"chunks": chunks, "userAgent": user_agent}), + ], + check=True, + capture_output=True, + text=True, + ).stdout + + +HTML = '

ok

' + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node missing") + + +@pytest.mark.parametrize("kind", ["string", "buffer", "uint8array"]) +def test_rewrites_modulepreload_for_any_chunk_type(kind: str, tmp_path: Path): + """Chunks of every type the dev server may write are decoded as text. + + React Router 8 writes plain ``Uint8Array`` chunks (not ``Buffer``), which + previously got stringified as comma-separated byte values. + + Args: + kind: The chunk type to write. + tmp_path: Pytest temporary directory. + """ + body = _run_plugin([(kind, HTML)], tmp_path=tmp_path) + assert body.startswith("") + assert 'href="/app.js?__reflex_ts=' in body + assert body.endswith("

ok

") + + +def test_multibyte_character_split_across_chunks(tmp_path: Path): + """A UTF-8 sequence spanning two chunks is decoded intact. + + Args: + tmp_path: Pytest temporary directory. + """ + raw = list("

é

".encode()) + body = _run_plugin( + [("bytes", json.dumps(raw[:4])), ("bytes", json.dumps(raw[4:]))], + tmp_path=tmp_path, + ) + assert body == "

é

" + + +def test_non_safari_passthrough(tmp_path: Path): + """Non-Safari browsers get the response untouched. + + Args: + tmp_path: Pytest temporary directory. + """ + body = _run_plugin( + [("uint8array", HTML)], + user_agent="Mozilla/5.0 Chrome/120 Safari/537.36", + tmp_path=tmp_path, + ) + assert body == "undefined" From 11321f5e73ec1f2988c3b27c8c364e486b7c07bb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 22:04:39 +0000 Subject: [PATCH 2/7] Stream Safari cachebust rewrites instead of buffering the response The middleware previously collected the whole HTML body before rewriting it, delaying Safari's first byte until React finished rendering. Rewrite each chunk as it arrives instead: hrefs are learned from complete modulepreload tags and applied to every later occurrence, and only a possibly-partial tag or href at the end of a chunk is held back until the next one. Chunks are decoded with StringDecoder so plain Uint8Array chunks and split multibyte characters are handled correctly. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017ThL29nvh7hzpWzXX63DWs --- .../+safari-cachebust-uint8array.bugfix.md | 2 +- .../web/vite-plugin-safari-cachebust.js | 200 ++++++++++++------ .../test_vite_plugin_safari_cachebust.py | 132 +++++++++--- 3 files changed, 244 insertions(+), 90 deletions(-) diff --git a/packages/reflex-base/news/+safari-cachebust-uint8array.bugfix.md b/packages/reflex-base/news/+safari-cachebust-uint8array.bugfix.md index 0ee12ec3b65..172e5ed1e72 100644 --- a/packages/reflex-base/news/+safari-cachebust-uint8array.bugfix.md +++ b/packages/reflex-base/news/+safari-cachebust-uint8array.bugfix.md @@ -1 +1 @@ -Fix the Safari dev-server cache-busting plugin rendering pages as comma-separated byte values with React Router 8, and keep multibyte characters intact when they span response chunks. +Fix the Safari dev-server cache-busting plugin rendering pages as comma-separated byte values with React Router 8. The rewritten HTML now streams through instead of being buffered, and multibyte characters split across response chunks stay intact. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js index 29d493051bc..6fd3f8860d4 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js @@ -13,6 +13,8 @@ * output already contains the file hash in the name. */ +import { StringDecoder } from "node:string_decoder"; + /** * @typedef {import('vite').Plugin} Plugin * @typedef {import('vite').ViteDevServer} ViteDevServer @@ -22,6 +24,9 @@ */ const pluginName = "vite-plugin-safari-cachebust"; +const tsParam = "__reflex_ts"; +const linkTagRe = /]*>/g; +const tsUrlRe = new RegExp(`(\\?|&)${tsParam}=\\d+`); /** * Creates a Vite plugin that adds cache-busting for Safari browsers @@ -50,63 +55,110 @@ function isSafari(ua) { } /** - * Converts a response chunk (string, Buffer or any ArrayBufferView) to a Buffer - * @param {any} chunk - The chunk written to the response - * @returns {Buffer} The chunk as a Buffer sharing the original memory when possible + * Escapes a string for literal use inside a RegExp + * @param {string} text - The text to escape + * @returns {string} The escaped text */ -function toBuffer(chunk) { - if (typeof chunk === "string") return Buffer.from(chunk); - return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength); +function escapeRegExp(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** - * Creates a middleware that adds cache-busting for Safari browsers - * @returns {NextHandleFunction} The middleware function + * Creates a streaming rewriter for one HTML response. + * + * Hrefs are discovered from modulepreload tags and every later + * occurrence (e.g. the ESM imports in the trailing inline script) is rewritten + * as well. Text is emitted as soon as it arrives; only a possibly-partial + * tag or href at the end of a chunk is held back until the next chunk. + * @param {number} timestamp - The cache-bust value for this response + * @returns {{push(text: string, flush: boolean): string, count: number}} The rewriter */ -function createSafariMiddleware() { - // Set when a log message for rewriting n links has been emitted. - let _have_logged_n = -1; +function createRewriter(timestamp) { + /** @type {Map} */ + const replacements = new Map(); + let pending = ""; /** - * Rewrites module import links in HTML content with cache-busting parameters - * @param {string} html - The HTML content to process - * @returns {string} The processed HTML content + * Registers the hrefs of every complete modulepreload tag in the text + * @param {string} text - The text to scan */ - function rewriteModuleImports(html) { - const currentTimestamp = new Date().getTime(); - const parts = html.split(/(]*>)/g); - /** @type {[string, string][]} */ - const replacements = parts - .map((chunk) => { - const match = chunk.match( - //, - ); - if (!match) return; - - const [fullMatch, href, rest] = match; - if (/^(https?:)?\/\//.test(href)) return; - - try { - const newHref = href.includes("?") - ? `${href}&__reflex_ts=${currentTimestamp}` - : `${href}?__reflex_ts=${currentTimestamp}`; - return [href, newHref]; - } catch { - // no worries; + function discover(text) { + for (const [, href] of text.matchAll(linkTagRe)) { + if ( + replacements.has(href) || + /^(https?:)?\/\//.test(href) || + href.includes(`${tsParam}=`) + ) { + continue; + } + replacements.set(href, { + // Skip occurrences that already carry the param (held-back text is rescanned). + re: new RegExp(`${escapeRegExp(href)}(?![?&]${tsParam}=)`, "g"), + replacement: `${href}${href.includes("?") ? "&" : "?"}${tsParam}=${timestamp}`, + }); + } + } + + /** + * Finds how much of the end of the text must wait for the next chunk + * @param {string} text - The rewritten text + * @returns {number} The index at which the held-back tail starts + */ + function cutIndex(text) { + let cut = text.length; + // An unclosed tag that may turn out to be a modulepreload link. + const lt = text.lastIndexOf("<"); + if (lt !== -1 && text.indexOf(">", lt) === -1) { + const tail = text.slice(lt, lt + 5); + if (tail.length < 5 ? " keep; k--) { + if (text.startsWith(href.slice(0, k), cut - k)) { + keep = k; + break; } - }) - .filter(Boolean); - if (replacements.length && _have_logged_n !== replacements.length) { - _have_logged_n = replacements.length; - console.debug( - `[${pluginName}] Rewrote ${replacements.length} modulepreload links with __reflex_ts param.`, - ); + } } - return replacements.reduce((accumulator, [target, replacement]) => { - return accumulator.split(target).join(replacement); - }, html); + return cut - keep; } + return { + /** + * Feeds text through the rewriter + * @param {string} text - The newly decoded text + * @param {boolean} flush - Whether this is the end of the response + * @returns {string} The text that may be sent now + */ + push(text, flush) { + text = pending + text; + discover(text); + for (const { re, replacement } of replacements.values()) { + // A function replacer keeps "$" in hrefs from being read as a pattern. + text = text.replace(re, () => replacement); + } + const cut = flush ? text.length : cutIndex(text); + pending = text.slice(cut); + return text.slice(0, cut); + }, + get count() { + return replacements.size; + }, + }; +} + +/** + * Creates a middleware that adds cache-busting for Safari browsers + * @returns {NextHandleFunction} The middleware function + */ +function createSafariMiddleware() { + // Set when a log message for rewriting n links has been emitted. + let _have_logged_n = -1; + /** * Middleware function to handle Safari cache busting * @param {IncomingMessage} req - The incoming request @@ -119,9 +171,9 @@ function createSafariMiddleware() { // Remove our special cache bust query param to avoid affecting lower middleware layers. if ( req.url && - (req.url.includes("?__reflex_ts=") || req.url.includes("&__reflex_ts=")) + (req.url.includes(`?${tsParam}=`) || req.url.includes(`&${tsParam}=`)) ) { - req.url = req.url.replace(/(\?|&)__reflex_ts=\d+/, ""); + req.url = req.url.replace(tsUrlRe, ""); return next(); } @@ -137,37 +189,63 @@ function createSafariMiddleware() { return next(); } - // Collect raw bytes and decode once at the end: the dev server may write - // plain Uint8Array chunks (not Buffer), and a multibyte character may span - // two chunks, so per-chunk string conversion corrupts the output. - /** @type {Buffer[]} */ - const chunks = []; + const rewriter = createRewriter(Date.now()); + // Chunks may be Buffer or plain Uint8Array and may split a multibyte character. + const decoder = new StringDecoder("utf-8"); + const _write = res.write.bind(res); const _end = res.end.bind(res); - res.setHeader("x-modified-by", "vite-plugin-safari-cachebust"); /** - * Overridden write method to collect chunks + * Decodes a written chunk to text + * @param {any} chunk - The chunk passed to write/end, if any + * @returns {string} The decoded text + */ + const decode = (chunk) => + typeof chunk === "string" ? chunk : chunk ? decoder.write(chunk) : ""; + + /** + * Extracts the optional completion callback from write/end arguments + * @param {any[]} args - The arguments following the chunk + * @returns {((err?: Error) => void) | undefined} The callback, if given + */ + const callback = (args) => args.find((arg) => typeof arg === "function"); + + res.setHeader("x-modified-by", pluginName); + /** + * Overridden write method to rewrite chunks as they stream through * @param {any} chunk - The chunk to write * @param {...any} args - Additional arguments * @returns {boolean} Result of the write operation */ res.write = function (chunk, ...args) { - if (chunk) chunks.push(toBuffer(chunk)); + const out = rewriter.push(decode(chunk), false); + const cb = callback(args); + if (out) return cb ? _write(out, cb) : _write(out); + // Everything was held back for the next chunk; nothing is queued. + cb?.(); return true; }; /** - * Overridden end method to process and send the final response + * Overridden end method to flush held-back text and finish the response * @param {any} chunk - The final chunk to write * @param {...any} args - Additional arguments * @returns {ServerResponse} The server response */ res.end = function (chunk, ...args) { - // res.end(callback) is valid: the callback is not a chunk. - if (typeof chunk === "function") args.unshift(chunk); - else if (chunk) chunks.push(toBuffer(chunk)); - const body = Buffer.concat(chunks).toString("utf-8"); - return _end(rewriteModuleImports(body), ...args); + if (typeof chunk === "function") { + args.unshift(chunk); + chunk = undefined; + } + const out = rewriter.push(decode(chunk) + decoder.end(), true); + if (rewriter.count && _have_logged_n !== rewriter.count) { + _have_logged_n = rewriter.count; + console.debug( + `[${pluginName}] Rewrote ${rewriter.count} modulepreload links with ${tsParam} param.`, + ); + } + const cb = callback(args); + return cb ? _end(out, cb) : _end(out); }; return next(); }; diff --git a/tests/units/reflex_base/templates/test_vite_plugin_safari_cachebust.py b/tests/units/reflex_base/templates/test_vite_plugin_safari_cachebust.py index 4930357f4de..dd96f777987 100644 --- a/tests/units/reflex_base/templates/test_vite_plugin_safari_cachebust.py +++ b/tests/units/reflex_base/templates/test_vite_plugin_safari_cachebust.py @@ -1,6 +1,7 @@ """Tests for the Safari cache-busting Vite plugin shipped in the web template.""" import json +import re import shutil import subprocess from pathlib import Path @@ -17,8 +18,9 @@ SAFARI_UA = "Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 Safari/605.1.15" -# Drives the plugin's middleware with a fake request/response and prints the -# body handed to the underlying ``res.end`` so the python side can inspect it. +# Drives the plugin's middleware with a fake request/response and prints every +# body fragment handed to the underlying ``res.write``/``res.end`` so the python +# side can check both the final document and how it was streamed. DRIVER = """ import { pathToFileURL } from "node:url"; const plugin = (await import(pathToFileURL(process.argv[2]).href)).default; @@ -27,8 +29,16 @@ let middleware; plugin().configureServer({ middlewares: { use: (m) => (middleware = m) } }); const req = { url: "/", headers: { "user-agent": userAgent, accept: "text/html" } }; -let body; -const res = { setHeader() {}, write() {}, end(chunk) { body = chunk; } }; +const writes = []; +const headers = {}; +const res = { + setHeader(name, value) { headers[name] = value; }, + write(chunk) { writes.push(String(chunk)); return true; }, + end(chunk) { + const end = chunk === undefined ? null : String(chunk); + process.stdout.write(JSON.stringify({ writes, end, headers })); + }, +}; middleware(req, res, () => { for (const [kind, text] of chunks) { const bytes = new TextEncoder().encode(text); @@ -39,36 +49,49 @@ } res.end(); }); -process.stdout.write(String(body)); """ def _run_plugin( - chunks: list[tuple[str, str]], user_agent: str = SAFARI_UA, tmp_path: Path = Path() -) -> str: + chunks: list[tuple[str, str]], tmp_path: Path, user_agent: str = SAFARI_UA +) -> dict: """Send the given chunks through the plugin middleware in node. Args: chunks: ``(kind, text)`` pairs written to the response in order. - user_agent: The request user agent. tmp_path: Directory used to write the driver script. + user_agent: The request user agent. Returns: - The response body as seen by the wrapped ``res.end``. + The recorded ``writes``, ``end`` body and response ``headers``. """ driver = tmp_path / "driver.mjs" driver.write_text(DRIVER) - return subprocess.run( - [ - "node", - str(driver), - str(PLUGIN_PATH), - json.dumps({"chunks": chunks, "userAgent": user_agent}), - ], - check=True, - capture_output=True, - text=True, - ).stdout + return json.loads( + subprocess.run( + [ + "node", + str(driver), + str(PLUGIN_PATH), + json.dumps({"chunks": chunks, "userAgent": user_agent}), + ], + check=True, + capture_output=True, + text=True, + ).stdout + ) + + +def _body(result: dict) -> str: + """Join everything the plugin sent into the final document. + + Args: + result: The driver output. + + Returns: + The full response body. + """ + return "".join(result["writes"]) + (result["end"] or "") HTML = '

ok

' @@ -87,7 +110,7 @@ def test_rewrites_modulepreload_for_any_chunk_type(kind: str, tmp_path: Path): kind: The chunk type to write. tmp_path: Pytest temporary directory. """ - body = _run_plugin([(kind, HTML)], tmp_path=tmp_path) + body = _body(_run_plugin([(kind, HTML)], tmp_path)) assert body.startswith("") assert 'href="/app.js?__reflex_ts=' in body assert body.endswith("

ok

") @@ -100,11 +123,62 @@ def test_multibyte_character_split_across_chunks(tmp_path: Path): tmp_path: Pytest temporary directory. """ raw = list("

é

".encode()) - body = _run_plugin( - [("bytes", json.dumps(raw[:4])), ("bytes", json.dumps(raw[4:]))], - tmp_path=tmp_path, + result = _run_plugin( + [("bytes", json.dumps(raw[:4])), ("bytes", json.dumps(raw[4:]))], tmp_path + ) + assert _body(result) == "

é

" + + +def test_streams_and_rewrites_across_chunk_boundaries(tmp_path: Path): + """Chunks stream out as they arrive, even when a tag or href is split. + + The document is cut inside a ```` tag and inside an href used by a + later ESM import; both must still be rewritten with one shared timestamp. + + Args: + tmp_path: Pytest temporary directory. + """ + chunks = [ + '' + '
content
', + ] + result = _run_plugin([("uint8array", c) for c in chunks], tmp_path) + body = _body(result) + timestamps = set(re.findall(r"__reflex_ts=(\d+)", body)) + assert len(timestamps) == 1 + ts = timestamps.pop() + assert body == ( + '
content
' + ) + # Every chunk was sent as it arrived, holding back only the partial tag/href. + assert len(result["writes"]) == len(chunks) + assert result["writes"][0] == "" + assert "
content
" in result["writes"][1] + assert result["end"] == "" + + +def test_href_with_query_and_external_links(tmp_path: Path): + """Existing query strings get ``&`` and external hrefs are left alone. + + Args: + tmp_path: Pytest temporary directory. + """ + html = ( + '' + '' + '' ) - assert body == "

é

" + body = _body(_run_plugin([("string", html)], tmp_path)) + assert body.count("/a.js?v=1&__reflex_ts=") == 2 + assert 'href="https://cdn.example/b.js"' in body def test_non_safari_passthrough(tmp_path: Path): @@ -113,9 +187,11 @@ def test_non_safari_passthrough(tmp_path: Path): Args: tmp_path: Pytest temporary directory. """ - body = _run_plugin( + result = _run_plugin( [("uint8array", HTML)], + tmp_path, user_agent="Mozilla/5.0 Chrome/120 Safari/537.36", - tmp_path=tmp_path, ) - assert body == "undefined" + assert "x-modified-by" not in result["headers"] + assert result["end"] is None + assert len(result["writes"]) == 1 From b6095104aa59240ac5f0d932e2da53b680d6b9c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 22:54:26 +0000 Subject: [PATCH 3/7] Name the news fragment after PR #7048 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017ThL29nvh7hzpWzXX63DWs --- .../{+safari-cachebust-uint8array.bugfix.md => 7048.bugfix.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/reflex-base/news/{+safari-cachebust-uint8array.bugfix.md => 7048.bugfix.md} (100%) diff --git a/packages/reflex-base/news/+safari-cachebust-uint8array.bugfix.md b/packages/reflex-base/news/7048.bugfix.md similarity index 100% rename from packages/reflex-base/news/+safari-cachebust-uint8array.bugfix.md rename to packages/reflex-base/news/7048.bugfix.md From 1465bab9f090630d8b74f2017645b47a356af939 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 23:05:27 +0000 Subject: [PATCH 4/7] Replace regex-based href rewriting with replaceAll RegExp.escape is only available from Node 24, and the regex existed solely for a negative lookahead. A string pattern with a replacer that inspects the following characters needs no escaping and no per-href RegExp construction. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017ThL29nvh7hzpWzXX63DWs --- .../web/vite-plugin-safari-cachebust.js | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js index 6fd3f8860d4..adde14064c2 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js @@ -55,12 +55,16 @@ function isSafari(ua) { } /** - * Escapes a string for literal use inside a RegExp - * @param {string} text - The text to escape - * @returns {string} The escaped text + * Checks whether the text at the given offset already carries the cache-bust param + * @param {string} text - The text to inspect + * @param {number} offset - The index just past an href occurrence + * @returns {boolean} True if the param follows */ -function escapeRegExp(text) { - return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +function hasTsParam(text, offset) { + const sep = text[offset]; + return ( + (sep === "?" || sep === "&") && text.startsWith(`${tsParam}=`, offset + 1) + ); } /** @@ -74,7 +78,7 @@ function escapeRegExp(text) { * @returns {{push(text: string, flush: boolean): string, count: number}} The rewriter */ function createRewriter(timestamp) { - /** @type {Map} */ + /** @type {Map} href -> href with the cache-bust param */ const replacements = new Map(); let pending = ""; @@ -91,11 +95,10 @@ function createRewriter(timestamp) { ) { continue; } - replacements.set(href, { - // Skip occurrences that already carry the param (held-back text is rescanned). - re: new RegExp(`${escapeRegExp(href)}(?![?&]${tsParam}=)`, "g"), - replacement: `${href}${href.includes("?") ? "&" : "?"}${tsParam}=${timestamp}`, - }); + replacements.set( + href, + `${href}${href.includes("?") ? "&" : "?"}${tsParam}=${timestamp}`, + ); } } @@ -137,9 +140,11 @@ function createRewriter(timestamp) { push(text, flush) { text = pending + text; discover(text); - for (const { re, replacement } of replacements.values()) { - // A function replacer keeps "$" in hrefs from being read as a pattern. - text = text.replace(re, () => replacement); + for (const [href, replacement] of replacements) { + // Held-back text is rescanned, so skip occurrences already rewritten. + text = text.replaceAll(href, (match, offset, whole) => + hasTsParam(whole, offset + match.length) ? match : replacement, + ); } const cut = flush ? text.length : cutIndex(text); pending = text.slice(cut); From 80caeea37e45ac4fb808dc1fa8fdf4c0e2c865dd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 23:28:05 +0000 Subject: [PATCH 5/7] Address review findings on the Safari cachebust rewriter Keep held-back text raw and rewrite only the emitted part, so rewritten output is never rescanned. This removes the substring guard for hrefs that already carry the param and makes a double timestamp at a chunk boundary impossible by construction. Skip a match when a longer known href starts at the same offset, so an href that prefixes another ("/a.js" vs "/a.jsx" or "/a.js?v=1") is not rewritten inside the longer one. Flush the decoder before appending a string chunk so bytes left over from a preceding partial multibyte sequence keep their stream order. Decode the node driver output as UTF-8 in the test; on Windows the locale codec turned the multibyte fixture into mojibake. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017ThL29nvh7hzpWzXX63DWs --- .../web/vite-plugin-safari-cachebust.js | 61 ++++++++++--------- .../test_vite_plugin_safari_cachebust.py | 33 +++++++++- 2 files changed, 65 insertions(+), 29 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js index adde14064c2..5acc9eee04f 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js @@ -54,26 +54,14 @@ function isSafari(ua) { return /Safari/.test(ua) && !/Chrome/.test(ua); } -/** - * Checks whether the text at the given offset already carries the cache-bust param - * @param {string} text - The text to inspect - * @param {number} offset - The index just past an href occurrence - * @returns {boolean} True if the param follows - */ -function hasTsParam(text, offset) { - const sep = text[offset]; - return ( - (sep === "?" || sep === "&") && text.startsWith(`${tsParam}=`, offset + 1) - ); -} - /** * Creates a streaming rewriter for one HTML response. * * Hrefs are discovered from modulepreload tags and every later * occurrence (e.g. the ESM imports in the trailing inline script) is rewritten * as well. Text is emitted as soon as it arrives; only a possibly-partial - * tag or href at the end of a chunk is held back until the next chunk. + * tag or href at the end of a chunk is held back, unrewritten, until + * the next chunk. * @param {number} timestamp - The cache-bust value for this response * @returns {{push(text: string, flush: boolean): string, count: number}} The rewriter */ @@ -88,13 +76,7 @@ function createRewriter(timestamp) { */ function discover(text) { for (const [, href] of text.matchAll(linkTagRe)) { - if ( - replacements.has(href) || - /^(https?:)?\/\//.test(href) || - href.includes(`${tsParam}=`) - ) { - continue; - } + if (replacements.has(href) || /^(https?:)?\/\//.test(href)) continue; replacements.set( href, `${href}${href.includes("?") ? "&" : "?"}${tsParam}=${timestamp}`, @@ -102,9 +84,28 @@ function createRewriter(timestamp) { } } + /** + * Checks whether a longer known href starts at the offset of a match. + * + * Such an occurrence belongs to the longer href (e.g. "/a.jsx" or + * "/a.js?v=1" when "/a.js" matched) and must be rewritten only once. + * @param {string} text - The text being rewritten + * @param {number} offset - The index where the shorter href matched + * @param {string} href - The matched href + * @returns {boolean} True if a longer href starts at the offset + */ + function hasLongerHrefAt(text, offset, href) { + for (const other of replacements.keys()) { + if (other.length > href.length && text.startsWith(other, offset)) { + return true; + } + } + return false; + } + /** * Finds how much of the end of the text must wait for the next chunk - * @param {string} text - The rewritten text + * @param {string} text - The raw text * @returns {number} The index at which the held-back tail starts */ function cutIndex(text) { @@ -140,15 +141,15 @@ function createRewriter(timestamp) { push(text, flush) { text = pending + text; discover(text); + const cut = flush ? text.length : cutIndex(text); + pending = text.slice(cut); + text = text.slice(0, cut); for (const [href, replacement] of replacements) { - // Held-back text is rescanned, so skip occurrences already rewritten. text = text.replaceAll(href, (match, offset, whole) => - hasTsParam(whole, offset + match.length) ? match : replacement, + hasLongerHrefAt(whole, offset, href) ? match : replacement, ); } - const cut = flush ? text.length : cutIndex(text); - pending = text.slice(cut); - return text.slice(0, cut); + return text; }, get count() { return replacements.size; @@ -206,7 +207,11 @@ function createSafariMiddleware() { * @returns {string} The decoded text */ const decode = (chunk) => - typeof chunk === "string" ? chunk : chunk ? decoder.write(chunk) : ""; + typeof chunk === "string" + ? decoder.end() + chunk + : chunk + ? decoder.write(chunk) + : ""; /** * Extracts the optional completion callback from write/end arguments diff --git a/tests/units/reflex_base/templates/test_vite_plugin_safari_cachebust.py b/tests/units/reflex_base/templates/test_vite_plugin_safari_cachebust.py index dd96f777987..2d303699259 100644 --- a/tests/units/reflex_base/templates/test_vite_plugin_safari_cachebust.py +++ b/tests/units/reflex_base/templates/test_vite_plugin_safari_cachebust.py @@ -77,7 +77,7 @@ def _run_plugin( ], check=True, capture_output=True, - text=True, + encoding="utf-8", ).stdout ) @@ -165,6 +165,37 @@ def test_streams_and_rewrites_across_chunk_boundaries(tmp_path: Path): assert result["end"] == "" +def test_hrefs_that_prefix_each_other(tmp_path: Path): + """An href that is a prefix of another is not rewritten inside the longer one. + + The document is also cut right where the longer href's query begins, so + the shorter match at the chunk end must wait for the continuation. + + Args: + tmp_path: Pytest temporary directory. + """ + chunks = [ + ( + '' + '' + '' + '', + ] + body = _body(_run_plugin([("string", c) for c in chunks], tmp_path)) + ts = re.search(r"__reflex_ts=(\d+)", body) + assert ts is not None + ts = ts.group(1) + assert body == ( + f'' + f'' + f'' + f'' + ) + + def test_href_with_query_and_external_links(tmp_path: Path): """Existing query strings get ``&`` and external hrefs are left alone. From b755602fe42ecded48240b8718a523a8e8a61c40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 00:16:42 +0000 Subject: [PATCH 6/7] Rewrite discovered hrefs in a single longest-first pass Replacing each href with its own replaceAll rescanned text already rewritten for another href, so an href that is a substring but not a prefix of a longer one ("/a.js" inside "/b/a.js") received a second cache-bust param in either discovery order. hasLongerHrefAt only covered the same-offset case. Build one alternation of the escaped hrefs, longest first, whenever a new href is discovered and rewrite the emitted text with a single replace. At any position the longest known href wins, so every occurrence is rewritten exactly once, and the per-href loop and offset check go away. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Gbv4QSYBEZbxsw4kKP2WeE --- .../web/vite-plugin-safari-cachebust.js | 44 ++++++++----------- .../test_vite_plugin_safari_cachebust.py | 25 +++++++++++ 2 files changed, 44 insertions(+), 25 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js index 5acc9eee04f..79744d9a38a 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js @@ -27,6 +27,7 @@ const pluginName = "vite-plugin-safari-cachebust"; const tsParam = "__reflex_ts"; const linkTagRe = /]*>/g; const tsUrlRe = new RegExp(`(\\?|&)${tsParam}=\\d+`); +const regExpSpecialsRe = /[.*+?^${}()|[\]\\]/g; /** * Creates a Vite plugin that adds cache-busting for Safari browsers @@ -68,6 +69,8 @@ function isSafari(ua) { function createRewriter(timestamp) { /** @type {Map} href -> href with the cache-bust param */ const replacements = new Map(); + /** @type {RegExp | null} Alternation of every known href, longest first */ + let hrefRe = null; let pending = ""; /** @@ -75,32 +78,26 @@ function createRewriter(timestamp) { * @param {string} text - The text to scan */ function discover(text) { + let changed = false; for (const [, href] of text.matchAll(linkTagRe)) { if (replacements.has(href) || /^(https?:)?\/\//.test(href)) continue; replacements.set( href, `${href}${href.includes("?") ? "&" : "?"}${tsParam}=${timestamp}`, ); + changed = true; } - } - - /** - * Checks whether a longer known href starts at the offset of a match. - * - * Such an occurrence belongs to the longer href (e.g. "/a.jsx" or - * "/a.js?v=1" when "/a.js" matched) and must be rewritten only once. - * @param {string} text - The text being rewritten - * @param {number} offset - The index where the shorter href matched - * @param {string} href - The matched href - * @returns {boolean} True if a longer href starts at the offset - */ - function hasLongerHrefAt(text, offset, href) { - for (const other of replacements.keys()) { - if (other.length > href.length && text.startsWith(other, offset)) { - return true; - } - } - return false; + if (!changed) return; + // Longest first, so an href that prefixes or contains another ("/a.js" in + // "/a.jsx", "/a.js?v=1" or "/b/a.js") is matched whole and every + // occurrence is rewritten exactly once in a single pass. + hrefRe = new RegExp( + [...replacements.keys()] + .sort((a, b) => b.length - a.length) + .map((href) => href.replace(regExpSpecialsRe, "\\$&")) + .join("|"), + "g", + ); } /** @@ -144,12 +141,9 @@ function createRewriter(timestamp) { const cut = flush ? text.length : cutIndex(text); pending = text.slice(cut); text = text.slice(0, cut); - for (const [href, replacement] of replacements) { - text = text.replaceAll(href, (match, offset, whole) => - hasLongerHrefAt(whole, offset, href) ? match : replacement, - ); - } - return text; + return hrefRe + ? text.replace(hrefRe, (match) => replacements.get(match)) + : text; }, get count() { return replacements.size; diff --git a/tests/units/reflex_base/templates/test_vite_plugin_safari_cachebust.py b/tests/units/reflex_base/templates/test_vite_plugin_safari_cachebust.py index 2d303699259..51cf5318f8e 100644 --- a/tests/units/reflex_base/templates/test_vite_plugin_safari_cachebust.py +++ b/tests/units/reflex_base/templates/test_vite_plugin_safari_cachebust.py @@ -196,6 +196,31 @@ def test_hrefs_that_prefix_each_other(tmp_path: Path): ) +def test_hrefs_that_contain_each_other(tmp_path: Path): + """An href occurring inside a longer one is rewritten once, as the longer href. + + ``/a.js`` is a substring, but not a prefix, of ``/b/a.js``; neither URL may + end up with two cache-bust params, whichever is discovered first. + + Args: + tmp_path: Pytest temporary directory. + """ + html = ( + '' + '' + '' + ) + body = _body(_run_plugin([("string", html)], tmp_path)) + ts = re.search(r"__reflex_ts=(\d+)", body) + assert ts is not None + ts = ts.group(1) + assert body == ( + f'' + f'' + f'' + ) + + def test_href_with_query_and_external_links(tmp_path: Path): """Existing query strings get ``&`` and external hrefs are left alone. From ef3ba329153492942c5843e53397a734c1590205 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 00:19:01 +0000 Subject: [PATCH 7/7] Defer the write callback when a chunk is fully held back Node never invokes a write callback synchronously; a caller writing from inside its callback would otherwise re-enter the middleware. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017ThL29nvh7hzpWzXX63DWs --- .../.templates/web/vite-plugin-safari-cachebust.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js index 79744d9a38a..ac2af098afc 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js @@ -225,8 +225,9 @@ function createSafariMiddleware() { const out = rewriter.push(decode(chunk), false); const cb = callback(args); if (out) return cb ? _write(out, cb) : _write(out); - // Everything was held back for the next chunk; nothing is queued. - cb?.(); + // Everything was held back for the next chunk; nothing is queued. Node + // never invokes a write callback synchronously, so defer it likewise. + if (cb) process.nextTick(cb); return true; };