react-on-rails-pro 16.7.0-rc.2 → 16.7.0-rc.3

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,6 +1,7 @@
1
1
  import * as React from 'react';
2
2
  /* eslint-disable import/prefer-default-export, no-underscore-dangle */
3
3
  const { createElement, useEffect, useRef } = React;
4
+ const sharedHydrationInitStates = new WeakMap();
4
5
  function extractDehydratedData(dehydratedRouter) {
5
6
  if (!dehydratedRouter || typeof dehydratedRouter !== 'object') {
6
7
  return undefined;
@@ -33,6 +34,77 @@ function preloadMatchedRouteChunks(router, matches) {
33
34
  console.error('react-on-rails-pro/tanstack-router: Error preloading matched route chunks:', error);
34
35
  });
35
36
  }
37
+ // Each guard repeats the matchRoutes check so TypeScript narrows correctly
38
+ // when either hydration path is used independently.
39
+ function hasLegacyHydrationStore(router) {
40
+ return typeof router.matchRoutes === 'function' && typeof router.__store?.setState === 'function';
41
+ }
42
+ function hasStoresHydrationApi(router) {
43
+ const { stores } = router;
44
+ return (typeof router.matchRoutes === 'function' &&
45
+ typeof stores?.status?.set === 'function' &&
46
+ typeof stores?.resolvedLocation?.set === 'function' &&
47
+ typeof stores?.setMatches === 'function');
48
+ }
49
+ function hasHydrationInternals(router) {
50
+ // The ordering here is load-bearing: legacy `__store` wins when both APIs
51
+ // are present, so routers exposing both during a TanStack upgrade keep
52
+ // taking the well-tested __store.setState path until the legacy API is
53
+ // removed. applyHydrationMatches mirrors this preference at the call site.
54
+ return hasLegacyHydrationStore(router) || hasStoresHydrationApi(router);
55
+ }
56
+ function throwMissingHydrationInternals() {
57
+ throw new Error('react-on-rails-pro/tanstack-router: router.matchRoutes() and router.__store.setState() or ' +
58
+ 'router.stores.setMatches() are required but not available. Ensure @tanstack/react-router ' +
59
+ '>=1.139.0 <2.0.0 is installed; older 1.x routers expose __store.setState(), while newer ' +
60
+ '1.x routers expose stores.setMatches().');
61
+ }
62
+ /**
63
+ * Apply server-rendered route matches to the router's internal hydration state.
64
+ *
65
+ * Precondition: callers must validate the router with `hasHydrationInternals(router)`
66
+ * before invoking this; the union parameter type encodes that contract so TypeScript
67
+ * enforces it at the call site.
68
+ */
69
+ function applyHydrationMatches(router, matches) {
70
+ if (hasLegacyHydrationStore(router)) {
71
+ router.__store.setState((s) => ({
72
+ ...s,
73
+ status: 'idle',
74
+ resolvedLocation: s.location,
75
+ matches,
76
+ }));
77
+ return;
78
+ }
79
+ // Legacy path didn't match, so the union narrows to TanStackRouterStoresHydrationInternals.
80
+ const applyStoresUpdate = () => {
81
+ router.stores.status.set('idle');
82
+ // The freshly-created router has not rendered or awaited work yet, so
83
+ // router.state.location matches the legacy __store updater's s.location.
84
+ // Invariant: router.update({ history }) does not mutate state.location synchronously;
85
+ // if that ever changes, this path and the legacy __store path diverge.
86
+ router.stores.resolvedLocation.set(router.state.location);
87
+ router.stores.setMatches(matches);
88
+ };
89
+ if (typeof router.batch === 'function') {
90
+ router.batch(applyStoresUpdate);
91
+ return;
92
+ }
93
+ // Without router.batch, the stores API cannot make these writes atomic like
94
+ // legacy __store.setState(); this render-phase update runs before
95
+ // RouterProvider subscribes, so hydration still starts from the final state.
96
+ // NOTE: correctness here depends on RouterProvider not subscribing to stores
97
+ // during synchronous render. Re-validate this path on TanStack Router major upgrades.
98
+ // In practice router.batch is present in the supported range (>=1.139.0), so this
99
+ // branch is a defensive belt-and-suspenders fallback rather than an expected runtime
100
+ // path — the dev warning below should not fire on a correctly pinned dependency.
101
+ if (process.env.NODE_ENV !== 'production') {
102
+ console.warn('react-on-rails-pro/tanstack-router: router.batch is unavailable; stores hydration writes ' +
103
+ 'are not atomic. Upgrade @tanstack/react-router to a version that exposes router.batch ' +
104
+ 'for safer hydration.');
105
+ }
106
+ applyStoresUpdate();
107
+ }
36
108
  /**
37
109
  * Converts a dehydrated match ID (using \0 separator) back to the standard
38
110
  * route ID format (using / separator) used by matchRoutes().
@@ -74,9 +146,7 @@ function applyDehydratedMatchData(matches, ssrMatches, onMissingSsrMatch) {
74
146
  return m;
75
147
  });
76
148
  }
77
- function TanStackHydrationApp({ options, incomingProps,
78
- // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Required by TanStackHydrationAppProps interface.
79
- railsContext: _railsContext, RouterProvider, createBrowserHistory, }) {
149
+ function TanStackHydrationApp({ options, incomingProps, railsContext: _railsContext, RouterProvider, createBrowserHistory, }) {
80
150
  const dehydratedState = incomingProps.__tanstackRouterDehydratedState;
81
151
  const hasSsrPayload = dehydratedState != null;
82
152
  const hasDehydratedRouter = dehydratedState?.dehydratedRouter !== undefined && dehydratedState.dehydratedRouter !== null;
@@ -113,91 +183,122 @@ railsContext: _railsContext, RouterProvider, createBrowserHistory, }) {
113
183
  // block completes. If React discards this render (StrictMode/concurrency),
114
184
  // the discarded router instance is dropped and a fresh instance is created
115
185
  // and initialized on the next render.
186
+ //
187
+ // Cross-mount invariant: sharedHydrationInitStates dedupes per-router
188
+ // side effects (loadRouteChunk, __store.setState, options.hydrate) across
189
+ // React 18 StrictMode's double-render-with-fresh-hooks behavior — which
190
+ // resets useRef on each pass, so this `routerRef.current === null` guard
191
+ // alone fires twice when options.createRouter returns the same instance.
116
192
  const router = options.createRouter();
117
- // Set browser history for client-side navigation
118
- const browserHistory = createBrowserHistory();
119
- router.update({ history: browserHistory });
120
- // Only apply SSR hydration when a server payload exists.
121
- // Client-only renders (prerender: false) must not set router.ssr or
122
- // inject matches the Transitioner handles initial loading for those.
123
- if (hasSsrPayload) {
124
- if (process.env.NODE_ENV === 'development' && !didWarnPrivateInternalsRef.current) {
125
- didWarnPrivateInternalsRef.current = true;
126
- console.warn('react-on-rails-pro/tanstack-router: Hydration uses TanStack Router private internals ' +
127
- '(matchRoutes, __store, loadRouteChunk, looseRoutesById). Keep @tanstack/react-router ' +
128
- 'within the supported range (>=1.139.0 <2.0.0) and run integration tests when upgrading.');
129
- }
130
- // Validate internal APIs before using them.
131
- if (typeof router.matchRoutes !== 'function' || !router.__store?.setState) {
132
- throw new Error('react-on-rails-pro/tanstack-router: router.matchRoutes() and router.__store are required ' +
133
- 'but not available. Ensure @tanstack/react-router >=1.139.0 <2.0.0 is installed.');
134
- }
135
- const hydrationRouter = router;
136
- // Synchronously inject route matches to match server-rendered output.
137
- // The server fully loads routes (via router.load()) before rendering, so
138
- // all matches are resolved. We replicate this on the client so the initial
139
- // render produces the same component tree as the server HTML.
140
- //
141
- // When ssrRouter match data is available (from serverRenderTanStackAppAsync),
142
- // we apply loaderData, beforeLoadContext, status, etc. from the server payload
143
- // so routes that render from loader results can hydrate correctly.
144
- // Otherwise we override 'pending' to 'success' to prevent MatchInner from
145
- // throwing loadPromise (which would cause Suspense suspension).
146
- const rawMatches = hydrationRouter.matchRoutes(hydrationRouter.state.location);
147
- routeChunkPreloadPromiseRef.current = preloadMatchedRouteChunks(router, rawMatches);
148
- const ssrMatches = dehydratedState?.ssrRouter?.matches;
149
- const matches = ssrMatches?.length
150
- ? applyDehydratedMatchData(rawMatches, ssrMatches, warnMissingSsrMatch)
151
- : rawMatches.map((match) => {
152
- const m = match;
153
- if (m.status === 'pending') {
154
- warnMissingSsrMatch(m);
155
- return { ...m, status: 'success' };
156
- }
157
- return m;
158
- });
159
- // Render-phase store injection is required for hydration parity: this
160
- // must happen before the first RouterProvider render.
161
- hydrationRouter.__store.setState((s) => ({
162
- ...s,
163
- status: 'idle',
164
- resolvedLocation: s.location,
165
- matches,
166
- }));
167
- // Set SSR flag so the Transitioner skips its initial router.load() call,
168
- // preventing a state update during hydration that would cause a mismatch.
169
- // The shape matches TanStack Router's internal $_TSR hydration contract
170
- // (the Transitioner only checks truthiness).
171
- // Preserve user-set values from createRouter() (e.g. TanStack Start).
172
- if (!router.ssr) {
173
- router.ssr = { manifest: undefined };
174
- didSetSsrFlagRef.current = true;
193
+ const cachedInit = sharedHydrationInitStates.get(router);
194
+ if (cachedInit) {
195
+ // Same router instance was already initialized by a discarded render
196
+ // (or prior mount). Reattach the pending preload/hydrate promises to
197
+ // this mount's refs so the post-hydration effect awaits the original
198
+ // work; restore didSetSsrFlag so cleanup correctly clears router.ssr.
199
+ routeChunkPreloadPromiseRef.current = cachedInit.routeChunkPreloadPromise;
200
+ hydrationCallbackPromiseRef.current = cachedInit.hydrationCallbackPromise;
201
+ didSetSsrFlagRef.current = cachedInit.didSetSsrFlag;
202
+ }
203
+ else {
204
+ // Set browser history for client-side navigation
205
+ const browserHistory = createBrowserHistory();
206
+ // Snapshot state.location before router.update so the dev-mode assertion
207
+ // below can verify the equivalence the stores hydration path relies on
208
+ // (see applyHydrationMatches: router.stores.resolvedLocation.set
209
+ // assumes router.update does not mutate state.location synchronously).
210
+ const locationBeforeUpdate = process.env.NODE_ENV !== 'production' ? router.state.location : undefined;
211
+ router.update({ history: browserHistory });
212
+ if (process.env.NODE_ENV !== 'production' &&
213
+ hasStoresHydrationApi(router) &&
214
+ // Identity check only: an in-place mutation of the same location object
215
+ // would slip past this guard. The dev warning is best-effort.
216
+ router.state.location !== locationBeforeUpdate) {
217
+ console.warn('react-on-rails-pro/tanstack-router: router.update({ history }) mutated router.state.location ' +
218
+ 'synchronously. The stores hydration path writes router.state.location to ' +
219
+ 'router.stores.resolvedLocation, which would diverge from the legacy __store path that ' +
220
+ 'snapshots the pre-update s.location. File an issue with your @tanstack/react-router version.');
175
221
  }
176
- try {
177
- // Run user-defined hydration callback for custom dehydratedData
178
- // (for example external query/cache payloads), matching TanStack
179
- // Router's ssr-client behavior.
180
- if (typeof router.options?.hydrate === 'function') {
181
- const hydrationResult = router.options.hydrate(extractDehydratedData(dehydratedState?.dehydratedRouter));
182
- // Let async hydration failures reject so we do not continue into
183
- // router.load() with partially hydrated client state.
184
- hydrationCallbackPromiseRef.current = Promise.resolve(hydrationResult).then(() => undefined);
222
+ // Only apply SSR hydration when a server payload exists.
223
+ // Client-only renders (prerender: false) must not set router.ssr or
224
+ // inject matches the Transitioner handles initial loading for those.
225
+ if (hasSsrPayload) {
226
+ if (process.env.NODE_ENV === 'development' && !didWarnPrivateInternalsRef.current) {
227
+ didWarnPrivateInternalsRef.current = true;
228
+ console.warn('react-on-rails-pro/tanstack-router: Hydration uses TanStack Router private internals ' +
229
+ '(matchRoutes, __store/stores, loadRouteChunk, looseRoutesById). Keep @tanstack/react-router ' +
230
+ 'within the supported range (>=1.139.0 <2.0.0) and run integration tests when upgrading.');
185
231
  }
186
- // Backward-compatibility hook: if user router exposes router.hydrate(),
187
- // invoke it with the full dehydrated router payload.
188
- if (hasDehydratedRouter && typeof router.hydrate === 'function') {
189
- router.hydrate(dehydratedState.dehydratedRouter);
232
+ // Validate internal APIs before using them.
233
+ if (!hasHydrationInternals(router)) {
234
+ throwMissingHydrationInternals();
190
235
  }
191
- }
192
- catch (error) {
193
- // If render-phase hydration throws, clear only the temporary SSR flag
194
- // created by this module so retries are not blocked.
195
- if (didSetSsrFlagRef.current) {
196
- router.ssr = undefined;
197
- didSetSsrFlagRef.current = false;
236
+ // Synchronously inject route matches to match server-rendered output.
237
+ // The server fully loads routes (via router.load()) before rendering, so
238
+ // all matches are resolved. We replicate this on the client so the initial
239
+ // render produces the same component tree as the server HTML.
240
+ //
241
+ // When ssrRouter match data is available (from serverRenderTanStackAppAsync),
242
+ // we apply loaderData, beforeLoadContext, status, etc. from the server payload
243
+ // so routes that render from loader results can hydrate correctly.
244
+ // Otherwise we override 'pending' to 'success' to prevent MatchInner from
245
+ // throwing loadPromise (which would cause Suspense suspension).
246
+ const rawMatches = router.matchRoutes(router.state.location);
247
+ routeChunkPreloadPromiseRef.current = preloadMatchedRouteChunks(router, rawMatches);
248
+ const ssrMatches = dehydratedState?.ssrRouter?.matches;
249
+ const matches = ssrMatches?.length
250
+ ? applyDehydratedMatchData(rawMatches, ssrMatches, warnMissingSsrMatch)
251
+ : rawMatches.map((match) => {
252
+ const m = match;
253
+ if (m.status === 'pending') {
254
+ warnMissingSsrMatch(m);
255
+ return { ...m, status: 'success' };
256
+ }
257
+ return m;
258
+ });
259
+ // Render-phase store injection is required for hydration parity: this
260
+ // must happen before the first RouterProvider render.
261
+ applyHydrationMatches(router, matches);
262
+ // Set SSR flag so the Transitioner skips its initial router.load() call,
263
+ // preventing a state update during hydration that would cause a mismatch.
264
+ // The shape matches TanStack Router's internal $_TSR hydration contract
265
+ // (the Transitioner only checks truthiness).
266
+ // Preserve user-set values from createRouter() (e.g. TanStack Start).
267
+ if (!router.ssr) {
268
+ router.ssr = { manifest: undefined };
269
+ didSetSsrFlagRef.current = true;
270
+ }
271
+ try {
272
+ // Run user-defined hydration callback for custom dehydratedData
273
+ // (for example external query/cache payloads), matching TanStack
274
+ // Router's ssr-client behavior.
275
+ if (typeof router.options?.hydrate === 'function') {
276
+ const hydrationResult = router.options.hydrate(extractDehydratedData(dehydratedState?.dehydratedRouter));
277
+ // Let async hydration failures reject so we do not continue into
278
+ // router.load() with partially hydrated client state.
279
+ hydrationCallbackPromiseRef.current = Promise.resolve(hydrationResult).then(() => undefined);
280
+ }
281
+ // Backward-compatibility hook: if user router exposes router.hydrate(),
282
+ // invoke it with the full dehydrated router payload.
283
+ if (hasDehydratedRouter && typeof router.hydrate === 'function') {
284
+ router.hydrate(dehydratedState.dehydratedRouter);
285
+ }
286
+ }
287
+ catch (error) {
288
+ // If render-phase hydration throws, clear only the temporary SSR flag
289
+ // created by this module so retries are not blocked.
290
+ if (didSetSsrFlagRef.current) {
291
+ router.ssr = undefined;
292
+ didSetSsrFlagRef.current = false;
293
+ }
294
+ throw error;
198
295
  }
199
- throw error;
200
296
  }
297
+ sharedHydrationInitStates.set(router, {
298
+ routeChunkPreloadPromise: routeChunkPreloadPromiseRef.current,
299
+ hydrationCallbackPromise: hydrationCallbackPromiseRef.current,
300
+ didSetSsrFlag: didSetSsrFlagRef.current,
301
+ });
201
302
  }
202
303
  routerRef.current = router;
203
304
  }
@@ -267,6 +368,14 @@ railsContext: _railsContext, RouterProvider, createBrowserHistory, }) {
267
368
  if (latestEffectRunIdRef.current === effectRunId && didSetSsrFlagRef.current) {
268
369
  router.ssr = undefined;
269
370
  didSetSsrFlagRef.current = false;
371
+ // Keep sharedHydrationInitStates in sync so a later mount of the
372
+ // same cached router doesn't restore a stale didSetSsrFlag=true and
373
+ // trigger the dev sanity-check warning below on a router whose ssr
374
+ // flag was already cleared correctly.
375
+ const cached = sharedHydrationInitStates.get(router);
376
+ if (cached) {
377
+ cached.didSetSsrFlag = false;
378
+ }
270
379
  }
271
380
  });
272
381
  return () => {
@@ -278,6 +387,10 @@ railsContext: _railsContext, RouterProvider, createBrowserHistory, }) {
278
387
  if (latestEffectRunIdRef.current === effectRunId && didSetSsrFlagRef.current) {
279
388
  router.ssr = undefined;
280
389
  didSetSsrFlagRef.current = false;
390
+ const cached = sharedHydrationInitStates.get(router);
391
+ if (cached) {
392
+ cached.didSetSsrFlag = false;
393
+ }
281
394
  }
282
395
  });
283
396
  const cancellableRouter = router;
@@ -2,6 +2,14 @@ import { createElement } from 'react';
2
2
  import { normalizeSearch } from "./utils.js";
3
3
  /**
4
4
  * Builds a React element tree with RouterProvider and optional AppWrapper.
5
+ *
6
+ * No <Suspense> boundary is inserted here. The client hydration tree renders
7
+ * RouterProvider directly without a wrapping <Suspense>, so introducing one
8
+ * on the server would emit `<!--$-->`/`<!--/$-->` markers (React 19's
9
+ * `renderToString` emits these for every Suspense boundary, even
10
+ * non-suspended ones) and break hydration parity. If RouterProvider suspends
11
+ * during SSR, React's own `renderToString` throws synchronously — that is
12
+ * already a loud failure mode and does not need a custom guard.
5
13
  */
6
14
  function buildAppElement(router, RouterProvider, AppWrapper, wrapperProps) {
7
15
  let app = createElement(RouterProvider, { router });
@@ -1,4 +1,15 @@
1
1
  import type { ComponentType, ReactNode } from 'react';
2
+ /**
3
+ * Shape of a writable TanStack store atom. Used by the modern `router.stores`
4
+ * API exposed on TanStackRouter below.
5
+ *
6
+ * `set` is declared as an overload to match the upstream `@tanstack/store`
7
+ * `Atom.set` signature (which is an overload, not a union parameter); a union
8
+ * parameter would not be structurally assignable from the upstream type.
9
+ */
10
+ export type TanStackRouterWritableStore<TValue = unknown> = {
11
+ set: ((value: TValue) => void) & ((updater: (prev: TValue) => TValue) => void);
12
+ };
2
13
  /**
3
14
  * Minimal type for TanStack Router instance.
4
15
  * We use this instead of importing @tanstack/react-router directly
@@ -13,6 +24,12 @@ export interface TanStackRouter {
13
24
  __store?: {
14
25
  setState: (updater: (s: Record<string, unknown>) => Record<string, unknown>) => void;
15
26
  };
27
+ stores?: {
28
+ status: TanStackRouterWritableStore<'idle' | 'pending'>;
29
+ resolvedLocation: TanStackRouterWritableStore<TanStackRouter['state']['location']>;
30
+ setMatches: (nextMatches: unknown[]) => void;
31
+ };
32
+ batch?: (callback: () => void) => void;
16
33
  looseRoutesById?: Record<string, unknown>;
17
34
  loadRouteChunk?: (route: unknown) => Promise<unknown>;
18
35
  state: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-on-rails-pro",
3
- "version": "16.7.0-rc.2",
3
+ "version": "16.7.0-rc.3",
4
4
  "description": "React on Rails Pro package with React Server Components support",
5
5
  "main": "lib/ReactOnRails.full.js",
6
6
  "type": "module",
@@ -48,7 +48,7 @@
48
48
  "./ServerComponentFetchError": "./lib/ServerComponentFetchError.js"
49
49
  },
50
50
  "dependencies": {
51
- "react-on-rails": "16.7.0-rc.2"
51
+ "react-on-rails": "16.7.0-rc.3"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "react": ">= 16",
@@ -74,6 +74,8 @@
74
74
  },
75
75
  "homepage": "https://reactonrails.com/docs/pro/",
76
76
  "devDependencies": {
77
+ "@tanstack/react-router": "1.163.3",
78
+ "@tanstack/store": "0.9.1",
77
79
  "@types/mock-fs": "^4.13.4",
78
80
  "mock-fs": "^5.5.0",
79
81
  "react": "^19.0.3",