docs(planning): JSON render-request body migration plan (#3280)#3299
Conversation
Findings doc derived from the completed #3280 experiment, placed in internal/planning/ per repo convention. Captures: - the 9-line demo change + why no renderer change is needed - full perf numbers (sequential / isolation / autocannon / web-vitals / paired-curl / parallel A/B / byte-equivalence), incl. the #3217 orthogonality re-verification - the 13 operational problems hit while running the experiment - a phased production plan (compat matrix + protocol-version gating, multipart preservation, streaming out-of-scope, tests, docs, audit) - links to the experiment commit/PR and the demo-app harness commit No code change — planning artifact only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WalkthroughThis PR adds a production planning document for switching steady-state render request bodies from form-encoded to JSON (when no bundle is attached), recording the demo, verification, benchmark results, operational issues, and a production rollout plan with protocol-version gating and required test/docs updates. ChangesJSON Render-Body Migration Planning
Estimated code review effort🎯 1 (Trivial) | ⏱️ ~5 minutes Possibly related issues
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryThis PR adds a docs-only planning artifact at
Confidence Score: 4/5Docs-only change; no executable code is modified. Safe to merge as a planning record. The document is thorough and well-structured, with three independent methodologies supporting the core performance claim. All three comments are about completeness gaps in the planning text: an unexplained web vitals anomaly in the baseline data, an optional re-measurement that should be a hard gate before shipping, and a missing version number for the protocol bump. None affect the correctness of the proposal itself. The web vitals table in §2.4 and the hello_server footnote merit a second look before the plan is used as the basis for a production PR. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[render_code called] --> B{send_bundle?}
B -- true --> C["perform_request form multipart"]
B -- false --> D["perform_request json application/json"]
D --> E{protocolVersion gate met?}
E -- yes --> F[Fastify default JSON body parser]
E -- no --> G[Fall back to form-encoded body]
F --> H[req.body read agnostically]
G --> H
C --> I[multipart handler]
I --> H
H --> J[extractBodyArrayField checks key and key-array]
J --> K[Byte-equivalent HTML response]
Reviews (1): Last reviewed commit: "docs(planning): JSON render-request body..." | Re-trigger Greptile |
|
|
||
| Streaming SSR (`hello_server`, `mega_benchmark`) flat ±noise — untouched path, | ||
| no regression. | ||
|
|
||
| ### 2.4 Web vitals — Playwright (3 iters) | ||
|
|
||
| | Page | base TTFB / load | after TTFB / load | | ||
| | ------------------------------ | ---------------: | ----------------: | | ||
| | hello_world | 8.5 / 55 | 4.0 / 45 | |
There was a problem hiding this comment.
Baseline web vitals appear anomalous for mega_benchmark — plan should acknowledge this
The mega_benchmark_traditional row shows baseline total page load of 240 ms with a 233 ms TTFB, implying the browser received and fully rendered 2.18 MB of HTML in only 7 ms — not physically plausible. The after-measurement (553 ms total, 135 ms TTFB → 418 ms remaining load) is far more credible for a 2.18 MB document. This looks like a baseline measurement artifact rather than an after-state regression, but the plan never says so. Similarly, heavy_benchmark_traditional load doubles (37 → 79 ms) without any note. The "Go" verdict and §2.5's "three methodologies agree" claim could be misread by implementers who see the web vitals table without this explanation — the footnote only addresses hello_server. A short note clarifying that the load-time increases reflect a plausible baseline anomaly would prevent this misreading.
| | ------------------------------ | ---------------: | ----------------: | | ||
| | hello_world | 8.5 / 55 | 4.0 / 45 | |
There was a problem hiding this comment.
hello_server re-measurement should be required, not conditional
The footnote phrases the ≥10-iteration re-measurement as "if it matters," but hello_server shows an ~20× TTFB increase (6 → 118.9 ms) on an allegedly untouched streaming path. Even if the root cause is cold-cache noise, a production plan that ships with an unresolved 20× regression on any endpoint — even a likely false positive — leaves reviewers without a clear "we checked and it's fine" sign-off. Consider promoting this to an explicit pre-ship gate in §4 rather than leaving it optional.
| tool die at the 10-minute max timeout. Fixed by launching detached: | ||
| `nohup bash -c '…' >/dev/null 2>&1 & disown` (and `setsid -f` earlier). | ||
| 6. **HTTPX pool exhaustion at c=50.** Default `renderer_http_pool_size` | ||
| plus a 5 s checkout timeout → `HTTPX::PoolTimeoutError` and a Rails | ||
| `Exiting` under sustained c=50. Fixed in the demo initializer: | ||
| `renderer_http_pool_size=64`, `renderer_http_pool_timeout=30`, | ||
| `ssr_timeout=30`. Identical on baseline and after, so the A/B stays fair; |
There was a problem hiding this comment.
Protocol version bump semantics are underspecified
§4.1 says to bump protocolVersion and gate JSON on renderers at/above the bump, but doesn't specify whether the bump is minor (2.0.0 → 2.1.0) or major (2.0.0 → 3.0.0). Sending a different Content-Type is a wire-breaking change for any renderer that doesn't have Fastify's default JSON parser (e.g., the pre-Fastify Express path listed as ✗ _risk_ in the compatibility matrix). Calling out the intended version string would prevent the production PR from having to relitigate this decision.
|
Code Review (PR 3299 - docs only, no code change) Verified against current source: extractBodyArrayField dual-key check, Fastify JSON parser never removed, bodyLimit 100MB, protocol version 2.0.0 matches both sides, password auth via timingSafeEqual, /asset-exists already uses json: keyword, common_form_data returns only JSON-serializable types. All claims accurate. Four gaps to address before the implementation PR:
Minor: Section 1 references utils.rb:167 for common_form_data but it is at line 165. Section 4.4 Ruby spec should verify perform_request is called with the json: keyword argument, not just inspect the serialized body bytes. Overall: benchmarking solid, compatibility analysis accurate, risk items identified. Resolve the protocol gating mechanism question (item 1) before writing the implementation PR. |
| `HTTPX::TCP.ancestors.include?(ReactOnRailsPro::HttpxTcpNodelayPatch) == true`): | ||
|
|
||
| | Workload | Δ, #3217 NOT on baseline | Δ, #3217 ON baseline | | ||
| | -------------------- | -----------------------: | -------------------: | |
There was a problem hiding this comment.
Protocol version gating mechanism needs clarification.
The plan says "gate on protocolVersion — bump it and have the gem send form to renderers below the bump, JSON at/above." But checkProtocolVersionHandler.ts uses a strict equality check — the gem cannot discover the renderer version before choosing its encoding format. A renderer still on 2.0.0 already rejects any gem sending protocolVersion: 2.1.0 with HTTP 412, regardless of content-type. So "send form to old renderers" is unreachable in practice.
Two concrete options to resolve this:
- Option A (simpler): Always send JSON once the gem bumps the protocol version. Old renderers are already broken by the 412 version-mismatch rejection. Document the minimum supported renderer version. Consistent with current protocol behavior.
- Option B (graceful degradation): Add a
GET /status(or equivalent) capability endpoint the gem queries once at startup, then stores and uses to choose encoding. More complex but allows rolling upgrades.
Option A is the path of least resistance given the existing strict-equality version gate.
| 1. **Branch base.** Worktree first cut from `main`; the experiment had to | ||
| match the renderer/gem version rsc-benchmar pins, so it was recut from | ||
| `upcoming-v16.3.0` (`3d571c32`). | ||
| 2. **Fresh demo-app setup.** `pnpm install` (root, auto-builds the 3 JS |
There was a problem hiding this comment.
Failure mode is loud (TypeError), not silent — reframe the test goal.
JSON.generate on a hash containing a Pathname raises TypeError: no implicit conversion of Pathname into String immediately. There is no silent failure path via the Pathname value itself.
The actual silent failure risk is if get_form_body_for_file is ever changed to return the file contents as a String — that value is JSON-serializable, so json: form would succeed, but the renderer would receive the full bundle JS embedded as a JSON string field rather than as a multipart file upload, and the rendering would silently misbehave.
The regression test should therefore assert that when send_bundle: true, the outgoing request's Content-Type header is multipart/form-data, not just that no exception is raised.
|
|
||
| | Point (u,p,c) | base total / gc (ms) | after total / gc (ms) | Δ total | | ||
| | ------------------- | -------------------: | --------------------: | -------: | | ||
| | 0, 0, 0 | 7 / 0.1 | 7 / 0.0 | 0 | |
There was a problem hiding this comment.
hello_server TTFB anomaly needs a mandatory re-measurement, not an optional one.
The footnote says "re-measure with iters≥10 if it matters." A 20x TTFB inflation on the streaming SSR path (6 ms → 119 ms) should be treated as a blocker requiring proactive clearance rather than something to revisit only "if it matters." The autocannon result (120→115 ms, flat) is encouraging but covers aggregate throughput, not per-page first-byte latency under realistic conditions.
Suggest strengthening the production plan to require: a clean Playwright web-vitals run with ≥10 iterations on hello_server and mega_benchmark (streaming paths) before the implementation PR is marked ready for merge, with the results included in the PR description.
| `bodyLimit` (100 MB, `worker.ts:222`) — fine; document it. | ||
|
|
||
| ### 4.2 Keep multipart where it must stay | ||
|
|
There was a problem hiding this comment.
Add a WAF/proxy operator note here.
The content-type change from application/x-www-form-urlencoded to application/json on the render endpoint is observable at the network level. Any operator who has:
- a WAF allowlisting specific content-types on the internal Rails↔renderer path
- a reverse proxy (nginx, HAProxy, etc.) with content-type filtering
- network-level logging/IDS inspecting that traffic
…will see requests start failing or triggering alerts after this change. The CHANGELOG entry should include a migration callout along the lines of: "If you have a WAF or proxy restricting Content-Type on the renderer endpoint, add application/json to the allowlist."
| ``` | ||
|
|
||
| No Node-side change. `react_on_rails_pro/lib/react_on_rails_pro/utils.rb:167` | ||
| `common_form_data` already produces only JSON-serializable values |
There was a problem hiding this comment.
Minor: utils.rb:167 — in the current checkout common_form_data is defined at line 165, not 167. Worth correcting before the implementation PR references it as a starting point.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 30231ea303
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| advertises it. Cleanest: gate on `protocolVersion` — bump it and have the | ||
| gem send form to renderers below the bump, JSON at/above. |
There was a problem hiding this comment.
Avoid using protocolVersion as the JSON feature gate
When the production PR follows this recommendation for mixed deployments, a new gem talking to an old renderer will not fall back cleanly: the gem includes its ReactOnRailsPro::PROTOCOL_VERSION in every request body, and the renderer rejects any mismatch with HTTP 412 before handling the render. If the protocol is bumped to select JSON vs form, the first fallback form request to an old renderer still carries the new protocol value and is rejected, so the plan needs a real capability discovery/config mechanism or an explicit old-protocol fallback.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/planning/json-render-body-migration-plan.md (1)
1-378:⚠️ Potential issue | 🔴 Critical | ⚡ Quick winFix Prettier formatting violations before merge.
The CI pipeline reports that this markdown file has formatting issues. As per coding guidelines, Prettier must be used for all markdown files.
Run the following to fix:
prettier --write internal/planning/json-render-body-migration-plan.mdThen commit the changes. As per coding guidelines, Prettier is required for markdown files matching
**/*.md.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/planning/json-render-body-migration-plan.md` around lines 1 - 378, Summary: Prettier formatting violations in the "Production Migration Plan — JSON render-request body (`#3280`)" markdown need fixing. Fix: run Prettier (project-configured prettier) to format that markdown document (the migration plan whose heading/title is "Production Migration Plan — JSON render-request body (`#3280`)"), review the formatted diff to ensure only whitespace/format changes, then commit the resulting changes; ensure CI passes. Location hints: look for the markdown file containing the document title and the experiment diff section in the repo.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@internal/planning/json-render-body-migration-plan.md`:
- Around line 1-378: Summary: Prettier formatting violations in the "Production
Migration Plan — JSON render-request body (`#3280`)" markdown need fixing. Fix:
run Prettier (project-configured prettier) to format that markdown document (the
migration plan whose heading/title is "Production Migration Plan — JSON
render-request body (`#3280`)"), review the formatted diff to ensure only
whitespace/format changes, then commit the resulting changes; ensure CI passes.
Location hints: look for the markdown file containing the document title and the
experiment diff section in the repo.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: fe079ca8-6318-4d98-9f40-c44d3a02918d
📒 Files selected for processing (1)
internal/planning/json-render-body-migration-plan.md
|
Code Review for PR 3299 - docs-only planning document. Overall: thorough and well-organized. See inline comments for specific issues. |
|
Code review top-level summary (see inline comments for detail): Docs-only planning artifact -- thorough and well-organized. Four issues: (1) Load-time regressions in s2.4 web vitals table (heavy +114%, mega +130%) are not flagged or explained -- re-measure at n>=10 before the production PR ships. (2) Two stale line numbers: utils.rb:167 should be 165 for common_form_data; s4.2 request.rb:189-197 maps to add_bundle_to_form, not populate_form_with_bundle_and_assets (165-187), and form_with_code is at 158-163. (3) s4.4 spec-update list cites ~:123-135 but misses the identical assertion at line 153 in a second test case. (4) s4.6 defensive audit underspecified -- suggest JSON.generate(common_form_data) unit test or a Sorbet sig as the concrete guard. |
| end | ||
| ``` | ||
|
|
||
| No Node-side change. `react_on_rails_pro/lib/react_on_rails_pro/utils.rb:167` |
There was a problem hiding this comment.
Stale line number: utils.rb:167 should be 165. def self.common_form_data is currently at line 165 in react_on_rails_pro/lib/react_on_rails_pro/utils.rb.
| `send_bundle: true` already stays on multipart (the experiment's conditional) | ||
| because `form_with_code` → `populate_form_with_bundle_and_assets` embeds | ||
| bundle/asset entries whose `:body` is a `Pathname`/IO | ||
| (`request.rb:189-197`, `321-344`). `/upload-assets` (multipart, file uploads) |
There was a problem hiding this comment.
The line range here points to the wrong function. In the current request.rb:
form_with_codeis at lines 158–163populate_form_with_bundle_and_assets(whichform_with_codecalls whensend_bundle: true) is at lines 165–187add_bundle_to_form(which actually has thePathname/IO:bodyentries) starts at line 189
So request.rb:189-197 is add_bundle_to_form, not populate_form_with_bundle_and_assets. The conceptual chain (form_with_code → populate_form_with_bundle_and_assets → add_bundle_to_form) is correct; only the cited line range needs updating.
| ### 4.4 Tests | ||
|
|
||
| - Ruby (`react_on_rails_pro/spec/react_on_rails_pro/request_spec.rb`): the | ||
| existing specs at ~`:123-135` assert `body.to_s` includes |
There was a problem hiding this comment.
The spec range ~:123-135 only covers the first test case. There is a second identical assertion at line 153 in request_spec.rb (the "raises duplicate bundle upload error" test case) that also asserts body.to_s.include("renderingRequest=console.log"). Both will need updating when the production implementation switches to JSON.
| | heavy_benchmark_traditional | 21.9 / 37 | 12.1 / 79 | | ||
| | **mega_benchmark_traditional** | **233.1 / 240** | **135.6 / 553** | | ||
| | hello_server | 6.0 / 13 | 118.9 / 220 ¹ | | ||
|
|
||
| ¹ `hello_server` first-iter cold-cache noise at 3 iters; autocannon c=1 shows | ||
| it flat (120→115). Re-measure with iters≥10 if it matters. |
There was a problem hiding this comment.
The load-time column shows significant regressions that are not mentioned anywhere in the document:
heavy_benchmark_traditional: 37 → 79 ms (+114%)mega_benchmark_traditional: 240 → 553 ms (+130%)
The hello_server TTFB spike has a footnote; these do not. At n=3 this is very likely noise, but without a note the reader cannot tell whether it was investigated. Recommend either adding a footnote explaining why these are noise (e.g., renderer GC or JIT warmup on first full-page load) or, better, requiring a re-run at ≥10 iterations as a prerequisite in the production PR rather than leaving it as optional ("if it matters").
…on main (#3301) ## Summary Two markdown files were merged to `main` without passing the repo-wide checks, which turns `markdown-format-check` and the `build` job's format step **red on every open PR** (both run `prettier --check` over the entire repo, not just changed files): - `internal/planning/json-render-body-migration-plan.md` (from #3299) — prettier formatting only. - `reports/ror-memory-leakage-investigation.md` (from #3296) — prettier formatting **and** a relative link to `../../docs/pro/js-memory-leaks.md`, which is one level too high and resolves outside the repo. The target exists at repo-root `docs/pro/js-memory-leaks.md`; corrected to `../docs/pro/js-memory-leaks.md`. Formatting changes are `prettier --write` output only (no content edits). The link change is a single relative-path correction; verified the target file exists and the `markdown-links` (lychee) pre-commit hook now passes. ## Why this is its own PR These files are unrelated to any feature work, but their CI failures block unrelated PRs (e.g. #3232). Fixing them on `main` directly via a focused PR unblocks everything in flight. ## Test plan - `pnpm exec prettier --check internal/planning/json-render-body-migration-plan.md reports/ror-memory-leakage-investigation.md` → clean - `markdown-links` lefthook hook passes (corrected link resolves) - No content changes beyond the one relative-path fix 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Updated planning documentation with status updates for related pull requests and issue tracking * Enhanced investigation report with improved structure and readability through reformatted content into consistent table formats <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/shakacode/react_on_rails/pull/3301) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…y-adapter * origin/main: feat(pro): Async props & incremental RSC rendering + length-prefixed streaming protocol (#2903) fix: stale references and lost Gemfile pins after Pro migration (#3104) Restore Metrics/ModuleLength limit to 180 Clarify RuboCop lint contract Exclude dead project URLs from markdown link check (#3306) Canonicalize GitHub links in docs Reduce `react-on-rails-pro-node-renderer` package size (#3304) fix: format stray markdown files and correct broken link breaking CI on main (#3301) docs(planning): JSON render-request body migration plan (#3280) (#3299) docs: experiment plan for decoupling props from JS source (#3281) (#3297) docs: memory leakage investigation report for SSR pipeline (#3296) docs: fix MDX-incompatible patterns blocking sc-website docs sync (#3275) docs: document RSC node renderer test setup (#3223) Clarify Pro pricing and license guidance (#3272) docs: add AI security scanner evaluation plan (#3236) fix: pin pnpm version in scaffolded CI when packageManager is absent (#3172) (#3174) docs: document RSC HTTP response ownership (#3222) docs: document legacy Webpacker compatibility shims (#3225) chore: sync address-review command and import prompt variant (#3271) # Conflicts: # .lychee.toml # react_on_rails/spec/lib/react_on_rails/doctor_spec.rb # react_on_rails_pro/lib/react_on_rails_pro/pre_seed_renderer_cache.rb
* origin/main: docs: clarify extensible precompile ownership (#3226) docs: fix broken Empirical-Core GitHub link in PROJECTS.md (#3326) docs(planning): PPR implementation plan for React on Rails (#3323) docs: plan React 19 partial pre-rendering work (#3237) Relax Pro jwt dependency to allow 3.2.0 security fix docs(planning): plan RSCRoute ssr=false implementation (#3317) docs: Add PPR investigation findings (#3314) feat(pro): Async props & incremental RSC rendering + length-prefixed streaming protocol (#2903) fix: stale references and lost Gemfile pins after Pro migration (#3104) Restore Metrics/ModuleLength limit to 180 Clarify RuboCop lint contract Exclude dead project URLs from markdown link check (#3306) Canonicalize GitHub links in docs Reduce `react-on-rails-pro-node-renderer` package size (#3304) fix: format stray markdown files and correct broken link breaking CI on main (#3301) docs(planning): JSON render-request body migration plan (#3280) (#3299) docs: experiment plan for decoupling props from JS source (#3281) (#3297) docs: memory leakage investigation report for SSR pipeline (#3296) docs: fix MDX-incompatible patterns blocking sc-website docs sync (#3275)
Planning artifact for #3280 — docs only, no code change. Lives in
internal/planning/per repo convention (alongsideMONOREPO_MERGER_PLAN.md,js-pro-package-separation-plan.md, etc.).Companion to experiment PR #3293. This is the production-quality follow-up plan the issue asked for once we knew the numbers moved: it records the change, the full benchmark results, the operational problems hit while running the experiment, the deep compatibility/protocol findings, and a phased production implementation plan.
What's in
internal/planning/json-render-body-migration-plan.mdrender_codeconditional and why no renderer change is needed (Fastify default JSON parser;extractBodyArrayFieldalready handles bothkeyandkey[]; auth readsreq.body.passwordeither way). Includes the runtime instrumentation proof (Ruby↔Node byte-for-byte match on 3 payload sizes).Links
352743ef1b90977ef3🤖 Generated with Claude Code
Summary by CodeRabbit