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.
- package/lib/RSCProvider.d.ts +6 -2
- package/lib/RSCProvider.js +75 -65
- package/lib/RSCProviderCache.d.ts +16 -4
- package/lib/RSCProviderCache.js +16 -4
- package/lib/cache/manifestLoader.d.ts +4 -2
- package/lib/cache/manifestLoader.js +2 -2
- package/lib/cache/manifestLoaderServer.d.ts +7 -2
- package/lib/cache/manifestLoaderServer.js +53 -9
- package/lib/getReactServerComponent.client.js +2 -1
- package/lib/injectRSCPayload.d.ts +1 -0
- package/lib/injectRSCPayload.js +17 -9
- package/lib/registerDefaultRSCProvider.client.js +1 -0
- package/lib/streamServerRenderedReactComponent.js +15 -1
- package/lib/wrapServerComponentRenderer/client.js +1 -0
- package/lib/wrapServerComponentRenderer/server.js +1 -0
- package/package.json +4 -4
package/lib/RSCProvider.d.ts
CHANGED
|
@@ -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;
|
package/lib/RSCProvider.js
CHANGED
|
@@ -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
|
|
232
|
-
//
|
|
233
|
-
//
|
|
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.
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
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
|
-
|
|
326
|
+
firstAttempt = prefetchedServerComponentPromise;
|
|
324
327
|
}
|
|
325
328
|
else {
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
348
|
+
throw error;
|
|
349
|
+
})
|
|
350
|
+
.finally(() => {
|
|
338
351
|
if (notifyRoutesOnSuccess && !payloadSucceeded && releaseInFlightEvictedSuccessLatch()) {
|
|
339
352
|
evictedSuccessfulPayloadKeys.set(key, true);
|
|
340
353
|
}
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
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 (
|
|
348
|
-
|
|
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.
|
|
10
|
-
*
|
|
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`;
|
|
59
|
-
*
|
|
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
|
package/lib/RSCProviderCache.js
CHANGED
|
@@ -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.
|
|
24
|
-
*
|
|
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`;
|
|
73
|
-
*
|
|
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<
|
|
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 {
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
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
|
};
|
package/lib/injectRSCPayload.js
CHANGED
|
@@ -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
|
|
149
|
+
function normalizeStylesheetHref(href) {
|
|
150
150
|
try {
|
|
151
|
-
return
|
|
151
|
+
return new URL(href, 'http://react-on-rails.local').pathname;
|
|
152
152
|
}
|
|
153
153
|
catch {
|
|
154
|
-
return
|
|
154
|
+
return href.split(/[?#]/, 1)[0];
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
|
-
function
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
106
|
+
"react-on-rails-rsc": "19.2.1-rc.1",
|
|
107
107
|
"ioredis": "5.11.1"
|
|
108
108
|
},
|
|
109
109
|
"scripts": {
|