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 ( +