@@ -644,4 +644,261 @@ describe('maybeParameterizeRoute', () => {
644644 expect ( maybeParameterizeRoute ( '/some/random/path' ) ) . toBe ( '/:catchall*' ) ;
645645 } ) ;
646646 } ) ;
647+
648+ describe ( 'i18n routing with optional prefix' , ( ) => {
649+ it ( 'should match routes with optional locale prefix for default locale paths' , ( ) => {
650+ const manifest : RouteManifest = {
651+ staticRoutes : [ { path : '/' } ] ,
652+ dynamicRoutes : [
653+ {
654+ path : '/:locale' ,
655+ regex : '^/([^/]+)$' ,
656+ paramNames : [ 'locale' ] ,
657+ hasOptionalPrefix : true ,
658+ } ,
659+ {
660+ path : '/:locale/foo' ,
661+ regex : '^/([^/]+)/foo$' ,
662+ paramNames : [ 'locale' ] ,
663+ hasOptionalPrefix : true ,
664+ } ,
665+ {
666+ path : '/:locale/bar' ,
667+ regex : '^/([^/]+)/bar$' ,
668+ paramNames : [ 'locale' ] ,
669+ hasOptionalPrefix : true ,
670+ } ,
671+ {
672+ path : '/:locale/products' ,
673+ regex : '^/([^/]+)/products$' ,
674+ paramNames : [ 'locale' ] ,
675+ hasOptionalPrefix : true ,
676+ } ,
677+ ] ,
678+ } ;
679+ globalWithInjectedManifest . _sentryRouteManifest = JSON . stringify ( manifest ) ;
680+
681+ // Default locale paths (without prefix) should match parameterized routes
682+ expect ( maybeParameterizeRoute ( '/foo' ) ) . toBe ( '/:locale/foo' ) ;
683+ expect ( maybeParameterizeRoute ( '/bar' ) ) . toBe ( '/:locale/bar' ) ;
684+ expect ( maybeParameterizeRoute ( '/products' ) ) . toBe ( '/:locale/products' ) ;
685+
686+ // Non-default locale paths (with prefix) should also match
687+ expect ( maybeParameterizeRoute ( '/ar/foo' ) ) . toBe ( '/:locale/foo' ) ;
688+ expect ( maybeParameterizeRoute ( '/ar/bar' ) ) . toBe ( '/:locale/bar' ) ;
689+ expect ( maybeParameterizeRoute ( '/ar/products' ) ) . toBe ( '/:locale/products' ) ;
690+ expect ( maybeParameterizeRoute ( '/en/foo' ) ) . toBe ( '/:locale/foo' ) ;
691+ expect ( maybeParameterizeRoute ( '/fr/products' ) ) . toBe ( '/:locale/products' ) ;
692+ } ) ;
693+
694+ it ( 'should handle nested routes with optional locale prefix' , ( ) => {
695+ const manifest : RouteManifest = {
696+ staticRoutes : [ ] ,
697+ dynamicRoutes : [
698+ {
699+ path : '/:locale/foo/:id' ,
700+ regex : '^/([^/]+)/foo/([^/]+)$' ,
701+ paramNames : [ 'locale' , 'id' ] ,
702+ hasOptionalPrefix : true ,
703+ } ,
704+ {
705+ path : '/:locale/products/:productId' ,
706+ regex : '^/([^/]+)/products/([^/]+)$' ,
707+ paramNames : [ 'locale' , 'productId' ] ,
708+ hasOptionalPrefix : true ,
709+ } ,
710+ ] ,
711+ } ;
712+ globalWithInjectedManifest . _sentryRouteManifest = JSON . stringify ( manifest ) ;
713+
714+ // Default locale (no prefix)
715+ expect ( maybeParameterizeRoute ( '/foo/123' ) ) . toBe ( '/:locale/foo/:id' ) ;
716+ expect ( maybeParameterizeRoute ( '/products/abc' ) ) . toBe ( '/:locale/products/:productId' ) ;
717+
718+ // Non-default locale (with prefix)
719+ expect ( maybeParameterizeRoute ( '/ar/foo/123' ) ) . toBe ( '/:locale/foo/:id' ) ;
720+ expect ( maybeParameterizeRoute ( '/ar/products/abc' ) ) . toBe ( '/:locale/products/:productId' ) ;
721+ expect ( maybeParameterizeRoute ( '/en/foo/456' ) ) . toBe ( '/:locale/foo/:id' ) ;
722+ } ) ;
723+
724+ it ( 'should prioritize direct matches over optional prefix matches' , ( ) => {
725+ const manifest : RouteManifest = {
726+ staticRoutes : [ ] ,
727+ dynamicRoutes : [
728+ {
729+ path : '/foo/:id' ,
730+ regex : '^/foo/([^/]+)$' ,
731+ paramNames : [ 'id' ] ,
732+ } ,
733+ {
734+ path : '/:locale/foo' ,
735+ regex : '^/([^/]+)/foo$' ,
736+ paramNames : [ 'locale' ] ,
737+ hasOptionalPrefix : true ,
738+ } ,
739+ ] ,
740+ } ;
741+ globalWithInjectedManifest . _sentryRouteManifest = JSON . stringify ( manifest ) ;
742+
743+ // Direct match should win
744+ expect ( maybeParameterizeRoute ( '/foo/123' ) ) . toBe ( '/foo/:id' ) ;
745+
746+ // Optional prefix match when direct match isn't available
747+ expect ( maybeParameterizeRoute ( '/foo' ) ) . toBe ( '/:locale/foo' ) ;
748+ expect ( maybeParameterizeRoute ( '/ar/foo' ) ) . toBe ( '/:locale/foo' ) ;
749+ } ) ;
750+
751+ it ( 'should handle lang and language parameters as optional prefixes' , ( ) => {
752+ const manifestWithLang : RouteManifest = {
753+ staticRoutes : [ ] ,
754+ dynamicRoutes : [
755+ {
756+ path : '/:lang/page' ,
757+ regex : '^/([^/]+)/page$' ,
758+ paramNames : [ 'lang' ] ,
759+ hasOptionalPrefix : true ,
760+ } ,
761+ ] ,
762+ } ;
763+ globalWithInjectedManifest . _sentryRouteManifest = JSON . stringify ( manifestWithLang ) ;
764+ expect ( maybeParameterizeRoute ( '/page' ) ) . toBe ( '/:lang/page' ) ;
765+ expect ( maybeParameterizeRoute ( '/en/page' ) ) . toBe ( '/:lang/page' ) ;
766+
767+ const manifestWithLanguage : RouteManifest = {
768+ staticRoutes : [ ] ,
769+ dynamicRoutes : [
770+ {
771+ path : '/:language/page' ,
772+ regex : '^/([^/]+)/page$' ,
773+ paramNames : [ 'language' ] ,
774+ hasOptionalPrefix : true ,
775+ } ,
776+ ] ,
777+ } ;
778+ globalWithInjectedManifest . _sentryRouteManifest = JSON . stringify ( manifestWithLanguage ) ;
779+ expect ( maybeParameterizeRoute ( '/page' ) ) . toBe ( '/:language/page' ) ;
780+ expect ( maybeParameterizeRoute ( '/en/page' ) ) . toBe ( '/:language/page' ) ;
781+ } ) ;
782+
783+ it ( 'should not apply optional prefix logic to non-i18n dynamic segments' , ( ) => {
784+ const manifest : RouteManifest = {
785+ staticRoutes : [ ] ,
786+ dynamicRoutes : [
787+ {
788+ path : '/:userId/profile' ,
789+ regex : '^/([^/]+)/profile$' ,
790+ paramNames : [ 'userId' ] ,
791+ hasOptionalPrefix : false ,
792+ } ,
793+ ] ,
794+ } ;
795+ globalWithInjectedManifest . _sentryRouteManifest = JSON . stringify ( manifest ) ;
796+
797+ // Should not match without the userId segment
798+ expect ( maybeParameterizeRoute ( '/profile' ) ) . toBeUndefined ( ) ;
799+
800+ // Should match with the userId segment
801+ expect ( maybeParameterizeRoute ( '/123/profile' ) ) . toBe ( '/:userId/profile' ) ;
802+ } ) ;
803+
804+ it ( 'should handle real-world next-intl scenario' , ( ) => {
805+ const manifest : RouteManifest = {
806+ staticRoutes : [ { path : '/' } ] ,
807+ dynamicRoutes : [
808+ {
809+ path : '/:locale' ,
810+ regex : '^/([^/]+)$' ,
811+ paramNames : [ 'locale' ] ,
812+ hasOptionalPrefix : true ,
813+ } ,
814+ {
815+ path : '/:locale/hola' ,
816+ regex : '^/([^/]+)/hola$' ,
817+ paramNames : [ 'locale' ] ,
818+ hasOptionalPrefix : true ,
819+ } ,
820+ {
821+ path : '/:locale/products' ,
822+ regex : '^/([^/]+)/products$' ,
823+ paramNames : [ 'locale' ] ,
824+ hasOptionalPrefix : true ,
825+ } ,
826+ ] ,
827+ } ;
828+ globalWithInjectedManifest . _sentryRouteManifest = JSON . stringify ( manifest ) ;
829+
830+ // Root should not be parameterized (it's a static route)
831+ expect ( maybeParameterizeRoute ( '/' ) ) . toBeUndefined ( ) ;
832+
833+ // Default locale (English, no prefix) - this was the bug
834+ expect ( maybeParameterizeRoute ( '/hola' ) ) . toBe ( '/:locale/hola' ) ;
835+ expect ( maybeParameterizeRoute ( '/products' ) ) . toBe ( '/:locale/products' ) ;
836+
837+ // Non-default locale (Arabic, with prefix)
838+ expect ( maybeParameterizeRoute ( '/ar' ) ) . toBe ( '/:locale' ) ;
839+ expect ( maybeParameterizeRoute ( '/ar/hola' ) ) . toBe ( '/:locale/hola' ) ;
840+ expect ( maybeParameterizeRoute ( '/ar/products' ) ) . toBe ( '/:locale/products' ) ;
841+
842+ // Other locales
843+ expect ( maybeParameterizeRoute ( '/en/hola' ) ) . toBe ( '/:locale/hola' ) ;
844+ expect ( maybeParameterizeRoute ( '/fr/products' ) ) . toBe ( '/:locale/products' ) ;
845+ } ) ;
846+
847+ it ( 'should prefer more specific routes over optional prefix matches' , ( ) => {
848+ const manifest : RouteManifest = {
849+ staticRoutes : [ ] ,
850+ dynamicRoutes : [
851+ {
852+ path : '/:locale' ,
853+ regex : '^/([^/]+)$' ,
854+ paramNames : [ 'locale' ] ,
855+ hasOptionalPrefix : true ,
856+ } ,
857+ {
858+ path : '/:locale/foo/:id' ,
859+ regex : '^/([^/]+)/foo/([^/]+)$' ,
860+ paramNames : [ 'locale' , 'id' ] ,
861+ hasOptionalPrefix : true ,
862+ } ,
863+ {
864+ path : '/:locale/foo' ,
865+ regex : '^/([^/]+)/foo$' ,
866+ paramNames : [ 'locale' ] ,
867+ hasOptionalPrefix : true ,
868+ } ,
869+ ] ,
870+ } ;
871+ globalWithInjectedManifest . _sentryRouteManifest = JSON . stringify ( manifest ) ;
872+
873+ // More specific route should win (specificity score)
874+ expect ( maybeParameterizeRoute ( '/foo/123' ) ) . toBe ( '/:locale/foo/:id' ) ;
875+ expect ( maybeParameterizeRoute ( '/foo' ) ) . toBe ( '/:locale/foo' ) ;
876+ expect ( maybeParameterizeRoute ( '/about' ) ) . toBe ( '/:locale' ) ;
877+ } ) ;
878+
879+ it ( 'should handle deeply nested i18n routes' , ( ) => {
880+ const manifest : RouteManifest = {
881+ staticRoutes : [ ] ,
882+ dynamicRoutes : [
883+ {
884+ path : '/:locale/users/:userId/posts/:postId/comments/:commentId' ,
885+ regex : '^/([^/]+)/users/([^/]+)/posts/([^/]+)/comments/([^/]+)$' ,
886+ paramNames : [ 'locale' , 'userId' , 'postId' , 'commentId' ] ,
887+ hasOptionalPrefix : true ,
888+ } ,
889+ ] ,
890+ } ;
891+ globalWithInjectedManifest . _sentryRouteManifest = JSON . stringify ( manifest ) ;
892+
893+ // Without locale prefix (default locale)
894+ expect ( maybeParameterizeRoute ( '/users/123/posts/456/comments/789' ) ) . toBe (
895+ '/:locale/users/:userId/posts/:postId/comments/:commentId' ,
896+ ) ;
897+
898+ // With locale prefix
899+ expect ( maybeParameterizeRoute ( '/ar/users/123/posts/456/comments/789' ) ) . toBe (
900+ '/:locale/users/:userId/posts/:postId/comments/:commentId' ,
901+ ) ;
902+ } ) ;
903+ } ) ;
647904} ) ;
0 commit comments