@octanejs/tanstack-router 0.1.59 → 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 +3 -3
- package/src/Asset.tsrx +22 -5
- package/src/HeadContent.tsrx +4 -3
- package/src/Match.tsrx +50 -9
- package/src/Scripts.tsrx +4 -3
- package/src/assetKeys.ts +35 -5
- package/src/ssr/RouterClient.tsrx +7 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octanejs/tanstack-router",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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.
|
|
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 (
|
|
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 (
|
|
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
|
}
|
package/src/HeadContent.tsrx
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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 (
|
|
13
|
-
//
|
|
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,
|
|
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
|
-
|
|
152
|
+
throw getMatchPromise('displayPendingPromise')!;
|
|
148
153
|
}
|
|
149
154
|
|
|
150
155
|
if (match._forcePending) {
|
|
151
|
-
|
|
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
|
-
|
|
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 —
|
|
177
|
-
//
|
|
178
|
-
|
|
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 {
|
|
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
|
|
35
|
+
const { assetKey, managedKey } = keys[index]!;
|
|
35
36
|
return createElement('script', {
|
|
36
37
|
...script.attrs,
|
|
37
38
|
key: assetKey,
|
|
38
|
-
'data-tsr-managed-key':
|
|
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
|
|
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
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
}
|