diff --git a/lib/react_on_rails/configuration.rb b/lib/react_on_rails/configuration.rb index 200bba49a..5700319cd 100644 --- a/lib/react_on_rails/configuration.rb +++ b/lib/react_on_rails/configuration.rb @@ -39,7 +39,8 @@ def self.configuration i18n_output_format: nil, components_subdirectory: nil, make_generated_server_bundle_the_entrypoint: false, - defer_generated_component_packs: true + defer_generated_component_packs: true, + force_load: false ) end @@ -53,7 +54,8 @@ class Configuration :server_render_method, :random_dom_id, :auto_load_bundle, :same_bundle_for_client_and_server, :rendering_props_extension, :make_generated_server_bundle_the_entrypoint, - :defer_generated_component_packs + :defer_generated_component_packs, + :force_load # rubocop:disable Metrics/AbcSize def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender: nil, @@ -68,7 +70,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender same_bundle_for_client_and_server: nil, i18n_dir: nil, i18n_yml_dir: nil, i18n_output_format: nil, random_dom_id: nil, server_render_method: nil, rendering_props_extension: nil, - components_subdirectory: nil, auto_load_bundle: nil) + components_subdirectory: nil, auto_load_bundle: nil, force_load: nil) self.node_modules_location = node_modules_location.present? ? node_modules_location : Rails.root self.generated_assets_dirs = generated_assets_dirs self.generated_assets_dir = generated_assets_dir diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index ac29fba10..1a229901e 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -441,6 +441,14 @@ def internal_react_component(react_component_name, options = {}) "data-trace" => (render_options.trace ? true : nil), "data-dom-id" => render_options.dom_id) + if render_options.force_load + component_specification_tag.concat( + content_tag(:script, %( +ReactOnRails.reactOnRailsComponentLoaded('#{render_options.dom_id}'); + ).html_safe) + ) + end + # Create the HTML rendering part result = server_rendered_react_component(render_options) diff --git a/lib/react_on_rails/react_component/render_options.rb b/lib/react_on_rails/react_component/render_options.rb index 01f9ffc4e..f73415bc8 100644 --- a/lib/react_on_rails/react_component/render_options.rb +++ b/lib/react_on_rails/react_component/render_options.rb @@ -91,6 +91,10 @@ def logging_on_server retrieve_configuration_value_for(:logging_on_server) end + def force_load + retrieve_configuration_value_for(:force_load) + end + def to_s "{ react_component_name = #{react_component_name}, options = #{options}, request_digest = #{request_digest}" end diff --git a/node_package/src/ReactOnRails.ts b/node_package/src/ReactOnRails.ts index 8a2152292..440ab3784 100644 --- a/node_package/src/ReactOnRails.ts +++ b/node_package/src/ReactOnRails.ts @@ -133,6 +133,10 @@ ctx.ReactOnRails = { ClientStartup.reactOnRailsPageLoaded(); }, + reactOnRailsComponentLoaded(domId: string): void { + ClientStartup.reactOnRailsComponentLoaded(domId); + }, + /** * Returns CSRF authenticity token inserted by Rails csrf_meta_tags * @returns String or null diff --git a/node_package/src/clientStartup.ts b/node_package/src/clientStartup.ts index 23260c7bd..1be0c56e8 100644 --- a/node_package/src/clientStartup.ts +++ b/node_package/src/clientStartup.ts @@ -218,6 +218,25 @@ export function reactOnRailsPageLoaded(): void { forEachReactOnRailsComponentRender(context, railsContext); } +export function reactOnRailsComponentLoaded(domId: string): void { + debugTurbolinks(`reactOnRailsComponentLoaded ${domId}`); + + const railsContext = parseRailsContext(); + + // If no react on rails components + if (!railsContext) return; + + const context = findContext(); + if (supportsRootApi) { + context.roots = []; + } + + const el = document.querySelector(`[data-dom-id=${domId}]`); + if (!el) return; + + render(el, context, railsContext); +} + function unmount(el: Element): void { const domNodeId = domNodeIdForEl(el); const domNode = document.getElementById(domNodeId); diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index 06ce51ae7..dd58529c4 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -126,6 +126,7 @@ export interface ReactOnRails { setOptions(newOptions: {traceTurbolinks: boolean}): void; reactHydrateOrRender(domNode: Element, reactElement: ReactElement, hydrate: boolean): RenderReturnType; reactOnRailsPageLoaded(): void; + reactOnRailsComponentLoaded(domId: string): void; authenticityToken(): string | null; authenticityHeaders(otherHeaders: { [id: string]: string }): AuthenticityHeaders; option(key: string): string | number | boolean | undefined; diff --git a/spec/dummy/Gemfile b/spec/dummy/Gemfile index a2bcc402b..0da58f353 100644 --- a/spec/dummy/Gemfile +++ b/spec/dummy/Gemfile @@ -5,3 +5,5 @@ source "https://rubygems.org" eval_gemfile File.expand_path("../../Gemfile.development_dependencies", __dir__) gem "react_on_rails", path: "../.." + +gem "turbo-rails" diff --git a/spec/dummy/app/controllers/pages_controller.rb b/spec/dummy/app/controllers/pages_controller.rb index b9371c0c5..a03395ddf 100644 --- a/spec/dummy/app/controllers/pages_controller.rb +++ b/spec/dummy/app/controllers/pages_controller.rb @@ -36,6 +36,12 @@ def data }.merge(xss_payload) } + @app_props_hello_from_turbo_stream = { + helloTurboStreamData: { + name: "Mrs. Client Side Rendering From Turbo Stream" + }.merge(xss_payload) + } + @app_props_hello_again = { helloWorldData: { name: "Mrs. Client Side Hello Again" diff --git a/spec/dummy/app/views/pages/turbo_frame_tag_hello_world.html.erb b/spec/dummy/app/views/pages/turbo_frame_tag_hello_world.html.erb new file mode 100644 index 000000000..34d688962 --- /dev/null +++ b/spec/dummy/app/views/pages/turbo_frame_tag_hello_world.html.erb @@ -0,0 +1,3 @@ +<%= turbo_frame_tag 'hello-turbo-stream' do %> + <%= button_to "send me hello-turbo-stream component", turbo_stream_send_hello_world_path %> +<% end %> \ No newline at end of file diff --git a/spec/dummy/app/views/pages/turbo_stream_send_hello_world.turbo_stream.erb b/spec/dummy/app/views/pages/turbo_stream_send_hello_world.turbo_stream.erb new file mode 100644 index 000000000..2fb020add --- /dev/null +++ b/spec/dummy/app/views/pages/turbo_stream_send_hello_world.turbo_stream.erb @@ -0,0 +1,3 @@ +<%= turbo_stream.update 'hello-turbo-stream' do %> + <%= react_component("HelloTurboStream", props: @app_props_hello_from_turbo_stream, force_load: true) %> +<% end %> \ No newline at end of file diff --git a/spec/dummy/client/app/packs/client-bundle.js b/spec/dummy/client/app/packs/client-bundle.js index 3545ace10..9de15b583 100644 --- a/spec/dummy/client/app/packs/client-bundle.js +++ b/spec/dummy/client/app/packs/client-bundle.js @@ -2,13 +2,21 @@ import 'core-js/stable'; import 'regenerator-runtime/runtime'; import 'jquery'; import 'jquery-ujs'; +import '@hotwired/turbo-rails'; import ReactOnRails from 'react-on-rails'; import SharedReduxStore from '../stores/SharedReduxStore'; +import HelloTurboStream from '../startup/HelloTurboStream'; + ReactOnRails.setOptions({ traceTurbolinks: true, + turbo: true +}); + +ReactOnRails.register({ + HelloTurboStream }); ReactOnRails.registerStore({ diff --git a/spec/dummy/client/app/startup/HelloTurboStream.jsx b/spec/dummy/client/app/startup/HelloTurboStream.jsx new file mode 100644 index 000000000..02db334f0 --- /dev/null +++ b/spec/dummy/client/app/startup/HelloTurboStream.jsx @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import RailsContext from '../components/RailsContext'; + +import css from '../components/HelloWorld.module.scss'; + +class HelloTurboStream extends React.Component { + static propTypes = { + helloTurboStreamData: PropTypes.shape({ + name: PropTypes.string, + }).isRequired, + railsContext: PropTypes.object, + }; + + constructor(props) { + super(props); + this.state = props.helloTurboStreamData; + this.setNameDomRef = this.setNameDomRef.bind(this); + this.handleChange = this.handleChange.bind(this); + } + + setNameDomRef(nameDomNode) { + this.nameDomRef = nameDomNode; + } + + handleChange() { + const name = this.nameDomRef.value; + this.setState({ name }); + } + + render() { + const { name } = this.state; + const { railsContext } = this.props; + + return ( + <div> + <h3 className={css.brightColor}>Hello, {name}!</h3> + {railsContext && <RailsContext {...{ railsContext }} />} + </div> + ); + } +} + +export default HelloTurboStream; diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index 141f8aed7..9dbfbdab4 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -41,4 +41,6 @@ get "image_example" => "pages#image_example" get "context_function_return_jsx" => "pages#context_function_return_jsx" get "pure_component_wrapped_in_function" => "pages#pure_component_wrapped_in_function" + get "turbo_frame_tag_hello_world" => "pages#turbo_frame_tag_hello_world" + post "turbo_stream_send_hello_world" => "pages#turbo_stream_send_hello_world" end diff --git a/spec/dummy/package.json b/spec/dummy/package.json index 94135da49..62d2bf8b6 100644 --- a/spec/dummy/package.json +++ b/spec/dummy/package.json @@ -12,6 +12,7 @@ "@babel/preset-env": "7", "@babel/preset-react": "^7.10.4", "@babel/runtime": "7.17.9", + "@hotwired/turbo-rails": "^8.0.4", "@rescript/react": "^0.10.3", "babel-loader": "8.2.4", "babel-plugin-macros": "^3.1.0", diff --git a/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb b/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb index f53699c88..8993f51e0 100644 --- a/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb +++ b/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb @@ -288,6 +288,26 @@ class PlainReactOnRailsHelper it { is_expected.not_to include '<span id="App-react-component-0"></span>' } it { is_expected.to include '<div id="App-react-component-0"></div>' } end + + context "force load" do + let(:force_load_script) do + %( +ReactOnRails.reactOnRailsComponentLoaded('App-react-component-0'); + ).html_safe + end + + context "with 'force_load' tag option" do + subject { react_component("App", force_load: true) } + + it { is_expected.to include force_load_script } + end + + context "without 'force_load' tag option" do + subject { react_component("App") } + + it { is_expected.not_to include force_load_script } + end + end end describe "#redux_store" do diff --git a/spec/dummy/spec/system/integration_spec.rb b/spec/dummy/spec/system/integration_spec.rb index c5d2f3f52..142b9a9cb 100644 --- a/spec/dummy/spec/system/integration_spec.rb +++ b/spec/dummy/spec/system/integration_spec.rb @@ -97,6 +97,16 @@ def finished_all_ajax_requests? end end +describe "TurboStream send react component", :js do + subject { page } + + it "should force load hello-world component immediately" do + visit "/turbo_frame_tag_hello_world" + click_on "send me hello-turbo-stream component" + expect(page).to have_text "Hello, Mrs. Client Side Rendering From Turbo Stream!" + end +end + describe "Pages/client_side_log_throw", :ignore_js_errors, :js do subject { page } diff --git a/spec/react_on_rails/react_component/render_options_spec.rb b/spec/react_on_rails/react_component/render_options_spec.rb index 9a91b933d..c777a7c02 100644 --- a/spec/react_on_rails/react_component/render_options_spec.rb +++ b/spec/react_on_rails/react_component/render_options_spec.rb @@ -9,6 +9,7 @@ replay_console raise_on_prerender_error random_dom_id + force_load ].freeze def the_attrs(react_component_name: "App", options: {})