Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/fetch-router/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@

This is the changelog for [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router). It follows [semantic versioning](https://semver.org/).

## HEAD

- Support nested routes in `createResource` & `createResources`
```ts
let books = {
...resources('books'),
author: 'books/:id/author',
reviews: resources('books/:id/reviews', { param: 'reviewId' }),
}
let profile = {
...resource('profile'),
admin: '/profile/admin',
todos: resource('profile/todos'),
}
```
Now becomes
```ts
let books = resources('books', {
children: { author: 'author', reviews: resources('reviews', { param: 'reviewId' }) },
})
let profile = resource('profile', { children: { admin: 'admin', todos: resource('todos') } })
```

## v0.6.0 (2025-10-10)

- BREAKING CHANGE: Rename
Expand Down
74 changes: 74 additions & 0 deletions packages/fetch-router/src/lib/resource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, it } from 'node:test'
import { ResourceMethods, createResource, ResourcesMethods, createResources } from './resource.ts'
import { Route } from './route-map.ts'
import type { Assert, IsEqual } from './type-utils.ts'
import { RoutePattern } from '@remix-run/route-pattern'

describe('createResource', () => {
it('creates a resource', () => {
Expand Down Expand Up @@ -58,6 +59,36 @@ describe('createResource', () => {
assert.equal((book as any).create, undefined)
})

it('creates a resource with `children` option', () => {
let profile = createResource('profile', {
children: {
admin: '/admin',
todos: createResource('todos', { only: ['show'] }),
},
only: ['show'],
})

type T = [
Assert<
IsEqual<
typeof profile,
{
show: Route<'GET', '/profile'>

readonly admin: Route<'ANY', '/profile/admin'>
readonly todos: {
show: Route<'GET', '/profile/todos'>
}
}
>
>,
]

assert.deepEqual(profile.show, new Route('GET', '/profile'))
assert.deepEqual(profile.admin, new Route('ANY', '/profile/admin'))
assert.deepEqual(profile.todos.show, new Route('GET', '/profile/todos'))
})

it('creates a resource with custom route names', () => {
let book = createResource('book', {
names: {
Expand Down Expand Up @@ -250,6 +281,49 @@ describe('createResources', () => {
assert.equal((books as any).destroy, undefined)
})

it('creates resources with `children` option', () => {
let books = createResources('books', {
children: {
author: '/author',
routePattern: new RoutePattern('/routePattern'),
objectWithPattern: { method: 'POST', pattern: 'objectWithPattern' },
reviews: createResources('reviews', { only: ['show'], param: 'reviewId' }),
todos: {
show: { method: 'GET', pattern: '/todos/:todoId' },
},
},
only: ['show'],
})

type T = [
Assert<
IsEqual<
typeof books,
{
show: Route<'GET', '/books/:id'>

readonly author: Route<'ANY', '/books/:id/author'>
readonly routePattern: Route<'ANY', '/books/:id/routePattern'>
readonly objectWithPattern: Route<'POST', '/books/:id/objectWithPattern'>
readonly reviews: {
show: Route<'GET', '/books/:id/reviews/:reviewId'>
}
readonly todos: {
readonly show: Route<'GET', '/books/:id/todos/:todoId'>
}
}
>
>,
]

assert.deepEqual(books.show, new Route('GET', '/books/:id'))
assert.deepEqual(books.author, new Route('ANY', '/books/:id/author'))
assert.deepEqual(books.routePattern, new Route('ANY', '/books/:id/routePattern'))
assert.deepEqual(books.objectWithPattern, new Route('POST', '/books/:id/objectWithPattern'))
assert.deepEqual(books.todos.show, new Route('GET', '/books/:id/todos/:todoId'))
assert.deepEqual(books.reviews.show, new Route('GET', '/books/:id/reviews/:reviewId'))
})

it('creates resources with custom param and only option', () => {
let articles = createResources('articles', {
only: ['index', 'show'],
Expand Down
80 changes: 66 additions & 14 deletions packages/fetch-router/src/lib/resource.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import type { RoutePattern } from '@remix-run/route-pattern'
import type { Join } from '@remix-run/route-pattern'
import { RoutePattern } from '@remix-run/route-pattern'

import { createRoutes } from './route-map.ts'
import type { RequestMethod } from './request-methods.ts'
import type { RouteDefs } from './route-map.ts'
import { createRoutes, Route } from './route-map.ts'
import type { BuildRouteMap } from './route-map.ts'
import type { Simplify } from './type-utils.ts'

export const ResourceMethods = ['new', 'show', 'create', 'edit', 'update', 'destroy'] as const
export type ResourceMethod = (typeof ResourceMethods)[number]

export interface ResourceOptions {
children?: RouteDefs
/**
* The resource methods to include in the route map. If not provided, all
* methods (`show`, `new`, `create`, `edit`, `update`, and `destroy`) will be
Expand Down Expand Up @@ -44,7 +49,7 @@ export function createResource<P extends string, const O extends ResourceOptions
let updateName = options?.names?.update ?? 'update'
let destroyName = options?.names?.destroy ?? 'destroy'

let routes: any = {}
let routes: RouteDefs = options?.children ?? {}

if (only.includes('new')) {
routes[newName] = { method: 'GET', pattern: `/new` }
Expand All @@ -70,10 +75,11 @@ export function createResource<P extends string, const O extends ResourceOptions

type BuildResourceMap<B extends string, O extends ResourceOptions> = BuildRouteMap<
B,
BuildResourceRoutes<
O,
O extends { only: readonly ResourceMethod[] } ? O['only'][number] : ResourceMethod
>
(O extends { children: infer C extends RouteDefs } ? C : {}) &
BuildResourceRoutes<
O,
O extends { only: readonly ResourceMethod[] } ? O['only'][number] : ResourceMethod
>
>

type BuildResourceRoutes<O extends ResourceOptions, M extends ResourceMethod> = {
Expand All @@ -100,6 +106,7 @@ export const ResourcesMethods = ['index', 'new', 'show', 'create', 'edit', 'upda
export type ResourcesMethod = (typeof ResourcesMethods)[number]

export type ResourcesOptions = {
children?: RouteDefs
/**
* The resource methods to include in the route map. If not provided, all
* methods (`index`, `show`, `new`, `create`, `edit`, `update`, and `destroy`)
Expand All @@ -124,6 +131,22 @@ export type ResourcesOptions = {
}
}

const addParamToPatterns = (defs: RouteDefs, param: string): RouteDefs =>
Object.fromEntries(
Object.entries(defs).map(([key, value]) => {
let updatedValue =
value instanceof Route
? new Route(value.method, new RoutePattern(`:${param}`).join(value.pattern))
: typeof value === 'string' || value instanceof RoutePattern
? new RoutePattern(`:${param}`).join(value)
: typeof value === 'object' && 'pattern' in value
? { ...value, pattern: new RoutePattern(`:${param}`).join((value as any).pattern) }
: addParamToPatterns(value, param)

return [key, updatedValue]
}),
)
Comment on lines +134 to +148
Copy link

Copilot AI Oct 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type assertion (value as any).pattern on line 143 bypasses TypeScript's type checking. Consider using a more specific type guard or conditional type checking to ensure type safety.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also done in a couple of other places, hence why I took it over.

If needed, I'm happy to add better typing here


/**
* Create a route map with standard CRUD routes for a resource collection.
*
Expand All @@ -144,7 +167,7 @@ export function createResources<P extends string, const O extends ResourcesOptio
let updateName = options?.names?.update ?? 'update'
let destroyName = options?.names?.destroy ?? 'destroy'

let routes: any = {}
let routes: RouteDefs = addParamToPatterns(options?.children ?? {}, param)

if (only.includes('index')) {
routes[indexName] = { method: 'GET', pattern: `/` }
Expand All @@ -171,13 +194,42 @@ export function createResources<P extends string, const O extends ResourcesOptio
return createRoutes(base, routes) as BuildResourcesMap<P, O>
}

type BuildResourcesMap<B extends string, O extends ResourcesOptions> = BuildRouteMap<
B,
BuildResourcesRoutes<
O,
O extends { only: readonly ResourcesMethod[] } ? O['only'][number] : ResourcesMethod,
GetParam<O>
type AddParamToPatterns<RouteDefinitions extends RouteDefs, Param extends string> = Simplify<{
[K in keyof RouteDefinitions]: RouteDefinitions[K] extends Route<
infer Method extends RequestMethod | 'ANY',
infer Pattern extends string
>
? Route<Method, Join<`:${Param}`, Pattern>>
: RouteDefinitions[K] extends string
? Join<`:${Param}`, RouteDefinitions[K]>
: RouteDefinitions[K] extends RoutePattern<infer Pattern extends string>
? RoutePattern<Join<`:${Param}`, Pattern>>
: RouteDefinitions[K] extends { method: infer Method; pattern: infer Pattern }
? {
method: Method
pattern: Pattern extends string
? Join<`:${Param}`, Pattern>
: Pattern extends RoutePattern<infer P extends string>
? Join<`:${Param}`, P>
: never
}
: RouteDefinitions[K] extends RouteDefs
? AddParamToPatterns<RouteDefinitions[K], Param>
: never
}>

type BuildResourcesMap<
B extends string,
O extends ResourcesOptions,
Param extends GetParam<O> = GetParam<O>,
> = BuildRouteMap<
B,
AddParamToPatterns<O extends { children: RouteDefs } ? O['children'] : {}, Param> &
BuildResourcesRoutes<
O,
O extends { only: readonly ResourcesMethod[] } ? O['only'][number] : ResourcesMethod,
Param
>
>

type BuildResourcesRoutes<
Expand Down