@ivogt/rsc-router 0.0.0-experimental.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +19 -0
  2. package/package.json +131 -0
  3. package/src/__mocks__/version.ts +6 -0
  4. package/src/__tests__/route-definition.test.ts +63 -0
  5. package/src/browser/event-controller.ts +876 -0
  6. package/src/browser/index.ts +18 -0
  7. package/src/browser/link-interceptor.ts +121 -0
  8. package/src/browser/lru-cache.ts +69 -0
  9. package/src/browser/merge-segment-loaders.ts +126 -0
  10. package/src/browser/navigation-bridge.ts +891 -0
  11. package/src/browser/navigation-client.ts +155 -0
  12. package/src/browser/navigation-store.ts +823 -0
  13. package/src/browser/partial-update.ts +545 -0
  14. package/src/browser/react/Link.tsx +248 -0
  15. package/src/browser/react/NavigationProvider.tsx +228 -0
  16. package/src/browser/react/ScrollRestoration.tsx +94 -0
  17. package/src/browser/react/context.ts +53 -0
  18. package/src/browser/react/index.ts +52 -0
  19. package/src/browser/react/location-state-shared.ts +120 -0
  20. package/src/browser/react/location-state.ts +62 -0
  21. package/src/browser/react/use-action.ts +240 -0
  22. package/src/browser/react/use-client-cache.ts +56 -0
  23. package/src/browser/react/use-handle.ts +178 -0
  24. package/src/browser/react/use-link-status.ts +134 -0
  25. package/src/browser/react/use-navigation.ts +150 -0
  26. package/src/browser/react/use-segments.ts +188 -0
  27. package/src/browser/request-controller.ts +149 -0
  28. package/src/browser/rsc-router.tsx +310 -0
  29. package/src/browser/scroll-restoration.ts +324 -0
  30. package/src/browser/server-action-bridge.ts +747 -0
  31. package/src/browser/shallow.ts +35 -0
  32. package/src/browser/types.ts +443 -0
  33. package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
  34. package/src/cache/__tests__/memory-store.test.ts +484 -0
  35. package/src/cache/cache-scope.ts +565 -0
  36. package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
  37. package/src/cache/cf/cf-cache-store.ts +274 -0
  38. package/src/cache/cf/index.ts +19 -0
  39. package/src/cache/index.ts +52 -0
  40. package/src/cache/memory-segment-store.ts +150 -0
  41. package/src/cache/memory-store.ts +253 -0
  42. package/src/cache/types.ts +366 -0
  43. package/src/client.rsc.tsx +88 -0
  44. package/src/client.tsx +609 -0
  45. package/src/components/DefaultDocument.tsx +20 -0
  46. package/src/default-error-boundary.tsx +88 -0
  47. package/src/deps/browser.ts +8 -0
  48. package/src/deps/html-stream-client.ts +2 -0
  49. package/src/deps/html-stream-server.ts +2 -0
  50. package/src/deps/rsc.ts +10 -0
  51. package/src/deps/ssr.ts +2 -0
  52. package/src/errors.ts +259 -0
  53. package/src/handle.ts +120 -0
  54. package/src/handles/MetaTags.tsx +178 -0
  55. package/src/handles/index.ts +6 -0
  56. package/src/handles/meta.ts +247 -0
  57. package/src/href-client.ts +128 -0
  58. package/src/href.ts +139 -0
  59. package/src/index.rsc.ts +69 -0
  60. package/src/index.ts +84 -0
  61. package/src/loader.rsc.ts +204 -0
  62. package/src/loader.ts +47 -0
  63. package/src/network-error-thrower.tsx +21 -0
  64. package/src/outlet-context.ts +15 -0
  65. package/src/root-error-boundary.tsx +277 -0
  66. package/src/route-content-wrapper.tsx +198 -0
  67. package/src/route-definition.ts +1333 -0
  68. package/src/route-map-builder.ts +140 -0
  69. package/src/route-types.ts +148 -0
  70. package/src/route-utils.ts +89 -0
  71. package/src/router/__tests__/match-context.test.ts +104 -0
  72. package/src/router/__tests__/match-pipelines.test.ts +537 -0
  73. package/src/router/__tests__/match-result.test.ts +566 -0
  74. package/src/router/__tests__/on-error.test.ts +935 -0
  75. package/src/router/__tests__/pattern-matching.test.ts +577 -0
  76. package/src/router/error-handling.ts +287 -0
  77. package/src/router/handler-context.ts +60 -0
  78. package/src/router/loader-resolution.ts +326 -0
  79. package/src/router/manifest.ts +116 -0
  80. package/src/router/match-context.ts +261 -0
  81. package/src/router/match-middleware/background-revalidation.ts +236 -0
  82. package/src/router/match-middleware/cache-lookup.ts +261 -0
  83. package/src/router/match-middleware/cache-store.ts +250 -0
  84. package/src/router/match-middleware/index.ts +81 -0
  85. package/src/router/match-middleware/intercept-resolution.ts +268 -0
  86. package/src/router/match-middleware/segment-resolution.ts +174 -0
  87. package/src/router/match-pipelines.ts +214 -0
  88. package/src/router/match-result.ts +212 -0
  89. package/src/router/metrics.ts +62 -0
  90. package/src/router/middleware.test.ts +1355 -0
  91. package/src/router/middleware.ts +748 -0
  92. package/src/router/pattern-matching.ts +271 -0
  93. package/src/router/revalidation.ts +190 -0
  94. package/src/router/router-context.ts +299 -0
  95. package/src/router/types.ts +96 -0
  96. package/src/router.ts +3484 -0
  97. package/src/rsc/__tests__/helpers.test.ts +175 -0
  98. package/src/rsc/handler.ts +942 -0
  99. package/src/rsc/helpers.ts +64 -0
  100. package/src/rsc/index.ts +56 -0
  101. package/src/rsc/nonce.ts +18 -0
  102. package/src/rsc/types.ts +225 -0
  103. package/src/segment-system.tsx +405 -0
  104. package/src/server/__tests__/request-context.test.ts +171 -0
  105. package/src/server/context.ts +340 -0
  106. package/src/server/handle-store.ts +230 -0
  107. package/src/server/loader-registry.ts +174 -0
  108. package/src/server/request-context.ts +470 -0
  109. package/src/server/root-layout.tsx +10 -0
  110. package/src/server/tsconfig.json +14 -0
  111. package/src/server.ts +126 -0
  112. package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
  113. package/src/ssr/index.tsx +215 -0
  114. package/src/types.ts +1473 -0
  115. package/src/use-loader.tsx +346 -0
  116. package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
  117. package/src/vite/expose-action-id.ts +344 -0
  118. package/src/vite/expose-handle-id.ts +209 -0
  119. package/src/vite/expose-loader-id.ts +357 -0
  120. package/src/vite/expose-location-state-id.ts +177 -0
  121. package/src/vite/index.ts +608 -0
  122. package/src/vite/version.d.ts +12 -0
  123. package/src/vite/virtual-entries.ts +109 -0
@@ -0,0 +1,566 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { collectSegments, buildMatchResult, collectMatchResult } from "../match-result";
3
+ import type { ResolvedSegment } from "../../types";
4
+ import type { MatchContext, MatchPipelineState } from "../match-context";
5
+ import { createPipelineState } from "../match-context";
6
+
7
+ // Mock metrics module
8
+ vi.mock("../metrics", () => ({
9
+ logMetrics: vi.fn(),
10
+ generateServerTiming: vi.fn(() => "metric1;dur=10"),
11
+ }));
12
+
13
+ // Helper to create async generator from array
14
+ async function* fromArray<T>(items: T[]): AsyncGenerator<T> {
15
+ for (const item of items) {
16
+ yield item;
17
+ }
18
+ }
19
+
20
+ // Helper to create a minimal mock MatchContext
21
+ function createMockContext(overrides: Partial<MatchContext> = {}): MatchContext {
22
+ return {
23
+ request: new Request("https://example.com/test"),
24
+ url: new URL("https://example.com/test"),
25
+ pathname: "/test",
26
+ env: {},
27
+ bindings: {},
28
+ clientSegmentIds: [],
29
+ clientSegmentSet: new Set(),
30
+ stale: false,
31
+ prevUrl: new URL("https://example.com/prev"),
32
+ prevParams: {},
33
+ prevMatch: null,
34
+ matched: { path: "/test", params: {}, entries: [], score: 0 },
35
+ manifestEntry: {} as any,
36
+ entries: [],
37
+ routeKey: "test",
38
+ localRouteName: "test",
39
+ handlerContext: {} as any,
40
+ loaderPromises: new Map(),
41
+ metricsStore: undefined,
42
+ Store: {},
43
+ interceptContextMatch: null,
44
+ interceptSelectorContext: { prevMatched: null, prevParams: {} },
45
+ isSameRouteNavigation: false,
46
+ interceptResult: null,
47
+ cacheScope: null,
48
+ isIntercept: false,
49
+ isAction: false,
50
+ routeMiddleware: [],
51
+ isFullMatch: false,
52
+ ...overrides,
53
+ } as MatchContext;
54
+ }
55
+
56
+ // Helper to create a test segment
57
+ function createSegment(
58
+ id: string,
59
+ options: Partial<ResolvedSegment> = {}
60
+ ): ResolvedSegment {
61
+ return {
62
+ id,
63
+ type: "route",
64
+ component: `Component_${id}`,
65
+ params: {},
66
+ ...options,
67
+ };
68
+ }
69
+
70
+ describe("match-result", () => {
71
+ beforeEach(() => {
72
+ vi.clearAllMocks();
73
+ });
74
+
75
+ describe("collectSegments()", () => {
76
+ it("should collect all segments from generator", async () => {
77
+ const segments = [createSegment("seg1"), createSegment("seg2"), createSegment("seg3")];
78
+
79
+ const result = await collectSegments(fromArray(segments));
80
+
81
+ expect(result).toHaveLength(3);
82
+ expect(result[0].id).toBe("seg1");
83
+ expect(result[1].id).toBe("seg2");
84
+ expect(result[2].id).toBe("seg3");
85
+ });
86
+
87
+ it("should return empty array for empty generator", async () => {
88
+ const result = await collectSegments(fromArray([]));
89
+ expect(result).toEqual([]);
90
+ });
91
+
92
+ it("should preserve segment properties", async () => {
93
+ const segment = createSegment("seg1", {
94
+ type: "layout",
95
+ component: "LayoutComponent",
96
+ loading: "LoadingComponent",
97
+ params: { id: "123" },
98
+ });
99
+
100
+ const result = await collectSegments(fromArray([segment]));
101
+
102
+ expect(result[0]).toEqual(segment);
103
+ });
104
+ });
105
+
106
+ describe("buildMatchResult() - full match", () => {
107
+ it("should include all segments for full match", () => {
108
+ const ctx = createMockContext({ isFullMatch: true });
109
+ const state = createPipelineState();
110
+ const segments = [createSegment("layout"), createSegment("page")];
111
+
112
+ const result = buildMatchResult(segments, ctx, state);
113
+
114
+ expect(result.matched).toEqual(["layout", "page"]);
115
+ expect(result.segments).toEqual(segments);
116
+ expect(result.diff).toEqual(["layout", "page"]);
117
+ });
118
+
119
+ it("should include params from matched route", () => {
120
+ const ctx = createMockContext({
121
+ isFullMatch: true,
122
+ matched: { path: "/users/:id", params: { id: "123" }, entries: [], score: 0 },
123
+ });
124
+ const state = createPipelineState();
125
+ const segments = [createSegment("page")];
126
+
127
+ const result = buildMatchResult(segments, ctx, state);
128
+
129
+ expect(result.params).toEqual({ id: "123" });
130
+ });
131
+ });
132
+
133
+ describe("buildMatchResult() - partial match", () => {
134
+ it("should use matchedIds from state for partial match", () => {
135
+ const ctx = createMockContext({ isFullMatch: false });
136
+ const state = createPipelineState();
137
+ state.matchedIds = ["seg1", "seg2"];
138
+
139
+ const segments = [createSegment("seg1"), createSegment("seg2")];
140
+ const result = buildMatchResult(segments, ctx, state);
141
+
142
+ expect(result.matched).toEqual(["seg1", "seg2"]);
143
+ });
144
+
145
+ it("should filter out segments with null components", () => {
146
+ const ctx = createMockContext({ isFullMatch: false });
147
+ const state = createPipelineState();
148
+ state.matchedIds = ["seg1", "seg2"];
149
+
150
+ const segments = [
151
+ createSegment("seg1", { component: null }),
152
+ createSegment("seg2", { component: "RealComponent" }),
153
+ ];
154
+
155
+ const result = buildMatchResult(segments, ctx, state);
156
+
157
+ expect(result.segments).toHaveLength(1);
158
+ expect(result.segments[0].id).toBe("seg2");
159
+ });
160
+
161
+ it("should include loader segments even with null component", () => {
162
+ const ctx = createMockContext({ isFullMatch: false });
163
+ const state = createPipelineState();
164
+ state.matchedIds = ["seg1"];
165
+
166
+ const segments = [
167
+ createSegment("seg1", { component: null, type: "loader" }),
168
+ ];
169
+
170
+ const result = buildMatchResult(segments, ctx, state);
171
+
172
+ expect(result.segments).toHaveLength(1);
173
+ expect(result.segments[0].type).toBe("loader");
174
+ });
175
+
176
+ it("should include intercept segments in matched array", () => {
177
+ const ctx = createMockContext({ isFullMatch: false });
178
+ const state = createPipelineState();
179
+ state.matchedIds = ["page"];
180
+ state.interceptSegments = [createSegment("modal")];
181
+
182
+ const segments = [createSegment("page")];
183
+ const result = buildMatchResult(segments, ctx, state);
184
+
185
+ expect(result.matched).toEqual(["page", "modal"]);
186
+ });
187
+ });
188
+
189
+ describe("buildMatchResult() - intercept handling", () => {
190
+ it("should use clientSegmentIds when intercepting with client segments", () => {
191
+ const ctx = createMockContext({
192
+ isFullMatch: false,
193
+ clientSegmentIds: ["layout", "page"],
194
+ interceptResult: { route: "modal", slot: "@modal" } as any,
195
+ });
196
+ const state = createPipelineState();
197
+ state.matchedIds = ["layout", "page"];
198
+ state.interceptSegments = [createSegment("modal-content")];
199
+
200
+ const segments = [createSegment("page")];
201
+ const result = buildMatchResult(segments, ctx, state);
202
+
203
+ // Should include client segments + intercept segments
204
+ expect(result.matched).toEqual(["layout", "page", "modal-content"]);
205
+ });
206
+
207
+ it("should use segment IDs when intercepting without client segments (HMR)", () => {
208
+ const ctx = createMockContext({
209
+ isFullMatch: false,
210
+ clientSegmentIds: [],
211
+ interceptResult: { route: "modal", slot: "@modal" } as any,
212
+ });
213
+ const state = createPipelineState();
214
+ state.interceptSegments = [createSegment("modal-content")];
215
+
216
+ const segments = [createSegment("layout"), createSegment("page")];
217
+ const result = buildMatchResult(segments, ctx, state);
218
+
219
+ // Should use actual segment IDs when client sent empty
220
+ expect(result.matched).toEqual(["layout", "page"]);
221
+ });
222
+ });
223
+
224
+ describe("buildMatchResult() - slots", () => {
225
+ it("should include slots when present", () => {
226
+ const ctx = createMockContext({ isFullMatch: true });
227
+ const state = createPipelineState();
228
+ state.slots = {
229
+ "@modal": {
230
+ active: true,
231
+ segments: [createSegment("modal-seg")],
232
+ },
233
+ };
234
+
235
+ const segments = [createSegment("page")];
236
+ const result = buildMatchResult(segments, ctx, state);
237
+
238
+ expect(result.slots).toBeDefined();
239
+ expect(result.slots!["@modal"].active).toBe(true);
240
+ });
241
+
242
+ it("should not include slots when empty", () => {
243
+ const ctx = createMockContext({ isFullMatch: true });
244
+ const state = createPipelineState();
245
+
246
+ const segments = [createSegment("page")];
247
+ const result = buildMatchResult(segments, ctx, state);
248
+
249
+ expect(result.slots).toBeUndefined();
250
+ });
251
+ });
252
+
253
+ describe("buildMatchResult() - route middleware", () => {
254
+ it("should include routeMiddleware when present", () => {
255
+ const ctx = createMockContext({
256
+ isFullMatch: true,
257
+ routeMiddleware: [{ handler: "auth", params: {} }],
258
+ });
259
+ const state = createPipelineState();
260
+
261
+ const segments = [createSegment("page")];
262
+ const result = buildMatchResult(segments, ctx, state);
263
+
264
+ expect(result.routeMiddleware).toHaveLength(1);
265
+ });
266
+
267
+ it("should not include routeMiddleware when empty", () => {
268
+ const ctx = createMockContext({
269
+ isFullMatch: true,
270
+ routeMiddleware: [],
271
+ });
272
+ const state = createPipelineState();
273
+
274
+ const segments = [createSegment("page")];
275
+ const result = buildMatchResult(segments, ctx, state);
276
+
277
+ expect(result.routeMiddleware).toBeUndefined();
278
+ });
279
+ });
280
+
281
+ describe("buildMatchResult() - metrics", () => {
282
+ it("should generate server timing when metricsStore is present", () => {
283
+ const ctx = createMockContext({
284
+ isFullMatch: true,
285
+ metricsStore: {} as any,
286
+ });
287
+ const state = createPipelineState();
288
+
289
+ const segments = [createSegment("page")];
290
+ const result = buildMatchResult(segments, ctx, state);
291
+
292
+ expect(result.serverTiming).toBe("metric1;dur=10");
293
+ });
294
+
295
+ it("should not include server timing without metricsStore", () => {
296
+ const ctx = createMockContext({
297
+ isFullMatch: true,
298
+ metricsStore: undefined,
299
+ });
300
+ const state = createPipelineState();
301
+
302
+ const segments = [createSegment("page")];
303
+ const result = buildMatchResult(segments, ctx, state);
304
+
305
+ expect(result.serverTiming).toBeUndefined();
306
+ });
307
+ });
308
+
309
+ describe("collectMatchResult()", () => {
310
+ it("should collect segments and build result", async () => {
311
+ const ctx = createMockContext({ isFullMatch: true });
312
+ const state = createPipelineState();
313
+ const segments = [createSegment("seg1"), createSegment("seg2")];
314
+
315
+ const result = await collectMatchResult(fromArray(segments), ctx, state);
316
+
317
+ expect(result.matched).toEqual(["seg1", "seg2"]);
318
+ expect(result.segments).toHaveLength(2);
319
+ });
320
+
321
+ it("should update state.segments if not already set", async () => {
322
+ const ctx = createMockContext({ isFullMatch: true });
323
+ const state = createPipelineState();
324
+ const segments = [createSegment("seg1")];
325
+
326
+ await collectMatchResult(fromArray(segments), ctx, state);
327
+
328
+ expect(state.segments).toHaveLength(1);
329
+ expect(state.segments[0].id).toBe("seg1");
330
+ });
331
+
332
+ it("should not overwrite state.segments if already set", async () => {
333
+ const ctx = createMockContext({ isFullMatch: true });
334
+ const state = createPipelineState();
335
+ state.segments = [createSegment("existing")];
336
+
337
+ const segments = [createSegment("new")];
338
+ await collectMatchResult(fromArray(segments), ctx, state);
339
+
340
+ expect(state.segments).toHaveLength(1);
341
+ expect(state.segments[0].id).toBe("existing");
342
+ });
343
+ });
344
+
345
+ describe("edge cases", () => {
346
+ it("should handle large number of segments", async () => {
347
+ const segments = Array.from({ length: 100 }, (_, i) =>
348
+ createSegment(`seg${i}`)
349
+ );
350
+ const result = await collectSegments(fromArray(segments));
351
+
352
+ expect(result).toHaveLength(100);
353
+ expect(result[0].id).toBe("seg0");
354
+ expect(result[99].id).toBe("seg99");
355
+ });
356
+
357
+ it("should handle mixed segment types", () => {
358
+ const ctx = createMockContext({ isFullMatch: true });
359
+ const state = createPipelineState();
360
+ const segments = [
361
+ createSegment("layout", { type: "layout" }),
362
+ createSegment("page", { type: "route" }),
363
+ createSegment("loader1", { type: "loader" }),
364
+ createSegment("parallel", { type: "parallel", slot: "@sidebar" }),
365
+ ];
366
+
367
+ const result = buildMatchResult(segments, ctx, state);
368
+
369
+ expect(result.matched).toEqual(["layout", "page", "loader1", "parallel"]);
370
+ expect(result.segments).toHaveLength(4);
371
+ });
372
+
373
+ it("should handle segments with complex params", () => {
374
+ const ctx = createMockContext({
375
+ isFullMatch: true,
376
+ matched: {
377
+ path: "/users/:userId/posts/:postId",
378
+ params: { userId: "123", postId: "456" },
379
+ entries: [],
380
+ score: 0,
381
+ },
382
+ });
383
+ const state = createPipelineState();
384
+ const segments = [createSegment("page", { params: { userId: "123", postId: "456" } })];
385
+
386
+ const result = buildMatchResult(segments, ctx, state);
387
+
388
+ expect(result.params).toEqual({ userId: "123", postId: "456" });
389
+ });
390
+
391
+ it("should handle multiple intercept segments", () => {
392
+ const ctx = createMockContext({ isFullMatch: false });
393
+ const state = createPipelineState();
394
+ state.matchedIds = ["page"];
395
+ state.interceptSegments = [
396
+ createSegment("modal1"),
397
+ createSegment("modal2"),
398
+ createSegment("modal3"),
399
+ ];
400
+
401
+ const segments = [createSegment("page")];
402
+ const result = buildMatchResult(segments, ctx, state);
403
+
404
+ expect(result.matched).toEqual(["page", "modal1", "modal2", "modal3"]);
405
+ });
406
+
407
+ it("should handle multiple slots", () => {
408
+ const ctx = createMockContext({ isFullMatch: true });
409
+ const state = createPipelineState();
410
+ state.slots = {
411
+ "@modal": {
412
+ active: true,
413
+ segments: [createSegment("modal-content")],
414
+ },
415
+ "@sidebar": {
416
+ active: true,
417
+ segments: [createSegment("sidebar-content")],
418
+ },
419
+ "@drawer": {
420
+ active: false,
421
+ segments: [],
422
+ },
423
+ };
424
+
425
+ const segments = [createSegment("page")];
426
+ const result = buildMatchResult(segments, ctx, state);
427
+
428
+ expect(result.slots).toBeDefined();
429
+ expect(Object.keys(result.slots!)).toHaveLength(3);
430
+ expect(result.slots!["@modal"].active).toBe(true);
431
+ expect(result.slots!["@sidebar"].active).toBe(true);
432
+ expect(result.slots!["@drawer"].active).toBe(false);
433
+ });
434
+
435
+ it("should handle segments with loading components", () => {
436
+ const ctx = createMockContext({ isFullMatch: true });
437
+ const state = createPipelineState();
438
+ const segments = [
439
+ createSegment("page", {
440
+ loading: "LoadingSpinner",
441
+ }),
442
+ ];
443
+
444
+ const result = buildMatchResult(segments, ctx, state);
445
+
446
+ expect(result.segments[0].loading).toBe("LoadingSpinner");
447
+ });
448
+
449
+ it("should handle segments with layout components", () => {
450
+ const ctx = createMockContext({ isFullMatch: true });
451
+ const state = createPipelineState();
452
+ const segments = [
453
+ createSegment("layout", {
454
+ type: "layout",
455
+ layout: "LayoutWrapper",
456
+ }),
457
+ ];
458
+
459
+ const result = buildMatchResult(segments, ctx, state);
460
+
461
+ expect(result.segments[0].layout).toBe("LayoutWrapper");
462
+ });
463
+
464
+ it("should handle partial match with all null components", () => {
465
+ const ctx = createMockContext({ isFullMatch: false });
466
+ const state = createPipelineState();
467
+ state.matchedIds = ["seg1", "seg2"];
468
+
469
+ const segments = [
470
+ createSegment("seg1", { component: null }),
471
+ createSegment("seg2", { component: null }),
472
+ ];
473
+
474
+ const result = buildMatchResult(segments, ctx, state);
475
+
476
+ expect(result.matched).toEqual(["seg1", "seg2"]);
477
+ expect(result.segments).toHaveLength(0); // All filtered out
478
+ expect(result.diff).toEqual([]); // No segments to render
479
+ });
480
+
481
+ it("should handle empty segment list", () => {
482
+ const ctx = createMockContext({ isFullMatch: true });
483
+ const state = createPipelineState();
484
+
485
+ const result = buildMatchResult([], ctx, state);
486
+
487
+ expect(result.matched).toEqual([]);
488
+ expect(result.segments).toEqual([]);
489
+ expect(result.diff).toEqual([]);
490
+ });
491
+
492
+ it("should handle segments with namespace", () => {
493
+ const ctx = createMockContext({ isFullMatch: true });
494
+ const state = createPipelineState();
495
+ const segments = [
496
+ createSegment("intercept:modal:content", {
497
+ namespace: "intercept:modal",
498
+ }),
499
+ ];
500
+
501
+ const result = buildMatchResult(segments, ctx, state);
502
+
503
+ expect(result.segments[0].namespace).toBe("intercept:modal");
504
+ });
505
+
506
+ it("should handle mixed loaders and routes in partial match", () => {
507
+ const ctx = createMockContext({ isFullMatch: false });
508
+ const state = createPipelineState();
509
+ state.matchedIds = ["route1", "route2", "loader1", "loader2"];
510
+
511
+ const segments = [
512
+ createSegment("route1", { component: null, type: "route" }),
513
+ createSegment("route2", { component: "RouteComponent", type: "route" }),
514
+ createSegment("loader1", { component: null, type: "loader" }),
515
+ createSegment("loader2", { component: null, type: "loader" }),
516
+ ];
517
+
518
+ const result = buildMatchResult(segments, ctx, state);
519
+
520
+ // Loaders should be included even with null component
521
+ // Routes with null component should be filtered
522
+ expect(result.segments).toHaveLength(3); // route2, loader1, loader2
523
+ expect(result.segments.map((s) => s.id)).toEqual([
524
+ "route2",
525
+ "loader1",
526
+ "loader2",
527
+ ]);
528
+ });
529
+
530
+ it("should preserve segment index", () => {
531
+ const ctx = createMockContext({ isFullMatch: true });
532
+ const state = createPipelineState();
533
+ const segments = [
534
+ createSegment("seg0", { index: 0 }),
535
+ createSegment("seg1", { index: 1 }),
536
+ createSegment("seg2", { index: 2 }),
537
+ ];
538
+
539
+ const result = buildMatchResult(segments, ctx, state);
540
+
541
+ expect(result.segments[0].index).toBe(0);
542
+ expect(result.segments[1].index).toBe(1);
543
+ expect(result.segments[2].index).toBe(2);
544
+ });
545
+
546
+ it("should handle multiple route middleware", () => {
547
+ const ctx = createMockContext({
548
+ isFullMatch: true,
549
+ routeMiddleware: [
550
+ { handler: "auth", params: {} },
551
+ { handler: "logger", params: { level: "debug" } },
552
+ { handler: "rateLimit", params: { max: "100" } },
553
+ ],
554
+ });
555
+ const state = createPipelineState();
556
+ const segments = [createSegment("page")];
557
+
558
+ const result = buildMatchResult(segments, ctx, state);
559
+
560
+ expect(result.routeMiddleware).toHaveLength(3);
561
+ expect(result.routeMiddleware![0].handler).toBe("auth");
562
+ expect(result.routeMiddleware![1].handler).toBe("logger");
563
+ expect(result.routeMiddleware![2].handler).toBe("rateLimit");
564
+ });
565
+ });
566
+ });