Skip to content

fix(router): pass outlet context to split to fix empty path named out…#67806

Draft
atscott wants to merge 1 commit intoangular:mainfrom
atscott:debug-router-empty-outlets-20260320
Draft

fix(router): pass outlet context to split to fix empty path named out…#67806
atscott wants to merge 1 commit intoangular:mainfrom
atscott:debug-router-empty-outlets-20260320

Conversation

@atscott
Copy link
Contributor

@atscott atscott commented Mar 23, 2026

…lets

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:

{
  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!

{
  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:

{
  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

…lets

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 angular#67708
@ngbot ngbot bot added this to the Backlog milestone Mar 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Empty named outlet behaving differently than not empty outlet

1 participant