react_on_rails 17.0.0.rc.2 → 17.0.0.rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/lib/generators/USAGE +6 -0
  4. data/lib/generators/react_on_rails/base_generator.rb +20 -2
  5. data/lib/generators/react_on_rails/dev_tests_generator.rb +20 -1
  6. data/lib/generators/react_on_rails/generator_helper.rb +7 -0
  7. data/lib/generators/react_on_rails/install_generator.rb +58 -1
  8. data/lib/generators/react_on_rails/js_dependency_manager.rb +37 -0
  9. data/lib/generators/react_on_rails/react_no_redux_generator.rb +20 -8
  10. data/lib/generators/react_on_rails/react_with_redux_generator.rb +28 -4
  11. data/lib/generators/react_on_rails/rsc_generator.rb +5 -0
  12. data/lib/generators/react_on_rails/rsc_setup.rb +35 -1
  13. data/lib/generators/react_on_rails/templates/agent_files/.cursor/rules/react-on-rails.mdc +11 -0
  14. data/lib/generators/react_on_rails/templates/agent_files/.github/copilot-instructions.md +7 -0
  15. data/lib/generators/react_on_rails/templates/agent_files/AGENTS.md +165 -0
  16. data/lib/generators/react_on_rails/templates/agent_files/CLAUDE.md +8 -0
  17. data/lib/generators/react_on_rails/templates/base/base/bin/switch-bundler +6 -1
  18. data/lib/generators/react_on_rails/templates/base/base/config/webpack/commonWebpackConfig.js.tt +81 -1
  19. data/lib/generators/react_on_rails/templates/base/base/config/webpack/development.js.tt +2 -2
  20. data/lib/generators/react_on_rails/templates/base/tailwind/app/javascript/src/HelloWorld/ror_components/HelloWorld.client.jsx +30 -0
  21. data/lib/generators/react_on_rails/templates/base/tailwind/app/javascript/src/HelloWorld/ror_components/HelloWorld.client.tsx +34 -0
  22. data/lib/generators/react_on_rails/templates/base/tailwind/app/javascript/stylesheets/application.css +1 -0
  23. data/lib/generators/react_on_rails/templates/dev_tests/eslint.config.mjs +50 -0
  24. data/lib/generators/react_on_rails/templates/redux/tailwind/app/javascript/bundles/HelloWorld/components/HelloWorld.jsx +25 -0
  25. data/lib/generators/react_on_rails/templates/redux/tailwind/app/javascript/bundles/HelloWorld/components/HelloWorld.tsx +29 -0
  26. data/lib/react_on_rails/controller/form_responders.rb +59 -0
  27. data/lib/react_on_rails/dev/server_manager.rb +1 -1
  28. data/lib/react_on_rails/doctor.rb +187 -24
  29. data/lib/react_on_rails/font_helper.rb +162 -0
  30. data/lib/react_on_rails/helper.rb +150 -0
  31. data/lib/react_on_rails/smart_error.rb +135 -5
  32. data/lib/react_on_rails/version.rb +1 -1
  33. data/lib/react_on_rails.rb +1 -0
  34. data/lib/tasks/doctor.rake +5 -2
  35. data/sig/react_on_rails/controller/form_responders.rbs +10 -0
  36. data/sig/react_on_rails/helper.rbs +3 -0
  37. data/sig/react_on_rails/smart_error.rbs +25 -2
  38. metadata +14 -2
  39. data/lib/generators/react_on_rails/templates/dev_tests/.eslintrc +0 -25
@@ -0,0 +1,165 @@
1
+ # AGENTS.md — React on Rails (for AI coding agents working IN this app)
2
+
3
+ This app uses **React on Rails** to render React components from Rails views, with
4
+ optional server-side rendering (SSR). This file teaches an AI agent the conventions
5
+ it needs to add, register, and render a component correctly on the first try.
6
+
7
+ It is intentionally short. For the full reference, see the links at the bottom.
8
+
9
+ > This file describes an app that **uses** React on Rails. It is not the
10
+ > contributor guide for the React on Rails framework itself.
11
+
12
+ ## 1. Adding a component (auto-bundling — the default)
13
+
14
+ Place a component under any directory named `ror_components`. React on Rails
15
+ auto-bundles it, so **no manual registration is needed**:
16
+
17
+ ```
18
+ app/javascript/src/<Name>/ror_components/<Name>.tsx # or .jsx
19
+ ```
20
+
21
+ The component file **must `export default`** the React component. Example:
22
+
23
+ ```tsx
24
+ // app/javascript/src/SimpleCounter/ror_components/SimpleCounter.tsx
25
+ import React, { useState } from 'react';
26
+
27
+ const SimpleCounter = (props: { initialCount?: number }) => {
28
+ const [count, setCount] = useState(props.initialCount ?? 0);
29
+ return <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>;
30
+ };
31
+
32
+ export default SimpleCounter; // default export is required
33
+ ```
34
+
35
+ ### Manual registration (alternative)
36
+
37
+ If you are not using the `ror_components/` auto-bundling convention, register the
38
+ component explicitly from a pack/entry file, importing from the `react-on-rails`
39
+ npm package:
40
+
41
+ ```ts
42
+ import ReactOnRails from 'react-on-rails';
43
+ import SimpleCounter from './SimpleCounter';
44
+
45
+ ReactOnRails.register({ SimpleCounter });
46
+ ```
47
+
48
+ The key (`SimpleCounter`) is the name you pass to `react_component` in the Rails view.
49
+
50
+ ## 2. Rendering it from a Rails view
51
+
52
+ Use the `react_component` view helper in any `.html.erb` view (e.g.
53
+ `app/views/<controller>/<action>.html.erb`). The first argument is the registered
54
+ component name; it must match the file/registration name exactly (case-sensitive).
55
+
56
+ ```erb
57
+ <%= react_component("SimpleCounter", props: { initialCount: 5 }, prerender: true) %>
58
+ ```
59
+
60
+ - `props:` — a Ruby Hash serialized to JSON and passed to the component.
61
+ - `prerender:` — `true` renders the component on the server (SSR) then hydrates on
62
+ the client; `false` renders only on the client. Set `prerender: false` to isolate
63
+ SSR issues while debugging.
64
+
65
+ ## 3. `.client` vs `.server` (and plain) bundles — what runs where
66
+
67
+ When a component name resolves to multiple files, React on Rails picks by suffix:
68
+
69
+ - `<Name>.client.tsx` — runs in the **browser** only. May use `window`, `document`,
70
+ browser-only APIs, and client-side hooks/effects.
71
+ - `<Name>.server.tsx` — runs during **server-side rendering** (Node, no DOM). It
72
+ **must not** use browser-only globals (`window`, `document`, `localStorage`).
73
+ Often it just re-exports the client component for SSR.
74
+ - `<Name>.tsx` (plain, no suffix) — used for **both** server and client. Keep it
75
+ isomorphic: guard any browser-only code with `if (typeof window !== 'undefined')`.
76
+
77
+ Rule of thumb: if `prerender: true`, the code path must run in Node without a DOM.
78
+
79
+ ## 4. The `ReactOnRails` JS API you will actually touch
80
+
81
+ Import from the `react-on-rails` npm package:
82
+
83
+ ```ts
84
+ import ReactOnRails from 'react-on-rails';
85
+ ```
86
+
87
+ - `ReactOnRails.register({ Name })` — register one or more components by name.
88
+ - `ReactOnRails.registerStore({ name })` / `ReactOnRails.getStore(name)` — register
89
+ and retrieve a Redux store (only if this app uses Redux).
90
+ - `ReactOnRails.reactOnRailsPageLoaded()` — rarely needed; React on Rails wires
91
+ client hydration automatically.
92
+
93
+ You usually only need `register` (and only when not using `ror_components/`).
94
+
95
+ ## 5. Top errors and fixes
96
+
97
+ These messages come straight from React on Rails' runtime errors. When you hit one,
98
+ apply the matching fix.
99
+
100
+ ### `Component '<Name>' Not Registered`
101
+
102
+ The component name in the view does not match a registered/auto-bundled component.
103
+
104
+ - Recommended (auto-bundling): put the component file directly inside a
105
+ `ror_components/` directory, e.g.
106
+ `app/javascript/src/<Name>/ror_components/<Name>.client.tsx`, with a `default`
107
+ export, then regenerate packs: `bin/rails react_on_rails:generate_packs`.
108
+ - Or register manually: `ReactOnRails.register({ <Name>: <Name> });` and
109
+ `import <Name> from './components/<Name>';`.
110
+ - Check the name matches the `react_component("<Name>", ...)` call exactly (case-sensitive).
111
+
112
+ ### `Auto-loaded Bundle Missing`
113
+
114
+ The component is set up for auto-loading but its bundle is missing.
115
+
116
+ 1. Run the pack generation task: `bin/rails react_on_rails:generate_packs`.
117
+ 2. Ensure the component is in the correct directory under `app/javascript`.
118
+ 3. Check naming conventions: file is `<Name>.jsx` or `<Name>.tsx` and `export default`s.
119
+ 4. Verify nested entries are enabled in your Shakapacker/webpack config.
120
+
121
+ ### `Hydration Mismatch`
122
+
123
+ Server-rendered HTML doesn't match what React rendered on the client.
124
+
125
+ 1. **Random IDs or timestamps**: use props or deterministic values, not
126
+ `Math.random()` / `Date.now()`.
127
+ 2. **Browser-only APIs**: guard with `if (typeof window !== 'undefined') { ... }`.
128
+ 3. **Different data**: ensure props/`railsContext`/store init are identical on
129
+ server and client.
130
+ 4. Temporarily set `prerender: false` to isolate the issue.
131
+
132
+ ### `Server Rendering Failed`
133
+
134
+ An error occurred while server-rendering a component.
135
+
136
+ 1. Check JS console output in the Rails log: `tail -f log/development.log | grep 'React on Rails'`.
137
+ 2. Common causes: missing Node dependencies, syntax errors, or browser-only APIs in
138
+ server code.
139
+ 3. Set `config.trace = true` and check `config.server_bundle_js_file` points at the
140
+ correct file.
141
+
142
+ ### `Redux Store Not Found`
143
+
144
+ A Redux store wasn't registered before a component that depends on it rendered.
145
+
146
+ 1. Register the store: `ReactOnRails.registerStore({ <store> });`.
147
+ 2. Initialize it in the view before the component: `<%= redux_store('<store>', props: {}) %>`.
148
+ 3. Declare the dependency: `store_dependencies: ['<store>']`.
149
+
150
+ ## 6. Diagnose your setup
151
+
152
+ Before guessing, run the doctor — it checks configuration, bundles, and dependencies:
153
+
154
+ ```bash
155
+ bin/rails react_on_rails:doctor
156
+ ```
157
+
158
+ ## 7. Where the full reference lives
159
+
160
+ - Hosted docs: https://reactonrails.com/docs/
161
+ - Getting-started tutorial: https://reactonrails.com/docs/getting-started/tutorial/
162
+ - Machine-readable route map / expanded reference (for agents):
163
+ https://github.com/shakacode/react_on_rails/blob/main/llms.txt,
164
+ https://github.com/shakacode/react_on_rails/blob/main/llms-full.txt (OSS), and
165
+ https://github.com/shakacode/react_on_rails/blob/main/llms-full-pro.txt (Pro)
@@ -0,0 +1,8 @@
1
+ # CLAUDE.md
2
+
3
+ This app uses **React on Rails**. For React on Rails conventions — adding and
4
+ registering components, the `react_component` view helper, `.client`/`.server`
5
+ bundle rules, the `ReactOnRails` JS API, common errors and fixes, and the
6
+ `bin/rails react_on_rails:doctor` diagnostic — read **[AGENTS.md](./AGENTS.md)**.
7
+
8
+ Follow any project-specific instructions elsewhere in this file or repository.
@@ -16,7 +16,12 @@ class BundlerSwitcher
16
16
 
17
17
  RSPACK_DEPS = {
18
18
  dependencies: %w[@rspack/core@^2.0.0-0 rspack-manifest-plugin@^5.0.0],
19
- dev_dependencies: %w[@rspack/cli@^2.0.0-0 @rspack/plugin-react-refresh@^2.0.0]
19
+ dev_dependencies: %w[
20
+ @rspack/cli@^2.0.0-0
21
+ @rspack/dev-server@^2.0.0
22
+ @rspack/plugin-react-refresh@^2.0.0
23
+ react-refresh
24
+ ]
20
25
  }.freeze
21
26
 
22
27
  def initialize(target_bundler)
@@ -11,7 +11,87 @@ const commonOptions = {
11
11
  },
12
12
  };
13
13
 
14
+ <% if use_tailwind? -%>
15
+ const tailwindPostcssPlugin = require('@tailwindcss/postcss');
16
+
17
+ const tailwindPostcssLoader = {
18
+ loader: 'postcss-loader',
19
+ options: {
20
+ postcssOptions: {
21
+ plugins: [tailwindPostcssPlugin],
22
+ },
23
+ },
24
+ };
25
+
26
+ const loaderName = (loader) => {
27
+ if (typeof loader === 'string') return loader;
28
+ if (loader && typeof loader.loader === 'string') return loader.loader;
29
+ return '';
30
+ };
31
+
32
+ const postcssLoaderUsesTailwind = (loader) => {
33
+ const plugins = loader?.options?.postcssOptions?.plugins || [];
34
+ const tailwindPluginName = tailwindPostcssPlugin.postcssPlugin || tailwindPostcssPlugin.name;
35
+
36
+ return plugins.some((plugin) => {
37
+ if (plugin === tailwindPostcssPlugin) return true;
38
+ if (!tailwindPluginName) return false;
39
+
40
+ return plugin?.postcssPlugin === tailwindPluginName || plugin?.name === tailwindPluginName;
41
+ });
42
+ };
43
+
44
+ const mergeTailwindPostcssLoader = (loader) => {
45
+ if (typeof loader === 'string') return tailwindPostcssLoader;
46
+ if (postcssLoaderUsesTailwind(loader)) return loader;
47
+
48
+ const existingOptions = loader.options || {};
49
+ const existingPostcssOptions = existingOptions.postcssOptions || {};
50
+ const existingPlugins = existingPostcssOptions.plugins || [];
51
+
52
+ return {
53
+ ...loader,
54
+ options: {
55
+ ...existingOptions,
56
+ postcssOptions: {
57
+ ...existingPostcssOptions,
58
+ plugins: [...existingPlugins, tailwindPostcssPlugin],
59
+ },
60
+ },
61
+ };
62
+ };
63
+
64
+ const addTailwindPostcssLoader = (webpackConfig) => {
65
+ const rules = webpackConfig.module?.rules;
66
+ if (!Array.isArray(rules)) return webpackConfig;
67
+
68
+ rules.forEach((rule) => {
69
+ if (!Array.isArray(rule.use)) return;
70
+
71
+ const postcssLoaderIndex = rule.use.findIndex((loader) => loaderName(loader).includes('postcss-loader'));
72
+ if (postcssLoaderIndex !== -1) {
73
+ rule.use[postcssLoaderIndex] = mergeTailwindPostcssLoader(rule.use[postcssLoaderIndex]);
74
+ return;
75
+ }
76
+
77
+ const cssLoaderIndex = rule.use.findIndex((loader) => loaderName(loader).includes('css-loader'));
78
+ if (cssLoaderIndex === -1) return;
79
+
80
+ rule.use.splice(cssLoaderIndex + 1, 0, tailwindPostcssLoader);
81
+ });
82
+
83
+ return webpackConfig;
84
+ };
85
+
86
+ <% end -%>
14
87
  // Copy the object using merge b/c the baseClientWebpackConfig and commonOptions are mutable globals
15
- const commonWebpackConfig = () => merge({}, baseClientWebpackConfig, commonOptions);
88
+ const commonWebpackConfig = () => {
89
+ const webpackConfig = merge({}, baseClientWebpackConfig, commonOptions);
90
+ <% if use_tailwind? -%>
91
+ return addTailwindPostcssLoader(webpackConfig);
92
+ <% else -%>
93
+ return webpackConfig;
94
+ <% end -%>
95
+ };
16
96
 
17
97
  module.exports = commonWebpackConfig;
@@ -10,8 +10,8 @@ const developmentEnvOnly = (clientWebpackConfig, _serverWebpackConfig) => {
10
10
  // eslint-disable-next-line global-require
11
11
  if (config.assets_bundler === 'rspack') {
12
12
  // Rspack uses @rspack/plugin-react-refresh for React Fast Refresh
13
- const ReactRefreshPlugin = require('@rspack/plugin-react-refresh');
14
- clientWebpackConfig.plugins.push(new ReactRefreshPlugin());
13
+ const { ReactRefreshRspackPlugin } = require('@rspack/plugin-react-refresh');
14
+ clientWebpackConfig.plugins.push(new ReactRefreshRspackPlugin());
15
15
  } else {
16
16
  // Webpack uses @pmmmwh/react-refresh-webpack-plugin
17
17
  const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
@@ -0,0 +1,30 @@
1
+ import React, { useState } from 'react';
2
+ import '../../../stylesheets/application.css';
3
+
4
+ const HelloWorld = (props) => {
5
+ const [name, setName] = useState(props.name);
6
+
7
+ return (
8
+ <section className="mx-auto max-w-xl rounded-lg border border-slate-200 bg-white p-6 text-slate-900 shadow-sm">
9
+ <p className="text-sm font-semibold uppercase text-sky-600">React on Rails + Tailwind CSS</p>
10
+ <h3 className="mt-2 text-2xl font-bold">Hello, {name}!</h3>
11
+ <p className="mt-3 text-sm leading-6 text-slate-600">
12
+ This component is server-rendered by Rails and styled by Tailwind CSS v4.
13
+ </p>
14
+ <form className="mt-5">
15
+ <label className="block text-sm font-medium text-slate-700" htmlFor="name">
16
+ Say hello to:
17
+ <input
18
+ id="name"
19
+ className="mt-2 w-full rounded-md border border-slate-300 px-3 py-2 shadow-sm focus:border-sky-500 focus:outline-hidden focus:ring-2 focus:ring-sky-200"
20
+ type="text"
21
+ value={name}
22
+ onChange={(e) => setName(e.target.value)}
23
+ />
24
+ </label>
25
+ </form>
26
+ </section>
27
+ );
28
+ };
29
+
30
+ export default HelloWorld;
@@ -0,0 +1,34 @@
1
+ import React, { useState } from 'react';
2
+ import '../../../stylesheets/application.css';
3
+
4
+ interface HelloWorldProps {
5
+ name: string;
6
+ }
7
+
8
+ const HelloWorld: React.FC<HelloWorldProps> = (props) => {
9
+ const [name, setName] = useState(props.name);
10
+
11
+ return (
12
+ <section className="mx-auto max-w-xl rounded-lg border border-slate-200 bg-white p-6 text-slate-900 shadow-sm">
13
+ <p className="text-sm font-semibold uppercase text-sky-600">React on Rails + Tailwind CSS</p>
14
+ <h3 className="mt-2 text-2xl font-bold">Hello, {name}!</h3>
15
+ <p className="mt-3 text-sm leading-6 text-slate-600">
16
+ This component is server-rendered by Rails and styled by Tailwind CSS v4.
17
+ </p>
18
+ <form className="mt-5">
19
+ <label className="block text-sm font-medium text-slate-700" htmlFor="name">
20
+ Say hello to:
21
+ <input
22
+ id="name"
23
+ className="mt-2 w-full rounded-md border border-slate-300 px-3 py-2 shadow-sm focus:border-sky-500 focus:outline-hidden focus:ring-2 focus:ring-sky-200"
24
+ type="text"
25
+ value={name}
26
+ onChange={(e) => setName(e.target.value)}
27
+ />
28
+ </label>
29
+ </form>
30
+ </section>
31
+ );
32
+ };
33
+
34
+ export default HelloWorld;
@@ -0,0 +1,50 @@
1
+ import js from '@eslint/js';
2
+ import prettier from 'eslint-config-prettier';
3
+ import importPlugin from 'eslint-plugin-import';
4
+ import react from 'eslint-plugin-react';
5
+ import reactHooks from 'eslint-plugin-react-hooks';
6
+ import globals from 'globals';
7
+
8
+ const reactHooksRecommendedLatest = reactHooks.configs['recommended-latest'];
9
+ const reactHooksRecommendedLatestConfigs = Array.isArray(reactHooksRecommendedLatest)
10
+ ? reactHooksRecommendedLatest
11
+ : [reactHooksRecommendedLatest];
12
+
13
+ export default [
14
+ js.configs.recommended,
15
+ react.configs.flat.recommended,
16
+ importPlugin.flatConfigs.recommended,
17
+ ...reactHooksRecommendedLatestConfigs,
18
+ {
19
+ files: ['**/*.{js,jsx,mjs,cjs}'],
20
+ languageOptions: {
21
+ ecmaVersion: 'latest',
22
+ sourceType: 'module',
23
+ globals: {
24
+ ...globals.browser,
25
+ ...globals.node,
26
+ ...globals.mocha,
27
+ __DEBUG_SERVER_ERRORS__: true,
28
+ __SERVER_ERRORS__: true,
29
+ },
30
+ },
31
+ settings: {
32
+ react: {
33
+ version: 'detect',
34
+ },
35
+ },
36
+ rules: {
37
+ 'no-console': 'off',
38
+
39
+ // https://github.com/import-js/eslint-plugin-import/issues/340
40
+ 'import/no-extraneous-dependencies': 'off',
41
+
42
+ // The internal generated examples use local workspace packages during tests.
43
+ 'import/no-unresolved': 'off',
44
+
45
+ 'react/prop-types': 'off',
46
+ semi: 'off',
47
+ },
48
+ },
49
+ prettier,
50
+ ];
@@ -0,0 +1,25 @@
1
+ import React from 'react';
2
+
3
+ const HelloWorld = ({ name, updateName }) => (
4
+ <section className="mx-auto max-w-xl rounded-lg border border-slate-200 bg-white p-6 text-slate-900 shadow-sm">
5
+ <p className="text-sm font-semibold uppercase text-sky-600">React on Rails + Redux + Tailwind CSS</p>
6
+ <h3 className="mt-2 text-2xl font-bold">Hello, {name}!</h3>
7
+ <p className="mt-3 text-sm leading-6 text-slate-600">
8
+ This Redux-backed component is server-rendered by Rails and styled by Tailwind CSS v4.
9
+ </p>
10
+ <div className="mt-5">
11
+ <label className="block text-sm font-medium text-slate-700" htmlFor="name">
12
+ Say hello to:
13
+ <input
14
+ id="name"
15
+ className="mt-2 w-full rounded-md border border-slate-300 px-3 py-2 shadow-sm focus:border-sky-500 focus:outline-hidden focus:ring-2 focus:ring-sky-200"
16
+ type="text"
17
+ value={name}
18
+ onChange={(e) => updateName(e.target.value)}
19
+ />
20
+ </label>
21
+ </div>
22
+ </section>
23
+ );
24
+
25
+ export default HelloWorld;
@@ -0,0 +1,29 @@
1
+ import React from 'react';
2
+ import type { PropsFromRedux } from '../containers/HelloWorldContainer';
3
+
4
+ // Component props are inferred from Redux container
5
+ type HelloWorldProps = PropsFromRedux;
6
+
7
+ const HelloWorld: React.FC<HelloWorldProps> = ({ name, updateName }) => (
8
+ <section className="mx-auto max-w-xl rounded-lg border border-slate-200 bg-white p-6 text-slate-900 shadow-sm">
9
+ <p className="text-sm font-semibold uppercase text-sky-600">React on Rails + Redux + Tailwind CSS</p>
10
+ <h3 className="mt-2 text-2xl font-bold">Hello, {name}!</h3>
11
+ <p className="mt-3 text-sm leading-6 text-slate-600">
12
+ This Redux-backed component is server-rendered by Rails and styled by Tailwind CSS v4.
13
+ </p>
14
+ <div className="mt-5">
15
+ <label className="block text-sm font-medium text-slate-700" htmlFor="name">
16
+ Say hello to:
17
+ <input
18
+ id="name"
19
+ className="mt-2 w-full rounded-md border border-slate-300 px-3 py-2 shadow-sm focus:border-sky-500 focus:outline-hidden focus:ring-2 focus:ring-sky-200"
20
+ type="text"
21
+ value={name}
22
+ onChange={(e) => updateName(e.target.value)}
23
+ />
24
+ </label>
25
+ </div>
26
+ </section>
27
+ );
28
+
29
+ export default HelloWorld;
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactOnRails
4
+ module Controller
5
+ # Opt-in controller helpers for responding to `useRailsForm` submissions
6
+ # (packages/react-on-rails/src/useRailsForm.ts).
7
+ #
8
+ # The hook posts JSON and expects model validation failures as HTTP 422 with
9
+ # the body shape `{ "errors": { "field_name": ["message", ...] } }`. This
10
+ # concern renders exactly that shape from any object exposing ActiveModel
11
+ # errors, so a controller action can stay a plain Rails action:
12
+ #
13
+ # class ContactMessagesController < ApplicationController
14
+ # include ReactOnRails::Controller::FormResponders
15
+ #
16
+ # def create
17
+ # contact_message = ContactMessage.new(contact_message_params)
18
+ # if contact_message.save
19
+ # render json: { message: "Thanks!" }, status: :created
20
+ # else
21
+ # render_model_errors(contact_message)
22
+ # end
23
+ # end
24
+ # end
25
+ #
26
+ # Including this concern is optional — `useRailsForm` works against any
27
+ # endpoint that returns the documented shape.
28
+ module FormResponders
29
+ # Renders the validation errors of an ActiveModel/ActiveRecord object as
30
+ # JSON in the shape `useRailsForm` maps onto per-field errors.
31
+ #
32
+ # record: any object responding to `errors` with `ActiveModel::Errors`
33
+ # (or anything whose `errors` responds to `messages`).
34
+ # status: Numeric HTTP status for the response. Defaults to 422
35
+ # (Unprocessable Content), which is what the hook's error mapping
36
+ # keys on. Numeric statuses sidestep Rack/Rails status-symbol
37
+ # renames such as :unprocessable_entity to :unprocessable_content.
38
+ # SECURITY: ActiveModel error messages are sent to the browser verbatim.
39
+ # Review custom validations for internal IDs, admin-only details, or
40
+ # security-sensitive wording before using this on a model.
41
+ def render_model_errors(record, status: 422)
42
+ unless status.is_a?(Integer)
43
+ raise ArgumentError, "render_model_errors status must be an Integer HTTP status code"
44
+ end
45
+
46
+ unless record.respond_to?(:errors)
47
+ raise ArgumentError, "render_model_errors record must respond to errors.messages"
48
+ end
49
+
50
+ errors = record.errors
51
+ unless errors.respond_to?(:messages)
52
+ raise ArgumentError, "render_model_errors record must respond to errors.messages"
53
+ end
54
+
55
+ render json: { errors: errors.messages }, status:
56
+ end
57
+ end
58
+ end
59
+ end
@@ -515,7 +515,7 @@ module ReactOnRails
515
515
  when "webpack"
516
516
  "config/webpack/development.js: ReactRefreshWebpackPlugin (enabled when WEBPACK_SERVE=true)"
517
517
  when "rspack"
518
- "config/rspack/development.js: @rspack/plugin-react-refresh / ReactRefreshPlugin " \
518
+ "config/rspack/development.js: @rspack/plugin-react-refresh / ReactRefreshRspackPlugin " \
519
519
  "(enabled for the dev server)"
520
520
  else
521
521
  "Check your bundler's React Refresh plugin documentation"