diff --git a/CHANGELOG.md b/CHANGELOG.md index a7f2c20f15..522def7b44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ - **[Pro]** **RSC CSS no longer flashes unstyled (FOUC) behind `'use client'` boundaries**: CSS imported by a `'use client'` boundary in a true React Server Component tree is now preloaded instead of loading only as a side effect of the JS chunk evaluating. The published `react-on-rails-rsc@19.0.5-rc.6` package now records each client reference's `.css` siblings in the RSC client manifest, and the Pro RSC renderer emits `` for them inside the RSC payload so React 19 hoists the stylesheets into `` and blocks paint until they load — on both server render and client-side navigation. Fixes [Issue 3211](https://github.com/shakacode/react_on_rails/issues/3211). [PR 3587](https://github.com/shakacode/react_on_rails/pull/3587) by [justin808](https://github.com/justin808). - **[Pro]** **`react-on-rails-rsc` prerelease (RC) versions no longer mark the dependency tree invalid**: The `react-on-rails-pro` peer dependency on the optional `react-on-rails-rsc` is now `*`, so installing any coordinated `react-on-rails-rsc` build — including prereleases such as `react-on-rails-rsc@19.0.5-rc.6` — no longer makes `npm ls react-on-rails-rsc` fail with `ELSPROBLEMS`. npm's strict semver only lets a prerelease satisfy a comparator that shares its exact `major.minor.patch` tuple, so no bounded range — including the `>= 19.0.2 < 20.0.0` range introduced in [PR 3580](https://github.com/shakacode/react_on_rails/pull/3580) — can admit prereleases across the React 19 line (e.g. `19.0.5-rc.6`, `19.2.x-rc.*`) without enumerating every patch tuple. `react-on-rails-rsc` stays an optional peer that Pro resolves only on the React Server Components path; the supported pairing is React on Rails RSC on the React 19 line (currently `>= 19.0.2`), and a mismatched build fails loudly at bundle time through Pro's `react-on-rails-rsc/*` imports rather than relying on the peer-range warning. Fixes [Issue 3609](https://github.com/shakacode/react_on_rails/issues/3609). [PR 3616](https://github.com/shakacode/react_on_rails/pull/3616) by [justin808](https://github.com/justin808). +- **TypeScript source server bundles work with auto-generated packs**: React on Rails now resolves the configured server bundle source entrypoint by extension, so apps can keep `config.server_bundle_js_file = "server-bundle.js"` as the compiled/runtime bundle name while using a TypeScript source entrypoint such as `packs/server-bundle.ts`. Public registration types also now cover plain object modules used by `server_render_js`, matching existing runtime behavior. Resolves [Issue 1583](https://github.com/shakacode/react_on_rails/issues/1583). [PR 3606](https://github.com/shakacode/react_on_rails/pull/3606) by [ihabadham](https://github.com/ihabadham). - **[Pro]** **Client teardown failures are no longer hidden at `console.info`**: when `ComponentRenderer.unmount()` catches an error from `unmountComponentAtNode` (the React 16/17 legacy unmount path), it now logs at `console.error` instead of `console.info`. A caught error there means the component tree did not unmount cleanly — a teardown failure — and most log collectors and default browser-console filters drop `info`, so the failure was effectively silent. Addresses item 2 of [Issue 3592](https://github.com/shakacode/react_on_rails/issues/3592). [PR 3610](https://github.com/shakacode/react_on_rails/pull/3610) by [justin808](https://github.com/justin808). - **Renderer functions no longer leak their mount on navigation/unmount**: Renderer functions (the 3-argument `(props, railsContext, domNodeId) => …` registration form) own their own React root, but React on Rails never tracked any cleanup state for them, so every renderer-function mount leaked on Turbo/Turbolinks navigation. Renderer functions may now optionally return a teardown wrapper (`{ teardown: () => void | Promise }`, sync or async); returning nothing keeps the previous behavior, so existing renderers are unaffected. Both the core and Pro client renderers invoke the teardown on page unload and on same-id node replacement, and cleanup failures on same-id replacement are now logged to `console.error` instead of only being visible when tracing is enabled. The renderers differ only in the async race: if a navigation unmounts the mount while an async renderer is still resolving its teardown, Pro still runs the teardown once it resolves, whereas the core renderer is best-effort and may drop a still-pending async teardown while the renderer is awaiting dynamic imports, fetches, or other I/O on a fast navigation; active async renderer failures are logged and then untracked so a later load call can retry. The framework-shipped Pro `wrapServerComponentRenderer` now returns such a teardown wrapper, closing the leak automatically for every `registerServerComponent` user. TypeScript note: the exported `RendererFunction` type covers 3-argument renderers that return nothing or an optional teardown wrapper; `RenderFunction` keeps its existing component/server-result return contract, including legacy 3-argument renderers that returned a component only to satisfy the old type. Fixes [Issue 3209](https://github.com/shakacode/react_on_rails/issues/3209). [PR 3576](https://github.com/shakacode/react_on_rails/pull/3576) by [justin808](https://github.com/justin808). diff --git a/docs/oss/api-reference/view-helpers-api.md b/docs/oss/api-reference/view-helpers-api.md index fda1a3718b..0778268f1f 100644 --- a/docs/oss/api-reference/view-helpers-api.md +++ b/docs/oss/api-reference/view-helpers-api.md @@ -113,7 +113,7 @@ Why would you want to take over mounting yourself? One use case is code splittin [React Router](https://reactrouter.com/) is supported via manual integration, including server-side rendering. See: 1. [React on Rails docs for React Router](../building-features/react-router.md) -2. Examples in [spec/dummy/app/views/react_router](https://github.com/shakacode/react_on_rails/tree/main/react_on_rails/spec/dummy/app/views/react_router) and follow to the JavaScript code in the [spec/dummy/client/app/startup/RouterApp.server.jsx](https://github.com/shakacode/react_on_rails/blob/main/react_on_rails/spec/dummy/client/app/startup/RouterApp.server.jsx). +2. Examples in [spec/dummy/app/views/react_router](https://github.com/shakacode/react_on_rails/tree/main/react_on_rails/spec/dummy/app/views/react_router) and follow to the JavaScript code in the [spec/dummy/client/app/startup/RouterApp.server.tsx](../../../react_on_rails/spec/dummy/client/app/startup/RouterApp.server.tsx). 3. [React on Rails Pro loadable-components guide](../building-features/code-splitting.md) for modern code splitting with server-side rendering. ### TanStack Router diff --git a/docs/oss/building-features/images.md b/docs/oss/building-features/images.md index de6b3c58d4..576f0d47b2 100644 --- a/docs/oss/building-features/images.md +++ b/docs/oss/building-features/images.md @@ -28,7 +28,7 @@ const assetLoaderRules = [ ]; ``` -A full example can be found at [react_on_rails/spec/dummy/client/app/startup/ImageExample.jsx](https://github.com/shakacode/react_on_rails/blob/main/react_on_rails/spec/dummy/client/app/startup/ImageExample.jsx) +A full example can be found at [react_on_rails/spec/dummy/client/app/startup/ImageExample.tsx](../../../react_on_rails/spec/dummy/client/app/startup/ImageExample.tsx) You are free to use images either in image tags or as background images in SCSS files. In current apps, prefer relative imports from files under `app/javascript`, or define your own webpack alias diff --git a/docs/oss/core-concepts/auto-bundling-file-system-based-automated-bundle-generation.md b/docs/oss/core-concepts/auto-bundling-file-system-based-automated-bundle-generation.md index e8e39f1a2f..db93542ae8 100644 --- a/docs/oss/core-concepts/auto-bundling-file-system-based-automated-bundle-generation.md +++ b/docs/oss/core-concepts/auto-bundling-file-system-based-automated-bundle-generation.md @@ -73,13 +73,13 @@ If you already have an existing server bundle entrypoint and have not set `make_ ```javascript // import statement added by react_on_rails:generate_packs rake task -import './../generated/server-bundle-generated.js'; +import '../generated/server-bundle-generated.js'; ``` We recommend committing this import statement to your version control system. > Example (dummy app): see the server bundle entrypoint import. -> [Dummy server-bundle.js](https://github.com/shakacode/react_on_rails/blob/main/react_on_rails/spec/dummy/client/app/packs/server-bundle.js) +> [Dummy `server-bundle.ts`](../../../react_on_rails/spec/dummy/client/app/packs/server-bundle.ts) ## Usage @@ -509,7 +509,7 @@ _Screenshots show browser dev tools network analysis demonstrating the dramatic If server rendering is enabled, the component will be registered for usage both in server and client rendering. To have separate definitions for client and server rendering, name the component files `ComponentName.server.jsx` and `ComponentName.client.jsx`. The `ComponentName.server.jsx` file will be used for server rendering and the `ComponentName.client.jsx` file for client rendering. If you don't want the component rendered on the server, you should only have the `ComponentName.client.jsx` file. -> Example (dummy app): paired files such as [`ReduxApp.client.jsx`](https://github.com/shakacode/react_on_rails/blob/main/react_on_rails/spec/dummy/client/app/startup/ReduxApp.client.jsx) and [`ReduxApp.server.jsx`](https://github.com/shakacode/react_on_rails/blob/main/react_on_rails/spec/dummy/client/app/startup/ReduxApp.server.jsx), and [`RouterApp.client.jsx`](https://github.com/shakacode/react_on_rails/blob/main/react_on_rails/spec/dummy/client/app/startup/RouterApp.client.jsx) and [`RouterApp.server.jsx`](https://github.com/shakacode/react_on_rails/blob/main/react_on_rails/spec/dummy/client/app/startup/RouterApp.server.jsx). +> Example (dummy app): paired files such as [`ReduxApp.client.tsx`](../../../react_on_rails/spec/dummy/client/app/startup/ReduxApp.client.tsx) and [`ReduxApp.server.tsx`](../../../react_on_rails/spec/dummy/client/app/startup/ReduxApp.server.tsx), and [`RouterApp.client.tsx`](../../../react_on_rails/spec/dummy/client/app/startup/RouterApp.client.tsx) and [`RouterApp.server.tsx`](../../../react_on_rails/spec/dummy/client/app/startup/RouterApp.server.tsx). Once generated, all server entrypoints will be imported into a file named `[ReactOnRails.configuration.server_bundle_js_file]-generated.js`, which in turn will be imported into a source file named the same as `ReactOnRails.configuration.server_bundle_js_file`. If your server bundling logic is such that your server bundle source entrypoint is not named the same as your `ReactOnRails.configuration.server_bundle_js_file` and changing it would be difficult, please let us know. @@ -607,11 +607,11 @@ registerServerComponent({ Dashboard, Profile }); ReactOnRails.register({ LikeButton, CommentForm }); ``` -Your existing `packs/server-bundle.js` entry file doesn't need manual changes — the packs generator adds one import line at the top pointing to the aggregated file: +Your existing `packs/server-bundle.js` or `packs/server-bundle.ts` entry file doesn't need manual changes — the packs generator adds one import line at the top pointing to the aggregated file: ```js -// packs/server-bundle.js -import './../generated/server-bundle-generated.js'; // added by react_on_rails:generate_packs +// packs/server-bundle.js or packs/server-bundle.ts +import '../generated/server-bundle-generated.js'; // added by react_on_rails:generate_packs // ... your own custom server-side code continues here ``` diff --git a/docs/oss/migrating/babel-to-swc-migration.md b/docs/oss/migrating/babel-to-swc-migration.md index 7f746a67ee..2f2d6bad43 100644 --- a/docs/oss/migrating/babel-to-swc-migration.md +++ b/docs/oss/migrating/babel-to-swc-migration.md @@ -52,11 +52,6 @@ try { const customConfig = { options: { jsc: { - parser: { - syntax: 'ecmascript', - jsx: true, - dynamicImport: true, - }, transform: { react: { runtime: 'automatic', @@ -140,13 +135,13 @@ If you need stable React Server Components support today: ### Features Migrated Successfully -| Babel Feature | SWC Equivalent | Notes | -| ------------------ | --------------------------------- | --------------------------- | -| JSX Transform | `jsc.transform.react` | Automatic runtime supported | -| React Fast Refresh | `jsc.transform.react.refresh` | Works in development mode | -| Dynamic Imports | `jsc.parser.dynamicImport` | Fully supported | -| Class Properties | Built-in | No config needed | -| TypeScript | `jsc.parser.syntax: 'typescript'` | Native support | +| Babel Feature | SWC Equivalent | Notes | +| ------------------ | ------------------------------ | --------------------------- | +| JSX Transform | `jsc.transform.react` | Automatic runtime supported | +| React Fast Refresh | `jsc.transform.react.refresh` | Works in development mode | +| Dynamic Imports | Shakapacker SWC parser default | Fully supported | +| Class Properties | Built-in | No config needed | +| TypeScript | Shakapacker SWC parser default | Native support | ### Features Requiring Different Approach @@ -225,18 +220,7 @@ yarn add -D @typescript-eslint/parser @typescript-eslint/eslint-plugin ### Issue: TypeScript Files Not Transpiling -**Solution**: For TypeScript files, update your SWC config to use TypeScript parser: - -```javascript -jsc: { - parser: { - syntax: 'typescript', - tsx: true, - dynamicImport: true, - }, - // ... rest of config -} -``` +**Solution**: Do not hardcode `jsc.parser` in `config/swc.config.js`. Shakapacker selects the SWC parser per file extension, using TypeScript mode for `.ts` and `.tsx` files. Keep custom settings under `jsc.transform`, `jsc.keepClassNames`, and other non-parser options unless the app has a specific parser feature to enable. ## Testing Results diff --git a/knip.ts b/knip.ts index 312584c0ff..a5e2d793f9 100644 --- a/knip.ts +++ b/knip.ts @@ -168,8 +168,8 @@ const config: KnipConfig = { 'react_on_rails/spec/dummy': { entry: [ 'app/assets/config/manifest.js!', - 'client/app/packs/**/*.js!', - // Not sure why this isn't detected as a dependency of client/app/packs/server-bundle.js + 'client/app/packs/**/*.{js,jsx,ts,tsx}!', + // Not sure why this isn't detected as a dependency of client/app/packs/server-bundle.ts 'client/app/generated/server-bundle-generated.js!', 'config/webpack/{production,development,test}.js', // Declaring this as webpack.config instead doesn't work correctly @@ -201,7 +201,7 @@ const config: KnipConfig = { 'client/app/startup/**', 'client/app/store/**', // ReScript entry files that import compiled .res.js files (compiled at build time) - 'client/app/packs/rescript-components.js', + 'client/app/packs/rescript-components.ts', ], project: ['**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}!', 'config/webpack/*.js'], paths: { diff --git a/packages/react-on-rails-pro/src/ComponentRegistry.ts b/packages/react-on-rails-pro/src/ComponentRegistry.ts index 3bf3fc9977..bc86df0a1f 100644 --- a/packages/react-on-rails-pro/src/ComponentRegistry.ts +++ b/packages/react-on-rails-pro/src/ComponentRegistry.ts @@ -12,7 +12,11 @@ * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md */ -import { type RegisteredComponent, type ReactComponentOrRenderFunction } from 'react-on-rails/types'; +import { + type ReactComponentOrRenderFunction, + type RegisteredComponent, + type RegisteredComponentValue, +} from 'react-on-rails/types'; import isRenderFunction from 'react-on-rails/isRenderFunction'; import CallbackRegistry from './CallbackRegistry.ts'; @@ -22,7 +26,7 @@ const componentRegistry = new CallbackRegistry('component') * @param components { component1: component1, component2: component2, etc. } * @public */ -export function register(components: Record): void { +export function register(components: Record): void { Object.keys(components).forEach((name) => { const component = components[name]; if (!component) { @@ -44,7 +48,7 @@ export function register(components: Record; +type RegisteredComponentEntry = RegisteredComponent; // An entry in `renderedRoots`. We track two kinds of mounts so both can be cleaned up on page // unload or same-id node replacement: @@ -110,7 +112,7 @@ function domNodeIdForEl(el: Element): string { type DelegationResult = { delegated: false } | { delegated: true; result: RendererResult }; function delegateToRenderer( - componentObj: RegisteredComponent, + componentObj: RegisteredComponentEntry, props: Record, railsContext: RailsContext, domNodeId: string, @@ -133,6 +135,10 @@ DELEGATING TO RENDERER ${name} for dom node with id: ${domNodeId} with props, ra // component union, so `as RendererFunction` is a runtime-invariant assertion guarded by // `isRenderer` (the registry only sets it for a 3-arg render function), not a structural // narrowing. + if (typeof component !== 'function') { + throw new Error(`Registered renderer "${name}" must be a function.`); + } + const result = (component as RendererFunction)(props, railsContext, domNodeId); return { delegated: true, result }; } diff --git a/packages/react-on-rails/src/ComponentRegistry.ts b/packages/react-on-rails/src/ComponentRegistry.ts index c0ff5d5cf3..ea477e5805 100644 --- a/packages/react-on-rails/src/ComponentRegistry.ts +++ b/packages/react-on-rails/src/ComponentRegistry.ts @@ -1,13 +1,15 @@ -import type { RegisteredComponent, ReactComponentOrRenderFunction } from './types/index.ts'; +import type { RegisteredComponent, RegisteredComponentValue } from './types/index.ts'; import isRenderFunction from './isRenderFunction.ts'; -const registeredComponents = new Map(); +type RegisteredComponentEntry = RegisteredComponent; + +const registeredComponents = new Map(); export default { /** * @param components { component1: component1, component2: component2, etc. } */ - register(components: Record): void { + register(components: Record): void { Object.keys(components).forEach((name) => { const component = components[name]; if (!component) { @@ -40,7 +42,7 @@ export default { * @param name * @returns { name, component, renderFunction, isRenderer } */ - get(name: string): RegisteredComponent { + get(name: string): RegisteredComponentEntry { const registeredComponent = registeredComponents.get(name); if (registeredComponent !== undefined) { return registeredComponent; @@ -56,7 +58,7 @@ Registered component names include [ ${keys} ]. Maybe you forgot to register the * @returns Map where key is the component name and values are the * { name, component, renderFunction, isRenderer} */ - components(): Map { + components(): Map { return registeredComponents; }, diff --git a/packages/react-on-rails/src/base/client.ts b/packages/react-on-rails/src/base/client.ts index 8cba25802d..a68cb147c9 100644 --- a/packages/react-on-rails/src/base/client.ts +++ b/packages/react-on-rails/src/base/client.ts @@ -7,7 +7,7 @@ import type { ReactElement } from 'react'; import type { RegisteredComponent, RenderReturnType, - ReactComponentOrRenderFunction, + RegisteredComponentValue, AuthenticityHeaders, Store, StoreGenerator, @@ -18,6 +18,7 @@ import * as Authenticity from '../Authenticity.ts'; import buildConsoleReplay, { consoleReplay } from '../buildConsoleReplay.ts'; import reactHydrateOrRender from '../reactHydrateOrRender.ts'; import createReactOutput from '../createReactOutput.ts'; +import componentRegistrationMetric from '../componentRegistrationMetric.ts'; const DEFAULT_OPTIONS = { traceTurbolinks: false, @@ -26,11 +27,13 @@ const DEFAULT_OPTIONS = { logComponentRegistration: false, }; +type RegisteredComponentEntry = RegisteredComponent; + interface Registries { ComponentRegistry: { - register: (components: Record) => void; - get: (name: string) => RegisteredComponent; - components: () => Map; + register: (components: Record) => void; + get: (name: string) => RegisteredComponentEntry; + components: () => Map; }; StoreRegistry: { register: (storeGenerators: Record) => void; @@ -194,7 +197,7 @@ Fix: Use only react-on-rails OR react-on-rails-pro, not both.`); // REGISTRY METHOD IMPLEMENTATIONS - Using provided registries // =================================================================== - register(components: Record): void { + register(components: Record): void { if (this.options.debugMode || this.options.logComponentRegistration) { // Use performance.now() if available, otherwise fallback to Date.now() const perf = typeof performance !== 'undefined' ? performance : { now: () => Date.now() }; @@ -215,8 +218,10 @@ Fix: Use only react-on-rails OR react-on-rails-pro, not both.`); if (this.options.debugMode) { componentNames.forEach((name) => { const component = components[name]; - const size = component.toString().length; - console.log(`[ReactOnRails] ✅ Registered: ${name} (${size} chars)`); + const registrationMetric = componentRegistrationMetric(component); + console.log( + `[ReactOnRails] ✅ Registered: ${name} (${registrationMetric.value} ${registrationMetric.label})`, + ); }); } } else { @@ -254,11 +259,11 @@ Fix: Use only react-on-rails OR react-on-rails-pro, not both.`); StoreRegistry.clearHydratedStores(); }, - getComponent(name: string): RegisteredComponent { + getComponent(name: string): RegisteredComponentEntry { return ComponentRegistry.get(name); }, - registeredComponents(): Map { + registeredComponents(): Map { return ComponentRegistry.components(); }, @@ -272,7 +277,7 @@ Fix: Use only react-on-rails OR react-on-rails-pro, not both.`); render( name: string, - props: Record, + props: Record, domNodeId: string, hydrate: boolean, ): RenderReturnType { diff --git a/packages/react-on-rails/src/capabilities/core.ts b/packages/react-on-rails/src/capabilities/core.ts index 3150cb74d2..47b4f8890a 100644 --- a/packages/react-on-rails/src/capabilities/core.ts +++ b/packages/react-on-rails/src/capabilities/core.ts @@ -2,7 +2,7 @@ import type { ReactElement } from 'react'; import type { RegisteredComponent, RenderReturnType, - ReactComponentOrRenderFunction, + RegisteredComponentValue, AuthenticityHeaders, Store, StoreGenerator, @@ -12,6 +12,7 @@ import * as Authenticity from '../Authenticity.ts'; import buildConsoleReplay, { consoleReplay } from '../buildConsoleReplay.ts'; import reactHydrateOrRender from '../reactHydrateOrRender.ts'; import createReactOutput from '../createReactOutput.ts'; +import componentRegistrationMetric from '../componentRegistrationMetric.ts'; const DEFAULT_OPTIONS = { traceTurbolinks: false, @@ -20,11 +21,13 @@ const DEFAULT_OPTIONS = { logComponentRegistration: false, }; +type RegisteredComponentEntry = RegisteredComponent; + export interface Registries { ComponentRegistry: { - register: (components: Record) => void; - get: (name: string) => RegisteredComponent; - components: () => Map; + register: (components: Record) => void; + get: (name: string) => RegisteredComponentEntry; + components: () => Map; }; StoreRegistry: { register: (storeGenerators: Record) => void; @@ -114,7 +117,7 @@ export function createCoreCapability(registries: Registries) { // REGISTRY METHOD IMPLEMENTATIONS - Using provided registries // =================================================================== - register(components: Record): void { + register(components: Record): void { if (this.options.debugMode || this.options.logComponentRegistration) { // Use performance.now() if available, otherwise fallback to Date.now() const perf = typeof performance !== 'undefined' ? performance : { now: () => Date.now() }; @@ -135,8 +138,10 @@ export function createCoreCapability(registries: Registries) { if (this.options.debugMode) { componentNames.forEach((name) => { const component = components[name]; - const size = component.toString().length; - console.log(`[ReactOnRails] ✅ Registered: ${name} (${size} chars)`); + const registrationMetric = componentRegistrationMetric(component); + console.log( + `[ReactOnRails] ✅ Registered: ${name} (${registrationMetric.value} ${registrationMetric.label})`, + ); }); } } else { @@ -174,11 +179,11 @@ export function createCoreCapability(registries: Registries) { StoreRegistry.clearHydratedStores(); }, - getComponent(name: string): RegisteredComponent { + getComponent(name: string): RegisteredComponentEntry { return ComponentRegistry.get(name); }, - registeredComponents(): Map { + registeredComponents(): Map { return ComponentRegistry.components(); }, @@ -192,7 +197,7 @@ export function createCoreCapability(registries: Registries) { render( name: string, - props: Record, + props: Record, domNodeId: string, hydrate: boolean, ): RenderReturnType { @@ -238,7 +243,7 @@ export function createCoreCapability(registries: Registries) { // PRO STUBS — overridden by Pro capabilities in react-on-rails-pro // =================================================================== - getOrWaitForComponent(): Promise { + getOrWaitForComponent(): Promise { throw new Error('getOrWaitForComponent requires the react-on-rails-pro package.'); }, diff --git a/packages/react-on-rails/src/componentRegistrationMetric.ts b/packages/react-on-rails/src/componentRegistrationMetric.ts new file mode 100644 index 0000000000..9ac7731cf2 --- /dev/null +++ b/packages/react-on-rails/src/componentRegistrationMetric.ts @@ -0,0 +1,14 @@ +import type { RegisteredComponentValue } from './types/index.ts'; + +type RegistrationMetric = { + label: string; + value: number; +}; + +export default function componentRegistrationMetric(component: RegisteredComponentValue): RegistrationMetric { + if (typeof component === 'function') { + return { label: 'source chars', value: component.toString().length }; + } + + return { label: 'export keys', value: Object.keys(component).length }; +} diff --git a/packages/react-on-rails/src/createReactOutput.ts b/packages/react-on-rails/src/createReactOutput.ts index 2cf55b1c2a..570dc440a3 100644 --- a/packages/react-on-rails/src/createReactOutput.ts +++ b/packages/react-on-rails/src/createReactOutput.ts @@ -7,6 +7,21 @@ const unsupportedManualRendererMessage = (name: string) => `ReactOnRails.render() does not support renderer functions ("${name}"). ` + 'Use normal React on Rails component rendering so renderer teardowns are captured on navigation.'; +function isReactObjectComponentType(value: unknown): value is ReactComponent { + if (value == null || typeof value !== 'object') { + return false; + } + + // React.memo, React.forwardRef, React.lazy, and related component types are non-callable + // objects tagged with React's element-type marker. + const typeMarker = (value as { $$typeof?: unknown }).$$typeof; + return typeof typeMarker === 'symbol' || typeof typeMarker === 'number'; +} + +function isReactComponentType(value: unknown): value is ReactComponent { + return typeof value === 'function' || typeof value === 'string' || isReactObjectComponentType(value); +} + function createReactElementFromRenderFunctionResult( renderFunctionResult: ReactComponent, name: string, @@ -83,6 +98,10 @@ export default function createReactOutput({ throw new Error(unsupportedManualRendererMessage(name)); } + if (typeof component !== 'function') { + throw new Error(`Registered render function "${name}" must be a function.`); + } + const renderFunctionResult = (component as RenderFunction)(props, railsContext); // Defense-in-depth: a 2-argument render function isn't expected to return a teardown wrapper, but // the public RenderFunction return type can't structurally exclude it, so reject that at runtime too. @@ -113,6 +132,12 @@ export default function createReactOutput({ return createReactElementFromRenderFunctionResult(renderFunctionResult, name, props); } - // else - return createElement(component as ReactComponent, props); + + if (!isReactComponentType(component)) { + throw new Error( + `Registered component "${name}" must be a function, string, or React object component type.`, + ); + } + + return createElement(component, props); } diff --git a/packages/react-on-rails/src/isRenderFunction.ts b/packages/react-on-rails/src/isRenderFunction.ts index 072b38b631..6388115a15 100644 --- a/packages/react-on-rails/src/isRenderFunction.ts +++ b/packages/react-on-rails/src/isRenderFunction.ts @@ -1,31 +1,37 @@ // See discussion: // https://discuss.reactjs.org/t/how-to-determine-if-js-object-is-react-component/2825/2 -import type { ReactComponentOrRenderFunction, RenderFunction, RendererFunction } from './types/index.ts'; +import type { RegisteredComponentValue, RenderFunction, RendererFunction } from './types/index.ts'; type AnyRenderFunction = RenderFunction | RendererFunction; /** - * Used to determine we'll call be calling React.createElement on the component of if this is a - * Render-Function used return a function that takes props to return a React element + * Used to determine whether we'll call React.createElement on the component or if this is a + * Render-Function used to return a function that takes props to return a React element * @param component * @returns {boolean} */ export default function isRenderFunction( - component: ReactComponentOrRenderFunction, + component: RegisteredComponentValue, ): component is AnyRenderFunction { + if (typeof component !== 'function') { + return false; + } + + const callableComponent = component as AnyRenderFunction; + // No for es5 or es6 React Component // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if ((component as AnyRenderFunction).prototype?.isReactComponent) { + if (callableComponent.prototype?.isReactComponent) { return false; } - if ((component as AnyRenderFunction).renderFunction) { + if (callableComponent.renderFunction) { return true; } // If zero or one args, then we know that this is a regular function that will // return a React component - if ((component as AnyRenderFunction).length >= 2) { + if (callableComponent.length >= 2) { return true; } diff --git a/packages/react-on-rails/src/serverRenderUtils.ts b/packages/react-on-rails/src/serverRenderUtils.ts index 173954e026..363865ed45 100644 --- a/packages/react-on-rails/src/serverRenderUtils.ts +++ b/packages/react-on-rails/src/serverRenderUtils.ts @@ -1,4 +1,9 @@ -import type { RegisteredComponent, RenderingError, FinalHtmlResult } from './types/index.ts'; +import type { + RegisteredComponent, + RegisteredComponentValue, + RenderingError, + FinalHtmlResult, +} from './types/index.ts'; /** * Builds the metadata object for the length-prefixed streaming protocol. @@ -122,7 +127,10 @@ export function convertToError(e: unknown): Error { return error; } -export function validateComponent(componentObj: RegisteredComponent, componentName: string) { +export function validateComponent( + componentObj: RegisteredComponent, + componentName: string, +) { if (componentObj.isRenderer) { throw new Error( `Detected a renderer while server rendering component '${componentName}'. See https://github.com/shakacode/react_on_rails#renderer-functions`, diff --git a/packages/react-on-rails/src/types/index.ts b/packages/react-on-rails/src/types/index.ts index 12f67c55b5..c45d5154db 100644 --- a/packages/react-on-rails/src/types/index.ts +++ b/packages/react-on-rails/src/types/index.ts @@ -1,6 +1,6 @@ /// -import type { ReactElement, ReactNode, Component, ComponentType } from 'react'; +import type { ReactElement, ReactNode, Component, ComponentType, ExoticComponent } from 'react'; import type { PipeableStream } from 'react-dom/server'; import type { Readable } from 'stream'; @@ -15,7 +15,7 @@ type Store = { getState(): unknown; }; -type ReactComponent = ComponentType | string; +type ReactComponent = ComponentType | ExoticComponent | string; // Keep these in sync with method lib/react_on_rails/helper.rb#rails_context export type RailsContext = { @@ -264,11 +264,14 @@ interface LegacyRendererRenderFunction extends RenderFunctionMarker { type RenderFunction = ServerRenderFunction | LegacyRendererRenderFunction; type ReactComponentOrRenderFunction = ReactComponent | RenderFunction | RendererFunction; +// Plain-object modules registered via server_render_js: no render function and no React component. +type RegisteredComponentValue = ReactComponentOrRenderFunction | Record; type PipeableOrReadableStream = PipeableStream | NodeJS.ReadableStream; export type { ReactComponentOrRenderFunction, + RegisteredComponentValue, ReactComponent, AuthenticityHeaders, RenderFunction, @@ -289,9 +292,17 @@ export type { PipeableOrReadableStream, }; -export interface RegisteredComponent { +/** + * The generic defaults to the pre-object-registration component type so existing consumers that + * read `registeredComponent.component` stay source-compatible. Use + * `RegisteredComponent` when handling plain-object server_render_js + * registrations. + */ +export interface RegisteredComponent< + ComponentValue extends RegisteredComponentValue = ReactComponentOrRenderFunction, +> { name: string; - component: ReactComponentOrRenderFunction; + component: ComponentValue; /** * Indicates if the registered component is a RenderFunction * @see RenderFunction for more details on its behavior and usage. @@ -331,7 +342,7 @@ export interface RSCRenderParams extends Omit { } export interface CreateParams extends Params { - componentObj: RegisteredComponent; + componentObj: RegisteredComponent; shouldHydrate?: boolean; } @@ -387,7 +398,7 @@ export interface ReactOnRails { * find you components for rendering. * @param components keys are component names, values are components */ - register(components: Record): void; + register(components: Record): void; /** @deprecated Use registerStoreGenerators instead */ registerStore(stores: Record): void; /** @@ -521,17 +532,22 @@ export interface ReactOnRailsInternal extends ReactOnRails { * (see "What is a root?" in https://github.com/reactwg/react-18/discussions/5). * Under React 16/17: Reference to your component's backing instance or `null` for stateless components. */ - render(name: string, props: Record, domNodeId: string, hydrate?: boolean): RenderReturnType; + render( + name: string, + props: Record, + domNodeId: string, + hydrate?: boolean, + ): RenderReturnType; /** * Get the component that you registered * @returns {name, component, renderFunction, isRenderer} */ - getComponent(name: string): RegisteredComponent; + getComponent(name: string): RegisteredComponent; /** * Get the component that you registered, or wait for it to be registered * @returns {name, component, renderFunction, isRenderer} */ - getOrWaitForComponent(name: string): Promise; + getOrWaitForComponent(name: string): Promise>; /** * Used by server rendering by Rails */ @@ -571,7 +587,7 @@ export interface ReactOnRailsInternal extends ReactOnRails { /** * Get a Map containing all registered components. Useful for debugging. */ - registeredComponents(): Map; + registeredComponents(): Map>; /** * Get a Map containing all registered store generators. Useful for debugging. */ diff --git a/packages/react-on-rails/tests/ComponentRegistry.test.js b/packages/react-on-rails/tests/ComponentRegistry.test.js index 3601ca6b4f..fdd0c7eb11 100644 --- a/packages/react-on-rails/tests/ComponentRegistry.test.js +++ b/packages/react-on-rails/tests/ComponentRegistry.test.js @@ -63,6 +63,24 @@ describe('ComponentRegistry', () => { expect(actual).toEqual(expected); }); + it('registers and retrieves plain object modules without treating them as render functions', () => { + const HelloString = { + world() { + return 'World'; + }, + }; + ComponentRegistry.register({ HelloString }); + const actual = ComponentRegistry.get('HelloString'); + const expected = { + name: 'HelloString', + component: HelloString, + renderFunction: false, + isRenderer: false, + }; + expect(actual).toEqual(expected); + expect(ComponentRegistry.components().get('HelloString')).toEqual(expected); + }); + it('registers and retrieves multiple components', () => { // Plain react stateless functional components const C5 = () =>
WHY
; diff --git a/packages/react-on-rails/tests/createReactOutput.test.ts b/packages/react-on-rails/tests/createReactOutput.test.ts new file mode 100644 index 0000000000..46b41bc21e --- /dev/null +++ b/packages/react-on-rails/tests/createReactOutput.test.ts @@ -0,0 +1,75 @@ +import * as React from 'react'; +import createReactOutput from '../src/createReactOutput.ts'; +import type { + CreateReactOutputResult, + ReactComponent, + RegisteredComponent, + RegisteredComponentValue, +} from '../src/types/index.ts'; + +type TestProps = { + message: string; +}; + +const props: TestProps = { + message: 'hello', +}; + +const createRegisteredComponent = ( + name: string, + component: RegisteredComponentValue, +): RegisteredComponent => ({ + name, + component, + renderFunction: false, + isRenderer: false, +}); + +const renderOutputFor = (name: string, component: RegisteredComponentValue): CreateReactOutputResult => + createReactOutput({ + componentObj: createRegisteredComponent(name, component), + props, + domNodeId: 'react-object-component', + }); + +const expectReactElement = (value: CreateReactOutputResult): React.ReactElement => { + if (!React.isValidElement(value)) { + throw new Error('Expected createReactOutput to return a React element'); + } + return value; +}; + +describe('createReactOutput', () => { + it.each<[string, ReactComponent]>([ + ['memo', React.memo(({ message }: TestProps) => React.createElement('div', null, message))], + [ + 'forwardRef', + React.forwardRef(({ message }, ref) => + React.createElement('div', { ref }, message), + ), + ], + [ + 'lazy', + React.lazy(async () => ({ + default: ({ message }: TestProps) => React.createElement('div', null, message), + })), + ], + ])('creates an element for React.%s object component types', (name, component) => { + const output = expectReactElement(renderOutputFor(name, component)); + + expect(output.type).toBe(component); + expect(output.props).toEqual(props); + }); + + it('rejects plain object registrations when they are rendered as components', () => { + const objectModule = { + hello() { + return 'world'; + }, + }; + + expect(() => renderOutputFor('ObjectModule', objectModule)).toThrow( + 'Registered component "ObjectModule" must be a function, string, or React object component type.', + ); + }); +}); diff --git a/packages/react-on-rails/tests/debugLogging.test.js b/packages/react-on-rails/tests/debugLogging.test.js index 2ef3f32f33..a80f67dc83 100644 --- a/packages/react-on-rails/tests/debugLogging.test.js +++ b/packages/react-on-rails/tests/debugLogging.test.js @@ -101,7 +101,7 @@ describe('Debug Logging', () => { expect.stringMatching(/\[ReactOnRails\] Component registration completed in \d+\.\d+ms/), ); expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringMatching(/\[ReactOnRails\] ✅ Registered: TestComponent \(\d+ chars\)/), + expect.stringMatching(/\[ReactOnRails\] ✅ Registered: TestComponent \(\d+ source chars\)/), ); }); @@ -120,10 +120,10 @@ describe('Debug Logging', () => { // Check that individual component registrations are logged with size info expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringMatching(/\[ReactOnRails\] ✅ Registered: SmallComponent \(\d+ chars\)/), + expect.stringMatching(/\[ReactOnRails\] ✅ Registered: SmallComponent \(\d+ source chars\)/), ); expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringMatching(/\[ReactOnRails\] ✅ Registered: LargerComponent \(\d+ chars\)/), + expect.stringMatching(/\[ReactOnRails\] ✅ Registered: LargerComponent \(\d+ source chars\)/), ); }); @@ -144,7 +144,7 @@ describe('Debug Logging', () => { expect.stringMatching(/\[ReactOnRails\] Component registration completed in \d+\.\d+ms/), ); expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringMatching(/\[ReactOnRails\] ✅ Registered: TestComponent \(\d+ chars\)/), + expect.stringMatching(/\[ReactOnRails\] ✅ Registered: TestComponent \(\d+ source chars\)/), ); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66f5dbb8ae..a6ddd7b84b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -421,9 +421,6 @@ importers: jquery-ujs: specifier: ^1.2.2 version: 1.2.3(jquery@3.7.1) - lodash: - specifier: ^4.18.1 - version: 4.18.1 mini-css-extract-plugin: specifier: ^2.4.4 version: 2.10.0(webpack@5.105.2) diff --git a/react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt b/react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt index 2a0ccb0bb1..83fbebf640 100644 --- a/react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt +++ b/react_on_rails/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt @@ -51,7 +51,7 @@ const configureServer = () => { if (!serverEntry['server-bundle']) { throw new Error( - "Create a pack with the file name 'server-bundle.js' containing all the server rendering files", + "Create a pack named 'server-bundle' containing all the server rendering files, for example 'server-bundle.js' or 'server-bundle.ts'", ); } diff --git a/react_on_rails/lib/react_on_rails/helper.rb b/react_on_rails/lib/react_on_rails/helper.rb index 97b5ac2cfc..206719190c 100644 --- a/react_on_rails/lib/react_on_rails/helper.rb +++ b/react_on_rails/lib/react_on_rails/helper.rb @@ -61,8 +61,8 @@ def reset_removed_immediate_hydration_warnings! # Exposing the react_component_name is necessary to both a plain ReactComponent as well as # a generator: # See README.md for how to "register" your React components. - # See spec/dummy/client/app/packs/server-bundle.js and - # spec/dummy/client/app/packs/client-bundle.js for examples of this. + # See spec/dummy/client/app/packs/server-bundle.ts and + # spec/dummy/client/app/packs/client-bundle.ts for examples of this. # # options: # props: Ruby Hash or JSON string which contains the properties to pass to the react object. Do @@ -104,7 +104,7 @@ def react_component(component_name, options = {}) when Hash msg = <<~MSG Use react_component_hash (not react_component) to return a Hash to your ruby view code. See - https://github.com/shakacode/react_on_rails/blob/main/react_on_rails/spec/dummy/client/app/startup/ReactHelmetApp.server.jsx + https://github.com/shakacode/react_on_rails/blob/main/react_on_rails/spec/dummy/client/app/startup/ReactHelmetApp.server.tsx for an example of the necessary javascript configuration. MSG raise ReactOnRails::Error, msg @@ -118,7 +118,7 @@ def react_component(component_name, options = {}) If you're trying to use a Render-Function to return a Hash to your ruby view code, then use react_component_hash instead of react_component and see - https://github.com/shakacode/react_on_rails/blob/main/react_on_rails/spec/dummy/client/app/startup/ReactHelmetApp.server.jsx + https://github.com/shakacode/react_on_rails/blob/main/react_on_rails/spec/dummy/client/app/startup/ReactHelmetApp.server.tsx for an example of the JavaScript code. MSG raise ReactOnRails::Error, msg @@ -170,7 +170,7 @@ def react_component_hash(component_name, options = {}) else msg = <<~MSG Render-Function used by react_component_hash for #{component_name} is expected to return - an Object. See https://github.com/shakacode/react_on_rails/blob/main/react_on_rails/spec/dummy/client/app/startup/ReactHelmetApp.server.jsx + an Object. See https://github.com/shakacode/react_on_rails/blob/main/react_on_rails/spec/dummy/client/app/startup/ReactHelmetApp.server.tsx for an example of the JavaScript code. Note, your Render-Function must either take 2 params or have the property `.renderFunction = true` added to it to distinguish it from a React Function Component. diff --git a/react_on_rails/lib/react_on_rails/packs_generator.rb b/react_on_rails/lib/react_on_rails/packs_generator.rb index 6f25b76ffa..c58001f329 100644 --- a/react_on_rails/lib/react_on_rails/packs_generator.rb +++ b/react_on_rails/lib/react_on_rails/packs_generator.rb @@ -28,6 +28,12 @@ module ReactOnRails class PacksGenerator CONTAINS_CLIENT_OR_SERVER_REGEX = /\.(server|client)($|\.)/ COMPONENT_EXTENSIONS = /\.(jsx?|tsx?)$/ + # Fallback order when the configured server bundle file is missing. Keep .jsx before + # TypeScript extensions as the closest migration fallback for apps moving from JS/JSX to TS. + # The configured extension is excluded, so server-bundle.js tries .jsx, .ts, .tsx, ... + SERVER_BUNDLE_SOURCE_EXTENSIONS = %w[.js .jsx .ts .tsx .mts .cts .mjs .cjs].freeze + # import/extensions suppressions are needed when generated imports include an explicit .js extension. + SERVER_BUNDLE_IMPORT_EXTENSION_COMMENT_EXTENSIONS = %w[.jsx .ts .tsx .mts .cts .mjs .cjs].freeze # Auto-registration requires nested_entries support which was added in 7.0.0 # Note: The gemspec requires Shakapacker >= 6.0 for basic functionality MINIMUM_SHAKAPACKER_VERSION_FOR_AUTO_BUNDLING = "7.0.0" @@ -47,6 +53,7 @@ def react_on_rails_npm_package def generate_packs_if_stale return unless ReactOnRails.configuration.auto_load_bundle + @server_bundle_entrypoint = nil verbose = ENV["REACT_ON_RAILS_VERBOSE"] == "true" with_generated_packs_lock(verbose: verbose) do @@ -60,6 +67,8 @@ def generate_packs_if_stale generate_packs(verbose: verbose) end end + ensure + @server_bundle_entrypoint = nil end private @@ -408,17 +417,39 @@ def add_generated_pack_to_server_bundle return if ReactOnRails.configuration.make_generated_server_bundle_the_entrypoint return if ReactOnRails.configuration.server_bundle_js_file.blank? - relative_path_to_generated_server_bundle = relative_path(server_bundle_entrypoint, - generated_server_bundle_file_path) + source_entrypoint = server_bundle_entrypoint + relative_path_to_generated_server_bundle = relative_path(source_entrypoint, generated_server_bundle_file_path) + relative_import_path_to_generated_server_bundle = relative_import_path(source_entrypoint, + generated_server_bundle_file_path) + import_path_to_generated_server_bundle = generated_server_bundle_import_path(source_entrypoint) + generated_server_bundle_import_statement = "import '#{import_path_to_generated_server_bundle}';" + if SERVER_BUNDLE_IMPORT_EXTENSION_COMMENT_EXTENSIONS.include?(File.extname(source_entrypoint)) + generated_server_bundle_import_statement += " // eslint-disable-line import/extensions" + end + content = <<~FILE_CONTENT // import statement added by react_on_rails:generate_packs rake task - import "./#{relative_path_to_generated_server_bundle}" + #{generated_server_bundle_import_statement} FILE_CONTENT + legacy_relative_import_path_to_generated_server_bundle = "./#{relative_path_to_generated_server_bundle}" + # Match today's normalized import path, the extension-stripped .js source form, and the + # legacy "./" prefixed path so repeated generation stays idempotent across old outputs. + generated_server_bundle_import_pattern = Regexp.union( + relative_import_path_to_generated_server_bundle, + import_path_to_generated_server_bundle, + legacy_relative_import_path_to_generated_server_bundle + ) + generated_server_bundle_import_regex = / + import\s+['"] + #{generated_server_bundle_import_pattern} + ['"] + /x + ReactOnRails::Utils.prepend_to_file_if_text_not_present( - file: server_bundle_entrypoint, + file: source_entrypoint, text_to_prepend: content, - regex: %r{import ['"]\./#{relative_path_to_generated_server_bundle}['"]} + regex: generated_server_bundle_import_regex ) end @@ -593,8 +624,54 @@ def create_directory_with_feedback(dir_path, verbose: false) end def server_bundle_entrypoint - Rails.root.join(ReactOnRails::PackerUtils.packer_source_entry_path, - ReactOnRails.configuration.server_bundle_js_file) + @server_bundle_entrypoint ||= begin + configured_entrypoint = Rails.root.join( + ReactOnRails::PackerUtils.packer_source_entry_path, + ReactOnRails.configuration.server_bundle_js_file + ).to_s + + if ReactOnRails.configuration.make_generated_server_bundle_the_entrypoint + configured_entrypoint + else + resolve_server_bundle_source_entrypoint(configured_entrypoint) + end + end + end + + def resolve_server_bundle_source_entrypoint(configured_entrypoint) + # Existing configured .js path wins over alternate .ts fallback when both exist. + return configured_entrypoint if File.exist?(configured_entrypoint) + + # Strip the existing extension when present; bare paths keep the full base and probe all extensions. + base_path = configured_entrypoint.sub(%r{\.[^./]+\z}, "") + server_bundle_source_extensions_for(configured_entrypoint).each do |extension| + candidate_entrypoint = "#{base_path}#{extension}" + next unless File.exist?(candidate_entrypoint) + + Rails.logger&.debug( + "[react_on_rails] server bundle source entrypoint resolved to #{candidate_entrypoint} " \ + "(configured: #{configured_entrypoint})" + ) + return candidate_entrypoint + end + + configured_entrypoint + end + + def server_bundle_source_extensions_for(configured_entrypoint) + configured_extension = File.extname(configured_entrypoint) + return SERVER_BUNDLE_SOURCE_EXTENSIONS if configured_extension.empty? + + SERVER_BUNDLE_SOURCE_EXTENSIONS.reject { |extension| extension == configured_extension } + end + + def generated_server_bundle_import_path(source_entrypoint) + import_path = relative_import_path(source_entrypoint, generated_server_bundle_file_path) + # .js entrypoints can use an extensionless import to satisfy import/extensions. Non-JS + # entrypoints keep the explicit .js output path and the caller adds the eslint suppression. + return import_path.delete_suffix(".js") if File.extname(source_entrypoint) == ".js" + + import_path end def generated_packs_directory_path @@ -625,6 +702,13 @@ def relative_path(from, to) to_path.relative_path_from(from_dir) end + def relative_import_path(from, to) + relative_path_string = relative_path(from, to).to_s + return relative_path_string if relative_path_string.start_with?(".") + + "./#{relative_path_string}" + end + def generated_pack_path(file_path) "#{generated_packs_directory_path}/#{component_name(file_path)}.js" end diff --git a/react_on_rails/spec/dummy/app/views/pages/client_side_hello_world.html.erb b/react_on_rails/spec/dummy/app/views/pages/client_side_hello_world.html.erb index 06e291d79b..4db8d11616 100644 --- a/react_on_rails/spec/dummy/app/views/pages/client_side_hello_world.html.erb +++ b/react_on_rails/spec/dummy/app/views/pages/client_side_hello_world.html.erb @@ -15,13 +15,13 @@

Setup

  1. - Create component source: spec/dummy/client/app/components/HelloWorld.jsx + Create component source: spec/dummy/client/app/startup/HelloWorld.tsx
  2. - Expose the HelloWorld Component: spec/dummy/client/app/packs/client-bundle.js + Expose the HelloWorld Component: spec/dummy/client/app/packs/client-bundle.ts
    -      import HelloWorld from '../components/HelloWorld';
    +      import HelloWorld from '../startup/HelloWorld';
           import ReactOnRails from 'react-on-rails/client';
           ReactOnRails.register({ HelloWorld });
         
    diff --git a/react_on_rails/spec/dummy/app/views/pages/client_side_hello_world_shared_store.html.erb b/react_on_rails/spec/dummy/app/views/pages/client_side_hello_world_shared_store.html.erb index c7ebd0e859..dc1a14916c 100644 --- a/react_on_rails/spec/dummy/app/views/pages/client_side_hello_world_shared_store.html.erb +++ b/react_on_rails/spec/dummy/app/views/pages/client_side_hello_world_shared_store.html.erb @@ -15,13 +15,13 @@

    Setup

    1. - Create component source: spec/dummy/client/app/startup/ReduxSharedStoreApp.jsx + Create component sources: spec/dummy/client/app/startup/ReduxSharedStoreApp.client.tsx and spec/dummy/client/app/startup/ReduxSharedStoreApp.server.tsx
    2. - Create store source: spec/dummy/client/app/stores/SharedReduxStore.jsx + Create store source: spec/dummy/client/app/stores/SharedReduxStore.ts
    3. - Register the components: spec/dummy/client/app/packs/client-bundle.js + Register the components: spec/dummy/client/app/packs/client-bundle.ts
    4. Place the components and store on the view: spec/dummy/app/views/pages/client_side_hello_world_shared_store.html.erb diff --git a/react_on_rails/spec/dummy/app/views/pages/client_side_hello_world_shared_store_controller.html.erb b/react_on_rails/spec/dummy/app/views/pages/client_side_hello_world_shared_store_controller.html.erb index 3c46822da8..35d8f661fc 100644 --- a/react_on_rails/spec/dummy/app/views/pages/client_side_hello_world_shared_store_controller.html.erb +++ b/react_on_rails/spec/dummy/app/views/pages/client_side_hello_world_shared_store_controller.html.erb @@ -14,13 +14,13 @@

      Setup

      1. - Create component source: spec/dummy/client/app/startup/ReduxSharedStoreApp.jsx + Create component sources: spec/dummy/client/app/startup/ReduxSharedStoreApp.client.tsx and spec/dummy/client/app/startup/ReduxSharedStoreApp.server.tsx
      2. - Create store source: spec/dummy/client/app/stores/SharedReduxStore.jsx + Create store source: spec/dummy/client/app/stores/SharedReduxStore.ts
      3. - Register the components: spec/dummy/client/app/packs/client-bundle.js + Register the components: spec/dummy/client/app/packs/client-bundle.ts
      4. Place the components and store on the view: spec/dummy/app/views/pages/client_side_hello_world_shared_store.html.erb diff --git a/react_on_rails/spec/dummy/app/views/pages/client_side_hello_world_shared_store_defer.html.erb b/react_on_rails/spec/dummy/app/views/pages/client_side_hello_world_shared_store_defer.html.erb index 4e690d4b31..41c94a3766 100644 --- a/react_on_rails/spec/dummy/app/views/pages/client_side_hello_world_shared_store_defer.html.erb +++ b/react_on_rails/spec/dummy/app/views/pages/client_side_hello_world_shared_store_defer.html.erb @@ -17,13 +17,13 @@ YO

        Setup

        1. - Create component source: spec/dummy/client/app/startup/ReduxSharedStoreApp.jsx + Create component sources: spec/dummy/client/app/startup/ReduxSharedStoreApp.client.tsx and spec/dummy/client/app/startup/ReduxSharedStoreApp.server.tsx
        2. - Create store source: spec/dummy/client/app/stores/SharedReduxStore.jsx + Create store source: spec/dummy/client/app/stores/SharedReduxStore.ts
        3. - Register the components: spec/dummy/client/app/packs/client-bundle.js + Register the components: spec/dummy/client/app/packs/client-bundle.ts
        4. Place the components and store on the view: spec/dummy/app/views/pages/client_side_hello_world_shared_store.html.erb diff --git a/react_on_rails/spec/dummy/app/views/pages/client_side_log_throw.html.erb b/react_on_rails/spec/dummy/app/views/pages/client_side_log_throw.html.erb index 1382fa523e..9d2468878d 100644 --- a/react_on_rails/spec/dummy/app/views/pages/client_side_log_throw.html.erb +++ b/react_on_rails/spec/dummy/app/views/pages/client_side_log_throw.html.erb @@ -8,7 +8,7 @@ This example demonstrates client side logging and error handling.
          Open up your browser console and see the messages.

          - What you see in your console is the result of running the JS code found in spec/dummy/client/app/components/HelloWorldWithLogAndThrow.jsx (reproduced below):
          + What you see in your console is the result of running the JS code found in spec/dummy/client/app/startup/HelloWorldWithLogAndThrow.tsx (reproduced below):
               console.log("console.log in HelloWorld")
               console.warn("console.warn in HelloWorld")
          diff --git a/react_on_rails/spec/dummy/app/views/pages/client_side_manual_render.html.erb b/react_on_rails/spec/dummy/app/views/pages/client_side_manual_render.html.erb
          index 6ebce65fe9..9e65944409 100644
          --- a/react_on_rails/spec/dummy/app/views/pages/client_side_manual_render.html.erb
          +++ b/react_on_rails/spec/dummy/app/views/pages/client_side_manual_render.html.erb
          @@ -16,20 +16,20 @@
             
        5. Use register to expose the rendering function to ReactOnRails:

          -  import ManualRenderApp from './ManualRenderAppRenderer';
          +  import ManualRenderApp from '../startup/ManualRenderApp';
           
             ReactOnRails.register({
               ManualRenderApp,
             });
               
          -

          spec/dummy/client/app/packs/client-bundle.js

          +

          spec/dummy/client/app/packs/client-bundle.ts


        6. A renderer is a function that accepts props, railsContext, domNodeId:

          -  import ReactDOMClient from 'react-dom/client';
          +  import { createRoot, hydrateRoot } from 'react-dom/client';
           
             const ManualRenderApp = (props, railsContext, domNodeId) => {
               const reactElement = (
          @@ -39,11 +39,16 @@
                 <%= '' %>
               );
           
          -    const root = ReactDOMClient.createRoot(document.getElementById(domNodeId));
          -    root.render(reactElement);
          +    const domNode = document.getElementById(domNodeId);
          +    if (props.prerender) {
          +      hydrateRoot(domNode, reactElement);
          +    } else {
          +      const root = createRoot(domNode);
          +      root.render(reactElement);
          +    }
             };
               
          -

          spec/dummy/client/app/startup/ManualRenderAppRenderer.jsx

          +

          spec/dummy/client/app/startup/ManualRenderApp.tsx


        7. diff --git a/react_on_rails/spec/dummy/app/views/pages/server_side_hello_world.html.erb b/react_on_rails/spec/dummy/app/views/pages/server_side_hello_world.html.erb index 8dcb1ea96e..2b2c5cb000 100644 --- a/react_on_rails/spec/dummy/app/views/pages/server_side_hello_world.html.erb +++ b/react_on_rails/spec/dummy/app/views/pages/server_side_hello_world.html.erb @@ -15,7 +15,7 @@ <%= '

          ' %> <%= '