@octanejs/tanstack-router 0.1.60 → 0.2.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.
- package/package.json +6 -6
- package/src/CatchBoundary.tsrx +2 -2
- package/src/Match.tsrx +69 -155
- package/src/MatchRoute.tsrx +8 -6
- package/src/Matches.tsrx +18 -7
- package/src/Outlet.tsrx +22 -17
- package/src/ScriptOnce.tsrx +11 -6
- package/src/Scripts.tsrx +12 -27
- package/src/Transitioner.tsrx +71 -100
- package/src/hooks.ts +9 -10
- package/src/link.ts +8 -4
- package/src/router.ts +15 -102
- package/src/scriptContentUtils.ts +23 -50
- package/src/scroll-restoration.tsrx +15 -2
- package/src/ssr/RouterClient.tsrx +1 -1
- package/src/ssr/renderRouterToStream.ts +108 -138
- package/src/ssr/renderRouterToString.ts +27 -39
- package/src/useBlocker.tsrx +8 -6
|
@@ -1,38 +1,26 @@
|
|
|
1
1
|
import { renderToReadableStream } from 'octane/server';
|
|
2
|
-
import { prerender } from 'octane/static';
|
|
3
2
|
import { isbot } from 'isbot';
|
|
4
|
-
import {
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
createSsrStreamResponse,
|
|
5
|
+
getSsrStatus,
|
|
6
|
+
transformReadableStreamWithRouter,
|
|
7
|
+
waitForRequest,
|
|
8
|
+
} from '@tanstack/router-core/ssr/server';
|
|
6
9
|
import type { ComponentBody } from 'octane';
|
|
7
|
-
import type { StreamInjectionSource } from 'octane/server';
|
|
8
10
|
import type { AnyRouter } from '@tanstack/router-core';
|
|
9
11
|
|
|
10
12
|
type RouterApp = ComponentBody<{ router: AnyRouter }>;
|
|
11
13
|
type ServerComponent = Parameters<typeof renderToReadableStream>[0];
|
|
12
14
|
|
|
13
|
-
//
|
|
14
|
-
// `
|
|
15
|
-
// `
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// renderer-owned styles into `<head>`, replacing the `prependDoctype` and
|
|
23
|
-
// `relocateLeadingOctaneStylesToHead` transforms this file previously piped
|
|
24
|
-
// the stream through.
|
|
25
|
-
//
|
|
26
|
-
// The serialization timeout is preserved: it arms when octane reports the
|
|
27
|
-
// render finished (`renderComplete`) and fails the stream if serialization
|
|
28
|
-
// never completes. The script barrier lifts when octane subscribes — octane
|
|
29
|
-
// only subscribes after the shell (which carries the barrier anchor) is on
|
|
30
|
-
// the wire, matching the transform's lift-after-marker-flush.
|
|
31
|
-
// (`setRenderFinished` lifts it as a backstop regardless, exactly as
|
|
32
|
-
// before.)
|
|
33
|
-
|
|
34
|
-
const SERIALIZATION_TIMEOUT_MS = 60_000;
|
|
35
|
-
|
|
15
|
+
// router-core 1.171.34 redesigned SSR serialization: the previous
|
|
16
|
+
// `ServerSsr` buffered-HTML / script-barrier pull API (which octane bridged to
|
|
17
|
+
// its native `StreamOptions.injection`) is gone. Octane now renders a plain HTML
|
|
18
|
+
// stream and pipes it through router-core's `transformReadableStreamWithRouter`,
|
|
19
|
+
// which injects the dehydrated router scripts at the `<Scripts>` boundary and
|
|
20
|
+
// prepends `<!DOCTYPE html>`; `createSsrStreamResponse` owns the request-lifetime
|
|
21
|
+
// teardown. Dehydration already ran in start-server-core's request handler. Bots
|
|
22
|
+
// wait for every suspense boundary (`stream.allReady`) before the response is
|
|
23
|
+
// produced so crawlers receive the fully-resolved document.
|
|
36
24
|
export async function renderRouterToStream({
|
|
37
25
|
request,
|
|
38
26
|
router,
|
|
@@ -44,147 +32,129 @@ export async function renderRouterToStream({
|
|
|
44
32
|
responseHeaders: Headers;
|
|
45
33
|
App: RouterApp;
|
|
46
34
|
}) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const serverSsr = router.serverSsr;
|
|
52
|
-
if (!serverSsr) {
|
|
53
|
-
throw new Error('Invariant failed: router.serverSsr is required');
|
|
35
|
+
const signal = request.signal;
|
|
36
|
+
if (signal.aborted) {
|
|
37
|
+
router.serverSsr?.cleanup();
|
|
38
|
+
throw signal.reason;
|
|
54
39
|
}
|
|
55
40
|
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
if (request.signal.aborted) {
|
|
59
|
-
onRequestAbort();
|
|
60
|
-
} else {
|
|
61
|
-
request.signal.addEventListener('abort', onRequestAbort, { once: true });
|
|
62
|
-
serverSsr.onCleanup(() => {
|
|
63
|
-
request.signal.removeEventListener('abort', onRequestAbort);
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
let serializationTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
68
|
-
let stopSerializationListener: (() => void) | undefined;
|
|
69
|
-
let settleDone!: () => void;
|
|
70
|
-
let failDone!: (reason: unknown) => void;
|
|
71
|
-
const done = new Promise<void>((resolve, reject) => {
|
|
72
|
-
settleDone = resolve;
|
|
73
|
-
failDone = reject;
|
|
74
|
-
});
|
|
75
|
-
if (serverSsr.isSerializationFinished()) {
|
|
76
|
-
settleDone();
|
|
77
|
-
} else {
|
|
78
|
-
stopSerializationListener = serverSsr.onSerializationFinished(() => settleDone());
|
|
79
|
-
}
|
|
80
|
-
const releaseInjection = () => {
|
|
81
|
-
if (serializationTimeout !== undefined) {
|
|
82
|
-
clearTimeout(serializationTimeout);
|
|
83
|
-
serializationTimeout = undefined;
|
|
84
|
-
}
|
|
85
|
-
stopSerializationListener?.();
|
|
86
|
-
stopSerializationListener = undefined;
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const injection: StreamInjectionSource = {
|
|
90
|
-
take: () => serverSsr.takeBufferedHtml() ?? '',
|
|
91
|
-
subscribe(notify) {
|
|
92
|
-
serverSsr.liftScriptBarrier();
|
|
93
|
-
return serverSsr.onInjectedHtml(notify);
|
|
94
|
-
},
|
|
95
|
-
done,
|
|
96
|
-
renderComplete() {
|
|
97
|
-
serverSsr.setRenderFinished();
|
|
98
|
-
if (!serverSsr.isSerializationFinished() && serializationTimeout === undefined) {
|
|
99
|
-
serializationTimeout = setTimeout(() => {
|
|
100
|
-
failDone(new Error('Serialization timeout after app render finished'));
|
|
101
|
-
}, SERIALIZATION_TIMEOUT_MS);
|
|
102
|
-
}
|
|
103
|
-
},
|
|
104
|
-
};
|
|
41
|
+
let rendererTeardown = false;
|
|
42
|
+
const bot = isbot(request.headers.get('User-Agent'));
|
|
105
43
|
|
|
106
44
|
try {
|
|
107
45
|
const stream = await renderToReadableStream(
|
|
108
46
|
App as unknown as ServerComponent,
|
|
109
47
|
{ router },
|
|
110
48
|
{
|
|
111
|
-
signal
|
|
49
|
+
signal,
|
|
112
50
|
nonce: router.options.ssr?.nonce,
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (!isAbortError(request, error)) {
|
|
51
|
+
onError(error: unknown) {
|
|
52
|
+
if (!rendererTeardown && !signal.aborted && !isAbortError(request, error)) {
|
|
116
53
|
console.error('Error in renderToReadableStream:', error);
|
|
117
54
|
}
|
|
118
55
|
},
|
|
119
56
|
},
|
|
120
57
|
);
|
|
121
58
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
59
|
+
const rendererAbort = bot ? new AbortController() : undefined;
|
|
60
|
+
const responseStream = transformReadableStreamWithRouter(
|
|
61
|
+
router,
|
|
62
|
+
finalizeDocumentShell(stream as unknown as ReadableStream<Uint8Array>),
|
|
63
|
+
{
|
|
64
|
+
rendererSafePoint: 'script-close',
|
|
65
|
+
signal,
|
|
66
|
+
onAbort: (reason: unknown) => {
|
|
67
|
+
rendererTeardown = true;
|
|
68
|
+
rendererAbort?.abort(reason);
|
|
69
|
+
},
|
|
133
70
|
},
|
|
134
71
|
);
|
|
135
72
|
|
|
73
|
+
if (rendererAbort) {
|
|
74
|
+
await waitForRequest(stream.allReady, rendererAbort.signal);
|
|
75
|
+
}
|
|
76
|
+
|
|
136
77
|
return createSsrStreamResponse(
|
|
137
78
|
router,
|
|
138
|
-
new Response(
|
|
139
|
-
status: router
|
|
79
|
+
new Response(responseStream, {
|
|
80
|
+
status: getSsrStatus(router),
|
|
140
81
|
headers: responseHeaders,
|
|
141
82
|
}),
|
|
142
83
|
);
|
|
143
84
|
} catch (error) {
|
|
144
|
-
renderController.abort(error);
|
|
145
|
-
releaseInjection();
|
|
146
85
|
router.serverSsr?.cleanup();
|
|
147
86
|
throw error;
|
|
148
87
|
}
|
|
149
88
|
}
|
|
150
89
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
90
|
+
// Finalize octane's streamed document shell for the router-core stream path.
|
|
91
|
+
// octane (without its former native injection) emits the deduped scoped-style
|
|
92
|
+
// tags (`<style data-octane=…>`) ahead of the document shell (before `<html>`)
|
|
93
|
+
// and no `<!DOCTYPE html>`, and router-core's *stream* transform injects router
|
|
94
|
+
// scripts but neither moves those styles nor adds the doctype (only its string
|
|
95
|
+
// transform does). So, for a document render, fold the leading style run into the
|
|
96
|
+
// shell `<head>` and prepend the doctype — what octane's native-injection
|
|
97
|
+
// document mode (and, before it, an explicit relocate transform) used to do. Only
|
|
98
|
+
// the shell prefix (through `</head>`) is buffered; everything after streams
|
|
99
|
+
// straight through, preserving out-of-order boundary flushing. A non-document
|
|
100
|
+
// render (no `</head>`) passes through unchanged.
|
|
101
|
+
export function finalizeDocumentShell(
|
|
102
|
+
source: ReadableStream<Uint8Array>,
|
|
103
|
+
): ReadableStream<Uint8Array> {
|
|
104
|
+
const reader = source.getReader();
|
|
105
|
+
const decoder = new TextDecoder();
|
|
106
|
+
const encoder = new TextEncoder();
|
|
107
|
+
let prefix = '';
|
|
108
|
+
let finalized = false;
|
|
109
|
+
return new ReadableStream<Uint8Array>({
|
|
110
|
+
async pull(controller) {
|
|
111
|
+
if (finalized) {
|
|
112
|
+
const { value, done } = await reader.read();
|
|
113
|
+
// Keep decoding through the same streaming decoder rather than passing
|
|
114
|
+
// raw bytes: the decoder may hold the leading bytes of a multi-byte
|
|
115
|
+
// character split across the chunk that contained `</head>`, and raw
|
|
116
|
+
// passthrough would drop them and corrupt the rest of the stream.
|
|
117
|
+
if (done) {
|
|
118
|
+
const tail = decoder.decode();
|
|
119
|
+
if (tail) controller.enqueue(encoder.encode(tail));
|
|
120
|
+
controller.close();
|
|
121
|
+
} else {
|
|
122
|
+
controller.enqueue(encoder.encode(decoder.decode(value, { stream: true })));
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
for (;;) {
|
|
127
|
+
const { value, done } = await reader.read();
|
|
128
|
+
if (value) prefix += decoder.decode(value, { stream: true });
|
|
129
|
+
if (prefix.includes('</head>')) {
|
|
130
|
+
const htmlStart = prefix.search(/<html[\s/>]/i);
|
|
131
|
+
if (htmlStart >= 0) {
|
|
132
|
+
if (htmlStart > 0) {
|
|
133
|
+
const leading = prefix.slice(0, htmlStart);
|
|
134
|
+
const doc = prefix.slice(htmlStart);
|
|
135
|
+
const headClose = doc.indexOf('</head>');
|
|
136
|
+
prefix = doc.slice(0, headClose) + leading + doc.slice(headClose);
|
|
137
|
+
}
|
|
138
|
+
if (!/^\s*<!doctype/i.test(prefix)) prefix = '<!DOCTYPE html>' + prefix;
|
|
172
139
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
140
|
+
controller.enqueue(encoder.encode(prefix));
|
|
141
|
+
prefix = '';
|
|
142
|
+
finalized = true;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (done) {
|
|
146
|
+
if (prefix) controller.enqueue(encoder.encode(prefix));
|
|
147
|
+
prefix = '';
|
|
148
|
+
finalized = true;
|
|
149
|
+
controller.close();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
cancel(reason) {
|
|
155
|
+
return reader.cancel(reason);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
188
158
|
}
|
|
189
159
|
|
|
190
160
|
function isAbortError(request: Request, error: unknown) {
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { renderToString as octaneRenderToString } from 'octane/server';
|
|
2
|
+
import { renderSsrHtmlResponse } from '@tanstack/router-core/ssr/server';
|
|
2
3
|
import type { ComponentBody } from 'octane';
|
|
3
4
|
import type { AnyRouter } from '@tanstack/router-core';
|
|
4
5
|
|
|
5
6
|
type RouterApp = ComponentBody<{ router: AnyRouter }>;
|
|
6
7
|
type ServerComponent = Parameters<typeof octaneRenderToString>[0];
|
|
7
8
|
|
|
8
|
-
//
|
|
9
|
+
// router-core 1.171.34 owns HTML serialization: `renderSsrHtmlResponse` runs the
|
|
10
|
+
// framework render, pipes the result through `transformHtmlStringWithRouter`
|
|
11
|
+
// (which prepends `<!DOCTYPE html>` and injects the dehydrated router scripts at
|
|
12
|
+
// the `<Scripts>` boundary), derives the HTTP status, and cleans up `serverSsr`.
|
|
13
|
+
// Dehydration already ran in start-server-core's request handler. Octane only
|
|
14
|
+
// supplies the rendered document HTML with its scoped CSS folded into `<head>`.
|
|
9
15
|
export async function renderRouterToString({
|
|
10
16
|
router,
|
|
11
17
|
responseHeaders,
|
|
@@ -15,44 +21,26 @@ export async function renderRouterToString({
|
|
|
15
21
|
responseHeaders: Headers;
|
|
16
22
|
App: RouterApp;
|
|
17
23
|
}) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
headers: responseHeaders,
|
|
31
|
-
},
|
|
32
|
-
);
|
|
33
|
-
} catch (error) {
|
|
34
|
-
console.error('Render to string error:', error);
|
|
35
|
-
return new Response('Internal Server Error', {
|
|
36
|
-
status: 500,
|
|
37
|
-
headers: responseHeaders,
|
|
38
|
-
});
|
|
39
|
-
} finally {
|
|
40
|
-
router.serverSsr?.cleanup();
|
|
41
|
-
}
|
|
24
|
+
return renderSsrHtmlResponse({
|
|
25
|
+
router,
|
|
26
|
+
responseHeaders,
|
|
27
|
+
render: () => {
|
|
28
|
+
const result = octaneRenderToString(
|
|
29
|
+
App as unknown as ServerComponent,
|
|
30
|
+
{ router },
|
|
31
|
+
{ nonce: router.options.ssr?.nonce },
|
|
32
|
+
);
|
|
33
|
+
return foldCssIntoHead(result.html, result.css);
|
|
34
|
+
},
|
|
35
|
+
});
|
|
42
36
|
}
|
|
43
37
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
html = html.includes('</body>')
|
|
53
|
-
? html.replace('</body>', `${injectedHtml}</body>`)
|
|
54
|
-
: `${html}${injectedHtml}`;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return `<!DOCTYPE html>${html}`;
|
|
38
|
+
// Fold octane's scoped-style output into the document head. Unlike the previous
|
|
39
|
+
// implementation this adds no `<!DOCTYPE html>` and no router-script injection —
|
|
40
|
+
// `transformHtmlStringWithRouter` now owns both.
|
|
41
|
+
export function foldCssIntoHead(renderedHtml: string, css: string) {
|
|
42
|
+
if (!css) return renderedHtml;
|
|
43
|
+
return renderedHtml.includes('</head>')
|
|
44
|
+
? renderedHtml.replace('</head>', `${css}</head>`)
|
|
45
|
+
: `${css}${renderedHtml}`;
|
|
58
46
|
}
|
package/src/useBlocker.tsrx
CHANGED
|
@@ -120,21 +120,23 @@ export function useBlocker(
|
|
|
120
120
|
const blockerFnComposed = async (blockerFnArgs: BlockerFnArgs) => {
|
|
121
121
|
function getLocation(location: HistoryLocation): ShouldBlockFnLocation {
|
|
122
122
|
const parsedLocation = router.parseLocation(location);
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
// router-core 1.171.34 getMatchedRoutes returns a tuple
|
|
124
|
+
// [matchedRoutes, rawParams, foundRoute] instead of an object.
|
|
125
|
+
const [, routeParams, foundRoute] = router.getMatchedRoutes(parsedLocation.pathname);
|
|
126
|
+
if (foundRoute === undefined) {
|
|
125
127
|
return {
|
|
126
128
|
routeId: '__notFound__',
|
|
127
129
|
fullPath: parsedLocation.pathname,
|
|
128
130
|
pathname: parsedLocation.pathname,
|
|
129
|
-
params:
|
|
131
|
+
params: routeParams,
|
|
130
132
|
search: router.options.parseSearch(location.search),
|
|
131
133
|
};
|
|
132
134
|
}
|
|
133
135
|
return {
|
|
134
|
-
routeId:
|
|
135
|
-
fullPath:
|
|
136
|
+
routeId: foundRoute.id,
|
|
137
|
+
fullPath: foundRoute.fullPath,
|
|
136
138
|
pathname: parsedLocation.pathname,
|
|
137
|
-
params:
|
|
139
|
+
params: routeParams,
|
|
138
140
|
search: router.options.parseSearch(location.search),
|
|
139
141
|
};
|
|
140
142
|
}
|