diff --git a/Gemfile.development_dependencies b/Gemfile.development_dependencies index ea9496173..ea7f695b9 100644 --- a/Gemfile.development_dependencies +++ b/Gemfile.development_dependencies @@ -22,6 +22,8 @@ gem "sprockets", "~> 4.0" gem "amazing_print" +gem "turbo-rails" + group :development, :test do gem "package_json" gem "listen" diff --git a/Gemfile.lock b/Gemfile.lock index af4b3f945..60088f3a0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - react_on_rails (14.0.3) + react_on_rails (14.0.4) addressable connection_pool execjs (~> 2.5) @@ -369,6 +369,10 @@ GEM tins (1.33.0) bigdecimal sync + turbo-rails (2.0.6) + actionpack (>= 6.0.0) + activejob (>= 6.0.0) + railties (>= 6.0.0) turbolinks (5.2.1) turbolinks-source (~> 5.2) turbolinks-source (5.2.0) @@ -431,6 +435,7 @@ DEPENDENCIES spring (~> 4.0) sprockets (~> 4.0) sqlite3 (~> 1.6) + turbo-rails turbolinks uglifier webdrivers (= 5.3.0) diff --git a/lib/react_on_rails/configuration.rb b/lib/react_on_rails/configuration.rb index 9d169e975..2a468c413 100644 --- a/lib/react_on_rails/configuration.rb +++ b/lib/react_on_rails/configuration.rb @@ -39,7 +39,9 @@ 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, + # forces the loading of React components + force_load: false ) end @@ -53,7 +55,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 +71,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 @@ -106,6 +109,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender self.auto_load_bundle = auto_load_bundle self.make_generated_server_bundle_the_entrypoint = make_generated_server_bundle_the_entrypoint self.defer_generated_component_packs = defer_generated_component_packs + self.force_load = force_load end # rubocop:enable Metrics/AbcSize diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index f354f2615..97e0953b2 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 + load_pack_for_generated_component(react_component_name, render_options) # 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.lock b/spec/dummy/Gemfile.lock index 3ffa8bda6..1202785a9 100644 --- a/spec/dummy/Gemfile.lock +++ b/spec/dummy/Gemfile.lock @@ -362,6 +362,10 @@ GEM timeout (0.4.1) tins (1.32.1) sync + turbo-rails (2.0.6) + actionpack (>= 6.0.0) + activejob (>= 6.0.0) + railties (>= 6.0.0) turbolinks (5.2.1) turbolinks-source (~> 5.2) turbolinks-source (5.2.0) @@ -423,6 +427,7 @@ DEPENDENCIES spring (~> 4.0) sprockets (~> 4.0) sqlite3 (~> 1.6) + turbo-rails turbolinks uglifier webdrivers (= 5.3.0) 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..df7747e19 --- /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 %> 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..93e5b75c6 --- /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 %> diff --git a/spec/dummy/client/app/packs/client-bundle.js b/spec/dummy/client/app/packs/client-bundle.js index 3545ace10..befa83674 100644 --- a/spec/dummy/client/app/packs/client-bundle.js +++ b/spec/dummy/client/app/packs/client-bundle.js @@ -2,13 +2,20 @@ import 'core-js/stable'; import 'regenerator-runtime/runtime'; import 'jquery'; import 'jquery-ujs'; +import '@hotwired/turbo-rails'; import ReactOnRails from 'react-on-rails'; +import HelloTurboStream from '../startup/HelloTurboStream'; import SharedReduxStore from '../stores/SharedReduxStore'; 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..5125044f8 --- /dev/null +++ b/spec/dummy/client/app/startup/HelloTurboStream.jsx @@ -0,0 +1,30 @@ +import PropTypes from 'prop-types'; +import React, { useState, useRef } from 'react'; +import RailsContext from '../components/RailsContext'; + +import css from '../components/HelloWorld.module.scss'; + +const HelloTurboStream = ({ helloTurboStreamData, railsContext }) => { + const [name, setName] = useState(helloTurboStreamData.name); + const nameDomRef = useRef(null); + + const handleChange = () => { + setName(nameDomRef.current.value); + }; + + return ( +
+

Hello, {name}!

+ {railsContext && } +
+ ); +}; + +HelloTurboStream.propTypes = { + helloTurboStreamData: PropTypes.shape({ + name: PropTypes.string, + }).isRequired, + railsContext: PropTypes.object, +}; + +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 9ee3eba05..44a437380 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 57c58cb2e..fc05f73ab 100644 --- a/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb +++ b/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb @@ -290,6 +290,26 @@ class PlainReactOnRailsHelper it { is_expected.not_to include '' } it { is_expected.to include '
' } end + + describe "'force_load' tag option" do + let(:force_load_script) do + %( +ReactOnRails.reactOnRailsComponentLoaded('App-react-component-0'); + ).html_safe + end + + context "with 'force_load' == true" 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..deb066c47 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 "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 } @@ -163,8 +173,7 @@ def finished_all_ajax_requests? subject { page } before do - visit "/" - click_on "React Router" + visit "/react_router" end context "when rendering /react_router" do diff --git a/spec/dummy/yarn.lock b/spec/dummy/yarn.lock index 2c66fbf79..1a634f47d 100644 --- a/spec/dummy/yarn.lock +++ b/spec/dummy/yarn.lock @@ -2056,6 +2056,19 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.6.tgz#d5e0706cf8c6acd8c6032f8d54070af261bbbb2f" integrity sha512-ws57AidsDvREKrZKYffXddNkyaF14iHNHm8VQnZH6t99E8gczjNN0GpvcGny0imC80yQ0tHz1xVUKk/KFQSUyA== +"@hotwired/turbo-rails@^8.0.4": + version "8.0.5" + resolved "https://registry.yarnpkg.com/@hotwired/turbo-rails/-/turbo-rails-8.0.5.tgz#18c2f0e4f7f952307650308590edf5eb9544b0d3" + integrity sha512-1A9G9u28IRAl0C57z8Ka3AhNPyJdwfOrbjr+ABZk2ZEUw2QO7cJ0pgs77asUj2E/tzn1PgrxrSVu24W+1Q5uBA== + dependencies: + "@hotwired/turbo" "^8.0.5" + "@rails/actioncable" "^7.0" + +"@hotwired/turbo@^8.0.5": + version "8.0.5" + resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-8.0.5.tgz#abae6dad018a891e4286e87fa0959217e3866d5a" + integrity sha512-TdZDA7fxVQ2ZycygvpnzjGPmFq4sO/E2QVg+2em/sJ3YTSsIWVEis8HmWlumz+c9DjWcUkcCuB+muF08TInpAQ== + "@jridgewell/gen-mapping@^0.1.0": version "0.1.1" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996" @@ -2116,6 +2129,11 @@ schema-utils "^3.0.0" source-map "^0.7.3" +"@rails/actioncable@^7.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-7.2.0.tgz#dee66d21bc125a9819dc8080ce896eac78d8c63f" + integrity sha512-crcsPF3skrqJkFZLxesZoyUEt8ol25XtTuOAUMdLa5qQKWTZpL8eLVW71bDCwKDQLbV2z5sBZ/XGEC0i+ZZa+A== + "@rescript/react@^0.10.3": version "0.10.3" resolved "https://registry.yarnpkg.com/@rescript/react/-/react-0.10.3.tgz#a2a8bed6b017940ec26c2154764b350f50348889" 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: {})