Skip to content

Commit d5e8863

Browse files
authored
Expose resolved invocation targets in next-routing (#91242)
### What? This PR extends `@next/routing` so `resolveRoutes()` returns the concrete route resolution data callers need after rewrites and dynamic matches: - rename `matchedPathname` to `resolvedPathname` - add `resolvedQuery` - add `invocationTarget` for the concrete pathname/query that should be invoked - export the new query/invocation target types from the package entrypoint It also removes the leftover `query` alias so the result shape consistently uses `resolvedQuery`. ### Why? `matchedPathname` only described part of the result, and it was ambiguous for dynamic routes because the resolved route template and the concrete invocation target are not always the same thing. For example, a dynamic route can resolve to `/blog/[slug]` while the actual invocation target is `/blog/post-1`, and rewrites can merge query params that callers need to preserve. Exposing these values directly makes the package easier to consume from adapters without each caller reconstructing them manually. ### How? - thread resolved query construction through the route resolution paths - build `invocationTarget` alongside `resolvedPathname` wherever rewrites, static matches, and dynamic matches resolve successfully - preserve merged rewrite query params in `resolvedQuery` - update the public types, README example, and existing tests to use `resolvedPathname` - add coverage for resolved query + invocation target behavior on rewrite and dynamic route matches Verified with: - `pnpm --filter @next/routing test -- --runInBand` - `pnpm --filter @next/routing build`
1 parent efcfe05 commit d5e8863

File tree

13 files changed

+435
-167
lines changed

13 files changed

+435
-167
lines changed

docs/01-app/03-api-reference/05-config/01-next-config-js/adapterPath.mdx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,8 +444,22 @@ const result = await resolveRoutes({
444444
return {}
445445
},
446446
})
447+
448+
if (result.resolvedPathname) {
449+
console.log('Resolved pathname:', result.resolvedPathname)
450+
console.log('Resolved query:', result.resolvedQuery)
451+
console.log('Invocation target:', result.invocationTarget)
452+
}
447453
```
448454

455+
`resolveRoutes()` returns:
456+
457+
- `resolvedPathname`: The route pathname selected by Next.js routing. For dynamic routes, this is the matched route template such as `/blog/[slug]`.
458+
- `resolvedQuery`: The final query after rewrites or middleware have added or replaced search params.
459+
- `invocationTarget`: The concrete pathname and query to invoke for the matched route.
460+
461+
For example, if `/blog/post-1?draft=1` matches `/blog/[slug]?slug=post-1`, `resolvedPathname` is `/blog/[slug]` while `invocationTarget.pathname` is `/blog/post-1`.
462+
449463
## Implementing PPR in an Adapter
450464

451465
For partially prerendered app routes, `onBuildComplete` gives you the data needed to seed and resume PPR:

packages/next-routing/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@ const result = await resolveRoutes({
3939
},
4040
})
4141

42-
if (result.matchedPathname) {
43-
console.log('Matched:', result.matchedPathname)
42+
if (result.resolvedPathname) {
43+
console.log('Resolved pathname:', result.resolvedPathname)
44+
console.log('Resolved query:', result.resolvedQuery)
45+
console.log('Invocation target:', result.invocationTarget)
4446
}
4547
```
4648

packages/next-routing/src/__tests__/captures.test.ts

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ describe('Regex Captures in Destination', () => {
5454

5555
const result = await resolveRoutes(params)
5656

57-
expect(result.matchedPathname).toBe('/posts/my-post')
57+
expect(result.resolvedPathname).toBe('/posts/my-post')
5858
})
5959

6060
it('should replace multiple numbered captures $1, $2, $3', async () => {
@@ -78,7 +78,7 @@ describe('Regex Captures in Destination', () => {
7878

7979
const result = await resolveRoutes(params)
8080

81-
expect(result.matchedPathname).toBe('/archive/2024/01/post-title')
81+
expect(result.resolvedPathname).toBe('/archive/2024/01/post-title')
8282
})
8383

8484
it('should replace named captures in destination', async () => {
@@ -102,7 +102,7 @@ describe('Regex Captures in Destination', () => {
102102

103103
const result = await resolveRoutes(params)
104104

105-
expect(result.matchedPathname).toBe('/u/alice/p/123')
105+
expect(result.resolvedPathname).toBe('/u/alice/p/123')
106106
})
107107

108108
it('should mix numbered and named captures', async () => {
@@ -126,7 +126,7 @@ describe('Regex Captures in Destination', () => {
126126

127127
const result = await resolveRoutes(params)
128128

129-
expect(result.matchedPathname).toBe('/internal/v1/user/john')
129+
expect(result.resolvedPathname).toBe('/internal/v1/user/john')
130130
})
131131

132132
it('should use captures in query parameters', async () => {
@@ -150,7 +150,7 @@ describe('Regex Captures in Destination', () => {
150150

151151
const result = await resolveRoutes(params)
152152

153-
expect(result.matchedPathname).toBe('/api/products')
153+
expect(result.resolvedPathname).toBe('/api/products')
154154
})
155155

156156
it('should replace captures in external rewrite', async () => {
@@ -240,7 +240,7 @@ describe('Has Condition Captures in Destination', () => {
240240

241241
const result = await resolveRoutes(params)
242242

243-
expect(result.matchedPathname).toBe('/users/12345/profile')
243+
expect(result.resolvedPathname).toBe('/users/12345/profile')
244244
})
245245

246246
it('should use cookie value in destination', async () => {
@@ -275,7 +275,7 @@ describe('Has Condition Captures in Destination', () => {
275275

276276
const result = await resolveRoutes(params)
277277

278-
expect(result.matchedPathname).toBe('/sessions/abc123xyz/dashboard')
278+
expect(result.resolvedPathname).toBe('/sessions/abc123xyz/dashboard')
279279
})
280280

281281
it('should use query parameter value in destination', async () => {
@@ -305,7 +305,7 @@ describe('Has Condition Captures in Destination', () => {
305305

306306
const result = await resolveRoutes(params)
307307

308-
expect(result.matchedPathname).toBe('/results/nextjs')
308+
expect(result.resolvedPathname).toBe('/results/nextjs')
309309
})
310310

311311
it('should combine regex captures and has captures', async () => {
@@ -340,7 +340,7 @@ describe('Has Condition Captures in Destination', () => {
340340

341341
const result = await resolveRoutes(params)
342342

343-
expect(result.matchedPathname).toBe('/tenants/acme/users/123')
343+
expect(result.resolvedPathname).toBe('/tenants/acme/users/123')
344344
})
345345

346346
it('should combine named regex captures and has captures', async () => {
@@ -375,7 +375,7 @@ describe('Has Condition Captures in Destination', () => {
375375

376376
const result = await resolveRoutes(params)
377377

378-
expect(result.matchedPathname).toBe('/api/v2/products/electronics')
378+
expect(result.resolvedPathname).toBe('/api/v2/products/electronics')
379379
})
380380

381381
it('should use multiple has captures in destination', async () => {
@@ -415,7 +415,7 @@ describe('Has Condition Captures in Destination', () => {
415415

416416
const result = await resolveRoutes(params)
417417

418-
expect(result.matchedPathname).toBe('/regions/us-west/tenants/acme/data')
418+
expect(result.resolvedPathname).toBe('/regions/us-west/tenants/acme/data')
419419
})
420420

421421
it('should use has captures with regex pattern match', async () => {
@@ -451,7 +451,7 @@ describe('Has Condition Captures in Destination', () => {
451451

452452
const result = await resolveRoutes(params)
453453

454-
expect(result.matchedPathname).toBe('/localized/en-US/page')
454+
expect(result.resolvedPathname).toBe('/localized/en-US/page')
455455
})
456456

457457
it('should use has captures in query string', async () => {
@@ -486,7 +486,7 @@ describe('Has Condition Captures in Destination', () => {
486486

487487
const result = await resolveRoutes(params)
488488

489-
expect(result.matchedPathname).toBe('/internal/dashboard')
489+
expect(result.resolvedPathname).toBe('/internal/dashboard')
490490
})
491491

492492
it('should use has captures in external rewrite', async () => {
@@ -601,7 +601,7 @@ describe('Has Condition Captures in Destination', () => {
601601

602602
const result = await resolveRoutes(params)
603603

604-
expect(result.matchedPathname).toBe('/users/123')
604+
expect(result.resolvedPathname).toBe('/users/123')
605605
expect(result.resolvedHeaders?.get('Location')).toBe('/profiles/123')
606606
expect(result.resolvedHeaders?.get('x-user-id')).toBe('123')
607607
expect(result.resolvedHeaders?.get('x-language')).toBe('es')
@@ -650,7 +650,7 @@ describe('Has Condition Captures in Destination', () => {
650650

651651
const result = await resolveRoutes(params)
652652

653-
expect(result.matchedPathname).toBe('/api/posts')
653+
expect(result.resolvedPathname).toBe('/api/posts')
654654
expect(result.resolvedHeaders?.get('x-resource')).toBe('posts')
655655
expect(result.resolvedHeaders?.get('x-language')).toBe('fr')
656656
expect(result.resolvedHeaders?.get('x-combined')).toBe('posts-fr')
@@ -689,7 +689,7 @@ describe('Has Condition Captures in Destination', () => {
689689

690690
const result = await resolveRoutes(params)
691691

692-
expect(result.matchedPathname).toBe('/fr/profile/john')
692+
expect(result.resolvedPathname).toBe('/fr/profile/john')
693693
expect(result.routeMatches).toEqual({
694694
'1': 'john',
695695
})
@@ -736,7 +736,7 @@ describe('Complex Capture Scenarios', () => {
736736

737737
const result = await resolveRoutes(params)
738738

739-
expect(result.matchedPathname).toBe(
739+
expect(result.resolvedPathname).toBe(
740740
'/orgs/myorg/users/john/projects/backend/issues/42'
741741
)
742742
})
@@ -762,7 +762,7 @@ describe('Complex Capture Scenarios', () => {
762762

763763
const result = await resolveRoutes(params)
764764

765-
expect(result.matchedPathname).toBe('/a/test/b/test/c/test')
765+
expect(result.resolvedPathname).toBe('/a/test/b/test/c/test')
766766
})
767767

768768
it('should handle capture with special characters', async () => {
@@ -786,7 +786,7 @@ describe('Complex Capture Scenarios', () => {
786786

787787
const result = await resolveRoutes(params)
788788

789-
expect(result.matchedPathname).toBe('/storage/my-file.test.js')
789+
expect(result.resolvedPathname).toBe('/storage/my-file.test.js')
790790
})
791791

792792
it('should not replace undefined captures', async () => {
@@ -810,7 +810,7 @@ describe('Complex Capture Scenarios', () => {
810810

811811
const result = await resolveRoutes(params)
812812

813-
expect(result.matchedPathname).toBe('/result/$1/$2')
813+
expect(result.resolvedPathname).toBe('/result/$1/$2')
814814
})
815815

816816
it('should handle captures across chained rewrites', async () => {
@@ -838,6 +838,6 @@ describe('Complex Capture Scenarios', () => {
838838

839839
const result = await resolveRoutes(params)
840840

841-
expect(result.matchedPathname).toBe('/internal/user-service/alice')
841+
expect(result.resolvedPathname).toBe('/internal/user-service/alice')
842842
})
843843
})

packages/next-routing/src/__tests__/conditions.test.ts

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe('Has conditions', () => {
6666

6767
const result = await resolveRoutes(params)
6868

69-
expect(result.matchedPathname).toBe('/admin-dashboard')
69+
expect(result.resolvedPathname).toBe('/admin-dashboard')
7070
})
7171

7272
it('should match route with cookie condition', async () => {
@@ -102,7 +102,7 @@ describe('Has conditions', () => {
102102

103103
const result = await resolveRoutes(params)
104104

105-
expect(result.matchedPathname).toBe('/dark-theme-page')
105+
expect(result.resolvedPathname).toBe('/dark-theme-page')
106106
})
107107

108108
it('should match route with query condition', async () => {
@@ -133,7 +133,7 @@ describe('Has conditions', () => {
133133

134134
const result = await resolveRoutes(params)
135135

136-
expect(result.matchedPathname).toBe('/preview-page')
136+
expect(result.resolvedPathname).toBe('/preview-page')
137137
})
138138

139139
it('should match route with host condition', async () => {
@@ -163,7 +163,7 @@ describe('Has conditions', () => {
163163

164164
const result = await resolveRoutes(params)
165165

166-
expect(result.matchedPathname).toBe('/subdomain-home')
166+
expect(result.resolvedPathname).toBe('/subdomain-home')
167167
})
168168

169169
it('should match when has condition checks key existence only', async () => {
@@ -199,7 +199,7 @@ describe('Has conditions', () => {
199199

200200
const result = await resolveRoutes(params)
201201

202-
expect(result.matchedPathname).toBe('/feature-enabled')
202+
expect(result.resolvedPathname).toBe('/feature-enabled')
203203
})
204204

205205
it('should match with regex pattern in has condition', async () => {
@@ -235,7 +235,7 @@ describe('Has conditions', () => {
235235

236236
const result = await resolveRoutes(params)
237237

238-
expect(result.matchedPathname).toBe('/mobile')
238+
expect(result.resolvedPathname).toBe('/mobile')
239239
})
240240

241241
it('should require ALL has conditions to match', async () => {
@@ -277,7 +277,7 @@ describe('Has conditions', () => {
277277

278278
const result = await resolveRoutes(params)
279279

280-
expect(result.matchedPathname).toBe('/admin-beta-feature')
280+
expect(result.resolvedPathname).toBe('/admin-beta-feature')
281281
})
282282

283283
it('should NOT match when one has condition fails', async () => {
@@ -320,7 +320,7 @@ describe('Has conditions', () => {
320320
const result = await resolveRoutes(params)
321321

322322
// Should not match the route, so stays at /feature
323-
expect(result.matchedPathname).toBe('/feature')
323+
expect(result.resolvedPathname).toBe('/feature')
324324
})
325325
})
326326

@@ -357,7 +357,7 @@ describe('Missing conditions', () => {
357357

358358
const result = await resolveRoutes(params)
359359

360-
expect(result.matchedPathname).toBe('/no-debug-page')
360+
expect(result.resolvedPathname).toBe('/no-debug-page')
361361
})
362362

363363
it('should NOT match when missing condition is present', async () => {
@@ -393,7 +393,7 @@ describe('Missing conditions', () => {
393393
const result = await resolveRoutes(params)
394394

395395
// Route should not match, stays at /page
396-
expect(result.matchedPathname).toBe('/page')
396+
expect(result.resolvedPathname).toBe('/page')
397397
})
398398

399399
it('should match when missing cookie is not present', async () => {
@@ -428,7 +428,7 @@ describe('Missing conditions', () => {
428428

429429
const result = await resolveRoutes(params)
430430

431-
expect(result.matchedPathname).toBe('/no-tracking')
431+
expect(result.resolvedPathname).toBe('/no-tracking')
432432
})
433433

434434
it('should match when missing query is not present', async () => {
@@ -458,7 +458,7 @@ describe('Missing conditions', () => {
458458

459459
const result = await resolveRoutes(params)
460460

461-
expect(result.matchedPathname).toBe('/no-preview')
461+
expect(result.resolvedPathname).toBe('/no-preview')
462462
})
463463

464464
it('should require ALL missing conditions to be absent', async () => {
@@ -498,7 +498,7 @@ describe('Missing conditions', () => {
498498

499499
const result = await resolveRoutes(params)
500500

501-
expect(result.matchedPathname).toBe('/standard-page')
501+
expect(result.resolvedPathname).toBe('/standard-page')
502502
})
503503
})
504504

@@ -543,7 +543,7 @@ describe('Combined has and missing conditions', () => {
543543

544544
const result = await resolveRoutes(params)
545545

546-
expect(result.matchedPathname).toBe('/member-content')
546+
expect(result.resolvedPathname).toBe('/member-content')
547547
})
548548

549549
it('should NOT match when has is satisfied but missing is present', async () => {
@@ -587,7 +587,7 @@ describe('Combined has and missing conditions', () => {
587587
const result = await resolveRoutes(params)
588588

589589
// Should not match, stays at /content
590-
expect(result.matchedPathname).toBe('/content')
590+
expect(result.resolvedPathname).toBe('/content')
591591
})
592592

593593
it('should NOT match when has fails even if missing is satisfied', async () => {
@@ -631,7 +631,7 @@ describe('Combined has and missing conditions', () => {
631631
const result = await resolveRoutes(params)
632632

633633
// Should not match, stays at /content
634-
expect(result.matchedPathname).toBe('/content')
634+
expect(result.resolvedPathname).toBe('/content')
635635
})
636636
})
637637

@@ -657,7 +657,7 @@ describe('Dynamic routes', () => {
657657

658658
const result = await resolveRoutes(params)
659659

660-
expect(result.matchedPathname).toBe('/posts/123')
660+
expect(result.resolvedPathname).toBe('/posts/123')
661661
expect(result.routeMatches).toEqual({
662662
'1': '123',
663663
})
@@ -684,7 +684,7 @@ describe('Dynamic routes', () => {
684684

685685
const result = await resolveRoutes(params)
686686

687-
expect(result.matchedPathname).toBe('/users/alice/posts/456')
687+
expect(result.resolvedPathname).toBe('/users/alice/posts/456')
688688
expect(result.routeMatches).toEqual({
689689
'1': 'alice',
690690
'2': '456',
@@ -726,7 +726,7 @@ describe('Dynamic routes', () => {
726726

727727
const result = await resolveRoutes(params)
728728

729-
expect(result.matchedPathname).toBe('/profile/john')
729+
expect(result.resolvedPathname).toBe('/profile/john')
730730
expect(result.routeMatches).toEqual({
731731
'1': 'john',
732732
})

0 commit comments

Comments
 (0)