react-on-rails-pro 16.6.0-rc.0 → 16.6.0

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,88 +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);
39
- const didSetLegacySsrFlagRef = 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
+ };
40
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.
41
125
  const router = options.createRouter();
42
126
  // Set browser history for client-side navigation
43
127
  const browserHistory = createBrowserHistory();
44
128
  router.update({ history: browserHistory });
45
- // Hydrate router with dehydrated state from server.
46
- if (hasSsrRouter) {
47
- // RouterClient will hydrate the route matches from window.$_TSR.
48
- }
49
- else if (hasDehydratedRouter && typeof router.hydrate === 'function') {
50
- router.hydrate(dehydratedState.dehydratedRouter);
51
- }
52
- else if (hasDehydratedRouter) {
53
- throw new Error('react-on-rails-pro/tanstack-router: Cannot hydrate SSR payload. ' +
54
- 'router.hydrate() is required but not available on your TanStack Router version. ' +
55
- 'Ensure @tanstack/react-router >=1.139.0 <2.0.0 is installed and provides router.hydrate().');
56
- }
57
- // Legacy hydration path only: signal SSR mode so the Transitioner skips its
58
- // initial router.load() call, preventing a hydration mismatch. The object
59
- // shape matches TanStack Router's internal $_TSR hydration contract (the
60
- // Transitioner only checks truthiness). The new ssrRouter/RouterClient path
61
- // does not need this RouterClient sets router.ssr internally via its own
62
- // hydrate() function.
63
- if (!hasSsrRouter && hasSsrPayload && !router.ssr) {
64
- router.ssr = { manifest: undefined };
65
- didSetLegacySsrFlagRef.current = 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
+ }
66
219
  }
67
220
  routerRef.current = router;
68
221
  }
69
222
  const router = routerRef.current;
70
- if (hasSsrRouter && typeof window !== 'undefined' && !didInitializeSsrGlobalRef.current) {
71
- setTanStackSsrGlobal(ssrRouter);
72
- didInitializeSsrGlobalRef.current = true;
73
- }
74
223
  // After mount, trigger router.load() to enable client-side navigation.
75
224
  // The SSR flag prevented auto-loading, so we do it manually here.
76
225
  useEffect(() => {
77
- if (!router) {
78
- return undefined;
79
- }
80
- if (hasSsrRouter) {
81
- return undefined;
82
- }
83
- // Only SSR hydration needs a manual load call.
84
- // For client-only renders, Transitioner handles initial loading.
85
- if (!hasSsrPayload) {
226
+ if (!router || !hasSsrPayload) {
86
227
  return undefined;
87
228
  }
88
229
  if (didTriggerPostHydrationLoadRef.current) {
@@ -90,50 +231,83 @@ railsContext: _railsContext, RouterProvider, RouterClient, createBrowserHistory,
90
231
  }
91
232
  didTriggerPostHydrationLoadRef.current = true;
92
233
  let cancelled = false;
93
- // `cancelled` only suppresses logging for a discarded mount. The in-flight load still
94
- // completes unless the router exposes a best-effort cancelLoad() hook.
95
- router
96
- .load()
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()
97
253
  .catch((err) => {
98
254
  if (!cancelled) {
99
255
  console.error('react-on-rails-pro/tanstack-router: Error loading routes after hydration:', err);
100
256
  }
101
257
  })
102
258
  .finally(() => {
103
- // Legacy hydration only: clear the temporary SSR hint after the first
104
- // client load has completed so it cannot influence later navigations.
105
- // Only clear when this module set it, so pre-existing router.ssr state
106
- // from user code or upstream router internals is preserved.
107
- if (didSetLegacySsrFlagRef.current) {
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) {
108
267
  router.ssr = undefined;
109
- didSetLegacySsrFlagRef.current = false;
268
+ didSetSsrFlagRef.current = false;
110
269
  }
111
270
  });
112
271
  return () => {
113
272
  cancelled = true;
273
+ if (didSetSsrFlagRef.current) {
274
+ router.ssr = undefined;
275
+ didSetSsrFlagRef.current = false;
276
+ }
114
277
  const cancellableRouter = router;
115
278
  if (typeof cancellableRouter.cancelLoad === 'function') {
116
279
  cancellableRouter.cancelLoad();
117
280
  }
118
281
  };
119
- }, [hasSsrPayload, hasSsrRouter, router]);
120
- const RouterRoot = hasSsrRouter && typeof window !== 'undefined' && RouterClient ? RouterClient : RouterProvider;
121
- 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 })));
122
298
  if (options.AppWrapper) {
123
299
  const wrapperProps = { ...incomingProps };
124
- // eslint-disable-next-line no-underscore-dangle -- Internal hydration payload key should not reach user AppWrapper props.
125
300
  delete wrapperProps.__tanstackRouterDehydratedState;
126
301
  app = createElement(options.AppWrapper, wrapperProps, app);
127
302
  }
128
303
  return app;
129
304
  }
130
- export function clientHydrateTanStackApp(options, props, railsContext, RouterProvider, RouterClient, createBrowserHistory) {
305
+ export function clientHydrateTanStackApp(options, props, railsContext, RouterProvider, createBrowserHistory) {
131
306
  return createElement(TanStackHydrationApp, {
132
307
  options,
133
308
  incomingProps: props,
134
309
  railsContext,
135
310
  RouterProvider,
136
- RouterClient,
137
311
  createBrowserHistory,
138
312
  });
139
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
@@ -76,6 +76,8 @@ export async function serverRenderTanStackAppAsync(options, props, railsContext,
76
76
  const dehydratedState = {
77
77
  url,
78
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.
79
81
  ssrRouter: buildSsrRouterState(router),
80
82
  };
81
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,6 +29,9 @@ export interface TanStackRouter {
23
29
  };
24
30
  dehydrate?: () => unknown;
25
31
  hydrate?: (data: unknown) => void;
32
+ options?: {
33
+ hydrate?: (dehydratedData: unknown) => Promise<unknown> | unknown;
34
+ };
26
35
  ssr?: {
27
36
  manifest?: unknown;
28
37
  };
@@ -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.6.0-rc.0",
3
+ "version": "16.6.0",
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.6.0-rc.0"
50
+ "react-on-rails": "16.6.0"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "react": ">= 16",