@tanstack/router-core 1.127.8 → 1.128.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/router-core",
3
- "version": "1.127.8",
3
+ "version": "1.128.3",
4
4
  "description": "Modern and scalable routing for React applications",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
package/src/path.ts CHANGED
@@ -3,9 +3,8 @@ import type { MatchLocation } from './RouterProvider'
3
3
  import type { AnyPathParams } from './route'
4
4
 
5
5
  export interface Segment {
6
- type: 'pathname' | 'param' | 'wildcard'
6
+ type: 'pathname' | 'param' | 'wildcard' | 'optional-param'
7
7
  value: string
8
- // Add a new property to store the static segment if present
9
8
  prefixSegment?: string
10
9
  suffixSegment?: string
11
10
  }
@@ -151,6 +150,18 @@ export function resolvePath({
151
150
  return `{$${param}}${segment.suffixSegment}`
152
151
  }
153
152
  }
153
+ if (segment.type === 'optional-param') {
154
+ const param = segment.value.substring(1)
155
+ if (segment.prefixSegment && segment.suffixSegment) {
156
+ return `${segment.prefixSegment}{-$${param}}${segment.suffixSegment}`
157
+ } else if (segment.prefixSegment) {
158
+ return `${segment.prefixSegment}{-$${param}}`
159
+ } else if (segment.suffixSegment) {
160
+ return `{-$${param}}${segment.suffixSegment}`
161
+ }
162
+ return `{-$${param}}`
163
+ }
164
+
154
165
  if (segment.type === 'wildcard') {
155
166
  if (segment.prefixSegment && segment.suffixSegment) {
156
167
  return `${segment.prefixSegment}{$}${segment.suffixSegment}`
@@ -168,6 +179,9 @@ export function resolvePath({
168
179
 
169
180
  const PARAM_RE = /^\$.{1,}$/ // $paramName
170
181
  const PARAM_W_CURLY_BRACES_RE = /^(.*?)\{(\$[a-zA-Z_$][a-zA-Z0-9_$]*)\}(.*)$/ // prefix{$paramName}suffix
182
+ const OPTIONAL_PARAM_W_CURLY_BRACES_RE =
183
+ /^(.*?)\{-(\$[a-zA-Z_$][a-zA-Z0-9_$]*)\}(.*)$/ // prefix{-$paramName}suffix
184
+
171
185
  const WILDCARD_RE = /^\$$/ // $
172
186
  const WILDCARD_W_CURLY_BRACES_RE = /^(.*?)\{\$\}(.*)$/ // prefix{$}suffix
173
187
 
@@ -177,8 +191,10 @@ const WILDCARD_W_CURLY_BRACES_RE = /^(.*?)\{\$\}(.*)$/ // prefix{$}suffix
177
191
  * Wildcard: `/foo/$` ✅
178
192
  * Wildcard with Prefix and Suffix: `/foo/prefix{$}suffix` ✅
179
193
  *
194
+ * Optional param: `/foo/{-$bar}`
195
+ * Optional param with Prefix and Suffix: `/foo/prefix{-$bar}suffix`
196
+
180
197
  * Future:
181
- * Optional: `/foo/{-bar}`
182
198
  * Optional named segment: `/foo/{bar}`
183
199
  * Optional named segment with Prefix and Suffix: `/foo/prefix{-bar}suffix`
184
200
  * Escape special characters:
@@ -225,6 +241,22 @@ export function parsePathname(pathname?: string): Array<Segment> {
225
241
  }
226
242
  }
227
243
 
244
+ // Check for optional parameter format: prefix{-$paramName}suffix
245
+ const optionalParamBracesMatch = part.match(
246
+ OPTIONAL_PARAM_W_CURLY_BRACES_RE,
247
+ )
248
+ if (optionalParamBracesMatch) {
249
+ const prefix = optionalParamBracesMatch[1]
250
+ const paramName = optionalParamBracesMatch[2]!
251
+ const suffix = optionalParamBracesMatch[3]
252
+ return {
253
+ type: 'optional-param',
254
+ value: paramName, // Now just $paramName (no prefix)
255
+ prefixSegment: prefix || undefined,
256
+ suffixSegment: suffix || undefined,
257
+ }
258
+ }
259
+
228
260
  // Check for the new parameter format: prefix{$paramName}suffix
229
261
  const paramBracesMatch = part.match(PARAM_W_CURLY_BRACES_RE)
230
262
  if (paramBracesMatch) {
@@ -368,6 +400,31 @@ export function interpolatePath({
368
400
  return `${segmentPrefix}${encodeParam(key) ?? 'undefined'}${segmentSuffix}`
369
401
  }
370
402
 
403
+ if (segment.type === 'optional-param') {
404
+ const key = segment.value.substring(1)
405
+
406
+ const segmentPrefix = segment.prefixSegment || ''
407
+ const segmentSuffix = segment.suffixSegment || ''
408
+
409
+ // Check if optional parameter is missing or undefined
410
+ if (!(key in params) || params[key] == null) {
411
+ // For optional params with prefix/suffix, keep the prefix/suffix but omit the param
412
+ if (segmentPrefix || segmentSuffix) {
413
+ return `${segmentPrefix}${segmentSuffix}`
414
+ }
415
+ // If no prefix/suffix, omit the entire segment
416
+ return undefined
417
+ }
418
+
419
+ usedParams[key] = params[key]
420
+
421
+ if (leaveParams) {
422
+ const value = encodeParam(segment.value)
423
+ return `${segmentPrefix}${segment.value}${value ?? ''}${segmentSuffix}`
424
+ }
425
+ return `${segmentPrefix}${encodeParam(key) ?? ''}${segmentSuffix}`
426
+ }
427
+
371
428
  return segment.value
372
429
  }),
373
430
  )
@@ -479,21 +536,23 @@ export function matchByPath(
479
536
  const params: Record<string, string> = {}
480
537
 
481
538
  const isMatch = (() => {
482
- for (
483
- let i = 0;
484
- i < Math.max(baseSegments.length, routeSegments.length);
485
- i++
539
+ let baseIndex = 0
540
+ let routeIndex = 0
541
+
542
+ while (
543
+ baseIndex < baseSegments.length ||
544
+ routeIndex < routeSegments.length
486
545
  ) {
487
- const baseSegment = baseSegments[i]
488
- const routeSegment = routeSegments[i]
546
+ const baseSegment = baseSegments[baseIndex]
547
+ const routeSegment = routeSegments[routeIndex]
489
548
 
490
- const isLastBaseSegment = i >= baseSegments.length - 1
491
- const isLastRouteSegment = i >= routeSegments.length - 1
549
+ const isLastBaseSegment = baseIndex >= baseSegments.length - 1
550
+ const isLastRouteSegment = routeIndex >= routeSegments.length - 1
492
551
 
493
552
  if (routeSegment) {
494
553
  if (routeSegment.type === 'wildcard') {
495
554
  // Capture all remaining segments for a wildcard
496
- const remainingBaseSegments = baseSegments.slice(i)
555
+ const remainingBaseSegments = baseSegments.slice(baseIndex)
497
556
 
498
557
  let _splat: string
499
558
 
@@ -551,7 +610,8 @@ export function matchByPath(
551
610
 
552
611
  if (routeSegment.type === 'pathname') {
553
612
  if (routeSegment.value === '/' && !baseSegment?.value) {
554
- return true
613
+ routeIndex++
614
+ continue
555
615
  }
556
616
 
557
617
  if (baseSegment) {
@@ -565,19 +625,25 @@ export function matchByPath(
565
625
  ) {
566
626
  return false
567
627
  }
628
+ baseIndex++
629
+ routeIndex++
630
+ continue
631
+ } else {
632
+ return false
568
633
  }
569
634
  }
570
635
 
571
- if (!baseSegment) {
572
- return false
573
- }
574
-
575
636
  if (routeSegment.type === 'param') {
637
+ if (!baseSegment) {
638
+ return false
639
+ }
640
+
576
641
  if (baseSegment.value === '/') {
577
642
  return false
578
643
  }
579
644
 
580
- let _paramValue: string
645
+ let _paramValue = ''
646
+ let matched = false
581
647
 
582
648
  // If this param has prefix/suffix, we need to extract the actual parameter value
583
649
  if (routeSegment.prefixSegment || routeSegment.suffixSegment) {
@@ -605,19 +671,143 @@ export function matchByPath(
605
671
  }
606
672
 
607
673
  _paramValue = decodeURIComponent(paramValue)
674
+ matched = true
608
675
  } else {
609
676
  // If no prefix/suffix, just decode the base segment value
610
677
  _paramValue = decodeURIComponent(baseSegment.value)
678
+ matched = true
679
+ }
680
+
681
+ if (matched) {
682
+ params[routeSegment.value.substring(1)] = _paramValue
683
+ baseIndex++
611
684
  }
612
685
 
613
- params[routeSegment.value.substring(1)] = _paramValue
686
+ routeIndex++
687
+ continue
688
+ }
689
+
690
+ if (routeSegment.type === 'optional-param') {
691
+ // Optional parameters can be missing - don't fail the match
692
+ if (!baseSegment) {
693
+ // No base segment for optional param - skip this route segment
694
+ routeIndex++
695
+ continue
696
+ }
697
+
698
+ if (baseSegment.value === '/') {
699
+ // Skip slash segments for optional params
700
+ routeIndex++
701
+ continue
702
+ }
703
+
704
+ let _paramValue = ''
705
+ let matched = false
706
+
707
+ // If this optional param has prefix/suffix, we need to extract the actual parameter value
708
+ if (routeSegment.prefixSegment || routeSegment.suffixSegment) {
709
+ const prefix = routeSegment.prefixSegment || ''
710
+ const suffix = routeSegment.suffixSegment || ''
711
+
712
+ // Check if the base segment starts with prefix and ends with suffix
713
+ const baseValue = baseSegment.value
714
+ if (
715
+ (!prefix || baseValue.startsWith(prefix)) &&
716
+ (!suffix || baseValue.endsWith(suffix))
717
+ ) {
718
+ let paramValue = baseValue
719
+ if (prefix && paramValue.startsWith(prefix)) {
720
+ paramValue = paramValue.slice(prefix.length)
721
+ }
722
+ if (suffix && paramValue.endsWith(suffix)) {
723
+ paramValue = paramValue.slice(
724
+ 0,
725
+ paramValue.length - suffix.length,
726
+ )
727
+ }
728
+
729
+ _paramValue = decodeURIComponent(paramValue)
730
+ matched = true
731
+ }
732
+ } else {
733
+ // For optional params without prefix/suffix, we need to check if the current
734
+ // base segment should match this optional param or a later route segment
735
+
736
+ // Look ahead to see if there's a later route segment that matches the current base segment
737
+ let shouldMatchOptional = true
738
+ for (
739
+ let lookAhead = routeIndex + 1;
740
+ lookAhead < routeSegments.length;
741
+ lookAhead++
742
+ ) {
743
+ const futureRouteSegment = routeSegments[lookAhead]
744
+ if (
745
+ futureRouteSegment?.type === 'pathname' &&
746
+ futureRouteSegment.value === baseSegment.value
747
+ ) {
748
+ // The current base segment matches a future pathname segment,
749
+ // so we should skip this optional parameter
750
+ shouldMatchOptional = false
751
+ break
752
+ }
753
+
754
+ // If we encounter a required param or wildcard, stop looking ahead
755
+ if (
756
+ futureRouteSegment?.type === 'param' ||
757
+ futureRouteSegment?.type === 'wildcard'
758
+ ) {
759
+ break
760
+ }
761
+ }
762
+
763
+ if (shouldMatchOptional) {
764
+ // If no prefix/suffix, just decode the base segment value
765
+ _paramValue = decodeURIComponent(baseSegment.value)
766
+ matched = true
767
+ }
768
+ }
769
+
770
+ if (matched) {
771
+ params[routeSegment.value.substring(1)] = _paramValue
772
+ baseIndex++
773
+ }
774
+
775
+ routeIndex++
776
+ continue
614
777
  }
615
778
  }
616
779
 
617
780
  if (!isLastBaseSegment && isLastRouteSegment) {
618
- params['**'] = joinPaths(baseSegments.slice(i + 1).map((d) => d.value))
781
+ params['**'] = joinPaths(
782
+ baseSegments.slice(baseIndex + 1).map((d) => d.value),
783
+ )
619
784
  return !!matchLocation.fuzzy && routeSegment?.value !== '/'
620
785
  }
786
+
787
+ // If we have base segments left but no route segments, it's not a match
788
+ if (
789
+ baseIndex < baseSegments.length &&
790
+ routeIndex >= routeSegments.length
791
+ ) {
792
+ return false
793
+ }
794
+
795
+ // If we have route segments left but no base segments, check if remaining are optional
796
+ if (
797
+ routeIndex < routeSegments.length &&
798
+ baseIndex >= baseSegments.length
799
+ ) {
800
+ // Check if all remaining route segments are optional
801
+ for (let i = routeIndex; i < routeSegments.length; i++) {
802
+ if (routeSegments[i]?.type !== 'optional-param') {
803
+ return false
804
+ }
805
+ }
806
+ // All remaining are optional, so we can finish
807
+ break
808
+ }
809
+
810
+ break
621
811
  }
622
812
 
623
813
  return true
package/src/router.ts CHANGED
@@ -1450,15 +1450,23 @@ export class RouterCore<
1450
1450
 
1451
1451
  // Resolve the next params
1452
1452
  let nextParams =
1453
- (dest.params ?? true) === true
1454
- ? fromParams
1455
- : {
1456
- ...fromParams,
1457
- ...functionalUpdate(dest.params as any, fromParams),
1458
- }
1453
+ dest.params === false || dest.params === null
1454
+ ? {}
1455
+ : (dest.params ?? true) === true
1456
+ ? fromParams
1457
+ : {
1458
+ ...fromParams,
1459
+ ...functionalUpdate(dest.params as any, fromParams),
1460
+ }
1461
+
1462
+ // Interpolate the path first to get the actual resolved path, then match against that
1463
+ const interpolatedNextTo = interpolatePath({
1464
+ path: nextTo,
1465
+ params: nextParams ?? {},
1466
+ }).interpolatedPath
1459
1467
 
1460
1468
  const destRoutes = this.matchRoutes(
1461
- nextTo,
1469
+ interpolatedNextTo,
1462
1470
  {},
1463
1471
  {
1464
1472
  _buildLocation: true,
@@ -1479,8 +1487,9 @@ export class RouterCore<
1479
1487
  })
1480
1488
  }
1481
1489
 
1482
- // Interpolate the next to into the next pathname
1483
1490
  const nextPathname = interpolatePath({
1491
+ // Use the original template path for interpolation
1492
+ // This preserves the original parameter syntax including optional parameters
1484
1493
  path: nextTo,
1485
1494
  params: nextParams ?? {},
1486
1495
  leaveWildcards: false,
@@ -1785,7 +1794,21 @@ export class RouterCore<
1785
1794
  state: true,
1786
1795
  _includeValidateSearch: true,
1787
1796
  })
1788
- if (trimPath(this.latestLocation.href) !== trimPath(nextLocation.href)) {
1797
+
1798
+ // Normalize URLs for comparison to handle encoding differences
1799
+ // Browser history always stores encoded URLs while buildLocation may produce decoded URLs
1800
+ const normalizeUrl = (url: string) => {
1801
+ try {
1802
+ return encodeURI(decodeURI(url))
1803
+ } catch {
1804
+ return url
1805
+ }
1806
+ }
1807
+
1808
+ if (
1809
+ trimPath(normalizeUrl(this.latestLocation.href)) !==
1810
+ trimPath(normalizeUrl(nextLocation.href))
1811
+ ) {
1789
1812
  throw redirect({ href: nextLocation.href })
1790
1813
  }
1791
1814
  }
@@ -1796,6 +1819,7 @@ export class RouterCore<
1796
1819
  this.__store.setState((s) => ({
1797
1820
  ...s,
1798
1821
  status: 'pending',
1822
+ statusCode: 200,
1799
1823
  isLoading: true,
1800
1824
  location: this.latestLocation,
1801
1825
  pendingMatches,
@@ -3225,43 +3249,51 @@ export function processRouteTree<TRouteLike extends RouteLike>({
3225
3249
  return 0.75
3226
3250
  }
3227
3251
 
3228
- if (
3229
- segment.type === 'param' &&
3230
- segment.prefixSegment &&
3231
- segment.suffixSegment
3232
- ) {
3233
- return 0.55
3234
- }
3252
+ if (segment.type === 'param') {
3253
+ if (segment.prefixSegment && segment.suffixSegment) {
3254
+ return 0.55
3255
+ }
3235
3256
 
3236
- if (segment.type === 'param' && segment.prefixSegment) {
3237
- return 0.52
3238
- }
3257
+ if (segment.prefixSegment) {
3258
+ return 0.52
3259
+ }
3239
3260
 
3240
- if (segment.type === 'param' && segment.suffixSegment) {
3241
- return 0.51
3242
- }
3261
+ if (segment.suffixSegment) {
3262
+ return 0.51
3263
+ }
3243
3264
 
3244
- if (segment.type === 'param') {
3245
3265
  return 0.5
3246
3266
  }
3247
3267
 
3248
- if (
3249
- segment.type === 'wildcard' &&
3250
- segment.prefixSegment &&
3251
- segment.suffixSegment
3252
- ) {
3253
- return 0.3
3254
- }
3268
+ if (segment.type === 'optional-param') {
3269
+ if (segment.prefixSegment && segment.suffixSegment) {
3270
+ return 0.45
3271
+ }
3255
3272
 
3256
- if (segment.type === 'wildcard' && segment.prefixSegment) {
3257
- return 0.27
3258
- }
3273
+ if (segment.prefixSegment) {
3274
+ return 0.42
3275
+ }
3259
3276
 
3260
- if (segment.type === 'wildcard' && segment.suffixSegment) {
3261
- return 0.26
3277
+ if (segment.suffixSegment) {
3278
+ return 0.41
3279
+ }
3280
+
3281
+ return 0.4
3262
3282
  }
3263
3283
 
3264
3284
  if (segment.type === 'wildcard') {
3285
+ if (segment.prefixSegment && segment.suffixSegment) {
3286
+ return 0.3
3287
+ }
3288
+
3289
+ if (segment.prefixSegment) {
3290
+ return 0.27
3291
+ }
3292
+
3293
+ if (segment.suffixSegment) {
3294
+ return 0.26
3295
+ }
3296
+
3265
3297
  return 0.25
3266
3298
  }
3267
3299
 
@@ -3275,19 +3307,33 @@ export function processRouteTree<TRouteLike extends RouteLike>({
3275
3307
  .sort((a, b) => {
3276
3308
  const minLength = Math.min(a.scores.length, b.scores.length)
3277
3309
 
3278
- // Sort by min available score
3310
+ // Sort by segment-by-segment score comparison ONLY for the common prefix
3279
3311
  for (let i = 0; i < minLength; i++) {
3280
3312
  if (a.scores[i] !== b.scores[i]) {
3281
3313
  return b.scores[i]! - a.scores[i]!
3282
3314
  }
3283
3315
  }
3284
3316
 
3285
- // Sort by length of score
3317
+ // If all common segments have equal scores, then consider length and specificity
3286
3318
  if (a.scores.length !== b.scores.length) {
3319
+ // Count optional parameters in each route
3320
+ const aOptionalCount = a.parsed.filter(
3321
+ (seg) => seg.type === 'optional-param',
3322
+ ).length
3323
+ const bOptionalCount = b.parsed.filter(
3324
+ (seg) => seg.type === 'optional-param',
3325
+ ).length
3326
+
3327
+ // If different number of optional parameters, fewer optional parameters wins (more specific)
3328
+ if (aOptionalCount !== bOptionalCount) {
3329
+ return aOptionalCount - bOptionalCount
3330
+ }
3331
+
3332
+ // If same number of optional parameters, longer path wins (for static segments)
3287
3333
  return b.scores.length - a.scores.length
3288
3334
  }
3289
3335
 
3290
- // Sort by min available parsed value
3336
+ // Sort by min available parsed value for alphabetical ordering
3291
3337
  for (let i = 0; i < minLength; i++) {
3292
3338
  if (a.parsed[i]!.value !== b.parsed[i]!.value) {
3293
3339
  return a.parsed[i]!.value > b.parsed[i]!.value ? 1 : -1
@@ -3328,7 +3374,7 @@ export function getMatchedRoutes<TRouteLike extends RouteLike>({
3328
3374
  const result = matchPathname(basepath, trimmedPath, {
3329
3375
  to: route.fullPath,
3330
3376
  caseSensitive: route.options?.caseSensitive ?? caseSensitive,
3331
- fuzzy: true,
3377
+ fuzzy: false,
3332
3378
  })
3333
3379
  return result
3334
3380
  }