Skip to content
Merged
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
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ concurrency:
cancel-in-progress: true

permissions:
contents: write
id-token: write
contents: read

jobs:
check:
Expand Down Expand Up @@ -74,6 +73,10 @@ jobs:
runs-on: ubuntu-latest
needs: [build]

permissions:
contents: write
id-token: write

# Only release on push to main, not on PRs
if: ${{ github.event_name == 'push' }}

Expand Down
644 changes: 333 additions & 311 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
"react": "^19.2.0",
"react-dom": "^19.2.0",
"typescript": "^6.0.2",
"vite": "^8.0.3",
"vite": "^8.0.8",
"vite-plugin-dts": "^4.5.4",
"vitest": "^4.0.7"
}
Expand Down
13 changes: 12 additions & 1 deletion src/react/components/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,18 @@ export interface RouterProps {
*/
export function Router(options: RouterProps) {
const contextNavigation = use(NavigationContext)
const navigation: Navigation = options.navigation ?? contextNavigation ?? window.navigation
const navigation: Navigation =
options.navigation ??
contextNavigation ??
(typeof window !== 'undefined' ? window.navigation : undefined)!

if (navigation === undefined || navigation === null) {
throw new Error(
'Router requires a navigation prop, NavigationContext provider, ' +
'or browser Navigation API support. ' +
'Use createMemoryNavigation() for SSR or non-browser environments.'
)
}
const matcher: Matcher<Handler> = options.matcher ?? use(MatcherContext)
const internalTransition = useTransition()
const transition = options.transition ?? internalTransition
Expand Down
27 changes: 27 additions & 0 deletions src/react/createRouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,33 @@ describe('createRouter', { concurrent: true }, function () {
expect(receivedUrl?.search).toBe('?q=test')
expect(context.controller.redirect).toHaveBeenCalledWith('/new-search?q=test')
})

it('does not inherit middleware from parent groups', function ({ expect }) {
const Auth = createMiddleware()

const router = createRouter(function (route) {
const authed = route().middleware([Auth]).group()

authed('/old').redirect('/new')
})

const match = router.match('/old')

expect(match).not.toBeNull()
expect(match?.handler.middlewares).toBeUndefined()
})

it('does not inherit scroll or focusReset configuration', function ({ expect }) {
const router = createRouter(function (route) {
route('/old').scroll('manual').focusReset('manual').redirect('/new')
})

const match = router.match('/old')

expect(match).not.toBeNull()
expect(match?.handler.scroll).toBeUndefined()
expect(match?.handler.focusReset).toBeUndefined()
})
})

describe('groups', { concurrent: true }, function () {
Expand Down
3 changes: 0 additions & 3 deletions src/react/createRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,14 +412,11 @@ function createRouteFactory(matcher: Matcher<Handler>, inherited: InheritedConfi

const handler: Handler = {
component: RedirectFallback,
middlewares: resolveMiddlewares(),
prefetch: function (context) {
const resolved = typeof target === 'function' ? target(context) : target

context.controller.redirect(resolved)
},
scroll: state.scroll,
focusReset: state.focusReset,
}

matcher.register(fullPath, handler)
Expand Down
2 changes: 1 addition & 1 deletion src/react/hooks/useNextMatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,6 @@ export function useNextMatch(options?: NextMatchOptions) {
return fallbackResolved
}

return matcher.match(new URL(destination).pathname) ?? fallbackResolved
return matcher.match(new URL(destination, 'http://localhost').pathname) ?? fallbackResolved
}
}
98 changes: 98 additions & 0 deletions src/router/matcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,5 +175,103 @@ describe('router', function () {
expect(route).not.toBeNull()
expect(route?.params).toStrictEqual({ id: '42', path: 'docs/readme.md' })
})

it('matches a route with handler value 0', function ({ expect }) {
const router = createMatcher<number>()

router.register('/zero', 0)

const route = router.match('/zero')

expect(route).not.toBeNull()
expect(route?.handler).toBe(0)
})

it('matches a route with handler value empty string', function ({ expect }) {
const router = createMatcher<string>()

router.register('/empty', '')

const route = router.match('/empty')

expect(route).not.toBeNull()
expect(route?.handler).toBe('')
})

it('matches a route with handler value false', function ({ expect }) {
const router = createMatcher<boolean>()

router.register('/false', false)

const route = router.match('/false')

expect(route).not.toBeNull()
expect(route?.handler).toBe(false)
})

it('matches a wildcard route with handler value 0', function ({ expect }) {
const router = createMatcher<number>()

router.register('/files/*path', 0)

const route = router.match('/files/readme.md')

expect(route).not.toBeNull()
expect(route?.handler).toBe(0)
expect(route?.params).toStrictEqual({ path: 'readme.md' })
})

it('matches root handler', function ({ expect }) {
const router = createMatcher<number>()

router.register('/', 1)

const route = router.match('/')

expect(route).not.toBeNull()
expect(route?.handler).toBe(1)
})
})

describe('param name conflicts', function () {
it('throws on conflicting dynamic param names at the same level', function ({ expect }) {
const router = createMatcher<number>()

router.register('/user/:id/profile', 1)

expect(function () {
router.register('/user/:name/settings', 2)
}).toThrow('conflicting dynamic param name')
})

it('throws on conflicting wildcard param names at the same level', function ({ expect }) {
const router = createMatcher<number>()

router.register('/files/*path', 1)

expect(function () {
router.register('/files/*filepath', 2)
}).toThrow('conflicting wildcard param name')
})

it('allows the same dynamic param name at the same level', function ({ expect }) {
const router = createMatcher<number>()

router.register('/user/:id/profile', 1)

expect(function () {
router.register('/user/:id/settings', 2)
}).not.toThrow()
})

it('allows the same wildcard param name at the same level', function ({ expect }) {
const router = createMatcher<number>()

router.register('/files/*path', 1)

expect(function () {
router.register('/other/*path', 2)
}).not.toThrow()
})
})
})
14 changes: 12 additions & 2 deletions src/router/matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ export function createMatcher<T>(options?: Options<T>): Matcher<T> {

if (!node.wildcard) {
node.wildcard = { children: new Map(), name }
} else if (node.wildcard.name !== name) {
throw new Error(
`conflicting wildcard param name at "${pattern}": ` +
`existing "*${node.wildcard.name}" vs new "*${name}"`
)
}

node = node.wildcard
Expand All @@ -167,6 +172,11 @@ export function createMatcher<T>(options?: Options<T>): Matcher<T> {

if (!node.child) {
node.child = { children: new Map(), name }
} else if (node.child.name !== name) {
throw new Error(
`conflicting dynamic param name at "${pattern}": ` +
`existing ":${node.child.name}" vs new ":${name}"`
)
}

node = node.child
Expand Down Expand Up @@ -216,7 +226,7 @@ export function createMatcher<T>(options?: Options<T>): Matcher<T> {
params: Record<string, string>
): Resolved<T> | null {
if (index === segments.length) {
return node.handler ? { handler: node.handler, params } : null
return node.handler !== undefined ? { handler: node.handler, params } : null
}

const segment = segments[index]
Expand All @@ -242,7 +252,7 @@ export function createMatcher<T>(options?: Options<T>): Matcher<T> {
}
}

if (node.wildcard && node.wildcard.name && node.wildcard.handler) {
if (node.wildcard && node.wildcard.name && node.wildcard.handler !== undefined) {
const rest = segments.slice(index).join('/')

return {
Expand Down
8 changes: 7 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,13 @@ export default defineConfig({
}),
dts({
include: ['**/*.ts*'],
exclude: ['**/*.test.ts*'],
exclude: [
'**/*.test.ts*',
'**/example.tsx',
'**/ExampleMain.tsx',
'**/examples/**',
'**/test-helpers.ts',
],
outDir: '../../dist',
root: './src/react',
}),
Expand Down