react-on-rails-pro 17.0.0-rc.7 → 17.0.0-rc.9

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.
@@ -24,17 +24,21 @@ type RSCContextType = {
24
24
  * This factory function accepts an environment-specific getServerComponent implementation,
25
25
  * allowing it to work correctly in both client and server environments.
26
26
  *
27
- * @param railsContext - Context for the current request
28
27
  * @param getServerComponent - Environment-specific function for fetching server components
28
+ * @param domNodeId - Optional root id used to locate embedded browser payloads
29
+ * @param retryRejectedPayloads - Whether to perform and retain the bounded rejected-payload retry.
30
+ * Official wrappers pass this explicitly; the environment check is only a compatibility fallback
31
+ * for direct callers of this internal factory.
29
32
  * @returns A provider component that wraps children with RSC context
30
33
  *
31
34
  * @important This is an internal function. End users should not use this directly.
32
35
  * Instead, use wrapServerComponentRenderer from 'react-on-rails/wrapServerComponentRenderer/client'
33
36
  * for client-side rendering or 'react-on-rails/wrapServerComponentRenderer/server' for server-side rendering.
34
37
  */
35
- export declare const createRSCProvider: ({ getServerComponent, domNodeId, }: {
38
+ export declare const createRSCProvider: ({ getServerComponent, domNodeId, retryRejectedPayloads, }: {
36
39
  getServerComponent: (props: ClientGetReactServerComponentProps) => Promise<ReactNode>;
37
40
  domNodeId?: string;
41
+ retryRejectedPayloads?: boolean;
38
42
  }) => ({ children }: {
39
43
  children: ReactNode;
40
44
  }) => import("react/jsx-runtime").JSX.Element;
@@ -15,7 +15,7 @@
15
15
  'use client';
16
16
  import { jsx as _jsx } from "react/jsx-runtime";
17
17
  import { createContext, useCallback, useContext, useMemo, useRef, useState, useTransition, } from 'react';
18
- import { BoundedLRU, RSC_EVICTED_SUCCESS_MARKER_MAX_ENTRIES, RSC_PAYLOAD_CACHE_MAX_ENTRIES, } from "./RSCProviderCache.js";
18
+ import { BoundedLRU, RSC_EVICTED_SUCCESS_MARKER_MAX_ENTRIES, RSC_PAYLOAD_CACHE_MAX_ENTRIES, RSC_PAYLOAD_FAILURE_RETENTION_MS, } from "./RSCProviderCache.js";
19
19
  import { consumePrefetchedServerComponent } from "./RSCPrefetchStore.js";
20
20
  import { createRSCPayloadKey, hasEmbeddedRSCPayload } from "./utils.js";
21
21
  const RSCContext = createContext(undefined);
@@ -32,6 +32,30 @@ const dropVersionStateKey = (prev, key) => {
32
32
  // rejected promises so they funnel through RSCRoute's error boundary the same
33
33
  // way async fetch failures do (#4372).
34
34
  const rejectWithError = (error) => Promise.reject(error instanceof Error ? error : new Error(String(error)));
35
+ const MAX_ERROR_CAUSE_DEPTH = 5;
36
+ const isRetryableRSCPayloadError = (error) => {
37
+ try {
38
+ let current = error;
39
+ for (let depth = 0; depth < MAX_ERROR_CAUSE_DEPTH && current != null; depth += 1) {
40
+ if (typeof current === 'object' &&
41
+ 'name' in current &&
42
+ current.name === 'AbortError') {
43
+ return false;
44
+ }
45
+ const { status } = current;
46
+ if (typeof status === 'number') {
47
+ return status >= 500 || status === 408 || status === 429;
48
+ }
49
+ if (!(current instanceof Error))
50
+ break;
51
+ current = current.cause;
52
+ }
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ return true;
58
+ };
35
59
  /**
36
60
  * Creates a provider context for React Server Components.
37
61
  *
@@ -43,15 +67,18 @@ const rejectWithError = (error) => Promise.reject(error instanceof Error ? error
43
67
  * This factory function accepts an environment-specific getServerComponent implementation,
44
68
  * allowing it to work correctly in both client and server environments.
45
69
  *
46
- * @param railsContext - Context for the current request
47
70
  * @param getServerComponent - Environment-specific function for fetching server components
71
+ * @param domNodeId - Optional root id used to locate embedded browser payloads
72
+ * @param retryRejectedPayloads - Whether to perform and retain the bounded rejected-payload retry.
73
+ * Official wrappers pass this explicitly; the environment check is only a compatibility fallback
74
+ * for direct callers of this internal factory.
48
75
  * @returns A provider component that wraps children with RSC context
49
76
  *
50
77
  * @important This is an internal function. End users should not use this directly.
51
78
  * Instead, use wrapServerComponentRenderer from 'react-on-rails/wrapServerComponentRenderer/client'
52
79
  * for client-side rendering or 'react-on-rails/wrapServerComponentRenderer/server' for server-side rendering.
53
80
  */
54
- export const createRSCProvider = ({ getServerComponent, domNodeId, }) => {
81
+ export const createRSCProvider = ({ getServerComponent, domNodeId, retryRejectedPayloads = typeof window !== 'undefined', }) => {
55
82
  return ({ children }) => {
56
83
  // Companion bookkeeping keyed by the same RSC payload key as the promise
57
84
  // cache. These are dropped in lockstep when a key is evicted from the LRU so
@@ -228,12 +255,14 @@ export const createRSCProvider = ({ getServerComponent, domNodeId, }) => {
228
255
  if (cached !== undefined) {
229
256
  return cached;
230
257
  }
231
- // Pin the key for the duration of the initial in-flight load so that
232
- // a burst of 50+ other keys loading before this one settles cannot
233
- // evict it. Without this pin, `markSuccessfulPromise`'s `peek` guard
258
+ // Pin the key for the logical load. Browser failures keep this pin
259
+ // through their retention window so LRU pressure cannot reopen the request
260
+ // budget before React consumes the rejection. Without the initial pin,
261
+ // `markSuccessfulPromise`'s `peek` guard
234
262
  // would see a stale (evicted) entry and skip recording last-successful,
235
263
  // leaving `recoverOnError` with nothing to restore after a failed
236
- // refetch. Unpinned in `.finally()` once the initial fetch settles.
264
+ // refetch. Successful loads release the pin after settling; terminal
265
+ // browser failures retain it through the window below.
237
266
  //
238
267
  // `setPinned` inserts and pins atomically (pin registered before
239
268
  // eviction): when 51+ initial loads start before any settle, a plain
@@ -278,67 +307,62 @@ export const createRSCProvider = ({ getServerComponent, domNodeId, }) => {
278
307
  }
279
308
  return payload;
280
309
  };
281
- // When the underlying fetch REJECTS (as opposed to resolving with an
282
- // `Error` value), the single-argument `.then` below would leave the
283
- // rejected promise cached under `key`, so every later same-key
284
- // `getComponent` returns that same rejection and a transient
285
- // renderer/network/deploy failure stays wedged until an explicit
286
- // refetch or reload (#3929). Evict the rejected entry so the next
287
- // same-key `getComponent` starts a fresh fetch.
288
- //
289
- // Deferred one macrotask (`setTimeout(0)`, not `queueMicrotask`) so
290
- // React's Suspense machinery observes the rejection on the cached
291
- // promise before the entry is removed; a microtask could interleave
292
- // with React's own queue and evict before Suspense reads it. Guarded on
293
- // promise identity so a newer same-key promise (a fresh `getComponent`
294
- // or a `refetchComponent`) installed during that window is not evicted
295
- // by this stale failure. Pins are preserved because the `.finally`
296
- // below still owns the matching `unpin` (mirrors the delete +
297
- // deferred-unpin handoff in `restoreLastSuccessfulPromise`).
298
- //
299
- // NOTE: payloads that RESOLVE with an `Error` value are intentionally
300
- // left cached here; whether those should also retry is a separate
301
- // `getServerComponent` contract decision tracked apart from #3929.
302
- const evictPromiseIfRejected = (error) => {
303
- setTimeout(() => {
304
- if (fetchRSCPromises.get(key, false) === promise) {
305
- fetchRSCPromises.deletePreservingPins(key);
306
- }
307
- }, 0);
308
- throw error;
310
+ const fetchPayload = (enforceRefetch = false) => {
311
+ try {
312
+ return getServerComponent(enforceRefetch
313
+ ? { componentName, componentProps, enforceRefetch: true }
314
+ : { componentName, componentProps });
315
+ }
316
+ catch (error) {
317
+ return rejectWithError(error);
318
+ }
309
319
  };
310
- let serverComponentPromise;
320
+ let firstAttempt;
311
321
  const preferEmbeddedPayload = hasEmbeddedRSCPayload(componentName, componentProps, domNodeId);
312
322
  const prefetchedServerComponentPromise = preferEmbeddedPayload
313
323
  ? undefined
314
324
  : consumePrefetchedServerComponent(key, providerCacheIdentityRef.current);
315
325
  if (prefetchedServerComponentPromise) {
316
- serverComponentPromise = prefetchedServerComponentPromise;
326
+ firstAttempt = prefetchedServerComponentPromise;
317
327
  }
318
328
  else {
319
- try {
320
- serverComponentPromise = getServerComponent({ componentName, componentProps });
329
+ firstAttempt = fetchPayload();
330
+ }
331
+ let terminalFailureRetained = false;
332
+ // React sees one cached promise for the entire logical load. A browser
333
+ // retry happens inside that promise instead of depending on a retry
334
+ // render to create another request.
335
+ promise = firstAttempt
336
+ .catch((error) => {
337
+ if (fetchRSCPromises.get(key, false) !== promise ||
338
+ !retryRejectedPayloads ||
339
+ !isRetryableRSCPayloadError(error)) {
340
+ throw error;
321
341
  }
322
- catch (error) {
323
- // A synchronous producer throw behaves exactly like an immediately
324
- // rejected fetch: the rejection flows through the standard
325
- // `evictPromiseIfRejected` + `.finally()` bookkeeping below, which
326
- // settles the evicted-success latch and evicts the cached rejection.
327
- serverComponentPromise = rejectWithError(error);
342
+ return fetchPayload(true);
343
+ })
344
+ .then(markPayloadIfSuccessful, (error) => {
345
+ if (retryRejectedPayloads && fetchRSCPromises.get(key, false) === promise) {
346
+ terminalFailureRetained = true;
328
347
  }
329
- }
330
- promise = serverComponentPromise.then(markPayloadIfSuccessful, evictPromiseIfRejected).finally(() => {
348
+ throw error;
349
+ })
350
+ .finally(() => {
331
351
  if (notifyRoutesOnSuccess && !payloadSucceeded && releaseInFlightEvictedSuccessLatch()) {
332
352
  evictedSuccessfulPayloadKeys.set(key, true);
333
353
  }
334
- // Defer the initial-load pin release so a route whose promise just
335
- // resolved can commit and install its mounted retain before over-cap
336
- // reconciliation runs. Without this handoff, 51+ simultaneously
337
- // resolving mounted routes can evict early visible keys before their
338
- // layout effects get to pin them as mounted.
354
+ // Successful and stale loads release their initial pin after the
355
+ // route can install its mounted retain. A terminal browser failure
356
+ // keeps the same rejected promise pinned until its retention ends.
339
357
  setTimeout(() => {
358
+ if (terminalFailureRetained && fetchRSCPromises.get(key, false) === promise) {
359
+ fetchRSCPromises.deletePreservingPins(key);
360
+ fetchRSCPromises.unpinWithoutEvict(key);
361
+ scheduleAbsentKeyVersionCleanup(key);
362
+ return;
363
+ }
340
364
  fetchRSCPromises.unpin(key);
341
- }, 0);
365
+ }, terminalFailureRetained ? RSC_PAYLOAD_FAILURE_RETENTION_MS : 0);
342
366
  });
343
367
  fetchRSCPromises.setPinned(key, promise);
344
368
  if (notifyRoutesOnSuccess) {
@@ -352,6 +376,7 @@ export const createRSCProvider = ({ getServerComponent, domNodeId, }) => {
352
376
  fetchRSCPromises,
353
377
  inFlightEvictedSuccessfulPayloadCounts,
354
378
  markSuccessfulPromise,
379
+ scheduleAbsentKeyVersionCleanup,
355
380
  ]);
356
381
  const refetchComponent = useCallback((componentName, componentProps, recoverOnError) => {
357
382
  let key;
@@ -6,8 +6,14 @@
6
6
  * along with all of its companion bookkeeping (last-successful promise and
7
7
  * refetch version). The common case — a small, stable set of routes — never
8
8
  * hits the cap, so cache hits, refetch, and recoverOnError restore are
9
- * unaffected. This cap is intentionally not configurable through
10
- * `createRSCProvider` today. See
9
+ * unaffected.
10
+ *
11
+ * The cap is soft while entries are pinned. Besides in-flight and mounted
12
+ * payloads, a terminal browser rejection stays pinned for its short retention
13
+ * window so LRU pressure cannot erase its retry limit and reopen a request
14
+ * loop. A high-cardinality outage can therefore keep more than 50 failures
15
+ * briefly; each is removed and unpinned when that window ends. This cap is
16
+ * intentionally not configurable through `createRSCProvider` today. See
11
17
  * https://github.com/shakacode/react_on_rails/issues/3564.
12
18
  *
13
19
  * NOTE: the per-key `useSyncExternalStore` subscription/fan-out optimization
@@ -18,6 +24,8 @@
18
24
  * need to tune the cap for unusually high-cardinality RSC routes.
19
25
  */
20
26
  export declare const RSC_PAYLOAD_CACHE_MAX_ENTRIES = 50;
27
+ /** How long a terminal browser rejection remains protected from render-driven reloads. */
28
+ export declare const RSC_PAYLOAD_FAILURE_RETENTION_MS = 5000;
21
29
  /**
22
30
  * Evicted-success markers need a wider window than the primary payload cache:
23
31
  * primary-cache churn can evict many successful keys before a mounted route asks
@@ -50,13 +58,17 @@ export declare const RSC_EVICTED_SUCCESS_MARKER_MAX_ENTRIES: number;
50
58
  * 3. Mounted `<RSCRoute>` entries retain their payload keys so provider-wide
51
59
  * refreshes do not make visible routes miss and refetch just because the
52
60
  * provider cache contains more mounted routes than the soft cap.
61
+ * 4. Terminal browser failures retain their rejected promise briefly so cache
62
+ * pressure cannot give the same failing key a fresh request budget before
63
+ * React consumes the rejection.
53
64
  *
54
65
  * Inserting an in-flight promise uses `setPinned`, which pins BEFORE running
55
66
  * eviction so the just-inserted key can never be chosen as the eviction victim
56
67
  * even when every other key is already pinned (a pure `set` then `pin` would
57
68
  * let `evictIfNeeded` drop the new unpinned key first). When every key is
58
- * pinned the map is allowed to temporarily exceed `maxEntries`; the matching
59
- * `unpin` reconciles it.
69
+ * pinned the map is allowed to temporarily exceed `maxEntries`; matching
70
+ * releases reconcile it, while terminal failures remove themselves at the end
71
+ * of their retention window.
60
72
  *
61
73
  * No external LRU dependency exists for this synchronous provider cache (the
62
74
  * repo's `InMemoryLRUCacheHandler` is an async `CacheHandler` tied to
@@ -96,6 +108,8 @@ export declare class BoundedLRU<V> {
96
108
  deleteWithoutEvict(key: string, preservePins?: boolean): void;
97
109
  deletePreservingPins(key: string): void;
98
110
  unpin(key: string): void;
111
+ unpinWithoutEvict(key: string): void;
112
+ private releasePin;
99
113
  /**
100
114
  * Evict least-recently-used unpinned keys until the map is back within
101
115
  * `maxEntries`. `preventEvictingKey`, when given, is treated as un-evictable
@@ -20,8 +20,14 @@
20
20
  * along with all of its companion bookkeeping (last-successful promise and
21
21
  * refetch version). The common case — a small, stable set of routes — never
22
22
  * hits the cap, so cache hits, refetch, and recoverOnError restore are
23
- * unaffected. This cap is intentionally not configurable through
24
- * `createRSCProvider` today. See
23
+ * unaffected.
24
+ *
25
+ * The cap is soft while entries are pinned. Besides in-flight and mounted
26
+ * payloads, a terminal browser rejection stays pinned for its short retention
27
+ * window so LRU pressure cannot erase its retry limit and reopen a request
28
+ * loop. A high-cardinality outage can therefore keep more than 50 failures
29
+ * briefly; each is removed and unpinned when that window ends. This cap is
30
+ * intentionally not configurable through `createRSCProvider` today. See
25
31
  * https://github.com/shakacode/react_on_rails/issues/3564.
26
32
  *
27
33
  * NOTE: the per-key `useSyncExternalStore` subscription/fan-out optimization
@@ -32,6 +38,8 @@
32
38
  * need to tune the cap for unusually high-cardinality RSC routes.
33
39
  */
34
40
  export const RSC_PAYLOAD_CACHE_MAX_ENTRIES = 50;
41
+ /** How long a terminal browser rejection remains protected from render-driven reloads. */
42
+ export const RSC_PAYLOAD_FAILURE_RETENTION_MS = 5000;
35
43
  /**
36
44
  * Evicted-success markers need a wider window than the primary payload cache:
37
45
  * primary-cache churn can evict many successful keys before a mounted route asks
@@ -64,13 +72,17 @@ export const RSC_EVICTED_SUCCESS_MARKER_MAX_ENTRIES = RSC_PAYLOAD_CACHE_MAX_ENTR
64
72
  * 3. Mounted `<RSCRoute>` entries retain their payload keys so provider-wide
65
73
  * refreshes do not make visible routes miss and refetch just because the
66
74
  * provider cache contains more mounted routes than the soft cap.
75
+ * 4. Terminal browser failures retain their rejected promise briefly so cache
76
+ * pressure cannot give the same failing key a fresh request budget before
77
+ * React consumes the rejection.
67
78
  *
68
79
  * Inserting an in-flight promise uses `setPinned`, which pins BEFORE running
69
80
  * eviction so the just-inserted key can never be chosen as the eviction victim
70
81
  * even when every other key is already pinned (a pure `set` then `pin` would
71
82
  * let `evictIfNeeded` drop the new unpinned key first). When every key is
72
- * pinned the map is allowed to temporarily exceed `maxEntries`; the matching
73
- * `unpin` reconciles it.
83
+ * pinned the map is allowed to temporarily exceed `maxEntries`; matching
84
+ * releases reconcile it, while terminal failures remove themselves at the end
85
+ * of their retention window.
74
86
  *
75
87
  * No external LRU dependency exists for this synchronous provider cache (the
76
88
  * repo's `InMemoryLRUCacheHandler` is an async `CacheHandler` tied to
@@ -160,6 +172,12 @@ export class BoundedLRU {
160
172
  this.deleteWithoutEvict(key, true);
161
173
  }
162
174
  unpin(key) {
175
+ this.releasePin(key, true);
176
+ }
177
+ unpinWithoutEvict(key) {
178
+ this.releasePin(key, false);
179
+ }
180
+ releasePin(key, reconcileEviction) {
163
181
  this.assertNotEvicting('unpin');
164
182
  const count = this.pins.get(key);
165
183
  if (count === undefined) {
@@ -170,6 +188,9 @@ export class BoundedLRU {
170
188
  return;
171
189
  }
172
190
  this.pins.delete(key);
191
+ if (!reconcileEviction) {
192
+ return;
193
+ }
173
194
  // The in-flight operation that held this pin just settled, so the key is
174
195
  // now the most-recently-used. `get` promotes only when the key is still
175
196
  // present; a key already deleted, e.g. by `restoreLastSuccessfulPromise`,
@@ -1,5 +1,7 @@
1
- import { buildClientRenderer } from 'react-on-rails-rsc/client.node';
1
+ import type { buildClientRenderer as buildClientRendererType } from 'react-on-rails-rsc/client.node';
2
+ type ClientRenderer = ReturnType<typeof buildClientRendererType>;
2
3
  export declare function setManifestFileNames(clientManifest: string, serverClientManifest: string): void;
3
4
  export declare function getClientManifestFileName(): string | undefined;
4
- export declare function getClientRenderer(): Promise<ReturnType<typeof buildClientRenderer>>;
5
+ export declare function getClientRenderer(): Promise<ClientRenderer>;
6
+ export {};
5
7
  //# sourceMappingURL=manifestLoader.d.ts.map
@@ -12,7 +12,6 @@
12
12
  * For licensing terms:
13
13
  * https://github.com/shakacode/react_on_rails/blob/main/REACT-ON-RAILS-PRO-LICENSE.md
14
14
  */
15
- import { buildClientRenderer } from 'react-on-rails-rsc/client.node';
16
15
  import loadJsonFile from "../loadJsonFile.js";
17
16
  let clientManifestFileName;
18
17
  let serverClientManifestFileName;
@@ -35,8 +34,9 @@ export function getClientRenderer() {
35
34
  clientRendererPromise = Promise.all([
36
35
  loadJsonFile(serverFile),
37
36
  loadJsonFile(clientFile),
37
+ import('react-on-rails-rsc/client.node'),
38
38
  ])
39
- .then(([reactServerManifest, reactClientManifest]) => buildClientRenderer(reactClientManifest, reactServerManifest))
39
+ .then(([reactServerManifest, reactClientManifest, { buildClientRenderer }]) => buildClientRenderer(reactClientManifest, reactServerManifest))
40
40
  .catch((err) => {
41
41
  clientRendererPromise = undefined;
42
42
  throw err;
@@ -1,3 +1,8 @@
1
- import { buildServerRenderer } from 'react-on-rails-rsc/server.node';
2
- export declare function getServerRenderer(): Promise<ReturnType<typeof buildServerRenderer>>;
1
+ import type { BundleManifest } from 'react-on-rails-rsc';
2
+ import type { buildServerRenderer as buildServerRendererType } from 'react-on-rails-rsc/server.node';
3
+ type ServerRenderer = ReturnType<typeof buildServerRendererType>;
4
+ export declare function collectRSCClientManifestStylesheetHrefs(reactClientManifest: BundleManifest): ReadonlySet<string>;
5
+ export declare function getServerRenderer(): Promise<ServerRenderer>;
6
+ export declare function getRSCClientManifestStylesheetHrefs(clientManifest: string): Promise<ReadonlySet<string>>;
7
+ export {};
3
8
  //# sourceMappingURL=manifestLoaderServer.d.ts.map
@@ -12,20 +12,51 @@
12
12
  * For licensing terms:
13
13
  * https://github.com/shakacode/react_on_rails/blob/main/REACT-ON-RAILS-PRO-LICENSE.md
14
14
  */
15
- import { buildServerRenderer } from 'react-on-rails-rsc/server.node';
16
15
  import loadJsonFile from "../loadJsonFile.js";
17
16
  import { getClientManifestFileName } from "./manifestLoader.js";
17
+ const reactClientManifestPromises = new Map();
18
18
  let serverRendererPromise;
19
- // eslint-disable-next-line import/prefer-default-export -- named export for consistency with manifestLoader
19
+ const rscClientManifestStylesheetHrefsPromises = new Map();
20
+ function normalizeStylesheetHref(href) {
21
+ try {
22
+ return new URL(href, 'http://react-on-rails.local').pathname;
23
+ }
24
+ catch {
25
+ return href.split(/[?#]/, 1)[0];
26
+ }
27
+ }
28
+ export function collectRSCClientManifestStylesheetHrefs(reactClientManifest) {
29
+ const stylesheetHrefs = new Set();
30
+ Object.values(reactClientManifest.filePathToModuleMetadata).forEach(({ css = [] }) => {
31
+ css.forEach((href) => stylesheetHrefs.add(normalizeStylesheetHref(href)));
32
+ });
33
+ return stylesheetHrefs;
34
+ }
35
+ function requireClientManifestFileName() {
36
+ const clientManifest = getClientManifestFileName();
37
+ if (!clientManifest) {
38
+ throw new Error('Manifest file names not set. Ensure setManifestFileNames() is called before getServerRenderer(). ' +
39
+ 'This is done automatically during the first RSC render request.');
40
+ }
41
+ return clientManifest;
42
+ }
43
+ function getReactClientManifest(clientManifest = requireClientManifestFileName()) {
44
+ const cachedPromise = reactClientManifestPromises.get(clientManifest);
45
+ if (cachedPromise)
46
+ return cachedPromise;
47
+ const promise = loadJsonFile(clientManifest);
48
+ reactClientManifestPromises.set(clientManifest, promise);
49
+ void promise.catch(() => {
50
+ if (reactClientManifestPromises.get(clientManifest) === promise) {
51
+ reactClientManifestPromises.delete(clientManifest);
52
+ }
53
+ });
54
+ return promise;
55
+ }
20
56
  export function getServerRenderer() {
21
57
  if (!serverRendererPromise) {
22
- const clientManifest = getClientManifestFileName();
23
- if (!clientManifest) {
24
- throw new Error('Manifest file names not set. Ensure setManifestFileNames() is called before getServerRenderer(). ' +
25
- 'This is done automatically during the first RSC render request.');
26
- }
27
- serverRendererPromise = loadJsonFile(clientManifest)
28
- .then((reactClientManifest) => buildServerRenderer(reactClientManifest))
58
+ serverRendererPromise = Promise.all([getReactClientManifest(), import('react-on-rails-rsc/server.node')])
59
+ .then(([reactClientManifest, { buildServerRenderer }]) => buildServerRenderer(reactClientManifest))
29
60
  .catch((err) => {
30
61
  serverRendererPromise = undefined;
31
62
  throw err;
@@ -33,4 +64,17 @@ export function getServerRenderer() {
33
64
  }
34
65
  return serverRendererPromise;
35
66
  }
67
+ export function getRSCClientManifestStylesheetHrefs(clientManifest) {
68
+ const cachedPromise = rscClientManifestStylesheetHrefsPromises.get(clientManifest);
69
+ if (cachedPromise)
70
+ return cachedPromise;
71
+ const promise = getReactClientManifest(clientManifest).then(collectRSCClientManifestStylesheetHrefs);
72
+ rscClientManifestStylesheetHrefsPromises.set(clientManifest, promise);
73
+ void promise.catch(() => {
74
+ if (rscClientManifestStylesheetHrefsPromises.get(clientManifest) === promise) {
75
+ rscClientManifestStylesheetHrefsPromises.delete(clientManifest);
76
+ }
77
+ });
78
+ return promise;
79
+ }
36
80
  //# sourceMappingURL=manifestLoaderServer.js.map
@@ -36,6 +36,7 @@ const replayConsole = (consoleReplayScript, nonce) => {
36
36
  document.body.appendChild(el);
37
37
  }
38
38
  };
39
+ const buildRSCPayloadHttpError = (message, status) => Object.assign(new Error(message), { name: 'RSCPayloadHttpError', status });
39
40
  /**
40
41
  * Parses a length-prefixed RSC fetch response stream.
41
42
  *
@@ -49,7 +50,7 @@ const createFromFetch = async (fetchPromise, { componentName, cspNonce, replayCo
49
50
  const statusDescription = response.statusText
50
51
  ? `${response.status} ${response.statusText}`
51
52
  : `${response.status}`;
52
- throw new Error(`RSC payload request for component "${componentName}" from ${sourceDescription} failed with HTTP ${statusDescription}.`);
53
+ throw buildRSCPayloadHttpError(`RSC payload request for component "${componentName}" from ${sourceDescription} failed with HTTP ${statusDescription}.`, response.status);
53
54
  }
54
55
  const { body } = response;
55
56
  if (!body) {
@@ -4,6 +4,7 @@ import RSCRequestTracker from './RSCRequestTracker.ts';
4
4
  type RSCClientChunkStylesheetHrefsByChunkName = Map<string, string[]>;
5
5
  export declare function resolveLoadableStatsModuleDirectory(commonJsModuleDirectory: string | undefined, stack?: unknown): string;
6
6
  type InjectRSCPayloadOptions = {
7
+ rscClientManifestStylesheetHrefs?: ReadonlySet<string>;
7
8
  rscClientChunkStylesheetHrefsByChunkName?: RSCClientChunkStylesheetHrefsByChunkName;
8
9
  rscStreamObservability?: boolean;
9
10
  };
@@ -146,17 +146,23 @@ function getQuotedAttribute(tag, attributeName) {
146
146
  const attributeMatch = tag.match(new RegExp(`\\s${escapeRegExpLiteral(attributeName)}=(["'])(.*?)\\1`, 'i'));
147
147
  return attributeMatch?.[2];
148
148
  }
149
- function isRSCClientChunkStylesheetHref(href) {
149
+ function normalizeStylesheetHref(href) {
150
150
  try {
151
- return RSC_CLIENT_CHUNK_STYLESHEET_PATH.test(new URL(href, 'http://react-on-rails.local').pathname);
151
+ return new URL(href, 'http://react-on-rails.local').pathname;
152
152
  }
153
153
  catch {
154
- return RSC_CLIENT_CHUNK_STYLESHEET_PATH.test(href.split(/[?#]/, 1)[0]);
154
+ return href.split(/[?#]/, 1)[0];
155
155
  }
156
156
  }
157
- function shouldPromoteStylesheetPreloadTag(linkTag) {
157
+ function isRSCClientChunkStylesheetHref(href) {
158
+ return RSC_CLIENT_CHUNK_STYLESHEET_PATH.test(normalizeStylesheetHref(href));
159
+ }
160
+ function shouldPromoteStylesheetPreloadTag(linkTag, rscClientManifestStylesheetHrefs) {
158
161
  const href = getQuotedAttribute(linkTag, 'href');
159
- return href ? isRSCClientChunkStylesheetHref(href) : false;
162
+ return href
163
+ ? rscClientManifestStylesheetHrefs.has(normalizeStylesheetHref(href)) ||
164
+ isRSCClientChunkStylesheetHref(href)
165
+ : false;
160
166
  }
161
167
  function assetHref(assetPath, publicPath) {
162
168
  if (/^(?:[a-z][a-z\d+.-]*:)?\/\//i.test(assetPath) || assetPath.startsWith('/')) {
@@ -1167,7 +1173,7 @@ function findIncompleteUTF8TailStartIndex(buffer) {
1167
1173
  }
1168
1174
  return tailStart;
1169
1175
  }
1170
- function applyStreamedStylesheetPreloadGating(html, incompleteHtmlTailMode, retainedTailScanState) {
1176
+ function applyStreamedStylesheetPreloadGating(html, incompleteHtmlTailMode, rscClientManifestStylesheetHrefs, retainedTailScanState) {
1171
1177
  let stringSafeHtmlBuffer = html;
1172
1178
  let incompleteUTF8TailBuffer = Buffer.alloc(0);
1173
1179
  if (incompleteHtmlTailMode !== 'none') {
@@ -1202,7 +1208,9 @@ function applyStreamedStylesheetPreloadGating(html, incompleteHtmlTailMode, reta
1202
1208
  } = splitTrailingIncompleteHtmlTagTail(completeHtml));
1203
1209
  }
1204
1210
  }
1205
- const gatedHtml = completeHtml.replace(/<link\b(?=[^>]*\brel=(["'])(?:(?!\1).)*\bpreload\b(?:(?!\1).)*\1)(?=[^>]*\bas=(["'])style\2)(?=[^>]*\bhref=(["'])(?:(?!\3).)+\3)[^>]*\/?>/gi, (linkTag) => shouldPromoteStylesheetPreloadTag(linkTag) ? promoteStylesheetPreloadTag(linkTag) : linkTag);
1211
+ const gatedHtml = completeHtml.replace(/<link\b(?=[^>]*\brel=(["'])(?:(?!\1).)*\bpreload\b(?:(?!\1).)*\1)(?=[^>]*\bas=(["'])style\2)(?=[^>]*\bhref=(["'])(?:(?!\3).)+\3)[^>]*\/?>/gi, (linkTag) => shouldPromoteStylesheetPreloadTag(linkTag, rscClientManifestStylesheetHrefs)
1212
+ ? promoteStylesheetPreloadTag(linkTag)
1213
+ : linkTag);
1206
1214
  const completeHtmlByteLength = Buffer.byteLength(completeHtml, 'utf8');
1207
1215
  const completeHtmlBuffer = completeHtmlByteLength === html.length ? html : stringSafeHtmlBuffer.subarray(0, completeHtmlByteLength);
1208
1216
  const incompleteHtmlTailBuffer = Buffer.concat([
@@ -1242,7 +1250,7 @@ function applyStreamedStylesheetPreloadGating(html, incompleteHtmlTailMode, reta
1242
1250
  * @returns A combined stream with embedded RSC payloads
1243
1251
  */
1244
1252
  export default function injectRSCPayload(pipeableHtmlStream, rscRequestTracker, domNodeId, cspNonce, options = {}) {
1245
- const { rscClientChunkStylesheetHrefsByChunkName = loadRSCClientChunkStylesheetHrefsByChunkName(), rscStreamObservability = false, } = options;
1253
+ const { rscClientManifestStylesheetHrefs = new Set(), rscClientChunkStylesheetHrefsByChunkName = loadRSCClientChunkStylesheetHrefsByChunkName(), rscStreamObservability = false, } = options;
1246
1254
  const sanitizedNonce = sanitizeNonce(cspNonce);
1247
1255
  const htmlStream = new PassThrough();
1248
1256
  const resultStream = new PassThrough();
@@ -1320,7 +1328,7 @@ export default function injectRSCPayload(pipeableHtmlStream, rscRequestTracker,
1320
1328
  // Calculate total buffer size for efficient memory allocation
1321
1329
  const rscInitializationSize = rscInitializationBuffers.reduce((sum, buf) => sum + buf.length, 0);
1322
1330
  const htmlBuffer = Buffer.concat(htmlBuffers);
1323
- const { gatedHtmlBuffer, incompleteHtmlTailBuffer, incompleteHtmlTailScanState: nextRetainedHtmlTailScanState, } = applyStreamedStylesheetPreloadGating(htmlBuffer, incompleteHtmlTailMode, retainedHtmlTailScanState);
1331
+ const { gatedHtmlBuffer, incompleteHtmlTailBuffer, incompleteHtmlTailScanState: nextRetainedHtmlTailScanState, } = applyStreamedStylesheetPreloadGating(htmlBuffer, incompleteHtmlTailMode, rscClientManifestStylesheetHrefs, retainedHtmlTailScanState);
1324
1332
  const shouldDeferRevealHtml = rscPromise &&
1325
1333
  shouldInferRSCClientStylesheets &&
1326
1334
  pendingRSCClientStylesheetInferenceStreams > 0 &&
@@ -20,6 +20,7 @@ if (typeof window !== 'undefined') {
20
20
  setDefaultRSCProviderFactory(({ reactElement, railsContext, domNodeId }) => {
21
21
  const RSCProvider = createRSCProvider({
22
22
  domNodeId,
23
+ retryRejectedPayloads: true,
23
24
  getServerComponent: async (args) => {
24
25
  // Keep the RSC browser runtime visible to the RSC Webpack plugin while still loading it lazily.
25
26
  await import('react-on-rails-rsc/client.browser');
@@ -18,6 +18,7 @@ import captureReactOwnerStack from 'react-on-rails/captureReactOwnerStack';
18
18
  import { convertToError } from 'react-on-rails/serverRenderUtils';
19
19
  import { assertRailsContextWithServerStreamingCapabilities, } from 'react-on-rails/types';
20
20
  import injectRSCPayload from "./injectRSCPayload.js";
21
+ import { getRSCClientManifestStylesheetHrefs } from "./cache/manifestLoaderServer.js";
21
22
  import { isRSCRouteSSRFalseBailoutError } from "./RSCRouteSSRFalseBailoutError.js";
22
23
  import { streamServerRenderedComponent, transformRenderStreamChunksToResultObject, } from "./streamingUtils.js";
23
24
  import handleError from "./handleError.js";
@@ -115,6 +116,12 @@ const streamRenderReactComponent = (reactRenderingResult, options, streamingTrac
115
116
  return ownerStack ? `${error.stack}${OWNER_STACK_MARKER}${ownerStack}` : undefined;
116
117
  };
117
118
  assertRailsContextWithServerStreamingCapabilities(railsContext);
119
+ const { reactClientManifestFileName } = railsContext;
120
+ // Manifest-backed promotion is additive. If a build does not ship the manifest,
121
+ // preserve the existing filename-regex fallback in injectRSCPayload.
122
+ const rscClientManifestStylesheetHrefsPromise = Promise.resolve()
123
+ .then(() => getRSCClientManifestStylesheetHrefs(reactClientManifestFileName))
124
+ .catch(() => new Set());
118
125
  Promise.resolve(reactRenderingResult)
119
126
  .then((reactRenderedElement) => {
120
127
  if (typeof reactRenderedElement === 'string') {
@@ -160,7 +167,14 @@ const streamRenderReactComponent = (reactRenderingResult, options, streamingTrac
160
167
  },
161
168
  onShellReady() {
162
169
  renderState.isShellReady = true;
163
- pipeToTransform(injectRSCPayload(renderingStream, streamingTrackers.rscRequestTracker, domNodeId, railsContext.cspNonce, { rscStreamObservability: railsContext.rscStreamObservability }));
170
+ void rscClientManifestStylesheetHrefsPromise.then((rscClientManifestStylesheetHrefs) => {
171
+ if (isConsumerAborted())
172
+ return;
173
+ pipeToTransform(injectRSCPayload(renderingStream, streamingTrackers.rscRequestTracker, domNodeId, railsContext.cspNonce, {
174
+ rscClientManifestStylesheetHrefs,
175
+ rscStreamObservability: railsContext.rscStreamObservability,
176
+ }));
177
+ });
164
178
  },
165
179
  onError(e) {
166
180
  const error = convertToError(e);
@@ -94,6 +94,7 @@ const wrapServerComponentRenderer = (componentOrRenderFunction, componentName =
94
94
  const RSCProvider = createRSCProvider({
95
95
  getServerComponent: getReactServerComponent(domNodeId, railsContext),
96
96
  domNodeId,
97
+ retryRejectedPayloads: true,
97
98
  });
98
99
  const shouldHydrate = !!domNode.innerHTML;
99
100
  const componentElement = _jsx(Component, { ...props });
@@ -71,6 +71,7 @@ const wrapServerComponentRenderer = (componentOrRenderFunction, componentName =
71
71
  }
72
72
  const RSCProvider = createRSCProvider({
73
73
  getServerComponent: getReactServerComponent(railsContext),
74
+ retryRejectedPayloads: false,
74
75
  });
75
76
  return () => (_jsx(RSCProvider, { children: _jsx(React.Suspense, { fallback: null, children: _jsx(Component, { ...props }) }) }));
76
77
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-on-rails-pro",
3
- "version": "17.0.0-rc.7",
3
+ "version": "17.0.0-rc.9",
4
4
  "description": "React on Rails Pro package with React Server Components support",
5
5
  "main": "lib/ReactOnRails.full.js",
6
6
  "type": "module",
@@ -67,12 +67,12 @@
67
67
  "./ServerComponentFetchError": "./lib/ServerComponentFetchError.js"
68
68
  },
69
69
  "dependencies": {
70
- "react-on-rails": "17.0.0-rc.7"
70
+ "react-on-rails": "17.0.0-rc.9"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "react": ">= 16",
74
74
  "react-dom": ">= 16",
75
- "react-on-rails-rsc": ">=19.2.1-rc.0 <20.0.0",
75
+ "react-on-rails-rsc": ">=19.2.1-rc.1 <20.0.0",
76
76
  "@tanstack/react-router": ">=1.139.0 <2.0.0",
77
77
  "ioredis": ">= 5"
78
78
  },
@@ -103,7 +103,7 @@
103
103
  "mock-fs": "^5.5.0",
104
104
  "react": "~19.2.7",
105
105
  "react-dom": "~19.2.7",
106
- "react-on-rails-rsc": "19.2.1-rc.0",
106
+ "react-on-rails-rsc": "19.2.1-rc.1",
107
107
  "ioredis": "5.11.1"
108
108
  },
109
109
  "scripts": {