react-on-rails 16.2.0-beta.9 → 16.2.0-rc.0

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.
package/LICENSE.md ADDED
@@ -0,0 +1,83 @@
1
+ # Licensing
2
+
3
+ This repository contains code under two different licenses:
4
+
5
+ - **Core**: MIT License (applies to most files)
6
+ - **Pro**: React on Rails Pro License (applies to specific directories)
7
+
8
+ ## License Scope
9
+
10
+ ### MIT Licensed Code
11
+
12
+ The following directories and all their contents are licensed under the **MIT License** (see full text below):
13
+
14
+ - `react_on_rails/` (entire directory, including lib/, spec/, sig/)
15
+ - `packages/react-on-rails/` (entire package)
16
+ - All other directories in this repository not explicitly listed as Pro-licensed
17
+
18
+ ### Pro Licensed Code
19
+
20
+ The following directories and all their contents are licensed under the **React on Rails Pro License**:
21
+
22
+ - `packages/react-on-rails-pro/` (entire package)
23
+ - `packages/react-on-rails-pro-node-renderer/` (entire package)
24
+ - `react_on_rails_pro/` (entire directory)
25
+
26
+ See [REACT-ON-RAILS-PRO-LICENSE.md](./REACT-ON-RAILS-PRO-LICENSE.md) for complete Pro license terms.
27
+
28
+ **Important:** Pro-licensed code is included in this package but requires a valid React on Rails Pro subscription to use. Using Pro features without a valid license violates the React on Rails Pro License.
29
+
30
+ ---
31
+
32
+ ## MIT License
33
+
34
+ This license applies to all MIT-licensed code as defined above.
35
+
36
+ Copyright (c) 2017, 2018 Justin Gordon and ShakaCode
37
+ Copyright (c) 2015–2025 ShakaCode, LLC
38
+
39
+ Permission is hereby granted, free of charge, to any person obtaining a copy
40
+ of this software and associated documentation files (the "Software"), to deal
41
+ in the Software without restriction, including without limitation the rights
42
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
43
+ copies of the Software, and to permit persons to whom the Software is
44
+ furnished to do so, subject to the following conditions:
45
+
46
+ The above copyright notice and this permission notice shall be included in
47
+ all copies or substantial portions of the Software.
48
+
49
+ ---
50
+
51
+ ## Disclaimer
52
+
53
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
54
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
55
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
56
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
57
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
58
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
59
+ SOFTWARE.
60
+
61
+ ---
62
+
63
+ ## React on Rails Pro License
64
+
65
+ For Pro-licensed code (as defined in the "License Scope" section above), see:
66
+ [REACT-ON-RAILS-PRO-LICENSE.md](./REACT-ON-RAILS-PRO-LICENSE.md)
67
+
68
+ **Key Points:**
69
+
70
+ - Pro features require a valid React on Rails Pro subscription for production use
71
+ - Free use is permitted for educational, personal, and non-production purposes
72
+ - Modifying MIT-licensed interface files is permitted under MIT terms
73
+ - However, using those modifications to access Pro features without a valid license violates the Pro License
74
+
75
+ ### License Validation Mechanisms
76
+
77
+ **License validation mechanisms** include but are not limited to:
78
+
79
+ - Runtime checks for valid Pro subscriptions
80
+ - Authentication systems in `react_on_rails/lib/react_on_rails/utils.rb` and Pro TypeScript modules
81
+ - The `react_on_rails_pro?` method and `rorPro` field generation
82
+
83
+ While MIT-licensed code may be modified under MIT terms, using such modifications to access Pro features without a valid license violates the React on Rails Pro License.
@@ -51,11 +51,48 @@ function renderElement(el, railsContext) {
51
51
  try {
52
52
  const domNode = document.getElementById(domNodeId);
53
53
  if (domNode) {
54
+ // Check if this component was already rendered by a previous call
55
+ // This prevents hydration errors when reactOnRailsPageLoaded() is called multiple times
56
+ // (e.g., for asynchronously loaded content)
57
+ const existing = renderedRoots.get(domNodeId);
58
+ if (existing) {
59
+ // Only skip if it's the exact same DOM node and it's still connected to the document.
60
+ // If the node was replaced (e.g., via innerHTML or Turbo), we need to unmount the old
61
+ // root and re-render to the new node to prevent memory leaks and ensure rendering works.
62
+ const sameNode = existing.domNode === domNode && existing.domNode.isConnected;
63
+ if (sameNode) {
64
+ if (trace) {
65
+ console.log(`Skipping already rendered component: ${name} (dom id: ${domNodeId})`);
66
+ }
67
+ return;
68
+ }
69
+ // DOM node was replaced (e.g., via async HTML injection) - clean up the old root
70
+ try {
71
+ if (supportsRootApi &&
72
+ existing.root &&
73
+ typeof existing.root === 'object' &&
74
+ 'unmount' in existing.root) {
75
+ existing.root.unmount();
76
+ }
77
+ else {
78
+ unmountComponentAtNode(existing.domNode);
79
+ }
80
+ }
81
+ catch (unmountError) {
82
+ // Ignore unmount errors for replaced nodes
83
+ if (trace) {
84
+ console.log(`Error unmounting replaced component: ${name}`, unmountError);
85
+ }
86
+ }
87
+ renderedRoots.delete(domNodeId);
88
+ }
54
89
  const componentObj = ComponentRegistry.get(name);
55
90
  if (delegateToRenderer(componentObj, props, railsContext, domNodeId, trace)) {
56
91
  return;
57
92
  }
58
- // Hydrate if available and was server rendered
93
+ // Hydrate if the DOM node has content (server-rendered HTML)
94
+ // Since we skip already-rendered components above, this check now correctly
95
+ // identifies only server-rendered content, not previously client-rendered content
59
96
  const shouldHydrate = !!domNode.innerHTML;
60
97
  const reactElementOrRouterResult = createReactOutput({
61
98
  componentObj,
@@ -140,7 +177,6 @@ function unmountAllComponents() {
140
177
  }
141
178
  else {
142
179
  // React 16-17 legacy API
143
- // eslint-disable-next-line @typescript-eslint/no-deprecated
144
180
  unmountComponentAtNode(domNode);
145
181
  }
146
182
  }
@@ -1,2 +1,2 @@
1
- export declare function wrapInScriptTags(scriptId: string, scriptBody: string): string;
1
+ export declare function wrapInScriptTags(scriptId: string, scriptBody: string, nonce?: string): string;
2
2
  //# sourceMappingURL=RenderUtils.d.ts.map
@@ -1,10 +1,14 @@
1
1
  // eslint-disable-next-line import/prefer-default-export -- only one export for now, but others may be added later
2
- export function wrapInScriptTags(scriptId, scriptBody) {
2
+ export function wrapInScriptTags(scriptId, scriptBody, nonce) {
3
3
  if (!scriptBody) {
4
4
  return '';
5
5
  }
6
+ // Sanitize nonce to prevent attribute injection attacks
7
+ // CSP nonces should be base64 strings, so only allow alphanumeric, +, /, =, -, and _
8
+ const sanitizedNonce = nonce?.replace(/[^a-zA-Z0-9+/=_-]/g, '');
9
+ const nonceAttr = sanitizedNonce ? ` nonce="${sanitizedNonce}"` : '';
6
10
  return `
7
- <script id="${scriptId}">
11
+ <script id="${scriptId}"${nonceAttr}>
8
12
  ${scriptBody}
9
13
  </script>`;
10
14
  }
@@ -1,5 +1,5 @@
1
1
  import * as Authenticity from "../Authenticity.js";
2
- import buildConsoleReplay from "../buildConsoleReplay.js";
2
+ import buildConsoleReplay, { consoleReplay } from "../buildConsoleReplay.js";
3
3
  import reactHydrateOrRender from "../reactHydrateOrRender.js";
4
4
  import createReactOutput from "../createReactOutput.js";
5
5
  const DEFAULT_OPTIONS = {
@@ -107,6 +107,9 @@ Fix: Use only react-on-rails OR react-on-rails-pro, not both.`);
107
107
  buildConsoleReplay() {
108
108
  return buildConsoleReplay();
109
109
  },
110
+ getConsoleReplayScript() {
111
+ return consoleReplay();
112
+ },
110
113
  resetOptions() {
111
114
  this.options = { ...DEFAULT_OPTIONS };
112
115
  },
@@ -8,8 +8,10 @@ export type ReactOnRailsFullSpecificFunctions = Pick<ReactOnRailsInternal, 'hand
8
8
  /**
9
9
  * Full object type that includes all base methods plus real SSR implementations.
10
10
  * Derived from ReactOnRailsInternal by picking base methods and SSR methods.
11
+ * Note: BaseClientObjectType already includes serverRenderReactComponent and handleError,
12
+ * so ReactOnRailsFullSpecificFunctions is a subset.
11
13
  * @public
12
14
  */
13
- export type BaseFullObjectType = Pick<ReactOnRailsInternal, keyof BaseClientObjectType | keyof ReactOnRailsFullSpecificFunctions>;
15
+ export type BaseFullObjectType = Pick<ReactOnRailsInternal, keyof BaseClientObjectType>;
14
16
  export declare function createBaseFullObject(registries: Parameters<typeof createBaseClientObject>[0], currentObject?: BaseClientObjectType | null): BaseFullObjectType;
15
17
  //# sourceMappingURL=full.d.ts.map
@@ -6,7 +6,11 @@ declare global {
6
6
  }[];
7
7
  }
8
8
  }
9
- /** @internal Exported only for tests */
10
- export declare function consoleReplay(customConsoleHistory?: (typeof console)['history'] | undefined, numberOfMessagesToSkip?: number): string;
11
- export default function buildConsoleReplay(customConsoleHistory?: (typeof console)['history'] | undefined, numberOfMessagesToSkip?: number): string;
9
+ /**
10
+ * Returns the console replay JavaScript code without wrapping it in script tags.
11
+ * This is useful when you want to wrap the code in script tags yourself (e.g., with a CSP nonce).
12
+ * @internal Exported for tests and for Ruby helper to wrap with nonce
13
+ */
14
+ export declare function consoleReplay(customConsoleHistory?: (typeof console)['history'], numberOfMessagesToSkip?: number): string;
15
+ export default function buildConsoleReplay(customConsoleHistory?: (typeof console)['history'], numberOfMessagesToSkip?: number, nonce?: string): string;
12
16
  //# sourceMappingURL=buildConsoleReplay.d.ts.map
@@ -1,6 +1,10 @@
1
1
  import { wrapInScriptTags } from "./RenderUtils.js";
2
2
  import scriptSanitizedVal from "./scriptSanitizedVal.js";
3
- /** @internal Exported only for tests */
3
+ /**
4
+ * Returns the console replay JavaScript code without wrapping it in script tags.
5
+ * This is useful when you want to wrap the code in script tags yourself (e.g., with a CSP nonce).
6
+ * @internal Exported for tests and for Ruby helper to wrap with nonce
7
+ */
4
8
  export function consoleReplay(customConsoleHistory = undefined, numberOfMessagesToSkip = 0) {
5
9
  // console.history is a global polyfill used in server rendering.
6
10
  const consoleHistory = customConsoleHistory ?? console.history;
@@ -34,11 +38,11 @@ export function consoleReplay(customConsoleHistory = undefined, numberOfMessages
34
38
  });
35
39
  return lines.join('\n');
36
40
  }
37
- export default function buildConsoleReplay(customConsoleHistory = undefined, numberOfMessagesToSkip = 0) {
41
+ export default function buildConsoleReplay(customConsoleHistory = undefined, numberOfMessagesToSkip = 0, nonce) {
38
42
  const consoleReplayJS = consoleReplay(customConsoleHistory, numberOfMessagesToSkip);
39
43
  if (consoleReplayJS.length === 0) {
40
44
  return '';
41
45
  }
42
- return wrapInScriptTags('consoleReplayLog', consoleReplayJS);
46
+ return wrapInScriptTags('consoleReplayLog', consoleReplayJS, nonce);
43
47
  }
44
48
  //# sourceMappingURL=buildConsoleReplay.js.map
package/lib/reactApis.cjs CHANGED
@@ -25,25 +25,38 @@ if (exports.supportsRootApi) {
25
25
  reactDomClient = ReactDOM;
26
26
  }
27
27
  }
28
- /* eslint-disable @typescript-eslint/no-deprecated,@typescript-eslint/no-non-null-assertion,react/no-deprecated --
29
- * while we need to support React 16
30
- */
28
+ // Cast ReactDOM to include legacy APIs for React 16/17 compatibility
29
+ // These methods exist at runtime but are removed from @types/react-dom@19
30
+ const legacyReactDOM = ReactDOM;
31
+ // Validate legacy APIs exist at runtime when needed (React < 18)
32
+ if (!exports.supportsRootApi) {
33
+ if (typeof legacyReactDOM.hydrate !== 'function') {
34
+ throw new Error('React legacy hydrate API not available. Expected React 16/17.');
35
+ }
36
+ if (typeof legacyReactDOM.render !== 'function') {
37
+ throw new Error('React legacy render API not available. Expected React 16/17.');
38
+ }
39
+ if (typeof legacyReactDOM.unmountComponentAtNode !== 'function') {
40
+ throw new Error('React legacy unmountComponentAtNode API not available. Expected React 16/17.');
41
+ }
42
+ }
43
+ /* eslint-disable @typescript-eslint/no-non-null-assertion -- reactDomClient is always defined when supportsRootApi is true */
31
44
  exports.reactHydrate = exports.supportsRootApi
32
45
  ? reactDomClient.hydrateRoot
33
- : (domNode, reactElement) => ReactDOM.hydrate(reactElement, domNode);
46
+ : (domNode, reactElement) => legacyReactDOM.hydrate(reactElement, domNode);
34
47
  function reactRender(domNode, reactElement) {
35
48
  if (exports.supportsRootApi) {
36
49
  const root = reactDomClient.createRoot(domNode);
37
50
  root.render(reactElement);
38
51
  return root;
39
52
  }
40
- // eslint-disable-next-line react/no-render-return-value
41
- return ReactDOM.render(reactElement, domNode);
53
+ return legacyReactDOM.render(reactElement, domNode);
42
54
  }
43
55
  exports.unmountComponentAtNode = exports.supportsRootApi
44
56
  ? // not used if we use root API
45
- () => false
46
- : ReactDOM.unmountComponentAtNode;
57
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
58
+ (_container) => false
59
+ : (container) => legacyReactDOM.unmountComponentAtNode(container);
47
60
  const ensureReactUseAvailable = () => {
48
61
  if (!('use' in React) || typeof React.use !== 'function') {
49
62
  throw new Error('React.use is not defined. Please ensure you are using React 19 to use server components.');
@@ -2,7 +2,7 @@ import { isValidElement } from 'react';
2
2
  // ComponentRegistry is accessed via globalThis.ReactOnRails.getComponent for cross-bundle compatibility
3
3
  import createReactOutput from "./createReactOutput.js";
4
4
  import { isPromise, isServerRenderHash } from "./isServerRenderResult.js";
5
- import buildConsoleReplay from "./buildConsoleReplay.js";
5
+ import { consoleReplay } from "./buildConsoleReplay.js";
6
6
  import handleError from "./handleError.js";
7
7
  import { renderToString } from "./ReactDOMServer.cjs";
8
8
  import { createResultObject, convertToError, validateComponent } from "./serverRenderUtils.js";
@@ -49,6 +49,7 @@ function processPromise(result, renderingReturnsPromises) {
49
49
  if (isValidElement(promiseResult)) {
50
50
  return processReactElement(promiseResult);
51
51
  }
52
+ // promiseResult is string | ServerRenderHashRenderedHtml (both are FinalHtmlResult)
52
53
  return promiseResult;
53
54
  });
54
55
  }
@@ -80,12 +81,12 @@ async function createPromiseResult(renderState, componentName, throwJsErrors) {
80
81
  const consoleHistory = console.history;
81
82
  try {
82
83
  const html = await renderState.result;
83
- const consoleReplayScript = buildConsoleReplay(consoleHistory);
84
+ const consoleReplayScript = consoleReplay(consoleHistory);
84
85
  return createResultObject(html, consoleReplayScript, renderState);
85
86
  }
86
87
  catch (e) {
87
88
  const errorRenderState = handleRenderingError(e, { componentName, throwJsErrors });
88
- const consoleReplayScript = buildConsoleReplay(consoleHistory);
89
+ const consoleReplayScript = consoleReplay(consoleHistory);
89
90
  return createResultObject(errorRenderState.result, consoleReplayScript, errorRenderState);
90
91
  }
91
92
  }
@@ -94,7 +95,7 @@ function createFinalResult(renderState, componentName, throwJsErrors) {
94
95
  if (isPromise(result)) {
95
96
  return createPromiseResult({ ...renderState, result }, componentName, throwJsErrors);
96
97
  }
97
- const consoleReplayScript = buildConsoleReplay();
98
+ const consoleReplayScript = consoleReplay();
98
99
  return JSON.stringify(createResultObject(result, consoleReplayScript, renderState));
99
100
  }
100
101
  function serverRenderReactComponentInternal(options) {
@@ -68,8 +68,8 @@ interface ServerRenderResult {
68
68
  routeError?: Error;
69
69
  error?: Error;
70
70
  }
71
- type CreateReactOutputSyncResult = ServerRenderResult | ReactElement<unknown>;
72
- type CreateReactOutputAsyncResult = Promise<string | ServerRenderHashRenderedHtml | ReactElement<unknown>>;
71
+ type CreateReactOutputSyncResult = ServerRenderResult | ReactElement;
72
+ type CreateReactOutputAsyncResult = Promise<string | ServerRenderHashRenderedHtml | ReactElement>;
73
73
  type CreateReactOutputResult = CreateReactOutputSyncResult | CreateReactOutputAsyncResult;
74
74
  type RenderFunctionSyncResult = ReactComponent | ServerRenderResult;
75
75
  type RenderFunctionAsyncResult = Promise<string | ServerRenderHashRenderedHtml | ReactComponent>;
@@ -249,7 +249,7 @@ export interface ReactOnRailsInternal extends ReactOnRails {
249
249
  * @param key
250
250
  * @returns option value
251
251
  */
252
- option<K extends keyof ReactOnRailsOptions>(key: K): ReactOnRailsOptions[K] | undefined;
252
+ option<K extends keyof ReactOnRailsOptions>(key: K): ReactOnRailsOptions[K];
253
253
  /**
254
254
  * Allows retrieval of the store generator by name. This is used internally by ReactOnRails after
255
255
  * a Rails form loads to prepare stores.
@@ -324,8 +324,14 @@ export interface ReactOnRailsInternal extends ReactOnRails {
324
324
  handleError(options: ErrorOptions): string | undefined;
325
325
  /**
326
326
  * Used by Rails server rendering to replay console messages.
327
+ * Returns the console replay script wrapped in script tags.
327
328
  */
328
329
  buildConsoleReplay(): string;
330
+ /**
331
+ * Returns the console replay JavaScript code without wrapping it in script tags.
332
+ * Useful when you need to add CSP nonce or other attributes to the script tag.
333
+ */
334
+ getConsoleReplayScript(): string;
329
335
  /**
330
336
  * Get a Map containing all registered components. Useful for debugging.
331
337
  */
package/package.json CHANGED
@@ -1,21 +1,9 @@
1
1
  {
2
2
  "name": "react-on-rails",
3
- "version": "16.2.0-beta.9",
3
+ "version": "16.2.0-rc.0",
4
4
  "description": "react-on-rails JavaScript for react_on_rails Ruby gem",
5
5
  "main": "lib/ReactOnRails.full.js",
6
6
  "type": "module",
7
- "scripts": {
8
- "build": "yarn run clean && yarn run tsc",
9
- "build-watch": "yarn run clean && yarn run tsc --watch",
10
- "clean": "rm -rf ./lib",
11
- "test": "jest tests",
12
- "type-check": "yarn run tsc --noEmit --noErrorTruncation",
13
- "prepack": "nps build.prepack",
14
- "prepare": "nps build.prepack",
15
- "prepublishOnly": "yarn run build",
16
- "yalc:publish": "yalc publish",
17
- "yalc": "yalc"
18
- },
19
7
  "repository": {
20
8
  "type": "git",
21
9
  "url": "git+https://github.com/shakacode/react_on_rails.git"
@@ -28,7 +16,7 @@
28
16
  "on",
29
17
  "Rails"
30
18
  ],
31
- "author": "justin.gordon@gmail.com",
19
+ "author": "justin@shakacode.com",
32
20
  "license": "SEE LICENSE IN LICENSE.md",
33
21
  "exports": {
34
22
  ".": {
@@ -60,13 +48,7 @@
60
48
  },
61
49
  "peerDependencies": {
62
50
  "react": ">= 16",
63
- "react-dom": ">= 16",
64
- "react-on-rails-rsc": "19.0.2"
65
- },
66
- "peerDependenciesMeta": {
67
- "react-on-rails-rsc": {
68
- "optional": true
69
- }
51
+ "react-dom": ">= 16"
70
52
  },
71
53
  "files": [
72
54
  "lib/**/*.js",
@@ -77,5 +59,14 @@
77
59
  "bugs": {
78
60
  "url": "https://github.com/shakacode/react_on_rails/issues"
79
61
  },
80
- "homepage": "https://github.com/shakacode/react_on_rails#readme"
81
- }
62
+ "homepage": "https://github.com/shakacode/react_on_rails#readme",
63
+ "scripts": {
64
+ "build": "pnpm run clean && tsc",
65
+ "build-watch": "pnpm run clean && tsc --watch",
66
+ "clean": "rm -rf ./lib",
67
+ "test": "jest tests",
68
+ "type-check": "tsc --noEmit --noErrorTruncation",
69
+ "yalc:publish": "yalc publish",
70
+ "yalc": "yalc"
71
+ }
72
+ }