Skip to content

Commit 0960592

Browse files
atscottpkozlowski-opensource
authored andcommitted
fix(router): pass outlet context to split to fix empty path named outlets
The `split` helper function in `packages/router/src/utils/config_matching.ts` was blind to the current outlet being processed. When encountering an empty path named outlet in the config, it would assume it needed to pull it in as a synthetic empty group, even if we were already in the process of resolving that very outlet! When navigating to `/(secondary:component-copy)` with this config: ```typescript { path: '', component: MainLayout, children: [ { path: '', outlet: 'secondary', component: SecondaryComponent, children: [{path: 'component-copy'}] } ] } ``` The router uses `MainLayout` as a pass-through and calls `split` on its children with segments `['component-copy']`. `split` uses the `containsEmptyPathMatchesWithNamedOutlets` helper to determine if there are any candidate empty path named outlets to pull in. Because of this, it sees `{ path: '', outlet: 'secondary' }` and says: "Ah, an empty path named outlet! I must pull it in!" Rather than falling through to standard segment matching, it returns `UrlSegmentGroup(segments: [], children: {secondary: emptyGroup})`. The router then tries to process `primary` (with `[]` segments) and fails because the config only has `secondary`. It also tries to process `secondary` with the `emptyGroup`. While `{ path: '', outlet: 'secondary' }` matches the empty group, its child `{ path: 'component-copy' }` fails to match because the `emptyGroup` has no segments! So both branches fail, resulting in a `NoMatch` error for the entire navigation! Pulling in empty path named outlets IS desired when they act as siblings to segments we are matching. This has worked before and continues to work! ```typescript { path: 'a', children: [ { path: 'b', component: ComponentB }, { path: '', component: ComponentC, outlet: 'aux' } ] } ``` When navigating to `a/b`, `split` sees segments `['b']` and the `aux` empty path. It pulls in `aux` so it gets instantiated alongside `b`. This is correct! If we have a named outlet with a non-empty path under an empty path parent: ```typescript { path: '', component: MainLayout, children: [ { path: 'component-copy', outlet: 'secondary', component: ComponentE } ] } ``` When we navigate to `/(secondary:component-copy)`: - `split` uses `containsEmptyPathMatchesWithNamedOutlets` to see if there are any empty path named outlets. Since it only sees `path: 'component-copy'`, it returns `false`. - It falls through to standard segment matching, which finds `component-copy` in the segments array and activates it flawlessly! This worked perfectly before the fix because it didn't use `containsEmptyPathMatchesWithNamedOutlets`. The fix passes the **current active outlet context** into `split`. If `split` finds an empty path named outlet that matches the outlet we are already processing, it ignores it as a pull-in candidate. When evaluating `MainLayout` children for `secondary`: - URL Segments left to process: `['component-copy']` - Current Outlet: `secondary` - `childConfig`: `[{ path: '', outlet: 'secondary' }]` Previously, `split` saw the empty path and pulled it in as a synthetic empty group, breaking matching. Now, since `getOutlet(r) === outlet` (both are `secondary`), the fix ignores it. Instead of returning empty segments, it **falls through to standard segment matching**, which successfully find the `component-copy` segment! When evaluating `ComponentA` children for `primary`: - URL Segments left to process: `['b']` - Current Outlet: `primary` - `childConfig`: `[{ path: 'b' }, { path: '', outlet: 'aux' }]` Since `getOutlet(aux) !== primary`, the fix **does not ignore it**. `split` pulls in `aux: emptyGroup` as a sibling, instantiating `ComponentC` alongside `ComponentB`. This preserves correct behavior for auxiliary outlets! fixes #67708 (cherry picked from commit daa9b2a)
1 parent d04ddd7 commit 0960592

3 files changed

Lines changed: 85 additions & 4 deletions

File tree

packages/router/src/recognize.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@ export class Recognizer {
454454
consumedSegments,
455455
remainingSegments,
456456
childConfig,
457+
outlet,
457458
);
458459

459460
if (slicedSegments.length === 0 && segmentGroup.hasChildren()) {

packages/router/src/utils/config_matching.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,14 @@ export function split(
126126
consumedSegments: UrlSegment[],
127127
slicedSegments: UrlSegment[],
128128
config: Route[],
129+
outlet?: string,
129130
): {
130131
segmentGroup: UrlSegmentGroup;
131132
slicedSegments: UrlSegment[];
132133
} {
133134
if (
134135
slicedSegments.length > 0 &&
135-
containsEmptyPathMatchesWithNamedOutlets(segmentGroup, slicedSegments, config)
136+
containsEmptyPathMatchesWithNamedOutlets(segmentGroup, slicedSegments, config, outlet)
136137
) {
137138
const s = new UrlSegmentGroup(
138139
consumedSegments,
@@ -195,10 +196,24 @@ function containsEmptyPathMatchesWithNamedOutlets(
195196
segmentGroup: UrlSegmentGroup,
196197
slicedSegments: UrlSegment[],
197198
routes: Route[],
199+
outlet?: string,
198200
): boolean {
199-
return routes.some(
200-
(r) => emptyPathMatch(segmentGroup, slicedSegments, r) && getOutlet(r) !== PRIMARY_OUTLET,
201-
);
201+
return routes.some((r) => {
202+
// 1. Can this route match as an empty path?
203+
const matchesEmpty = emptyPathMatch(segmentGroup, slicedSegments, r);
204+
if (!matchesEmpty) return false;
205+
206+
// 2. Is this a named outlet? (We only pull in empty paths if they are named outlets).
207+
const isNamedOutlet = getOutlet(r) !== PRIMARY_OUTLET;
208+
if (!isNamedOutlet) return false;
209+
210+
// 3. Are we already processing this outlet? If so, we ignore it as a pull-in
211+
// candidate. For example, if we are evaluating the 'secondary' outlet, we shouldn't
212+
// "pull in" an empty 'secondary' group. We should let standard
213+
// segment matching handle it (which looks at the actual characters in the URL).
214+
const isSelfEvaluating = outlet !== undefined && getOutlet(r) === outlet;
215+
return !isSelfEvaluating;
216+
});
202217
}
203218

204219
function containsEmptyPathMatches(

packages/router/test/recognize.spec.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,71 @@ describe('recognize', () => {
578578
await expectAsync(recognizePromise).toBeRejected();
579579
});
580580
});
581+
582+
describe('nested empty paths with outlets (issue 67708)', () => {
583+
it('should match nested primary child regardless of named outlet empty path sibling', async () => {
584+
const config = [
585+
{
586+
path: '',
587+
component: ComponentA,
588+
children: [
589+
{
590+
path: '',
591+
component: ComponentB,
592+
children: [{path: 'component', component: ComponentC}],
593+
},
594+
{
595+
path: '',
596+
outlet: 'secondary',
597+
component: ComponentD,
598+
children: [{path: 'component-copy', component: ComponentE}],
599+
},
600+
],
601+
},
602+
];
603+
604+
const s = await recognize(config, 'component');
605+
checkActivatedRoute(s.root.firstChild!, '', {}, ComponentA);
606+
const c = s.root.firstChild!.children;
607+
// Should find primary child
608+
checkActivatedRoute(c[0], '', {}, ComponentB, PRIMARY_OUTLET);
609+
checkActivatedRoute(c[0].firstChild!, 'component', {}, ComponentC);
610+
});
611+
612+
it('should match named outlet child when navigating to it via secondary URL', async () => {
613+
const config = [
614+
{
615+
path: '',
616+
component: ComponentA,
617+
children: [
618+
{
619+
path: '',
620+
component: ComponentB,
621+
children: [{path: 'component', component: ComponentC}],
622+
},
623+
{
624+
path: '',
625+
outlet: 'secondary',
626+
component: ComponentD,
627+
children: [{path: 'component-copy', component: ComponentA}],
628+
},
629+
],
630+
},
631+
];
632+
633+
const s = await recognize(config, '(secondary:component-copy)');
634+
checkActivatedRoute(s.root.firstChild!, '', {}, ComponentA);
635+
const c = s.root.firstChild!.children;
636+
const primaryRoute = c.find((r: any) => r.outlet === PRIMARY_OUTLET);
637+
expect(primaryRoute).toBeDefined();
638+
checkActivatedRoute(primaryRoute!, '', {}, ComponentB, PRIMARY_OUTLET);
639+
640+
const secondaryRoute = c.find((r: any) => r.outlet === 'secondary');
641+
expect(secondaryRoute).toBeDefined();
642+
checkActivatedRoute(secondaryRoute!, '', {}, ComponentD, 'secondary');
643+
checkActivatedRoute(secondaryRoute!.firstChild!, 'component-copy', {}, ComponentA);
644+
});
645+
});
581646
});
582647

583648
describe('wildcards', () => {

0 commit comments

Comments
 (0)