Fix Safari cache-busting plugin to handle React Router 8 Uint8Array chunks - #7048
Fix Safari cache-busting plugin to handle React Router 8 Uint8Array chunks#7048masenf wants to merge 7 commits into
Conversation
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ThL29nvh7hzpWzXX63DWs
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 <link> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ThL29nvh7hzpWzXX63DWs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ThL29nvh7hzpWzXX63DWs
Merging this PR will not alter performance
Comparing Footnotes
|
There was a problem hiding this comment.
1 issue found across 4 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js">
<violation number="1" location="packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js:28">
P2: When Vite emits `crossorigin` before `href`, `linkTagRe` does not discover the modulepreload link, so Safari still receives the stale URL. Match `rel` and `href` independently within the complete tag instead of requiring them to be adjacent.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Greptile SummaryThis PR updates the Safari development-server cache-busting middleware to decode React Router 8 byte chunks correctly while preserving streamed HTML output.
Confidence Score: 5/5The PR appears safe to merge because the changes since the previous review introduce no established correctness or repository-rule violations. The longest-first escaped alternation correctly addresses overlapping discovered hrefs, and deferring held-back write callbacks preserves asynchronous response semantics. No previous Greptile threads were supplied as outstanding.
|
| Filename | Overview |
|---|---|
| packages/reflex-base/src/reflex_base/.templates/web/vite-plugin-safari-cachebust.js | Implements streaming UTF-8 decoding and longest-first cache-bust rewriting for discovered module-preload URLs; no new actionable issue was established in the post-review changes. |
| tests/units/reflex_base/templates/test_vite_plugin_safari_cachebust.py | Adds regression coverage for supported chunk types, split UTF-8 sequences, streaming boundaries, overlapping hrefs, query parameters, external links, and browser passthrough. |
| packages/reflex-base/news/7048.bugfix.md | Documents the user-visible Safari rendering and streaming fix. |
Reviews (4): Last reviewed commit: "Defer the write callback when a chunk is..." | Re-trigger Greptile
There was a problem hiding this comment.
Review completed against the latest diff
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ThL29nvh7hzpWzXX63DWs
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ThL29nvh7hzpWzXX63DWs
masenf
left a comment
There was a problem hiding this comment.
Production-readiness review of 80caeea
Verdict: ready to merge. The fix is correct, streams for real, and matches the old plugin's output byte-for-byte on real React Router 8 HTML. Two pre-existing edge cases are noted below; neither is reachable with the URLs Reflex's dev server produces, so they are suggestions rather than blockers. A tested patch for the first one is on claude/pr-7048-review-72wi3a (0e62838) if you want to pick it up.
What I verified
Root cause. In @react-router/node 8.3.1, writeReadableStreamToWritable hands res.write the Uint8Array values from reader.read(). The pre-PR plugin stringifies those, and feeding the app's real HTML to it as a Uint8Array reproduces the symptom exactly ("60,33,68,79,67,84,89,80,69,...").
Sample app, dev mode, PR head (react-router 8.3.1, vite 8.2.2, three routes, multibyte text in titles and state):
- Safari UA via curl: proper HTML,
x-modified-byset, the 3 modulepreload hrefs (/@id/__x00__virtual:react-router/browser-manifest,/app/entry.client.js,/app/root.jsx) and their 3 inlineimportoccurrences all rewritten with one shared timestamp. Chrome UA: untouched, no header. The 404 route andHEAD /behave the same way, and?__reflex_ts=…module URLs resolve totext/javascriptthrough the strip branch. - Streaming is real on the wire:
curl --rawshows the Safari response arriving in the same 5 chunks as the Chrome response, with only the chunk sizes growing by the inserted params (324→402, 2048→2126). The old plugin collapsed this into one chunk atend. - Chromium via Playwright, once with a Safari UA and once with a Chrome UA: hydration,
on_click/on_changeround trips with multibyte text, client-side navigation, full reload of/about, nested route, all 9 cache-busted module requests returned 200text/javascript, no console errors. Editing the Python source with the Safari UA reloaded the page in ~1s and it stayed interactive on the fresh timestamp. - Equivalence with the old rewriter: the raw HTML captured from the app produces identical output (modulo timestamp) through old and new plugin for one chunk, one chunk per byte, and 300 seeded random chunkings (91–122 writes per response). Same for 500 random chunkings of a synthetic document with
$,+,(in hrefs,<inside inline script, and a<linksplit as</li/nk….Uint8Arrayviews with a non-zerobyteOffsetandwrite(chunk, cb)/end(cb)/end(chunk, cb)also behave. - Tests, ruff, pyright, biome (2.4.8, the pre-commit version) all clean locally; CI is green across the ubuntu/windows × 3.10–3.14 unit-test matrix.
Findings
- Substring hrefs still get a double param (pre-existing, not reachable today). The per-href
replaceAllloop rescans text already rewritten for an earlier href, andhasLongerHrefAtonly covers a longer href starting at the same offset. With/a.jsand/b/a.jsboth discovered,/b/a.jsbecomes/b/a.js?__reflex_ts=T?__reflex_ts=Tin either discovery order. The oldsplit/joindid the same, and Reflex's dev URLs (/app/…,/@id/…,/node_modules/.vite/deps/…) never contain one another, so this is not blocking. The single-pass variant in 0e62838 builds one alternation of the escaped hrefs sorted longest-first whenever discovery adds one and does a singlereplace; that removeshasLongerHrefAtand the loop, fixes the prefix and substring cases uniformly, passes every check above plus a newtest_hrefs_that_contain_each_other, and was about 2× faster in a 20 000-single-byte-chunk stress run. Content-Lengthresponses are truncated when the body grows (pre-existing). Vite servespublic/*.htmlvia sirv withwriteHead(200, { "Content-Length": … }). A static page inassets/containing a modulepreload link came back cut off at the original 212 bytes with the Safari UA. React Router never emits such a page in dev, so I would leave it out of this PR; hardening would need awriteHeadintercept, since by the timewriteruns the header block is already serialized andremoveHeaderthrows.- Nit:
res.writecalls the completion callback synchronously when the whole chunk is held back. Node always defers it, so a caller that writes from inside its callback would re-enter. RR8's writer passes no callback, so harmless today;process.nextTick(cb)would match stream semantics. - Agree with keeping the attribute-order-specific
<link>regex: RR 8.3.1 renders<link rel="modulepreload" href="…"/>withrelfirst, confirmed in the served HTML.
Generated by Claude Code
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gbv4QSYBEZbxsw4kKP2WeE
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ThL29nvh7hzpWzXX63DWs
|
Thanks for the thorough review. Actions on the four findings:
Head is now ef3ba32; 9 tests, ruff, pyright and biome clean locally. Generated by Claude Code |
Type of change
Description
The Safari cache-busting Vite plugin was rendering pages as comma-separated byte values when used with React Router 8, which writes plain
Uint8Arraychunks instead ofBufferobjects. The plugin was also buffering the entire response before rewriting, which prevented streaming.This fix:
StringDecoderto handle multibyte UTF-8 sequences that may span chunk boundaries&, external URLs are skipped, and an href that prefixes or contains another (/a.jsvs/a.jsx,/a.js?v=1or/b/a.js) is never rewritten inside the longer oneChanges
vite-plugin-safari-cachebust.js: Replaced the simple string-based buffering approach with a streaming rewriter that:
StringDecoderto properly decode chunks of any type<link rel="modulepreload">tagstest_vite_plugin_safari_cachebust.py: Added comprehensive test suite covering:
packages/reflex-base/news/7048.bugfix.md: Changelog entry documenting the fix
Test Plan
Added 9 unit tests in
tests/units/reflex_base/templates/test_vite_plugin_safari_cachebust.pythat verify:All tests pass and require Node.js to be available (skipped if missing).
https://claude.ai/code/session_017ThL29nvh7hzpWzXX63DWs