@timber-js/app 0.2.0-alpha.123 → 0.2.0-alpha.125
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-ZlcKx0H9.js → actions-418ygYKC.js} +2 -2
- package/dist/_chunks/{actions-ZlcKx0H9.js.map → actions-418ygYKC.js.map} +1 -1
- package/dist/_chunks/{build-output-helper-DYOWsPjZ.js → build-output-helper-K7oUCRqU.js} +1 -1
- package/dist/_chunks/{build-output-helper-DYOWsPjZ.js.map → build-output-helper-K7oUCRqU.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-VcCf1TFY.js → cli-schema-sync-DFHBj6yZ.js} +59 -12
- package/dist/_chunks/{cli-schema-sync-VcCf1TFY.js.map → cli-schema-sync-DFHBj6yZ.js.map} +1 -1
- package/dist/_chunks/cli-schema-sync-vdobDWwX.js +2 -0
- package/dist/_chunks/{define-C0V_eKiW.js → define-BJMwm0c4.js} +2 -2
- package/dist/_chunks/{define-C0V_eKiW.js.map → define-BJMwm0c4.js.map} +1 -1
- package/dist/_chunks/{navigation-root-Ds_hDtRW.js → navigation-root-CeNTCxzS.js} +6 -6
- package/dist/_chunks/navigation-root-CeNTCxzS.js.map +1 -0
- package/dist/_chunks/{stale-reload-BN2kcE06.js → stale-reload-CCqb62Z3.js} +22 -2
- package/dist/_chunks/{stale-reload-BN2kcE06.js.map → stale-reload-CCqb62Z3.js.map} +1 -1
- package/dist/_chunks/{use-segment-params-Du7u25cK.js → use-segment-params-ByGqwUuO.js} +3 -2
- package/dist/_chunks/use-segment-params-ByGqwUuO.js.map +1 -0
- package/dist/_chunks/{walkers-tkw7sDlj.js → walkers-DMSGZeCk.js} +2 -2
- package/dist/_chunks/{walkers-tkw7sDlj.js.map → walkers-DMSGZeCk.js.map} +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/cli-schema-sync.d.ts +6 -4
- package/dist/cli-schema-sync.d.ts.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +12 -6
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts +4 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/index.js +4 -4
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +147 -301
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link-pending-store.d.ts +19 -17
- package/dist/client/link-pending-store.d.ts.map +1 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts +1 -0
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +4 -1
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/router.d.ts +10 -7
- 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 +2 -0
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/segment-outlet.d.ts +23 -37
- package/dist/client/segment-outlet.d.ts.map +1 -1
- package/dist/client/segment-update-context.d.ts +21 -0
- package/dist/client/segment-update-context.d.ts.map +1 -0
- package/dist/client/use-search-params.d.ts +7 -5
- package/dist/client/use-search-params.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/routing/index.js +2 -2
- package/dist/segment-params/index.js +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +58 -2
- package/dist/server/internal.js.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +1 -0
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +12 -5
- package/dist/server/state-tree-diff.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli-schema-sync.ts +80 -13
- package/src/cli.ts +18 -7
- package/src/client/browser-entry/hydrate.ts +9 -1
- package/src/client/browser-entry/index.ts +3 -1
- package/src/client/browser-entry/post-hydration.ts +1 -13
- package/src/client/browser-entry/router-init.ts +50 -24
- package/src/client/link-pending-store.ts +22 -17
- package/src/client/link.tsx +12 -6
- package/src/client/navigation-context.ts +2 -1
- package/src/client/navigation-root.tsx +40 -18
- package/src/client/router.ts +113 -99
- package/src/client/rsc-fetch.ts +85 -6
- package/src/client/segment-cache.ts +5 -2
- package/src/client/segment-outlet.tsx +40 -49
- package/src/client/segment-update-context.ts +42 -0
- package/src/client/use-search-params.ts +25 -44
- package/src/server/route-element-builder.ts +69 -2
- package/src/server/rsc-entry/helpers.ts +18 -14
- package/src/server/state-tree-diff.ts +26 -22
- package/dist/_chunks/cli-schema-sync-4HcQIziG.js +0 -2
- package/dist/_chunks/navigation-root-Ds_hDtRW.js.map +0 -1
- package/dist/_chunks/use-segment-params-Du7u25cK.js.map +0 -1
- package/dist/client/segment-merger.d.ts +0 -119
- package/dist/client/segment-merger.d.ts.map +0 -1
- package/src/client/segment-merger.ts +0 -339
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAY9D,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAY9D,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAc7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAuCzD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,OAAO,GAAG,OAAO,CAM7D;AAID;;;GAGG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;gBAC/B,OAAO,EAAE,MAAM;CAI5B;AAID,qDAAqD;AACrD,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;CACvC;AAED,+CAA+C;AAC/C,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IAC3C,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,wFAAwF;IACxF,OAAO,EAAE,KAAK,CAAC,YAAY,CAAC;IAC5B,2CAA2C;IAC3C,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,wDAAwD;IACxD,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;IACzC,qCAAqC;IACrC,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,eAAe,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED;;;;;;;GAOG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;aAE7B,MAAM,EAAE,UAAU,GAAG,cAAc;aACnC,gBAAgB,EAAE,oBAAoB,EAAE;aACxC,QAAQ,EAAE,mBAAmB,EAAE;gBAF/B,MAAM,EAAE,UAAU,GAAG,cAAc,EACnC,gBAAgB,EAAE,oBAAoB,EAAE,EACxC,QAAQ,EAAE,mBAAmB,EAAE;CAIlD;AA8DD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,YAAY,CAAC,EAAE,mBAAmB,EAClC,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,EACpC,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC3C,OAAO,CAAC,kBAAkB,CAAC,CAob7B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/helpers.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC/D,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAGlD,+DAA+D;AAC/D,eAAO,MAAM,gBAAgB,qBAAqB,CAAC;AAEnD;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,IAAI;IAAE,QAAQ,EAAE,cAAc,CAAC;IAAC,QAAQ,EAAE,cAAc,CAAA;CAAE,CAQ/F;AAID;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,KAAK,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;CACzB;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,cAAc,CAAC;IACzB,QAAQ,EAAE,cAAc,CAAC;IACzB,yDAAyD;IACzD,gBAAgB,IAAI,MAAM,CAAC;IAC3B,yEAAyE;IACzE,aAAa,IAAI,mBAAmB,EAAE,CAAC;CACxC;AAED,wBAAgB,2BAA2B,IAAI,qBAAqB,CAkCnE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,mBAAmB,EAAE,CA8ClE;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,mBAAmB,EAAE,EAC/B,gBAAgB,EAAE,KAAK,CAAC;IACtB,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IAC3C,OAAO,EAAE,mBAAmB,CAAC;CAC9B,CAAC,GACD,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC,
|
|
1
|
+
{"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/helpers.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC/D,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAGlD,+DAA+D;AAC/D,eAAO,MAAM,gBAAgB,qBAAqB,CAAC;AAEnD;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,IAAI;IAAE,QAAQ,EAAE,cAAc,CAAC;IAAC,QAAQ,EAAE,cAAc,CAAA;CAAE,CAQ/F;AAID;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,KAAK,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;CACzB;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,cAAc,CAAC;IACzB,QAAQ,EAAE,cAAc,CAAC;IACzB,yDAAyD;IACzD,gBAAgB,IAAI,MAAM,CAAC;IAC3B,yEAAyE;IACzE,aAAa,IAAI,mBAAmB,EAAE,CAAC;CACxC;AAED,wBAAgB,2BAA2B,IAAI,qBAAqB,CAkCnE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,mBAAmB,EAAE,CA8ClE;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,mBAAmB,EAAE,EAC/B,gBAAgB,EAAE,KAAK,CAAC;IACtB,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IAC3C,OAAO,EAAE,mBAAmB,CAAC;CAC9B,CAAC,GACD,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC,CA8B/D;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAGzD;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,cAAc,EACtB,eAAe,EAAE,OAAO,GACvB,QAAQ,CAOV;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAIpD;AAGD,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAErD;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAW1E"}
|
|
@@ -27,17 +27,24 @@ export declare function parseClientStateTree(req: Request): Set<string> | null;
|
|
|
27
27
|
* Determine whether a segment's layout rendering can be skipped.
|
|
28
28
|
*
|
|
29
29
|
* A segment is skipped when ALL of the following are true:
|
|
30
|
-
* 1. The client
|
|
31
|
-
* 2. The
|
|
32
|
-
* 3. The segment
|
|
30
|
+
* 1. The client sent a state tree (clientSegments is non-null)
|
|
31
|
+
* 2. The client has this segment cached (clientSegments contains urlPath)
|
|
32
|
+
* 3. The segment has a layout (otherwise nothing to skip)
|
|
33
|
+
* 4. The layout is sync (not an async function — async layouts may depend
|
|
34
|
+
* on per-request data and must always re-render)
|
|
35
|
+
* 5. The segment is NOT the leaf (pages are never cached across navigations)
|
|
36
|
+
*
|
|
37
|
+
* Additional constraints enforced by the caller (buildRouteElement):
|
|
38
|
+
* - Must have a rendered layout below (so SegmentOutlet has content to show)
|
|
39
|
+
* - Route groups are never skipped (siblings share urlPath)
|
|
33
40
|
*
|
|
34
41
|
* Access.ts still runs for skipped segments — this is enforced by the caller
|
|
35
|
-
*
|
|
42
|
+
* which wraps with AccessGate regardless of skip status.
|
|
36
43
|
*
|
|
37
44
|
* @param urlPath - The segment's URL path (e.g., "/", "/dashboard")
|
|
38
45
|
* @param layoutComponent - The loaded layout component function
|
|
39
46
|
* @param isLeaf - Whether this is the leaf segment (page segment)
|
|
40
47
|
* @param clientSegments - Set of paths from X-Timber-State-Tree, or null
|
|
41
48
|
*/
|
|
42
|
-
export declare function shouldSkipSegment(
|
|
49
|
+
export declare function shouldSkipSegment(urlPath: string, layoutComponent: ((...args: unknown[]) => unknown) | undefined, isLeaf: boolean, clientSegments: Set<string> | null): boolean;
|
|
43
50
|
//# sourceMappingURL=state-tree-diff.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"state-tree-diff.d.ts","sourceRoot":"","sources":["../../src/server/state-tree-diff.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAIH;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAcrE;AAED
|
|
1
|
+
{"version":3,"file":"state-tree-diff.d.ts","sourceRoot":"","sources":["../../src/server/state-tree-diff.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAIH;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAcrE;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,eAAe,EAAE,CAAC,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,GAAG,SAAS,EAC9D,MAAM,EAAE,OAAO,EACf,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GACjC,OAAO,CAYT"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timber-js/app",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.125",
|
|
4
4
|
"description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
package/src/cli-schema-sync.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `timber schema sync` — scan filesystem for dynamic segments
|
|
3
|
-
* missing entries
|
|
4
|
-
*
|
|
5
|
-
* Only adds, never removes or modifies existing entries.
|
|
2
|
+
* `timber schema sync` — scan filesystem for dynamic segments, append
|
|
3
|
+
* missing entries with `codec.string` defaults, and remove stale entries
|
|
4
|
+
* whose segments no longer exist in the filesystem.
|
|
6
5
|
*
|
|
7
6
|
* Design doc: design/41-global-params.md §Auto-Scaffolding
|
|
8
7
|
*/
|
|
@@ -66,6 +65,12 @@ export function findSchemaFile(appDir: string): string | undefined {
|
|
|
66
65
|
return undefined;
|
|
67
66
|
}
|
|
68
67
|
|
|
68
|
+
export function defaultCodecForSegment(seg: string): string {
|
|
69
|
+
if (seg.startsWith('[[...')) return 'codec.optionalCatchAll(codec.string)';
|
|
70
|
+
if (seg.startsWith('[...')) return 'codec.stringArray';
|
|
71
|
+
return 'codec.string';
|
|
72
|
+
}
|
|
73
|
+
|
|
69
74
|
/**
|
|
70
75
|
* Generate the content for a new app/schema.ts with the given segment keys.
|
|
71
76
|
*/
|
|
@@ -77,7 +82,7 @@ function generateNewSchema(segments: string[]): string {
|
|
|
77
82
|
lines.push('export default defineSchema({');
|
|
78
83
|
lines.push(' segmentParams: {');
|
|
79
84
|
for (const seg of segments) {
|
|
80
|
-
lines.push(` '${seg}':
|
|
85
|
+
lines.push(` '${seg}': ${defaultCodecForSegment(seg)},`);
|
|
81
86
|
}
|
|
82
87
|
lines.push(' },');
|
|
83
88
|
lines.push('});');
|
|
@@ -147,7 +152,9 @@ function appendToSchema(content: string, newSegments: string[]): string {
|
|
|
147
152
|
const indent = existingLine ? existingLine[1] : ' ';
|
|
148
153
|
|
|
149
154
|
// Build insertion text
|
|
150
|
-
const insertion = newSegments
|
|
155
|
+
const insertion = newSegments
|
|
156
|
+
.map((seg) => `${indent}'${seg}': ${defaultCodecForSegment(seg)},`)
|
|
157
|
+
.join('\n');
|
|
151
158
|
|
|
152
159
|
// Insert before the closing brace
|
|
153
160
|
const before = content.slice(0, closeBrace);
|
|
@@ -159,6 +166,54 @@ function appendToSchema(content: string, newSegments: string[]): string {
|
|
|
159
166
|
return before + separator + insertion + '\n' + after;
|
|
160
167
|
}
|
|
161
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Remove entries from the segmentParams block whose keys match `staleSegments`.
|
|
171
|
+
* Removes the entire line including trailing comma/whitespace.
|
|
172
|
+
*/
|
|
173
|
+
function removeFromSchema(content: string, staleSegments: string[]): string {
|
|
174
|
+
for (const seg of staleSegments) {
|
|
175
|
+
const escaped = seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
176
|
+
const keyRe = new RegExp(`^[ \t]*['"]${escaped}['"][ \t]*:`, 'gm');
|
|
177
|
+
let m: RegExpExecArray | null;
|
|
178
|
+
while ((m = keyRe.exec(content)) !== null) {
|
|
179
|
+
const start = m.index;
|
|
180
|
+
let i = m.index + m[0].length;
|
|
181
|
+
let depth = 0;
|
|
182
|
+
let inString: string | null = null;
|
|
183
|
+
|
|
184
|
+
while (i < content.length) {
|
|
185
|
+
const ch = content[i];
|
|
186
|
+
if (inString) {
|
|
187
|
+
if (ch === '\\') {
|
|
188
|
+
i++;
|
|
189
|
+
} else if (ch === inString) {
|
|
190
|
+
inString = null;
|
|
191
|
+
}
|
|
192
|
+
} else if (ch === '"' || ch === "'" || ch === '`') {
|
|
193
|
+
inString = ch;
|
|
194
|
+
} else if (ch === '(' || ch === '{' || ch === '[') {
|
|
195
|
+
depth++;
|
|
196
|
+
} else if (ch === ')' || ch === '}' || ch === ']') {
|
|
197
|
+
depth--;
|
|
198
|
+
} else if (depth === 0 && ch === ',') {
|
|
199
|
+
i++;
|
|
200
|
+
break;
|
|
201
|
+
} else if (depth === 0 && (ch === '\n' || ch === '}')) {
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
i++;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Consume trailing newline
|
|
208
|
+
if (i < content.length && content[i] === '\n') i++;
|
|
209
|
+
|
|
210
|
+
content = content.slice(0, start) + content.slice(i);
|
|
211
|
+
keyRe.lastIndex = start;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return content;
|
|
215
|
+
}
|
|
216
|
+
|
|
162
217
|
export interface SchemaSyncResult {
|
|
163
218
|
/** Segments found in the filesystem. */
|
|
164
219
|
filesystemSegments: string[];
|
|
@@ -166,6 +221,8 @@ export interface SchemaSyncResult {
|
|
|
166
221
|
existingSegments: string[];
|
|
167
222
|
/** Segments that were added. */
|
|
168
223
|
addedSegments: string[];
|
|
224
|
+
/** Segments that were removed (no longer in filesystem). */
|
|
225
|
+
removedSegments: string[];
|
|
169
226
|
/** Whether a new schema file was created. */
|
|
170
227
|
created: boolean;
|
|
171
228
|
}
|
|
@@ -178,17 +235,18 @@ export function runSchemaSync(projectRoot: string): SchemaSyncResult {
|
|
|
178
235
|
|
|
179
236
|
const filesystemSegments = collectDynamicSegments(appDir);
|
|
180
237
|
|
|
181
|
-
|
|
238
|
+
const schemaPath = findSchemaFile(appDir);
|
|
239
|
+
|
|
240
|
+
if (filesystemSegments.length === 0 && !schemaPath) {
|
|
182
241
|
return {
|
|
183
242
|
filesystemSegments: [],
|
|
184
243
|
existingSegments: [],
|
|
185
244
|
addedSegments: [],
|
|
245
|
+
removedSegments: [],
|
|
186
246
|
created: false,
|
|
187
247
|
};
|
|
188
248
|
}
|
|
189
249
|
|
|
190
|
-
const schemaPath = findSchemaFile(appDir);
|
|
191
|
-
|
|
192
250
|
if (!schemaPath) {
|
|
193
251
|
// Create new schema file
|
|
194
252
|
const newPath = join(appDir, 'schema.ts');
|
|
@@ -197,32 +255,41 @@ export function runSchemaSync(projectRoot: string): SchemaSyncResult {
|
|
|
197
255
|
filesystemSegments,
|
|
198
256
|
existingSegments: [],
|
|
199
257
|
addedSegments: filesystemSegments,
|
|
258
|
+
removedSegments: [],
|
|
200
259
|
created: true,
|
|
201
260
|
};
|
|
202
261
|
}
|
|
203
262
|
|
|
204
|
-
// Read existing schema and
|
|
263
|
+
// Read existing schema and diff
|
|
205
264
|
const content = readFileSync(schemaPath, 'utf-8');
|
|
206
265
|
const existingSegments = parseExistingSchemaKeys(content);
|
|
207
266
|
const missing = filesystemSegments.filter((seg) => !existingSegments.includes(seg));
|
|
267
|
+
const stale = existingSegments.filter((seg) => !filesystemSegments.includes(seg));
|
|
208
268
|
|
|
209
|
-
if (missing.length === 0) {
|
|
269
|
+
if (missing.length === 0 && stale.length === 0) {
|
|
210
270
|
return {
|
|
211
271
|
filesystemSegments,
|
|
212
272
|
existingSegments,
|
|
213
273
|
addedSegments: [],
|
|
274
|
+
removedSegments: [],
|
|
214
275
|
created: false,
|
|
215
276
|
};
|
|
216
277
|
}
|
|
217
278
|
|
|
218
|
-
|
|
219
|
-
|
|
279
|
+
let updated = content;
|
|
280
|
+
if (stale.length > 0) {
|
|
281
|
+
updated = removeFromSchema(updated, stale);
|
|
282
|
+
}
|
|
283
|
+
if (missing.length > 0) {
|
|
284
|
+
updated = appendToSchema(updated, missing);
|
|
285
|
+
}
|
|
220
286
|
writeFileSync(schemaPath, updated, 'utf-8');
|
|
221
287
|
|
|
222
288
|
return {
|
|
223
289
|
filesystemSegments,
|
|
224
290
|
existingSegments,
|
|
225
291
|
addedSegments: missing,
|
|
292
|
+
removedSegments: stale,
|
|
226
293
|
created: false,
|
|
227
294
|
};
|
|
228
295
|
}
|
package/src/cli.ts
CHANGED
|
@@ -188,7 +188,7 @@ export async function runCheck(options: CommandOptions): Promise<void> {
|
|
|
188
188
|
|
|
189
189
|
/**
|
|
190
190
|
* `timber schema sync` — scan filesystem for dynamic segments, diff against
|
|
191
|
-
* app/schema.ts, and append missing entries with codec
|
|
191
|
+
* app/schema.ts, and append missing entries with type-appropriate codec defaults.
|
|
192
192
|
*
|
|
193
193
|
* See design/41-global-params.md §Auto-Scaffolding
|
|
194
194
|
*/
|
|
@@ -201,7 +201,7 @@ export async function runSchema(args: string[]): Promise<void> {
|
|
|
201
201
|
);
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
const { runSchemaSync } = await import('./cli-schema-sync.js');
|
|
204
|
+
const { runSchemaSync, defaultCodecForSegment } = await import('./cli-schema-sync.js');
|
|
205
205
|
const result = runSchemaSync(process.cwd());
|
|
206
206
|
|
|
207
207
|
if (result.filesystemSegments.length === 0) {
|
|
@@ -212,19 +212,30 @@ export async function runSchema(args: string[]): Promise<void> {
|
|
|
212
212
|
if (result.created) {
|
|
213
213
|
console.log(`[timber] Created app/schema.ts with ${result.addedSegments.length} segment(s):`);
|
|
214
214
|
for (const seg of result.addedSegments) {
|
|
215
|
-
console.log(` + ${seg}:
|
|
215
|
+
console.log(` + ${seg}: ${defaultCodecForSegment(seg)}`);
|
|
216
216
|
}
|
|
217
217
|
return;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
if (result.addedSegments.length === 0) {
|
|
220
|
+
if (result.addedSegments.length === 0 && result.removedSegments.length === 0) {
|
|
221
221
|
console.log('[timber] Schema is up to date — all segments are registered.');
|
|
222
222
|
return;
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
225
|
+
if (result.addedSegments.length > 0) {
|
|
226
|
+
console.log(`[timber] Added ${result.addedSegments.length} segment(s) to app/schema.ts:`);
|
|
227
|
+
for (const seg of result.addedSegments) {
|
|
228
|
+
console.log(` + ${seg}: ${defaultCodecForSegment(seg)}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (result.removedSegments.length > 0) {
|
|
233
|
+
console.log(
|
|
234
|
+
`[timber] Removed ${result.removedSegments.length} stale segment(s) from app/schema.ts:`
|
|
235
|
+
);
|
|
236
|
+
for (const seg of result.removedSegments) {
|
|
237
|
+
console.log(` - ${seg}`);
|
|
238
|
+
}
|
|
228
239
|
}
|
|
229
240
|
}
|
|
230
241
|
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
getNavigationState,
|
|
26
26
|
setNavigationState,
|
|
27
27
|
} from '../navigation-context.js';
|
|
28
|
+
import { SegmentUpdateContext, EMPTY_SEGMENT_UPDATES } from '../segment-update-context.js';
|
|
28
29
|
import { TimberNuqsAdapter } from '../nuqs-adapter.js';
|
|
29
30
|
import { isPageUnloading } from '../unload-guard.js';
|
|
30
31
|
import { NavigationRoot, installDeferredNavigation } from '../navigation-root.js';
|
|
@@ -50,12 +51,14 @@ export function runPreHydration(): void {
|
|
|
50
51
|
setNavigationState({
|
|
51
52
|
params: earlyParams as Record<string, string | string[]>,
|
|
52
53
|
pathname: window.location.pathname,
|
|
54
|
+
search: window.location.search,
|
|
53
55
|
});
|
|
54
56
|
delete (self as unknown as Record<string, unknown>).__timber_params;
|
|
55
57
|
} else {
|
|
56
58
|
setNavigationState({
|
|
57
59
|
params: {},
|
|
58
60
|
pathname: window.location.pathname,
|
|
61
|
+
search: window.location.search,
|
|
59
62
|
});
|
|
60
63
|
}
|
|
61
64
|
}
|
|
@@ -87,7 +90,12 @@ export function hydrateApp({ rscResult, config }: HydrateOptions): void {
|
|
|
87
90
|
{ value: navState },
|
|
88
91
|
element as React.ReactNode
|
|
89
92
|
);
|
|
90
|
-
const
|
|
93
|
+
const withUpdates = createElement(
|
|
94
|
+
SegmentUpdateContext.Provider,
|
|
95
|
+
{ value: EMPTY_SEGMENT_UPDATES },
|
|
96
|
+
withNav
|
|
97
|
+
);
|
|
98
|
+
const wrapped = createElement(TimberNuqsAdapter, null, withUpdates);
|
|
91
99
|
const rootElement = createElement(NavigationRoot, {
|
|
92
100
|
initial: wrapped,
|
|
93
101
|
topLoaderConfig: config.topLoader,
|
|
@@ -81,7 +81,9 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
81
81
|
const rscResult = createRscPayloadStream();
|
|
82
82
|
|
|
83
83
|
// Step 3: Create router + Navigation API integration
|
|
84
|
-
const { router, navApiController } = createTimberRouter(
|
|
84
|
+
const { router, navApiController } = createTimberRouter({
|
|
85
|
+
initialElement: rscResult?.element ?? undefined,
|
|
86
|
+
});
|
|
85
87
|
|
|
86
88
|
// Step 4: Pre-hydration — set params + navigation state (MUST run after router init)
|
|
87
89
|
runPreHydration();
|
|
@@ -58,19 +58,6 @@ export function setupPostHydration({
|
|
|
58
58
|
delete (self as unknown as Record<string, unknown>).__timber_segments;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
// Cache segment elements from the initial RSC payload after hydration.
|
|
62
|
-
// The element from createFromReadableStream is a Flight thenable that
|
|
63
|
-
// React resolves during hydration. Once resolved (status='fulfilled'),
|
|
64
|
-
// its .value contains the React element tree we can walk.
|
|
65
|
-
// Use requestAnimationFrame to run after hydration commits.
|
|
66
|
-
if (initialElement != null) {
|
|
67
|
-
requestAnimationFrame(() => {
|
|
68
|
-
const el = initialElement as { status?: string; value?: unknown };
|
|
69
|
-
const resolved = el.status === 'fulfilled' && el.value != null ? el.value : initialElement;
|
|
70
|
-
router.cacheElementTree(resolved);
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
|
|
74
61
|
// If the hydration path was skipped (no RSC payload), populate the
|
|
75
62
|
// fallback params from server embed here.
|
|
76
63
|
const lateTimberParams = (self as unknown as Record<string, unknown>).__timber_params;
|
|
@@ -79,6 +66,7 @@ export function setupPostHydration({
|
|
|
79
66
|
setNavigationState({
|
|
80
67
|
params: lateTimberParams as Record<string, string | string[]>,
|
|
81
68
|
pathname: window.location.pathname,
|
|
69
|
+
search: window.location.search,
|
|
82
70
|
});
|
|
83
71
|
delete (self as unknown as Record<string, unknown>).__timber_params;
|
|
84
72
|
}
|
|
@@ -16,6 +16,7 @@ import { applyHeadElements } from '../head.js';
|
|
|
16
16
|
import { TimberNuqsAdapter } from '../nuqs-adapter.js';
|
|
17
17
|
import { NavigationProvider } from '../navigation-context.js';
|
|
18
18
|
import { transitionRender, navigateTransition } from '../navigation-root.js';
|
|
19
|
+
import { SegmentUpdateContext, EMPTY_SEGMENT_UPDATES } from '../segment-update-context.js';
|
|
19
20
|
import { isStaleClientReference, triggerStaleReload } from '../stale-reload.js';
|
|
20
21
|
import {
|
|
21
22
|
hasNavigationApi,
|
|
@@ -24,6 +25,10 @@ import {
|
|
|
24
25
|
} from '../navigation-api.js';
|
|
25
26
|
import { getScrollY } from './scroll.js';
|
|
26
27
|
|
|
28
|
+
export interface RouterInitOptions {
|
|
29
|
+
initialElement?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
27
32
|
export interface RouterInitResult {
|
|
28
33
|
router: RouterInstance;
|
|
29
34
|
navApiController: NavigationApiController | null;
|
|
@@ -36,12 +41,20 @@ export interface RouterInitResult {
|
|
|
36
41
|
* the initial render (methods lazily resolve the router at invocation,
|
|
37
42
|
* not render time, but initRouter must still run first).
|
|
38
43
|
*/
|
|
39
|
-
export function createTimberRouter(): RouterInitResult {
|
|
44
|
+
export function createTimberRouter(options?: RouterInitOptions): RouterInitResult {
|
|
40
45
|
// Feature-detect Navigation API. When available, the navigate event
|
|
41
46
|
// replaces popstate for back/forward and catches external navigations.
|
|
42
47
|
// See design/19-client-navigation.md §"Navigation API Integration"
|
|
43
48
|
const useNavApi = hasNavigationApi();
|
|
44
49
|
|
|
50
|
+
// Track the current unwrapped RSC payload. On partial navigation, the
|
|
51
|
+
// router re-wraps this same element with new NavigationProvider context
|
|
52
|
+
// so mounted SegmentOutlets are preserved (they pick up changes via
|
|
53
|
+
// SegmentUpdateContext, not from new children props).
|
|
54
|
+
// Initialized from the hydration element so partial nav works on the
|
|
55
|
+
// very first client navigation (before any wrapPayload call).
|
|
56
|
+
let currentPayload: unknown = options?.initialElement ?? null;
|
|
57
|
+
|
|
45
58
|
const deps: RouterDeps = {
|
|
46
59
|
fetch: (url, init) => window.fetch(url, init),
|
|
47
60
|
pushState: (data, unused, url) => window.history.pushState(data, unused, url),
|
|
@@ -75,20 +88,14 @@ export function createTimberRouter(): RouterInitResult {
|
|
|
75
88
|
// reference module IDs that don't exist in the old client bundle.
|
|
76
89
|
// We catch "Could not find the module" errors and trigger a full
|
|
77
90
|
// page reload so the browser fetches the new bundle.
|
|
78
|
-
|
|
91
|
+
// Pass the fetch promise directly to createFromFetch for streaming.
|
|
92
|
+
// React progressively renders chunks via Suspense as they arrive.
|
|
93
|
+
// The transition stays open via decodePromise (awaited inside
|
|
94
|
+
// startTransition) so useOptimistic pending state persists for
|
|
95
|
+
// the full stream duration.
|
|
96
|
+
decodeRsc: (fetchPromise: Promise<Response>) => {
|
|
79
97
|
try {
|
|
80
|
-
|
|
81
|
-
// createFromFetch resolves on the first Flight chunk (the shell),
|
|
82
|
-
// but we need the transition to stay pending for the full response
|
|
83
|
-
// so useOptimistic (per-link pending) persists during slow navigations.
|
|
84
|
-
// Buffer the complete response, then decode from the buffered stream.
|
|
85
|
-
const response = await fetchPromise;
|
|
86
|
-
const body = await response.arrayBuffer();
|
|
87
|
-
const buffered = new Response(body, {
|
|
88
|
-
headers: response.headers,
|
|
89
|
-
status: response.status,
|
|
90
|
-
});
|
|
91
|
-
return await createFromFetch(Promise.resolve(buffered));
|
|
98
|
+
return createFromFetch(fetchPromise);
|
|
92
99
|
} catch (error) {
|
|
93
100
|
if (isStaleClientReference(error)) {
|
|
94
101
|
triggerStaleReload();
|
|
@@ -114,7 +121,12 @@ export function createTimberRouter(): RouterInitResult {
|
|
|
114
121
|
{ value: navState },
|
|
115
122
|
element as React.ReactNode
|
|
116
123
|
);
|
|
117
|
-
const
|
|
124
|
+
const withUpdates = createElement(
|
|
125
|
+
SegmentUpdateContext.Provider,
|
|
126
|
+
{ value: EMPTY_SEGMENT_UPDATES },
|
|
127
|
+
withNav
|
|
128
|
+
);
|
|
129
|
+
const wrapped = createElement(TimberNuqsAdapter, null, withUpdates);
|
|
118
130
|
transitionRender(wrapped);
|
|
119
131
|
},
|
|
120
132
|
|
|
@@ -128,18 +140,32 @@ export function createTimberRouter(): RouterInitResult {
|
|
|
128
140
|
// is passed explicitly by the router — no getNavigationState() needed.
|
|
129
141
|
navigateTransition: (pendingUrl: string, perform) => {
|
|
130
142
|
return navigateTransition(pendingUrl, async () => {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
143
|
+
const { element, decodePromise } = await perform(
|
|
144
|
+
(
|
|
145
|
+
rawPayload: unknown,
|
|
146
|
+
navState: NavigationState,
|
|
147
|
+
segmentUpdates?: Map<string, unknown>
|
|
148
|
+
) => {
|
|
149
|
+
currentPayload = rawPayload;
|
|
150
|
+
const withNav = createElement(
|
|
151
|
+
NavigationProvider,
|
|
152
|
+
{ value: navState },
|
|
153
|
+
rawPayload as React.ReactNode
|
|
154
|
+
);
|
|
155
|
+
const withUpdates = createElement(
|
|
156
|
+
SegmentUpdateContext.Provider,
|
|
157
|
+
{ value: (segmentUpdates ?? EMPTY_SEGMENT_UPDATES) as Map<string, React.ReactNode> },
|
|
158
|
+
withNav
|
|
159
|
+
);
|
|
160
|
+
return createElement(TimberNuqsAdapter, null, withUpdates);
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
return { element: element as React.ReactNode, decodePromise };
|
|
140
164
|
});
|
|
141
165
|
},
|
|
142
166
|
|
|
167
|
+
_getCurrentPayload: () => currentPayload,
|
|
168
|
+
|
|
143
169
|
// Schedule a callback after the next paint so scroll operations
|
|
144
170
|
// happen after React commits the new content to the DOM.
|
|
145
171
|
// Double-rAF ensures the browser has painted the new frame.
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Link Pending Store — per-link
|
|
2
|
+
* Link Pending Store — per-link pending state.
|
|
3
3
|
*
|
|
4
4
|
* Tracks which link instance is currently navigating so that only the
|
|
5
|
-
* clicked link shows pending. Uses
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* clicked link shows pending. Uses useState in Link — the pending
|
|
6
|
+
* state is set as an urgent update (outside startTransition) so it
|
|
7
|
+
* shows immediately, then reset inside the transition so it commits
|
|
8
|
+
* atomically with the new tree.
|
|
9
9
|
*
|
|
10
10
|
* Flow:
|
|
11
11
|
* 1. Link click → setLinkForCurrentNavigation(instance) stores the ref
|
|
12
|
-
* 2. NavigationRoot
|
|
13
|
-
* instance.setIsPending(LINK_PENDING)
|
|
14
|
-
* 3.
|
|
12
|
+
* 2. NavigationRoot → activateLinkPending() calls
|
|
13
|
+
* instance.setIsPending(LINK_PENDING) as an urgent update
|
|
14
|
+
* 3. Inside startTransition → resetLinkPending() commits with new tree
|
|
15
15
|
*
|
|
16
16
|
* SINGLETON GUARANTEE: Uses `globalThis` via `Symbol.for` keys because
|
|
17
17
|
* the RSC client bundler can duplicate this module across chunks.
|
|
@@ -52,21 +52,25 @@ function getStore(): { current: LinkPendingInstance | null } {
|
|
|
52
52
|
/**
|
|
53
53
|
* Register the link instance that initiated the current navigation.
|
|
54
54
|
* Called from Link's click handler. Does NOT call setIsPending —
|
|
55
|
-
* that happens
|
|
56
|
-
* activateLinkPending().
|
|
55
|
+
* that happens via activateLinkPending() called from NavigationRoot.
|
|
57
56
|
*
|
|
58
|
-
* Resets the previous link to idle immediately
|
|
59
|
-
*
|
|
57
|
+
* Resets the previous link to idle immediately if a different link
|
|
58
|
+
* is being registered (handles overlapping link clicks).
|
|
60
59
|
*/
|
|
61
60
|
export function setLinkForCurrentNavigation(link: LinkPendingInstance | null): void {
|
|
62
61
|
const store = getStore();
|
|
62
|
+
if (store.current && store.current !== link) {
|
|
63
|
+
store.current.setIsPending(LINK_IDLE);
|
|
64
|
+
}
|
|
63
65
|
store.current = link;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
/**
|
|
67
69
|
* Activate pending state on the current link instance.
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
+
* Called as an urgent update (outside startTransition) so the link
|
|
71
|
+
* shows pending immediately. The corresponding resetLinkPending()
|
|
72
|
+
* is called inside the transition so it commits atomically with
|
|
73
|
+
* the new tree.
|
|
70
74
|
*/
|
|
71
75
|
export function activateLinkPending(): void {
|
|
72
76
|
const store = getStore();
|
|
@@ -74,9 +78,10 @@ export function activateLinkPending(): void {
|
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
/**
|
|
77
|
-
* Reset the current link's pending state
|
|
78
|
-
*
|
|
79
|
-
*
|
|
81
|
+
* Reset the current link's pending state and clear the store.
|
|
82
|
+
* Call inside startTransition so the reset commits atomically
|
|
83
|
+
* with the new tree (no frame where pending=false but old content
|
|
84
|
+
* is still visible).
|
|
80
85
|
*/
|
|
81
86
|
export function resetLinkPending(): void {
|
|
82
87
|
const store = getStore();
|
package/src/client/link.tsx
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// - searchParams and inline query string are mutually exclusive
|
|
20
20
|
|
|
21
21
|
import {
|
|
22
|
-
|
|
22
|
+
useState,
|
|
23
23
|
useEffect,
|
|
24
24
|
useRef,
|
|
25
25
|
type AnchorHTMLAttributes,
|
|
@@ -501,16 +501,22 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
|
|
|
501
501
|
} = props as LinkRuntimeProps;
|
|
502
502
|
const { href: baseHref } = buildLinkProps({ href, params: segmentParams, searchParams });
|
|
503
503
|
|
|
504
|
-
// ─── Per-link pending state
|
|
504
|
+
// ─── Per-link pending state ─────────────────────────────────────────
|
|
505
505
|
// Each Link has its own pending state. Only the clicked link's
|
|
506
506
|
// setter is invoked during navigation — zero other links re-render.
|
|
507
507
|
//
|
|
508
|
-
// Link click stores the instance; NavigationRoot activates
|
|
509
|
-
//
|
|
510
|
-
//
|
|
508
|
+
// Link click stores the instance; NavigationRoot activates as an
|
|
509
|
+
// urgent update (outside startTransition) so the pending state
|
|
510
|
+
// shows immediately. resetLinkPending() inside the transition
|
|
511
|
+
// commits atomically with the new tree.
|
|
512
|
+
//
|
|
513
|
+
// Uses useState (not useOptimistic) to avoid TIM-964: useOptimistic
|
|
514
|
+
// setters inside async startTransition + await decodePromise
|
|
515
|
+
// trigger "Rendered more hooks" when two navigations overlap
|
|
516
|
+
// under the Navigation API.
|
|
511
517
|
//
|
|
512
518
|
// See design/19-client-navigation.md §"Per-Link Pending State"
|
|
513
|
-
const [linkStatus, setIsPending] =
|
|
519
|
+
const [linkStatus, setIsPending] = useState(LINK_IDLE);
|
|
514
520
|
|
|
515
521
|
// Build the link instance ref for the pending store.
|
|
516
522
|
// The ref is stable across renders — we update the setter on each
|
|
@@ -48,6 +48,7 @@ import React, { createElement, type ReactNode } from 'react';
|
|
|
48
48
|
export interface NavigationState {
|
|
49
49
|
params: Record<string, string | string[]>;
|
|
50
50
|
pathname: string;
|
|
51
|
+
search: string;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
// ---------------------------------------------------------------------------
|
|
@@ -153,7 +154,7 @@ const NAV_STATE_KEY = Symbol.for('__timber_nav_state');
|
|
|
153
154
|
function _getNavStateStore(): { current: NavigationState } {
|
|
154
155
|
const g = globalThis as Record<symbol, unknown>;
|
|
155
156
|
if (!g[NAV_STATE_KEY]) {
|
|
156
|
-
g[NAV_STATE_KEY] = { current: { params: {}, pathname: '/' } };
|
|
157
|
+
g[NAV_STATE_KEY] = { current: { params: {}, pathname: '/', search: '' } };
|
|
157
158
|
}
|
|
158
159
|
return g[NAV_STATE_KEY] as { current: NavigationState };
|
|
159
160
|
}
|