@timber-js/app 0.1.10 → 0.1.12
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/dist/_chunks/request-context-BzES06i1.js.map +1 -1
- package/dist/_chunks/ssr-data-BgSwMbN9.js +38 -0
- package/dist/_chunks/ssr-data-BgSwMbN9.js.map +1 -0
- package/dist/_chunks/{use-cookie-HcvNlW4L.js → use-cookie-D2cZu0jK.js} +3 -37
- package/dist/_chunks/use-cookie-D2cZu0jK.js.map +1 -0
- package/dist/_chunks/use-query-states-wEXY2JQB.js +109 -0
- package/dist/_chunks/use-query-states-wEXY2JQB.js.map +1 -0
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +8 -0
- package/dist/client/error-boundary.js.map +1 -1
- package/dist/client/index.js +3 -84
- package/dist/client/index.js.map +1 -1
- package/dist/client/ssr-data.d.ts +9 -0
- package/dist/client/ssr-data.d.ts.map +1 -1
- package/dist/client/use-query-states.d.ts.map +1 -1
- package/dist/cookies/index.js +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -12
- package/dist/index.js.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/routing/status-file-lint.d.ts.map +1 -1
- package/dist/search-params/create.d.ts.map +1 -1
- package/dist/search-params/index.js +13 -4
- package/dist/search-params/index.js.map +1 -1
- package/dist/server/fallback-error.d.ts +28 -0
- package/dist/server/fallback-error.d.ts.map +1 -0
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +13 -10
- package/dist/server/index.js.map +1 -1
- package/dist/server/pipeline.d.ts +12 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts +1 -1
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts +7 -0
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts +10 -0
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/package.json +23 -23
- package/src/client/browser-entry.ts +1 -1
- package/src/client/error-boundary.tsx +22 -0
- package/src/client/ssr-data.ts +7 -0
- package/src/client/use-query-states.ts +13 -1
- package/src/index.ts +16 -16
- package/src/plugins/dev-server.ts +3 -1
- package/src/plugins/entries.ts +2 -1
- package/src/plugins/routing.ts +5 -4
- package/src/plugins/server-bundle.ts +15 -6
- package/src/plugins/shims.ts +8 -14
- package/src/routing/status-file-lint.ts +1 -3
- package/src/search-params/create.ts +15 -8
- package/src/server/error-formatter.ts +12 -0
- package/src/server/fallback-error.ts +159 -0
- package/src/server/html-injectors.ts +9 -4
- package/src/server/pipeline.ts +24 -0
- package/src/server/request-context.ts +0 -1
- package/src/server/route-matcher.ts +1 -4
- package/src/server/rsc-entry/index.ts +98 -39
- package/src/server/slot-resolver.ts +38 -5
- package/src/server/ssr-entry.ts +12 -1
- package/src/server/tree-builder.ts +39 -11
- package/src/shims/server-only-noop.js +1 -0
- package/dist/_chunks/registry-BfPM41ri.js +0 -20
- package/dist/_chunks/registry-BfPM41ri.js.map +0 -1
- package/dist/_chunks/use-cookie-HcvNlW4L.js.map +0 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fallback error rendering — handles catastrophic errors that escape the
|
|
3
|
+
* render pipeline entirely (e.g. module evaluation failures).
|
|
4
|
+
*
|
|
5
|
+
* In dev mode: renders a styled HTML page with error details and stack trace.
|
|
6
|
+
* The Vite client script is included so the error overlay still fires.
|
|
7
|
+
*
|
|
8
|
+
* In production: attempts to render root error pages (500.tsx / 5xx.tsx /
|
|
9
|
+
* error.tsx) via the normal RSC → SSR pipeline. Stack traces are never
|
|
10
|
+
* exposed to the client (design/13-security.md principle 4).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { RouteMatch } from '#/server/pipeline.js';
|
|
14
|
+
import type { ManifestSegmentNode } from '#/server/route-matcher.js';
|
|
15
|
+
import type { ClientBootstrapConfig } from '#/server/html-injectors.js';
|
|
16
|
+
import type { LayoutEntry } from '#/server/deny-renderer.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Render a fallback error page when the render pipeline throws.
|
|
20
|
+
*
|
|
21
|
+
* In dev: styled HTML with error details.
|
|
22
|
+
* In prod: renders root error pages via renderErrorPage.
|
|
23
|
+
*/
|
|
24
|
+
export async function renderFallbackError(
|
|
25
|
+
error: unknown,
|
|
26
|
+
req: Request,
|
|
27
|
+
responseHeaders: Headers,
|
|
28
|
+
isDev: boolean,
|
|
29
|
+
rootSegment: ManifestSegmentNode,
|
|
30
|
+
clientBootstrap: ClientBootstrapConfig
|
|
31
|
+
): Promise<Response> {
|
|
32
|
+
if (isDev) {
|
|
33
|
+
return renderDevErrorPage(error);
|
|
34
|
+
}
|
|
35
|
+
// Lazy import to avoid loading error-renderer in the pipeline module
|
|
36
|
+
const { renderErrorPage } = await import('#/server/rsc-entry/error-renderer.js');
|
|
37
|
+
const segments = [rootSegment];
|
|
38
|
+
const layoutComponents: LayoutEntry[] = [];
|
|
39
|
+
if (rootSegment.layout) {
|
|
40
|
+
const mod = (await rootSegment.layout.load()) as Record<string, unknown>;
|
|
41
|
+
if (mod.default) {
|
|
42
|
+
layoutComponents.push({
|
|
43
|
+
component: mod.default as (...args: unknown[]) => unknown,
|
|
44
|
+
segment: rootSegment,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const match: RouteMatch = { segments: segments as never, params: {} };
|
|
49
|
+
return renderErrorPage(
|
|
50
|
+
error,
|
|
51
|
+
500,
|
|
52
|
+
segments,
|
|
53
|
+
layoutComponents,
|
|
54
|
+
req,
|
|
55
|
+
match,
|
|
56
|
+
responseHeaders,
|
|
57
|
+
clientBootstrap
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Render a dev-mode 500 error page with error message and stack trace.
|
|
63
|
+
*
|
|
64
|
+
* Returns an HTML Response that displays the error in a styled page.
|
|
65
|
+
* The Vite HMR client script is included so the error overlay still fires.
|
|
66
|
+
*/
|
|
67
|
+
export function renderDevErrorPage(error: unknown): Response {
|
|
68
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
69
|
+
const title = err.name || 'Error';
|
|
70
|
+
const message = escapeHtml(err.message);
|
|
71
|
+
const stack = err.stack ? escapeHtml(err.stack) : '';
|
|
72
|
+
|
|
73
|
+
const html = `<!DOCTYPE html>
|
|
74
|
+
<html lang="en">
|
|
75
|
+
<head>
|
|
76
|
+
<meta charset="utf-8">
|
|
77
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
78
|
+
<title>500 — ${escapeHtml(title)}</title>
|
|
79
|
+
<script type="module" src="/@vite/client"></script>
|
|
80
|
+
<style>
|
|
81
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
82
|
+
body {
|
|
83
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
84
|
+
background: #1a1a2e;
|
|
85
|
+
color: #e0e0e0;
|
|
86
|
+
padding: 2rem;
|
|
87
|
+
line-height: 1.6;
|
|
88
|
+
}
|
|
89
|
+
.container { max-width: 800px; margin: 0 auto; }
|
|
90
|
+
.badge {
|
|
91
|
+
display: inline-block;
|
|
92
|
+
background: #e74c3c;
|
|
93
|
+
color: white;
|
|
94
|
+
font-size: 0.75rem;
|
|
95
|
+
font-weight: 700;
|
|
96
|
+
padding: 0.2rem 0.6rem;
|
|
97
|
+
border-radius: 4px;
|
|
98
|
+
text-transform: uppercase;
|
|
99
|
+
letter-spacing: 0.05em;
|
|
100
|
+
margin-bottom: 1rem;
|
|
101
|
+
}
|
|
102
|
+
h1 {
|
|
103
|
+
font-size: 1.5rem;
|
|
104
|
+
color: #ff6b6b;
|
|
105
|
+
margin-bottom: 0.5rem;
|
|
106
|
+
word-break: break-word;
|
|
107
|
+
}
|
|
108
|
+
.message {
|
|
109
|
+
font-size: 1.1rem;
|
|
110
|
+
color: #ccc;
|
|
111
|
+
margin-bottom: 1.5rem;
|
|
112
|
+
word-break: break-word;
|
|
113
|
+
}
|
|
114
|
+
.stack-container {
|
|
115
|
+
background: #16213e;
|
|
116
|
+
border: 1px solid #2a2a4a;
|
|
117
|
+
border-radius: 8px;
|
|
118
|
+
padding: 1rem;
|
|
119
|
+
overflow-x: auto;
|
|
120
|
+
}
|
|
121
|
+
.stack {
|
|
122
|
+
font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace;
|
|
123
|
+
font-size: 0.8rem;
|
|
124
|
+
color: #a0a0c0;
|
|
125
|
+
white-space: pre-wrap;
|
|
126
|
+
word-break: break-all;
|
|
127
|
+
}
|
|
128
|
+
.hint {
|
|
129
|
+
margin-top: 1.5rem;
|
|
130
|
+
font-size: 0.85rem;
|
|
131
|
+
color: #666;
|
|
132
|
+
}
|
|
133
|
+
</style>
|
|
134
|
+
</head>
|
|
135
|
+
<body>
|
|
136
|
+
<div class="container">
|
|
137
|
+
<span class="badge">500 Internal Server Error</span>
|
|
138
|
+
<h1>${escapeHtml(title)}</h1>
|
|
139
|
+
<p class="message">${message}</p>
|
|
140
|
+
${stack ? `<div class="stack-container"><pre class="stack">${stack}</pre></div>` : ''}
|
|
141
|
+
<p class="hint">This error page is only shown in development.</p>
|
|
142
|
+
</div>
|
|
143
|
+
</body>
|
|
144
|
+
</html>`;
|
|
145
|
+
|
|
146
|
+
return new Response(html, {
|
|
147
|
+
status: 500,
|
|
148
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function escapeHtml(str: string): string {
|
|
153
|
+
return str
|
|
154
|
+
.replace(/&/g, '&')
|
|
155
|
+
.replace(/</g, '<')
|
|
156
|
+
.replace(/>/g, '>')
|
|
157
|
+
.replace(/"/g, '"')
|
|
158
|
+
.replace(/'/g, ''');
|
|
159
|
+
}
|
|
@@ -323,8 +323,7 @@ export function injectRscPayload(
|
|
|
323
323
|
|
|
324
324
|
// Single transform: strip </body></html>, inject RSC scripts at
|
|
325
325
|
// body level, re-emit suffix at the very end.
|
|
326
|
-
return htmlStream
|
|
327
|
-
.pipeThrough(createFlightInjectionTransform(rscScriptStream));
|
|
326
|
+
return htmlStream.pipeThrough(createFlightInjectionTransform(rscScriptStream));
|
|
328
327
|
}
|
|
329
328
|
|
|
330
329
|
/**
|
|
@@ -354,7 +353,10 @@ function findManifestEntry(map: Record<string, string>, suffix: string): string
|
|
|
354
353
|
}
|
|
355
354
|
|
|
356
355
|
/** Find a manifest array entry by matching the key suffix. */
|
|
357
|
-
function findManifestEntryArray(
|
|
356
|
+
function findManifestEntryArray(
|
|
357
|
+
map: Record<string, string[]>,
|
|
358
|
+
suffix: string
|
|
359
|
+
): string[] | undefined {
|
|
358
360
|
for (const [key, value] of Object.entries(map)) {
|
|
359
361
|
if (key.endsWith(suffix)) return value;
|
|
360
362
|
}
|
|
@@ -431,7 +433,10 @@ export function buildClientScripts(runtimeConfig: {
|
|
|
431
433
|
|
|
432
434
|
if (browserEntryUrl) {
|
|
433
435
|
// Modulepreload hints for browser entry dependencies
|
|
434
|
-
const preloads =
|
|
436
|
+
const preloads =
|
|
437
|
+
(manifest
|
|
438
|
+
? findManifestEntryArray(manifest.modulepreload, 'client/browser-entry.ts')
|
|
439
|
+
: undefined) ?? [];
|
|
435
440
|
for (const url of preloads) {
|
|
436
441
|
preloadLinks += `<link rel="modulepreload" href="${url}">`;
|
|
437
442
|
}
|
package/src/server/pipeline.ts
CHANGED
|
@@ -121,6 +121,22 @@ export interface PipelineConfig {
|
|
|
121
121
|
* Undefined in production — zero overhead.
|
|
122
122
|
*/
|
|
123
123
|
onPipelineError?: (error: Error, phase: string) => void;
|
|
124
|
+
/**
|
|
125
|
+
* Fallback error renderer — called when a catastrophic error escapes the
|
|
126
|
+
* render phase. Produces an HTML Response instead of a bare empty 500.
|
|
127
|
+
*
|
|
128
|
+
* In dev mode, this renders a styled error page with the error message
|
|
129
|
+
* and stack trace. In production, this attempts to render the app's
|
|
130
|
+
* error.tsx / 5xx.tsx / 500.tsx from the root segment.
|
|
131
|
+
*
|
|
132
|
+
* If this function throws, the pipeline falls back to a bare
|
|
133
|
+
* `new Response(null, { status: 500 })`.
|
|
134
|
+
*/
|
|
135
|
+
renderFallbackError?: (
|
|
136
|
+
error: unknown,
|
|
137
|
+
req: Request,
|
|
138
|
+
responseHeaders: Headers
|
|
139
|
+
) => Response | Promise<Response>;
|
|
124
140
|
}
|
|
125
141
|
|
|
126
142
|
// ─── Pipeline ──────────────────────────────────────────────────────────────
|
|
@@ -403,6 +419,14 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
403
419
|
logRenderError({ method, path, error });
|
|
404
420
|
await fireOnRequestError(error, req, 'render');
|
|
405
421
|
if (onPipelineError && error instanceof Error) onPipelineError(error, 'render');
|
|
422
|
+
// Try fallback error page before bare 500
|
|
423
|
+
if (config.renderFallbackError) {
|
|
424
|
+
try {
|
|
425
|
+
return await config.renderFallbackError(error, req, responseHeaders);
|
|
426
|
+
} catch {
|
|
427
|
+
// Fallback rendering itself failed — fall through to bare 500
|
|
428
|
+
}
|
|
429
|
+
}
|
|
406
430
|
return new Response(null, { status: 500 });
|
|
407
431
|
}
|
|
408
432
|
}
|
|
@@ -53,7 +53,6 @@ export const requestContextAls = new AsyncLocalStorage<RequestContextStore>();
|
|
|
53
53
|
// the ALS context persists for the entire request lifecycle including
|
|
54
54
|
// async stream consumption by React's renderToReadableStream.
|
|
55
55
|
|
|
56
|
-
|
|
57
56
|
// ─── Cookie Signing Secrets ──────────────────────────────────────────────
|
|
58
57
|
|
|
59
58
|
/**
|
|
@@ -10,10 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { RouteMatch } from './pipeline.js';
|
|
12
12
|
import type { MiddlewareFn } from './middleware-runner.js';
|
|
13
|
-
import {
|
|
14
|
-
METADATA_ROUTE_CONVENTIONS,
|
|
15
|
-
type MetadataRouteType,
|
|
16
|
-
} from './metadata-routes.js';
|
|
13
|
+
import { METADATA_ROUTE_CONVENTIONS, type MetadataRouteType } from './metadata-routes.js';
|
|
17
14
|
|
|
18
15
|
// ─── Manifest Types ───────────────────────────────────────────────────────
|
|
19
16
|
// The virtual module manifest has a slightly different shape than SegmentNode:
|
|
@@ -24,49 +24,49 @@ import buildManifest from 'virtual:timber-build-manifest';
|
|
|
24
24
|
|
|
25
25
|
import { renderToReadableStream } from '@vitejs/plugin-rsc/rsc';
|
|
26
26
|
|
|
27
|
-
import
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import type {
|
|
31
|
-
import { logRenderError } from '#/server/logger.js';
|
|
32
|
-
import { resolveLogMode } from '#/server/dev-logger.js';
|
|
33
|
-
import { createRouteMatcher, createMetadataRouteMatcher } from '#/server/route-matcher.js';
|
|
34
|
-
import type { ManifestSegmentNode } from '#/server/route-matcher.js';
|
|
35
|
-
import { DenySignal, RedirectSignal, RenderError, SsrStreamError } from '#/server/primitives.js';
|
|
36
|
-
import { buildClientScripts } from '#/server/html-injectors.js';
|
|
37
|
-
import type { ClientBootstrapConfig } from '#/server/html-injectors.js';
|
|
38
|
-
import { renderDenyPage, renderDenyPageAsRsc } from '#/server/deny-renderer.js';
|
|
39
|
-
import type { LayoutEntry } from '#/server/deny-renderer.js';
|
|
27
|
+
import type { FormRerender } from '#/server/action-handler.js';
|
|
28
|
+
import { handleActionRequest, isActionRequest } from '#/server/action-handler.js';
|
|
29
|
+
import type { BodyLimitsConfig } from '#/server/body-limits.js';
|
|
30
|
+
import type { BuildManifest } from '#/server/build-manifest.js';
|
|
40
31
|
import {
|
|
41
|
-
collectRouteCss,
|
|
42
|
-
collectRouteFonts,
|
|
43
|
-
collectRouteModulepreloads,
|
|
44
32
|
buildCssLinkTags,
|
|
45
33
|
buildFontPreloadTags,
|
|
46
34
|
buildModulepreloadTags,
|
|
35
|
+
collectRouteCss,
|
|
36
|
+
collectRouteFonts,
|
|
37
|
+
collectRouteModulepreloads,
|
|
47
38
|
} from '#/server/build-manifest.js';
|
|
48
|
-
import type {
|
|
49
|
-
import {
|
|
39
|
+
import type { LayoutEntry } from '#/server/deny-renderer.js';
|
|
40
|
+
import { renderDenyPage, renderDenyPageAsRsc } from '#/server/deny-renderer.js';
|
|
41
|
+
import { resolveLogMode } from '#/server/dev-logger.js';
|
|
50
42
|
import { sendEarlyHints103 } from '#/server/early-hints-sender.js';
|
|
51
|
-
import
|
|
52
|
-
import { buildRouteElement, RouteSignalWithContext } from '#/server/route-element-builder.js';
|
|
53
|
-
import { isActionRequest, handleActionRequest } from '#/server/action-handler.js';
|
|
54
|
-
import type { FormRerender } from '#/server/action-handler.js';
|
|
55
|
-
import type { BodyLimitsConfig } from '#/server/body-limits.js';
|
|
43
|
+
import { collectEarlyHintHeaders } from '#/server/early-hints.js';
|
|
56
44
|
import { runWithFormFlash } from '#/server/form-flash.js';
|
|
45
|
+
import type { ClientBootstrapConfig } from '#/server/html-injectors.js';
|
|
46
|
+
import { buildClientScripts } from '#/server/html-injectors.js';
|
|
47
|
+
import { logRenderError } from '#/server/logger.js';
|
|
48
|
+
import type { InterceptionContext, PipelineConfig, RouteMatch } from '#/server/pipeline.js';
|
|
49
|
+
import { createPipeline } from '#/server/pipeline.js';
|
|
50
|
+
import { DenySignal, RedirectSignal, RenderError, SsrStreamError } from '#/server/primitives.js';
|
|
51
|
+
import { buildRouteElement, RouteSignalWithContext } from '#/server/route-element-builder.js';
|
|
52
|
+
import type { ManifestSegmentNode } from '#/server/route-matcher.js';
|
|
53
|
+
import { createMetadataRouteMatcher, createRouteMatcher } from '#/server/route-matcher.js';
|
|
54
|
+
import type { NavContext } from '#/server/ssr-entry.js';
|
|
55
|
+
import { initDevTracing } from '#/server/tracing.js';
|
|
57
56
|
|
|
57
|
+
import { renderFallbackError as renderFallback } from '#/server/fallback-error.js';
|
|
58
|
+
import { handleApiRoute } from './api-handler.js';
|
|
59
|
+
import { renderErrorPage, renderNoMatchPage } from './error-renderer.js';
|
|
58
60
|
import {
|
|
59
|
-
createDebugChannelSink,
|
|
60
|
-
buildSegmentInfo,
|
|
61
|
-
isRscPayloadRequest,
|
|
62
61
|
buildRedirectResponse,
|
|
62
|
+
buildSegmentInfo,
|
|
63
|
+
createDebugChannelSink,
|
|
63
64
|
escapeHtml,
|
|
64
65
|
isAbortError,
|
|
66
|
+
isRscPayloadRequest,
|
|
65
67
|
parseCookiesFromHeader,
|
|
66
68
|
RSC_CONTENT_TYPE,
|
|
67
69
|
} from './helpers.js';
|
|
68
|
-
import { handleApiRoute } from './api-handler.js';
|
|
69
|
-
import { renderErrorPage, renderNoMatchPage } from './error-renderer.js';
|
|
70
70
|
import { callSsr } from './ssr-bridge.js';
|
|
71
71
|
|
|
72
72
|
// Dev-only pipeline error handler, set by the dev server after import.
|
|
@@ -183,6 +183,8 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
183
183
|
if (_devPipelineErrorHandler) _devPipelineErrorHandler(error, phase);
|
|
184
184
|
}
|
|
185
185
|
: undefined,
|
|
186
|
+
renderFallbackError: (error, req, responseHeaders) =>
|
|
187
|
+
renderFallback(error, req, responseHeaders, isDev, manifest.root, clientBootstrap),
|
|
186
188
|
};
|
|
187
189
|
|
|
188
190
|
const pipeline = createPipeline(pipelineConfig);
|
|
@@ -409,6 +411,30 @@ async function renderRoute(
|
|
|
409
411
|
status: error.status,
|
|
410
412
|
});
|
|
411
413
|
}
|
|
414
|
+
// Dev diagnostic: detect "Invalid hook call" errors which indicate
|
|
415
|
+
// a 'use client' component is being executed during RSC rendering
|
|
416
|
+
// instead of being serialized as a client reference. This happens when
|
|
417
|
+
// the RSC plugin's transform doesn't detect the directive — e.g., the
|
|
418
|
+
// directive isn't at the very top of the file, or the component is
|
|
419
|
+
// re-exported through a barrel file without 'use client'.
|
|
420
|
+
// See LOCAL-297.
|
|
421
|
+
if (
|
|
422
|
+
process.env.NODE_ENV !== 'production' &&
|
|
423
|
+
error instanceof Error &&
|
|
424
|
+
error.message.includes('Invalid hook call')
|
|
425
|
+
) {
|
|
426
|
+
console.error(
|
|
427
|
+
'[timber] A React hook was called during RSC rendering. This usually means a ' +
|
|
428
|
+
"'use client' component is being executed as a server component instead of " +
|
|
429
|
+
'being serialized as a client reference.\n\n' +
|
|
430
|
+
'Common causes:\n' +
|
|
431
|
+
" 1. The 'use client' directive is not the FIRST statement in the file (before any imports)\n" +
|
|
432
|
+
" 2. The component is re-exported through a barrel file (index.ts) that lacks 'use client'\n" +
|
|
433
|
+
' 3. @vitejs/plugin-rsc is not loaded or is misconfigured\n\n' +
|
|
434
|
+
`Request: ${_req.method} ${new URL(_req.url).pathname}`
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
412
438
|
// Track unhandled errors for pre-flush handling (500 status)
|
|
413
439
|
if (!renderError) {
|
|
414
440
|
renderError = { error, status: 500 };
|
|
@@ -635,20 +661,40 @@ async function renderRoute(
|
|
|
635
661
|
// Helper: check if render-phase signals were captured and return the
|
|
636
662
|
// appropriate HTTP response. Used after both successful SSR (signal
|
|
637
663
|
// promotion from Suspense) and failed SSR (signal outside Suspense).
|
|
638
|
-
|
|
664
|
+
//
|
|
665
|
+
// When `skipHandledDeny` is true (SSR success path), skip DenySignal
|
|
666
|
+
// promotion if the denial was already handled by a TimberErrorBoundary
|
|
667
|
+
// (e.g., slot error boundary). The boundary sets navContext._denyHandledByBoundary
|
|
668
|
+
// during SSR rendering. See LOCAL-298.
|
|
669
|
+
function checkCapturedSignals(
|
|
670
|
+
skipHandledDeny = false
|
|
671
|
+
): Response | Promise<Response> | null {
|
|
639
672
|
const sig = redirectSignal as RedirectSignal | null;
|
|
640
673
|
if (sig) return buildRedirectResponse(_req, sig, responseHeaders);
|
|
641
|
-
if (denySignal) {
|
|
674
|
+
if (denySignal && !(skipHandledDeny && navContext._denyHandledByBoundary)) {
|
|
642
675
|
return renderDenyPage(
|
|
643
|
-
denySignal,
|
|
644
|
-
|
|
676
|
+
denySignal,
|
|
677
|
+
segments,
|
|
678
|
+
layoutComponents as LayoutEntry[],
|
|
679
|
+
_req,
|
|
680
|
+
match,
|
|
681
|
+
responseHeaders,
|
|
682
|
+
clientBootstrap,
|
|
683
|
+
createDebugChannelSink,
|
|
684
|
+
callSsr
|
|
645
685
|
);
|
|
646
686
|
}
|
|
647
687
|
const err = renderError as { error: unknown; status: number } | null;
|
|
648
688
|
if (err) {
|
|
649
689
|
return renderErrorPage(
|
|
650
|
-
err.error,
|
|
651
|
-
|
|
690
|
+
err.error,
|
|
691
|
+
err.status,
|
|
692
|
+
segments,
|
|
693
|
+
layoutComponents as LayoutEntry[],
|
|
694
|
+
_req,
|
|
695
|
+
match,
|
|
696
|
+
responseHeaders,
|
|
697
|
+
clientBootstrap
|
|
652
698
|
);
|
|
653
699
|
}
|
|
654
700
|
return null;
|
|
@@ -664,7 +710,7 @@ async function renderRoute(
|
|
|
664
710
|
// See design/05-streaming.md §"deferSuspenseFor and the Hold Window"
|
|
665
711
|
await new Promise<void>((r) => setTimeout(r, 0));
|
|
666
712
|
|
|
667
|
-
const promoted = checkCapturedSignals();
|
|
713
|
+
const promoted = checkCapturedSignals(/* skipHandledDeny */ true);
|
|
668
714
|
if (promoted) {
|
|
669
715
|
ssrResponse.body?.cancel();
|
|
670
716
|
return promoted;
|
|
@@ -688,15 +734,28 @@ async function renderRoute(
|
|
|
688
734
|
if (denySignal) {
|
|
689
735
|
// Render deny page without layouts — pass empty layout list
|
|
690
736
|
return renderDenyPage(
|
|
691
|
-
denySignal,
|
|
692
|
-
|
|
737
|
+
denySignal,
|
|
738
|
+
segments,
|
|
739
|
+
[] as LayoutEntry[],
|
|
740
|
+
_req,
|
|
741
|
+
match,
|
|
742
|
+
responseHeaders,
|
|
743
|
+
clientBootstrap,
|
|
744
|
+
createDebugChannelSink,
|
|
745
|
+
callSsr
|
|
693
746
|
);
|
|
694
747
|
}
|
|
695
748
|
const err = renderError as { error: unknown; status: number } | null;
|
|
696
749
|
if (err) {
|
|
697
750
|
return renderErrorPage(
|
|
698
|
-
err.error,
|
|
699
|
-
|
|
751
|
+
err.error,
|
|
752
|
+
err.status,
|
|
753
|
+
segments,
|
|
754
|
+
[] as LayoutEntry[],
|
|
755
|
+
_req,
|
|
756
|
+
match,
|
|
757
|
+
responseHeaders,
|
|
758
|
+
clientBootstrap
|
|
700
759
|
);
|
|
701
760
|
}
|
|
702
761
|
// No captured signal — return bare 500
|
|
@@ -16,12 +16,13 @@
|
|
|
16
16
|
* See design/02-rendering-pipeline.md §"Parallel Slots"
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
20
|
-
import type { RouteMatch, InterceptionContext } from './pipeline.js';
|
|
21
|
-
import { SlotAccessGate } from './access-gate.js';
|
|
22
|
-
import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
|
|
23
19
|
import { TimberErrorBoundary } from '#/client/error-boundary.js';
|
|
24
20
|
import SlotErrorFallback from '#/client/slot-error-fallback.js';
|
|
21
|
+
import { SlotAccessGate } from './access-gate.js';
|
|
22
|
+
import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
|
|
23
|
+
import type { InterceptionContext, RouteMatch } from './pipeline.js';
|
|
24
|
+
import { DenySignal } from './primitives.js';
|
|
25
|
+
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
25
26
|
|
|
26
27
|
type CreateElementFn = (...args: unknown[]) => React.ReactElement;
|
|
27
28
|
|
|
@@ -56,7 +57,39 @@ export async function resolveSlotElement(
|
|
|
56
57
|
const mod = (await slotMatch.page.load()) as Record<string, unknown>;
|
|
57
58
|
if (mod.default) {
|
|
58
59
|
const SlotPage = mod.default as (...args: unknown[]) => unknown;
|
|
59
|
-
|
|
60
|
+
|
|
61
|
+
// Load default.tsx fallback for notFound() handling in the slot page.
|
|
62
|
+
// When a slot page calls notFound() or deny(), it should gracefully
|
|
63
|
+
// degrade to default.tsx or null — not crash the page. This matches
|
|
64
|
+
// Next.js behavior. See design/02-rendering-pipeline.md
|
|
65
|
+
// §"Slot Access Failure = Graceful Degradation"
|
|
66
|
+
let denyFallback: React.ReactElement | null = null;
|
|
67
|
+
if (slotNode.default) {
|
|
68
|
+
const defaultMod = (await slotNode.default.load()) as Record<string, unknown>;
|
|
69
|
+
const DefaultComp = defaultMod.default as ((...args: unknown[]) => unknown) | undefined;
|
|
70
|
+
if (DefaultComp) {
|
|
71
|
+
denyFallback = h(DefaultComp, { params: paramsPromise, searchParams: {} });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Wrap the slot page to catch DenySignal (from notFound() or deny())
|
|
76
|
+
// at the component level. This prevents the signal from reaching the
|
|
77
|
+
// RSC onError callback and being tracked as a page-level denial, which
|
|
78
|
+
// would cause the pipeline to replace the entire successful SSR response
|
|
79
|
+
// with a deny page. Instead, the slot gracefully degrades.
|
|
80
|
+
const denyFallbackCapture = denyFallback;
|
|
81
|
+
const SafeSlotPage = async (props: Record<string, unknown>) => {
|
|
82
|
+
try {
|
|
83
|
+
return await (SlotPage as (props: Record<string, unknown>) => unknown)(props);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error instanceof DenySignal) {
|
|
86
|
+
return denyFallbackCapture;
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
let element: React.ReactElement = h(SafeSlotPage, {
|
|
60
93
|
params: paramsPromise,
|
|
61
94
|
searchParams: {},
|
|
62
95
|
});
|
package/src/server/ssr-entry.ts
CHANGED
|
@@ -74,6 +74,13 @@ export interface NavContext {
|
|
|
74
74
|
/** Request cookies as name→value pairs. Used by useCookie() during SSR
|
|
75
75
|
* to return correct cookie values before hydration. */
|
|
76
76
|
cookies?: Map<string, string>;
|
|
77
|
+
/**
|
|
78
|
+
* Mutable flag: set by TimberErrorBoundary during SSR when it catches
|
|
79
|
+
* a DenySignal (via digest). This tells the RSC entry that the denial
|
|
80
|
+
* was contained by a slot error boundary and should NOT be promoted
|
|
81
|
+
* to a page-level deny. See LOCAL-298.
|
|
82
|
+
*/
|
|
83
|
+
_denyHandledByBoundary?: boolean;
|
|
77
84
|
}
|
|
78
85
|
|
|
79
86
|
/**
|
|
@@ -111,6 +118,7 @@ export async function handleSsr(
|
|
|
111
118
|
searchParams: navContext.searchParams,
|
|
112
119
|
cookies: navContext.cookies ?? new Map(),
|
|
113
120
|
params: navContext.params,
|
|
121
|
+
_navContext: navContext,
|
|
114
122
|
};
|
|
115
123
|
|
|
116
124
|
// Run the entire render inside the SSR data ALS scope.
|
|
@@ -156,7 +164,10 @@ export async function handleSsr(
|
|
|
156
164
|
// Wrap in SsrStreamError so the RSC entry can handle it without
|
|
157
165
|
// re-executing server components via renderDenyPage.
|
|
158
166
|
// See LOCAL-293.
|
|
159
|
-
console.error(
|
|
167
|
+
console.error(
|
|
168
|
+
'[timber] SSR shell failed from RSC stream error:',
|
|
169
|
+
formatSsrError(renderError)
|
|
170
|
+
);
|
|
160
171
|
throw new SsrStreamError(
|
|
161
172
|
'SSR renderToReadableStream failed due to RSC stream error',
|
|
162
173
|
renderError
|