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.
@@ -1,8 +1,8 @@
1
1
  export declare function renderOrHydrateComponent(domIdOrElement: string | Element): Promise<void>;
2
- export declare const renderOrHydrateImmediateHydratedComponents: () => Promise<void>;
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 hydrateImmediateHydratedStores: () => Promise<void>;
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
- root = new ComponentRenderer(domIdOrElement);
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 later when their
218
- * immediate hydration script executes and calls reactOnRailsComponentLoaded().
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
- export const renderOrHydrateImmediateHydratedComponents = () => forAllCompleteElementsAsync('.js-react-on-rails-component[data-immediate-hydration="true"]', renderOrHydrateComponent);
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
- storeRenderer = new StoreRenderer(storeDataElement);
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 hydrateImmediateHydratedStores = () => forAllCompleteElementsAsync(`[${REACT_ON_RAILS_STORE_ATTRIBUTE}][data-immediate-hydration="true"]`, hydrateStore);
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, renderOrHydrateImmediateHydratedComponents, hydrateImmediateHydratedStores, unmountAll, } from "./ClientSideRenderer.js";
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
- void renderOrHydrateImmediateHydratedComponents();
39
- void hydrateImmediateHydratedStores();
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 with immediate hydration support (only on first initialization)
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 ComponentType, type ReactElement } from 'react';
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
- }>, RouterClient: ComponentType<{
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 setTanStackSsrGlobal(ssrRouter) {
3
- const globalState = window;
4
- const existing = globalState.$_TSR;
5
- const tsrGlobal = existing ?? {
6
- buffer: [],
7
- h() {
8
- this.hydrated = true;
9
- },
10
- e() {
11
- this.streamEnded = true;
12
- },
13
- c() { },
14
- p(script) {
15
- if (!this.initialized) {
16
- this.buffer.push(script);
17
- return;
18
- }
19
- script();
20
- },
21
- };
22
- tsrGlobal.router = ssrRouter;
23
- globalState.$_TSR = tsrGlobal;
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, RouterClient, createBrowserHistory, }) {
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 didInitializeSsrGlobalRef = useRef(false);
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
- // Hydrate router with dehydrated state from server.
45
- if (hasSsrRouter) {
46
- // RouterClient will hydrate the route matches from window.$_TSR.
47
- }
48
- else if (hasDehydratedRouter && typeof router.hydrate === 'function') {
49
- router.hydrate(dehydratedState.dehydratedRouter);
50
- }
51
- else if (hasDehydratedRouter) {
52
- throw new Error('react-on-rails-pro/tanstack-router: Cannot hydrate SSR payload. ' +
53
- 'router.hydrate() is required but not available on your TanStack Router version. ' +
54
- 'Ensure @tanstack/react-router >=1.139.0 <2.0.0 is installed and provides router.hydrate().');
55
- }
56
- // Keep SSR mode enabled on hydration paths so Transitioner does not run
57
- // a client-only initial load before hydration settles.
58
- if (!hasSsrRouter && hasSsrPayload && router.ssr !== true) {
59
- router.ssr = true;
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
- // `cancelled` only suppresses logging for a discarded mount. The in-flight load still
88
- // completes unless the router exposes a best-effort cancelLoad() hook.
89
- router.load().catch((err) => {
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, hasSsrRouter, router]);
102
- const RouterRoot = hasSsrRouter && typeof window !== 'undefined' && RouterClient ? RouterClient : RouterProvider;
103
- let app = createElement(RouterRoot, { router });
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, RouterClient, createBrowserHistory) {
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
- * Optional RouterClient component from @tanstack/react-router/ssr/client.
47
- * When provided, it enables TanStack Router's SSR client hydration path for
48
- * router versions that do not expose router.dehydrate()/router.hydrate().
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
- return clientHydrateTanStackApp(options, clientProps, railsContext, RouterProvider, RouterClient, createBrowserHistory);
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
- ssr?: boolean | {
27
- manifest: unknown;
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 Router SSR match payload used by RouterClient hydration */
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.5.1",
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.5.1"
50
+ "react-on-rails": "16.6.0-rc.1"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "react": ">= 16",