react-on-rails-pro 16.5.1 → 16.6.0-rc.1
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/ClientSideRenderer.d.ts +2 -2
- package/lib/ClientSideRenderer.js +23 -22
- package/lib/createReactOnRailsPro.js +17 -5
- package/lib/tanstack-router/clientHydrate.d.ts +2 -4
- package/lib/tanstack-router/clientHydrate.js +260 -68
- package/lib/tanstack-router/index.d.ts +4 -3
- package/lib/tanstack-router/index.js +8 -1
- package/lib/tanstack-router/serverRender.js +5 -13
- package/lib/tanstack-router/types.d.ts +12 -3
- package/package.json +2 -2
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export declare function renderOrHydrateComponent(domIdOrElement: string | Element): Promise<void>;
|
|
2
|
-
export declare const
|
|
2
|
+
export declare const renderOrHydrateCompleteComponents: () => Promise<void>;
|
|
3
3
|
export declare const renderOrHydrateAllComponents: () => Promise<void>;
|
|
4
4
|
export declare function hydrateStore(storeNameOrElement: string | Element): Promise<void>;
|
|
5
|
-
export declare const
|
|
5
|
+
export declare const hydrateCompleteStores: () => Promise<void>;
|
|
6
6
|
export declare const hydrateAllStores: () => Promise<void>;
|
|
7
7
|
export declare function unmountAll(): void;
|
|
8
8
|
//# sourceMappingURL=ClientSideRenderer.d.ts.map
|
|
@@ -17,12 +17,9 @@ import { isServerRenderHash } from 'react-on-rails/isServerRenderResult';
|
|
|
17
17
|
import { supportsHydrate, supportsRootApi, unmountComponentAtNode } from 'react-on-rails/reactApis';
|
|
18
18
|
import reactHydrateOrRender from 'react-on-rails/reactHydrateOrRender';
|
|
19
19
|
import { debugTurbolinks } from 'react-on-rails/turbolinksUtils';
|
|
20
|
-
import { onPageLoaded } from 'react-on-rails/pageLifecycle';
|
|
21
20
|
import * as StoreRegistry from "./StoreRegistry.js";
|
|
22
21
|
import * as ComponentRegistry from "./ComponentRegistry.js";
|
|
23
22
|
const REACT_ON_RAILS_STORE_ATTRIBUTE = 'data-js-react-on-rails-store';
|
|
24
|
-
const IMMEDIATE_HYDRATION_PRO_WARNING = "[REACT ON RAILS] The 'immediate_hydration' feature requires the React on Rails Pro gem to be installed on the server. " +
|
|
25
|
-
'Please visit https://pro.reactonrails.com/ for installation details.';
|
|
26
23
|
async function delegateToRenderer(componentObj, props, railsContext, domNodeId, trace) {
|
|
27
24
|
const { name, component, isRenderer } = componentObj;
|
|
28
25
|
if (isRenderer) {
|
|
@@ -57,24 +54,14 @@ class ComponentRenderer {
|
|
|
57
54
|
return this.render(el, railsContext);
|
|
58
55
|
});
|
|
59
56
|
}
|
|
57
|
+
hasStartedRendering() {
|
|
58
|
+
return this.renderPromise !== undefined;
|
|
59
|
+
}
|
|
60
60
|
/**
|
|
61
61
|
* Used for client rendering by ReactOnRails. Either calls ReactDOM.hydrate, ReactDOM.render, or
|
|
62
62
|
* delegates to a renderer registered by the user.
|
|
63
63
|
*/
|
|
64
64
|
async render(el, railsContext) {
|
|
65
|
-
const isImmediateHydrationRequested = el.getAttribute('data-immediate-hydration') === 'true';
|
|
66
|
-
// rorPro signals gem presence on the server, not license validity.
|
|
67
|
-
const hasProGemInstalled = railsContext.rorPro;
|
|
68
|
-
// Handle immediate_hydration feature usage without Pro gem installed
|
|
69
|
-
if (isImmediateHydrationRequested && !hasProGemInstalled) {
|
|
70
|
-
console.warn(IMMEDIATE_HYDRATION_PRO_WARNING);
|
|
71
|
-
// Fallback to standard behavior: wait for page load before hydrating
|
|
72
|
-
if (document.readyState === 'loading') {
|
|
73
|
-
await new Promise((resolve) => {
|
|
74
|
-
onPageLoaded(resolve);
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
65
|
// This must match lib/react_on_rails/helper.rb
|
|
79
66
|
const name = el.getAttribute('data-component-name') || '';
|
|
80
67
|
const { domNodeId } = this;
|
|
@@ -176,6 +163,9 @@ class StoreRenderer {
|
|
|
176
163
|
StoreRegistry.setStore(name, store);
|
|
177
164
|
this.state = 'hydrated';
|
|
178
165
|
}
|
|
166
|
+
hasStartedHydrating() {
|
|
167
|
+
return this.hydratePromise !== undefined;
|
|
168
|
+
}
|
|
179
169
|
waitUntilHydrated() {
|
|
180
170
|
if (this.state === 'hydrating' && this.hydratePromise) {
|
|
181
171
|
return this.hydratePromise;
|
|
@@ -192,7 +182,11 @@ export function renderOrHydrateComponent(domIdOrElement) {
|
|
|
192
182
|
debugTurbolinks('renderOrHydrateComponent', domId);
|
|
193
183
|
let root = renderedRoots.get(domId);
|
|
194
184
|
if (!root) {
|
|
195
|
-
|
|
185
|
+
const newRoot = new ComponentRenderer(domIdOrElement);
|
|
186
|
+
if (!newRoot.hasStartedRendering()) {
|
|
187
|
+
return Promise.resolve();
|
|
188
|
+
}
|
|
189
|
+
root = newRoot;
|
|
196
190
|
renderedRoots.set(domId, root);
|
|
197
191
|
}
|
|
198
192
|
return root.waitUntilRendered();
|
|
@@ -214,8 +208,8 @@ async function forAllElementsAsync(selector, callback) {
|
|
|
214
208
|
* - Therefore, if nextSibling exists (even whitespace or comments), the closing
|
|
215
209
|
* tag was parsed and the content is guaranteed to be complete
|
|
216
210
|
*
|
|
217
|
-
* Elements without a nextSibling will be hydrated
|
|
218
|
-
*
|
|
211
|
+
* Elements without a nextSibling will be hydrated via inline scripts as streaming completes (Pro),
|
|
212
|
+
* or on DOMContentLoaded (non-Pro).
|
|
219
213
|
*
|
|
220
214
|
* See: https://github.com/shakacode/react_on_rails/issues/2283
|
|
221
215
|
*/
|
|
@@ -224,7 +218,10 @@ async function forAllCompleteElementsAsync(selector, callback) {
|
|
|
224
218
|
const completeEls = Array.from(els).filter((el) => el.nextSibling !== null);
|
|
225
219
|
await Promise.all(completeEls.map(callback));
|
|
226
220
|
}
|
|
227
|
-
|
|
221
|
+
// For Pro streaming pages: hydrate all components whose markup has been fully streamed
|
|
222
|
+
// (identified by having a nextSibling). On non-streaming pages this matches ALL components,
|
|
223
|
+
// but ClientSideRenderer memoizes by DOM node id so the later DOMContentLoaded sweep is a no-op.
|
|
224
|
+
export const renderOrHydrateCompleteComponents = () => forAllCompleteElementsAsync('.js-react-on-rails-component', renderOrHydrateComponent);
|
|
228
225
|
export const renderOrHydrateAllComponents = () => forAllElementsAsync('.js-react-on-rails-component', renderOrHydrateComponent);
|
|
229
226
|
function unmountAllComponents() {
|
|
230
227
|
renderedRoots.forEach((root) => root.unmount());
|
|
@@ -244,12 +241,16 @@ export async function hydrateStore(storeNameOrElement) {
|
|
|
244
241
|
if (!storeDataElement) {
|
|
245
242
|
return;
|
|
246
243
|
}
|
|
247
|
-
|
|
244
|
+
const newStoreRenderer = new StoreRenderer(storeDataElement);
|
|
245
|
+
if (!newStoreRenderer.hasStartedHydrating()) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
storeRenderer = newStoreRenderer;
|
|
248
249
|
storeRenderers.set(storeName, storeRenderer);
|
|
249
250
|
}
|
|
250
251
|
await storeRenderer.waitUntilHydrated();
|
|
251
252
|
}
|
|
252
|
-
export const
|
|
253
|
+
export const hydrateCompleteStores = () => forAllCompleteElementsAsync(`[${REACT_ON_RAILS_STORE_ATTRIBUTE}]`, hydrateStore);
|
|
253
254
|
export const hydrateAllStores = () => forAllElementsAsync(`[${REACT_ON_RAILS_STORE_ATTRIBUTE}]`, hydrateStore);
|
|
254
255
|
function unmountAllStores() {
|
|
255
256
|
storeRenderers.forEach((storeRenderer) => storeRenderer.unmount());
|
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
* For licensing terms, please see:
|
|
12
12
|
* https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md
|
|
13
13
|
*/
|
|
14
|
+
import { getRailsContext } from 'react-on-rails/context';
|
|
14
15
|
import { onPageLoaded, onPageUnloaded } from 'react-on-rails/pageLifecycle';
|
|
15
16
|
import { debugTurbolinks } from 'react-on-rails/turbolinksUtils';
|
|
16
17
|
import * as ProComponentRegistry from "./ComponentRegistry.js";
|
|
17
18
|
import * as ProStoreRegistry from "./StoreRegistry.js";
|
|
18
|
-
import { renderOrHydrateComponent, hydrateStore, renderOrHydrateAllComponents, hydrateAllStores,
|
|
19
|
-
// Pro client startup with immediate hydration support
|
|
19
|
+
import { renderOrHydrateComponent, hydrateStore, renderOrHydrateAllComponents, hydrateAllStores, renderOrHydrateCompleteComponents, hydrateCompleteStores, unmountAll, } from "./ClientSideRenderer.js";
|
|
20
20
|
async function reactOnRailsPageLoaded() {
|
|
21
21
|
debugTurbolinks('reactOnRailsPageLoaded [PRO]');
|
|
22
22
|
await Promise.all([hydrateAllStores(), renderOrHydrateAllComponents()]);
|
|
@@ -35,8 +35,20 @@ function clientStartup() {
|
|
|
35
35
|
}
|
|
36
36
|
// eslint-disable-next-line no-underscore-dangle
|
|
37
37
|
globalThis.__REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__ = true;
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
const railsContext = getRailsContext();
|
|
39
|
+
if (railsContext === null) {
|
|
40
|
+
// Context element not yet in DOM — expected in streaming scenarios.
|
|
41
|
+
// Early Pro hydration skipped; the page-loaded sweep will recover all components.
|
|
42
|
+
if (process.env.NODE_ENV !== 'production' && typeof console !== 'undefined') {
|
|
43
|
+
console.debug('[React on Rails] railsContext not available at clientStartup — early Pro hydration skipped, falling back to page-load sweep.');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else if (railsContext.rorPro) {
|
|
47
|
+
// Streaming pages can trigger these incrementally as markup arrives. The later
|
|
48
|
+
// page-loaded sweep is safe because ClientSideRenderer memoizes by DOM/store id.
|
|
49
|
+
void renderOrHydrateCompleteComponents();
|
|
50
|
+
void hydrateCompleteStores();
|
|
51
|
+
}
|
|
40
52
|
onPageLoaded(reactOnRailsPageLoaded);
|
|
41
53
|
onPageUnloaded(reactOnRailsPageUnloaded);
|
|
42
54
|
}
|
|
@@ -103,7 +115,7 @@ export default function createReactOnRailsPro(baseObjectCreator, currentGlobal =
|
|
|
103
115
|
globalThis.ReactOnRails = reactOnRailsPro;
|
|
104
116
|
// Reset options to defaults (only on first initialization)
|
|
105
117
|
reactOnRailsPro.resetOptions();
|
|
106
|
-
// Run Pro client startup
|
|
118
|
+
// Run Pro client startup (only on first initialization)
|
|
107
119
|
clientStartup();
|
|
108
120
|
}
|
|
109
121
|
return reactOnRailsPro;
|
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type ReactElement } from 'react';
|
|
2
2
|
import type { TanStackHistory, TanStackRouter, TanStackRouterOptions } from './types.ts';
|
|
3
3
|
import type { RailsContext } from 'react-on-rails/types';
|
|
4
4
|
export declare function clientHydrateTanStackApp(options: TanStackRouterOptions, props: Record<string, unknown>, railsContext: RailsContext & {
|
|
5
5
|
serverSide: false;
|
|
6
6
|
}, RouterProvider: React.ComponentType<{
|
|
7
7
|
router: TanStackRouter;
|
|
8
|
-
}>,
|
|
9
|
-
router: TanStackRouter;
|
|
10
|
-
}> | undefined, createBrowserHistory: () => TanStackHistory): ReactElement;
|
|
8
|
+
}>, createBrowserHistory: () => TanStackHistory): ReactElement;
|
|
11
9
|
//# sourceMappingURL=clientHydrate.d.ts.map
|
|
@@ -1,82 +1,229 @@
|
|
|
1
|
-
import { createElement, useEffect, useRef } from 'react';
|
|
2
|
-
function
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
1
|
+
import { Suspense, createElement, useEffect, useRef } from 'react';
|
|
2
|
+
function RouteChunkPreloadGate({ preloadPromise, preloadSettledRef, isHydrating, children, }) {
|
|
3
|
+
// During SSR hydration (first render), skip the suspension gate to avoid a
|
|
4
|
+
// hydration mismatch: the server rendered RouterProvider content directly,
|
|
5
|
+
// so throwing a promise here would cause Suspense to render the null fallback
|
|
6
|
+
// instead of matching the server HTML. After hydration completes (the
|
|
7
|
+
// post-mount effect sets didTriggerPostHydrationLoadRef), the gate activates
|
|
8
|
+
// normally for any subsequent re-renders.
|
|
9
|
+
if (!isHydrating && preloadPromise && !preloadSettledRef.current) {
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Suspense boundaries intentionally suspend on thrown Promise.
|
|
11
|
+
throw preloadPromise;
|
|
12
|
+
}
|
|
13
|
+
return children;
|
|
14
|
+
}
|
|
15
|
+
function extractDehydratedData(dehydratedRouter) {
|
|
16
|
+
if (!dehydratedRouter || typeof dehydratedRouter !== 'object') {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
return dehydratedRouter.dehydratedData;
|
|
20
|
+
}
|
|
21
|
+
function preloadMatchedRouteChunks(router, matches) {
|
|
22
|
+
const { loadRouteChunk, looseRoutesById } = router;
|
|
23
|
+
if (typeof loadRouteChunk !== 'function' || !looseRoutesById) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const routeChunkPromises = [];
|
|
27
|
+
matches.forEach((match) => {
|
|
28
|
+
const { routeId } = match;
|
|
29
|
+
if (typeof routeId !== 'string') {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const route = looseRoutesById[routeId];
|
|
33
|
+
if (!route) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
routeChunkPromises.push(loadRouteChunk(route));
|
|
37
|
+
});
|
|
38
|
+
if (!routeChunkPromises.length) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return Promise.all(routeChunkPromises)
|
|
42
|
+
.then(() => undefined)
|
|
43
|
+
.catch((error) => {
|
|
44
|
+
console.error('react-on-rails-pro/tanstack-router: Error preloading matched route chunks:', error);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Converts a dehydrated match ID (using \0 separator) back to the standard
|
|
49
|
+
* route ID format (using / separator) used by matchRoutes().
|
|
50
|
+
*
|
|
51
|
+
* Inverse of dehydrateSsrMatchId() in serverRender.ts, which replaces '/'
|
|
52
|
+
* with '\0' to match the $_TSR bootstrap wire format used by TanStack Router's
|
|
53
|
+
* DehydrateRouter component (see @tanstack/react-router/src/DehydrateRouter.tsx).
|
|
54
|
+
*/
|
|
55
|
+
function rehydrateMatchId(dehydratedId) {
|
|
56
|
+
return dehydratedId.split('\0').join('/');
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Applies server-rendered match data (loaderData, beforeLoadContext, status, etc.)
|
|
60
|
+
* from the dehydrated SSR payload to fresh client matches. This ensures the first
|
|
61
|
+
* client render can access the same data the server used, preventing mismatches
|
|
62
|
+
* for routes that render from loader results.
|
|
63
|
+
*/
|
|
64
|
+
function applyDehydratedMatchData(matches, ssrMatches, onMissingSsrMatch) {
|
|
65
|
+
return matches.map((match) => {
|
|
66
|
+
const m = match;
|
|
67
|
+
const ssrMatch = ssrMatches.find((sm) => rehydrateMatchId(sm.i) === m.id);
|
|
68
|
+
if (ssrMatch) {
|
|
69
|
+
return {
|
|
70
|
+
...m,
|
|
71
|
+
status: ssrMatch.s,
|
|
72
|
+
updatedAt: ssrMatch.u,
|
|
73
|
+
...(ssrMatch.l !== undefined ? { loaderData: ssrMatch.l } : {}),
|
|
74
|
+
...(ssrMatch.b !== undefined ? { __beforeLoadContext: ssrMatch.b } : {}),
|
|
75
|
+
...(ssrMatch.e !== undefined ? { error: ssrMatch.e } : {}),
|
|
76
|
+
...(ssrMatch.ssr !== undefined ? { ssr: ssrMatch.ssr } : {}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// No server match — override pending to success to prevent MatchInner
|
|
80
|
+
// from throwing loadPromise (which would cause Suspense suspension).
|
|
81
|
+
if (m.status === 'pending') {
|
|
82
|
+
onMissingSsrMatch?.(m);
|
|
83
|
+
return { ...m, status: 'success' };
|
|
84
|
+
}
|
|
85
|
+
return m;
|
|
86
|
+
});
|
|
24
87
|
}
|
|
25
88
|
function TanStackHydrationApp({ options, incomingProps,
|
|
26
89
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Required by TanStackHydrationAppProps interface.
|
|
27
|
-
railsContext: _railsContext, RouterProvider,
|
|
28
|
-
// eslint-disable-next-line no-underscore-dangle -- Internal hydration payload key injected by server-side render.
|
|
90
|
+
railsContext: _railsContext, RouterProvider, createBrowserHistory, }) {
|
|
29
91
|
const dehydratedState = incomingProps.__tanstackRouterDehydratedState;
|
|
30
|
-
const ssrRouter = dehydratedState?.ssrRouter;
|
|
31
92
|
const hasSsrPayload = dehydratedState != null;
|
|
32
|
-
const hasSsrRouter = ssrRouter !== undefined;
|
|
33
93
|
const hasDehydratedRouter = dehydratedState?.dehydratedRouter !== undefined && dehydratedState.dehydratedRouter !== null;
|
|
34
94
|
const routerRef = useRef(null);
|
|
35
|
-
// This only deduplicates the manual load within one mounted hydration instance.
|
|
36
|
-
// A full remount creates a fresh router and may legitimately issue another load.
|
|
37
95
|
const didTriggerPostHydrationLoadRef = useRef(false);
|
|
38
|
-
const
|
|
96
|
+
const didSetSsrFlagRef = useRef(false);
|
|
97
|
+
const routeChunkPreloadPromiseRef = useRef(null);
|
|
98
|
+
const routeChunkPreloadSettledRef = useRef(true);
|
|
99
|
+
const hydrationCallbackPromiseRef = useRef(null);
|
|
100
|
+
const didWarnPrivateInternalsRef = useRef(false);
|
|
101
|
+
const warnedMissingSsrMatchIdsRef = useRef(new Set());
|
|
102
|
+
const warnMissingSsrMatch = (match) => {
|
|
103
|
+
if (process.env.NODE_ENV !== 'development') {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const routeId = typeof match.id === 'string' || typeof match.id === 'number' ? String(match.id) : '<unknown>';
|
|
107
|
+
if (warnedMissingSsrMatchIdsRef.current.has(routeId)) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
warnedMissingSsrMatchIdsRef.current.add(routeId);
|
|
111
|
+
console.warn(`react-on-rails-pro/tanstack-router: No server match found for route "${routeId}". ` +
|
|
112
|
+
'Overriding match.status from "pending" to "success" to prevent hydration suspension.');
|
|
113
|
+
};
|
|
39
114
|
if (routerRef.current === null) {
|
|
115
|
+
// Intentionally initialize the hydration router during render so the very
|
|
116
|
+
// first RouterProvider render sees SSR-injected matches and the temporary
|
|
117
|
+
// router.ssr flag. Moving this to an effect causes a first-render mismatch
|
|
118
|
+
// (the provider would render before hydration state is injected).
|
|
119
|
+
//
|
|
120
|
+
// Safety invariant: all mutations in this block target only the router
|
|
121
|
+
// instance created here and routerRef.current is assigned only after the
|
|
122
|
+
// block completes. If React discards this render (StrictMode/concurrency),
|
|
123
|
+
// the discarded router instance is dropped and a fresh instance is created
|
|
124
|
+
// and initialized on the next render.
|
|
40
125
|
const router = options.createRouter();
|
|
41
126
|
// Set browser history for client-side navigation
|
|
42
127
|
const browserHistory = createBrowserHistory();
|
|
43
128
|
router.update({ history: browserHistory });
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
129
|
+
// Only apply SSR hydration when a server payload exists.
|
|
130
|
+
// Client-only renders (prerender: false) must not set router.ssr or
|
|
131
|
+
// inject matches — the Transitioner handles initial loading for those.
|
|
132
|
+
if (hasSsrPayload) {
|
|
133
|
+
if (process.env.NODE_ENV === 'development' && !didWarnPrivateInternalsRef.current) {
|
|
134
|
+
didWarnPrivateInternalsRef.current = true;
|
|
135
|
+
console.warn('react-on-rails-pro/tanstack-router: Hydration uses TanStack Router private internals ' +
|
|
136
|
+
'(matchRoutes, __store, loadRouteChunk, looseRoutesById). Keep @tanstack/react-router ' +
|
|
137
|
+
'within the supported range (>=1.139.0 <2.0.0) and run integration tests when upgrading.');
|
|
138
|
+
}
|
|
139
|
+
// Validate internal APIs before using them.
|
|
140
|
+
if (typeof router.matchRoutes !== 'function' || !router.__store?.setState) {
|
|
141
|
+
throw new Error('react-on-rails-pro/tanstack-router: router.matchRoutes() and router.__store are required ' +
|
|
142
|
+
'but not available. Ensure @tanstack/react-router >=1.139.0 <2.0.0 is installed.');
|
|
143
|
+
}
|
|
144
|
+
const hydrationRouter = router;
|
|
145
|
+
// Synchronously inject route matches to match server-rendered output.
|
|
146
|
+
// The server fully loads routes (via router.load()) before rendering, so
|
|
147
|
+
// all matches are resolved. We replicate this on the client so the initial
|
|
148
|
+
// render produces the same component tree as the server HTML.
|
|
149
|
+
//
|
|
150
|
+
// When ssrRouter match data is available (from serverRenderTanStackAppAsync),
|
|
151
|
+
// we apply loaderData, beforeLoadContext, status, etc. from the server payload
|
|
152
|
+
// so routes that render from loader results can hydrate correctly.
|
|
153
|
+
// Otherwise we override 'pending' to 'success' to prevent MatchInner from
|
|
154
|
+
// throwing loadPromise (which would cause Suspense suspension).
|
|
155
|
+
const rawMatches = hydrationRouter.matchRoutes(hydrationRouter.state.location);
|
|
156
|
+
routeChunkPreloadPromiseRef.current = preloadMatchedRouteChunks(router, rawMatches);
|
|
157
|
+
if (routeChunkPreloadPromiseRef.current) {
|
|
158
|
+
routeChunkPreloadSettledRef.current = false;
|
|
159
|
+
void routeChunkPreloadPromiseRef.current.finally(() => {
|
|
160
|
+
routeChunkPreloadSettledRef.current = true;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
routeChunkPreloadSettledRef.current = true;
|
|
165
|
+
}
|
|
166
|
+
const ssrMatches = dehydratedState?.ssrRouter?.matches;
|
|
167
|
+
const matches = ssrMatches?.length
|
|
168
|
+
? applyDehydratedMatchData(rawMatches, ssrMatches, warnMissingSsrMatch)
|
|
169
|
+
: rawMatches.map((match) => {
|
|
170
|
+
const m = match;
|
|
171
|
+
if (m.status === 'pending') {
|
|
172
|
+
warnMissingSsrMatch(m);
|
|
173
|
+
return { ...m, status: 'success' };
|
|
174
|
+
}
|
|
175
|
+
return m;
|
|
176
|
+
});
|
|
177
|
+
// Render-phase store injection is required for hydration parity: this
|
|
178
|
+
// must happen before the first RouterProvider render.
|
|
179
|
+
hydrationRouter.__store.setState((s) => ({
|
|
180
|
+
...s,
|
|
181
|
+
status: 'idle',
|
|
182
|
+
resolvedLocation: s.location,
|
|
183
|
+
matches,
|
|
184
|
+
}));
|
|
185
|
+
// Set SSR flag so the Transitioner skips its initial router.load() call,
|
|
186
|
+
// preventing a state update during hydration that would cause a mismatch.
|
|
187
|
+
// The shape matches TanStack Router's internal $_TSR hydration contract
|
|
188
|
+
// (the Transitioner only checks truthiness).
|
|
189
|
+
// Preserve user-set values from createRouter() (e.g. TanStack Start).
|
|
190
|
+
if (!router.ssr) {
|
|
191
|
+
router.ssr = { manifest: undefined };
|
|
192
|
+
didSetSsrFlagRef.current = true;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
// Run user-defined hydration callback for custom dehydratedData
|
|
196
|
+
// (for example external query/cache payloads), matching TanStack
|
|
197
|
+
// Router's ssr-client behavior.
|
|
198
|
+
if (typeof router.options?.hydrate === 'function') {
|
|
199
|
+
const hydrationResult = router.options.hydrate(extractDehydratedData(dehydratedState?.dehydratedRouter));
|
|
200
|
+
// Let async hydration failures reject so we do not continue into
|
|
201
|
+
// router.load() with partially hydrated client state.
|
|
202
|
+
hydrationCallbackPromiseRef.current = Promise.resolve(hydrationResult).then(() => undefined);
|
|
203
|
+
}
|
|
204
|
+
// Backward-compatibility hook: if user router exposes router.hydrate(),
|
|
205
|
+
// invoke it with the full dehydrated router payload.
|
|
206
|
+
if (hasDehydratedRouter && typeof router.hydrate === 'function') {
|
|
207
|
+
router.hydrate(dehydratedState.dehydratedRouter);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
// If render-phase hydration throws, clear only the temporary SSR flag
|
|
212
|
+
// created by this module so retries are not blocked.
|
|
213
|
+
if (didSetSsrFlagRef.current) {
|
|
214
|
+
router.ssr = undefined;
|
|
215
|
+
didSetSsrFlagRef.current = false;
|
|
216
|
+
}
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
60
219
|
}
|
|
61
220
|
routerRef.current = router;
|
|
62
221
|
}
|
|
63
222
|
const router = routerRef.current;
|
|
64
|
-
if (hasSsrRouter && typeof window !== 'undefined' && !didInitializeSsrGlobalRef.current) {
|
|
65
|
-
setTanStackSsrGlobal(ssrRouter);
|
|
66
|
-
didInitializeSsrGlobalRef.current = true;
|
|
67
|
-
}
|
|
68
223
|
// After mount, trigger router.load() to enable client-side navigation.
|
|
69
224
|
// The SSR flag prevented auto-loading, so we do it manually here.
|
|
70
225
|
useEffect(() => {
|
|
71
|
-
if (!router) {
|
|
72
|
-
return undefined;
|
|
73
|
-
}
|
|
74
|
-
if (hasSsrRouter) {
|
|
75
|
-
return undefined;
|
|
76
|
-
}
|
|
77
|
-
// Only SSR hydration needs a manual load call.
|
|
78
|
-
// For client-only renders, Transitioner handles initial loading.
|
|
79
|
-
if (!hasSsrPayload) {
|
|
226
|
+
if (!router || !hasSsrPayload) {
|
|
80
227
|
return undefined;
|
|
81
228
|
}
|
|
82
229
|
if (didTriggerPostHydrationLoadRef.current) {
|
|
@@ -84,38 +231,83 @@ railsContext: _railsContext, RouterProvider, RouterClient, createBrowserHistory,
|
|
|
84
231
|
}
|
|
85
232
|
didTriggerPostHydrationLoadRef.current = true;
|
|
86
233
|
let cancelled = false;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
234
|
+
const runPostHydrationLoad = async () => {
|
|
235
|
+
if (hydrationCallbackPromiseRef.current) {
|
|
236
|
+
await hydrationCallbackPromiseRef.current;
|
|
237
|
+
if (cancelled) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (routeChunkPreloadPromiseRef.current) {
|
|
242
|
+
await routeChunkPreloadPromiseRef.current;
|
|
243
|
+
if (cancelled) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (cancelled) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
await router.load();
|
|
251
|
+
};
|
|
252
|
+
void runPostHydrationLoad()
|
|
253
|
+
.catch((err) => {
|
|
90
254
|
if (!cancelled) {
|
|
91
255
|
console.error('react-on-rails-pro/tanstack-router: Error loading routes after hydration:', err);
|
|
92
256
|
}
|
|
257
|
+
})
|
|
258
|
+
.finally(() => {
|
|
259
|
+
// Always clear temporary router.ssr set by this module, regardless of
|
|
260
|
+
// cancellation state. In React 18 StrictMode, the effect cleanup sets
|
|
261
|
+
// cancelled=true and didTriggerPostHydrationLoadRef prevents re-trigger
|
|
262
|
+
// on re-mount — if we skip cleanup here the SSR flag stays set
|
|
263
|
+
// permanently, blocking the Transitioner from ever calling router.load().
|
|
264
|
+
// The didSetSsrFlagRef guard ensures we only clear values this module
|
|
265
|
+
// created, preserving user-provided router.ssr from createRouter().
|
|
266
|
+
if (didSetSsrFlagRef.current) {
|
|
267
|
+
router.ssr = undefined;
|
|
268
|
+
didSetSsrFlagRef.current = false;
|
|
269
|
+
}
|
|
93
270
|
});
|
|
94
271
|
return () => {
|
|
95
272
|
cancelled = true;
|
|
273
|
+
if (didSetSsrFlagRef.current) {
|
|
274
|
+
router.ssr = undefined;
|
|
275
|
+
didSetSsrFlagRef.current = false;
|
|
276
|
+
}
|
|
96
277
|
const cancellableRouter = router;
|
|
97
278
|
if (typeof cancellableRouter.cancelLoad === 'function') {
|
|
98
279
|
cancellableRouter.cancelLoad();
|
|
99
280
|
}
|
|
100
281
|
};
|
|
101
|
-
}, [hasSsrPayload,
|
|
102
|
-
|
|
103
|
-
|
|
282
|
+
}, [hasSsrPayload, router]);
|
|
283
|
+
// Always use RouterProvider directly — matching the server-rendered tree.
|
|
284
|
+
// RouterClient is NOT used because it wraps RouterProvider in <Await> which
|
|
285
|
+
// introduces a Suspense boundary that doesn't exist in the server HTML,
|
|
286
|
+
// causing React hydration mismatch errors.
|
|
287
|
+
//
|
|
288
|
+
// RouteChunkPreloadGate blocks re-renders until matched lazy chunks finish
|
|
289
|
+
// preloading (when preload support is available). During the initial SSR
|
|
290
|
+
// hydration render, the gate is skipped to match the server-rendered tree
|
|
291
|
+
// and avoid a hydration mismatch. After the post-mount effect runs
|
|
292
|
+
// (didTriggerPostHydrationLoadRef becomes true), the gate activates normally.
|
|
293
|
+
let app = createElement(Suspense, { fallback: null }, createElement(RouteChunkPreloadGate, {
|
|
294
|
+
preloadPromise: routeChunkPreloadPromiseRef.current,
|
|
295
|
+
preloadSettledRef: routeChunkPreloadSettledRef,
|
|
296
|
+
isHydrating: hasSsrPayload && !didTriggerPostHydrationLoadRef.current,
|
|
297
|
+
}, createElement(RouterProvider, { router })));
|
|
104
298
|
if (options.AppWrapper) {
|
|
105
299
|
const wrapperProps = { ...incomingProps };
|
|
106
|
-
// eslint-disable-next-line no-underscore-dangle -- Internal hydration payload key should not reach user AppWrapper props.
|
|
107
300
|
delete wrapperProps.__tanstackRouterDehydratedState;
|
|
108
301
|
app = createElement(options.AppWrapper, wrapperProps, app);
|
|
109
302
|
}
|
|
110
303
|
return app;
|
|
111
304
|
}
|
|
112
|
-
export function clientHydrateTanStackApp(options, props, railsContext, RouterProvider,
|
|
305
|
+
export function clientHydrateTanStackApp(options, props, railsContext, RouterProvider, createBrowserHistory) {
|
|
113
306
|
return createElement(TanStackHydrationApp, {
|
|
114
307
|
options,
|
|
115
308
|
incomingProps: props,
|
|
116
309
|
railsContext,
|
|
117
310
|
RouterProvider,
|
|
118
|
-
RouterClient,
|
|
119
311
|
createBrowserHistory,
|
|
120
312
|
});
|
|
121
313
|
}
|
|
@@ -43,9 +43,10 @@ interface TanStackRouterDeps {
|
|
|
43
43
|
router: TanStackRouter;
|
|
44
44
|
}>;
|
|
45
45
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
46
|
+
* @deprecated No longer used for hydration. RouterProvider is always used
|
|
47
|
+
* directly to match the server-rendered tree. RouterClient caused hydration
|
|
48
|
+
* mismatches because it wraps RouterProvider in <Await> which suspends.
|
|
49
|
+
* Kept for backward compatibility only.
|
|
49
50
|
*/
|
|
50
51
|
RouterClient?: React.ComponentType<{
|
|
51
52
|
router: TanStackRouter;
|
|
@@ -48,6 +48,7 @@ export { serverRenderTanStackAppAsync } from "./serverRender.js";
|
|
|
48
48
|
*/
|
|
49
49
|
export function createTanStackRouterRenderFunction(options, deps) {
|
|
50
50
|
const { RouterProvider, RouterClient, createMemoryHistory, createBrowserHistory } = deps;
|
|
51
|
+
let didWarnRouterClientDeprecated = false;
|
|
51
52
|
const renderFn = (props = {}, railsContext) => {
|
|
52
53
|
if (!railsContext) {
|
|
53
54
|
throw new Error('react-on-rails-pro/tanstack-router: railsContext is required. ' +
|
|
@@ -66,7 +67,13 @@ export function createTanStackRouterRenderFunction(options, deps) {
|
|
|
66
67
|
// This intentionally creates a fresh closure per renderFn call so the client component
|
|
67
68
|
// captures the current railsContext and TanStack Router dependencies for that mount.
|
|
68
69
|
return function TanStackRouterClientApp(clientProps = {}) {
|
|
69
|
-
|
|
70
|
+
if (RouterClient && !didWarnRouterClientDeprecated) {
|
|
71
|
+
didWarnRouterClientDeprecated = true;
|
|
72
|
+
console.warn('react-on-rails-pro/tanstack-router: The RouterClient parameter is deprecated and ignored. ' +
|
|
73
|
+
'RouterProvider is now used directly to avoid SSR hydration mismatches. ' +
|
|
74
|
+
'You can safely remove the RouterClient import from your createTanStackRouterRenderFunction call.');
|
|
75
|
+
}
|
|
76
|
+
return clientHydrateTanStackApp(options, clientProps, railsContext, RouterProvider, createBrowserHistory);
|
|
70
77
|
};
|
|
71
78
|
};
|
|
72
79
|
// Mark as a render function so React on Rails executes it rather than treating it
|
|
@@ -1,16 +1,5 @@
|
|
|
1
1
|
import { createElement } from 'react';
|
|
2
2
|
import { normalizeSearch } from "./utils.js";
|
|
3
|
-
/**
|
|
4
|
-
* Enables TanStack Router's internal SSR mode and verifies the flag is writable.
|
|
5
|
-
*/
|
|
6
|
-
function enableRouterSsrMode(router) {
|
|
7
|
-
const routerWithSsrFlag = router;
|
|
8
|
-
routerWithSsrFlag.ssr = true;
|
|
9
|
-
if (!routerWithSsrFlag.ssr) {
|
|
10
|
-
throw new Error('react-on-rails-pro/tanstack-router: Expected router.ssr to accept a boolean flag. ' +
|
|
11
|
-
'Please check that your @tanstack/react-router version is compatible.');
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
3
|
/**
|
|
15
4
|
* Builds a React element tree with RouterProvider and optional AppWrapper.
|
|
16
5
|
*/
|
|
@@ -80,12 +69,15 @@ export async function serverRenderTanStackAppAsync(options, props, railsContext,
|
|
|
80
69
|
const memoryHistory = createMemoryHistory({ initialEntries: [url] });
|
|
81
70
|
router.update({ history: memoryHistory });
|
|
82
71
|
// Async path uses router.load() public API, so no private store access is needed.
|
|
72
|
+
// No router.ssr flag is set here: React effects (including Transitioner's auto-load)
|
|
73
|
+
// do not execute during server-side renderToString, and router.dehydrate() does not
|
|
74
|
+
// depend on router.ssr.
|
|
83
75
|
await router.load();
|
|
84
|
-
// Ensure SSR output avoids client-only Suspense wrappers that can cause hydration mismatch.
|
|
85
|
-
enableRouterSsrMode(router);
|
|
86
76
|
const dehydratedState = {
|
|
87
77
|
url,
|
|
88
78
|
dehydratedRouter: typeof router.dehydrate === 'function' ? router.dehydrate() : null,
|
|
79
|
+
// Keep ssrRouter payload for compatibility and to restore server match data
|
|
80
|
+
// before first client render.
|
|
89
81
|
ssrRouter: buildSsrRouterState(router),
|
|
90
82
|
};
|
|
91
83
|
return {
|
|
@@ -9,6 +9,12 @@ export interface TanStackRouter {
|
|
|
9
9
|
history: TanStackHistory;
|
|
10
10
|
}) => void;
|
|
11
11
|
load: () => Promise<void>;
|
|
12
|
+
matchRoutes?: (location: unknown) => unknown[];
|
|
13
|
+
__store?: {
|
|
14
|
+
setState: (updater: (s: Record<string, unknown>) => Record<string, unknown>) => void;
|
|
15
|
+
};
|
|
16
|
+
looseRoutesById?: Record<string, unknown>;
|
|
17
|
+
loadRouteChunk?: (route: unknown) => Promise<unknown>;
|
|
12
18
|
state: {
|
|
13
19
|
status: string;
|
|
14
20
|
location: {
|
|
@@ -23,8 +29,11 @@ export interface TanStackRouter {
|
|
|
23
29
|
};
|
|
24
30
|
dehydrate?: () => unknown;
|
|
25
31
|
hydrate?: (data: unknown) => void;
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
options?: {
|
|
33
|
+
hydrate?: (dehydratedData: unknown) => Promise<unknown> | unknown;
|
|
34
|
+
};
|
|
35
|
+
ssr?: {
|
|
36
|
+
manifest?: unknown;
|
|
28
37
|
};
|
|
29
38
|
}
|
|
30
39
|
export interface TanStackHistory {
|
|
@@ -93,7 +102,7 @@ export interface DehydratedRouterState {
|
|
|
93
102
|
url: string;
|
|
94
103
|
/** Router dehydrated state from router.dehydrate() */
|
|
95
104
|
dehydratedRouter: unknown;
|
|
96
|
-
/** TanStack
|
|
105
|
+
/** Legacy TanStack SSR match payload used for compatibility and match-data restoration during hydration */
|
|
97
106
|
ssrRouter?: TanStackSsrRouterState;
|
|
98
107
|
}
|
|
99
108
|
//# sourceMappingURL=types.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-on-rails-pro",
|
|
3
|
-
"version": "16.
|
|
3
|
+
"version": "16.6.0-rc.1",
|
|
4
4
|
"description": "React on Rails Pro package with React Server Components support",
|
|
5
5
|
"main": "lib/ReactOnRails.full.js",
|
|
6
6
|
"type": "module",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"./ServerComponentFetchError": "./lib/ServerComponentFetchError.js"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"react-on-rails": "16.
|
|
50
|
+
"react-on-rails": "16.6.0-rc.1"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"react": ">= 16",
|