Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Add support for RSC #1644

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,6 @@ npm-debug.*
yalc.lock

.byebug_history

# IDE
.idea/
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
module.exports = {
preset: 'ts-jest/presets/js-with-ts',
testEnvironment: 'jsdom',
setupFiles: ['<rootDir>/node_package/tests/jest.setup.js'],
};
113 changes: 98 additions & 15 deletions lib/react_on_rails/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,39 @@ def react_component(component_name, options = {})
end
end

def rsc_react_component(component_name, options = {})
rendering_fiber = Fiber.new do
res = internal_rsc_react_component(component_name, options)
res.each_chunk do |chunk|
Fiber.yield chunk
end
Fiber.yield nil
end
rendering_fiber
end

def stream_react_component(component_name, options = {})
rendering_fiber = Fiber.new do
stream = stream_react_component_internal(component_name, options)
stream.each_chunk do |chunk|
Fiber.yield chunk
end
Fiber.yield nil
end

if @rorp_rendering_fibers.nil?
raise ReactOnRails::Error,
"You must call stream_view_containing_react_components to render the view containing the react component"
end

@rorp_rendering_fibers << rendering_fiber

# return the first chunk of the fiber
# It contains the initial html of the component
# all updates will be appended to the stream sent to browser
rendering_fiber.resume
end

# react_component_hash is used to return multiple HTML strings for server rendering, such as for
# adding meta-tags to a page.
# It is exactly like react_component except for the following:
Expand Down Expand Up @@ -317,19 +350,30 @@ def load_pack_for_generated_component(react_component_name, render_options)
return unless render_options.auto_load_bundle

ReactOnRails::PackerUtils.raise_nested_entries_disabled unless ReactOnRails::PackerUtils.nested_entries?
if Rails.env.development?
is_component_pack_present = File.exist?(generated_components_pack_path(react_component_name))
raise_missing_autoloaded_bundle(react_component_name) unless is_component_pack_present
end
append_javascript_pack_tag("generated/#{react_component_name}",
defer: ReactOnRails.configuration.defer_generated_component_packs)
append_stylesheet_pack_tag("generated/#{react_component_name}")
append_javascript_pack_tag("client-bundle")
# if Rails.env.development?
# is_component_pack_present = File.exist?(generated_components_pack_path(react_component_name))
# raise_missing_autoloaded_bundle(react_component_name) unless is_component_pack_present
# end
# append_javascript_pack_tag("generated/#{react_component_name}",
# defer: ReactOnRails.configuration.defer_generated_component_packs)
# append_stylesheet_pack_tag("generated/#{react_component_name}")
Comment on lines +354 to +360
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should this be removed or is the plan to revert (if so, better to add a comment)?

end

# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity

private

def stream_react_component_internal(component_name, options = {})
options = options.merge(stream?: true)
result = internal_react_component(component_name, options)
build_react_component_result_for_server_streamed_content(
rendered_html_stream: result[:result],
component_specification_tag: result[:tag],
render_options: result[:render_options]
)
end

def generated_components_pack_path(component_name)
"#{ReactOnRails::PackerUtils.packer_source_entry_path}/generated/#{component_name}.js"
end
Expand Down Expand Up @@ -361,6 +405,33 @@ def build_react_component_result_for_server_rendered_string(
prepend_render_rails_context(result)
end

def build_react_component_result_for_server_streamed_content(
rendered_html_stream: required("rendered_html_stream"),
component_specification_tag: required("component_specification_tag"),
render_options: required("render_options")
)
content_tag_options_html_tag = render_options.html_options[:tag] || "div"
# The component_specification_tag is appended to the first chunk
# We need to pass it early with the first chunk because it's needed in hydration
# We need to make sure that client can hydrate the app early even before all components are streamed
is_first_chunk = true
rendered_html_stream = rendered_html_stream.transform do |chunk|
if is_first_chunk
is_first_chunk = false
html_content = <<-HTML
#{rails_context_if_not_already_rendered}
#{component_specification_tag}
<#{content_tag_options_html_tag} id="#{render_options.dom_id}">#{chunk}</#{content_tag_options_html_tag}>
HTML
next html_content.strip
end
chunk
end

rendered_html_stream.transform(&:html_safe)
# TODO: handle console logs
end

def build_react_component_result_for_server_rendered_hash(
server_rendered_html: required("server_rendered_html"),
component_specification_tag: required("component_specification_tag"),
Expand Down Expand Up @@ -404,20 +475,29 @@ def compose_react_component_html_with_spec_and_console(component_specification_t
HTML
end

# prepend the rails_context if not yet applied
def prepend_render_rails_context(render_value)
return render_value if @rendered_rails_context
def rails_context_if_not_already_rendered
return "" if @rendered_rails_context

data = rails_context(server_side: false)

@rendered_rails_context = true

rails_context_content = content_tag(:script,
json_safe_and_pretty(data).html_safe,
type: "application/json",
id: "js-react-on-rails-context")
content_tag(:script,
json_safe_and_pretty(data).html_safe,
type: "application/json",
id: "js-react-on-rails-context")
end

# prepend the rails_context if not yet applied
def prepend_render_rails_context(render_value)
"#{rails_context_if_not_already_rendered}\n#{render_value}".strip.html_safe
end

"#{rails_context_content}\n#{render_value}".html_safe
def internal_rsc_react_component(react_component_name, options = {})
options = options.merge(rsc?: true)
render_options = ReactOnRails::ReactComponent::RenderOptions.new(react_component_name: react_component_name,
options: options)
server_rendered_react_component(render_options)
end

def internal_react_component(react_component_name, options = {})
Expand Down Expand Up @@ -512,6 +592,9 @@ def server_rendered_react_component(render_options)
js_code: js_code)
end

# TODO: handle errors for streams
Copy link
Collaborator

Choose a reason for hiding this comment

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

Still left as TODO?

return result if render_options.stream? || render_options.rsc?

if result["hasErrors"] && render_options.raise_on_prerender_error
# We caught this exception on our backtrace handler
raise ReactOnRails::PrerenderError.new(component_name: react_component_name,
Expand Down
8 changes: 8 additions & 0 deletions lib/react_on_rails/react_component/render_options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ def set_option(key, value)
options[key] = value
end

def stream?
options[:stream?]
end

def rsc?
options[:rsc?]
end

private

attr_reader :options
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ def exec_server_render_js(js_code, render_options, js_evaluator = nil)
end
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/PerceivedComplexity

# TODO: merge with exec_server_render_js
def exec_server_render_streaming_js(js_code, render_options, js_evaluator = nil)
js_evaluator ||= self
js_evaluator.eval_streaming_js(js_code, render_options)
end

def trace_js_code_used(msg, js_code, file_name = "tmp/server-generated.js", force: false)
return unless ReactOnRails.configuration.trace || force

Expand Down
59 changes: 36 additions & 23 deletions lib/react_on_rails/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def self.server_bundle_path_is_http?
server_bundle_js_file_path =~ %r{https?://}
end

def self.server_bundle_js_file_path
def self.bundle_js_file_path(bundle_name)
# Either:
# 1. Using same bundle for both server and client, so server bundle will be hashed in manifest
# 2. Using a different bundle (different Webpack config), so file is not hashed, and
Expand All @@ -76,36 +76,49 @@ def self.server_bundle_js_file_path
# a. The webpack manifest plugin would have a race condition where the same manifest.json
# is edited by both the webpack-dev-server
# b. There is no good reason to hash the server bundle name.
return @server_bundle_path if @server_bundle_path && !Rails.env.development?

bundle_name = ReactOnRails.configuration.server_bundle_js_file
@server_bundle_path = if ReactOnRails::PackerUtils.using_packer?
begin
bundle_js_file_path(bundle_name)
rescue Object.const_get(
ReactOnRails::PackerUtils.packer_type.capitalize
)::Manifest::MissingEntryError
File.expand_path(
File.join(ReactOnRails::PackerUtils.packer_public_output_path,
bundle_name)
)
end
else
bundle_js_file_path(bundle_name)
end
end

def self.bundle_js_file_path(bundle_name)
if ReactOnRails::PackerUtils.using_packer? && bundle_name != "manifest.json"
ReactOnRails::PackerUtils.bundle_js_uri_from_packer(bundle_name)
begin
ReactOnRails::PackerUtils.bundle_js_uri_from_packer(bundle_name)
rescue Object.const_get(
ReactOnRails::PackerUtils.packer_type.capitalize
)::Manifest::MissingEntryError
Comment on lines +82 to +84
Copy link
Collaborator

Choose a reason for hiding this comment

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

This looks like it could easily raise an error itself. Any way around it?

File.expand_path(
File.join(ReactOnRails::PackerUtils.packer_public_output_path,
bundle_name)
)
end
else
# Default to the non-hashed name in the specified output directory, which, for legacy
# React on Rails, this is the output directory picked up by the asset pipeline.
# For Shakapacker, this is the public output path defined in the (shaka/web)packer.yml file.
# For Webpacker, this is the public output path defined in the webpacker.yml file.
Comment on lines -104 to +93
Copy link
Collaborator

Choose a reason for hiding this comment

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

Probably should be reverted.

File.join(generated_assets_full_path, bundle_name)
end
end

def self.server_bundle_js_file_path
Copy link
Collaborator

Choose a reason for hiding this comment

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

What's the benefit from moving it here? The drawback is less clear diff.

# Either:
# 1. Using same bundle for both server and client, so server bundle will be hashed in manifest
# 2. Using a different bundle (different Webpack config), so file is not hashed, and
# bundle_js_path will throw so the default path is used without a hash.
# 3. The third option of having the server bundle hashed and a different configuration than
# the client bundle is not supported for 2 reasons:
# a. The webpack manifest plugin would have a race condition where the same manifest.json
# is edited by both the webpack-dev-server
# b. There is no good reason to hash the server bundle name.
return @server_bundle_path if @server_bundle_path && !Rails.env.development?

bundle_name = ReactOnRails.configuration.server_bundle_js_file
@server_bundle_path = bundle_js_file_path(bundle_name)
end

def self.rsc_bundle_js_file_path
return @rsc_bundle_path if @rsc_bundle_path && !Rails.env.development?

# TODO: make it configurable
bundle_name = "rsc-bundle.js"
@rsc_bundle_path = bundle_js_file_path(bundle_name)
end

def self.running_on_windows?
(/cygwin|mswin|mingw|bccwin|wince|emx/ =~ RUBY_PLATFORM) != nil
end
Expand Down
20 changes: 19 additions & 1 deletion node_package/src/ReactOnRails.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { ReactElement } from 'react';
import type { PassThrough } from 'stream';

import * as ClientStartup from './clientStartup';
import handleError from './handleError';
import ComponentRegistry from './ComponentRegistry';
import StoreRegistry from './StoreRegistry';
import serverRenderReactComponent from './serverRenderReactComponent';
import serverRenderReactComponent, { streamServerRenderedReactComponent } from './serverRenderReactComponent';
import buildConsoleReplay from './buildConsoleReplay';
import createReactOutput from './createReactOutput';
import Authenticity from './Authenticity';
Expand Down Expand Up @@ -241,6 +242,23 @@ ctx.ReactOnRails = {
return serverRenderReactComponent(options);
},

/**
* Used by server rendering by Rails
* @param options
*/
streamServerRenderedReactComponent(options: RenderParams): PassThrough {
return streamServerRenderedReactComponent(options);
},

/**
* Used by server rendering by Rails
* @param options
Copy link
Collaborator

Choose a reason for hiding this comment

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

No use including this line without a description.

*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
serverRenderRSCReactComponent(options: RenderParams): PassThrough {
throw new Error('serverRenderRSCReactComponent is supported in RSC bundle only.');
},

/**
* Used by Rails to catch errors in rendering
* @param options
Expand Down
Loading
Loading