@timber-js/app 0.1.9 → 0.1.11

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 (46) hide show
  1. package/dist/_chunks/request-context-BzES06i1.js.map +1 -1
  2. package/dist/_chunks/{use-query-states-Dd9PVu-L.js → use-query-states-wEXY2JQB.js} +9 -2
  3. package/dist/_chunks/{use-query-states-Dd9PVu-L.js.map → use-query-states-wEXY2JQB.js.map} +1 -1
  4. package/dist/client/index.js +1 -1
  5. package/dist/client/use-query-states.d.ts.map +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/plugins/entries.d.ts.map +1 -1
  9. package/dist/plugins/routing.d.ts.map +1 -1
  10. package/dist/routing/status-file-lint.d.ts.map +1 -1
  11. package/dist/search-params/index.js +1 -1
  12. package/dist/search-params/index.js.map +1 -1
  13. package/dist/server/fallback-error.d.ts +28 -0
  14. package/dist/server/fallback-error.d.ts.map +1 -0
  15. package/dist/server/html-injectors.d.ts.map +1 -1
  16. package/dist/server/index.js +3 -0
  17. package/dist/server/index.js.map +1 -1
  18. package/dist/server/pipeline.d.ts +12 -0
  19. package/dist/server/pipeline.d.ts.map +1 -1
  20. package/dist/server/request-context.d.ts.map +1 -1
  21. package/dist/server/route-matcher.d.ts.map +1 -1
  22. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  23. package/dist/server/slot-resolver.d.ts +1 -1
  24. package/dist/server/slot-resolver.d.ts.map +1 -1
  25. package/dist/server/ssr-entry.d.ts.map +1 -1
  26. package/dist/server/tree-builder.d.ts.map +1 -1
  27. package/package.json +23 -23
  28. package/src/client/browser-entry.ts +1 -1
  29. package/src/client/use-query-states.ts +13 -1
  30. package/src/index.ts +16 -16
  31. package/src/plugins/dev-server.ts +3 -1
  32. package/src/plugins/entries.ts +2 -1
  33. package/src/plugins/routing.ts +5 -4
  34. package/src/routing/status-file-lint.ts +1 -3
  35. package/src/search-params/create.ts +4 -1
  36. package/src/server/error-formatter.ts +2 -2
  37. package/src/server/fallback-error.ts +159 -0
  38. package/src/server/html-injectors.ts +9 -4
  39. package/src/server/pipeline.ts +24 -0
  40. package/src/server/request-context.ts +0 -1
  41. package/src/server/route-matcher.ts +63 -28
  42. package/src/server/rsc-entry/index.ts +64 -36
  43. package/src/server/slot-resolver.ts +38 -5
  44. package/src/server/ssr-entry.ts +4 -1
  45. package/src/server/tree-builder.ts +4 -1
  46. package/src/shims/server-only-noop.js +1 -0
@@ -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:
@@ -127,6 +124,43 @@ function matchPathname(root: ManifestSegmentNode, pathname: string): RouteMatch
127
124
  };
128
125
  }
129
126
 
127
+ /**
128
+ * An effective child flattened through group segments.
129
+ * Includes the chain of group nodes that must be added to the segments
130
+ * array before the child itself (to preserve the group → child nesting
131
+ * that the renderer expects).
132
+ */
133
+ interface EffectiveChild {
134
+ child: ManifestSegmentNode;
135
+ groupChain: ManifestSegmentNode[];
136
+ }
137
+
138
+ /**
139
+ * Collect effective children by flattening through group segments.
140
+ *
141
+ * Groups are transparent for URL matching — their non-group descendants
142
+ * are returned with the chain of group nodes that lead to them. This
143
+ * allows the caller to apply priority ordering (static > dynamic > ...)
144
+ * across all groups uniformly instead of per-group.
145
+ */
146
+ function collectEffectiveChildren(
147
+ node: ManifestSegmentNode,
148
+ groupChain: ManifestSegmentNode[] = []
149
+ ): EffectiveChild[] {
150
+ const result: EffectiveChild[] = [];
151
+ for (const child of node.children) {
152
+ if (child.segmentType === 'group') {
153
+ // Look through the group — its children become effective children
154
+ // with this group prepended to their chain
155
+ const nested = collectEffectiveChildren(child, [...groupChain, child]);
156
+ result.push(...nested);
157
+ } else {
158
+ result.push({ child, groupChain });
159
+ }
160
+ }
161
+ return result;
162
+ }
163
+
130
164
  /**
131
165
  * Recursively match URL segments against the segment tree.
132
166
  *
@@ -136,8 +170,10 @@ function matchPathname(root: ManifestSegmentNode, pathname: string): RouteMatch
136
170
  * 3. Catch-all segments ([...param])
137
171
  * 4. Optional catch-all segments ([[...param]])
138
172
  *
139
- * Groups are transparent — they don't consume URL segments but their
140
- * children are checked as if they were direct children of the parent.
173
+ * Groups are transparent — they don't consume URL segments. Children
174
+ * are flattened through groups so that priority ordering applies across
175
+ * all groups uniformly (a static in group A always beats a dynamic in
176
+ * group B, regardless of group ordering).
141
177
  */
142
178
  function matchSegments(
143
179
  node: ManifestSegmentNode,
@@ -163,11 +199,12 @@ function matchSegments(
163
199
  }
164
200
  }
165
201
 
166
- // Check optional catch-all children (they can match zero segments)
167
- for (const child of node.children) {
202
+ // Check optional catch-all children (direct and through groups)
203
+ const effective = collectEffectiveChildren(node);
204
+ for (const { child, groupChain } of effective) {
168
205
  if (child.segmentType === 'optional-catch-all') {
169
206
  if (child.page || child.route) {
170
- segments.push(child);
207
+ segments.push(...groupChain, child);
171
208
  // Zero segments → param is undefined (not set), matching Next.js semantics
172
209
  return true;
173
210
  }
@@ -180,35 +217,33 @@ function matchSegments(
180
217
 
181
218
  const part = parts[index];
182
219
 
183
- // Try children in priority order
220
+ // Flatten children through groups so priority ordering applies globally
221
+ // across all groups, not per-group.
222
+ const effective = collectEffectiveChildren(node);
184
223
 
185
224
  // 1. Static segments
186
- for (const child of node.children) {
225
+ for (const { child, groupChain } of effective) {
187
226
  if (child.segmentType === 'static' && child.segmentName === part) {
227
+ segments.push(...groupChain);
188
228
  if (matchSegments(child, parts, index + 1, segments, params)) {
189
229
  return true;
190
230
  }
231
+ // Backtrack group chain
232
+ segments.length -= groupChain.length;
191
233
  }
192
234
  }
193
235
 
194
- // 2. Group segments (transparent — recurse without consuming)
195
- for (const child of node.children) {
196
- if (child.segmentType === 'group') {
197
- if (matchSegments(child, parts, index, segments, params)) {
198
- return true;
199
- }
200
- }
201
- }
202
-
203
- // 3. Dynamic segments ([param])
204
- for (const child of node.children) {
236
+ // 2. Dynamic segments ([param])
237
+ for (const { child, groupChain } of effective) {
205
238
  if (child.segmentType === 'dynamic' && child.paramName) {
239
+ segments.push(...groupChain);
206
240
  const prevParam = params[child.paramName];
207
241
  params[child.paramName] = part;
208
242
  if (matchSegments(child, parts, index + 1, segments, params)) {
209
243
  return true;
210
244
  }
211
245
  // Backtrack
246
+ segments.length -= groupChain.length;
212
247
  if (prevParam !== undefined) {
213
248
  params[child.paramName] = prevParam;
214
249
  } else {
@@ -217,24 +252,24 @@ function matchSegments(
217
252
  }
218
253
  }
219
254
 
220
- // 4. Catch-all segments ([...param])
221
- for (const child of node.children) {
255
+ // 3. Catch-all segments ([...param])
256
+ for (const { child, groupChain } of effective) {
222
257
  if (child.segmentType === 'catch-all' && child.paramName) {
223
258
  if (child.page || child.route) {
224
259
  const remaining = parts.slice(index);
225
- segments.push(child);
260
+ segments.push(...groupChain, child);
226
261
  params[child.paramName] = remaining;
227
262
  return true;
228
263
  }
229
264
  }
230
265
  }
231
266
 
232
- // 5. Optional catch-all segments ([[...param]])
233
- for (const child of node.children) {
267
+ // 4. Optional catch-all segments ([[...param]])
268
+ for (const { child, groupChain } of effective) {
234
269
  if (child.segmentType === 'optional-catch-all' && child.paramName) {
235
270
  if (child.page || child.route) {
236
271
  const remaining = parts.slice(index);
237
- segments.push(child);
272
+ segments.push(...groupChain, child);
238
273
  params[child.paramName] = remaining;
239
274
  return true;
240
275
  }
@@ -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);
@@ -664,15 +666,28 @@ async function renderRoute(
664
666
  if (sig) return buildRedirectResponse(_req, sig, responseHeaders);
665
667
  if (denySignal) {
666
668
  return renderDenyPage(
667
- denySignal, segments, layoutComponents as LayoutEntry[],
668
- _req, match, responseHeaders, clientBootstrap, createDebugChannelSink, callSsr
669
+ denySignal,
670
+ segments,
671
+ layoutComponents as LayoutEntry[],
672
+ _req,
673
+ match,
674
+ responseHeaders,
675
+ clientBootstrap,
676
+ createDebugChannelSink,
677
+ callSsr
669
678
  );
670
679
  }
671
680
  const err = renderError as { error: unknown; status: number } | null;
672
681
  if (err) {
673
682
  return renderErrorPage(
674
- err.error, err.status, segments, layoutComponents as LayoutEntry[],
675
- _req, match, responseHeaders, clientBootstrap
683
+ err.error,
684
+ err.status,
685
+ segments,
686
+ layoutComponents as LayoutEntry[],
687
+ _req,
688
+ match,
689
+ responseHeaders,
690
+ clientBootstrap
676
691
  );
677
692
  }
678
693
  return null;
@@ -712,15 +727,28 @@ async function renderRoute(
712
727
  if (denySignal) {
713
728
  // Render deny page without layouts — pass empty layout list
714
729
  return renderDenyPage(
715
- denySignal, segments, [] as LayoutEntry[],
716
- _req, match, responseHeaders, clientBootstrap, createDebugChannelSink, callSsr
730
+ denySignal,
731
+ segments,
732
+ [] as LayoutEntry[],
733
+ _req,
734
+ match,
735
+ responseHeaders,
736
+ clientBootstrap,
737
+ createDebugChannelSink,
738
+ callSsr
717
739
  );
718
740
  }
719
741
  const err = renderError as { error: unknown; status: number } | null;
720
742
  if (err) {
721
743
  return renderErrorPage(
722
- err.error, err.status, segments, [] as LayoutEntry[],
723
- _req, match, responseHeaders, clientBootstrap
744
+ err.error,
745
+ err.status,
746
+ segments,
747
+ [] as LayoutEntry[],
748
+ _req,
749
+ match,
750
+ responseHeaders,
751
+ clientBootstrap
724
752
  );
725
753
  }
726
754
  // 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
  });
@@ -156,7 +156,10 @@ export async function handleSsr(
156
156
  // Wrap in SsrStreamError so the RSC entry can handle it without
157
157
  // re-executing server components via renderDenyPage.
158
158
  // See LOCAL-293.
159
- console.error('[timber] SSR shell failed from RSC stream error:', formatSsrError(renderError));
159
+ console.error(
160
+ '[timber] SSR shell failed from RSC stream error:',
161
+ formatSsrError(renderError)
162
+ );
160
163
  throw new SsrStreamError(
161
164
  'SSR renderToReadableStream failed due to RSC stream error',
162
165
  renderError
@@ -79,7 +79,10 @@ export interface AccessGateProps {
79
79
  * - 'pass': render children
80
80
  * - DenySignal/RedirectSignal: throw synchronously
81
81
  */
82
- verdict?: 'pass' | import('./primitives.js').DenySignal | import('./primitives.js').RedirectSignal;
82
+ verdict?:
83
+ | 'pass'
84
+ | import('./primitives.js').DenySignal
85
+ | import('./primitives.js').RedirectSignal;
83
86
  children: ReactElement;
84
87
  }
85
88
 
@@ -1,3 +1,4 @@
1
+ // oxlint-disable
1
2
  // No-op shim for server-only/client-only packages.
2
3
  // In dev mode, Vite externalizes node_modules and loads them via Node's
3
4
  // require(). Deps like `bright` that import `server-only` would hit the