Skip to content

Commit 36e539f

Browse files
[WIP] Hydrate components immediately after downloading chunks (#1656)
* hydrate the component immediately when loaded and registered * auto register server components and immediately hydrate stores * move react-server-dom-webpack.d.ts to types directory * ensure to initialize registered stores array before accessing * refactor registration callback into separate class * make the early hydration compatible with turbopack, backward compatible and refactor * pass rsc path to RSC Client Root and move the config to RORP * update min node version to 18 * export registerServerComponent as a separate entrypoint to avoid client bundle increase * Update webpack assets status checker to use server bundle configuration * Update webpack assets status checker to handle bundle file paths * [WIP] handle errors happen in rsc payload (#1663) * stream rsc payload in json objects like streamed react components * make path to rsc bundle and react client manifest configurable * feat: Improve client manifest path handling for dev server - Add `dev_server_url` helper to centralize dev server URL construction - Add `public_output_uri_path` to get relative webpack output path - Add `asset_uri_from_packer` to handle asset URIs consistently - Update `react_client_manifest_file_path` to return dev server URLs when appropriate - Add comprehensive specs for new asset URI handling This change ensures client manifest paths are properly resolved to dev server URLs during development, improving hot-reloading functionality. * fix: normalize RSC URL path by absorbing leading/trailing slashes * specify Shakapacker as top-level module * add tests for RSCClientRoot * Make RSCClientRoot tests run with react 18 * Update webpack asset path configuration for client manifest --------- Co-authored-by: Judah Meek <[email protected]>
1 parent 5bf7a87 commit 36e539f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+1943
-401
lines changed

.github/workflows/main.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
- name: Setup Node
2929
uses: actions/setup-node@v4
3030
with:
31-
node-version: ${{ matrix.versions == 'oldest' && '16' || '20' }}
31+
node-version: ${{ matrix.versions == 'oldest' && '18' || '20' }}
3232
- name: Print system information
3333
run: |
3434
echo "Linux release: "; cat /etc/issue
@@ -104,7 +104,7 @@ jobs:
104104
- name: Setup Node
105105
uses: actions/setup-node@v4
106106
with:
107-
node-version: ${{ matrix.versions == 'oldest' && '16' || '20' }}
107+
node-version: ${{ matrix.versions == 'oldest' && '18' || '20' }}
108108
- name: Print system information
109109
run: |
110110
echo "Linux release: "; cat /etc/issue

.github/workflows/package-js-tests.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
- name: Setup Node
2121
uses: actions/setup-node@v4
2222
with:
23-
node-version: ${{ matrix.versions == 'oldest' && '16' || '20' }}
23+
node-version: ${{ matrix.versions == 'oldest' && '18' || '20' }}
2424
- name: Print system information
2525
run: |
2626
echo "Linux release: "; cat /etc/issue
@@ -43,3 +43,6 @@ jobs:
4343
sudo yarn global add yalc
4444
- name: Run JS unit tests for Renderer package
4545
run: yarn test
46+
# TODO: Remove this once we made these tests compatible with React 19
47+
- name: Run JS unit tests for Renderer package with React 18 (for tests not compatible with React 19)
48+
run: yarn test:react-18

Gemfile.lock

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -385,11 +385,6 @@ GEM
385385
nokogiri (~> 1.6)
386386
rubyzip (>= 1.3.0)
387387
selenium-webdriver (~> 4.0, < 4.11)
388-
webpacker (6.0.0.rc.6)
389-
activesupport (>= 5.2)
390-
rack-proxy (>= 0.6.1)
391-
railties (>= 5.2)
392-
semantic_range (>= 2.3.0)
393388
webrick (1.8.1)
394389
websocket (1.2.10)
395390
websocket-driver (0.7.6)
@@ -444,7 +439,6 @@ DEPENDENCIES
444439
turbolinks
445440
uglifier
446441
webdrivers (= 5.3.0)
447-
webpacker (= 6.0.0.rc.6)
448442

449443
BUNDLED WITH
450444
2.5.9

jest.config.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,15 @@ module.exports = {
22
preset: 'ts-jest/presets/js-with-ts',
33
testEnvironment: 'jsdom',
44
setupFiles: ['<rootDir>/node_package/tests/jest.setup.js'],
5+
// TODO: Remove this once we made RSCClientRoot compatible with React 19
6+
moduleNameMapper: process.env.USE_REACT_18
7+
? {
8+
'^react$': '<rootDir>/node_modules/react-18',
9+
'^react/(.*)$': '<rootDir>/node_modules/react-18/$1',
10+
'^react-dom$': '<rootDir>/node_modules/react-dom-18',
11+
'^react-dom/(.*)$': '<rootDir>/node_modules/react-dom-18/$1',
12+
}
13+
: {
14+
'react-server-dom-webpack/client': '<rootDir>/node_package/tests/emptyForTesting.js',
15+
},
516
};

knip.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ const config: KnipConfig = {
99
'node_package/src/ReactOnRails.node.ts!',
1010
'node_package/src/ReactOnRailsRSC.ts!',
1111
'node_package/src/RSCWebpackLoader.js!',
12+
'node_package/src/registerServerComponent.ts!',
13+
'node_package/src/RSCClientRoot.ts!',
1214
],
1315
project: ['node_package/src/**/*.[jt]s!', 'node_package/tests/**/*.[jt]s'],
1416
babel: {
@@ -32,6 +34,10 @@ const config: KnipConfig = {
3234
// Used in CI
3335
'@arethetypeswrong/cli',
3436
'react-server-dom-webpack',
37+
'cross-fetch',
38+
'jsdom',
39+
'react-18',
40+
'react-dom-18',
3541
],
3642
},
3743
'spec/dummy': {

lib/react_on_rails/configuration.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ def self.configure
99
end
1010

1111
DEFAULT_GENERATED_ASSETS_DIR = File.join(%w[public webpack], Rails.env).freeze
12+
DEFAULT_REACT_CLIENT_MANIFEST_FILE = "react-client-manifest.json"
1213

1314
def self.configuration
1415
@configuration ||= Configuration.new(
@@ -18,6 +19,7 @@ def self.configuration
1819
generated_assets_dir: "",
1920
server_bundle_js_file: "",
2021
rsc_bundle_js_file: "",
22+
react_client_manifest_file: DEFAULT_REACT_CLIENT_MANIFEST_FILE,
2123
prerender: false,
2224
auto_load_bundle: false,
2325
replay_console: true,
@@ -57,8 +59,8 @@ class Configuration
5759
:server_render_method, :random_dom_id, :auto_load_bundle,
5860
:same_bundle_for_client_and_server, :rendering_props_extension,
5961
:make_generated_server_bundle_the_entrypoint,
60-
:defer_generated_component_packs, :rsc_bundle_js_file,
61-
:force_load
62+
:defer_generated_component_packs, :force_load, :rsc_bundle_js_file,
63+
:react_client_manifest_file
6264

6365
# rubocop:disable Metrics/AbcSize
6466
def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender: nil,
@@ -74,7 +76,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender
7476
i18n_dir: nil, i18n_yml_dir: nil, i18n_output_format: nil, i18n_yml_safe_load_options: nil,
7577
random_dom_id: nil, server_render_method: nil, rendering_props_extension: nil,
7678
components_subdirectory: nil, auto_load_bundle: nil, force_load: nil,
77-
rsc_bundle_js_file: nil)
79+
rsc_bundle_js_file: nil, react_client_manifest_file: nil)
7880
self.node_modules_location = node_modules_location.present? ? node_modules_location : Rails.root
7981
self.generated_assets_dirs = generated_assets_dirs
8082
self.generated_assets_dir = generated_assets_dir
@@ -102,6 +104,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender
102104
# Server rendering:
103105
self.server_bundle_js_file = server_bundle_js_file
104106
self.rsc_bundle_js_file = rsc_bundle_js_file
107+
self.react_client_manifest_file = react_client_manifest_file
105108
self.same_bundle_for_client_and_server = same_bundle_for_client_and_server
106109
self.server_renderer_pool_size = self.development_mode ? 1 : server_renderer_pool_size
107110
self.server_renderer_timeout = server_renderer_timeout # seconds
@@ -247,6 +250,8 @@ def ensure_webpack_generated_files_exists
247250
files = ["manifest.json"]
248251
files << server_bundle_js_file if server_bundle_js_file.present?
249252
files << rsc_bundle_js_file if rsc_bundle_js_file.present?
253+
files << react_client_manifest_file if react_client_manifest_file.present?
254+
250255
self.webpack_generated_files = files
251256
end
252257

lib/react_on_rails/helper.rb

Lines changed: 61 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ def react_component(component_name, options = {})
124124
# @option options [Boolean] :raise_on_prerender_error Set to true to raise exceptions during server-side rendering
125125
# Any other options are passed to the content tag, including the id.
126126
def stream_react_component(component_name, options = {})
127+
options = options.merge(force_load: true) unless options.key?(:force_load)
127128
run_stream_inside_fiber do
128129
internal_stream_react_component(component_name, options)
129130
end
@@ -193,17 +194,18 @@ def react_component_hash(component_name, options = {})
193194
# props: Ruby Hash or JSON string which contains the properties to pass to the redux store.
194195
# Options
195196
# defer: false -- pass as true if you wish to render this below your component.
196-
def redux_store(store_name, props: {}, defer: false)
197+
# force_load: false -- pass as true if you wish to hydrate this store immediately instead of
198+
# waiting for the page to load.
199+
def redux_store(store_name, props: {}, defer: false, force_load: false)
197200
redux_store_data = { store_name: store_name,
198-
props: props }
201+
props: props,
202+
force_load: force_load }
199203
if defer
200-
@registered_stores_defer_render ||= []
201-
@registered_stores_defer_render << redux_store_data
204+
registered_stores_defer_render << redux_store_data
202205
"YOU SHOULD NOT SEE THIS ON YOUR VIEW -- Uses as a code block, like <% redux_store %> " \
203206
"and not <%= redux store %>"
204207
else
205-
@registered_stores ||= []
206-
@registered_stores << redux_store_data
208+
registered_stores << redux_store_data
207209
result = render_redux_store_data(redux_store_data)
208210
prepend_render_rails_context(result)
209211
end
@@ -215,9 +217,9 @@ def redux_store(store_name, props: {}, defer: false)
215217
# client side rendering of this hydration data, which is a hidden div with a matching class
216218
# that contains a data props.
217219
def redux_store_hydration_data
218-
return if @registered_stores_defer_render.blank?
220+
return if registered_stores_defer_render.blank?
219221

220-
@registered_stores_defer_render.reduce(+"") do |accum, redux_store_data|
222+
registered_stores_defer_render.reduce(+"") do |accum, redux_store_data|
221223
accum << render_redux_store_data(redux_store_data)
222224
end.html_safe
223225
end
@@ -400,6 +402,25 @@ def run_stream_inside_fiber
400402
rendering_fiber.resume
401403
end
402404

405+
def registered_stores
406+
@registered_stores ||= []
407+
end
408+
409+
def registered_stores_defer_render
410+
@registered_stores_defer_render ||= []
411+
end
412+
413+
def registered_stores_including_deferred
414+
registered_stores + registered_stores_defer_render
415+
end
416+
417+
def create_render_options(react_component_name, options)
418+
# If no store dependencies are passed, default to all registered stores up till now
419+
options[:store_dependencies] ||= registered_stores_including_deferred.map { |store| store[:store_name] }
420+
ReactOnRails::ReactComponent::RenderOptions.new(react_component_name: react_component_name,
421+
options: options)
422+
end
423+
403424
def internal_stream_react_component(component_name, options = {})
404425
options = options.merge(stream?: true)
405426
result = internal_react_component(component_name, options)
@@ -415,7 +436,7 @@ def internal_rsc_react_component(react_component_name, options = {})
415436
render_options = create_render_options(react_component_name, options)
416437
json_stream = server_rendered_react_component(render_options)
417438
json_stream.transform do |chunk|
418-
chunk[:html].html_safe
439+
"#{chunk.to_json}\n".html_safe
419440
end
420441
end
421442

@@ -510,7 +531,8 @@ def build_react_component_result_for_server_rendered_hash(
510531
)
511532
end
512533

513-
def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output, console_script)
534+
def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output,
535+
console_script)
514536
# IMPORTANT: Ensure that we mark string as html_safe to avoid escaping.
515537
html_content = <<~HTML
516538
#{rendered_output}
@@ -546,18 +568,20 @@ def internal_react_component(react_component_name, options = {})
546568
# (re-hydrate the data). This enables react rendered on the client to see that the
547569
# server has already rendered the HTML.
548570

549-
render_options = ReactOnRails::ReactComponent::RenderOptions.new(react_component_name: react_component_name,
550-
options: options)
571+
render_options = create_render_options(react_component_name, options)
551572

552573
# Setup the page_loaded_js, which is the same regardless of prerendering or not!
553574
# The reason is that React is smart about not doing extra work if the server rendering did its job.
554575
component_specification_tag = content_tag(:script,
555576
json_safe_and_pretty(render_options.client_props).html_safe,
556577
type: "application/json",
557578
class: "js-react-on-rails-component",
579+
id: "js-react-on-rails-component-#{render_options.dom_id}",
558580
"data-component-name" => render_options.react_component_name,
559581
"data-trace" => (render_options.trace ? true : nil),
560-
"data-dom-id" => render_options.dom_id)
582+
"data-dom-id" => render_options.dom_id,
583+
"data-store-dependencies" => render_options.store_dependencies.to_json,
584+
"data-force-load" => (render_options.force_load ? true : nil))
561585

562586
if render_options.force_load
563587
component_specification_tag.concat(
@@ -579,12 +603,22 @@ def internal_react_component(react_component_name, options = {})
579603
end
580604

581605
def render_redux_store_data(redux_store_data)
582-
result = content_tag(:script,
583-
json_safe_and_pretty(redux_store_data[:props]).html_safe,
584-
type: "application/json",
585-
"data-js-react-on-rails-store" => redux_store_data[:store_name].html_safe)
606+
store_hydration_data = content_tag(:script,
607+
json_safe_and_pretty(redux_store_data[:props]).html_safe,
608+
type: "application/json",
609+
"data-js-react-on-rails-store" => redux_store_data[:store_name].html_safe,
610+
"data-force-load" => (redux_store_data[:force_load] ? true : nil))
611+
612+
if redux_store_data[:force_load]
613+
store_hydration_data.concat(
614+
content_tag(:script, <<~JS.strip_heredoc.html_safe
615+
ReactOnRails.reactOnRailsStoreLoaded('#{redux_store_data[:store_name]}');
616+
JS
617+
)
618+
)
619+
end
586620

587-
prepend_render_rails_context(result)
621+
prepend_render_rails_context(store_hydration_data)
588622
end
589623

590624
def props_string(props)
@@ -641,7 +675,7 @@ def server_rendered_react_component(render_options) # rubocop:disable Metrics/Cy
641675
js_code = ReactOnRails::ServerRenderingJsCode.server_rendering_component_js_code(
642676
props_string: props_string(props).gsub("\u2028", '\u2028').gsub("\u2029", '\u2029'),
643677
rails_context: rails_context(server_side: true).to_json,
644-
redux_stores: initialize_redux_stores,
678+
redux_stores: initialize_redux_stores(render_options),
645679
react_component_name: react_component_name,
646680
render_options: render_options
647681
)
@@ -657,10 +691,7 @@ def server_rendered_react_component(render_options) # rubocop:disable Metrics/Cy
657691
js_code: js_code)
658692
end
659693

660-
# TODO: handle errors for rsc streams
661-
return result if render_options.rsc?
662-
663-
if render_options.stream?
694+
if render_options.stream? || render_options.rsc?
664695
result.transform do |chunk_json_result|
665696
if should_raise_streaming_prerender_error?(chunk_json_result, render_options)
666697
raise_prerender_error(chunk_json_result, react_component_name, props, js_code)
@@ -675,17 +706,20 @@ def server_rendered_react_component(render_options) # rubocop:disable Metrics/Cy
675706
result
676707
end
677708

678-
def initialize_redux_stores
709+
def initialize_redux_stores(render_options)
679710
result = +<<-JS
680711
ReactOnRails.clearHydratedStores();
681712
JS
682713

683-
return result unless @registered_stores.present? || @registered_stores_defer_render.present?
714+
store_dependencies = render_options.store_dependencies
715+
return result unless store_dependencies.present?
684716

685717
declarations = +"var reduxProps, store, storeGenerator;\n"
686-
all_stores = (@registered_stores || []) + (@registered_stores_defer_render || [])
718+
store_objects = registered_stores_including_deferred.select do |store|
719+
store_dependencies.include?(store[:store_name])
720+
end
687721

688-
result << all_stores.each_with_object(declarations) do |redux_store_data, memo|
722+
result << store_objects.each_with_object(declarations) do |redux_store_data, memo|
689723
store_name = redux_store_data[:store_name]
690724
props = props_string(redux_store_data[:props])
691725
memo << <<-JS.strip_heredoc

lib/react_on_rails/packer_utils.rb

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ def self.dev_server_running?
4545
packer.dev_server.running?
4646
end
4747

48+
def self.dev_server_url
49+
"#{packer.dev_server.protocol}://#{packer.dev_server.host_with_port}"
50+
end
51+
4852
def self.shakapacker_version
4953
return @shakapacker_version if defined?(@shakapacker_version)
5054
return nil unless ReactOnRails::Utils.gem_available?("shakapacker")
@@ -79,12 +83,27 @@ def self.bundle_js_uri_from_packer(bundle_name)
7983

8084
if packer.dev_server.running? && (!is_bundle_running_on_server ||
8185
ReactOnRails.configuration.same_bundle_for_client_and_server)
82-
"#{packer.dev_server.protocol}://#{packer.dev_server.host_with_port}#{hashed_bundle_name}"
86+
"#{dev_server_url}#{hashed_bundle_name}"
8387
else
8488
File.expand_path(File.join("public", hashed_bundle_name)).to_s
8589
end
8690
end
8791

92+
def self.public_output_uri_path
93+
"#{packer.config.public_output_path.relative_path_from(packer.config.public_path)}/"
94+
end
95+
96+
# The function doesn't ensure that the asset exists.
97+
# - It just returns url to the asset if dev server is running
98+
# - Otherwise it returns file path to the asset
99+
def self.asset_uri_from_packer(asset_name)
100+
if dev_server_running?
101+
"#{dev_server_url}/#{public_output_uri_path}#{asset_name}"
102+
else
103+
File.join(packer_public_output_path, asset_name).to_s
104+
end
105+
end
106+
88107
def self.precompile?
89108
return ::Webpacker.config.webpacker_precompile? if using_webpacker_const?
90109
return ::Shakapacker.config.shakapacker_precompile? if using_shakapacker_const?

0 commit comments

Comments
 (0)