@timber-js/app 0.2.0-alpha.166 → 0.2.0-alpha.168
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks/{actions-CDPfMp_I.js → actions-O_LsyCE4.js} +3 -3
- package/dist/_chunks/{actions-CDPfMp_I.js.map → actions-O_LsyCE4.js.map} +1 -1
- package/dist/_chunks/{cache-api-DygSeKCB.js → cache-api-B-lhk9p4.js} +107 -58
- package/dist/_chunks/cache-api-B-lhk9p4.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-EXGYPhI2.js → cli-schema-sync-B73L6pMq.js} +5 -3
- package/dist/_chunks/cli-schema-sync-B73L6pMq.js.map +1 -0
- package/dist/_chunks/cloudflare-AHoWYTYr.js +1188 -0
- package/dist/_chunks/cloudflare-AHoWYTYr.js.map +1 -0
- package/dist/_chunks/json-lossy-check-ClNvBM_3.js +63 -0
- package/dist/_chunks/json-lossy-check-ClNvBM_3.js.map +1 -0
- package/dist/_chunks/{logger-t3uxAmbX.js → logger-AWfuX-KJ.js} +2 -19
- package/dist/_chunks/logger-AWfuX-KJ.js.map +1 -0
- package/dist/_chunks/{walkers-DBVzXuWc.js → walkers-CoOC8Hga.js} +2 -2
- package/dist/_chunks/{walkers-DBVzXuWc.js.map → walkers-CoOC8Hga.js.map} +1 -1
- package/dist/adapters/cloudflare-dev.js +1 -1
- package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +10 -3
- package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
- package/dist/adapters/cloudflare.d.ts +12 -1
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +2 -461
- package/dist/adapters/types.d.ts +2 -0
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/cache/cache-api.d.ts +33 -11
- package/dist/cache/cache-api.d.ts.map +1 -1
- package/dist/cache/index.d.ts +1 -1
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cache/json-lossy-check.d.ts +11 -0
- package/dist/cache/json-lossy-check.d.ts.map +1 -0
- package/dist/cache/redis-handler.d.ts +42 -0
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/singleflight.d.ts.map +1 -1
- package/dist/cache/stores/cloudflare-kv.d.ts +1 -1
- package/dist/cache/stores/cloudflare-kv.d.ts.map +1 -1
- package/dist/cache/stores/memory.d.ts +1 -1
- package/dist/cache/stores/memory.d.ts.map +1 -1
- package/dist/cache/stores/redis.d.ts +1 -1
- package/dist/cache/stores/redis.d.ts.map +1 -1
- package/dist/cache/stores/vercel.d.ts +1 -1
- package/dist/cache/stores/vercel.d.ts.map +1 -1
- package/dist/cache/tag-aware-handler.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/cdn/cloudflare-purge.d.ts.map +1 -1
- package/dist/cdn/fastly-purge.d.ts.map +1 -1
- package/dist/cdn/workers-cache-purge.d.ts.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -2
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts +1 -0
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/child-segment-context.d.ts +2 -2
- package/dist/client/child-segment-context.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/history.d.ts +10 -0
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/internal.js +141 -116
- package/dist/client/internal.js.map +1 -1
- package/dist/client/router.d.ts +6 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/segment-update-context.d.ts +3 -3
- package/dist/client/segment-update-context.d.ts.map +1 -1
- package/dist/client/slot-outlet.d.ts +1 -1
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +2 -1
- package/dist/codec.js.map +1 -1
- package/dist/config-types.d.ts +16 -37
- package/dist/config-types.d.ts.map +1 -1
- package/dist/config-validation.d.ts.map +1 -1
- package/dist/dev-tools/instrumentation.d.ts.map +1 -1
- package/dist/fonts/pipeline.d.ts +19 -0
- package/dist/fonts/pipeline.d.ts.map +1 -1
- package/dist/fonts/transform.d.ts.map +1 -1
- package/dist/fonts/virtual-modules.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +378 -109
- package/dist/index.js.map +1 -1
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/adapter-build.d.ts +1 -0
- package/dist/plugins/adapter-build.d.ts.map +1 -1
- package/dist/plugins/cache.d.ts +8 -8
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts +27 -3
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/server/action-client.d.ts +1 -1
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +7 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/body-limits.d.ts.map +1 -1
- package/dist/server/deny-boundary.d.ts +3 -1
- package/dist/server/deny-boundary.d.ts.map +1 -1
- package/dist/server/form-data.d.ts.map +1 -1
- package/dist/server/html-injector-core.d.ts.map +1 -1
- package/dist/server/index.js +20 -3
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +28 -41
- package/dist/server/internal.js.map +1 -1
- package/dist/server/middleware-runner.d.ts +0 -17
- package/dist/server/middleware-runner.d.ts.map +1 -1
- package/dist/server/param-coercion.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts +6 -3
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +18 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/prebuilt/capture-state.d.ts.map +1 -1
- package/dist/server/prebuilt/synthetic-store.d.ts.map +1 -1
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/render-timeout.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts +9 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/action-middleware-runner.d.ts +14 -14
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts +1 -0
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +8 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +44 -74
- package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -1
- package/dist/server/safe-load.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/shared/redirect-type.d.ts +2 -2
- package/dist/shared/redirect-type.d.ts.map +1 -1
- package/dist/shims/font-google.d.ts.map +1 -1
- package/dist/shims/image.d.ts +120 -120
- package/dist/shims/image.d.ts.map +1 -1
- package/docs/api/32-api-cache.mdx +116 -4
- package/docs/api/34-api-config.mdx +0 -26
- package/docs/learn/09-caching.mdx +126 -10
- package/docs/learn/12-client-navigation.mdx +9 -1
- package/docs/learn/13-configuration.mdx +6 -8
- package/docs/learn/14-deploying.mdx +18 -18
- package/docs/more/03-coming-from-nextjs.mdx +2 -2
- package/docs/more/04-metadata-and-fonts.mdx +1 -1
- package/docs/more/50-ai-agent-instructions.mdx +5 -5
- package/package.json +6 -5
- package/src/adapters/cloudflare-kv-cache.ts +27 -6
- package/src/adapters/cloudflare.ts +63 -25
- package/src/adapters/types.ts +2 -0
- package/src/cache/cache-api.ts +84 -84
- package/src/cache/index.ts +1 -1
- package/src/cache/json-lossy-check.ts +75 -0
- package/src/cache/redis-handler.ts +65 -14
- package/src/cache/timber-cache.ts +10 -2
- package/src/client/browser-entry/index.ts +2 -0
- package/src/client/browser-entry/post-hydration.ts +16 -9
- package/src/client/browser-entry/router-init.ts +2 -0
- package/src/client/history.ts +11 -0
- package/src/client/router.ts +224 -208
- package/src/codec.ts +3 -1
- package/src/config-types.ts +16 -37
- package/src/config-validation.ts +7 -5
- package/src/fonts/pipeline.ts +32 -0
- package/src/fonts/transform.ts +30 -24
- package/src/plugins/adapter-build.ts +132 -13
- package/src/plugins/cache.ts +45 -30
- package/src/plugins/entries.ts +40 -43
- package/src/plugins/fonts.ts +30 -0
- package/src/plugins/server-bundle.ts +7 -15
- package/src/routing/scanner.ts +7 -0
- package/src/server/action-client.ts +1 -1
- package/src/server/als-registry.ts +7 -0
- package/src/server/deny-boundary.ts +6 -3
- package/src/server/middleware-runner.ts +0 -44
- package/src/server/param-coercion.ts +1 -0
- package/src/server/pipeline-phases.ts +11 -13
- package/src/server/pipeline.ts +51 -3
- package/src/server/route-element-builder.ts +59 -9
- package/src/server/rsc-entry/action-middleware-runner.ts +14 -14
- package/src/server/rsc-entry/index.ts +30 -40
- package/src/server/rsc-entry/render-route.ts +6 -2
- package/src/server/rsc-entry/rsc-payload.ts +21 -4
- package/src/server/rsc-entry/rsc-stream.ts +8 -0
- package/src/server/rsc-entry/wrap-action-dispatch.ts +109 -372
- package/dist/_chunks/cache-api-DygSeKCB.js.map +0 -1
- package/dist/_chunks/cli-schema-sync-EXGYPhI2.js.map +0 -1
- package/dist/_chunks/logger-t3uxAmbX.js.map +0 -1
- package/dist/_chunks/tree-match-D2l830j2.js +0 -102
- package/dist/_chunks/tree-match-D2l830j2.js.map +0 -1
- package/dist/adapters/cloudflare.js.map +0 -1
package/src/client/router.ts
CHANGED
|
@@ -155,6 +155,13 @@ export interface RouterDeps {
|
|
|
155
155
|
* as a full page load with an unknown fragment landing at the top.
|
|
156
156
|
*/
|
|
157
157
|
scrollToHash?: (hash: string) => boolean;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Whether the client segment cache is enabled. When false (the default),
|
|
161
|
+
* the router does not send X-Timber-State-Tree headers and does not
|
|
162
|
+
* populate the segment cache. Every navigation gets a full RSC payload.
|
|
163
|
+
*/
|
|
164
|
+
clientSegmentCache?: boolean;
|
|
158
165
|
}
|
|
159
166
|
|
|
160
167
|
export interface RouterInstance {
|
|
@@ -292,6 +299,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
292
299
|
|
|
293
300
|
/** Update the segment cache from server-provided segment metadata. */
|
|
294
301
|
function updateSegmentCache(segmentInfo: SegmentInfo[] | null | undefined): void {
|
|
302
|
+
if (!deps.clientSegmentCache) return;
|
|
295
303
|
if (!segmentInfo || segmentInfo.length === 0) return;
|
|
296
304
|
const tree = buildSegmentTree(segmentInfo);
|
|
297
305
|
if (tree) {
|
|
@@ -306,6 +314,89 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
306
314
|
}
|
|
307
315
|
}
|
|
308
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Atomically update all navigation-owned state for a new page. Every
|
|
319
|
+
* code path that changes the "current page" must go through this
|
|
320
|
+
* function — making "forgot a field" impossible by construction.
|
|
321
|
+
*
|
|
322
|
+
* The three operations:
|
|
323
|
+
* 1. Segment cache — update from server-provided segment metadata
|
|
324
|
+
* 2. Navigation state — params + pathname for useSegmentParams/usePathname
|
|
325
|
+
* 3. History stack — store the payload for instant back/forward replay
|
|
326
|
+
*/
|
|
327
|
+
function commitNavigation(
|
|
328
|
+
url: string,
|
|
329
|
+
opts: {
|
|
330
|
+
payload: unknown;
|
|
331
|
+
params?: Record<string, string | string[]> | null;
|
|
332
|
+
segmentInfo?: SegmentInfo[] | null;
|
|
333
|
+
/** When true, clear the segment cache if segmentInfo is empty
|
|
334
|
+
* (popstate replay for entries without layout metadata). */
|
|
335
|
+
clearSegmentCacheOnEmpty?: boolean;
|
|
336
|
+
}
|
|
337
|
+
): NavigationState {
|
|
338
|
+
if (opts.segmentInfo && opts.segmentInfo.length > 0) {
|
|
339
|
+
updateSegmentCache(opts.segmentInfo);
|
|
340
|
+
} else if (opts.clearSegmentCacheOnEmpty) {
|
|
341
|
+
segmentCache.clear();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const navState = updateNavigationState(opts.params, url);
|
|
345
|
+
|
|
346
|
+
historyStack.push(url, {
|
|
347
|
+
payload: opts.payload,
|
|
348
|
+
params: navState.params,
|
|
349
|
+
segmentInfo: opts.segmentInfo,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
return navState;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Wrap a navigation in the standard abort/pending/cleanup lifecycle.
|
|
357
|
+
* Consolidates the createNavAbort + setPending + staleness-guarded
|
|
358
|
+
* finally that was duplicated across navigate, refresh, and both
|
|
359
|
+
* handlePopState paths. AbortErrors are swallowed (not application
|
|
360
|
+
* errors); all other errors propagate to the caller.
|
|
361
|
+
*/
|
|
362
|
+
async function runNavigation(
|
|
363
|
+
url: string,
|
|
364
|
+
fn: (navAbort: AbortController) => Promise<void>,
|
|
365
|
+
externalSignal?: AbortSignal
|
|
366
|
+
): Promise<void> {
|
|
367
|
+
const navAbort = createNavAbort(externalSignal);
|
|
368
|
+
setPending(true, url);
|
|
369
|
+
try {
|
|
370
|
+
await fn(navAbort);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (isAbortError(error)) return;
|
|
373
|
+
throw error;
|
|
374
|
+
} finally {
|
|
375
|
+
if (currentNavAbort === navAbort) {
|
|
376
|
+
currentNavAbort = null;
|
|
377
|
+
setPending(false);
|
|
378
|
+
deps.completeRouterNavigation?.();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Resolve thenable payloads in the test/fallback path (no navigateTransition).
|
|
385
|
+
* In production, React handles thenables from createFromFetch directly via
|
|
386
|
+
* Suspense. In tests, renderRoot is a plain mock that expects resolved values.
|
|
387
|
+
*/
|
|
388
|
+
async function resolveForFallback(payload: unknown): Promise<unknown> {
|
|
389
|
+
if (
|
|
390
|
+
!deps.navigateTransition &&
|
|
391
|
+
payload != null &&
|
|
392
|
+
typeof payload === 'object' &&
|
|
393
|
+
'then' in payload
|
|
394
|
+
) {
|
|
395
|
+
return await (payload as PromiseLike<unknown>);
|
|
396
|
+
}
|
|
397
|
+
return payload;
|
|
398
|
+
}
|
|
399
|
+
|
|
309
400
|
function isPartialNavigation(skippedSegments: string[] | null | undefined): boolean {
|
|
310
401
|
return skippedSegments != null && skippedSegments.length > 0;
|
|
311
402
|
}
|
|
@@ -357,9 +448,11 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
357
448
|
|
|
358
449
|
/**
|
|
359
450
|
* Render a payload via navigateTransition (production) or renderRoot (tests).
|
|
360
|
-
* The perform callback should fetch data,
|
|
361
|
-
* FetchResult plus the NavigationState
|
|
362
|
-
*
|
|
451
|
+
* The perform callback should fetch data, call commitNavigation, and return
|
|
452
|
+
* the FetchResult plus the NavigationState.
|
|
453
|
+
*
|
|
454
|
+
* State management (segmentCache, navState, historyStack) is handled by
|
|
455
|
+
* commitNavigation inside perform — this function only handles rendering.
|
|
363
456
|
*/
|
|
364
457
|
async function renderViaTransition(
|
|
365
458
|
url: string,
|
|
@@ -372,11 +465,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
372
465
|
if (isPartialNavigation(result.skippedSegments)) {
|
|
373
466
|
const segmentUpdates = buildSegmentUpdates(result);
|
|
374
467
|
|
|
375
|
-
historyStack.push(url, {
|
|
376
|
-
payload: null,
|
|
377
|
-
params: result.params,
|
|
378
|
-
});
|
|
379
|
-
|
|
380
468
|
// Re-wrap the CURRENT element with new context values.
|
|
381
469
|
// SegmentOutlets read updates from SegmentUpdateContext;
|
|
382
470
|
// NavigationProvider gets new params/pathname.
|
|
@@ -389,10 +477,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
389
477
|
}
|
|
390
478
|
|
|
391
479
|
// Full navigation — empty updates, render the new tree.
|
|
392
|
-
historyStack.push(url, {
|
|
393
|
-
payload: result.payload,
|
|
394
|
-
params: result.params,
|
|
395
|
-
});
|
|
396
480
|
const element = wrapPayload(result.payload, result.navState);
|
|
397
481
|
return { element, decodePromise: result.decodePromise };
|
|
398
482
|
});
|
|
@@ -400,24 +484,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
400
484
|
}
|
|
401
485
|
// Fallback: no transition (tests, no React tree)
|
|
402
486
|
const result = await perform();
|
|
403
|
-
if (isPartialNavigation(result.skippedSegments)) {
|
|
404
|
-
|
|
405
|
-
payload: null,
|
|
406
|
-
params: result.params,
|
|
407
|
-
});
|
|
408
|
-
} else {
|
|
409
|
-
// Resolve thenables before storing/rendering in the fallback path
|
|
410
|
-
// (tests without navigateTransition). In production, React handles
|
|
411
|
-
// thenables via Suspense; in tests, renderRoot expects resolved values.
|
|
412
|
-
const payload =
|
|
413
|
-
result.payload != null && typeof result.payload === 'object' && 'then' in result.payload
|
|
414
|
-
? await (result.payload as PromiseLike<unknown>)
|
|
415
|
-
: result.payload;
|
|
416
|
-
historyStack.push(url, {
|
|
417
|
-
payload,
|
|
418
|
-
params: result.params,
|
|
419
|
-
});
|
|
420
|
-
renderPayload(payload, result.navState);
|
|
487
|
+
if (!isPartialNavigation(result.skippedSegments)) {
|
|
488
|
+
renderPayload(result.payload, result.navState);
|
|
421
489
|
}
|
|
422
490
|
}
|
|
423
491
|
|
|
@@ -480,7 +548,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
480
548
|
if (result === undefined) {
|
|
481
549
|
// Fetch RSC payload with state tree for partial rendering.
|
|
482
550
|
// Send current URL for intercepting route resolution (modal pattern).
|
|
483
|
-
const stateTree = segmentCache.serializeStateTree();
|
|
551
|
+
const stateTree = deps.clientSegmentCache ? segmentCache.serializeStateTree() : undefined;
|
|
484
552
|
const rawCurrentUrl = deps.getCurrentUrl();
|
|
485
553
|
const currentUrl = rawCurrentUrl.startsWith('http')
|
|
486
554
|
? new URL(rawCurrentUrl).pathname
|
|
@@ -505,18 +573,21 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
505
573
|
deps.setRouterNavigating?.(false);
|
|
506
574
|
}
|
|
507
575
|
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
//
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
576
|
+
// Resolve thenable payloads in the test path so popstate replay
|
|
577
|
+
// and renderPayload receive plain values (see resolveForFallback).
|
|
578
|
+
const payload = await resolveForFallback(result.payload);
|
|
579
|
+
|
|
580
|
+
// Atomically update all navigation state via commitNavigation.
|
|
581
|
+
// Partial navigations store null payload — the partial RSC tree
|
|
582
|
+
// can't be replayed standalone; popstate will fetch fresh.
|
|
583
|
+
const isPartial = isPartialNavigation(result.skippedSegments);
|
|
584
|
+
const navState = commitNavigation(url, {
|
|
585
|
+
payload: isPartial ? null : payload,
|
|
586
|
+
params: result.params,
|
|
587
|
+
segmentInfo: result.segmentInfo,
|
|
588
|
+
});
|
|
518
589
|
|
|
519
|
-
return { ...result, navState };
|
|
590
|
+
return { ...result, payload, navState };
|
|
520
591
|
}
|
|
521
592
|
|
|
522
593
|
async function navigate(url: string, options: NavigationOptions = {}): Promise<void> {
|
|
@@ -534,10 +605,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
534
605
|
const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
|
|
535
606
|
const fetchUrl = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
|
536
607
|
|
|
537
|
-
// Create an abort controller for this navigation. Links to the external
|
|
538
|
-
// signal (Navigation API's event.signal) when provided.
|
|
539
|
-
const navAbort = createNavAbort(externalSignal);
|
|
540
|
-
|
|
541
608
|
// Capture the departing page's scroll position for scroll={false} preservation.
|
|
542
609
|
const currentScrollY = deps.getScrollY();
|
|
543
610
|
|
|
@@ -549,114 +616,69 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
549
616
|
deps.replaceState({ timber: true, scrollY: currentScrollY }, '', deps.getCurrentUrl());
|
|
550
617
|
}
|
|
551
618
|
|
|
552
|
-
// When Navigation API is active, initiate the navigation via
|
|
553
|
-
// navigation.navigate() BEFORE the fetch. Unlike history.pushState()
|
|
554
|
-
// which commits the URL synchronously (so Chrome sees it as "done"),
|
|
555
|
-
// navigation.navigate() fires the navigate event before committing.
|
|
556
|
-
// Our handler intercepts with a deferred promise, and Chrome shows
|
|
557
|
-
// its native loading indicator until completeRouterNavigation()
|
|
558
|
-
// resolves it in the finally block (same time as TopLoader clears).
|
|
559
619
|
let effectiveSkipHistory = skipHistory;
|
|
560
|
-
if (!skipHistory && deps.navigationNavigate) {
|
|
561
|
-
deps.setRouterNavigating?.(true);
|
|
562
|
-
deps.navigationNavigate(url, replace);
|
|
563
|
-
deps.setRouterNavigating?.(false);
|
|
564
|
-
effectiveSkipHistory = true;
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
setPending(true, url);
|
|
568
620
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
621
|
+
await runNavigation(
|
|
622
|
+
url,
|
|
623
|
+
async (navAbort) => {
|
|
624
|
+
// When Navigation API is active, initiate the navigation via
|
|
625
|
+
// navigation.navigate() BEFORE the fetch. Must happen after
|
|
626
|
+
// createNavAbort supersedes the previous navigation (done by
|
|
627
|
+
// runNavigation) so the old deferred is resolved first.
|
|
628
|
+
if (!effectiveSkipHistory && deps.navigationNavigate) {
|
|
629
|
+
deps.setRouterNavigating?.(true);
|
|
630
|
+
deps.navigationNavigate(url, replace);
|
|
631
|
+
deps.setRouterNavigating?.(false);
|
|
632
|
+
effectiveSkipHistory = true;
|
|
633
|
+
}
|
|
578
634
|
|
|
579
|
-
|
|
580
|
-
|
|
635
|
+
try {
|
|
636
|
+
await renderViaTransition(fetchUrl, () =>
|
|
637
|
+
performNavigationFetch(fetchUrl, {
|
|
638
|
+
replace,
|
|
639
|
+
commitUrl: url,
|
|
640
|
+
signal: navAbort.signal,
|
|
641
|
+
skipHistory: effectiveSkipHistory,
|
|
642
|
+
})
|
|
643
|
+
);
|
|
581
644
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
// §"Error Page Rendering for Client Navigation".
|
|
614
|
-
//
|
|
615
|
-
// Set hard-navigating flag BEFORE setting window.location.href:
|
|
616
|
-
// 1. Prevents Navigation API from intercepting → infinite loop
|
|
617
|
-
// 2. Causes NavigationRoot to throw unresolvedThenable → prevents
|
|
618
|
-
// React from rendering children during page teardown (avoids
|
|
619
|
-
// "Rendered more hooks" crashes). See TIM-626.
|
|
620
|
-
if (error instanceof ServerErrorResponse) {
|
|
621
|
-
setHardNavigating(true);
|
|
622
|
-
window.location.href = error.url;
|
|
623
|
-
return new Promise(() => {}) as never;
|
|
624
|
-
}
|
|
625
|
-
// Abort errors are not application errors — swallow silently.
|
|
626
|
-
if (isAbortError(error)) return;
|
|
627
|
-
throw error;
|
|
628
|
-
} finally {
|
|
629
|
-
// Staleness guard (TIM-1034): only clean up when this navigation
|
|
630
|
-
// still owns the abort controller. When a newer navigation
|
|
631
|
-
// superseded this one, createNavAbort() already replaced
|
|
632
|
-
// currentNavAbort — running setPending(false) /
|
|
633
|
-
// completeRouterNavigation() here would clear the NEWER
|
|
634
|
-
// navigation's pending state and resolve its Navigation API
|
|
635
|
-
// deferred. The superseding navigation resolved OUR deferred in
|
|
636
|
-
// createNavAbort().
|
|
637
|
-
if (currentNavAbort === navAbort) {
|
|
638
|
-
// Clear the abort controller so we don't abort a completed
|
|
639
|
-
// navigation when the next one starts. In dev mode, the RSC body
|
|
640
|
-
// stream stays open after data arrives (React's Flight client
|
|
641
|
-
// waits for debug rows). Aborting a "completed" navigation kills
|
|
642
|
-
// the open stream reader → "BodyStreamBuffer was aborted".
|
|
643
|
-
currentNavAbort = null;
|
|
644
|
-
setPending(false);
|
|
645
|
-
// Resolve the Navigation API deferred — clears the browser's
|
|
646
|
-
// native loading state (tab spinner) at the same time as the
|
|
647
|
-
// TopLoader.
|
|
648
|
-
deps.completeRouterNavigation?.();
|
|
649
|
-
}
|
|
650
|
-
}
|
|
645
|
+
// Notify nuqs adapter (and any other listeners) that navigation completed.
|
|
646
|
+
window.dispatchEvent(new Event('timber:navigation-end'));
|
|
647
|
+
|
|
648
|
+
// Scroll-to-top on forward navigation, scroll to the #fragment target
|
|
649
|
+
// when the URL has one, or restore captured position for scroll={false}.
|
|
650
|
+
if (scroll && hash) {
|
|
651
|
+
scrollToHashAfterPaint(hash);
|
|
652
|
+
} else {
|
|
653
|
+
restoreScrollAfterPaint(scroll ? 0 : currentScrollY);
|
|
654
|
+
}
|
|
655
|
+
} catch (error) {
|
|
656
|
+
if (error instanceof VersionSkewError) {
|
|
657
|
+
setHardNavigating(true);
|
|
658
|
+
window.location.reload();
|
|
659
|
+
await new Promise(() => {});
|
|
660
|
+
}
|
|
661
|
+
if (error instanceof RedirectError) {
|
|
662
|
+
if (currentNavAbort !== navAbort) return;
|
|
663
|
+
await navigate(error.redirectUrl, { replace: true });
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (error instanceof ServerErrorResponse) {
|
|
667
|
+
setHardNavigating(true);
|
|
668
|
+
window.location.href = error.url;
|
|
669
|
+
await new Promise(() => {});
|
|
670
|
+
}
|
|
671
|
+
throw error;
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
externalSignal
|
|
675
|
+
);
|
|
651
676
|
}
|
|
652
677
|
|
|
653
678
|
async function refresh(): Promise<void> {
|
|
654
679
|
const currentUrl = deps.getCurrentUrl();
|
|
655
|
-
const navAbort = createNavAbort();
|
|
656
680
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
try {
|
|
681
|
+
await runNavigation(currentUrl, async (navAbort) => {
|
|
660
682
|
await renderViaTransition(currentUrl, async () => {
|
|
661
683
|
// No state tree sent — server renders the complete RSC payload
|
|
662
684
|
const result = await fetchRscPayload(
|
|
@@ -666,22 +688,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
666
688
|
undefined,
|
|
667
689
|
navAbort.signal
|
|
668
690
|
);
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
691
|
+
const payload = await resolveForFallback(result.payload);
|
|
692
|
+
const navState = commitNavigation(currentUrl, {
|
|
693
|
+
payload,
|
|
694
|
+
params: result.params,
|
|
695
|
+
segmentInfo: result.segmentInfo,
|
|
696
|
+
});
|
|
697
|
+
return { ...result, payload, navState };
|
|
673
698
|
});
|
|
674
|
-
}
|
|
675
|
-
if (isAbortError(error)) return;
|
|
676
|
-
throw error;
|
|
677
|
-
} finally {
|
|
678
|
-
// Staleness guard (TIM-1034) — see navigate()'s finally block.
|
|
679
|
-
if (currentNavAbort === navAbort) {
|
|
680
|
-
currentNavAbort = null;
|
|
681
|
-
setPending(false);
|
|
682
|
-
deps.completeRouterNavigation?.();
|
|
683
|
-
}
|
|
684
|
-
}
|
|
699
|
+
});
|
|
685
700
|
}
|
|
686
701
|
|
|
687
702
|
async function handlePopState(
|
|
@@ -697,47 +712,53 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
697
712
|
if (entry && entry.payload !== null) {
|
|
698
713
|
// Replay cached payload — no server roundtrip.
|
|
699
714
|
//
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
715
|
+
// runNavigation supersedes any in-flight forward navigation (TIM-1022):
|
|
716
|
+
// aborts its fetch and invalidates its render transition so the stale
|
|
717
|
+
// forward payload can't commit over this replay. The replay itself is
|
|
718
|
+
// synchronous — the fn resolves immediately.
|
|
719
|
+
await runNavigation(
|
|
720
|
+
url,
|
|
721
|
+
async () => {
|
|
722
|
+
// clearSegmentCacheOnEmpty: popstate to an entry without layout
|
|
723
|
+
// metadata (e.g., initial SSR page) clears the cache so the next
|
|
724
|
+
// forward navigation gets a full render.
|
|
725
|
+
const navState = commitNavigation(url, {
|
|
726
|
+
payload: entry.payload,
|
|
727
|
+
params: entry.params,
|
|
728
|
+
segmentInfo: entry.segmentInfo,
|
|
729
|
+
clearSegmentCacheOnEmpty: true,
|
|
730
|
+
});
|
|
731
|
+
renderPayload(entry.payload, navState);
|
|
732
|
+
restoreScrollAfterPaint(scrollY);
|
|
733
|
+
},
|
|
734
|
+
externalSignal
|
|
735
|
+
);
|
|
713
736
|
} else {
|
|
714
737
|
// No cached payload — fetch from server.
|
|
715
738
|
// This happens when navigating back to the initial SSR'd page
|
|
716
739
|
// (its payload is null since it was rendered via SSR, not RSC fetch)
|
|
717
740
|
// or when the entry doesn't exist at all.
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
741
|
+
await runNavigation(
|
|
742
|
+
url,
|
|
743
|
+
async (navAbort) => {
|
|
744
|
+
await renderViaTransition(url, async () => {
|
|
745
|
+
const stateTree = deps.clientSegmentCache
|
|
746
|
+
? segmentCache.serializeStateTree()
|
|
747
|
+
: undefined;
|
|
748
|
+
const result = await fetchRscPayload(url, deps, stateTree, undefined, navAbort.signal);
|
|
749
|
+
const payload = await resolveForFallback(result.payload);
|
|
750
|
+
const navState = commitNavigation(url, {
|
|
751
|
+
payload,
|
|
752
|
+
params: result.params,
|
|
753
|
+
segmentInfo: result.segmentInfo,
|
|
754
|
+
});
|
|
755
|
+
return { ...result, payload, navState };
|
|
756
|
+
});
|
|
729
757
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
} finally {
|
|
735
|
-
// Staleness guard (TIM-1034) — see navigate()'s finally block.
|
|
736
|
-
if (currentNavAbort === navAbort) {
|
|
737
|
-
currentNavAbort = null;
|
|
738
|
-
setPending(false);
|
|
739
|
-
}
|
|
740
|
-
}
|
|
758
|
+
restoreScrollAfterPaint(scrollY);
|
|
759
|
+
},
|
|
760
|
+
externalSignal
|
|
761
|
+
);
|
|
741
762
|
}
|
|
742
763
|
}
|
|
743
764
|
|
|
@@ -756,7 +777,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
756
777
|
if (historyStack.has(fetchUrl)) return;
|
|
757
778
|
|
|
758
779
|
// Fire-and-forget fetch
|
|
759
|
-
const stateTree = segmentCache.serializeStateTree();
|
|
780
|
+
const stateTree = deps.clientSegmentCache ? segmentCache.serializeStateTree() : undefined;
|
|
760
781
|
void fetchRscPayload(fetchUrl, deps, stateTree).then(
|
|
761
782
|
(result) => {
|
|
762
783
|
result.decodePromise?.catch(() => {});
|
|
@@ -783,21 +804,16 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
783
804
|
// Render the piggybacked element tree from a server action response.
|
|
784
805
|
// Updates the current history entry with the fresh payload —
|
|
785
806
|
// same as refresh() but without a server fetch.
|
|
786
|
-
// Revalidation payloads are already resolved (no Flight thenable),
|
|
787
|
-
// so no segment merging is needed — just cache and render.
|
|
788
807
|
const currentUrl = deps.getCurrentUrl();
|
|
789
808
|
|
|
790
|
-
//
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
// Keep params on the overwritten entry — dropping them would make
|
|
796
|
-
// away-and-back navigation replay this page with params normalized
|
|
797
|
-
// to {} (TIM-1037).
|
|
798
|
-
historyStack.push(currentUrl, {
|
|
809
|
+
// Preserve existing segmentInfo so away-and-back navigation replays
|
|
810
|
+
// with a correct segment cache (TIM-1037). Preserve current params
|
|
811
|
+
// so dynamic route params aren't cleared to {}.
|
|
812
|
+
const existingEntry = historyStack.get(currentUrl);
|
|
813
|
+
const navState = commitNavigation(currentUrl, {
|
|
799
814
|
payload: element,
|
|
800
|
-
params:
|
|
815
|
+
params: getNavigationState().params,
|
|
816
|
+
segmentInfo: existingEntry?.segmentInfo,
|
|
801
817
|
});
|
|
802
818
|
renderPayload(element, navState);
|
|
803
819
|
},
|
package/src/codec.ts
CHANGED
|
@@ -80,8 +80,10 @@ export const codec = {
|
|
|
80
80
|
integer: {
|
|
81
81
|
parse(value: string | string[] | undefined): number {
|
|
82
82
|
const str = Array.isArray(value) ? value[0] : value;
|
|
83
|
+
if (!str || !/^(?:0|-?[1-9]\d*)$/.test(str))
|
|
84
|
+
throw new Error(`Expected integer, got '${str}'`);
|
|
83
85
|
const n = Number(str);
|
|
84
|
-
if (!Number.
|
|
86
|
+
if (!Number.isSafeInteger(n)) throw new Error(`Expected integer, got '${str}'`);
|
|
85
87
|
return n;
|
|
86
88
|
},
|
|
87
89
|
serialize(value: number): string | null {
|
package/src/config-types.ts
CHANGED
|
@@ -46,9 +46,6 @@ export interface TimberUserConfig {
|
|
|
46
46
|
*/
|
|
47
47
|
clientJavascript?: boolean | ClientJavascriptConfig;
|
|
48
48
|
adapter?: unknown;
|
|
49
|
-
cacheHandler?: unknown;
|
|
50
|
-
/** CDN purge handler — called on revalidateTag/cache.invalidate to purge CDN-cached responses. */
|
|
51
|
-
cdnPurge?: unknown;
|
|
52
49
|
allowedOrigins?: string[];
|
|
53
50
|
csrf?: boolean;
|
|
54
51
|
limits?: {
|
|
@@ -56,40 +53,6 @@ export interface TimberUserConfig {
|
|
|
56
53
|
uploadBodySize?: string;
|
|
57
54
|
maxFields?: number;
|
|
58
55
|
};
|
|
59
|
-
/**
|
|
60
|
-
* Server-action form handling.
|
|
61
|
-
*
|
|
62
|
-
* See design/08-forms-and-actions.md §"Validation errors" and
|
|
63
|
-
* design/13-security.md §"Sensitive field stripping".
|
|
64
|
-
*/
|
|
65
|
-
/**
|
|
66
|
-
* Server action runtime behavior.
|
|
67
|
-
*
|
|
68
|
-
* See design/08-forms-and-actions.md §"Middleware for Server Actions".
|
|
69
|
-
*/
|
|
70
|
-
actions?: {
|
|
71
|
-
/**
|
|
72
|
-
* Run `middleware.ts` on server action requests before dispatching.
|
|
73
|
-
*
|
|
74
|
-
* **Default: `true`** — middleware runs on every action POST so
|
|
75
|
-
* authentication, rate limiting, tenant isolation, IP allow-listing,
|
|
76
|
-
* and request-header injection apply uniformly to page renders, route
|
|
77
|
-
* handlers, and server actions. This closes the auth-bypass class of
|
|
78
|
-
* issue identified by Next.js CVE-2025-29927: developers can put a
|
|
79
|
-
* single auth check in `middleware.ts` and trust that it gates every
|
|
80
|
-
* unsafe-method request.
|
|
81
|
-
*
|
|
82
|
-
* Set to `false` to restore the legacy behavior where actions skip
|
|
83
|
-
* middleware entirely. This is **not recommended** outside of niche
|
|
84
|
-
* cases (e.g. middleware that rewrites POST bodies and would corrupt
|
|
85
|
-
* action submissions). When false, you are responsible for placing
|
|
86
|
-
* auth, rate limiting, and other cross-cutting checks inside every
|
|
87
|
-
* action — typically via `createActionClient({ middleware: ... })`.
|
|
88
|
-
*
|
|
89
|
-
* See TIM-871.
|
|
90
|
-
*/
|
|
91
|
-
runMiddleware?: boolean;
|
|
92
|
-
};
|
|
93
56
|
forms?: {
|
|
94
57
|
/**
|
|
95
58
|
* Strip sensitive fields (passwords, tokens, CVV, SSN, etc.) from the
|
|
@@ -286,6 +249,22 @@ export interface TimberUserConfig {
|
|
|
286
249
|
* ```
|
|
287
250
|
*/
|
|
288
251
|
buildDir?: string;
|
|
252
|
+
/**
|
|
253
|
+
* Enable the client-side segment cache for partial navigation.
|
|
254
|
+
*
|
|
255
|
+
* When enabled, the router serializes the mounted segment tree into an
|
|
256
|
+
* `X-Timber-State-Tree` header on every client navigation. The server
|
|
257
|
+
* skips re-rendering unchanged sync layouts and sends a partial RSC
|
|
258
|
+
* payload. The client merges the partial payload with its cached segments.
|
|
259
|
+
*
|
|
260
|
+
* When disabled (the default), every navigation gets a full RSC payload.
|
|
261
|
+
*
|
|
262
|
+
* This is a performance optimization only — not a security boundary.
|
|
263
|
+
* The server always runs all `access.ts` files regardless.
|
|
264
|
+
*
|
|
265
|
+
* See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
266
|
+
*/
|
|
267
|
+
clientSegmentCache?: boolean;
|
|
289
268
|
topLoader?: {
|
|
290
269
|
/** Whether the top-loader is enabled. Default: true. */
|
|
291
270
|
enabled?: boolean;
|