react-on-rails-pro 16.7.0-rc.3 → 17.0.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.
@@ -1,4 +1,6 @@
1
1
  declare const ReactOnRails: import("react-on-rails/types").ReactOnRailsInternal;
2
2
  export * from 'react-on-rails/types';
3
+ export { unstable_cache, registerCacheHandler } from './cache/index.ts';
4
+ export type { CacheHandler, CacheEntry, UnstableCacheOptions } from './cache/index.ts';
3
5
  export default ReactOnRails;
4
6
  //# sourceMappingURL=ReactOnRailsRSC.d.ts.map
@@ -17,5 +17,6 @@ import createReactOnRailsPro from "./createReactOnRailsPro.js";
17
17
  const currentGlobal = globalThis.ReactOnRails || null;
18
18
  const ReactOnRails = createReactOnRailsPro([createSSRCapability(), createProRSCCapability()], currentGlobal);
19
19
  export * from 'react-on-rails/types';
20
+ export { unstable_cache, registerCacheHandler } from "./cache/index.js"; // eslint-disable-line camelcase -- matches Next.js API naming convention
20
21
  export default ReactOnRails;
21
22
  //# sourceMappingURL=ReactOnRailsRSC.js.map
@@ -0,0 +1,10 @@
1
+ export interface CacheEntry {
2
+ value: Buffer[];
3
+ revalidate: number;
4
+ timestamp: number;
5
+ }
6
+ export interface CacheHandler {
7
+ get(key: string): Promise<CacheEntry | null>;
8
+ set(key: string, entry: CacheEntry): Promise<void>;
9
+ }
10
+ //# sourceMappingURL=CacheHandler.d.ts.map
@@ -0,0 +1,15 @@
1
+ /*
2
+ * Copyright (c) 2025 Shakacode LLC
3
+ *
4
+ * This file is NOT licensed under the MIT (open source) license.
5
+ * It is part of the React on Rails Pro offering and is licensed separately.
6
+ *
7
+ * Unauthorized copying, modification, distribution, or use of this file,
8
+ * via any medium, is strictly prohibited without a valid license agreement
9
+ * from Shakacode LLC.
10
+ *
11
+ * For licensing terms, please see:
12
+ * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md
13
+ */
14
+ export {};
15
+ //# sourceMappingURL=CacheHandler.js.map
@@ -0,0 +1,9 @@
1
+ import type { CacheEntry, CacheHandler } from './CacheHandler.ts';
2
+ export declare class InMemoryLRUCacheHandler implements CacheHandler {
3
+ private cache;
4
+ private maxEntries;
5
+ constructor(maxEntries?: number);
6
+ get(key: string): Promise<CacheEntry | null>;
7
+ set(key: string, entry: CacheEntry): Promise<void>;
8
+ }
9
+ //# sourceMappingURL=InMemoryLRUCacheHandler.d.ts.map
@@ -0,0 +1,50 @@
1
+ /*
2
+ * Copyright (c) 2025 Shakacode LLC
3
+ *
4
+ * This file is NOT licensed under the MIT (open source) license.
5
+ * It is part of the React on Rails Pro offering and is licensed separately.
6
+ *
7
+ * Unauthorized copying, modification, distribution, or use of this file,
8
+ * via any medium, is strictly prohibited without a valid license agreement
9
+ * from Shakacode LLC.
10
+ *
11
+ * For licensing terms, please see:
12
+ * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md
13
+ */
14
+ // eslint-disable-next-line import/prefer-default-export -- designed for named import alongside the interface
15
+ export class InMemoryLRUCacheHandler {
16
+ constructor(maxEntries = 1000) {
17
+ this.cache = new Map();
18
+ this.maxEntries = maxEntries;
19
+ }
20
+ // eslint-disable-next-line @typescript-eslint/require-await -- CacheHandler interface is async for remote implementations
21
+ async get(key) {
22
+ const entry = this.cache.get(key);
23
+ if (!entry)
24
+ return null;
25
+ if (entry.revalidate > 0 && Date.now() - entry.timestamp > entry.revalidate * 1000) {
26
+ this.cache.delete(key);
27
+ return null;
28
+ }
29
+ // Move to end (most-recently-used) by re-inserting
30
+ this.cache.delete(key);
31
+ this.cache.set(key, entry);
32
+ return entry;
33
+ }
34
+ // eslint-disable-next-line @typescript-eslint/require-await
35
+ async set(key, entry) {
36
+ // If key already exists, remove it first so re-insert goes to end
37
+ if (this.cache.has(key)) {
38
+ this.cache.delete(key);
39
+ }
40
+ // Evict oldest (first) entry if at capacity
41
+ if (this.cache.size >= this.maxEntries) {
42
+ const oldestKey = this.cache.keys().next().value;
43
+ if (oldestKey !== undefined) {
44
+ this.cache.delete(oldestKey);
45
+ }
46
+ }
47
+ this.cache.set(key, entry);
48
+ }
49
+ }
50
+ //# sourceMappingURL=InMemoryLRUCacheHandler.js.map
@@ -0,0 +1,2 @@
1
+ export declare function buildCacheKey(buildId: string, id: string, args: unknown[]): string;
2
+ //# sourceMappingURL=buildCacheKey.d.ts.map
@@ -0,0 +1,25 @@
1
+ /*
2
+ * Copyright (c) 2025 Shakacode LLC
3
+ *
4
+ * This file is NOT licensed under the MIT (open source) license.
5
+ * It is part of the React on Rails Pro offering and is licensed separately.
6
+ *
7
+ * Unauthorized copying, modification, distribution, or use of this file,
8
+ * via any medium, is strictly prohibited without a valid license agreement
9
+ * from Shakacode LLC.
10
+ *
11
+ * For licensing terms, please see:
12
+ * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md
13
+ */
14
+ import { createHash } from 'crypto';
15
+ // eslint-disable-next-line import/prefer-default-export -- will grow with additional key utilities
16
+ export function buildCacheKey(buildId, id, args) {
17
+ const hash = createHash('sha256');
18
+ hash.update(buildId);
19
+ hash.update(':');
20
+ hash.update(id);
21
+ hash.update(':');
22
+ hash.update(JSON.stringify(args));
23
+ return `rorp:rsc-cache:${hash.digest('hex')}`;
24
+ }
25
+ //# sourceMappingURL=buildCacheKey.js.map
@@ -0,0 +1,3 @@
1
+ export declare function setBuildId(id: string): void;
2
+ export declare function getBuildId(): string;
3
+ //# sourceMappingURL=buildIdProvider.d.ts.map
@@ -0,0 +1,25 @@
1
+ /*
2
+ * Copyright (c) 2025 Shakacode LLC
3
+ *
4
+ * This file is NOT licensed under the MIT (open source) license.
5
+ * It is part of the React on Rails Pro offering and is licensed separately.
6
+ *
7
+ * Unauthorized copying, modification, distribution, or use of this file,
8
+ * via any medium, is strictly prohibited without a valid license agreement
9
+ * from Shakacode LLC.
10
+ *
11
+ * For licensing terms, please see:
12
+ * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md
13
+ */
14
+ let buildId;
15
+ export function setBuildId(id) {
16
+ buildId = id;
17
+ }
18
+ export function getBuildId() {
19
+ if (!buildId) {
20
+ throw new Error('BUILD_ID not set. Ensure unstable_cache is used within a React Server Component render context. ' +
21
+ 'The BUILD_ID is initialized from rscBundleHash during the first render request.');
22
+ }
23
+ return buildId;
24
+ }
25
+ //# sourceMappingURL=buildIdProvider.js.map
@@ -0,0 +1,4 @@
1
+ import type { CacheHandler } from './CacheHandler.ts';
2
+ export declare function registerCacheHandler(kind: string, handler: CacheHandler): void;
3
+ export declare function getCacheHandler(kind: string): CacheHandler;
4
+ //# sourceMappingURL=cacheHandlerRegistry.d.ts.map
@@ -0,0 +1,29 @@
1
+ /*
2
+ * Copyright (c) 2025 Shakacode LLC
3
+ *
4
+ * This file is NOT licensed under the MIT (open source) license.
5
+ * It is part of the React on Rails Pro offering and is licensed separately.
6
+ *
7
+ * Unauthorized copying, modification, distribution, or use of this file,
8
+ * via any medium, is strictly prohibited without a valid license agreement
9
+ * from Shakacode LLC.
10
+ *
11
+ * For licensing terms, please see:
12
+ * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md
13
+ */
14
+ import { InMemoryLRUCacheHandler } from "./InMemoryLRUCacheHandler.js";
15
+ const handlers = new Map();
16
+ handlers.set('default', new InMemoryLRUCacheHandler());
17
+ export function registerCacheHandler(kind, handler) {
18
+ handlers.set(kind, handler);
19
+ }
20
+ export function getCacheHandler(kind) {
21
+ const handler = handlers.get(kind);
22
+ if (!handler) {
23
+ throw new Error(`No CacheHandler registered for kind "${kind}". ` +
24
+ `Available kinds: ${[...handlers.keys()].join(', ')}. ` +
25
+ 'Register a handler with registerCacheHandler(kind, handler).');
26
+ }
27
+ return handler;
28
+ }
29
+ //# sourceMappingURL=cacheHandlerRegistry.js.map
@@ -0,0 +1,5 @@
1
+ export { unstable_cache } from './unstable_cache.ts';
2
+ export type { UnstableCacheOptions } from './unstable_cache.ts';
3
+ export type { CacheHandler, CacheEntry } from './CacheHandler.ts';
4
+ export { registerCacheHandler } from './cacheHandlerRegistry.ts';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,17 @@
1
+ /*
2
+ * Copyright (c) 2025 Shakacode LLC
3
+ *
4
+ * This file is NOT licensed under the MIT (open source) license.
5
+ * It is part of the React on Rails Pro offering and is licensed separately.
6
+ *
7
+ * Unauthorized copying, modification, distribution, or use of this file,
8
+ * via any medium, is strictly prohibited without a valid license agreement
9
+ * from Shakacode LLC.
10
+ *
11
+ * For licensing terms, please see:
12
+ * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md
13
+ */
14
+ /* eslint-disable camelcase -- matches Next.js API naming convention */
15
+ export { unstable_cache } from "./unstable_cache.js";
16
+ export { registerCacheHandler } from "./cacheHandlerRegistry.js";
17
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Stub module for non-react-server contexts (e.g. SSR server bundle).
3
+ *
4
+ * The auto-load system includes .server.tsx files in both the RSC bundle
5
+ * and the SSR server bundle. The SSR bundle never executes server component
6
+ * code (it delegates to the RSC bundle), but webpack still needs to resolve
7
+ * all imports at build time. This stub satisfies that requirement.
8
+ */
9
+ import type { ReactNode } from 'react';
10
+ import type { CacheHandler, CacheEntry } from './CacheHandler.ts';
11
+ export type { CacheHandler, CacheEntry };
12
+ export interface UnstableCacheOptions {
13
+ id: string;
14
+ revalidate?: number;
15
+ kind?: string;
16
+ }
17
+ export declare function unstable_cache<TArgs extends unknown[]>(originalFn: (...args: TArgs) => Promise<ReactNode> | ReactNode, options: UnstableCacheOptions): (...args: TArgs) => Promise<ReactNode>;
18
+ export declare function registerCacheHandler(kind: string, handler: CacheHandler): void;
19
+ //# sourceMappingURL=index.stub.d.ts.map
@@ -0,0 +1,25 @@
1
+ /*
2
+ * Copyright (c) 2025 Shakacode LLC
3
+ *
4
+ * This file is NOT licensed under the MIT (open source) license.
5
+ * It is part of the React on Rails Pro offering and is licensed separately.
6
+ *
7
+ * Unauthorized copying, modification, distribution, or use of this file,
8
+ * via any medium, is strictly prohibited without a valid license agreement
9
+ * from Shakacode LLC.
10
+ *
11
+ * For licensing terms, please see:
12
+ * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md
13
+ */
14
+ const STUB_ERROR = 'unstable_cache is only available in the react-server bundle. ' +
15
+ 'It should not be called from the SSR server bundle or client bundle.';
16
+ // eslint-disable-next-line camelcase -- matches Next.js API naming convention
17
+ export function unstable_cache(originalFn, options) {
18
+ return () => {
19
+ throw new Error(STUB_ERROR);
20
+ };
21
+ }
22
+ export function registerCacheHandler(kind, handler) {
23
+ throw new Error(STUB_ERROR);
24
+ }
25
+ //# sourceMappingURL=index.stub.js.map
@@ -0,0 +1,5 @@
1
+ import { buildClientRenderer } from 'react-on-rails-rsc/client.node';
2
+ export declare function setManifestFileNames(clientManifest: string, serverClientManifest: string): void;
3
+ export declare function getClientManifestFileName(): string | undefined;
4
+ export declare function getClientRenderer(): Promise<ReturnType<typeof buildClientRenderer>>;
5
+ //# sourceMappingURL=manifestLoader.d.ts.map
@@ -0,0 +1,46 @@
1
+ /*
2
+ * Copyright (c) 2025 Shakacode LLC
3
+ *
4
+ * This file is NOT licensed under the MIT (open source) license.
5
+ * It is part of the React on Rails Pro offering and is licensed separately.
6
+ *
7
+ * Unauthorized copying, modification, distribution, or use of this file,
8
+ * via any medium, is strictly prohibited without a valid license agreement
9
+ * from Shakacode LLC.
10
+ *
11
+ * For licensing terms, please see:
12
+ * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md
13
+ */
14
+ import { buildClientRenderer } from 'react-on-rails-rsc/client.node';
15
+ import loadJsonFile from "../loadJsonFile.js";
16
+ let clientManifestFileName;
17
+ let serverClientManifestFileName;
18
+ let clientRendererPromise;
19
+ export function setManifestFileNames(clientManifest, serverClientManifest) {
20
+ clientManifestFileName = clientManifest;
21
+ serverClientManifestFileName = serverClientManifest;
22
+ }
23
+ export function getClientManifestFileName() {
24
+ return clientManifestFileName;
25
+ }
26
+ export function getClientRenderer() {
27
+ if (!clientRendererPromise) {
28
+ if (!clientManifestFileName || !serverClientManifestFileName) {
29
+ throw new Error('Manifest file names not set. Ensure setManifestFileNames() is called before getClientRenderer(). ' +
30
+ 'This is done automatically during the first RSC render request.');
31
+ }
32
+ const clientFile = clientManifestFileName;
33
+ const serverFile = serverClientManifestFileName;
34
+ clientRendererPromise = Promise.all([
35
+ loadJsonFile(serverFile),
36
+ loadJsonFile(clientFile),
37
+ ])
38
+ .then(([reactServerManifest, reactClientManifest]) => buildClientRenderer(reactClientManifest, reactServerManifest))
39
+ .catch((err) => {
40
+ clientRendererPromise = undefined;
41
+ throw err;
42
+ });
43
+ }
44
+ return clientRendererPromise;
45
+ }
46
+ //# sourceMappingURL=manifestLoader.js.map
@@ -0,0 +1,3 @@
1
+ import { buildServerRenderer } from 'react-on-rails-rsc/server.node';
2
+ export declare function getServerRenderer(): Promise<ReturnType<typeof buildServerRenderer>>;
3
+ //# sourceMappingURL=manifestLoaderServer.d.ts.map
@@ -0,0 +1,35 @@
1
+ /*
2
+ * Copyright (c) 2025 Shakacode LLC
3
+ *
4
+ * This file is NOT licensed under the MIT (open source) license.
5
+ * It is part of the React on Rails Pro offering and is licensed separately.
6
+ *
7
+ * Unauthorized copying, modification, distribution, or use of this file,
8
+ * via any medium, is strictly prohibited without a valid license agreement
9
+ * from Shakacode LLC.
10
+ *
11
+ * For licensing terms, please see:
12
+ * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md
13
+ */
14
+ import { buildServerRenderer } from 'react-on-rails-rsc/server.node';
15
+ import loadJsonFile from "../loadJsonFile.js";
16
+ import { getClientManifestFileName } from "./manifestLoader.js";
17
+ let serverRendererPromise;
18
+ // eslint-disable-next-line import/prefer-default-export -- named export for consistency with manifestLoader
19
+ export function getServerRenderer() {
20
+ if (!serverRendererPromise) {
21
+ const clientManifest = getClientManifestFileName();
22
+ if (!clientManifest) {
23
+ throw new Error('Manifest file names not set. Ensure setManifestFileNames() is called before getServerRenderer(). ' +
24
+ 'This is done automatically during the first RSC render request.');
25
+ }
26
+ serverRendererPromise = loadJsonFile(clientManifest)
27
+ .then((reactClientManifest) => buildServerRenderer(reactClientManifest))
28
+ .catch((err) => {
29
+ serverRendererPromise = undefined;
30
+ throw err;
31
+ });
32
+ }
33
+ return serverRendererPromise;
34
+ }
35
+ //# sourceMappingURL=manifestLoaderServer.js.map
@@ -0,0 +1,11 @@
1
+ import type { ReactNode } from 'react';
2
+ export interface UnstableCacheOptions {
3
+ /** Stable identifier for this cached function. Required. */
4
+ id: string;
5
+ /** Time in seconds before the cache entry is considered stale. 0 = indefinite. */
6
+ revalidate?: number;
7
+ /** Cache handler kind to use. Defaults to 'default' (in-memory LRU). */
8
+ kind?: string;
9
+ }
10
+ export declare function unstable_cache<TArgs extends unknown[]>(originalFn: (...args: TArgs) => Promise<ReactNode> | ReactNode, options: UnstableCacheOptions): (...args: TArgs) => Promise<ReactNode>;
11
+ //# sourceMappingURL=unstable_cache.d.ts.map
@@ -0,0 +1,85 @@
1
+ /*
2
+ * Copyright (c) 2025 Shakacode LLC
3
+ *
4
+ * This file is NOT licensed under the MIT (open source) license.
5
+ * It is part of the React on Rails Pro offering and is licensed separately.
6
+ *
7
+ * Unauthorized copying, modification, distribution, or use of this file,
8
+ * via any medium, is strictly prohibited without a valid license agreement
9
+ * from Shakacode LLC.
10
+ *
11
+ * For licensing terms, please see:
12
+ * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md
13
+ */
14
+ import { PassThrough } from 'stream';
15
+ import { getCacheHandler } from "./cacheHandlerRegistry.js";
16
+ import { buildCacheKey } from "./buildCacheKey.js";
17
+ import { getBuildId } from "./buildIdProvider.js";
18
+ import { getClientRenderer } from "./manifestLoader.js";
19
+ import { getServerRenderer } from "./manifestLoaderServer.js";
20
+ function chunksToNodeStream(chunks) {
21
+ const stream = new PassThrough();
22
+ for (const chunk of chunks) {
23
+ stream.push(chunk);
24
+ }
25
+ stream.push(null);
26
+ return stream;
27
+ }
28
+ function bufferNodeStream(stream) {
29
+ const chunks = [];
30
+ return new Promise((resolve, reject) => {
31
+ stream.on('data', (chunk) => chunks.push(chunk));
32
+ stream.on('end', () => resolve(chunks));
33
+ stream.on('error', reject);
34
+ });
35
+ }
36
+ // eslint-disable-next-line camelcase -- matches Next.js API naming convention
37
+ export function unstable_cache(originalFn, options) {
38
+ const { id, revalidate = 0, kind = 'default' } = options;
39
+ return async function cachedFn(...args) {
40
+ const handler = getCacheHandler(kind);
41
+ const cacheKey = buildCacheKey(getBuildId(), id, args);
42
+ // --- HIT path ---
43
+ const entry = await handler.get(cacheKey);
44
+ if (entry) {
45
+ const replayStream = chunksToNodeStream(entry.value);
46
+ const { createFromNodeStream } = await getClientRenderer();
47
+ return createFromNodeStream(replayStream);
48
+ }
49
+ // --- MISS path ---
50
+ const reactTree = await originalFn(...args);
51
+ const { renderToPipeableStream } = await getServerRenderer();
52
+ const rscPipeable = renderToPipeableStream(reactTree);
53
+ const source = new PassThrough();
54
+ const forCache = new PassThrough();
55
+ const forReturn = new PassThrough();
56
+ rscPipeable.pipe(source);
57
+ source.on('data', (chunk) => {
58
+ forCache.push(chunk);
59
+ forReturn.push(chunk);
60
+ });
61
+ source.on('end', () => {
62
+ forCache.push(null);
63
+ forReturn.push(null);
64
+ });
65
+ source.on('error', (err) => {
66
+ forCache.destroy(err);
67
+ forReturn.destroy(err);
68
+ });
69
+ bufferNodeStream(forCache)
70
+ .then((chunks) => {
71
+ const newEntry = {
72
+ value: chunks,
73
+ revalidate,
74
+ timestamp: Date.now(),
75
+ };
76
+ return handler.set(cacheKey, newEntry);
77
+ })
78
+ .catch((err) => {
79
+ console.error('unstable_cache: failed to store cache entry', err);
80
+ });
81
+ const { createFromNodeStream } = await getClientRenderer();
82
+ return createFromNodeStream(forReturn);
83
+ };
84
+ }
85
+ //# sourceMappingURL=unstable_cache.js.map
@@ -1,17 +1,64 @@
1
1
  /* eslint-disable import/prefer-default-export -- named export for consistency with capability API */
2
- import { buildServerRenderer } from 'react-on-rails-rsc/server.node';
3
2
  import { assertRailsContextWithServerStreamingCapabilities, } from 'react-on-rails/types';
4
3
  import { convertToError } from 'react-on-rails/serverRenderUtils';
5
4
  import handleError from "../handleErrorRSC.js";
6
5
  import { getOrCreateAsyncPropsManager } from "../AsyncPropsManager.js";
7
6
  import { streamServerRenderedComponent, transformRenderStreamChunksToResultObject, } from "../streamingUtils.js";
8
- import loadJsonFile from "../loadJsonFile.js";
9
- let serverRendererPromise;
7
+ import { setManifestFileNames } from "../cache/manifestLoader.js";
8
+ import { getServerRenderer } from "../cache/manifestLoaderServer.js";
9
+ import { setBuildId } from "../cache/buildIdProvider.js";
10
+ const CLIENT_HOOK_NAMES = [
11
+ 'useState',
12
+ 'useEffect',
13
+ 'useReducer',
14
+ 'useCallback',
15
+ 'useMemo',
16
+ 'useRef',
17
+ 'useLayoutEffect',
18
+ 'useImperativeHandle',
19
+ 'useContext',
20
+ 'useSyncExternalStore',
21
+ 'useTransition',
22
+ 'useDeferredValue',
23
+ 'useId',
24
+ 'useDebugValue',
25
+ 'useInsertionEffect',
26
+ 'useOptimistic',
27
+ 'useActionState',
28
+ ].join('|');
29
+ const CLIENT_HOOK_RUNTIME_ERROR_REGEX = new RegExp(`(?:(?:React\\.)|\\(0\\s*,\\s*[\\w$]+\\.)?(${CLIENT_HOOK_NAMES})\\)? is not a function\\b`);
30
+ const addRSCClientHookDiagnostic = (error, componentName) => {
31
+ const match = error.message.match(CLIENT_HOOK_RUNTIME_ERROR_REGEX);
32
+ if (!match)
33
+ return error;
34
+ const hookName = match[1];
35
+ const enhancedError = new Error(`[React on Rails Pro] Component "${componentName}" called client hook "${hookName}" while rendering in ` +
36
+ `the React Server Components runtime.\n\n` +
37
+ `Most likely cause: "${componentName}", or a component it imports, uses client-only APIs but is missing ` +
38
+ `the '"use client";' directive.\n\n` +
39
+ `Add '"use client";' as the first statement of the client component file, or move hooks, event handlers, ` +
40
+ `and class components into a separate client component.\n\n` +
41
+ `Note: .client/.server file suffixes only control bundle placement. The '"use client";' directive controls ` +
42
+ `RSC client/server classification.\n\n` +
43
+ `Original error: ${error.message}`);
44
+ enhancedError.name = error.name;
45
+ enhancedError.cause = error;
46
+ const enhancedStackFrames = enhancedError.stack?.split('\n').slice(1).join('\n');
47
+ enhancedError.stack = `${enhancedError.name}: ${enhancedError.message}${enhancedStackFrames ? `\n${enhancedStackFrames}` : ''}\nCaused by: ${error.stack || error.message}`;
48
+ return enhancedError;
49
+ };
10
50
  const streamRenderRSCComponent = (reactRenderingResult, options, streamingTrackers) => {
11
- const { throwJsErrors } = options;
51
+ const { name: componentName, throwJsErrors } = options;
12
52
  const { railsContext } = options;
13
53
  assertRailsContextWithServerStreamingCapabilities(railsContext);
14
- const { reactClientManifestFileName } = railsContext;
54
+ const { reactClientManifestFileName, reactServerClientManifestFileName } = railsContext;
55
+ // Initialize manifest loader and BUILD_ID on first render request.
56
+ // These are per-process constants that don't change between requests.
57
+ setManifestFileNames(reactClientManifestFileName, reactServerClientManifestFileName);
58
+ const rscPayloadParams = railsContext.serverSideRSCPayloadParameters;
59
+ if (rscPayloadParams?.rscBundleHash) {
60
+ setBuildId(rscPayloadParams.rscBundleHash);
61
+ }
15
62
  const renderState = {
16
63
  result: null,
17
64
  hasErrors: false,
@@ -19,23 +66,17 @@ const streamRenderRSCComponent = (reactRenderingResult, options, streamingTracke
19
66
  };
20
67
  const { pipeToTransform, readableStream, emitError } = transformRenderStreamChunksToResultObject(renderState);
21
68
  const reportError = (error) => {
22
- console.error('Error in RSC stream', error);
69
+ const diagnosticError = addRSCClientHookDiagnostic(error, componentName);
70
+ console.error('Error in RSC stream', diagnosticError);
23
71
  if (throwJsErrors) {
24
- emitError(error);
72
+ emitError(diagnosticError);
25
73
  }
26
74
  renderState.hasErrors = true;
27
- renderState.error = error;
75
+ renderState.error = diagnosticError;
76
+ return diagnosticError;
28
77
  };
29
78
  const initializeAndRender = async () => {
30
- if (!serverRendererPromise) {
31
- serverRendererPromise = loadJsonFile(reactClientManifestFileName)
32
- .then((reactClientManifest) => buildServerRenderer(reactClientManifest))
33
- .catch((err) => {
34
- serverRendererPromise = undefined;
35
- throw err;
36
- });
37
- }
38
- const { renderToPipeableStream } = await serverRendererPromise;
79
+ const { renderToPipeableStream } = await getServerRenderer();
39
80
  const rscStream = renderToPipeableStream(await reactRenderingResult, {
40
81
  onError: (err) => {
41
82
  const error = convertToError(err);
@@ -45,8 +86,7 @@ const streamRenderRSCComponent = (reactRenderingResult, options, streamingTracke
45
86
  pipeToTransform(rscStream);
46
87
  };
47
88
  initializeAndRender().catch((e) => {
48
- const error = convertToError(e);
49
- reportError(error);
89
+ const error = reportError(convertToError(e));
50
90
  const errorHtml = handleError({ e: error, name: options.name, serverSide: true });
51
91
  pipeToTransform(errorHtml);
52
92
  });
@@ -15,6 +15,7 @@ import { createFromReadableStream } from 'react-on-rails-rsc/client.browser';
15
15
  import { createRSCPayloadKey, fetch, wrapInNewPromise, extractErrorMessage } from "./utils.js";
16
16
  import sanitizeNonce from 'react-on-rails/@internal/sanitizeNonce';
17
17
  import LengthPrefixedStreamParser from "./parseLengthPrefixedStream.js";
18
+ import { buildRSCStreamDiagnosticError, mergeRSCStreamDiagnosticError, RSC_STREAM_DIAGNOSTIC_ERROR_NAME, } from "./rscDiagnostics.js";
18
19
  /**
19
20
  * Replays a consoleReplayScript by injecting it as a <script> element.
20
21
  */
@@ -39,7 +40,7 @@ const replayConsole = (consoleReplayScript, nonce) => {
39
40
  *
40
41
  * Extracts raw Flight data for React, replays console from metadata.
41
42
  */
42
- const createFromFetch = async (fetchPromise, cspNonce) => {
43
+ const createFromFetch = async (fetchPromise, { componentName, cspNonce, source, }) => {
43
44
  const response = await fetchPromise;
44
45
  const { body } = response;
45
46
  if (!body) {
@@ -47,6 +48,13 @@ const createFromFetch = async (fetchPromise, cspNonce) => {
47
48
  }
48
49
  const nonce = sanitizeNonce(cspNonce);
49
50
  const parser = new LengthPrefixedStreamParser();
51
+ let rscDiagnosticError;
52
+ const reportDiagnosticError = (metadata) => {
53
+ const diagnosticError = buildRSCStreamDiagnosticError(metadata, { componentName, source });
54
+ if (diagnosticError && !rscDiagnosticError) {
55
+ rscDiagnosticError = diagnosticError;
56
+ }
57
+ };
50
58
  const transformedStream = new ReadableStream({
51
59
  async start(controller) {
52
60
  const reader = body.getReader();
@@ -58,6 +66,7 @@ const createFromFetch = async (fetchPromise, cspNonce) => {
58
66
  done = readResult.done;
59
67
  if (readResult.value) {
60
68
  parser.feed(readResult.value, (content, metadata) => {
69
+ reportDiagnosticError(metadata);
61
70
  controller.enqueue(content);
62
71
  const consoleScript = metadata.consoleReplayScript ?? '';
63
72
  if (consoleScript) {
@@ -75,7 +84,14 @@ const createFromFetch = async (fetchPromise, cspNonce) => {
75
84
  },
76
85
  });
77
86
  const renderPromise = createFromReadableStream(transformedStream);
78
- return wrapInNewPromise(renderPromise);
87
+ // `rscDiagnosticError` is set before the matching chunk is enqueued: the parser callback
88
+ // records it first, then enqueues the content in the ReadableStream `start` callback above.
89
+ // React can only read a chunk after it has been enqueued, so by the time `renderPromise`
90
+ // rejects the diagnostic — if the stream carried one — is already set; it is never undefined
91
+ // purely because of timing.
92
+ return wrapInNewPromise(renderPromise).catch((error) => {
93
+ throw mergeRSCStreamDiagnosticError(error, rscDiagnosticError);
94
+ });
79
95
  };
80
96
  /**
81
97
  * Fetches an RSC payload via HTTP request.
@@ -104,9 +120,22 @@ const fetchRSC = ({ componentName, componentProps, railsContext, }) => {
104
120
  const propsString = JSON.stringify(componentProps);
105
121
  const strippedUrlPath = rscPayloadGenerationUrlPath.replace(/^\/|\/$/g, '');
106
122
  const encodedParams = new URLSearchParams({ props: propsString }).toString();
107
- const fetchUrl = `/${strippedUrlPath}/${componentName}?${encodedParams}`;
108
- return createFromFetch(fetch(fetchUrl), railsContext.cspNonce).catch((error) => {
109
- throw new Error(`Failed to fetch RSC payload for component "${componentName}" from "${fetchUrl}": ${extractErrorMessage(error)}`);
123
+ const sourcePath = `/${strippedUrlPath}/${componentName}`;
124
+ const fetchUrl = `${sourcePath}?${encodedParams}`;
125
+ return createFromFetch(fetch(fetchUrl), {
126
+ componentName,
127
+ cspNonce: railsContext.cspNonce,
128
+ // Keep `source` query-string free so serialized props aren't echoed into error messages
129
+ // or attached error-monitoring events. The outer wrapper below retains `fetchUrl` for reproducibility.
130
+ source: sourcePath,
131
+ }).catch((error) => {
132
+ // RSC stream diagnostic errors already carry component/source context — preserve them
133
+ // (including .cause and the merged stack) instead of flattening to a plain Error.
134
+ if (error instanceof Error && error.name === RSC_STREAM_DIAGNOSTIC_ERROR_NAME)
135
+ throw error;
136
+ const wrapper = new Error(`Failed to fetch RSC payload for component "${componentName}" from "${fetchUrl}": ${extractErrorMessage(error)}`);
137
+ wrapper.cause = error;
138
+ throw wrapper;
110
139
  });
111
140
  }
112
141
  catch (error) {
@@ -158,12 +187,22 @@ const createRSCStreamFromArray = (payloads) => {
158
187
  * the payload is already embedded in the page.
159
188
  *
160
189
  * @param payloads - Array of raw Flight data strings from the global array
190
+ * @param componentName - Name of the server component, used for error context
161
191
  * @returns A Promise resolving to the rendered React element
162
192
  */
163
- const createFromPreloadedPayloads = (payloads) => {
193
+ const createFromPreloadedPayloads = (payloads, componentName) => {
164
194
  const stream = createRSCStreamFromArray(payloads);
165
195
  const renderPromise = createFromReadableStream(stream);
166
- return wrapInNewPromise(renderPromise);
196
+ return wrapInNewPromise(renderPromise).catch((error) => {
197
+ // Preloaded payloads carry only raw Flight data — the server consumes the length-prefixed
198
+ // `renderingError` metadata during injection (see injectRSCPayload), so there is no RSC
199
+ // bundle diagnostic to merge here. Still, name the failing component (and preserve the
200
+ // original error as `cause`) so a hydration failure is attributable instead of surfacing a
201
+ // bare React error.
202
+ const wrapper = new Error(`Failed to hydrate preloaded RSC payload for component "${componentName}": ${extractErrorMessage(error)}`);
203
+ wrapper.cause = error;
204
+ throw wrapper;
205
+ });
167
206
  };
168
207
  /**
169
208
  * Creates a function that fetches and renders a server component on the client side.
@@ -202,7 +241,7 @@ const getReactServerComponent = (domNodeId, railsContext) => ({ componentName, c
202
241
  const rscPayloadKey = createRSCPayloadKey(componentName, componentProps, domNodeId);
203
242
  const payloads = window.REACT_ON_RAILS_RSC_PAYLOADS[rscPayloadKey];
204
243
  if (payloads) {
205
- return createFromPreloadedPayloads(payloads);
244
+ return createFromPreloadedPayloads(payloads, componentName);
206
245
  }
207
246
  }
208
247
  return fetchRSC({ componentName, componentProps, railsContext });
@@ -14,8 +14,9 @@
14
14
  import { buildClientRenderer } from 'react-on-rails-rsc/client.node';
15
15
  import transformRSCStream from "./transformRSCNodeStream.js";
16
16
  import loadJsonFile from "./loadJsonFile.js";
17
+ import { mergeRSCStreamDiagnosticError } from "./rscDiagnostics.js";
17
18
  let clientRendererPromise;
18
- const createFromReactOnRailsNodeStream = async (stream, reactServerManifestFileName, reactClientManifestFileName) => {
19
+ const createFromReactOnRailsNodeStream = async (stream, reactServerManifestFileName, reactClientManifestFileName, componentName) => {
19
20
  if (!clientRendererPromise) {
20
21
  clientRendererPromise = Promise.all([
21
22
  loadJsonFile(reactServerManifestFileName),
@@ -28,8 +29,26 @@ const createFromReactOnRailsNodeStream = async (stream, reactServerManifestFileN
28
29
  });
29
30
  }
30
31
  const { createFromNodeStream } = await clientRendererPromise;
31
- const transformedStream = transformRSCStream(stream);
32
- return createFromNodeStream(transformedStream);
32
+ let rscDiagnosticError;
33
+ const transformedStream = transformRSCStream(stream, {
34
+ componentName,
35
+ onDiagnosticError(error) {
36
+ rscDiagnosticError = error;
37
+ },
38
+ });
39
+ // Note: this try/catch enriches any error that rejects `createFromNodeStream` — stream read
40
+ // failures, malformed Flight data, or synchronous render errors. It does NOT catch errors
41
+ // React surfaces through its error-boundary / Suspense mechanism during the deferred render
42
+ // phase (e.g. when a Suspense boundary resolves a lazy element); those never reject this
43
+ // promise. In that case `rscDiagnosticError` (a local that goes out of scope when this
44
+ // function returns) is discarded and the caller sees only the generic React error. Enriching
45
+ // the deferred-render path is tracked in #3475.
46
+ try {
47
+ return await createFromNodeStream(transformedStream);
48
+ }
49
+ catch (error) {
50
+ throw mergeRSCStreamDiagnosticError(error, rscDiagnosticError);
51
+ }
33
52
  };
34
53
  /**
35
54
  * Creates a function that fetches and renders a server component on the server side.
@@ -64,7 +83,7 @@ const createFromReactOnRailsNodeStream = async (stream, reactServerManifestFileN
64
83
  */
65
84
  const getReactServerComponent = (railsContext) => async ({ componentName, componentProps }) => {
66
85
  const rscPayloadStream = await railsContext.getRSCPayloadStream(componentName, componentProps);
67
- return createFromReactOnRailsNodeStream(rscPayloadStream, railsContext.reactServerClientManifestFileName, railsContext.reactClientManifestFileName);
86
+ return createFromReactOnRailsNodeStream(rscPayloadStream, railsContext.reactServerClientManifestFileName, railsContext.reactClientManifestFileName, componentName);
68
87
  };
69
88
  export default getReactServerComponent;
70
89
  //# sourceMappingURL=getReactServerComponent.server.js.map
@@ -0,0 +1,29 @@
1
+ type RSCStreamDiagnosticContext = {
2
+ componentName?: string;
3
+ source?: string;
4
+ };
5
+ export type RSCStreamDiagnosticsOptions = RSCStreamDiagnosticContext & {
6
+ onDiagnosticError?: (error: Error) => void;
7
+ };
8
+ export declare const RSC_STREAM_DIAGNOSTIC_ERROR_NAME = "ReactOnRailsRSCStreamError";
9
+ export declare const MERGED_DIAGNOSTIC_FLAG = "__rorRSCDiagMerged";
10
+ type RSCStreamDiagnosticError = Error & {
11
+ [MERGED_DIAGNOSTIC_FLAG]?: true;
12
+ cause?: unknown;
13
+ };
14
+ export declare const extractModulePathFromStack: (stack?: string) => string | undefined;
15
+ export declare const buildRSCStreamDiagnosticError: (metadata: Record<string, unknown>, context?: RSCStreamDiagnosticContext) => Error | undefined;
16
+ /**
17
+ * Merges a generic React stream error with the original RSC bundle diagnostic.
18
+ *
19
+ * Idempotent on `error`: if `error` is already a merged result (carries `MERGED_DIAGNOSTIC_FLAG`)
20
+ * it is returned untouched, so re-entering this in a chain of catch handlers won't double-wrap.
21
+ *
22
+ * Precondition: `diagnosticError` must be a fresh (non-merged) diagnostic — in practice it always
23
+ * comes from `buildRSCStreamDiagnosticError`, which never sets the flag. The idempotency guard
24
+ * intentionally only inspects `error`; passing an already-merged error as `diagnosticError` would
25
+ * duplicate context.
26
+ */
27
+ export declare const mergeRSCStreamDiagnosticError: (error: unknown, diagnosticError?: Error) => RSCStreamDiagnosticError;
28
+ export {};
29
+ //# sourceMappingURL=rscDiagnostics.d.ts.map
@@ -0,0 +1,130 @@
1
+ /*
2
+ * Copyright (c) 2025 Shakacode LLC
3
+ *
4
+ * This file is NOT licensed under the MIT (open source) license.
5
+ * It is part of the React on Rails Pro offering and is licensed separately.
6
+ *
7
+ * Unauthorized copying, modification, distribution, or use of this file,
8
+ * via any medium, is strictly prohibited without a valid license agreement
9
+ * from Shakacode LLC.
10
+ *
11
+ * For licensing terms, please see:
12
+ * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md
13
+ */
14
+ import { extractErrorMessage } from "./utils.js";
15
+ export const RSC_STREAM_DIAGNOSTIC_ERROR_NAME = 'ReactOnRailsRSCStreamError';
16
+ // Exported so tests reference the marker by constant rather than a duplicated string literal.
17
+ export const MERGED_DIAGNOSTIC_FLAG = '__rorRSCDiagMerged';
18
+ const nonEmptyString = (value) => {
19
+ if (typeof value !== 'string')
20
+ return undefined;
21
+ // Trim so a whitespace-only `renderingError.message`/`stack` (e.g. `" "` or `"\n"`) is
22
+ // treated as absent rather than surfacing as `Original error:` with blank text.
23
+ const trimmed = value.trim();
24
+ return trimmed.length > 0 ? trimmed : undefined;
25
+ };
26
+ // Bundler/framework frames point at library code rather than the failing component, so they
27
+ // are a poor value for `Module:`. Skip them when a later user-code frame is available.
28
+ // Note: this is matched against the extracted file *path*, not the raw frame, so a webpack
29
+ // runtime frame is caught by its `webpack/` or `webpack-internal:` path, not its function name.
30
+ const INTERNAL_FRAME_RE = /(?:^|[\\/])node_modules[\\/]|(?:^|[\\/])webpack[\\/]|\bwebpack-internal:/;
31
+ export const extractModulePathFromStack = (stack) => {
32
+ if (!stack)
33
+ return undefined;
34
+ let firstLocation;
35
+ for (const rawLine of stack.split('\n')) {
36
+ const line = rawLine.trim();
37
+ // Two V8 frame shapes:
38
+ // `at fn (/path/to/file.js:10:5)` -> parenthesized
39
+ // `at /path:10:5` / `at async /path:10:5` -> anonymous/top-level
40
+ // The anonymous form is anchored to an absolute path (POSIX `/` or a Windows drive) and
41
+ // tolerates the optional `async ` keyword, so a bare function name in an unusual
42
+ // `at fn /path:10:5` frame isn't mistaken for the module path.
43
+ const match = /\(([^()]+):\d+:\d+\)$|\bat\s+(?:async\s+)?((?:\/|[A-Za-z]:[\\/]).*?):\d+:\d+$/.exec(line);
44
+ const location = (match?.[1] ?? match?.[2])?.replace(/\?.*$/, '');
45
+ if (location) {
46
+ firstLocation ?? (firstLocation = location);
47
+ if (!INTERNAL_FRAME_RE.test(location))
48
+ return location;
49
+ }
50
+ }
51
+ // Every frame was framework-internal — fall back to the first so `Module:` is still populated.
52
+ return firstLocation;
53
+ };
54
+ export const buildRSCStreamDiagnosticError = (metadata, context = {}) => {
55
+ const raw = metadata.renderingError;
56
+ // `RenderingErrorMetadata` has only optional `unknown` fields, so a plain annotation
57
+ // accepts any object without a type assertion; `nonEmptyString()` validates each field at runtime.
58
+ const re = typeof raw === 'object' && raw !== null ? raw : {};
59
+ const originalMessage = nonEmptyString(re.message);
60
+ const originalStack = nonEmptyString(re.stack);
61
+ // Wire contract: the React on Rails server bundle only emits `renderingError` on actual
62
+ // failure, so presence of a message or stack is treated as a failure signal even when
63
+ // `hasErrors` isn't explicitly set. Belt-and-suspenders intentional — if a future producer
64
+ // wants to use `renderingError` for non-fatal info, this guard needs to change with it.
65
+ if (metadata.hasErrors !== true && !originalMessage && !originalStack)
66
+ return undefined;
67
+ const modulePath = extractModulePathFromStack(originalStack);
68
+ // Without a message, name the actual signal that triggered the diagnostic: `hasErrors=true`
69
+ // when that flag is set, otherwise a stack-only `renderingError` (claiming `hasErrors=true`
70
+ // in the latter case would be inaccurate).
71
+ const fallbackOriginalError = metadata.hasErrors === true
72
+ ? 'RSC stream metadata reported hasErrors=true'
73
+ : 'RSC stream metadata reported a rendering error';
74
+ const message = [
75
+ '[ReactOnRails] RSC bundle rendering failed.',
76
+ context.componentName && `Component: ${context.componentName}`,
77
+ context.source && `Source: ${context.source}`,
78
+ modulePath && `Module: ${modulePath}`,
79
+ `Original error: ${originalMessage ?? fallbackOriginalError}`,
80
+ ]
81
+ .filter((line) => Boolean(line))
82
+ .join('\n');
83
+ const diagnosticError = new Error(message);
84
+ diagnosticError.name = RSC_STREAM_DIAGNOSTIC_ERROR_NAME;
85
+ // Always overwrite the auto-generated stack. With an original stack we surface it; without one
86
+ // we reduce the stack to just the header line, because the V8-generated stack would otherwise
87
+ // point at this diagnostics module and error monitors (Sentry, Honeybadger) would misattribute
88
+ // the error origin to react-on-rails internals.
89
+ diagnosticError.stack = originalStack
90
+ ? `${diagnosticError.name}: ${message}\nOriginal stack:\n${originalStack}`
91
+ : `${diagnosticError.name}: ${message}`;
92
+ return diagnosticError;
93
+ };
94
+ /**
95
+ * Merges a generic React stream error with the original RSC bundle diagnostic.
96
+ *
97
+ * Idempotent on `error`: if `error` is already a merged result (carries `MERGED_DIAGNOSTIC_FLAG`)
98
+ * it is returned untouched, so re-entering this in a chain of catch handlers won't double-wrap.
99
+ *
100
+ * Precondition: `diagnosticError` must be a fresh (non-merged) diagnostic — in practice it always
101
+ * comes from `buildRSCStreamDiagnosticError`, which never sets the flag. The idempotency guard
102
+ * intentionally only inspects `error`; passing an already-merged error as `diagnosticError` would
103
+ * duplicate context.
104
+ */
105
+ export const mergeRSCStreamDiagnosticError = (error, diagnosticError) => {
106
+ const streamError = error instanceof Error ? error : new Error(extractErrorMessage(error));
107
+ if (!diagnosticError || streamError[MERGED_DIAGNOSTIC_FLAG])
108
+ return streamError;
109
+ const message = `${diagnosticError.message}\nReact stream error: ${streamError.message}`;
110
+ const mergedError = new Error(message);
111
+ mergedError.name = RSC_STREAM_DIAGNOSTIC_ERROR_NAME;
112
+ mergedError.cause = streamError;
113
+ // Non-enumerable so error reporters that iterate own keys (Sentry "extra data",
114
+ // structured cloning, etc.) don't pick up this internal marker.
115
+ Object.defineProperty(mergedError, MERGED_DIAGNOSTIC_FLAG, {
116
+ value: true,
117
+ writable: false,
118
+ enumerable: false,
119
+ configurable: false,
120
+ });
121
+ mergedError.stack = [
122
+ `${mergedError.name}: ${message}`,
123
+ diagnosticError.stack && `RSC diagnostic stack:\n${diagnosticError.stack}`,
124
+ streamError.stack && `React stream stack:\n${streamError.stack}`,
125
+ ]
126
+ .filter((line) => Boolean(line))
127
+ .join('\n\n');
128
+ return mergedError;
129
+ };
130
+ //# sourceMappingURL=rscDiagnostics.js.map
@@ -1,3 +1,4 @@
1
+ import { RSCStreamDiagnosticsOptions } from './rscDiagnostics.ts';
1
2
  /**
2
3
  * Transforms an RSC Node.js stream for server-side processing.
3
4
  *
@@ -12,5 +13,5 @@
12
13
  * @param stream - The Node.js RSC payload stream
13
14
  * @returns A transformed stream compatible with React's SSR runtime
14
15
  */
15
- export default function transformRSCStream(stream: NodeJS.ReadableStream): NodeJS.ReadableStream;
16
+ export default function transformRSCStream(stream: NodeJS.ReadableStream, diagnosticsOptions?: RSCStreamDiagnosticsOptions): NodeJS.ReadableStream;
16
17
  //# sourceMappingURL=transformRSCNodeStream.d.ts.map
@@ -14,6 +14,7 @@
14
14
  import { Transform } from 'stream';
15
15
  import safePipe from "./safePipe.js";
16
16
  import LengthPrefixedStreamParser from "./parseLengthPrefixedStream.js";
17
+ import { buildRSCStreamDiagnosticError } from "./rscDiagnostics.js";
17
18
  /**
18
19
  * Transforms an RSC Node.js stream for server-side processing.
19
20
  *
@@ -28,12 +29,21 @@ import LengthPrefixedStreamParser from "./parseLengthPrefixedStream.js";
28
29
  * @param stream - The Node.js RSC payload stream
29
30
  * @returns A transformed stream compatible with React's SSR runtime
30
31
  */
31
- export default function transformRSCStream(stream) {
32
+ export default function transformRSCStream(stream, diagnosticsOptions = {}) {
32
33
  const parser = new LengthPrefixedStreamParser();
34
+ let reportedDiagnosticError = false;
33
35
  const htmlExtractor = new Transform({
34
36
  transform(chunk, _, callback) {
35
37
  try {
36
- parser.feed(chunk, (content) => {
38
+ parser.feed(chunk, (content, metadata) => {
39
+ const diagnosticError = buildRSCStreamDiagnosticError(metadata, diagnosticsOptions);
40
+ // First-wins: report only the earliest diagnostic. A failing RSC stream emits a single
41
+ // renderingError, so this avoids duplicate reports of the same failure. (A later chunk
42
+ // carrying a richer renderingError would be dropped, but that doesn't occur in practice.)
43
+ if (diagnosticError && !reportedDiagnosticError) {
44
+ reportedDiagnosticError = true;
45
+ diagnosticsOptions.onDiagnosticError?.(diagnosticError);
46
+ }
37
47
  this.push(content);
38
48
  });
39
49
  callback();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-on-rails-pro",
3
- "version": "16.7.0-rc.3",
3
+ "version": "17.0.0-rc.0",
4
4
  "description": "React on Rails Pro package with React Server Components support",
5
5
  "main": "lib/ReactOnRails.full.js",
6
6
  "type": "module",
@@ -43,12 +43,16 @@
43
43
  "react-server": "./lib/wrapServerComponentRenderer/server.rsc.js",
44
44
  "default": "./lib/wrapServerComponentRenderer/server.js"
45
45
  },
46
+ "./cache": {
47
+ "react-server": "./lib/cache/index.js",
48
+ "default": "./lib/cache/index.stub.js"
49
+ },
46
50
  "./RSCRoute": "./lib/RSCRoute.js",
47
51
  "./RSCProvider": "./lib/RSCProvider.js",
48
52
  "./ServerComponentFetchError": "./lib/ServerComponentFetchError.js"
49
53
  },
50
54
  "dependencies": {
51
- "react-on-rails": "16.7.0-rc.3"
55
+ "react-on-rails": "17.0.0-rc.0"
52
56
  },
53
57
  "peerDependencies": {
54
58
  "react": ">= 16",