@timber-js/app 0.2.0-alpha.36 → 0.2.0-alpha.37
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/LICENSE +8 -0
- package/dist/_chunks/als-registry-Ba7URUIn.js.map +1 -1
- package/dist/_chunks/{define-cookie-w5GWm_bL.js → define-cookie-BmKbSyp0.js} +4 -4
- package/dist/_chunks/{define-cookie-w5GWm_bL.js.map → define-cookie-BmKbSyp0.js.map} +1 -1
- package/dist/_chunks/{error-boundary-TYEQJZ1-.js → error-boundary-BAN3751q.js} +1 -1
- package/dist/_chunks/{error-boundary-TYEQJZ1-.js.map → error-boundary-BAN3751q.js.map} +1 -1
- package/dist/_chunks/{request-context-CZz_T0Bc.js → request-context-BxYIJM24.js} +59 -4
- package/dist/_chunks/request-context-BxYIJM24.js.map +1 -0
- package/dist/_chunks/segment-context-C6byCyZU.js +69 -0
- package/dist/_chunks/segment-context-C6byCyZU.js.map +1 -0
- package/dist/_chunks/{tracing-BPyIzIdu.js → tracing-CuXiCP5p.js} +1 -1
- package/dist/_chunks/{tracing-BPyIzIdu.js.map → tracing-CuXiCP5p.js.map} +1 -1
- package/dist/_chunks/{wrappers-C1SN725w.js → wrappers-C6J0nNji.js} +2 -2
- package/dist/_chunks/{wrappers-C1SN725w.js.map → wrappers-C6J0nNji.js.map} +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +25 -8
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts +15 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/cookies/index.js +1 -1
- package/dist/params/index.js +1 -1
- package/dist/search-params/index.js +1 -1
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +14 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +42 -26
- package/dist/server/index.js.map +1 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +30 -3
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +39 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.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/tree-builder.d.ts +7 -4
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/shared/merge-search-params.d.ts +22 -0
- package/dist/shared/merge-search-params.d.ts.map +1 -0
- package/package.json +6 -7
- package/src/cli.ts +0 -0
- package/src/client/index.ts +1 -0
- package/src/client/link.tsx +57 -3
- package/src/server/access-gate.tsx +6 -5
- package/src/server/als-registry.ts +14 -0
- package/src/server/index.ts +3 -0
- package/src/server/pipeline.ts +6 -0
- package/src/server/primitives.ts +47 -5
- package/src/server/request-context.ts +69 -1
- package/src/server/route-element-builder.ts +10 -16
- package/src/server/slot-resolver.ts +10 -19
- package/src/server/tree-builder.ts +13 -15
- package/src/shared/merge-search-params.ts +48 -0
- package/dist/_chunks/request-context-CZz_T0Bc.js.map +0 -1
- package/dist/_chunks/segment-context-Dpq2XOKg.js +0 -34
- package/dist/_chunks/segment-context-Dpq2XOKg.js.map +0 -1
|
@@ -178,6 +178,72 @@ export function rawSearchParams(): Promise<URLSearchParams> {
|
|
|
178
178
|
return store.searchParamsPromise;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Returns a Promise resolving to the current request's coerced segment params.
|
|
183
|
+
*
|
|
184
|
+
* Segment params are set by the pipeline after route matching and param
|
|
185
|
+
* coercion (via params.ts codecs). When no params.ts exists, values are
|
|
186
|
+
* raw strings. When codecs are defined, values are already coerced
|
|
187
|
+
* (e.g., `id` is a `number` if `defineSegmentParams({ id: z.coerce.number() })`).
|
|
188
|
+
*
|
|
189
|
+
* This is the primary way page and layout components access route params:
|
|
190
|
+
*
|
|
191
|
+
* ```ts
|
|
192
|
+
* import { rawSegmentParams } from '@timber-js/app/server'
|
|
193
|
+
*
|
|
194
|
+
* export default async function Page() {
|
|
195
|
+
* const { slug } = await rawSegmentParams()
|
|
196
|
+
* // ...
|
|
197
|
+
* }
|
|
198
|
+
* ```
|
|
199
|
+
*
|
|
200
|
+
* Throws if called outside a request context.
|
|
201
|
+
*/
|
|
202
|
+
export function rawSegmentParams(): Promise<Record<string, string | string[]>> {
|
|
203
|
+
const store = requestContextAls.getStore();
|
|
204
|
+
if (!store) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
'[timber] rawSegmentParams() called outside of a request context. ' +
|
|
207
|
+
'It can only be used in middleware, access checks, server components, and server actions.'
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (!store.segmentParamsPromise) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
'[timber] rawSegmentParams() called before route matching completed. ' +
|
|
213
|
+
'Segment params are not available until after the route is matched.'
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return store.segmentParamsPromise;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Set the segment params promise on the current request context.
|
|
221
|
+
* Called by the pipeline after route matching and param coercion.
|
|
222
|
+
*
|
|
223
|
+
* @internal — framework use only
|
|
224
|
+
*/
|
|
225
|
+
export function setSegmentParams(params: Record<string, string | string[]>): void {
|
|
226
|
+
const store = requestContextAls.getStore();
|
|
227
|
+
if (!store) {
|
|
228
|
+
throw new Error('[timber] setSegmentParams() called outside of a request context.');
|
|
229
|
+
}
|
|
230
|
+
store.segmentParamsPromise = Promise.resolve(params);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Returns the raw search string from the current request URL (e.g. "?foo=bar").
|
|
235
|
+
* Synchronous — safe for use in `redirect()` which throws synchronously.
|
|
236
|
+
*
|
|
237
|
+
* Returns empty string if called outside a request context (non-throwing for
|
|
238
|
+
* use in redirect's optional preserveSearchParams path).
|
|
239
|
+
*
|
|
240
|
+
* @internal — used by redirect() for preserveSearchParams support.
|
|
241
|
+
*/
|
|
242
|
+
export function getRequestSearchString(): string {
|
|
243
|
+
const store = requestContextAls.getStore();
|
|
244
|
+
return store?.searchString ?? '';
|
|
245
|
+
}
|
|
246
|
+
|
|
181
247
|
// ─── Types ────────────────────────────────────────────────────────────────
|
|
182
248
|
|
|
183
249
|
/**
|
|
@@ -253,11 +319,13 @@ export interface RequestCookies {
|
|
|
253
319
|
*/
|
|
254
320
|
export function runWithRequestContext<T>(req: Request, fn: () => T): T {
|
|
255
321
|
const originalCopy = new Headers(req.headers);
|
|
322
|
+
const parsedUrl = new URL(req.url);
|
|
256
323
|
const store: RequestContextStore = {
|
|
257
324
|
headers: freezeHeaders(req.headers),
|
|
258
325
|
originalHeaders: originalCopy,
|
|
259
326
|
cookieHeader: req.headers.get('cookie') ?? '',
|
|
260
|
-
searchParamsPromise: Promise.resolve(
|
|
327
|
+
searchParamsPromise: Promise.resolve(parsedUrl.searchParams),
|
|
328
|
+
searchString: parsedUrl.search,
|
|
261
329
|
cookieJar: new Map(),
|
|
262
330
|
flushed: false,
|
|
263
331
|
mutableContext: false,
|
|
@@ -110,7 +110,7 @@ function rejectLegacyGenerateMetadata(mod: Record<string, unknown>, filePath: st
|
|
|
110
110
|
` // Before\n` +
|
|
111
111
|
` export async function generateMetadata({ params }) { ... }\n\n` +
|
|
112
112
|
` // After\n` +
|
|
113
|
-
` export async function metadata(
|
|
113
|
+
` export async function metadata() { ... }`
|
|
114
114
|
);
|
|
115
115
|
}
|
|
116
116
|
}
|
|
@@ -119,19 +119,21 @@ function rejectLegacyGenerateMetadata(mod: Record<string, unknown>, filePath: st
|
|
|
119
119
|
* Extract and resolve metadata from a module (layout or page).
|
|
120
120
|
* Handles both static metadata objects and async metadata functions.
|
|
121
121
|
* Returns the resolved Metadata, or null if none exported.
|
|
122
|
+
*
|
|
123
|
+
* Metadata functions no longer receive { params } — they access params
|
|
124
|
+
* via rawSegmentParams() from ALS, same as page/layout components.
|
|
122
125
|
*/
|
|
123
126
|
async function extractMetadata(
|
|
124
127
|
mod: Record<string, unknown>,
|
|
125
|
-
segment: ManifestSegmentNode
|
|
126
|
-
paramsPromise: Promise<Record<string, string | string[]>>
|
|
128
|
+
segment: ManifestSegmentNode
|
|
127
129
|
): Promise<Metadata | null> {
|
|
128
130
|
if (typeof mod.metadata === 'function') {
|
|
129
|
-
type MetadataFn = (
|
|
131
|
+
type MetadataFn = () => Promise<Metadata>;
|
|
130
132
|
return (
|
|
131
133
|
(await withSpan(
|
|
132
134
|
'timber.metadata',
|
|
133
135
|
{ 'timber.segment': segment.segmentName ?? segment.urlPath },
|
|
134
|
-
() => (mod.metadata as MetadataFn)(
|
|
136
|
+
() => (mod.metadata as MetadataFn)()
|
|
135
137
|
)) ?? null
|
|
136
138
|
);
|
|
137
139
|
}
|
|
@@ -172,9 +174,6 @@ export async function buildRouteElement(
|
|
|
172
174
|
): Promise<RouteElementResult> {
|
|
173
175
|
const segments = match.segments as unknown as ManifestSegmentNode[];
|
|
174
176
|
|
|
175
|
-
// Params are passed as a Promise to match Next.js 15+ convention.
|
|
176
|
-
const paramsPromise = Promise.resolve(match.params);
|
|
177
|
-
|
|
178
177
|
// Load all modules along the segment chain
|
|
179
178
|
const metadataEntries: Array<{ metadata: Metadata; isPage: boolean }> = [];
|
|
180
179
|
const layoutComponents: LayoutComponentEntry[] = [];
|
|
@@ -199,7 +198,7 @@ export async function buildRouteElement(
|
|
|
199
198
|
// middleware and rendering. See coerceSegmentParams() in pipeline.ts.
|
|
200
199
|
|
|
201
200
|
rejectLegacyGenerateMetadata(mod, segment.layout.filePath ?? segment.urlPath);
|
|
202
|
-
const layoutMetadata = await extractMetadata(mod, segment
|
|
201
|
+
const layoutMetadata = await extractMetadata(mod, segment);
|
|
203
202
|
if (layoutMetadata) {
|
|
204
203
|
metadataEntries.push({ metadata: layoutMetadata, isPage: false });
|
|
205
204
|
}
|
|
@@ -217,7 +216,7 @@ export async function buildRouteElement(
|
|
|
217
216
|
PageComponent = mod.default as (...args: unknown[]) => unknown;
|
|
218
217
|
}
|
|
219
218
|
rejectLegacyGenerateMetadata(mod, segment.page.filePath ?? segment.urlPath);
|
|
220
|
-
const pageMetadata = await extractMetadata(mod, segment
|
|
219
|
+
const pageMetadata = await extractMetadata(mod, segment);
|
|
221
220
|
if (pageMetadata) {
|
|
222
221
|
metadataEntries.push({ metadata: pageMetadata, isPage: true });
|
|
223
222
|
}
|
|
@@ -317,9 +316,7 @@ export async function buildRouteElement(
|
|
|
317
316
|
);
|
|
318
317
|
};
|
|
319
318
|
|
|
320
|
-
let element = h(TracedPage, {
|
|
321
|
-
params: paramsPromise,
|
|
322
|
-
});
|
|
319
|
+
let element = h(TracedPage, {});
|
|
323
320
|
|
|
324
321
|
// Build a lookup of layout components by segment for O(1) access.
|
|
325
322
|
const layoutBySegment = new Map(
|
|
@@ -399,7 +396,6 @@ export async function buildRouteElement(
|
|
|
399
396
|
if (accessFn) {
|
|
400
397
|
element = h(AccessGate, {
|
|
401
398
|
accessFn,
|
|
402
|
-
params: match.params,
|
|
403
399
|
segmentName: segment.segmentName,
|
|
404
400
|
verdict: accessVerdicts.get(i),
|
|
405
401
|
children: element,
|
|
@@ -416,7 +412,6 @@ export async function buildRouteElement(
|
|
|
416
412
|
slotProps[slotName] = await resolveSlotElement(
|
|
417
413
|
slotNode as ManifestSegmentNode,
|
|
418
414
|
match,
|
|
419
|
-
paramsPromise,
|
|
420
415
|
h,
|
|
421
416
|
interception
|
|
422
417
|
);
|
|
@@ -447,7 +442,6 @@ export async function buildRouteElement(
|
|
|
447
442
|
parallelRouteKeys,
|
|
448
443
|
children: h(TracedLayout, {
|
|
449
444
|
...slotProps,
|
|
450
|
-
params: paramsPromise,
|
|
451
445
|
children: element,
|
|
452
446
|
}),
|
|
453
447
|
});
|
|
@@ -45,13 +45,12 @@ async function loadComponent(loader: {
|
|
|
45
45
|
*/
|
|
46
46
|
async function renderDefaultFallback(
|
|
47
47
|
slotNode: ManifestSegmentNode,
|
|
48
|
-
paramsPromise: Promise<Record<string, string | string[]>>,
|
|
49
48
|
h: CreateElementFn
|
|
50
49
|
): Promise<React.ReactElement | null> {
|
|
51
50
|
if (!slotNode.default) return null;
|
|
52
51
|
const DefaultComp = await loadComponent(slotNode.default);
|
|
53
52
|
if (!DefaultComp) return null;
|
|
54
|
-
return h(DefaultComp, {
|
|
53
|
+
return h(DefaultComp, {});
|
|
55
54
|
}
|
|
56
55
|
|
|
57
56
|
// ─── Segment Tree Matching ──────────────────────────────────────────────────
|
|
@@ -153,7 +152,6 @@ function walkSegmentTree(
|
|
|
153
152
|
export async function resolveSlotElement(
|
|
154
153
|
slotNode: ManifestSegmentNode,
|
|
155
154
|
match: RouteMatch,
|
|
156
|
-
paramsPromise: Promise<Record<string, string | string[]>>,
|
|
157
155
|
h: CreateElementFn,
|
|
158
156
|
interception?: InterceptionContext
|
|
159
157
|
): Promise<React.ReactElement | null> {
|
|
@@ -174,7 +172,7 @@ export async function resolveSlotElement(
|
|
|
174
172
|
// degrade to default.tsx or null — not crash the page. This matches
|
|
175
173
|
// Next.js behavior. See design/02-rendering-pipeline.md
|
|
176
174
|
// §"Slot Access Failure = Graceful Degradation"
|
|
177
|
-
const denyFallback = await renderDefaultFallback(slotNode,
|
|
175
|
+
const denyFallback = await renderDefaultFallback(slotNode, h);
|
|
178
176
|
|
|
179
177
|
// Wrap the slot page to catch DenySignal (from notFound() or deny())
|
|
180
178
|
// at the component level. This prevents the signal from reaching the
|
|
@@ -192,23 +190,21 @@ export async function resolveSlotElement(
|
|
|
192
190
|
}
|
|
193
191
|
};
|
|
194
192
|
|
|
195
|
-
let element: React.ReactElement = h(SafeSlotPage, {
|
|
196
|
-
params: paramsPromise,
|
|
197
|
-
});
|
|
193
|
+
let element: React.ReactElement = h(SafeSlotPage, {});
|
|
198
194
|
|
|
199
195
|
// Wrap with error boundaries and layouts from intermediate slot segments
|
|
200
196
|
// (everything between slot root and leaf). Process innermost-first, same
|
|
201
197
|
// order as route-element-builder.ts handles main segments. The slot root
|
|
202
198
|
// (index 0) is handled separately after the access gate below.
|
|
203
|
-
element = await wrapWithIntermediateSegments(slotMatch.chain, element,
|
|
199
|
+
element = await wrapWithIntermediateSegments(slotMatch.chain, element, h);
|
|
204
200
|
|
|
205
201
|
// Wrap in SlotAccessGate if slot root has access.ts.
|
|
206
202
|
// On denial: denied.tsx → default.tsx → null (graceful degradation).
|
|
207
203
|
// See design/04-authorization.md §"Slot-Level Auth".
|
|
208
|
-
element = await wrapWithAccessGate(slotNode, element,
|
|
204
|
+
element = await wrapWithAccessGate(slotNode, element, h);
|
|
209
205
|
|
|
210
206
|
// Wrap with slot root's layout (outermost, outside access gate)
|
|
211
|
-
element = await wrapWithLayout(slotNode, element,
|
|
207
|
+
element = await wrapWithLayout(slotNode, element, h);
|
|
212
208
|
|
|
213
209
|
// Wrap with slot root's error boundaries (outermost)
|
|
214
210
|
element = await wrapSegmentWithErrorBoundaries(slotNode, element, h);
|
|
@@ -231,7 +227,7 @@ export async function resolveSlotElement(
|
|
|
231
227
|
}
|
|
232
228
|
|
|
233
229
|
// No matching page — render default.tsx fallback
|
|
234
|
-
return renderDefaultFallback(slotNode,
|
|
230
|
+
return renderDefaultFallback(slotNode, h);
|
|
235
231
|
}
|
|
236
232
|
|
|
237
233
|
// ─── Element Wrapping Helpers ───────────────────────────────────────────────
|
|
@@ -244,13 +240,12 @@ export async function resolveSlotElement(
|
|
|
244
240
|
async function wrapWithIntermediateSegments(
|
|
245
241
|
chain: ManifestSegmentNode[],
|
|
246
242
|
element: React.ReactElement,
|
|
247
|
-
paramsPromise: Promise<Record<string, string | string[]>>,
|
|
248
243
|
h: CreateElementFn
|
|
249
244
|
): Promise<React.ReactElement> {
|
|
250
245
|
for (let i = chain.length - 1; i > 0; i--) {
|
|
251
246
|
const seg = chain[i];
|
|
252
247
|
element = await wrapSegmentWithErrorBoundaries(seg, element, h);
|
|
253
|
-
element = await wrapWithLayout(seg, element,
|
|
248
|
+
element = await wrapWithLayout(seg, element, h);
|
|
254
249
|
}
|
|
255
250
|
return element;
|
|
256
251
|
}
|
|
@@ -261,13 +256,12 @@ async function wrapWithIntermediateSegments(
|
|
|
261
256
|
async function wrapWithLayout(
|
|
262
257
|
node: ManifestSegmentNode,
|
|
263
258
|
element: React.ReactElement,
|
|
264
|
-
paramsPromise: Promise<Record<string, string | string[]>>,
|
|
265
259
|
h: CreateElementFn
|
|
266
260
|
): Promise<React.ReactElement> {
|
|
267
261
|
if (!node.layout) return element;
|
|
268
262
|
const Layout = await loadComponent(node.layout);
|
|
269
263
|
if (!Layout) return element;
|
|
270
|
-
return h(Layout, {
|
|
264
|
+
return h(Layout, { children: element });
|
|
271
265
|
}
|
|
272
266
|
|
|
273
267
|
/**
|
|
@@ -277,7 +271,6 @@ async function wrapWithLayout(
|
|
|
277
271
|
async function wrapWithAccessGate(
|
|
278
272
|
slotNode: ManifestSegmentNode,
|
|
279
273
|
element: React.ReactElement,
|
|
280
|
-
paramsPromise: Promise<Record<string, string | string[]>>,
|
|
281
274
|
h: CreateElementFn
|
|
282
275
|
): Promise<React.ReactElement> {
|
|
283
276
|
if (!slotNode.access) return element;
|
|
@@ -295,12 +288,10 @@ async function wrapWithAccessGate(
|
|
|
295
288
|
// Extract slot name from the directory name (strip @ prefix)
|
|
296
289
|
const slotName = slotNode.segmentName?.replace(/^@/, '') ?? '';
|
|
297
290
|
|
|
298
|
-
const defaultFallback = await renderDefaultFallback(slotNode,
|
|
299
|
-
const params = await paramsPromise;
|
|
291
|
+
const defaultFallback = await renderDefaultFallback(slotNode, h);
|
|
300
292
|
|
|
301
293
|
return h(SlotAccessGate, {
|
|
302
294
|
accessFn,
|
|
303
|
-
params,
|
|
304
295
|
DeniedComponent,
|
|
305
296
|
slotName,
|
|
306
297
|
createElement: h,
|
|
@@ -46,8 +46,13 @@ export type SlotElements = Map<string, ReactElement>;
|
|
|
46
46
|
export interface TreeBuilderConfig {
|
|
47
47
|
/** The matched segment chain from root to leaf. */
|
|
48
48
|
segments: SegmentNode[];
|
|
49
|
-
/**
|
|
50
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Route params extracted by the matcher (catch-all segments produce string[]).
|
|
51
|
+
* @deprecated Params are now accessed via rawSegmentParams() from ALS.
|
|
52
|
+
* This field is kept for backward compatibility but is no longer used
|
|
53
|
+
* by the tree builder itself.
|
|
54
|
+
*/
|
|
55
|
+
params?: Record<string, string | string[]>;
|
|
51
56
|
/** Loads a route file's module. */
|
|
52
57
|
loadModule: ModuleLoader;
|
|
53
58
|
/** React.createElement or equivalent. */
|
|
@@ -76,7 +81,6 @@ export interface TreeBuilderConfig {
|
|
|
76
81
|
*/
|
|
77
82
|
export interface AccessGateProps {
|
|
78
83
|
accessFn: (ctx: { params: Record<string, string | string[]> }) => unknown;
|
|
79
|
-
params: Record<string, string | string[]>;
|
|
80
84
|
/** Segment name for dev logging (e.g. "authenticated", "dashboard"). */
|
|
81
85
|
segmentName?: string;
|
|
82
86
|
/**
|
|
@@ -102,7 +106,6 @@ export interface AccessGateProps {
|
|
|
102
106
|
*/
|
|
103
107
|
export interface SlotAccessGateProps {
|
|
104
108
|
accessFn: (ctx: { params: Record<string, string | string[]> }) => unknown;
|
|
105
|
-
params: Record<string, string | string[]>;
|
|
106
109
|
/** The denied.tsx component (not a pre-built element). null if no denied.tsx exists. */
|
|
107
110
|
DeniedComponent: ((...args: unknown[]) => unknown) | null;
|
|
108
111
|
/** Slot directory name without @ prefix (e.g. "admin", "sidebar"). */
|
|
@@ -149,7 +152,7 @@ export interface TreeBuildResult {
|
|
|
149
152
|
* Parallel slots are resolved at each layout level and composed as named props.
|
|
150
153
|
*/
|
|
151
154
|
export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeBuildResult> {
|
|
152
|
-
const { segments,
|
|
155
|
+
const { segments, loadModule, createElement, errorBoundaryComponent } = config;
|
|
153
156
|
|
|
154
157
|
if (segments.length === 0) {
|
|
155
158
|
throw new Error('[timber] buildElementTree: empty segment chain');
|
|
@@ -173,8 +176,8 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
|
|
|
173
176
|
);
|
|
174
177
|
}
|
|
175
178
|
|
|
176
|
-
// Build the page element
|
|
177
|
-
let element: ReactElement = createElement(PageComponent, {
|
|
179
|
+
// Build the page element — params are accessed via rawSegmentParams() from ALS
|
|
180
|
+
let element: ReactElement = createElement(PageComponent, {});
|
|
178
181
|
|
|
179
182
|
// Build tree bottom-up: wrap page, then walk segments from leaf to root
|
|
180
183
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
@@ -195,7 +198,6 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
|
|
|
195
198
|
const accessFn = accessModule.default as AccessGateProps['accessFn'];
|
|
196
199
|
element = createElement('timber:access-gate', {
|
|
197
200
|
accessFn,
|
|
198
|
-
params,
|
|
199
201
|
segmentName: segment.segmentName,
|
|
200
202
|
children: element,
|
|
201
203
|
} satisfies AccessGateProps);
|
|
@@ -215,7 +217,6 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
|
|
|
215
217
|
for (const [slotName, slotNode] of segment.slots) {
|
|
216
218
|
slotProps[slotName] = await buildSlotElement(
|
|
217
219
|
slotNode,
|
|
218
|
-
params,
|
|
219
220
|
loadModule,
|
|
220
221
|
createElement,
|
|
221
222
|
errorBoundaryComponent
|
|
@@ -225,7 +226,6 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
|
|
|
225
226
|
|
|
226
227
|
element = createElement(LayoutComponent, {
|
|
227
228
|
...slotProps,
|
|
228
|
-
params,
|
|
229
229
|
children: element,
|
|
230
230
|
});
|
|
231
231
|
}
|
|
@@ -245,7 +245,6 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
|
|
|
245
245
|
*/
|
|
246
246
|
async function buildSlotElement(
|
|
247
247
|
slotNode: SegmentNode,
|
|
248
|
-
params: Record<string, string | string[]>,
|
|
249
248
|
loadModule: ModuleLoader,
|
|
250
249
|
createElement: CreateElement,
|
|
251
250
|
errorBoundaryComponent: unknown
|
|
@@ -262,10 +261,10 @@ async function buildSlotElement(
|
|
|
262
261
|
|
|
263
262
|
// If no page, render default.tsx or null
|
|
264
263
|
if (!PageComponent) {
|
|
265
|
-
return DefaultComponent ? createElement(DefaultComponent, {
|
|
264
|
+
return DefaultComponent ? createElement(DefaultComponent, {}) : null;
|
|
266
265
|
}
|
|
267
266
|
|
|
268
|
-
let element: ReactElement = createElement(PageComponent, {
|
|
267
|
+
let element: ReactElement = createElement(PageComponent, {});
|
|
269
268
|
|
|
270
269
|
// Wrap in error boundaries
|
|
271
270
|
element = await wrapWithErrorBoundaries(
|
|
@@ -287,11 +286,10 @@ async function buildSlotElement(
|
|
|
287
286
|
const DeniedComponent =
|
|
288
287
|
(deniedModule?.default as ((...args: unknown[]) => ReactElement) | undefined) ?? null;
|
|
289
288
|
|
|
290
|
-
const defaultFallback = DefaultComponent ? createElement(DefaultComponent, {
|
|
289
|
+
const defaultFallback = DefaultComponent ? createElement(DefaultComponent, {}) : null;
|
|
291
290
|
|
|
292
291
|
element = createElement('timber:slot-access-gate', {
|
|
293
292
|
accessFn,
|
|
294
|
-
params,
|
|
295
293
|
DeniedComponent,
|
|
296
294
|
slotName: slotNode.segmentName.replace(/^@/, ''),
|
|
297
295
|
createElement,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utility for merging preserved search params into a target URL.
|
|
3
|
+
*
|
|
4
|
+
* Used by both <Link> (client) and redirect() (server). Extracted to a shared
|
|
5
|
+
* module to avoid importing client code ('use client') from server modules.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Merge preserved search params from the current URL into a target href.
|
|
10
|
+
*
|
|
11
|
+
* When `preserve` is `true`, all current search params are merged.
|
|
12
|
+
* When `preserve` is a `string[]`, only the named params are merged.
|
|
13
|
+
*
|
|
14
|
+
* The target href's own search params take precedence — preserved params
|
|
15
|
+
* are only added if the target doesn't already define them.
|
|
16
|
+
*
|
|
17
|
+
* @param targetHref - The resolved target href (may already contain query string)
|
|
18
|
+
* @param currentSearch - The current URL's search string (e.g. "?private=access&page=2")
|
|
19
|
+
* @param preserve - `true` to preserve all, or `string[]` to preserve specific params
|
|
20
|
+
* @returns The target href with preserved search params merged in
|
|
21
|
+
*/
|
|
22
|
+
export function mergePreservedSearchParams(
|
|
23
|
+
targetHref: string,
|
|
24
|
+
currentSearch: string,
|
|
25
|
+
preserve: true | string[]
|
|
26
|
+
): string {
|
|
27
|
+
const currentParams = new URLSearchParams(currentSearch);
|
|
28
|
+
if (currentParams.size === 0) return targetHref;
|
|
29
|
+
|
|
30
|
+
// Split target into path and existing query
|
|
31
|
+
const qIdx = targetHref.indexOf('?');
|
|
32
|
+
const targetPath = qIdx >= 0 ? targetHref.slice(0, qIdx) : targetHref;
|
|
33
|
+
const targetParams = new URLSearchParams(qIdx >= 0 ? targetHref.slice(qIdx + 1) : '');
|
|
34
|
+
|
|
35
|
+
// Collect params to preserve (that aren't already in the target)
|
|
36
|
+
const merged = new URLSearchParams(targetParams);
|
|
37
|
+
for (const [key, value] of currentParams) {
|
|
38
|
+
// Only preserve if: (a) not already in target, and (b) included in preserve list
|
|
39
|
+
if (!targetParams.has(key)) {
|
|
40
|
+
if (preserve === true || preserve.includes(key)) {
|
|
41
|
+
merged.append(key, value);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const qs = merged.toString();
|
|
47
|
+
return qs ? `${targetPath}?${qs}` : targetPath;
|
|
48
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"request-context-CZz_T0Bc.js","names":[],"sources":["../../src/server/request-context.ts"],"sourcesContent":["/**\n * Request Context — per-request ALS store for headers() and cookies().\n *\n * Follows the same pattern as tracing.ts: a module-level AsyncLocalStorage\n * instance, public accessor functions that throw outside request scope,\n * and a framework-internal `runWithRequestContext()` to establish scope.\n *\n * See design/04-authorization.md §\"AccessContext does not include cookies or headers\"\n * and design/11-platform.md §\"AsyncLocalStorage\".\n * See design/29-cookies.md for cookie mutation semantics.\n */\n\nimport { requestContextAls, type RequestContextStore, type CookieEntry } from './als-registry.js';\nimport { isDebug } from './debug.js';\n\n// Re-export the ALS for framework-internal consumers that need direct access.\nexport { requestContextAls };\n\n// No fallback needed — we use enterWith() instead of run() to ensure\n// the ALS context persists for the entire request lifecycle including\n// async stream consumption by React's renderToReadableStream.\n\n// ─── Public API ───────────────────────────────────────────────────────────\n\n/**\n * Returns a read-only view of the current request's headers.\n *\n * Available in middleware, access checks, server components, and server actions.\n * Throws if called outside a request context (security principle #2: no global fallback).\n */\nexport function headers(): ReadonlyHeaders {\n const store = requestContextAls.getStore();\n if (!store) {\n throw new Error(\n '[timber] headers() called outside of a request context. ' +\n 'It can only be used in middleware, access checks, server components, and server actions.'\n );\n }\n return store.headers;\n}\n\n/**\n * Returns a cookie accessor for the current request.\n *\n * Available in middleware, access checks, server components, and server actions.\n * Throws if called outside a request context (security principle #2: no global fallback).\n *\n * Read methods (.get, .has, .getAll) are always available and reflect\n * read-your-own-writes from .set() calls in the same request.\n *\n * Mutation methods (.set, .delete, .clear) are only available in mutable\n * contexts (middleware.ts, server actions, route.ts handlers). Calling them\n * in read-only contexts (access.ts, server components) throws.\n *\n * See design/29-cookies.md\n */\nexport function cookies(): RequestCookies {\n const store = requestContextAls.getStore();\n if (!store) {\n throw new Error(\n '[timber] cookies() called outside of a request context. ' +\n 'It can only be used in middleware, access checks, server components, and server actions.'\n );\n }\n\n // Parse cookies lazily on first access\n if (!store.parsedCookies) {\n store.parsedCookies = parseCookieHeader(store.cookieHeader);\n }\n\n const map = store.parsedCookies;\n return {\n get(name: string): string | undefined {\n return map.get(name);\n },\n has(name: string): boolean {\n return map.has(name);\n },\n getAll(): Array<{ name: string; value: string }> {\n return Array.from(map.entries()).map(([name, value]) => ({ name, value }));\n },\n get size(): number {\n return map.size;\n },\n\n set(name: string, value: string, options?: CookieOptions): void {\n assertMutable(store, 'set');\n if (store.flushed) {\n if (isDebug()) {\n console.warn(\n `[timber] warn: cookies().set('${name}') called after response headers were committed.\\n` +\n ` The cookie will NOT be sent. Move cookie mutations to middleware.ts, a server action,\\n` +\n ` or a route.ts handler.`\n );\n }\n return;\n }\n const opts = { ...DEFAULT_COOKIE_OPTIONS, ...options };\n store.cookieJar.set(name, { name, value, options: opts });\n // Read-your-own-writes: update the parsed cookies map\n map.set(name, value);\n },\n\n delete(name: string, options?: Pick<CookieOptions, 'path' | 'domain'>): void {\n assertMutable(store, 'delete');\n if (store.flushed) {\n if (isDebug()) {\n console.warn(\n `[timber] warn: cookies().delete('${name}') called after response headers were committed.\\n` +\n ` The cookie will NOT be deleted. Move cookie mutations to middleware.ts, a server action,\\n` +\n ` or a route.ts handler.`\n );\n }\n return;\n }\n const opts: CookieOptions = {\n ...DEFAULT_COOKIE_OPTIONS,\n ...options,\n maxAge: 0,\n expires: new Date(0),\n };\n store.cookieJar.set(name, { name, value: '', options: opts });\n // Remove from read view\n map.delete(name);\n },\n\n clear(): void {\n assertMutable(store, 'clear');\n if (store.flushed) return;\n // Delete every incoming cookie\n for (const name of Array.from(map.keys())) {\n store.cookieJar.set(name, {\n name,\n value: '',\n options: { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0, expires: new Date(0) },\n });\n }\n map.clear();\n },\n\n toString(): string {\n return Array.from(map.entries())\n .map(([name, value]) => `${name}=${value}`)\n .join('; ');\n },\n };\n}\n\n/**\n * Returns a Promise resolving to the current request's raw URLSearchParams.\n *\n * For typed, parsed search params, import the definition from params.ts\n * and call `.load()` or `.parse()`:\n *\n * ```ts\n * import { searchParams } from './params'\n * const parsed = await searchParams.load()\n * ```\n *\n * Or explicitly:\n *\n * ```ts\n * import { rawSearchParams } from '@timber-js/app/server'\n * import { searchParams } from './params'\n * const parsed = searchParams.parse(await rawSearchParams())\n * ```\n *\n * Throws if called outside a request context.\n */\nexport function rawSearchParams(): Promise<URLSearchParams> {\n const store = requestContextAls.getStore();\n if (!store) {\n throw new Error(\n '[timber] rawSearchParams() called outside of a request context. ' +\n 'It can only be used in middleware, access checks, server components, and server actions.'\n );\n }\n return store.searchParamsPromise;\n}\n\n// ─── Types ────────────────────────────────────────────────────────────────\n\n/**\n * Read-only Headers interface. The standard Headers class is mutable;\n * this type narrows it to read-only methods. The underlying object is\n * still a Headers instance, but user code should not mutate it.\n */\nexport type ReadonlyHeaders = Pick<\n Headers,\n 'get' | 'has' | 'entries' | 'keys' | 'values' | 'forEach' | typeof Symbol.iterator\n>;\n\n/** Options for setting a cookie. See design/29-cookies.md. */\nexport interface CookieOptions {\n /** Domain scope. Default: omitted (current domain only). */\n domain?: string;\n /** URL path scope. Default: '/'. */\n path?: string;\n /** Expiration date. Mutually exclusive with maxAge. */\n expires?: Date;\n /** Max age in seconds. Mutually exclusive with expires. */\n maxAge?: number;\n /** Prevent client-side JS access. Default: true. */\n httpOnly?: boolean;\n /** Only send over HTTPS. Default: true. */\n secure?: boolean;\n /** Cross-site request policy. Default: 'lax'. */\n sameSite?: 'strict' | 'lax' | 'none';\n /** Partitioned (CHIPS) — isolate cookie per top-level site. Default: false. */\n partitioned?: boolean;\n}\n\nconst DEFAULT_COOKIE_OPTIONS: CookieOptions = {\n path: '/',\n httpOnly: true,\n secure: true,\n sameSite: 'lax',\n};\n\n/**\n * Cookie accessor returned by `cookies()`.\n *\n * Read methods are always available. Mutation methods throw in read-only\n * contexts (access.ts, server components).\n */\nexport interface RequestCookies {\n /** Get a cookie value by name. Returns undefined if not present. */\n get(name: string): string | undefined;\n /** Check if a cookie exists. */\n has(name: string): boolean;\n /** Get all cookies as an array of { name, value } pairs. */\n getAll(): Array<{ name: string; value: string }>;\n /** Number of cookies. */\n readonly size: number;\n /** Set a cookie. Only available in mutable contexts (middleware, actions, route handlers). */\n set(name: string, value: string, options?: CookieOptions): void;\n /** Delete a cookie. Only available in mutable contexts. */\n delete(name: string, options?: Pick<CookieOptions, 'path' | 'domain'>): void;\n /** Delete all cookies. Only available in mutable contexts. */\n clear(): void;\n /** Serialize cookies as a Cookie header string. */\n toString(): string;\n}\n\n// ─── Framework-Internal Helpers ───────────────────────────────────────────\n\n/**\n * Run a callback within a request context. Used by the pipeline to establish\n * per-request ALS scope so that `headers()` and `cookies()` work.\n *\n * @param req - The incoming Request object.\n * @param fn - The function to run within the request context.\n */\nexport function runWithRequestContext<T>(req: Request, fn: () => T): T {\n const originalCopy = new Headers(req.headers);\n const store: RequestContextStore = {\n headers: freezeHeaders(req.headers),\n originalHeaders: originalCopy,\n cookieHeader: req.headers.get('cookie') ?? '',\n searchParamsPromise: Promise.resolve(new URL(req.url).searchParams),\n cookieJar: new Map(),\n flushed: false,\n mutableContext: false,\n };\n return requestContextAls.run(store, fn);\n}\n\n/**\n * Enable cookie mutation for the current context. Called by the framework\n * when entering middleware.ts, server actions, or route.ts handlers.\n *\n * See design/29-cookies.md §\"Context Tracking\"\n */\nexport function setMutableCookieContext(mutable: boolean): void {\n const store = requestContextAls.getStore();\n if (store) {\n store.mutableContext = mutable;\n }\n}\n\n/**\n * Mark the response as flushed (headers committed). After this point,\n * cookie mutations log a warning instead of throwing.\n *\n * See design/29-cookies.md §\"Streaming Constraint: Post-Flush Cookie Warning\"\n */\nexport function markResponseFlushed(): void {\n const store = requestContextAls.getStore();\n if (store) {\n store.flushed = true;\n }\n}\n\n/**\n * Build a Map of cookie name → value reflecting the current request's\n * read-your-own-writes state. Includes incoming cookies plus any\n * mutations from cookies().set() / cookies().delete() in the same request.\n *\n * Used by SSR renderers to populate NavContext.cookies so that\n * useCookie()'s server snapshot matches the actual response state.\n *\n * See design/29-cookies.md §\"Read-Your-Own-Writes\"\n * See design/triage/TIM-441-cookie-api-triage.md §4\n */\nexport function getCookiesForSsr(): Map<string, string> {\n const store = requestContextAls.getStore();\n if (!store) {\n throw new Error('[timber] getCookiesForSsr() called outside of a request context.');\n }\n\n // Trigger lazy parsing if not yet done\n if (!store.parsedCookies) {\n store.parsedCookies = parseCookieHeader(store.cookieHeader);\n }\n\n // The parsedCookies map already reflects read-your-own-writes:\n // - cookies().set() updates the map via map.set(name, value)\n // - cookies().delete() removes from the map via map.delete(name)\n // Return a copy so callers can't mutate the internal map.\n return new Map(store.parsedCookies);\n}\n\n/**\n * Collect all Set-Cookie headers from the cookie jar.\n * Called by the framework at flush time to apply cookies to the response.\n *\n * Returns an array of serialized Set-Cookie header values.\n */\nexport function getSetCookieHeaders(): string[] {\n const store = requestContextAls.getStore();\n if (!store) return [];\n return Array.from(store.cookieJar.values()).map(serializeCookieEntry);\n}\n\n/**\n * Apply middleware-injected request headers to the current request context.\n *\n * Called by the pipeline after middleware.ts runs. Merges overlay headers\n * on top of the original request headers so downstream code (access.ts,\n * server components, server actions) sees them via `headers()`.\n *\n * The original request headers are never mutated — a new frozen Headers\n * object is created with the overlay applied on top.\n *\n * See design/07-routing.md §\"Request Header Injection\"\n */\nexport function applyRequestHeaderOverlay(overlay: Headers): void {\n const store = requestContextAls.getStore();\n if (!store) {\n throw new Error('[timber] applyRequestHeaderOverlay() called outside of a request context.');\n }\n\n // Check if the overlay has any headers — skip if empty\n let hasOverlay = false;\n overlay.forEach(() => {\n hasOverlay = true;\n });\n if (!hasOverlay) return;\n\n // Merge: start with original headers, overlay on top\n const merged = new Headers(store.originalHeaders);\n overlay.forEach((value, key) => {\n merged.set(key, value);\n });\n store.headers = freezeHeaders(merged);\n}\n\n// ─── Read-Only Headers ────────────────────────────────────────────────────\n\nconst MUTATING_METHODS = new Set(['set', 'append', 'delete']);\n\n/**\n * Wrap a Headers object in a Proxy that throws on mutating methods.\n * Object.freeze doesn't work on Headers (native internal slots), so we\n * intercept property access and reject set/append/delete at runtime.\n *\n * Read methods (get, has, entries, etc.) must be bound to the underlying\n * Headers instance because they access private #headersList slots.\n */\nfunction freezeHeaders(source: Headers): Headers {\n const copy = new Headers(source);\n return new Proxy(copy, {\n get(target, prop) {\n if (typeof prop === 'string' && MUTATING_METHODS.has(prop)) {\n return () => {\n throw new Error(\n `[timber] headers() returns a read-only Headers object. ` +\n `Calling .${prop}() is not allowed. ` +\n `Use ctx.requestHeaders in middleware to inject headers for downstream components.`\n );\n };\n }\n const value = Reflect.get(target, prop);\n // Bind methods to the real Headers instance so private slot access works\n if (typeof value === 'function') {\n return value.bind(target);\n }\n return value;\n },\n });\n}\n\n// ─── Cookie Helpers ───────────────────────────────────────────────────────\n\n/** Throw if cookie mutation is attempted in a read-only context. */\nfunction assertMutable(store: RequestContextStore, method: string): void {\n if (!store.mutableContext) {\n throw new Error(\n `[timber] cookies().${method}() cannot be called in this context.\\n` +\n ` Set cookies in middleware.ts, server actions, or route.ts handlers.`\n );\n }\n}\n\n/**\n * Parse a Cookie header string into a Map of name → value pairs.\n * Follows RFC 6265 §4.2.1: cookies are semicolon-separated key=value pairs.\n */\nfunction parseCookieHeader(header: string): Map<string, string> {\n const map = new Map<string, string>();\n if (!header) return map;\n\n for (const pair of header.split(';')) {\n const eqIndex = pair.indexOf('=');\n if (eqIndex === -1) continue;\n const name = pair.slice(0, eqIndex).trim();\n const value = pair.slice(eqIndex + 1).trim();\n if (name) {\n map.set(name, value);\n }\n }\n\n return map;\n}\n\n/** Serialize a CookieEntry into a Set-Cookie header value. */\nfunction serializeCookieEntry(entry: CookieEntry): string {\n const parts = [`${entry.name}=${entry.value}`];\n const opts = entry.options;\n\n if (opts.domain) parts.push(`Domain=${opts.domain}`);\n if (opts.path) parts.push(`Path=${opts.path}`);\n if (opts.expires) parts.push(`Expires=${opts.expires.toUTCString()}`);\n if (opts.maxAge !== undefined) parts.push(`Max-Age=${opts.maxAge}`);\n if (opts.httpOnly) parts.push('HttpOnly');\n if (opts.secure) parts.push('Secure');\n if (opts.sameSite) {\n parts.push(`SameSite=${opts.sameSite.charAt(0).toUpperCase()}${opts.sameSite.slice(1)}`);\n }\n if (opts.partitioned) parts.push('Partitioned');\n\n return parts.join('; ');\n}\n"],"mappings":"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"}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { createContext, createElement, useContext, useMemo } from "react";
|
|
2
|
-
//#region src/client/segment-context.ts
|
|
3
|
-
/**
|
|
4
|
-
* Segment Context — provides layout segment position for useSelectedLayoutSegment hooks.
|
|
5
|
-
*
|
|
6
|
-
* Each layout in the segment tree is wrapped with a SegmentProvider that stores
|
|
7
|
-
* the URL segments from root to the current layout level. The hooks read this
|
|
8
|
-
* context to determine which child segments are active below the calling layout.
|
|
9
|
-
*
|
|
10
|
-
* The context value is intentionally minimal: just the segment path array and
|
|
11
|
-
* parallel route keys. No internal cache details are exposed.
|
|
12
|
-
*
|
|
13
|
-
* Design docs: design/19-client-navigation.md, design/14-ecosystem.md
|
|
14
|
-
*/
|
|
15
|
-
var SegmentContext = createContext(null);
|
|
16
|
-
/** Read the segment context. Returns null if no provider is above this component. */
|
|
17
|
-
function useSegmentContext() {
|
|
18
|
-
return useContext(SegmentContext);
|
|
19
|
-
}
|
|
20
|
-
/**
|
|
21
|
-
* Wraps each layout to provide segment position context.
|
|
22
|
-
* Injected by rsc-entry.ts during element tree construction.
|
|
23
|
-
*/
|
|
24
|
-
function SegmentProvider({ segments, segmentId: _segmentId, parallelRouteKeys, children }) {
|
|
25
|
-
const value = useMemo(() => ({
|
|
26
|
-
segments,
|
|
27
|
-
parallelRouteKeys
|
|
28
|
-
}), [segments.join("/"), parallelRouteKeys.join(",")]);
|
|
29
|
-
return createElement(SegmentContext.Provider, { value }, children);
|
|
30
|
-
}
|
|
31
|
-
//#endregion
|
|
32
|
-
export { useSegmentContext as n, SegmentProvider as t };
|
|
33
|
-
|
|
34
|
-
//# sourceMappingURL=segment-context-Dpq2XOKg.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"segment-context-Dpq2XOKg.js","names":[],"sources":["../../src/client/segment-context.ts"],"sourcesContent":["/**\n * Segment Context — provides layout segment position for useSelectedLayoutSegment hooks.\n *\n * Each layout in the segment tree is wrapped with a SegmentProvider that stores\n * the URL segments from root to the current layout level. The hooks read this\n * context to determine which child segments are active below the calling layout.\n *\n * The context value is intentionally minimal: just the segment path array and\n * parallel route keys. No internal cache details are exposed.\n *\n * Design docs: design/19-client-navigation.md, design/14-ecosystem.md\n */\n\n'use client';\n\nimport { createContext, useContext, createElement, useMemo } from 'react';\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport interface SegmentContextValue {\n /** URL segments from root to this layout (e.g. ['', 'dashboard', 'settings']) */\n segments: string[];\n /** Parallel route slot keys available at this layout level (e.g. ['sidebar', 'modal']) */\n parallelRouteKeys: string[];\n}\n\n// ─── Context ─────────────────────────────────────────────────────\n\nconst SegmentContext = createContext<SegmentContextValue | null>(null);\n\n/** Read the segment context. Returns null if no provider is above this component. */\nexport function useSegmentContext(): SegmentContextValue | null {\n return useContext(SegmentContext);\n}\n\n// ─── Provider ────────────────────────────────────────────────────\n\ninterface SegmentProviderProps {\n segments: string[];\n /**\n * Unique identifier for this segment, used by the client-side segment\n * merger for element caching. For route groups this includes the group\n * name (e.g., \"/(marketing)\") since groups share their parent's urlPath.\n * Falls back to the reconstructed path from `segments` if not provided.\n */\n segmentId?: string;\n parallelRouteKeys: string[];\n children: React.ReactNode;\n}\n\n/**\n * Wraps each layout to provide segment position context.\n * Injected by rsc-entry.ts during element tree construction.\n */\nexport function SegmentProvider({\n segments,\n segmentId: _segmentId,\n parallelRouteKeys,\n children,\n}: SegmentProviderProps) {\n const value = useMemo(\n () => ({ segments, parallelRouteKeys }),\n // segments and parallelRouteKeys are static per layout — they don't change\n // across navigations. The layout's position in the tree is fixed.\n // Intentionally using derived keys — segments/parallelRouteKeys are static per layout\n [segments.join('/'), parallelRouteKeys.join(',')]\n );\n return createElement(SegmentContext.Provider, { value }, children);\n}\n"],"mappings":";;;;;;;;;;;;;;AA4BA,IAAM,iBAAiB,cAA0C,KAAK;;AAGtE,SAAgB,oBAAgD;AAC9D,QAAO,WAAW,eAAe;;;;;;AAsBnC,SAAgB,gBAAgB,EAC9B,UACA,WAAW,YACX,mBACA,YACuB;CACvB,MAAM,QAAQ,eACL;EAAE;EAAU;EAAmB,GAItC,CAAC,SAAS,KAAK,IAAI,EAAE,kBAAkB,KAAK,IAAI,CAAC,CAClD;AACD,QAAO,cAAc,eAAe,UAAU,EAAE,OAAO,EAAE,SAAS"}
|