Skip to content

Fix Safari cache-busting plugin to handle React Router 8 Uint8Array chunks - #7048

Open
masenf wants to merge 7 commits into
mainfrom
claude/vite-safari-cachebust-compat-ugx3h0
Open

Fix Safari cache-busting plugin to handle React Router 8 Uint8Array chunks#7048
masenf wants to merge 7 commits into
mainfrom
claude/vite-safari-cachebust-compat-ugx3h0

Conversation

@masenf

@masenf masenf commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Description

The Safari cache-busting Vite plugin was rendering pages as comma-separated byte values when used with React Router 8, which writes plain Uint8Array chunks instead of Buffer objects. The plugin was also buffering the entire response before rewriting, which prevented streaming.

This fix:

  1. Properly decodes all chunk types (string, Buffer, Uint8Array) using Node's StringDecoder to handle multibyte UTF-8 sequences that may span chunk boundaries
  2. Streams responses instead of buffering them — text is emitted as it arrives, with only incomplete tags/hrefs held back (unrewritten) until the next chunk
  3. Discovers and rewrites hrefs dynamically — modulepreload links are discovered from complete tags and all later occurrences (including ESM imports in inline scripts) are rewritten with a shared timestamp in a single longest-first pass
  4. Handles edge cases — existing query strings use &, external URLs are skipped, and an href that prefixes or contains another (/a.js vs /a.jsx, /a.js?v=1 or /b/a.js) is never rewritten inside the longer one

Changes

  • vite-plugin-safari-cachebust.js: Replaced the simple string-based buffering approach with a streaming rewriter that:

    • Uses StringDecoder to properly decode chunks of any type
    • Discovers hrefs from complete <link rel="modulepreload"> tags
    • Rewrites all occurrences of discovered hrefs with a single timestamp via one longest-first alternation, so overlapping hrefs are each rewritten exactly once
    • Holds back only partial tags/hrefs at chunk boundaries, keeping them raw so rewritten output is never rescanned
    • Emits text immediately as it's processed and forwards write callbacks with stream semantics
  • test_vite_plugin_safari_cachebust.py: Added comprehensive test suite covering:

    • All chunk types (string, Buffer, Uint8Array)
    • Multibyte UTF-8 sequences split across chunks
    • Tags and hrefs split across chunk boundaries
    • Hrefs that prefix or contain each other
    • Query string handling and external URL passthrough
    • Non-Safari browser passthrough
  • 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.py that verify:

  • Chunks of every type are decoded correctly
  • Multibyte characters split across chunks are handled
  • Streaming works correctly with tags/hrefs split across boundaries
  • Overlapping hrefs are each rewritten exactly once, including across a chunk boundary
  • Query strings and external links are handled properly
  • Non-Safari browsers bypass the rewriter

All tests pass and require Node.js to be available (skipped if missing).

https://claude.ai/code/session_017ThL29nvh7hzpWzXX63DWs

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
@masenf
masenf requested a review from a team as a code owner September 4, 2026 22:53
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ThL29nvh7hzpWzXX63DWs
@codspeed-hq

codspeed-hq Bot commented Sep 4, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 32 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing claude/vite-safari-cachebust-compat-ugx3h0 (ef3ba32) with main (c57b32c)

Open in CodSpeed

Footnotes

  1. 8 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-apps

greptile-apps Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR updates the Safari development-server cache-busting middleware to decode React Router 8 byte chunks correctly while preserving streamed HTML output.

  • Uses StringDecoder for Buffer and Uint8Array response chunks.
  • Discovers module-preload URLs incrementally and rewrites later occurrences with one timestamp.
  • Handles split tags, split URLs, query strings, external URLs, and overlapping known hrefs.
  • Adds focused Node-backed regression tests and a reflex-base changelog fragment.

Confidence Score: 5/5

The 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.

Important Files Changed

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

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 masenf left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-by set, the 3 modulepreload hrefs (/@id/__x00__virtual:react-router/browser-manifest, /app/entry.client.js, /app/root.jsx) and their 3 inline import occurrences all rewritten with one shared timestamp. Chrome UA: untouched, no header. The 404 route and HEAD / behave the same way, and ?__reflex_ts=… module URLs resolve to text/javascript through the strip branch.
  • Streaming is real on the wire: curl --raw shows 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 at end.
  • Chromium via Playwright, once with a Safari UA and once with a Chrome UA: hydration, on_click/on_change round trips with multibyte text, client-side navigation, full reload of /about, nested route, all 9 cache-busted module requests returned 200 text/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 <link split as < / li / nk…. Uint8Array views with a non-zero byteOffset and write(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

  1. Substring hrefs still get a double param (pre-existing, not reachable today). The per-href replaceAll loop rescans text already rewritten for an earlier href, and hasLongerHrefAt only covers a longer href starting at the same offset. With /a.js and /b/a.js both discovered, /b/a.js becomes /b/a.js?__reflex_ts=T?__reflex_ts=T in either discovery order. The old split/join did 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 single replace; that removes hasLongerHrefAt and the loop, fixes the prefix and substring cases uniformly, passes every check above plus a new test_hrefs_that_contain_each_other, and was about 2× faster in a 20 000-single-byte-chunk stress run.
  2. Content-Length responses are truncated when the body grows (pre-existing). Vite serves public/*.html via sirv with writeHead(200, { "Content-Length": … }). A static page in assets/ 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 a writeHead intercept, since by the time write runs the header block is already serialized and removeHeader throws.
  3. Nit: res.write calls 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.
  4. Agree with keeping the attribute-order-specific <link> regex: RR 8.3.1 renders <link rel="modulepreload" href="…"/> with rel first, 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

masenf commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review. Actions on the four findings:

  1. Substring hrefs: agreed that hasLongerHrefAt only covered a longer href at the same offset. Cherry-picked 0e62838 as b755602: one longest-first alternation, single replace, and test_hrefs_that_contain_each_other. It also removes the per-href loop.
  2. Content-Length truncation for static HTML served by sirv: pre-existing and not reachable through React Router's dev output, so left out of this PR as agreed. Noted as follow-up work: it needs a writeHead intercept since the header block is already serialized by the time write runs.
  3. Synchronous write callback: fixed in ef3ba32, the held-back path now defers the callback with process.nextTick.
  4. Keeping the attribute-order-specific <link> regex, as discussed.

Head is now ef3ba32; 9 tests, ruff, pyright and biome clean locally.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants