@octanejs/tanstack-router 0.1.58 → 0.1.60

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/tanstack-router",
3
- "version": "0.1.58",
3
+ "version": "0.1.60",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -58,7 +58,7 @@
58
58
  "isbot": "^5.1.22"
59
59
  },
60
60
  "peerDependencies": {
61
- "octane": "^0.5.0"
61
+ "octane": "^0.7.0"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@tanstack/react-router": "1.170.18",
@@ -67,7 +67,7 @@
67
67
  "react": "19.2.7",
68
68
  "react-dom": "19.2.7",
69
69
  "vitest": "^4.1.10",
70
- "octane": "0.5.0"
70
+ "octane": "0.7.0"
71
71
  },
72
72
  "scripts": {
73
73
  "test": "vitest run"
package/src/Asset.tsrx CHANGED
@@ -14,6 +14,11 @@ type ManagedAssetProps = AssetProps & { managedKey?: string };
14
14
 
15
15
  const MANAGED_KEY_ATTR = 'data-tsr-managed-key';
16
16
 
17
+ // Elements currently owned by a mounted Asset. A managed key is a 32-bit
18
+ // content hash, so a collision must not let a second Asset adopt, and later
19
+ // remove, an element another Asset still owns.
20
+ const claimedElements = new WeakSet<Element>();
21
+
17
22
  function attributeName(name: string) {
18
23
  switch (name) {
19
24
  case 'charSet':
@@ -46,20 +51,28 @@ function setAttributes(element: Element, attrs: RouterManagedTag['attrs']) {
46
51
 
47
52
  function findManagedElement(parent: HTMLElement, key: string) {
48
53
  for (const element of parent.querySelectorAll(`[${MANAGED_KEY_ATTR}]`)) {
49
- if (element.getAttribute(MANAGED_KEY_ATTR) === key) {
54
+ if (element.getAttribute(MANAGED_KEY_ATTR) === key && !claimedElements.has(element)) {
50
55
  return element;
51
56
  }
52
57
  }
53
58
  return undefined;
54
59
  }
55
60
 
61
+ function claim(element: Element) {
62
+ claimedElements.add(element);
63
+ return () => {
64
+ claimedElements.delete(element);
65
+ element.remove();
66
+ };
67
+ }
68
+
56
69
  function mountAsset(props: ManagedAssetProps) {
57
70
  const parent =
58
71
  props.target === 'head' ? document.head : document.body;
59
72
  const managedKey = props.managedKey ?? props.assetKey;
60
73
  const existing = findManagedElement(parent, managedKey);
61
74
  if (existing) {
62
- return () => existing.remove();
75
+ return claim(existing);
63
76
  }
64
77
 
65
78
  const element = document.createElement(props.tag);
@@ -69,7 +82,7 @@ function mountAsset(props: ManagedAssetProps) {
69
82
  element.textContent = props.children;
70
83
  }
71
84
  parent.appendChild(element);
72
- return () => element.remove();
85
+ return claim(element);
73
86
  }
74
87
 
75
88
  export function Asset(props: AssetProps) @{
@@ -78,12 +91,16 @@ export function Asset(props: AssetProps) @{
78
91
  const managedKey = (props as ManagedAssetProps).managedKey ?? props.assetKey;
79
92
 
80
93
  if (!(isServer ?? router.isServer)) {
94
+ // Compare attrs by value: a rebuilt tag list allocates new attrs objects on
95
+ // every navigation, and an identity dependency would remove and re-add
96
+ // unchanged assets such as the stylesheet.
97
+ const attrsKey = JSON.stringify(props.attrs ?? null);
81
98
  useLayoutEffect(() => mountAsset(props), [
82
99
  props.assetKey,
83
100
  managedKey,
84
- props.attrs,
85
- props.children,
86
101
  props.tag,
102
+ attrsKey,
103
+ props.children,
87
104
  props.target,
88
105
  ]);
89
106
  }
@@ -3,7 +3,7 @@ import { DEV_STYLES_ATTR } from '@tanstack/router-core';
3
3
  import type { AssetCrossOriginConfig } from '@tanstack/router-core';
4
4
  import { Asset } from './Asset.tsrx';
5
5
  import type { AssetProps } from './Asset.tsrx';
6
- import { getAssetKey, getManagedAssetKey } from './assetKeys.ts';
6
+ import { getAssetKeys } from './assetKeys.ts';
7
7
  import { useTags } from './headContentUtils.ts';
8
8
  import { useHydrated } from './ClientOnly.tsrx';
9
9
 
@@ -27,15 +27,16 @@ export function HeadContent(props: HeadContentProps) @{
27
27
  process.env.NODE_ENV !== 'production' && hydrated
28
28
  ? tags.filter((tag) => tag.tag !== 'link' || tag.attrs?.[DEV_STYLES_ATTR] !== true)
29
29
  : tags;
30
+ const keys = getAssetKeys('head', owner, filteredTags);
30
31
  <>
31
32
  {filteredTags.map((tag, index) => {
32
- const assetKey = getAssetKey('head', tag, index);
33
+ const { assetKey, managedKey } = keys[index]!;
33
34
  // `key` is lifted out of props by createElement (React semantics); the
34
35
  // explicit type argument admits it alongside the AssetProps shape.
35
36
  return createElement<AssetProps & { key?: string; managedKey?: string }>(Asset, {
36
37
  ...tag,
37
38
  assetKey,
38
- managedKey: getManagedAssetKey('head', owner, index),
39
+ managedKey,
39
40
  target: 'head',
40
41
  key: assetKey,
41
42
  });
package/src/Match.tsrx CHANGED
@@ -9,15 +9,15 @@
9
9
  // component for it — otherwise `SafeFragment` passes through so suspensions and
10
10
  // errors bubble to the nearest ancestor boundary (upstream's
11
11
  // ResolvedSuspenseBoundary/ResolvedCatchBoundary/ResolvedNotFoundBoundary).
12
- // `MatchInner` handles the match STATUS: it suspends (octane `use`, upstream
13
- // `throw promise`) on pending/redirected/_displayPending/_forcePending, renders
12
+ // `MatchInner` handles the match STATUS: it suspends (a thrown promise, as
13
+ // upstream) on pending/redirected/_displayPending/_forcePending, renders
14
14
  // the route's not-found UI for `status === 'notFound'`, throws to the
15
15
  // CatchBoundary for `status === 'error'`, and otherwise renders the route
16
16
  // component (or `<Outlet/>` for component-less layout routes) keyed by
17
17
  // remountDeps. `OnRendered` (rendered by the match directly below the root)
18
18
  // emits the router's `onRendered` event after the subtree commits — scroll
19
19
  // restoration restores on it.
20
- import { useRef, useLayoutEffect, use, createElement, Suspense } from 'octane';
20
+ import { useRef, useLayoutEffect, createElement, Suspense } from 'octane';
21
21
  import {
22
22
  createControlledPromise,
23
23
  getLocationChangeInfo,
@@ -143,12 +143,17 @@ function MatchInner(props: { matchId: string }) {
143
143
  const getMatchPromise = (key: 'displayPendingPromise' | 'minPendingPromise' | 'loadPromise') =>
144
144
  router.getMatch(match.id)?._nonReactive[key] ?? match._nonReactive[key];
145
145
 
146
+ // Suspend by throwing, like upstream, not with `use()`. Each match state
147
+ // waits on a different promise, and `use()` is positional: a replay that
148
+ // finds the match in a new state would get the promise stored for the old
149
+ // one. Router-core's promises settle to `status: 'resolved'`, which `use()`
150
+ // reads as still pending, so that stored promise retries forever.
146
151
  if (match._displayPending) {
147
- use(getMatchPromise('displayPendingPromise')!);
152
+ throw getMatchPromise('displayPendingPromise')!;
148
153
  }
149
154
 
150
155
  if (match._forcePending) {
151
- use(getMatchPromise('minPendingPromise')!);
156
+ throw getMatchPromise('minPendingPromise')!;
152
157
  }
153
158
 
154
159
  if (match.status === 'pending') {
@@ -165,7 +170,7 @@ function MatchInner(props: { matchId: string }) {
165
170
  }, pendingMinMs);
166
171
  }
167
172
  }
168
- use(getMatchPromise('loadPromise')!);
173
+ throw getMatchPromise('loadPromise')!;
169
174
  }
170
175
 
171
176
  if (match.status === 'notFound') {
@@ -173,9 +178,12 @@ function MatchInner(props: { matchId: string }) {
173
178
  }
174
179
 
175
180
  if (match.status === 'redirected') {
176
- // Observed mid-transition while a redirect is in flight — suspend on the
177
- // load so this stale render is abandoned and the redirect completes.
178
- use(getMatchPromise('loadPromise')!);
181
+ // Observed mid-transition while a redirect is in flight — stay suspended so
182
+ // this stale render is abandoned and the redirect completes.
183
+ const atom = matchStore as unknown as Partial<MatchAtom>;
184
+ throw typeof atom.subscribe === 'function'
185
+ ? nextMatchUpdate(atom as MatchAtom, match)
186
+ : getMatchPromise('loadPromise')!;
179
187
  }
180
188
 
181
189
  if (match.status === 'error') {
@@ -199,6 +207,39 @@ function MatchInner(props: { matchId: string }) {
199
207
  return createElement(Outlet, {});
200
208
  }
201
209
 
210
+ interface MatchAtom {
211
+ get: () => object;
212
+ subscribe: (cb: () => void) => { unsubscribe: () => void };
213
+ }
214
+
215
+ const matchUpdates = new WeakMap<object, Promise<void>>();
216
+
217
+ // Settles on the first store update after `snapshot`. A redirected match waits
218
+ // on this, not on its loadPromise: router-core resolves that promise when it
219
+ // marks the redirect, and a settled promise retries on a microtask until the
220
+ // redirect target has loaded, starving the timers and network callbacks that
221
+ // load waits on. React's scheduler paces those retries; Octane's retry does
222
+ // not. Server atoms cannot subscribe, so the server keeps upstream's promise:
223
+ // a server redirect is answered before the app renders.
224
+ function nextMatchUpdate(store: MatchAtom, snapshot: object): Promise<void> {
225
+ let update = matchUpdates.get(snapshot);
226
+ if (update === undefined) {
227
+ update = new Promise<void>((resolve) => {
228
+ const subscription = store.subscribe(() => {
229
+ subscription.unsubscribe();
230
+ resolve();
231
+ });
232
+ // The store may have moved on between this render and the subscription.
233
+ if (store.get() !== snapshot) {
234
+ subscription.unsubscribe();
235
+ resolve();
236
+ }
237
+ });
238
+ matchUpdates.set(snapshot, update);
239
+ }
240
+ return update;
241
+ }
242
+
202
243
  // Emits the router's `onRendered` event once the subtree below the root layout
203
244
  // has committed (this component renders as a later sibling of the match content,
204
245
  // so its layout effect runs after the subtree's). Tracks the previously-resolved
package/src/Scripts.tsrx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createElement, useEffect, useId, useState } from 'octane';
2
2
  import { isServer } from '@tanstack/router-core/isServer';
3
3
  import type { RouterManagedTag } from '@tanstack/router-core';
4
- import { getAssetKey, getManagedAssetKey } from './assetKeys.ts';
4
+ import { getAssetKeys } from './assetKeys.ts';
5
5
  import { useRouter } from './context.ts';
6
6
  import { useScripts } from './scriptContentUtils.ts';
7
7
 
@@ -29,13 +29,14 @@ export function Scripts() @{
29
29
  ...scripts,
30
30
  ]
31
31
  : scripts;
32
+ const keys = getAssetKeys('body', owner, renderScripts);
32
33
  <>
33
34
  {renderScripts.map((script, index) => {
34
- const assetKey = getAssetKey('body', script, index);
35
+ const { assetKey, managedKey } = keys[index]!;
35
36
  return createElement('script', {
36
37
  ...script.attrs,
37
38
  key: assetKey,
38
- 'data-tsr-managed-key': getManagedAssetKey('body', owner, index),
39
+ 'data-tsr-managed-key': managedKey,
39
40
  dangerouslySetInnerHTML: { __html: script.children ?? '' },
40
41
  });
41
42
  })}
package/src/assetKeys.ts CHANGED
@@ -1,15 +1,45 @@
1
1
  import type { RouterManagedTag } from '@tanstack/router-core';
2
2
 
3
- export function getAssetKey(scope: 'head' | 'body', asset: RouterManagedTag, index: number) {
3
+ export type AssetKeys = { assetKey: string; managedKey: string };
4
+
5
+ function assetContent(asset: RouterManagedTag) {
4
6
  const inlineCss = asset.tag === 'style' && asset.inlineCss;
5
- return `${scope}:${index}:${JSON.stringify({
7
+ return JSON.stringify({
6
8
  tag: asset.tag,
7
9
  attrs: asset.attrs,
8
10
  children: inlineCss ? undefined : asset.children,
9
11
  inlineCss,
10
- })}`;
12
+ });
13
+ }
14
+
15
+ // FNV-1a, so the managed-key attribute stays short on the SSR element.
16
+ function hashContent(content: string) {
17
+ let hash = 0x811c9dc5;
18
+ for (let i = 0; i < content.length; i++) {
19
+ hash ^= content.charCodeAt(i);
20
+ hash = Math.imul(hash, 0x01000193);
21
+ }
22
+ return (hash >>> 0).toString(36);
11
23
  }
12
24
 
13
- export function getManagedAssetKey(scope: 'head' | 'body', owner: string, index: number) {
14
- return `${scope}:${owner}:${index.toString(36)}`;
25
+ // Keys derive from each tag's content, never its list position. A navigation
26
+ // that adds or removes a route's preload links shifts every later tag's index;
27
+ // an index-based key remounts the asset, which removes and re-adds the
28
+ // stylesheet link and flashes unstyled content. Identical tags are told apart
29
+ // by occurrence count.
30
+ export function getAssetKeys(
31
+ scope: 'head' | 'body',
32
+ owner: string,
33
+ assets: ReadonlyArray<RouterManagedTag>,
34
+ ): Array<AssetKeys> {
35
+ const seen = new Map<string, number>();
36
+ return assets.map((asset) => {
37
+ const content = assetContent(asset);
38
+ const occurrence = seen.get(content) ?? 0;
39
+ seen.set(content, occurrence + 1);
40
+ return {
41
+ assetKey: `${scope}:${occurrence}:${content}`,
42
+ managedKey: `${scope}:${owner}:${hashContent(content)}:${occurrence.toString(36)}`,
43
+ };
44
+ });
15
45
  }
@@ -22,15 +22,13 @@ function getHydrationPromise(router: AnyRouter) {
22
22
  return promise;
23
23
  }
24
24
 
25
- function RouterClientInner(props: { router: AnyRouter }) @{
25
+ // Per react-router's RouterClient, which suspends through <Await> without a
26
+ // fallback: no boundary sits above the router. A root that suspends keeps the
27
+ // server DOM while hydrating and commits the whole tree once hydrate() settles.
28
+ // A boundary here would commit an empty fallback, throttle the hydrated tree's
29
+ // reveal as a Suspense retry, and add a range the server's RouterServer never
30
+ // rendered.
31
+ export function RouterClient(props: { router: AnyRouter }) @{
26
32
  use(toExternalHydrationThenable(getHydrationPromise(props.router)));
27
33
  <RouterProvider router={props.router} />
28
34
  }
29
-
30
- export function RouterClient(props: { router: AnyRouter }) @{
31
- @try {
32
- <RouterClientInner router={props.router} />
33
- } @pending {
34
- <></>
35
- }
36
- }