react-on-rails-pro 17.0.0-rc.8 → 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,82 +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 past the first macrotask (`setTimeout(0)`, not
290
- // `queueMicrotask`) so React's Suspense machinery observes the
291
- // rejection on the cached promise before the entry is removed; a
292
- // microtask could interleave with React's own queue and evict before
293
- // Suspense reads it. The extra macrotask also lets the `.finally`
294
- // below release the in-flight pin before the key becomes available for
295
- // a fresh same-key `getComponent`; otherwise a repeated failing request
296
- // can replace the rejected promise with a new pending promise before
297
- // the user ErrorBoundary commits its fallback (#4522). Guarded on
298
- // promise identity so a newer same-key promise (such as
299
- // `refetchComponent`) installed during that window is not evicted by
300
- // this stale failure.
301
- //
302
- // NOTE: payloads that RESOLVE with an `Error` value are intentionally
303
- // left cached here; whether those should also retry is a separate
304
- // `getServerComponent` contract decision tracked apart from #3929.
305
- let payloadRejected = false;
306
- const evictPromiseIfRejected = (error) => {
307
- payloadRejected = true;
308
- setTimeout(() => {
309
- setTimeout(() => {
310
- if (fetchRSCPromises.get(key, false) === promise) {
311
- fetchRSCPromises.deleteWithoutEvict(key);
312
- }
313
- }, 0);
314
- }, 0);
315
- 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
+ }
316
319
  };
317
- let serverComponentPromise;
320
+ let firstAttempt;
318
321
  const preferEmbeddedPayload = hasEmbeddedRSCPayload(componentName, componentProps, domNodeId);
319
322
  const prefetchedServerComponentPromise = preferEmbeddedPayload
320
323
  ? undefined
321
324
  : consumePrefetchedServerComponent(key, providerCacheIdentityRef.current);
322
325
  if (prefetchedServerComponentPromise) {
323
- serverComponentPromise = prefetchedServerComponentPromise;
326
+ firstAttempt = prefetchedServerComponentPromise;
324
327
  }
325
328
  else {
326
- try {
327
- 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;
328
341
  }
329
- catch (error) {
330
- // A synchronous producer throw behaves exactly like an immediately
331
- // rejected fetch: the rejection flows through the standard
332
- // `evictPromiseIfRejected` + `.finally()` bookkeeping below, which
333
- // settles the evicted-success latch and evicts the cached rejection.
334
- serverComponentPromise = rejectWithError(error);
342
+ return fetchPayload(true);
343
+ })
344
+ .then(markPayloadIfSuccessful, (error) => {
345
+ if (retryRejectedPayloads && fetchRSCPromises.get(key, false) === promise) {
346
+ terminalFailureRetained = true;
335
347
  }
336
- }
337
- promise = serverComponentPromise.then(markPayloadIfSuccessful, evictPromiseIfRejected).finally(() => {
348
+ throw error;
349
+ })
350
+ .finally(() => {
338
351
  if (notifyRoutesOnSuccess && !payloadSucceeded && releaseInFlightEvictedSuccessLatch()) {
339
352
  evictedSuccessfulPayloadKeys.set(key, true);
340
353
  }
341
- // Defer the initial-load pin release so a route whose promise just
342
- // resolved can commit and install its mounted retain before over-cap
343
- // reconciliation runs. Without this handoff, 51+ simultaneously
344
- // resolving mounted routes can evict early visible keys before their
345
- // 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.
346
357
  setTimeout(() => {
347
- if (payloadRejected) {
348
- // The rejected entry is already scheduled for deletion in the
349
- // next macrotask. Releasing its pin must not reconcile over-cap
350
- // eviction against healthy entries during this short grace
351
- // window.
358
+ if (terminalFailureRetained && fetchRSCPromises.get(key, false) === promise) {
359
+ fetchRSCPromises.deletePreservingPins(key);
352
360
  fetchRSCPromises.unpinWithoutEvict(key);
361
+ scheduleAbsentKeyVersionCleanup(key);
353
362
  return;
354
363
  }
355
364
  fetchRSCPromises.unpin(key);
356
- }, 0);
365
+ }, terminalFailureRetained ? RSC_PAYLOAD_FAILURE_RETENTION_MS : 0);
357
366
  });
358
367
  fetchRSCPromises.setPinned(key, promise);
359
368
  if (notifyRoutesOnSuccess) {
@@ -367,6 +376,7 @@ export const createRSCProvider = ({ getServerComponent, domNodeId, }) => {
367
376
  fetchRSCPromises,
368
377
  inFlightEvictedSuccessfulPayloadCounts,
369
378
  markSuccessfulPromise,
379
+ scheduleAbsentKeyVersionCleanup,
370
380
  ]);
371
381
  const refetchComponent = useCallback((componentName, componentProps, recoverOnError) => {
372
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
@@ -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
@@ -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.8",
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.8"
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": {