@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.
Files changed (70) hide show
  1. package/dist/_chunks/request-context-BzES06i1.js.map +1 -1
  2. package/dist/_chunks/ssr-data-BgSwMbN9.js +38 -0
  3. package/dist/_chunks/ssr-data-BgSwMbN9.js.map +1 -0
  4. package/dist/_chunks/{use-cookie-HcvNlW4L.js → use-cookie-D2cZu0jK.js} +3 -37
  5. package/dist/_chunks/use-cookie-D2cZu0jK.js.map +1 -0
  6. package/dist/_chunks/use-query-states-wEXY2JQB.js +109 -0
  7. package/dist/_chunks/use-query-states-wEXY2JQB.js.map +1 -0
  8. package/dist/client/error-boundary.d.ts.map +1 -1
  9. package/dist/client/error-boundary.js +8 -0
  10. package/dist/client/error-boundary.js.map +1 -1
  11. package/dist/client/index.js +3 -84
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/client/ssr-data.d.ts +9 -0
  14. package/dist/client/ssr-data.d.ts.map +1 -1
  15. package/dist/client/use-query-states.d.ts.map +1 -1
  16. package/dist/cookies/index.js +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +10 -12
  19. package/dist/index.js.map +1 -1
  20. package/dist/plugins/entries.d.ts.map +1 -1
  21. package/dist/plugins/routing.d.ts.map +1 -1
  22. package/dist/plugins/server-bundle.d.ts.map +1 -1
  23. package/dist/plugins/shims.d.ts.map +1 -1
  24. package/dist/routing/status-file-lint.d.ts.map +1 -1
  25. package/dist/search-params/create.d.ts.map +1 -1
  26. package/dist/search-params/index.js +13 -4
  27. package/dist/search-params/index.js.map +1 -1
  28. package/dist/server/fallback-error.d.ts +28 -0
  29. package/dist/server/fallback-error.d.ts.map +1 -0
  30. package/dist/server/html-injectors.d.ts.map +1 -1
  31. package/dist/server/index.js +13 -10
  32. package/dist/server/index.js.map +1 -1
  33. package/dist/server/pipeline.d.ts +12 -0
  34. package/dist/server/pipeline.d.ts.map +1 -1
  35. package/dist/server/request-context.d.ts.map +1 -1
  36. package/dist/server/route-matcher.d.ts.map +1 -1
  37. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  38. package/dist/server/slot-resolver.d.ts +1 -1
  39. package/dist/server/slot-resolver.d.ts.map +1 -1
  40. package/dist/server/ssr-entry.d.ts +7 -0
  41. package/dist/server/ssr-entry.d.ts.map +1 -1
  42. package/dist/server/tree-builder.d.ts +10 -0
  43. package/dist/server/tree-builder.d.ts.map +1 -1
  44. package/package.json +23 -23
  45. package/src/client/browser-entry.ts +1 -1
  46. package/src/client/error-boundary.tsx +22 -0
  47. package/src/client/ssr-data.ts +7 -0
  48. package/src/client/use-query-states.ts +13 -1
  49. package/src/index.ts +16 -16
  50. package/src/plugins/dev-server.ts +3 -1
  51. package/src/plugins/entries.ts +2 -1
  52. package/src/plugins/routing.ts +5 -4
  53. package/src/plugins/server-bundle.ts +15 -6
  54. package/src/plugins/shims.ts +8 -14
  55. package/src/routing/status-file-lint.ts +1 -3
  56. package/src/search-params/create.ts +15 -8
  57. package/src/server/error-formatter.ts +12 -0
  58. package/src/server/fallback-error.ts +159 -0
  59. package/src/server/html-injectors.ts +9 -4
  60. package/src/server/pipeline.ts +24 -0
  61. package/src/server/request-context.ts +0 -1
  62. package/src/server/route-matcher.ts +1 -4
  63. package/src/server/rsc-entry/index.ts +98 -39
  64. package/src/server/slot-resolver.ts +38 -5
  65. package/src/server/ssr-entry.ts +12 -1
  66. package/src/server/tree-builder.ts +39 -11
  67. package/src/shims/server-only-noop.js +1 -0
  68. package/dist/_chunks/registry-BfPM41ri.js +0 -20
  69. package/dist/_chunks/registry-BfPM41ri.js.map +0 -1
  70. 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, '&amp;')
155
+ .replace(/</g, '&lt;')
156
+ .replace(/>/g, '&gt;')
157
+ .replace(/"/g, '&quot;')
158
+ .replace(/'/g, '&#x27;');
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(map: Record<string, string[]>, suffix: string): string[] | undefined {
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 = (manifest ? findManifestEntryArray(manifest.modulepreload, 'client/browser-entry.ts') : undefined) ?? [];
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
  }
@@ -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 React, { createElement } from 'react';
28
- import { createPipeline } from '#/server/pipeline.js';
29
- import { initDevTracing } from '#/server/tracing.js';
30
- import type { PipelineConfig, RouteMatch, InterceptionContext } from '#/server/pipeline.js';
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 { BuildManifest } from '#/server/build-manifest.js';
49
- import { collectEarlyHintHeaders } from '#/server/early-hints.js';
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 type { NavContext } from '#/server/ssr-entry.js';
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
- function checkCapturedSignals(): Response | Promise<Response> | null {
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, segments, layoutComponents as LayoutEntry[],
644
- _req, match, responseHeaders, clientBootstrap, createDebugChannelSink, callSsr
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, err.status, segments, layoutComponents as LayoutEntry[],
651
- _req, match, responseHeaders, clientBootstrap
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, segments, [] as LayoutEntry[],
692
- _req, match, responseHeaders, clientBootstrap, createDebugChannelSink, callSsr
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, err.status, segments, [] as LayoutEntry[],
699
- _req, match, responseHeaders, clientBootstrap
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
- let element: React.ReactElement = h(SlotPage, {
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
  });
@@ -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('[timber] SSR shell failed from RSC stream error:', formatSsrError(renderError));
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