From 1e7ddf2822e66c3009307bd05f52a8be1a5298e2 Mon Sep 17 00:00:00 2001 From: Nicholas Barone Date: Tue, 1 Oct 2024 12:17:02 -0700 Subject: [PATCH 1/4] add support for passing `children` in from Rails --- lib/react_on_rails/helper.rb | 4 +++- node_package/src/createReactOutput.ts | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 97e0953b2..67b404b0f 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -53,7 +53,9 @@ module Helper # Any other options are passed to the content tag, including the id. # random_dom_id can be set to override the default from the config/initializers. That's only # used if you have multiple instance of the same component on the Rails view. - def react_component(component_name, options = {}) + def react_component(component_name, options = {}, &block) + (options[:props] ||= {})[:children_html] = capture(&block) if block + internal_result = internal_react_component(component_name, options) server_rendered_html = internal_result[:result]["html"] console_script = internal_result[:result]["consoleReplayScript"] diff --git a/node_package/src/createReactOutput.ts b/node_package/src/createReactOutput.ts index 1c4572c74..59d281142 100644 --- a/node_package/src/createReactOutput.ts +++ b/node_package/src/createReactOutput.ts @@ -26,6 +26,8 @@ export default function createReactOutput({ }: CreateParams): CreateReactOutputResult { const { name, component, renderFunction } = componentObj; + const children = props?.children_html ? React.createElement('div', {dangerouslySetInnerHTML: {__html: props.children_html }}) : null; + if (trace) { if (railsContext && railsContext.serverSide) { console.log(`RENDERED ${name} to dom node with id: ${domNodeId}`); @@ -68,8 +70,8 @@ work if you return JSX. Update by wrapping the result JSX of ${name} in a fat ar // If a component, then wrap in an element const reactComponent = renderFunctionResult as ReactComponent; - return React.createElement(reactComponent, props); + return React.createElement(reactComponent, props, children); } // else - return React.createElement(component as ReactComponent, props); + return React.createElement(component as ReactComponent, props, children); } From 99c6c72b7efe68868bb277aae69b43133ef2471f Mon Sep 17 00:00:00 2001 From: Nicholas Barone Date: Tue, 1 Oct 2024 14:29:35 -0700 Subject: [PATCH 2/4] Add tests, dummy examples, docs, plus a bit of polish --- docs/getting-started.md | 18 ++++++++++++++++++ lib/react_on_rails/helper.rb | 4 +++- node_package/src/createReactOutput.ts | 19 ++++++++++++------- .../app/views/pages/children_example.erb | 4 ++++ spec/dummy/app/views/shared/_header.erb | 3 +++ .../client/app/startup/ChildrenExample.jsx | 10 ++++++++++ spec/dummy/config/routes.rb | 1 + spec/dummy/spec/system/integration_spec.rb | 15 +++++++++++++++ 8 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 spec/dummy/app/views/pages/children_example.erb create mode 100644 spec/dummy/client/app/startup/ChildrenExample.jsx diff --git a/docs/getting-started.md b/docs/getting-started.md index 5617ab5d2..f5a81ebff 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -121,6 +121,24 @@ issue. }; ``` +- You can pass a block to `react_component`, and it will be provided as the `children` prop to the React component, as a React element: + ```ruby + <%= react_component("YourComponent") do %> +

Contained HTML

+ <%= render "your/partial" %> + <% end %> + ``` + ```js + import React from 'react'; + + export default (props) => { + return () => ( +

{ props.children }
+ ); + }; + ``` + **Note that this is implementing using React's [dangerouslySetInnerHtml](https://react.dev/reference/react-dom/components/common#dangerously-setting-the-inner-html)**, so named because of the exposure to cross-site scripting attacks. You should generally be fine since the HTML is coming from your own code via the Rails rendering engine, but this is sharp knife so please exercise caution :) + See the [View Helpers API](https://www.shakacode.com/react-on-rails/docs/api/view-helpers-api/) for more details on `react_component` and its sibling function `react_component_hash`. ## Globally Exposing Your React Components diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 67b404b0f..3529956cc 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -110,8 +110,10 @@ def react_component(component_name, options = {}, &block) # <% end %> # <%= react_helmet_app["componentHtml"] %> # - def react_component_hash(component_name, options = {}) + def react_component_hash(component_name, options = {}, &block) + (options[:props] ||= {})[:children_html] = capture(&block) if block options[:prerender] = true + internal_result = internal_react_component(component_name, options) server_rendered_html = internal_result[:result]["html"] console_script = internal_result[:result]["consoleReplayScript"] diff --git a/node_package/src/createReactOutput.ts b/node_package/src/createReactOutput.ts index 59d281142..88e192fba 100644 --- a/node_package/src/createReactOutput.ts +++ b/node_package/src/createReactOutput.ts @@ -26,8 +26,6 @@ export default function createReactOutput({ }: CreateParams): CreateReactOutputResult { const { name, component, renderFunction } = componentObj; - const children = props?.children_html ? React.createElement('div', {dangerouslySetInnerHTML: {__html: props.children_html }}) : null; - if (trace) { if (railsContext && railsContext.serverSide) { console.log(`RENDERED ${name} to dom node with id: ${domNodeId}`); @@ -38,13 +36,20 @@ export default function createReactOutput({ console.log(`RENDERED ${name} to dom node with id: ${domNodeId} with props, railsContext:`, props, railsContext); } + + if (renderFunction) { + console.log(`${name} is a renderFunction`); + } } + // Convert any nested content passed to the component into a React element. + const children = props?.children_html ? React.createElement('div', {dangerouslySetInnerHTML: {__html: props.children_html }}) : null; + + const createElementFromComponent = (komponent: ReactComponent) => React.createElement(komponent, props, children); + if (renderFunction) { // Let's invoke the function to get the result - if (trace) { - console.log(`${name} is a renderFunction`); - } + const renderFunctionResult = (component as RenderFunction)(props, railsContext); if (isServerRenderHash(renderFunctionResult as CreateReactOutputResult)) { // We just return at this point, because calling function knows how to handle this case and @@ -70,8 +75,8 @@ work if you return JSX. Update by wrapping the result JSX of ${name} in a fat ar // If a component, then wrap in an element const reactComponent = renderFunctionResult as ReactComponent; - return React.createElement(reactComponent, props, children); + return createElementFromComponent(reactComponent); } // else - return React.createElement(component as ReactComponent, props, children); + return createElementFromComponent(component as ReactComponent); } diff --git a/spec/dummy/app/views/pages/children_example.erb b/spec/dummy/app/views/pages/children_example.erb new file mode 100644 index 000000000..3ce44ffdf --- /dev/null +++ b/spec/dummy/app/views/pages/children_example.erb @@ -0,0 +1,4 @@ +<%= react_component("ChildrenExample", prerender: true, trace: true) do %> +

This page demonstrates using Rails to produce content for child nodes of React components

+

And, just to check that multiple DOM nodes are fine

+<% end %> diff --git a/spec/dummy/app/views/shared/_header.erb b/spec/dummy/app/views/shared/_header.erb index 020a330f4..0687b745d 100644 --- a/spec/dummy/app/views/shared/_header.erb +++ b/spec/dummy/app/views/shared/_header.erb @@ -104,5 +104,8 @@
  • <%= link_to "Incorrectly wrapping a pure component in a function", pure_component_wrapped_in_function_path %>
  • +
  • + <%= link_to "Children Example", children_example_path %> +

  • diff --git a/spec/dummy/client/app/startup/ChildrenExample.jsx b/spec/dummy/client/app/startup/ChildrenExample.jsx new file mode 100644 index 000000000..f4fc65287 --- /dev/null +++ b/spec/dummy/client/app/startup/ChildrenExample.jsx @@ -0,0 +1,10 @@ +import React from 'react'; + +const ComponentWithChildren = ({ children }) => ( +
    +

    This is component for testing passing children in from Rails

    + { children } +
    +); + +export default ComponentWithChildren; diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index 9dbfbdab4..1f65861e5 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -39,6 +39,7 @@ get "react_helmet_broken" => "pages#react_helmet_broken" get "broken_app" => "pages#broken_app" get "image_example" => "pages#image_example" + get "children_example" => "pages#children_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" diff --git a/spec/dummy/spec/system/integration_spec.rb b/spec/dummy/spec/system/integration_spec.rb index deb066c47..d0751d7f9 100644 --- a/spec/dummy/spec/system/integration_spec.rb +++ b/spec/dummy/spec/system/integration_spec.rb @@ -284,6 +284,21 @@ def finished_all_ajax_requests? end end +describe "with children", :js do + subject { page } + + before { visit "/children_example" } + + it "children_example should not have any errors" do + expect(page).to have_text( + "This page demonstrates using Rails to produce content for child nodes of React components" + ) + expect(page).to have_text( + "And, just to check that multiple DOM nodes are fine" + ) + end +end + describe "use different props for server/client", :js do subject { page } From 0caeb6e547aa0e3e040731db0cf58c44de4eb324 Mon Sep 17 00:00:00 2001 From: Nicholas Barone Date: Tue, 1 Oct 2024 15:01:03 -0700 Subject: [PATCH 3/4] Good suggestions from the AI for the docs --- docs/getting-started.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index f5a81ebff..df543d7e9 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -131,13 +131,13 @@ issue. ```js import React from 'react'; - export default (props) => { - return () => ( -
    { props.children }
    - ); - }; + const YourComponent = (props) => ( +
    { props.children }
    + ); + + export default YourComponent; ``` - **Note that this is implementing using React's [dangerouslySetInnerHtml](https://react.dev/reference/react-dom/components/common#dangerously-setting-the-inner-html)**, so named because of the exposure to cross-site scripting attacks. You should generally be fine since the HTML is coming from your own code via the Rails rendering engine, but this is sharp knife so please exercise caution :) + **Security Note**: This feature is implemented using React's [dangerouslySetInnerHtml](https://react.dev/reference/react-dom/components/common#dangerously-setting-the-inner-html)**. While generally safe when the HTML comes from your own Rails rendering engine, it can expose your application to cross-site scripting (XSS) attacks if not used carefully. Always ensure that the content passed to the block is properly sanitized and comes from trusted sources. Avoid passing user-generated content without proper sanitization. See the [View Helpers API](https://www.shakacode.com/react-on-rails/docs/api/view-helpers-api/) for more details on `react_component` and its sibling function `react_component_hash`. From eceb9f488c4ceb1ed0ed8145e62c69dcb432db53 Mon Sep 17 00:00:00 2001 From: Nicholas Barone Date: Tue, 1 Oct 2024 15:09:28 -0700 Subject: [PATCH 4/4] DRY up the changes to the helper --- lib/react_on_rails/helper.rb | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 3529956cc..7d883a8b1 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -54,9 +54,7 @@ module Helper # random_dom_id can be set to override the default from the config/initializers. That's only # used if you have multiple instance of the same component on the Rails view. def react_component(component_name, options = {}, &block) - (options[:props] ||= {})[:children_html] = capture(&block) if block - - internal_result = internal_react_component(component_name, options) + internal_result = internal_react_component(component_name, options, &block) server_rendered_html = internal_result[:result]["html"] console_script = internal_result[:result]["consoleReplayScript"] render_options = internal_result[:render_options] @@ -111,10 +109,9 @@ def react_component(component_name, options = {}, &block) # <%= react_helmet_app["componentHtml"] %> # def react_component_hash(component_name, options = {}, &block) - (options[:props] ||= {})[:children_html] = capture(&block) if block options[:prerender] = true - internal_result = internal_react_component(component_name, options) + internal_result = internal_react_component(component_name, options, &block) server_rendered_html = internal_result[:result]["html"] console_script = internal_result[:result]["consoleReplayScript"] render_options = internal_result[:render_options] @@ -424,7 +421,7 @@ def prepend_render_rails_context(render_value) "#{rails_context_content}\n#{render_value}".html_safe end - def internal_react_component(react_component_name, options = {}) + def internal_react_component(react_component_name, options = {}, &block) # Create the JavaScript and HTML to allow either client or server rendering of the # react_component. # @@ -432,6 +429,8 @@ def internal_react_component(react_component_name, options = {}) # (re-hydrate the data). This enables react rendered on the client to see that the # server has already rendered the HTML. + (options[:props] ||= {})[:children_html] = capture(&block) if block + render_options = ReactOnRails::ReactComponent::RenderOptions.new(react_component_name: react_component_name, options: options)