@octanejs/tanstack-router 0.2.8 → 0.2.9
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/README.md +42 -22
- package/package.json +2 -2
- package/src/Asset.tsrx +3 -135
- package/src/ClientOnly.tsrx +2 -0
- package/src/Link.tsrx +2 -53
- package/src/Matches.tsrx +6 -4
- package/src/Scripts.tsrx +2 -19
- package/src/Transitioner.tsrx +5 -4
- package/src/assetComponent.tsrx +146 -0
- package/src/compiled/Document.tsrx +101 -0
- package/src/compiled/Link.tsrx +18 -0
- package/src/compiled/Matches.tsrx +28 -19
- package/src/compiled/bindings.ts +2 -0
- package/src/compiled/route.tsrx +71 -0
- package/src/compiled.ts +36 -1
- package/src/fileRoute.ts +7 -58
- package/src/headContentBinding.ts +178 -0
- package/src/headContentUtils.ts +2 -170
- package/src/internal.ts +2 -0
- package/src/link.ts +5 -379
- package/src/linkBinding.ts +391 -0
- package/src/linkComponent.tsrx +61 -0
- package/src/route.ts +6 -51
- package/src/routeHookBindings.ts +146 -0
- package/src/scriptComponent.tsrx +27 -0
- package/src/scriptContentBinding.ts +43 -0
- package/src/scriptContentUtils.ts +2 -35
package/README.md
CHANGED
|
@@ -135,26 +135,26 @@ package's `status.json`).
|
|
|
135
135
|
synthetic events.
|
|
136
136
|
- Router devtools are distributed separately and are not part of this binding.
|
|
137
137
|
|
|
138
|
-
## Compiled
|
|
138
|
+
## Compiled route trees
|
|
139
139
|
|
|
140
|
-
Use `@octanejs/tanstack-router/compiled` for a tree whose selected route,
|
|
141
|
-
error and not-found components are compiled Octane `@{ ... }` bodies.
|
|
142
|
-
`
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
acknowledgements.
|
|
140
|
+
Use `@octanejs/tanstack-router/compiled` for a tree whose selected route, shell,
|
|
141
|
+
pending, error and not-found components are compiled Octane `@{ ... }` bodies.
|
|
142
|
+
The entry exports `RouterProvider`, `Outlet`, and `lazyRouteComponent`; the
|
|
143
|
+
existing `CompiledRouterProvider`, `CompiledOutlet`, and
|
|
144
|
+
`lazyCompiledRouteComponent` names remain aliases. TanStack owns matching,
|
|
145
|
+
loading, navigation, history and render acknowledgements.
|
|
146
146
|
|
|
147
147
|
```tsx
|
|
148
148
|
import {
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
createRootRoute, createRoute, createRouter, createBrowserHistory,
|
|
150
|
+
RouterProvider, Outlet, lazyRouteComponent,
|
|
151
151
|
} from '@octanejs/tanstack-router/compiled';
|
|
152
152
|
|
|
153
|
-
const root =
|
|
154
|
-
const item =
|
|
153
|
+
const root = createRootRoute({ component: Layout });
|
|
154
|
+
const item = createRoute({
|
|
155
155
|
getParentRoute: () => root,
|
|
156
156
|
path: 'items/$id',
|
|
157
|
-
component:
|
|
157
|
+
component: lazyRouteComponent(() => import('./Item')),
|
|
158
158
|
});
|
|
159
159
|
const router = createRouter({
|
|
160
160
|
routeTree: root.addChildren([item]),
|
|
@@ -162,10 +162,10 @@ const router = createRouter({
|
|
|
162
162
|
});
|
|
163
163
|
|
|
164
164
|
function Layout() @{
|
|
165
|
-
<main><
|
|
165
|
+
<main><Outlet /></main>
|
|
166
166
|
}
|
|
167
167
|
function App() @{
|
|
168
|
-
<
|
|
168
|
+
<RouterProvider router={router} />
|
|
169
169
|
}
|
|
170
170
|
```
|
|
171
171
|
|
|
@@ -173,12 +173,32 @@ Import read/navigation hooks (`useMatch`, `useParams`, `useSearch`,
|
|
|
173
173
|
`useLoaderData`, `useLoaderDeps`, `useRouteContext`, `useLocation`,
|
|
174
174
|
`useMatches`, `useParentMatches`, `useChildMatches`, `useNavigate`,
|
|
175
175
|
`useCanGoBack`, `useRouter`, and `useRouterState`) from the same `/compiled`
|
|
176
|
-
entry. It owns
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
176
|
+
entry. It owns separate non-callable contexts. Its `createRoute`,
|
|
177
|
+
`createRootRoute`, `createRootRouteWithContext`, `getRouteApi`, `createFileRoute`,
|
|
178
|
+
`createLazyRoute`, and `createLazyFileRoute` attach accessors to those contexts.
|
|
179
|
+
Route-bound hooks and `Route.Link` work alongside the exported `Link`,
|
|
180
|
+
`useLinkProps`, and `createLink`. Factories construct router-core base instances;
|
|
181
|
+
they do not promise `instanceof Route` from the ordinary entry.
|
|
182
|
+
|
|
183
|
+
The compiled entry also exports `Html`, `Head`, `Body`, `HeadContent`, `Scripts`,
|
|
184
|
+
`Asset`, `useTags`, `getAssetKeys`, `ClientOnly`, and `useHydrated` for document
|
|
185
|
+
shells. Root shells can read their root match and context. SSR and client builds
|
|
186
|
+
must select the same entry for a tree. Start applications select this entry with
|
|
187
|
+
`compiled: true` in their Start plugin options, including generated route and
|
|
188
|
+
split-module imports.
|
|
189
|
+
|
|
190
|
+
Compiled selection removes descriptor creation from route selection, bound
|
|
191
|
+
links and document shells, but does not make the entire binding Strong or
|
|
192
|
+
remove the generic renderer. Link retains synchronous `flushSync` so its
|
|
193
|
+
transition cue is already visible in `onBeforeNavigate` callbacks. Both entries
|
|
194
|
+
share a non-Strong script component that renders host descriptors: new inline and
|
|
195
|
+
external scripts execute, and self-removing hydration data scripts leave no
|
|
196
|
+
template row frames behind. Route scripts retain their managed nodes through
|
|
197
|
+
hydration and are replaced with route ownership. The scroll-restoration
|
|
198
|
+
script remains through adoption and is dropped afterward. Error propagation,
|
|
199
|
+
scroll-script hydration compatibility, and lifecycle dependencies retain their
|
|
200
|
+
existing contracts.
|
|
201
|
+
|
|
202
|
+
`Wrap` and `InnerWrap` remain unsupported and produce an explicit diagnostic.
|
|
182
203
|
For returned JSX, host strings, renderable wrappers or mixed route trees, retain
|
|
183
|
-
|
|
184
|
-
SSR and client builds must select the same entry for a tree.
|
|
204
|
+
the ordinary entry. Its public APIs and contexts remain available unchanged.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octanejs/tanstack-router",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"@tanstack/react-router": "1.170.41",
|
|
66
66
|
"@tsrx/react": "^0.4.5",
|
|
67
67
|
"esbuild": "^0.28.1",
|
|
68
|
-
"octane": "0.13.
|
|
68
|
+
"octane": "0.13.7",
|
|
69
69
|
"react": "19.2.7",
|
|
70
70
|
"react-dom": "19.2.7",
|
|
71
71
|
"vitest": "^4.1.10"
|
package/src/Asset.tsrx
CHANGED
|
@@ -1,137 +1,5 @@
|
|
|
1
|
-
import { createElement, useLayoutEffect } from 'octane';
|
|
2
|
-
import { isServer } from '@tanstack/router-core/isServer';
|
|
3
|
-
import type { RouterManagedTag } from '@tanstack/router-core';
|
|
4
1
|
import { useRouter } from './context.ts';
|
|
5
|
-
import {
|
|
2
|
+
import { createAsset } from './assetComponent.tsrx';
|
|
6
3
|
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
target: 'head' | 'body';
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
type ManagedAssetProps = AssetProps & { managedKey?: string };
|
|
13
|
-
|
|
14
|
-
const MANAGED_KEY_ATTR = 'data-tsr-managed-key';
|
|
15
|
-
|
|
16
|
-
// Elements currently owned by a mounted Asset. A managed key is a 32-bit
|
|
17
|
-
// content hash, so a collision must not let a second Asset adopt, and later
|
|
18
|
-
// remove, an element another Asset still owns.
|
|
19
|
-
const claimedElements = new WeakSet<Element>();
|
|
20
|
-
|
|
21
|
-
function attributeName(name: string) {
|
|
22
|
-
switch (name) {
|
|
23
|
-
case 'charSet':
|
|
24
|
-
return 'charset';
|
|
25
|
-
case 'className':
|
|
26
|
-
return 'class';
|
|
27
|
-
case 'crossOrigin':
|
|
28
|
-
return 'crossorigin';
|
|
29
|
-
case 'httpEquiv':
|
|
30
|
-
return 'http-equiv';
|
|
31
|
-
default:
|
|
32
|
-
return name;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function setAttributes(element: Element, attrs: RouterManagedTag['attrs']) {
|
|
37
|
-
if (!attrs) {
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
for (const [name, value] of Object.entries(attrs)) {
|
|
41
|
-
if (
|
|
42
|
-
name === 'suppressHydrationWarning' ||
|
|
43
|
-
value === undefined ||
|
|
44
|
-
value === null ||
|
|
45
|
-
value === false
|
|
46
|
-
) {
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
element.setAttribute(attributeName(name), value === true ? '' : String(value));
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function findManagedElement(parent: HTMLElement, key: string) {
|
|
54
|
-
for (const element of parent.querySelectorAll(`[${MANAGED_KEY_ATTR}]`)) {
|
|
55
|
-
if (element.getAttribute(MANAGED_KEY_ATTR) === key && !claimedElements.has(element)) {
|
|
56
|
-
return element;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return undefined;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function claim(element: Element) {
|
|
63
|
-
claimedElements.add(element);
|
|
64
|
-
return () => {
|
|
65
|
-
claimedElements.delete(element);
|
|
66
|
-
element.remove();
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function mountAsset(props: ManagedAssetProps) {
|
|
71
|
-
const parent = props.target === 'head' ? document.head : document.body;
|
|
72
|
-
const managedKey = props.managedKey ?? props.assetKey;
|
|
73
|
-
const existing = findManagedElement(parent, managedKey);
|
|
74
|
-
if (existing) {
|
|
75
|
-
return claim(existing);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const element = document.createElement(props.tag);
|
|
79
|
-
element.setAttribute(MANAGED_KEY_ATTR, managedKey);
|
|
80
|
-
setAttributes(element, props.attrs);
|
|
81
|
-
if (typeof props.children === 'string') {
|
|
82
|
-
element.textContent = props.children;
|
|
83
|
-
}
|
|
84
|
-
parent.appendChild(element);
|
|
85
|
-
return claim(element);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function Asset(props: AssetProps) @{
|
|
89
|
-
const router = useRouter();
|
|
90
|
-
const hydrated = useHydrated();
|
|
91
|
-
const managedKey = (props as ManagedAssetProps).managedKey ?? props.assetKey;
|
|
92
|
-
|
|
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);
|
|
98
|
-
useLayoutEffect(
|
|
99
|
-
() => mountAsset(props),
|
|
100
|
-
[props.assetKey, managedKey, props.tag, attrsKey, props.children, props.target],
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const visibleTag =
|
|
105
|
-
(isServer ?? router.isServer) || (props.target === 'body' && !hydrated) ? props.tag : undefined;
|
|
106
|
-
|
|
107
|
-
@switch (visibleTag) {
|
|
108
|
-
@case 'title': {
|
|
109
|
-
<title {...props.attrs} data-tsr-managed-key={managedKey}>
|
|
110
|
-
{props.children ?? ('' as string)}
|
|
111
|
-
</title>
|
|
112
|
-
}
|
|
113
|
-
@case 'meta': {
|
|
114
|
-
<meta {...props.attrs} data-tsr-managed-key={managedKey} />
|
|
115
|
-
}
|
|
116
|
-
@case 'link': {
|
|
117
|
-
<link {...props.attrs} data-tsr-managed-key={managedKey} />
|
|
118
|
-
}
|
|
119
|
-
@case 'style': {
|
|
120
|
-
{createElement('style', {
|
|
121
|
-
...props.attrs,
|
|
122
|
-
'data-tsr-managed-key': managedKey,
|
|
123
|
-
dangerouslySetInnerHTML: { __html: props.children ?? '' },
|
|
124
|
-
})}
|
|
125
|
-
}
|
|
126
|
-
@case 'script': {
|
|
127
|
-
<script
|
|
128
|
-
{...props.attrs}
|
|
129
|
-
data-tsr-managed-key={managedKey}
|
|
130
|
-
dangerouslySetInnerHTML={{ __html: props.children ?? '' }}
|
|
131
|
-
/>
|
|
132
|
-
}
|
|
133
|
-
@default: {
|
|
134
|
-
<></>
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
4
|
+
export const Asset = createAsset(useRouter);
|
|
5
|
+
export type { AssetProps } from './assetComponent.tsrx';
|
package/src/ClientOnly.tsrx
CHANGED
package/src/Link.tsrx
CHANGED
|
@@ -1,55 +1,4 @@
|
|
|
1
|
-
// A navigating anchor — a thin shell over `useLinkProps` (link.ts), which
|
|
2
|
-
// carries ALL of react-router Link's behavior: href building (mask-aware),
|
|
3
|
-
// external-URL + dangerous-protocol handling, active-state detection,
|
|
4
|
-
// preloading (intent/viewport/render), and the navigate click handler with
|
|
5
|
-
// replace/resetScroll/hashScrollIntoView/viewTransition/startTransition/
|
|
6
|
-
// ignoreBlocker forwarded. Children may be a render prop —
|
|
7
|
-
// `{({ isActive, isTransitioning }) => …}` — and `createLink` renders a custom
|
|
8
|
-
// component via the internal `_asChild` prop. Ref is a prop (octane's model —
|
|
9
|
-
// no forwardRef); `disabled` renders without an href + role="link" (upstream
|
|
10
|
-
// drops the anchor's disabled attribute; the aria props carry the state).
|
|
11
|
-
import { isChildrenBlock } from 'octane';
|
|
12
1
|
import { useLinkProps } from './link.ts';
|
|
13
|
-
import
|
|
14
|
-
import type { LinkComponentProps, OctaneAnchorProps, UseLinkPropsOptions } from './linkTypes.ts';
|
|
2
|
+
import { createLinkComponent } from './linkComponent.tsrx';
|
|
15
3
|
|
|
16
|
-
|
|
17
|
-
// route-aware `to`/`params`/`search` inference and native anchor event types are
|
|
18
|
-
// available before declaration emit.
|
|
19
|
-
export function Link<
|
|
20
|
-
TRouter extends AnyRouter = RegisteredRouter,
|
|
21
|
-
TFrom extends string = string,
|
|
22
|
-
TTo extends string | undefined = undefined,
|
|
23
|
-
TMaskFrom extends string = TFrom,
|
|
24
|
-
TMaskTo extends string = '',
|
|
25
|
-
>(props: LinkComponentProps<'a', TRouter, TFrom, TTo, TMaskFrom, TMaskTo>) @{
|
|
26
|
-
const {
|
|
27
|
-
_asChild: AsChild,
|
|
28
|
-
children,
|
|
29
|
-
...rest
|
|
30
|
-
} = props as typeof props & {
|
|
31
|
-
_asChild?: any;
|
|
32
|
-
};
|
|
33
|
-
const linkProps = useLinkProps(
|
|
34
|
-
rest as UseLinkPropsOptions<TRouter, TFrom, TTo, TMaskFrom, TMaskTo>,
|
|
35
|
-
) as OctaneAnchorProps & {
|
|
36
|
-
disabled?: boolean;
|
|
37
|
-
'data-status'?: string;
|
|
38
|
-
'data-transitioning'?: string;
|
|
39
|
-
};
|
|
40
|
-
const { disabled: _disabled, ...aProps } = linkProps;
|
|
41
|
-
|
|
42
|
-
const resolvedChildren =
|
|
43
|
-
typeof children === 'function' && !isChildrenBlock(children)
|
|
44
|
-
? children({
|
|
45
|
-
isActive: linkProps['data-status'] === 'active',
|
|
46
|
-
isTransitioning: linkProps['data-transitioning'] === 'transitioning',
|
|
47
|
-
})
|
|
48
|
-
: children;
|
|
49
|
-
|
|
50
|
-
@if (AsChild) {
|
|
51
|
-
<AsChild {...linkProps}>{resolvedChildren}</AsChild>
|
|
52
|
-
} @else {
|
|
53
|
-
<a {...(aProps as Record<string, any>)}>{resolvedChildren}</a>
|
|
54
|
-
}
|
|
55
|
-
}
|
|
4
|
+
export const Link = createLinkComponent(useLinkProps);
|
package/src/Matches.tsrx
CHANGED
|
@@ -32,16 +32,16 @@ export function Matches() @{
|
|
|
32
32
|
useLayoutEffect(() => {
|
|
33
33
|
if (router.options.scrollRestoration) setupScrollRestoration(router);
|
|
34
34
|
}, [router]);
|
|
35
|
-
useTransitioner(router);
|
|
35
|
+
const expectedMatches = useTransitioner(router);
|
|
36
36
|
|
|
37
37
|
<InnerWrap>
|
|
38
38
|
<Suspense fallback={pendingElement}>
|
|
39
|
-
<MatchesInner />
|
|
39
|
+
<MatchesInner expectedMatches={expectedMatches} />
|
|
40
40
|
</Suspense>
|
|
41
41
|
</InnerWrap>
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
function MatchesInner() @{
|
|
44
|
+
function MatchesInner(props: { expectedMatches: unknown }) @{
|
|
45
45
|
const router = useRouter();
|
|
46
46
|
// router-core 1.171.34 dropped the `firstId`/`loadedAt` stores: the root match
|
|
47
47
|
// is `matches[0]` and the global error boundary resets on its identity. While a
|
|
@@ -49,7 +49,9 @@ function MatchesInner() @{
|
|
|
49
49
|
// (`acknowledgement[0]`) so the committed tree equals what it awaits, then
|
|
50
50
|
// settle that render acknowledgement from a layout effect once it lands.
|
|
51
51
|
const acknowledgement: Array<any> = ((router as any)._rendered ??= []);
|
|
52
|
-
const matches = useStore(router.stores.matches, (m: any) =>
|
|
52
|
+
const matches = useStore(router.stores.matches, (m: any) =>
|
|
53
|
+
acknowledgement[0] === props.expectedMatches ? (props.expectedMatches ?? m) : m,
|
|
54
|
+
) as any;
|
|
53
55
|
const rootMatch = matches[0];
|
|
54
56
|
const routeId = rootMatch?.routeId as string | undefined;
|
|
55
57
|
const CatchWrap = router.options.disableGlobalCatchBoundary ? SafeFragment : CatchBoundary;
|
package/src/Scripts.tsrx
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { RouterManagedTag } from '@tanstack/router-core';
|
|
3
|
-
import { getAssetKeys } from './assetKeys.ts';
|
|
1
|
+
import { createScripts } from './scriptComponent.tsrx';
|
|
4
2
|
import { useScripts } from './scriptContentUtils.ts';
|
|
5
3
|
|
|
6
4
|
// router-core 1.171.34 owns the hydration-script lifecycle: the server appends
|
|
@@ -11,19 +9,4 @@ import { useScripts } from './scriptContentUtils.ts';
|
|
|
11
9
|
// the client renders — so there is no client-only barrier to prepend (the former
|
|
12
10
|
// `$tsr-stream-barrier` belonged to the removed buffered-script model and left a
|
|
13
11
|
// client <script> the server never rendered).
|
|
14
|
-
export
|
|
15
|
-
const owner = useId();
|
|
16
|
-
const scripts: Array<RouterManagedTag> = useScripts();
|
|
17
|
-
const keys = getAssetKeys('body', owner, scripts);
|
|
18
|
-
<>
|
|
19
|
-
{scripts.map((script, index) => {
|
|
20
|
-
const { assetKey, managedKey } = keys[index]!;
|
|
21
|
-
return createElement('script', {
|
|
22
|
-
...script.attrs,
|
|
23
|
-
key: assetKey,
|
|
24
|
-
'data-tsr-managed-key': managedKey,
|
|
25
|
-
dangerouslySetInnerHTML: { __html: script.children ?? '' },
|
|
26
|
-
});
|
|
27
|
-
})}
|
|
28
|
-
</>
|
|
29
|
-
}
|
|
12
|
+
export const Scripts = createScripts(useScripts);
|
package/src/Transitioner.tsrx
CHANGED
|
@@ -31,7 +31,7 @@ export function settleOwner(owner: Array<any>, rendered: boolean) {
|
|
|
31
31
|
|
|
32
32
|
export function useTransitioner(routerInstance: AnyRouter) {
|
|
33
33
|
const router = routerInstance as any;
|
|
34
|
-
const [,
|
|
34
|
+
const [expectedMatches, setExpectedMatches] = useState<unknown>();
|
|
35
35
|
|
|
36
36
|
const acknowledgement: Array<any> = (router._rendered ??= []);
|
|
37
37
|
|
|
@@ -40,9 +40,9 @@ export function useTransitioner(routerInstance: AnyRouter) {
|
|
|
40
40
|
// A newer transition supersedes an unrendered older one.
|
|
41
41
|
settleOwner(acknowledgement, false);
|
|
42
42
|
acknowledgement.push(expected, resolve);
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
43
|
+
// Publish the matches this transaction needs. MatchesInner consumes this
|
|
44
|
+
// snapshot and acknowledges it only after that exact tree commits.
|
|
45
|
+
setExpectedMatches(expected);
|
|
46
46
|
startTransition(fn);
|
|
47
47
|
});
|
|
48
48
|
|
|
@@ -93,4 +93,5 @@ export function useTransitioner(routerInstance: AnyRouter) {
|
|
|
93
93
|
}
|
|
94
94
|
return unsub;
|
|
95
95
|
}, [router, router.history]);
|
|
96
|
+
return expectedMatches;
|
|
96
97
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strong';
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect, useMemo, trustHTML } from 'octane';
|
|
4
|
+
import { isServer } from '@tanstack/router-core/isServer';
|
|
5
|
+
import type { RouterManagedTag } from '@tanstack/router-core';
|
|
6
|
+
import type { useRouter as UseRouter } from './context.ts';
|
|
7
|
+
import { useHydrated } from './ClientOnly.tsrx';
|
|
8
|
+
|
|
9
|
+
export type AssetProps = RouterManagedTag & {
|
|
10
|
+
assetKey: string;
|
|
11
|
+
target: 'head' | 'body';
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type ManagedAssetProps = AssetProps & { managedKey?: string };
|
|
15
|
+
|
|
16
|
+
const MANAGED_KEY_ATTR = 'data-tsr-managed-key';
|
|
17
|
+
|
|
18
|
+
// Elements currently owned by a mounted Asset. A managed key is a 32-bit
|
|
19
|
+
// content hash, so a collision must not let a second Asset adopt, and later
|
|
20
|
+
// remove, an element another Asset still owns.
|
|
21
|
+
const claimedElements = new WeakSet<Element>();
|
|
22
|
+
|
|
23
|
+
function attributeName(name: string) {
|
|
24
|
+
switch (name) {
|
|
25
|
+
case 'charSet':
|
|
26
|
+
return 'charset';
|
|
27
|
+
case 'className':
|
|
28
|
+
return 'class';
|
|
29
|
+
case 'crossOrigin':
|
|
30
|
+
return 'crossorigin';
|
|
31
|
+
case 'httpEquiv':
|
|
32
|
+
return 'http-equiv';
|
|
33
|
+
default:
|
|
34
|
+
return name;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function setAttributes(element: Element, attrs: RouterManagedTag['attrs']) {
|
|
39
|
+
if (!attrs) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
43
|
+
if (
|
|
44
|
+
name === 'suppressHydrationWarning' ||
|
|
45
|
+
value === undefined ||
|
|
46
|
+
value === null ||
|
|
47
|
+
value === false
|
|
48
|
+
) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
element.setAttribute(attributeName(name), value === true ? '' : String(value));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function findManagedElement(parent: HTMLElement, key: string) {
|
|
56
|
+
for (const element of parent.querySelectorAll(`[${MANAGED_KEY_ATTR}]`)) {
|
|
57
|
+
if (element.getAttribute(MANAGED_KEY_ATTR) === key && !claimedElements.has(element)) {
|
|
58
|
+
return element;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function claim(element: Element) {
|
|
65
|
+
claimedElements.add(element);
|
|
66
|
+
return () => {
|
|
67
|
+
claimedElements.delete(element);
|
|
68
|
+
element.remove();
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function mountAsset(props: ManagedAssetProps) {
|
|
73
|
+
const parent = props.target === 'head' ? document.head : document.body;
|
|
74
|
+
const managedKey = props.managedKey ?? props.assetKey;
|
|
75
|
+
const existing = findManagedElement(parent, managedKey);
|
|
76
|
+
if (existing) {
|
|
77
|
+
return claim(existing);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const element = document.createElement(props.tag);
|
|
81
|
+
element.setAttribute(MANAGED_KEY_ATTR, managedKey);
|
|
82
|
+
setAttributes(element, props.attrs);
|
|
83
|
+
if (typeof props.children === 'string') {
|
|
84
|
+
element.textContent = props.children;
|
|
85
|
+
}
|
|
86
|
+
parent.appendChild(element);
|
|
87
|
+
return claim(element);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createAsset(useRouter: typeof UseRouter) {
|
|
91
|
+
function Asset(props: AssetProps) @{
|
|
92
|
+
const router = useRouter();
|
|
93
|
+
const hydrated = useHydrated();
|
|
94
|
+
const managedKey = (props as ManagedAssetProps).managedKey ?? props.assetKey;
|
|
95
|
+
|
|
96
|
+
if (!(isServer ?? router.isServer)) {
|
|
97
|
+
// Compare attrs by value: a rebuilt tag list allocates new attrs objects on
|
|
98
|
+
// every navigation, and an identity dependency would remove and re-add
|
|
99
|
+
// unchanged assets such as the stylesheet.
|
|
100
|
+
const attrsKey = JSON.stringify(props.attrs ?? null);
|
|
101
|
+
const asset = useMemo(
|
|
102
|
+
() => props,
|
|
103
|
+
[props.assetKey, managedKey, props.tag, attrsKey, props.children, props.target],
|
|
104
|
+
);
|
|
105
|
+
useLayoutEffect(() => mountAsset(asset));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const visibleTag =
|
|
109
|
+
(isServer ?? router.isServer) || (props.target === 'body' && !hydrated)
|
|
110
|
+
? props.tag
|
|
111
|
+
: undefined;
|
|
112
|
+
|
|
113
|
+
@switch (visibleTag) {
|
|
114
|
+
@case 'title': {
|
|
115
|
+
<title {...props.attrs} data-tsr-managed-key={managedKey}>
|
|
116
|
+
{props.children ?? ('' as string)}
|
|
117
|
+
</title>
|
|
118
|
+
}
|
|
119
|
+
@case 'meta': {
|
|
120
|
+
<meta {...props.attrs} data-tsr-managed-key={managedKey} />
|
|
121
|
+
}
|
|
122
|
+
@case 'link': {
|
|
123
|
+
<link {...props.attrs} data-tsr-managed-key={managedKey} />
|
|
124
|
+
}
|
|
125
|
+
@case 'style': {
|
|
126
|
+
<style
|
|
127
|
+
{...props.attrs}
|
|
128
|
+
data-tsr-managed-key={managedKey}
|
|
129
|
+
dangerouslySetInnerHTML={trustHTML(props.children ?? '')}
|
|
130
|
+
/>
|
|
131
|
+
}
|
|
132
|
+
@case 'script': {
|
|
133
|
+
<script
|
|
134
|
+
{...props.attrs}
|
|
135
|
+
data-tsr-managed-key={managedKey}
|
|
136
|
+
dangerouslySetInnerHTML={trustHTML(props.children ?? '')}
|
|
137
|
+
/>
|
|
138
|
+
}
|
|
139
|
+
@default: {
|
|
140
|
+
<></>
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return Asset;
|
|
146
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strong';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, HYDRATION_RANGE_BOUNDARY, createPortal, useState } from 'octane';
|
|
4
|
+
import { DEV_STYLES_ATTR } from '@tanstack/router-core';
|
|
5
|
+
import type { AssetCrossOriginConfig } from '@tanstack/router-core';
|
|
6
|
+
import { createAsset } from '../assetComponent.tsrx';
|
|
7
|
+
import { createUseTags } from '../headContentBinding';
|
|
8
|
+
import { createUseScripts } from '../scriptContentBinding';
|
|
9
|
+
import { createScripts } from '../scriptComponent.tsrx';
|
|
10
|
+
import { getAssetKeys } from '../assetKeys';
|
|
11
|
+
import { useHydrated } from '../ClientOnly.tsrx';
|
|
12
|
+
import { useRouter } from './bindings';
|
|
13
|
+
|
|
14
|
+
import { isServer } from '@tanstack/router-core/isServer';
|
|
15
|
+
|
|
16
|
+
import type { HtmlProps } from '../Html';
|
|
17
|
+
import type { HeadProps } from '../Head';
|
|
18
|
+
import type { BodyProps } from '../Body';
|
|
19
|
+
|
|
20
|
+
// This boundary returns the authored children directly, as the ordinary Body
|
|
21
|
+
// owner does. Adding a template frame here changes the SSR range it claims.
|
|
22
|
+
function HydrationRangeOwner(props: Pick<BodyProps, 'children'>) {
|
|
23
|
+
return props.children;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
(
|
|
27
|
+
HydrationRangeOwner as typeof HydrationRangeOwner & {
|
|
28
|
+
[HYDRATION_RANGE_BOUNDARY]: 'owner';
|
|
29
|
+
}
|
|
30
|
+
)[HYDRATION_RANGE_BOUNDARY] = 'owner';
|
|
31
|
+
|
|
32
|
+
// Preserve the ordinary document and hydration-range ownership. Start owns
|
|
33
|
+
// the outer passthrough boundary; this entry reads the compiled router context.
|
|
34
|
+
export function Html(props: HtmlProps) @{
|
|
35
|
+
const router = useRouter();
|
|
36
|
+
const { children, ...attrs } = props;
|
|
37
|
+
@if (isServer ?? router.isServer) {
|
|
38
|
+
<html {...attrs}>{children}</html>
|
|
39
|
+
} @else {
|
|
40
|
+
<>{children}</>
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function Head(props: HeadProps) @{
|
|
45
|
+
const router = useRouter();
|
|
46
|
+
const { children, ...attrs } = props;
|
|
47
|
+
@if (isServer ?? router.isServer) {
|
|
48
|
+
<head {...attrs}>{children}</head>
|
|
49
|
+
} @else {
|
|
50
|
+
// Capture the portal target once, before client adoption. This is a
|
|
51
|
+
// browser handle, not state copied from a post-hydration effect.
|
|
52
|
+
const [target] = useState(() => (typeof document === 'undefined' ? null : document.head));
|
|
53
|
+
@if (target) {
|
|
54
|
+
<>
|
|
55
|
+
{createPortal(() => @{
|
|
56
|
+
<>{children}</>
|
|
57
|
+
}, target)}
|
|
58
|
+
</>
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function Body(props: BodyProps) @{
|
|
64
|
+
const router = useRouter();
|
|
65
|
+
const { children, ...attrs } = props;
|
|
66
|
+
@if (isServer ?? router.isServer) {
|
|
67
|
+
<body {...attrs}>
|
|
68
|
+
<div id="__app">
|
|
69
|
+
<HydrationRangeOwner children={children} />
|
|
70
|
+
</div>
|
|
71
|
+
</body>
|
|
72
|
+
} @else {
|
|
73
|
+
<HydrationRangeOwner children={children} />
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export const Asset = createAsset(useRouter);
|
|
77
|
+
export const useTags = createUseTags(useRouter);
|
|
78
|
+
const useScripts = createUseScripts(useRouter);
|
|
79
|
+
export const Scripts = createScripts(useScripts);
|
|
80
|
+
|
|
81
|
+
export function HeadContent(props: { assetCrossOrigin?: AssetCrossOriginConfig }) @{
|
|
82
|
+
const owner = useId();
|
|
83
|
+
const tags = useTags(props.assetCrossOrigin);
|
|
84
|
+
const hydrated = useHydrated();
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (process.env.NODE_ENV !== 'production' && hydrated) {
|
|
87
|
+
document.querySelectorAll(`link[${DEV_STYLES_ATTR}]`).forEach((element) => element.remove());
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
const filtered =
|
|
91
|
+
process.env.NODE_ENV !== 'production' && hydrated
|
|
92
|
+
? tags.filter((tag) => tag.tag !== 'link' || tag.attrs?.[DEV_STYLES_ATTR] !== true)
|
|
93
|
+
: tags;
|
|
94
|
+
const keys = getAssetKeys('head', owner, filtered);
|
|
95
|
+
const assets = filtered.map((tag, index) => ({ ...tag, ...keys[index]! }));
|
|
96
|
+
<>
|
|
97
|
+
@for (const asset of assets; key asset.assetKey) {
|
|
98
|
+
<Asset {...asset} target="head" />
|
|
99
|
+
}
|
|
100
|
+
</>
|
|
101
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strong';
|
|
2
|
+
|
|
3
|
+
import { createUseLinkProps } from '../linkBinding';
|
|
4
|
+
import { createLinkComponent } from '../linkComponent.tsrx';
|
|
5
|
+
import { useRouter } from './bindings';
|
|
6
|
+
import type { createLink as StandardCreateLink } from '../link';
|
|
7
|
+
|
|
8
|
+
// The shared hook intentionally retains flushSync's synchronous navigation cue.
|
|
9
|
+
// The compiled entry does not relax its observable event timing.
|
|
10
|
+
export const useLinkProps = createUseLinkProps(useRouter);
|
|
11
|
+
export const Link = createLinkComponent(useLinkProps);
|
|
12
|
+
|
|
13
|
+
export const createLink: typeof StandardCreateLink = (Component: any) => {
|
|
14
|
+
function CreatedLink(props: any) @{
|
|
15
|
+
<Link {...props} _asChild={Component} />
|
|
16
|
+
}
|
|
17
|
+
return CreatedLink as any;
|
|
18
|
+
};
|