Skip to content

Commit eb99b79

Browse files
authored
Fix RSC payload JSON corruption from Rails view annotations (#2535)
## Summary - render the RSC payload endpoint template with `formats: [:text]` so Rails does not apply HTML view annotations - rename the payload view template to `rsc_payload.text.erb` - add a regression request spec that enables `ActionView::Base.annotate_rendered_view_with_filenames` and verifies NDJSON lines remain parseable Fixes #2520 ## Test Plan - `bundle exec rubocop react_on_rails_pro/lib/react_on_rails_pro/concerns/rsc_payload_renderer.rb react_on_rails_pro/spec/dummy/spec/requests/rsc_payload_spec.rb` - `cd react_on_rails_pro/spec/dummy && bundle install` - `cd react_on_rails_pro/spec/dummy && pnpm run node-renderer` (separate terminal) - `cd react_on_rails_pro/spec/dummy && bundle exec rspec spec/requests/rsc_payload_spec.rb` <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Small change to response rendering options plus a regression test; low risk aside from potential clients expecting a different negotiated format. > > **Overview** > For the `rsc_payload` streaming endpoint, forces the render `formats` to `[:text]` so Rails doesn’t inject HTML view-annotation comments that can corrupt the NDJSON payload. > > Adds a request spec that enables `ActionView::Base.annotate_rendered_view_with_filenames` and asserts the response lines remain JSON-parseable and contain expected `html` chunks. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fe6374b. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Prevented NDJSON payload corruption when view annotation is enabled by rendering RSC payloads as plain text and serving application/x-ndjson; custom RSC payload templates must produce plain text (e.g., .text) not HTML. * **Tests** * Added integration tests validating NDJSON responses and behavior with view annotations enabled. * **Documentation** * Added upgrade notes describing template override requirements and migration verification. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent f0c334a commit eb99b79

9 files changed

Lines changed: 272 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Changes since the last non-beta release.
4040
- **Handle HTTPX error responses when fetching dev-server bundle/assets for upload**: During development startup races, `get_form_body_for_file` could receive `HTTPX::ErrorResponse` and still call `response.body`, causing an unexpected crash path. The request layer now raises `ReactOnRailsPro::Error` with HTTPX error details before body access and includes regression tests for local path, HTTP success, and HTTP error cases. [PR 2532](https://github.com/shakacode/react_on_rails/pull/2532) by [justin808](https://github.com/justin808).
4141
- **Fix streaming SSR hangs and silent error absorption in RSC payload injection**: Fixed two related issues: (1) streaming SSR renders hanging forever when errors occur because Node.js `stream.pipe()` doesn't propagate errors or closure from source to destination, and (2) errors in the RSC payload injection pipeline being silently absorbed, preventing them from reaching error reporters like Sentry. Introduced a shared `safePipe` utility and used `'close'` events as reliable termination signals across the streaming pipeline (Node renderer, RSC payload injection, transform streams, and Ruby async task). Also added a Ruby safety net to prevent Rails request hangs when async rendering tasks raise before the first chunk. [PR 2407](https://github.com/shakacode/react_on_rails/pull/2407) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
4242
- **Node renderer duplicate error reports for render failures**: Fixed duplicate `errorReporter.message` notifications when unexpected exceptions occurred in `handleRenderRequest`. The handler now returns an error `ResponseResult` instead of rethrowing, so the same failure is not reported again by `worker.ts` while still returning a 400 response. [PR 2531](https://github.com/shakacode/react_on_rails/pull/2531) by [justin808](https://github.com/justin808).
43+
- **Fix RSC payload JSON corruption from Rails view annotations in development**: RSC payload responses were rendered through an HTML template, so when `annotate_rendered_view_with_filenames` was enabled, Rails wrapped NDJSON chunks with HTML comments and broke client-side `JSON.parse`. The payload endpoint now renders the template in text format and serves `application/x-ndjson`, and a request spec covers the annotated-view scenario. If you override `custom_rsc_payload_template`, ensure it resolves to a text or format-neutral template (for example, `.text.erb`) rather than `.html.erb`. When RSC support is enabled, startup now also warns if `Rack::Deflater` is present, because response-transforming middleware can interfere with `ActionController::Live` NDJSON streaming. [PR 2535](https://github.com/shakacode/react_on_rails/pull/2535) by [justin808](https://github.com/justin808).
4344

4445
### [16.4.0.rc.6] - 2026-03-04
4546

react_on_rails_pro/app/views/react_on_rails_pro/rsc_payload.html.erb renamed to react_on_rails_pro/app/views/react_on_rails_pro/rsc_payload.text.erb

File renamed without changes.

react_on_rails_pro/docs/updating.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,16 @@ export REACT_ON_RAILS_PRO_LICENSE="your-license-token-here"
198198

199199
For complete licensing details, see [LICENSE_SETUP.md](https://github.com/shakacode/react_on_rails/blob/master/react_on_rails_pro/LICENSE_SETUP.md).
200200

201+
### Additional Upgrade Notes
202+
203+
#### Upgrading to 16.4.0 or later
204+
205+
##### RSC payload template overrides
206+
207+
React on Rails Pro now renders the built-in RSC payload template with `formats: [:text]` so Rails view annotations cannot inject HTML comments into NDJSON responses.
208+
209+
If your app overrides `custom_rsc_payload_template`, make sure that override resolves to a text or format-neutral template path, such as `app/views/.../rsc_payload.text.erb`. Overrides that only exist as `.html.erb` templates will raise `ActionView::MissingTemplate` when the RSC payload endpoint renders.
210+
201211
### Verify Migration
202212

203213
#### 1. Verify Gem Installation

react_on_rails_pro/lib/react_on_rails_pro/concerns/rsc_payload_renderer.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,20 @@ def rsc_payload
1515

1616
stream_view_containing_react_components(
1717
template: custom_rsc_payload_template,
18-
layout: false
18+
layout: false,
19+
# Render as text so Rails does not inject HTML view annotation comments
20+
# into the NDJSON stream. Custom template overrides must resolve to a
21+
# text or format-neutral template, not `.html.erb`.
22+
formats: [:text],
23+
content_type: "application/x-ndjson"
24+
)
25+
rescue ActionView::MissingTemplate => e
26+
raise e.exception(
27+
"[React on Rails Pro] RSC payload templates are now rendered with format :text. " \
28+
"If you override `custom_rsc_payload_template`, make sure the override resolves to " \
29+
"a text or format-neutral template (for example `rsc_payload.text.erb`) instead of " \
30+
"only `.html.erb`. See react_on_rails_pro/docs/updating.md for upgrade notes.\n\n" \
31+
"Original error: #{e.message}"
1932
)
2033
end
2134

react_on_rails_pro/lib/react_on_rails_pro/concerns/stream.rb

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ module Stream
1212
#
1313
# @param template [String] The path to the template file to be streamed.
1414
# @param close_stream_at_end [Boolean] Whether to automatically close the stream after rendering (default: true).
15+
# @param content_type [String, nil] Optional response content type. Set after rendering but before the first
16+
# stream write, overriding any content type inferred from the template format. When using
17+
# a non-HTML `formats:` value (for example `[:text]`), pass `content_type` too unless
18+
# committing the format-derived MIME type is intentional.
1519
# @param render_options [Hash] Additional options to pass to `render_to_string`.
1620
#
1721
# components must be added to the view using the `stream_react_component` helper.
@@ -30,10 +34,13 @@ module Stream
3034
# For more details, refer to `lib/react_on_rails/helper.rb` in the react_on_rails repository.
3135
#
3236
# @see ReactOnRails::Helper#stream_react_component
33-
def stream_view_containing_react_components(template:, close_stream_at_end: true, **render_options)
37+
def stream_view_containing_react_components(
38+
template:, close_stream_at_end: true, content_type: nil, **render_options
39+
)
3440
require "async"
3541
require "async/barrier"
3642
require "async/limited_queue"
43+
warn_on_non_html_formats_without_content_type(render_options[:formats], content_type)
3744

3845
Sync do |parent_task|
3946
# Initialize async primitives for concurrent component streaming
@@ -48,6 +55,11 @@ def stream_view_containing_react_components(template:, close_stream_at_end: true
4855
# View may contain extra newlines, chunk already contains a newline
4956
# Having multiple newlines between chunks causes hydration errors
5057
# So we strip extra newlines from the template string and add a single newline
58+
# `formats: [:text]` causes render_to_string to set response.content_type
59+
# to `text/plain`; override it here before the first stream write, which
60+
# is when ActionController::Live commits headers. render_to_string itself
61+
# never writes to response.stream, so this assignment is always safe.
62+
response.content_type = content_type if content_type
5163
response.stream.write(template_string)
5264

5365
drain_streams_concurrently(parent_task)
@@ -120,5 +132,19 @@ def log_client_disconnect(context, exception)
120132
"[React on Rails Pro] Client disconnected during streaming (#{context}): #{exception.class}"
121133
end
122134
end
135+
136+
def warn_on_non_html_formats_without_content_type(formats, content_type)
137+
return if content_type.present?
138+
139+
requested_formats = Array(formats).compact.map(&:to_sym)
140+
return if requested_formats.empty? || requested_formats.all?(:html)
141+
142+
Rails.logger.warn(
143+
"[React on Rails Pro] stream_view_containing_react_components received non-HTML formats " \
144+
"#{requested_formats.inspect} without `content_type:`. Rails will commit the format-derived " \
145+
"MIME type (for example `text/plain` for `:text`). Pass `content_type:` explicitly when " \
146+
"streaming non-HTML responses."
147+
)
148+
end
123149
end
124150
end

react_on_rails_pro/lib/react_on_rails_pro/engine.rb

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ class Engine < Rails::Engine
77
LICENSE_URL = "https://www.shakacode.com/react-on-rails-pro/"
88
# TODO: Remove this legacy migration warning path after 16.5.0 stable release (target: 2026-05-31).
99
LEGACY_LICENSE_FILE = "config/react_on_rails_pro_license.key"
10+
RSC_STREAMING_MIDDLEWARE_WARNING_TARGETS = ["Rack::Deflater"].freeze
1011
private_constant :LICENSE_URL
1112
private_constant :LEGACY_LICENSE_FILE
13+
private_constant :RSC_STREAMING_MIDDLEWARE_WARNING_TARGETS
1214

1315
initializer "react_on_rails_pro.routes" do
1416
ActionDispatch::Routing::Mapper.include ReactOnRailsPro::Routes
@@ -20,6 +22,10 @@ class Engine < Rails::Engine
2022
config.after_initialize { ReactOnRailsPro::Engine.log_license_status }
2123
end
2224

25+
initializer "react_on_rails_pro.check_rsc_streaming_middleware" do
26+
config.after_initialize { ReactOnRailsPro::Engine.log_rsc_streaming_middleware_warning }
27+
end
28+
2329
class << self
2430
def log_license_status
2531
status = ReactOnRailsPro::LicenseValidator.license_status
@@ -40,6 +46,24 @@ def log_license_status
4046
end
4147
end
4248

49+
def log_rsc_streaming_middleware_warning
50+
return unless ReactOnRailsPro.configuration.enable_rsc_support
51+
return if Rails.env.test?
52+
53+
middleware_names = middleware_stack_names
54+
problematic = RSC_STREAMING_MIDDLEWARE_WARNING_TARGETS & middleware_names
55+
return if problematic.empty?
56+
57+
route_path = ReactOnRailsPro.configuration.rsc_payload_generation_url_path
58+
Rails.logger.warn(
59+
"[React on Rails Pro] React Server Components support is enabled and the middleware " \
60+
"stack includes #{problematic.join(', ')}. Compression and other response-transforming " \
61+
"middleware can interfere with ActionController::Live NDJSON streaming. If your " \
62+
"`#{route_path}` payload route is not already exempt, consider bypassing " \
63+
"#{problematic.join(', ')} for that endpoint if you see stalled or corrupted RSC payloads."
64+
)
65+
end
66+
4367
private
4468

4569
def log_valid_license
@@ -105,6 +129,40 @@ def log_legacy_license_migration_notice
105129
Rails.logger.info message
106130
end
107131
end
132+
133+
def middleware_stack_names
134+
middleware_stack = Rails.application&.middleware
135+
return [] unless middleware_stack
136+
137+
entries =
138+
if middleware_stack.respond_to?(:middlewares)
139+
middleware_stack.middlewares
140+
elsif middleware_stack.respond_to?(:to_a)
141+
middleware_stack.to_a
142+
else
143+
Array(middleware_stack)
144+
end
145+
146+
entries.filter_map { |entry| middleware_entry_name(entry) }.uniq
147+
end
148+
149+
def middleware_entry_name(entry)
150+
candidate =
151+
if entry.respond_to?(:klass) && entry.klass
152+
entry.klass
153+
elsif entry.is_a?(Array)
154+
entry.first
155+
else
156+
entry
157+
end
158+
159+
case candidate
160+
when Module
161+
candidate.name
162+
else
163+
candidate.to_s.presence
164+
end
165+
end
108166
end
109167
end
110168
end
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe "RSC payload endpoint" do
6+
def request_rsc_payload
7+
get "/rsc_payload/RscEchoProps", params: { props: { message: "hello" }.to_json }
8+
end
9+
10+
def parsed_chunks
11+
response.body.each_line.filter_map do |line|
12+
stripped_line = line.strip
13+
next if stripped_line.empty?
14+
15+
begin
16+
JSON.parse(stripped_line)
17+
rescue JSON::ParserError => e
18+
raise "Rails view annotation leaked into RSC payload response: #{stripped_line.inspect}" \
19+
if stripped_line.include?("<!--")
20+
21+
raise "Non-JSON line in RSC payload response: #{stripped_line.inspect} (#{e.message})"
22+
end
23+
end
24+
end
25+
26+
def expect_valid_rsc_payload_response
27+
expect(response).to have_http_status(:ok)
28+
expect(response.media_type).to eq("application/x-ndjson")
29+
30+
chunks = parsed_chunks
31+
32+
expect(chunks).not_to be_empty
33+
html_chunk_message =
34+
"Expected at least one RSC chunk to contain an 'html' key, got: #{chunks.inspect}"
35+
expect(chunks.any? { |chunk| chunk.key?("html") }).to be(true), html_chunk_message
36+
end
37+
38+
def render_annotated_html_inline_template
39+
ApplicationController.render(inline: "<p>hello</p>", type: :erb, layout: false, formats: [:html])
40+
end
41+
42+
it "returns parseable NDJSON without view annotation comments" do
43+
request_rsc_payload
44+
expect_valid_rsc_payload_response
45+
end
46+
47+
it "returns parseable NDJSON when view annotation comments are enabled" do
48+
allow(ActionView::Base).to receive(:annotate_rendered_view_with_filenames).and_return(true)
49+
50+
# Rails annotation comment format verified against Rails 7.x.
51+
# If this assertion fails after a Rails upgrade, check ActionView annotation output.
52+
annotated = nil
53+
expect { annotated = render_annotated_html_inline_template }.not_to raise_error
54+
expect(annotated).to include(
55+
"<!--"
56+
), "Rails annotation comment format may have changed - check ActionView annotation output."
57+
58+
request_rsc_payload
59+
60+
expect_valid_rsc_payload_response
61+
end
62+
end

react_on_rails_pro/spec/react_on_rails_pro/engine_spec.rb

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,4 +291,75 @@
291291
end
292292
end
293293
end
294+
295+
describe ".log_rsc_streaming_middleware_warning" do
296+
def build_application(*middleware_klasses)
297+
middleware_entry_class = Struct.new(:klass)
298+
middleware_stack_class = Struct.new(:middlewares)
299+
rails_application_class = Struct.new(:middleware)
300+
middleware_entries = middleware_klasses.map { |klass| middleware_entry_class.new(klass) }
301+
302+
rails_application_class.new(middleware_stack_class.new(middleware_entries))
303+
end
304+
305+
def build_rsc_configuration(enable_rsc_support:, route_path: "rsc_payload/")
306+
instance_double(
307+
ReactOnRailsPro::Configuration,
308+
enable_rsc_support: enable_rsc_support,
309+
rsc_payload_generation_url_path: route_path
310+
)
311+
end
312+
313+
it "logs a startup warning when RSC support is enabled with Rack::Deflater in development" do
314+
allow(Rails).to receive_messages(
315+
application: build_application(Rack::Deflater),
316+
env: ActiveSupport::StringInquirer.new("development")
317+
)
318+
allow(ReactOnRailsPro).to receive(:configuration)
319+
.and_return(build_rsc_configuration(enable_rsc_support: true))
320+
321+
expect(mock_logger).to receive(:warn).with(a_string_including("Rack::Deflater", "rsc_payload/"))
322+
323+
described_class.log_rsc_streaming_middleware_warning
324+
end
325+
326+
it "does not log a warning when no known streaming hazard is present" do
327+
allow(Rails).to receive_messages(
328+
application: build_application(ActionDispatch::Executor),
329+
env: ActiveSupport::StringInquirer.new("development")
330+
)
331+
allow(ReactOnRailsPro).to receive(:configuration)
332+
.and_return(build_rsc_configuration(enable_rsc_support: true))
333+
334+
expect(mock_logger).not_to receive(:warn)
335+
336+
described_class.log_rsc_streaming_middleware_warning
337+
end
338+
339+
it "does not log a warning when RSC support is disabled" do
340+
allow(Rails).to receive_messages(
341+
application: build_application(Rack::Deflater),
342+
env: ActiveSupport::StringInquirer.new("development")
343+
)
344+
allow(ReactOnRailsPro).to receive(:configuration)
345+
.and_return(build_rsc_configuration(enable_rsc_support: false))
346+
347+
expect(mock_logger).not_to receive(:warn)
348+
349+
described_class.log_rsc_streaming_middleware_warning
350+
end
351+
352+
it "does not log a warning in test" do
353+
allow(Rails).to receive_messages(
354+
application: build_application(Rack::Deflater),
355+
env: ActiveSupport::StringInquirer.new("test")
356+
)
357+
allow(ReactOnRailsPro).to receive(:configuration)
358+
.and_return(build_rsc_configuration(enable_rsc_support: true))
359+
360+
expect(mock_logger).not_to receive(:warn)
361+
362+
described_class.log_rsc_streaming_middleware_warning
363+
end
364+
end
294365
end

react_on_rails_pro/spec/react_on_rails_pro/stream_spec.rb

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -376,9 +376,11 @@ def render_to_string(**_opts)
376376
end
377377

378378
describe "Component streaming concurrency" do
379-
def run_stream(controller, template: "ignored")
379+
def run_stream(controller, template: "ignored", **options)
380380
Sync do |parent|
381-
parent.async { controller.stream_view_containing_react_components(template: template) }
381+
parent.async do
382+
controller.stream_view_containing_react_components(template: template, **options)
383+
end
382384
yield(parent)
383385
end
384386
end
@@ -390,6 +392,7 @@ def setup_stream_test(component_count: 2)
390392
mocked_response = instance_double(ActionController::Live::Response)
391393
mocked_stream = instance_double(ActionController::Live::Buffer)
392394
allow(mocked_response).to receive(:stream).and_return(mocked_stream)
395+
allow(mocked_response).to receive(:content_type=)
393396
allow(mocked_stream).to receive(:write)
394397
allow(mocked_stream).to receive(:close)
395398
allow(mocked_stream).to receive(:closed?).and_return(false)
@@ -491,6 +494,30 @@ def setup_stream_test(component_count: 2)
491494
expect(gaps.all? { |gap| gap >= 0.04 }).to be true
492495
end
493496

497+
it "warns when non-HTML formats are streamed without an explicit content type" do
498+
_queues, controller, _stream = setup_stream_test(component_count: 0)
499+
mock_logger = instance_double(Logger, warn: nil)
500+
allow(Rails).to receive(:logger).and_return(mock_logger)
501+
502+
expect(mock_logger).to receive(:warn).with(/non-HTML formats \[:text\].*without `content_type:`/)
503+
504+
run_stream(controller, formats: [:text]) do |_parent|
505+
sleep 0.1
506+
end
507+
end
508+
509+
it "does not warn when non-HTML formats provide an explicit content type" do
510+
_queues, controller, _stream = setup_stream_test(component_count: 0)
511+
mock_logger = instance_double(Logger, warn: nil)
512+
allow(Rails).to receive(:logger).and_return(mock_logger)
513+
514+
expect(mock_logger).not_to receive(:warn)
515+
516+
run_stream(controller, formats: [:text], content_type: "application/x-ndjson") do |_parent|
517+
sleep 0.1
518+
end
519+
end
520+
494521
describe "client disconnect handling" do
495522
it "stops writing on IOError" do
496523
queues, controller, stream = setup_stream_test(component_count: 1)

0 commit comments

Comments
 (0)