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
2 changes: 1 addition & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
"rules": {
"react/react-in-jsx-scope": "off"
},
"ignorePatterns": ["dist/**", "coverage/**", "node_modules/**", "src/react-old/**", "src/old/**"]
"ignorePatterns": ["dist/**", "coverage/**", "node_modules/**"]
}
9 changes: 6 additions & 3 deletions src/react/components/NotFound.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
/**
* Default fallback component rendered when no registered
* route matches the current URL. Can be overridden via the
* `notFound` prop on the Router component.
* route matches the current URL. Uses an `<h1>` heading
* for semantic document structure and accessibility.
*
* Can be overridden via the `notFound` prop on the Router
* component for custom 404 pages.
*/
export function NotFound() {
return <div>Not Found</div>
return <h1>Not Found</h1>
}
2 changes: 1 addition & 1 deletion src/react/components/Router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Router } from './Router'
import { createMatcher } from 'router:matcher'
import { type Handler } from 'router/react:router'
import { PathnameContext } from 'router/react:context/PathnameContext'
import { ParamsContext } from 'router/react:context/PropsContext'
import { ParamsContext } from 'router/react:context/ParamsContext'
import { NavigationContext } from 'router/react:context/NavigationContext'
import { TransitionContext } from 'router/react:context/TransitionContext'

Expand Down
18 changes: 9 additions & 9 deletions src/react/components/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from 'react'
import { type Handler } from 'router/react:router'
import { type Matcher } from 'router:matcher'
import { ParamsContext } from 'router/react:context/PropsContext'
import { ParamsContext } from 'router/react:context/ParamsContext'
import { NavigationContext } from 'router/react:context/NavigationContext'
import { NavigationSignalContext } from 'router/react:context/NavigationSignalContext'
import { NavigationTypeContext } from 'router/react:context/NavigationTypeContext'
Expand Down Expand Up @@ -152,10 +152,10 @@ export interface RouterProps {
* - `PathnameContext` — the current URL pathname
* - `ParamsContext` — the extracted route parameters
*/
export function Router(options: RouterProps) {
export function Router(props: RouterProps) {
const contextNavigation = use(NavigationContext)
const navigation: Navigation =
options.navigation ??
props.navigation ??
contextNavigation ??
(typeof window !== 'undefined' ? window.navigation : undefined)!

Expand All @@ -166,11 +166,11 @@ export function Router(options: RouterProps) {
'Use createMemoryNavigation() for SSR or non-browser environments.'
)
}
const matcher: Matcher<Handler> = options.matcher ?? use(MatcherContext)
const matcher: Matcher<Handler> = props.matcher ?? use(MatcherContext)
const internalTransition = useTransition()
const transition = options.transition ?? internalTransition
const transition = props.transition ?? internalTransition
const next = useNextMatch({ matcher })
const notFound = options.notFound ?? NotFound
const notFound = props.notFound ?? NotFound

const [current, setCurrent] = useState<CurrentState>(function () {
const url = navigation.currentEntry?.url ?? null
Expand Down Expand Up @@ -247,8 +247,8 @@ export function Router(options: RouterProps) {

useNavigationEvents(navigation, {
onNavigate,
onNavigateSuccess: options.onNavigateSuccess,
onNavigateError: options.onNavigateError,
onNavigateSuccess: props.onNavigateSuccess,
onNavigateError: props.onNavigateError,
})

const CurrentComponent = current.match.handler.component
Expand All @@ -263,7 +263,7 @@ export function Router(options: RouterProps) {
<PathnameContext value={current.pathname}>
<UrlContext value={current.url}>
<ParamsContext value={current.match.params}>
<Suspense fallback={options.fallback}>
<Suspense fallback={props.fallback}>
<Middlewares value={middlewares}>
<CurrentComponent />
</Middlewares>
Expand Down
7 changes: 6 additions & 1 deletion src/react/context/NavigationSignalContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,10 @@ import { createContext } from 'react'
* Consumers can use this to cancel in-flight async operations
* (fetches, transitions, etc.) when a navigation is superseded
* by another one.
*
* Defaults to `undefined` when no Router is present — the
* `useNavigationSignal` hook throws in this case. The Router
* provides `null` on initial render (before any navigation
* event), which is distinct from the `undefined` sentinel.
*/
export const NavigationSignalContext = createContext<AbortSignal | null>(null)
export const NavigationSignalContext = createContext<AbortSignal | null | undefined>(undefined)
7 changes: 6 additions & 1 deletion src/react/context/NavigationTypeContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,10 @@ import { createContext } from 'react'
* Provides the navigation type of the most recent NavigateEvent
* (`push`, `replace`, `reload`, or `traverse`). Allows route
* components to vary behavior based on how they were reached.
*
* Defaults to `undefined` when no Router is present — the
* `useNavigationType` hook throws in this case. The Router
* provides `null` on initial render (before any navigation
* event), which is distinct from the `undefined` sentinel.
*/
export const NavigationTypeContext = createContext<NavigationType | null>(null)
export const NavigationTypeContext = createContext<NavigationType | null | undefined>(undefined)
12 changes: 12 additions & 0 deletions src/react/context/ParamsContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createContext } from 'react'

/**
* Provides the route parameters extracted from the matched URL
* pattern as a string-keyed record. Defaults to `null` when no
* Router is present in the tree — the `useParams` hook throws
* a descriptive error in this case.
*
* The Router component updates this context on every successful
* navigation with the newly extracted parameters.
*/
export const ParamsContext = createContext<Record<string, string> | null>(null)
9 changes: 5 additions & 4 deletions src/react/context/PathnameContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { createContext } from 'react'
* Updated by the Router on every navigation with the pathname
* extracted from the destination URL.
*
* Consumed by the `usePathname` hook and the `Link` component
* for active link detection. Defaults to `'/'` when no Router
* is present in the tree.
* Defaults to `null` when no Router is present in the tree —
* the `usePathname` hook throws a descriptive error in this
* case. Consumed by the `usePathname` hook and the `Link`
* component for active link detection.
*/
export const PathnameContext = createContext<string>('/')
export const PathnameContext = createContext<string | null>(null)
12 changes: 0 additions & 12 deletions src/react/context/PropsContext.ts

This file was deleted.

8 changes: 1 addition & 7 deletions src/react/context/TransitionContext.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type TransitionFunction, createContext, useTransition } from 'react'
import { createContext, useTransition } from 'react'

/**
* Provides the `[isPending, startTransition]` tuple from
Expand All @@ -13,9 +13,3 @@ import { type TransitionFunction, createContext, useTransition } from 'react'
* explicit provider instead.
*/
export const TransitionContext = createContext<ReturnType<typeof useTransition> | null>(null)

/**
* Type alias for the startTransition function extracted from
* the useTransition tuple.
*/
export type StartTransitionFn = (callback: TransitionFunction) => void
5 changes: 2 additions & 3 deletions src/react/extractPathname.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/**
* Extracts the pathname portion from a URL string. Uses a
* dummy base URL to handle both absolute and relative paths
* correctly. Returns `'/'` when the input is null, undefined,
* or an empty string.
* correctly. Returns `'/'` when the input is null or undefined.
*
* Used by the Router (to extract pathname from navigation
* destination URLs), Link (for active link comparison), and
Expand All @@ -15,7 +14,7 @@
* provided.
*/
export function extractPathname(url: string | null | undefined): string {
if (!url) {
if (url === null || url === undefined) {
return '/'
}

Expand Down
11 changes: 11 additions & 0 deletions src/react/hooks/useActiveLinkProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ export function useActiveLinkProps(
options?: ActiveLinkOptions
): { isActive: boolean; props: ActiveLinkProps } {
const currentPathname = use(PathnameContext)

if (currentPathname === null) {
return {
isActive: false,
props: {
'data-active': undefined,
'aria-current': undefined,
},
}
}

const isExact = options?.exact ?? true

const isActive = isActiveHref(href, currentPathname, isExact)
Expand Down
15 changes: 15 additions & 0 deletions src/react/hooks/useNavigate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ import { useNavigation } from 'router/react:hooks/useNavigation'
*
* @returns A navigate function that accepts a URL string and
* optional `NavigationNavigateOptions`.
*
* @example
* ```tsx
* function LogoutButton() {
* const navigate = useNavigate()
*
* return (
* <button onClick={function () {
* navigate('/login', { history: 'replace' })
* }}>
* Log Out
* </button>
* )
* }
* ```
*/
export function useNavigate() {
const navigation = useNavigation()
Expand Down
10 changes: 10 additions & 0 deletions src/react/hooks/useNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ import { NavigationContext } from 'router/react:context/NavigationContext'
*
* @returns The Navigation object from the nearest provider.
* @throws When used outside a NavigationContext provider.
*
* @example
* ```tsx
* function HistoryDebug() {
* const navigation = useNavigation()
* const entries = navigation.entries()
*
* return <pre>{JSON.stringify(entries.map(e => e.url))}</pre>
* }
* ```
*/
export function useNavigation(): Navigation {
const navigation = use(NavigationContext)
Expand Down
18 changes: 18 additions & 0 deletions src/react/hooks/useNavigationEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@ export interface NavigationEventHandlers {
* @param navigation - The Navigation object to subscribe to.
* @param handlers - Callbacks for each navigation lifecycle
* event. All are optional.
*
* @example
* ```tsx
* function NavigationLogger() {
* const navigation = useNavigation()
*
* useNavigationEvents(navigation, {
* onNavigateSuccess() {
* console.log('navigation completed')
* },
* onNavigateError(error) {
* console.error('navigation failed', error)
* },
* })
*
* return null
* }
* ```
*/
export function useNavigationEvents(navigation: Navigation, handlers: NavigationEventHandlers) {
/**
Expand Down
9 changes: 9 additions & 0 deletions src/react/hooks/useNavigationHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ export interface PrecommitHandlerOptions {
* provides TransitionContext.
* @throws When no transition tuple is provided and the
* hook is used outside a TransitionContext provider.
*
* @example
* ```tsx
* function CustomRouter() {
* const transition = useTransition()
* const { createHandler } = useNavigationHandlers(transition)
* // use createHandler to build intercept handlers
* }
* ```
*/
export function useNavigationHandlers(transition?: ReturnType<typeof useTransition>) {
const contextTransition = transition ?? use(TransitionContext)
Expand Down
30 changes: 26 additions & 4 deletions src/react/hooks/useNavigationSignal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,32 @@ import { useNavigationSignal } from './useNavigationSignal'
import { NavigationSignalContext } from 'router/react:context/NavigationSignalContext'

describe('useNavigationSignal', { concurrent: true }, function () {
it('returns null by default', function ({ expect, onTestFinished }) {
const { current, unmount } = renderHook(function () {
return useNavigationSignal()
})
it('throws when used outside a provider', function ({ expect }) {
expect(function () {
renderHook(function () {
return useNavigationSignal()
})
}).toThrow('useNavigationSignal requires a <Router> or <NavigationSignalContext> provider')
})

it('returns null when provider gives null (initial render)', function ({
expect,
onTestFinished,
}) {
/**
* Wrapper providing null signal, simulating the initial
* render before any navigation event has fired.
*/
function Wrapper({ children }: { children: ReactNode }) {
return createElement(NavigationSignalContext, { value: null }, children)
}

const { current, unmount } = renderHook(
function () {
return useNavigationSignal()
},
{ wrapper: Wrapper }
)

onTestFinished(unmount)

Expand Down
23 changes: 22 additions & 1 deletion src/react/hooks/useNavigationSignal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,29 @@ import { NavigationSignalContext } from 'router/react:context/NavigationSignalCo
* Returns `null` before any navigation event has occurred
* (i.e. on the initial render).
*
* Must be used inside a `<Router>` component tree.
*
* @returns The current AbortSignal or null.
* @throws When used outside a Router or NavigationSignalContext
* provider.
*
* @example
* ```tsx
* function UserProfile({ id }: { id: string }) {
* const signal = useNavigationSignal()
*
* useEffect(function () {
* fetch(`/api/user/${id}`, { signal })
* }, [id, signal])
* }
* ```
*/
export function useNavigationSignal(): AbortSignal | null {
return use(NavigationSignalContext)
const signal = use(NavigationSignalContext)

if (signal === undefined) {
throw new Error('useNavigationSignal requires a <Router> or <NavigationSignalContext> provider')
}

return signal
}
30 changes: 26 additions & 4 deletions src/react/hooks/useNavigationType.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,32 @@ import { useNavigationType } from './useNavigationType'
import { NavigationTypeContext } from 'router/react:context/NavigationTypeContext'

describe('useNavigationType', { concurrent: true }, function () {
it('returns null by default', function ({ expect, onTestFinished }) {
const { current, unmount } = renderHook(function () {
return useNavigationType()
})
it('throws when used outside a provider', function ({ expect }) {
expect(function () {
renderHook(function () {
return useNavigationType()
})
}).toThrow('useNavigationType requires a <Router> or <NavigationTypeContext> provider')
})

it('returns null when provider gives null (initial render)', function ({
expect,
onTestFinished,
}) {
/**
* Wrapper providing null navigation type, simulating the
* initial render before any navigation event has fired.
*/
function Wrapper({ children }: { children: ReactNode }) {
return createElement(NavigationTypeContext, { value: null }, children)
}

const { current, unmount } = renderHook(
function () {
return useNavigationType()
},
{ wrapper: Wrapper }
)

onTestFinished(unmount)

Expand Down
Loading