@budarin/use-route 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -21
  3. package/README.md +631 -594
  4. package/demo/node_modules/.bin/browserslist +2 -2
  5. package/demo/node_modules/.bin/browserslist.CMD +12 -0
  6. package/demo/node_modules/.bin/browserslist.ps1 +41 -0
  7. package/demo/node_modules/.bin/tsc +2 -2
  8. package/demo/node_modules/.bin/tsc.CMD +12 -0
  9. package/demo/node_modules/.bin/tsc.ps1 +41 -0
  10. package/demo/node_modules/.bin/tsserver +2 -2
  11. package/demo/node_modules/.bin/tsserver.CMD +12 -0
  12. package/demo/node_modules/.bin/tsserver.ps1 +41 -0
  13. package/demo/node_modules/.bin/vite +2 -2
  14. package/demo/node_modules/.bin/vite.CMD +12 -0
  15. package/demo/node_modules/.bin/vite.ps1 +41 -0
  16. package/demo/node_modules/.vite/deps/@budarin_use-route.js +25 -71
  17. package/demo/node_modules/.vite/deps/@budarin_use-route.js.map +3 -3
  18. package/demo/node_modules/.vite/deps/_metadata.json +15 -15
  19. package/demo/node_modules/.vite/deps/{chunk-DBBEQ5LR.js → chunk-3SNVYWQ3.js} +3 -16
  20. package/demo/node_modules/.vite/deps/{chunk-DBBEQ5LR.js.map → chunk-3SNVYWQ3.js.map} +1 -1
  21. package/demo/node_modules/.vite/deps/{chunk-4BQM3FN6.js → chunk-OTZU4T7N.js} +3 -16
  22. package/demo/node_modules/.vite/deps/{chunk-4BQM3FN6.js.map → chunk-OTZU4T7N.js.map} +1 -1
  23. package/demo/node_modules/.vite/deps/react-dom.js +2 -2
  24. package/demo/node_modules/.vite/deps/react-dom_client.js +10 -34
  25. package/demo/node_modules/.vite/deps/react-dom_client.js.map +1 -1
  26. package/demo/node_modules/.vite/deps/react.js +1 -1
  27. package/demo/node_modules/.vite/deps/react_jsx-dev-runtime.js +2 -15
  28. package/demo/node_modules/.vite/deps/react_jsx-dev-runtime.js.map +1 -1
  29. package/demo/node_modules/.vite/deps/react_jsx-runtime.js +2 -15
  30. package/demo/node_modules/.vite/deps/react_jsx-runtime.js.map +1 -1
  31. package/demo/package.json +9 -8
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +34 -2
  34. package/dist/index.js.map +1 -1
  35. package/dist/types.d.ts +4 -0
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js.map +1 -1
  38. package/package.json +2 -1
  39. package/demo/dist/assets/index-CehTkyXl.css +0 -1
  40. package/demo/dist/assets/index-wDy-y7oj.js +0 -49
  41. package/demo/dist/index.html +0 -13
  42. package/demo/tsconfig.tsbuildinfo +0 -1
  43. package/tsconfig.tsbuildinfo +0 -1
package/README.md CHANGED
@@ -1,594 +1,631 @@
1
- # @budarin/use-route
2
-
3
- **Минимум кода. Максимум SPA-навигации.**
4
-
5
- Инфраструктурный хук для React. **Требует Navigation API и URLPattern** — работает только в браузерах и средах, где они есть (см. таблицу версий). Для same-document навигации (без перезагрузки) используется перехват события `navigate` и вызов `event.intercept()`. Без провайдеров, без контекста, без бизнес-логики.
6
-
7
- **Сферы применения:**
8
-
9
- - **Чистая архитектура** — загрузка данных в use cases и сервисах, не в роутере; хук даёт только состояние маршрута и навигацию, без loaders и встроенной загрузки данных.
10
- - **Динамическое дерево компонентов** что рендерить определяется в рантайме по URL (`pathname`, `params`, `matched`), а не статичным деревом маршрутов. Подходит, когда маршруты зависят от ролей, фич-флагов, CMS или конфига с бэка.
11
- - **Иерархия URL без вложенных роутов** плоская, графовая или условная структура путей; один паттерн (или PathMatcher) и проверка по `params` вместо вложенных `<Route>`.
12
- - **SPA по подпути**приложение располагается не в корне домена, а по подпути (например `/app/`); глобальный `base` в конфиге и опция `base` в `navigate`/`replace` для переходов «вне» этого пути.
13
- - **SSR и гибридные сетапы** на сервере задаётся `initialLocation` в конфиге один раз перед рендером запроса; единообразная настройка без отдельного API.
14
- - **Реальные SPA и гибридные приложения**один хук, один конфиг, типы и тесты; применим в продакшене при опоре на современные браузеры (Navigation API + URLPattern).
15
-
16
- История формируется динамически: при каждом переходе можно выбрать `push` или `replace`.
17
-
18
- [![CI](https://github.com/budarin/use-route/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/budarin/use-route/actions/workflows/ci.yml)
19
- [![npm](https://img.shields.io/npm/v/@budarin/use-route?color=cb0000)](https://www.npmjs.com/package/@budarin/use-route)
20
- [![npm](https://img.shields.io/npm/dt/@budarin/use-route)](https://www.npmjs.com/package/@budarin/use-route)
21
- [![bundle](https://img.shields.io/bundlephobia/minzip/@budarin/use-route)](https://bundlephobia.com/result?p=@budarin/use-route)
22
- [![GitHub](https://img.shields.io/github/license/budarin/use-route)](https://github.com/budarin/use-route)
23
-
24
- **Живое демо:** запуск в браузере без установки;.
25
-
26
- - [Open in StackBlitz](https://stackblitz.com/github/budarin/use-route/tree/master/demo)
27
- - [Open in CodeSandbox](https://codesandbox.io/p/sandbox/github/budarin/use-route/tree/master/demo)
28
-
29
- ## ✨ Особенности
30
-
31
- - ✅ **Динамическое дерево** — маршрутизация в рантайме по pathname/params, без статичного route tree
32
- - ✅ **Динамическая история**при каждом `navigate`/`replace` выбирается `push` или `replace`
33
- - ✅ **Navigation API**`navigation.navigate()`, `back()`, `forward()`, `traverseTo()`; для same-document (без перезагрузки) — перехват события `navigate` и вызов `event.intercept()`
34
- - ✅ **URLPattern** для парсинга `:params` (только актуальные браузеры и Node.js)
35
- - ✅ `useSyncExternalStore` concurrent-safe, SSR-ready
36
- - ✅ `canGoBack(n)`, `canGoForward(n)` точная проверка по истории
37
- - ✅ **LRU кэш URL** с настраиваемым лимитом (по умолчанию 50)
38
- - ✅ **O(1) поиск** `historyIndex` через Map
39
- - ✅ **Только актуальные браузеры и Node.js** (Navigation API + URLPattern), без fallback
40
- - ✅ **0 провайдеров** просто `useRoute()`
41
- - ✅ **~4 kB** gzipped (исходный код; с минификацией в бандле меньше)
42
-
43
- ## ⚠️ Когда не использовать
44
-
45
- - **Нужна поддержка старых браузеров** — хук требует Navigation API и URLPattern (см. таблицу версий). Для старых браузеров возьмите React Router, TanStack Router или роутер с полифиллами.
46
- - **Нужны loaders или загрузка данных в роутере** здесь загрузка данных не входит в зону ответственности; её делают use cases и сервисы. Если вы хотите loaders/данные «из коробки» в маршруте подойдут React Router (loaders) или TanStack Router.
47
- - **Нужно декларативное дерево маршрутов**хук не предоставляет `<Route>` / `<Routes>`; что рендерить, вы решаете в коде по `pathname`/`params`. Если важна именно декларативная вложенная структура маршрутовиспользуйте один из перечисленных роутеров.
48
- - **Нужны встроенные guards, redirects, lazy-роуты** этого в пакете нет; реализуется в приложении поверх хука.
49
-
50
- В остальных случаях (современные браузеры, чистая архитектура, динамические маршруты, без loaders в роутере) пакет подходит.
51
-
52
- ## 🚀 Быстрый старт
53
-
54
- ```bash
55
- npm i @budarin/use-route
56
- ```
57
-
58
- ```typescript
59
- import { useRoute, configureRouter } from '@budarin/use-route';
60
-
61
-
62
- function App() {
63
- const {
64
- pathname,
65
- params,
66
- searchParams,
67
- navigate,
68
- go,
69
- canGoBack
70
- } = useRoute('/users/:id'); // опционально: паттерн для парсинга params
71
-
72
- return (
73
- <div>
74
- <h1>Current: {pathname}</h1>
75
- <p>User ID: {params.id}</p>
76
-
77
- <button onClick={() => navigate('/users/123')}>
78
- To Profile
79
- </button>
80
-
81
- <button onClick={() => go(-1)} disabled={!canGoBack()}>
82
- Back
83
- </button>
84
- </div>
85
- );
86
- }
87
- ```
88
-
89
- ## 📖 API
90
-
91
- ### `useRoute(pattern?: string | PathMatcher, options?: UseRouteOptions)` / `useRoute(options: UseRouteOptions)`
92
-
93
- **Формы вызова:**
94
-
95
- - **`useRoute()`** — без pattern и опций.
96
- - **`useRoute(pattern)`** — только pattern (строка или PathMatcher).
97
- - **`useRoute(pattern, options)`** — pattern и опции (например `section`).
98
- - **`useRoute({ section: '/dashboard' })`** — только опции, без pattern (раздел под глобальным base; pathname и navigate относительно раздела).
99
-
100
- **Параметры:**
101
-
102
- - **`pattern`** (опционально) — строка-паттерн (URLPattern) или PathMatcher для парсинга `params` и `matched`.
103
- - **`options`** (опционально) — **`section`**: путь раздела под глобальным base (например `/dashboard`). `pathname` возвращается без префикса (глобальный base + section), `navigate(to)` по умолчанию добавляет к путям полный префикс (base + section). Комбинируется с глобальным `base` из `configureRouter`, не заменяет его. В компонентах раздела вызывайте `useRoute({ section: '/dashboard' })` и работайте с путями относительно раздела.
104
-
105
- **Возвращает:**
106
-
107
- ```typescript
108
- {
109
- // Текущее состояние
110
- location: string;
111
- pathname: string;
112
- searchParams: URLSearchParams; // только чтение, не мутировать
113
- params: Record<string, string>;
114
- historyIndex: number;
115
- matched?: boolean; // true/false при переданном pattern, иначе undefined
116
-
117
- // Навигация
118
- navigate: (to: string | URL, options?) => Promise<void>; // Navigation API; same-document при перехвате navigate + intercept()
119
- back: () => void;
120
- forward: () => void;
121
- go: (delta: number) => void;
122
- replace: (to: string | URL, options?: NavigateOptions) => Promise<void>;
123
- canGoBack: (steps?: number) => boolean;
124
- canGoForward: (steps?: number) => boolean;
125
- }
126
- ```
127
-
128
- **Опции `navigate` и `replace`** (один интерфейс **NavigateOptions**):
129
-
130
- ```typescript
131
- {
132
- history?: 'push' | 'replace' | 'auto'; // по умолчанию из configureRouter или 'auto'
133
- state?: unknown; // опциональные данные перехода (только подсказки для UX); подробнее — раздел про state ниже
134
- base?: string; // полная подстановка префикса для этого вызова: '' или '/' — без префикса (другое приложение); иначе — полный путь (напр. '/auth')
135
- section?: string; // переопределение секции для этого вызова: '' — корень приложения (только global base); '/path' — другая секция
136
- }
137
- ```
138
-
139
- **`state`** произвольные данные, которые вы передаёте вместе с переходом в `navigate(to, { state })` или `replace(to, { state })`. Используйте только для опциональных подсказок (скролл, откуда пришли, префилл формы): страница должна корректно работать и при заходе по прямой ссылке без state. Подробно: см. ниже «Параметр state: когда добавлять в историю».
140
-
141
- **`replace(to, options?)`** — то же, что `navigate(to, { ...options, history: 'replace' })`. Опции те же, что у **navigate** (state, base, section); поле **history** игнорируется (всегда замена записи).
142
-
143
- **Параметр state: когда добавлять в историю и какое состояние можно передавать**
144
-
145
- Многие разработчики ни разу не используют state при переходах — это нормально. State нужен только в узких сценариях. Ниже — когда его стоит добавлять, какое состояние можно передавать и какое нельзя, чтобы не было недопониманий.
146
-
147
- **Что такое state и откуда он берётся.** State — это произвольные данные, которые вы передаёте в `navigate(to, { state })` или `replace(to, { state })`. Они сохраняются в записи истории (Navigation API) и доступны через `window.navigation.currentEntry.getState()`. **Важно:** state появляется только при программном переходе (ваш вызов navigate/replace). Если пользователь попал на тот же URL извне — ввёл адрес в строке, перешёл по букмарку, по ссылке с другого сайта, обновил страницу — для этой записи истории state нет. Поэтому поведение страницы не должно от state критически зависеть.
148
-
149
- **Когда нужно добавлять state в историю.** Добавляйте state только когда вы хотите передать «подсказку» для целевой страницы, которая улучшает UX при программном переходе, но не обязательна для корректной работы:
150
-
151
- - **Подсказка для скролла** — уходя со страницы списка, сохраняете в state позицию скролла; по «Назад» можно вернуть пользователя на то же место. Если зашли по прямой ссылке — state нет, показываете список с начала.
152
- - **Подсказка «откуда пришли»** переход с поиска на карточку: в state передаёте `{ from: 'search', highlight: 'keyword' }`; на карточке можно подсветить слово. При заходе по прямой ссылке подсветки нет страница остаётся корректной.
153
- - **Опциональный префилл формы** — переход «Редактировать» из списка: в state передаёте черновик; на странице редактирования при наличии state подставляете его, при отсутствии — грузите данные по id из URL/сервера.
154
- - **Источник перехода (аналитика, UI)** — в state передаёте `{ source: 'dashboard' }`; целевая страница может отправить это в аналитику или чуть изменить UI. При заходе по ссылке без state считаете источник «прямой» или «unknown».
155
-
156
- **Какое state можно передавать.** Только то, что является **опциональным улучшением**: подсказки для скролла, флаги «откуда пришли», опциональный префилл, метаданные для аналитики. Правило: целевая страница должна корректно работать и без state (при прямом заходе по URL).
157
-
158
- **Какое state нельзя передавать.** Не используйте state для того, без чего страница работает некорректно или неполно:
159
-
160
- - **Обязательные данные страницы**например, результаты поиска только из state. При переходе по ссылке `/search?q=foo` state нет экран пустой. Результаты должны браться из query или сервера.
161
- - **То, что должно быть в URL (шаринг, букмарк)** state не попадает в URL. Если поведение страницы должно воспроизводиться по одной ссылке используйте pathname и query, не state.
162
- - **Авторизация, права, критичные данные**не опирайтесь на state: пользователь может открыть URL напрямую. Проверки по сессии/серверу.
163
- - **Основной контент страницы** — что показывать, определяется URL и данными с бэкенда. State — только для подсказок, не источник правды.
164
-
165
- **Итог.** State в истории — опциональный инструмент для «передать что-то вместе с переходом», когда это улучшение, а не требование. Если сомневаетесь — можно не использовать; в большинстве приложений достаточно pathname, query и запросов к API.
166
-
167
- **`configureRouter(config)`** — глобальная настройка один раз при старте приложения:
168
-
169
- ```typescript
170
- configureRouter({
171
- urlCacheLimit: 50, // лимит LRU-кэша URL (по умолчанию 50)
172
- defaultHistory: 'replace', // history по умолчанию для всех navigate()
173
- logger: myLogger, // логгер (дефолт: console)
174
- base: '/app', // базовый путь: pathname без base, navigate(to) добавляет base к относительным путям
175
- initialLocation: request.url, // для SSR: начальный URL при рендере на сервере (нет window)
176
- });
177
- ```
178
-
179
- **`base`** (глобальный) — нужен только когда **приложение располагается не в корне домена**, а по подпути. Пример: сайт `https://example.com/` — корень; ваше приложение отдаётся по `https://example.com/app/`, то есть все его маршруты физически лежат под путём `/app`. В этом случае задайте `base: '/app'`: тогда `pathname` в хуке возвращается без префикса (как будто приложение в корне — `/dashboard` вместо `/app/dashboard`), а `navigate('/dashboard')` переходит на `/app/dashboard`. Если приложение в корне домена (`https://example.com/`), глобальный `base` задавать не нужно — префикс не используется.
180
-
181
- **`initialLocation`** — при SSR (нет `window`) хук не знает URL запроса. Задайте `initialLocation: request.url` (или полный URL страницы) один раз перед рендером запроса — тогда `pathname` и `searchParams` будут соответствовать запросу. На клиенте не используется. **По умолчанию задавать не нужно:** если на SSR `initialLocation` не задан, используется `'/'` (pathname и searchParams для корня).
182
-
183
- **Логгер:** тип `Logger` объект с методами `trace`, `debug`, `info`, `warn`, `error` (как у `console`). Уровни: `LoggerLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'`. Если не передан — используется `console`.
184
-
185
- **`pattern` (опционально):** строка-шаблон пути (нативный **URLPattern**) или функция **PathMatcher**. См. ниже.
186
-
187
- **Строка (URLPattern).** Поддерживается:
188
-
189
- - **Именованные параметры** `:name` (имя как в JS: буквы, цифры, `_`). Значение сегмента попадает в `params[name]`.
190
- - **Опциональные группы** — `{ ... }?`: часть пути можно сделать необязательной. Один паттерн покрывает пути разной глубины; в `params` только те ключи, для которых есть сегмент в URL.
191
- - **Wildcard**`*`: совпадает с «хвостом» пути; в `params` не попадает (числовые ключи из `groups` отфильтрованы).
192
- - **Regexp в параметре** — `:name(регулярка)` для ограничения формата сегмента (например только цифры). В `params` по-прежнему строка.
193
-
194
- ```typescript
195
- useRoute('/users/:id');
196
- useRoute('/elements/:elementId/*/:subElementId'); // wildcard
197
-
198
- // Опциональные группы
199
- useRoute('/users/:id{/posts/:postId}?');
200
-
201
- // Ограничение формата параметра (regexp)
202
- useRoute('/blog/:year(\\d+)/:month(\\d+)');
203
-
204
- // Функция-матчер (иерархия, кастомный разбор)
205
- const matchPost = (pathname: string) => ({ matched: pathname.startsWith('/posts/'), params: {} });
206
- useRoute(matchPost);
207
- ```
208
-
209
- Полный синтаксис URLPattern: [URL Pattern API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API), [WHATWG URL Pattern](https://urlpattern.spec.whatwg.org/).
210
-
211
- **PathMatcher** — функция, которую можно передать вместо строки, когда одного URLPattern недостаточно (иерархия сегментов, кастомная валидация, разбор через `split` или RegExp). Хук вызывает её с текущим `pathname` и подставляет возвращённые `matched` и `params` в состояние.
212
-
213
- - **Параметр:** `pathname: string` текущий pathname (без origin и query).
214
- - **Возвращаемый тип:** `{ matched: boolean; params: RouteParams }`.
215
- `matched` — совпал ли путь с вашей логикой; `params` — объект «имя параметра → значение сегмента» (тип `RouteParams` = `Record<RouteParamName, RouteParamValue>`).
216
- - **Где использовать:** иерархические маршруты (например, `postId` только при наличии `userId`), пути с жёстким порядком сегментов, кастомные правила, которые не выразить одним URLPattern.
217
-
218
- ## 🛠 Примеры
219
-
220
- ### 1. Базовая навигация (pathname, navigate)
221
-
222
- ```tsx
223
- import { useRoute } from '@budarin/use-route';
224
-
225
- function BasicNavigationExample() {
226
- const { pathname, navigate } = useRoute();
227
-
228
- return (
229
- <div>
230
- <p>Текущий путь: {pathname}</p>
231
- <button type="button" onClick={() => navigate('/posts')}>
232
- К постам
233
- </button>
234
- <button type="button" onClick={() => navigate('/')}>
235
- На главную
236
- </button>
237
- </div>
238
- );
239
- }
240
- ```
241
-
242
- ### 2. Параметры пути (useRoute('/users/:id'), params)
243
-
244
- ```tsx
245
- import { useRoute } from '@budarin/use-route';
246
-
247
- function ParamsExample() {
248
- const { params, pathname, navigate } = useRoute('/users/:id');
249
-
250
- return (
251
- <div>
252
- <p>Pathname: {pathname}</p>
253
- <p>User ID из params: {params.id ?? ''}</p>
254
- <button type="button" onClick={() => navigate('/users/123')}>
255
- User 123
256
- </button>
257
- <button type="button" onClick={() => navigate('/users/456')}>
258
- User 456
259
- </button>
260
- </div>
261
- );
262
- }
263
- ```
264
-
265
- ### 3. Search params (query)
266
-
267
- ```tsx
268
- import { useRoute } from '@budarin/use-route';
269
-
270
- function SearchParamsExample() {
271
- const { searchParams, navigate, pathname } = useRoute('/posts');
272
- const pageParam = searchParams.get('page') ?? '1';
273
- const currentPage = Number.parseInt(pageParam, 10) || 1;
274
-
275
- return (
276
- <div>
277
- <p>Путь: {pathname}</p>
278
- <p>Страница: {currentPage}</p>
279
- <button
280
- type="button"
281
- onClick={() => navigate(`/posts?page=${currentPage - 1}`)}
282
- disabled={currentPage <= 1}
283
- >
284
- Пред. страница
285
- </button>
286
- <button type="button" onClick={() => navigate(`/posts?page=${currentPage + 1}`)}>
287
- След. страница
288
- </button>
289
- </div>
290
- );
291
- }
292
- ```
293
-
294
- ### 4. История (back, forward, go, canGoBack, canGoForward)
295
-
296
- ```tsx
297
- import { useRoute } from '@budarin/use-route';
298
-
299
- function HistoryExample() {
300
- const { go, back, forward, canGoBack, canGoForward } = useRoute();
301
-
302
- return (
303
- <div>
304
- <button type="button" onClick={() => back()} disabled={!canGoBack()}>
305
- Назад
306
- </button>
307
- <button type="button" onClick={() => go(-2)} disabled={!canGoBack(2)}>
308
- 2 шага
309
- </button>
310
- <button type="button" onClick={() => go(1)} disabled={!canGoForward()}>
311
- Вперёд →
312
- </button>
313
- <button type="button" onClick={() => forward()} disabled={!canGoForward()}>
314
- Forward
315
- </button>
316
- </div>
317
- );
318
- }
319
- ```
320
-
321
- ### 5. Push и replace (и метод replace())
322
-
323
- ```tsx
324
- import { useRoute } from '@budarin/use-route';
325
-
326
- function PushReplaceExample() {
327
- const { navigate, replace, pathname } = useRoute();
328
-
329
- return (
330
- <div>
331
- <p>Текущий путь: {pathname}</p>
332
- <button type="button" onClick={() => navigate('/step-push', { history: 'push' })}>
333
- Перейти (push) — в истории появится запись
334
- </button>
335
- <button type="button" onClick={() => navigate('/step-replace', { history: 'replace' })}>
336
- Перейти (replace через navigate)
337
- </button>
338
- <button type="button" onClick={() => replace('/step-replace-method')}>
339
- Перейти через replace() — то же, что history: 'replace'
340
- </button>
341
- </div>
342
- );
343
- }
344
- ```
345
-
346
- ### 6. matched (совпадение pathname с pattern)
347
-
348
- ```tsx
349
- import { useRoute } from '@budarin/use-route';
350
-
351
- function MatchedExample() {
352
- const { pathname, matched, params } = useRoute('/users/:id');
353
-
354
- return (
355
- <div>
356
- <p>Pathname: {pathname}</p>
357
- <p>Pattern /users/:id совпал: {matched === true ? 'да' : 'нет'}</p>
358
- {matched === true ? (
359
- <p>User ID: {params.id}</p>
360
- ) : (
361
- <p>Это не страница пользователя (path не совпал с /users/:id).</p>
362
- )}
363
- </div>
364
- );
365
- }
366
- ```
367
-
368
- ### 7. Функция-матчер (PathMatcher)
369
-
370
- Удобно, когда один URLPattern или простой regex не справляется: иерархия (например, `postId` только вместе с `userId`), кастомная валидация, разный порядок сегментов. Ниже — матчер для `/users/:userId` и `/users/:userId/posts/:postId`: два параметра, причём `postId` допустим только после литерала `posts` и только при наличии `userId`.
371
-
372
- ```tsx
373
- import { useRoute, type PathMatcher } from '@budarin/use-route';
374
-
375
- const matchUserPosts: PathMatcher = (pathname) => {
376
- const segments = pathname.split('/').filter(Boolean);
377
-
378
- if (segments[0] !== 'users' || !segments[1]) return { matched: false, params: {} };
379
-
380
- const params: Record<string, string> = { userId: segments[1] };
381
-
382
- if (segments[2] === 'posts' && segments[3]) {
383
- params.postId = segments[3];
384
- }
385
-
386
- return { matched: true, params };
387
- };
388
-
389
- function UserPostsExample() {
390
- const { pathname, matched, params } = useRoute(matchUserPosts);
391
-
392
- if (!matched) return null;
393
-
394
- return (
395
- <div>
396
- <p>Путь: {pathname}</p>
397
- <p>User ID: {params.userId}</p>
398
- {params.postId && <p>Post ID: {params.postId}</p>}
399
- </div>
400
- );
401
- }
402
- ```
403
-
404
- ### 8. Глобальный base (приложение по подпути, не в корне домена)
405
-
406
- Когда приложение располагается **не в корне домена**, а по подпути (например `https://example.com/app/` все маршруты под `/app`), задайте в конфиге `base: '/app'`. Тогда `pathname` возвращается без префикса, а `navigate(to)` добавляет base к относительным путям. Для одноразового перехода «вне» этого пути (например на `/login`) используйте опцию `base` в `navigate` или `replace`: `navigate('/login', { base: '' })`.
407
-
408
- ```tsx
409
- import { useRoute, configureRouter } from '@budarin/use-route';
410
- configureRouter({ base: '/app' });
411
-
412
- function AppUnderBase() {
413
- const { pathname, navigate } = useRoute();
414
-
415
- return (
416
- <div>
417
- <p>Текущий путь: {pathname}</p>
418
-
419
- <button type="button" onClick={() => navigate('/dashboard')}>
420
- В дашборд → /app/dashboard
421
- </button>
422
-
423
- <button type="button" onClick={() => navigate('/login', { base: '' })}>
424
- На логин (/login)
425
- </button>
426
-
427
- <button type="button" onClick={() => navigate('/auth/profile', { base: '/auth' })}>
428
- В другой раздел (/auth/profile)
429
- </button>
430
- </div>
431
- );
432
- }
433
- ```
434
-
435
- ### 9. Section в хуке (options.section)
436
-
437
- Когда у приложения несколько разделов по своим подпутям (`/dashboard`, `/admin`, `/auth`), в компонентах раздела задайте **section**: вызовите `useRoute({ section: '/dashboard' })`. Тогда `pathname` возвращается без префикса раздела (срезаются глобальный base и section), а `navigate(to)` по умолчанию добавляет полный префикс (base + section). Переход в корень приложения (без секции): `navigate('/', { section: '' })`. Переход «вне» приложения: `navigate('/login', { base: '' })`.
438
-
439
- ```tsx
440
- import { useRoute } from '@budarin/use-route';
441
-
442
- const DASHBOARD_BASE = '/dashboard';
443
-
444
- function DashboardSection() {
445
- // Section для раздела: pathname и navigate относительно /dashboard (под глобальным base, если задан)
446
- const { pathname, navigate } = useRoute({ section: DASHBOARD_BASE });
447
-
448
- return (
449
- <div>
450
- {/* При URL /dashboard/reports pathname === '/reports' */}
451
- <p>Раздел Dashboard. Путь: {pathname}</p>
452
-
453
- <button type="button" onClick={() => navigate('/reports')}>
454
- Отчёты → /dashboard/reports
455
- </button>
456
-
457
- <button type="button" onClick={() => navigate('/settings')}>
458
- Настройки → /dashboard/settings
459
- </button>
460
-
461
- {/* Переход в корень приложения (без секции) или на главную */}
462
- <button type="button" onClick={() => navigate('/', { section: '' })}>
463
- На главную
464
- </button>
465
- </div>
466
- );
467
- }
468
- ```
469
-
470
- ### 10. initialLocation (SSR)
471
-
472
- При рендере на сервере нет `window`, поэтому хук не знает URL запроса. Задайте `initialLocation` в конфиге один раз перед рендером запроса (например `request.url`) — тогда `pathname` и `searchParams` будут соответствовать запросу. На клиенте `initialLocation` не используется.
473
-
474
- ```tsx
475
- // Серверный обработчик (псевдокод: Express, Fastify, Next и т.д.)
476
- import { configureRouter } from '@budarin/use-route';
477
- import { renderToStaticMarkup } from 'react-dom/server';
478
- import { App } from './App';
479
-
480
- function handleRequest(req, res) {
481
- // Один раз перед рендером этого запроса
482
- configureRouter({ initialLocation: req.url });
483
-
484
- const html = renderToStaticMarkup(<App />);
485
- res.send(html);
486
- }
487
-
488
- // В App компоненты используют useRoute() — на сервере получают pathname/searchParams из initialLocation
489
- function App() {
490
- const { pathname, searchParams } = useRoute();
491
- return (
492
- <div>
493
- <p>Pathname: {pathname}</p>
494
- <p>Query: {searchParams.toString()}</p>
495
- </div>
496
- );
497
- }
498
- ```
499
-
500
- ### 11. Компонент Link (пример реализации)
501
-
502
- Минимальный пример компонента-ссылки поверх хука. Можно взять за основу и развивать под себя: активное состояние, префетч, аналитика, стили.
503
-
504
- ```tsx
505
- import { useRoute } from '@budarin/use-route';
506
- import { useCallback, type ComponentPropsWithoutRef } from 'react';
507
-
508
- interface LinkProps extends ComponentPropsWithoutRef<'a'> {
509
- to: string;
510
- replace?: boolean;
511
- }
512
-
513
- function Link({ to, replace = false, onClick, ...props }: LinkProps) {
514
- const { navigate } = useRoute();
515
-
516
- const handleClick = useCallback(
517
- (e: React.MouseEvent<HTMLAnchorElement>) => {
518
- onClick?.(e);
519
-
520
- if (!e.defaultPrevented) {
521
- e.preventDefault();
522
- navigate(to, { history: replace ? 'replace' : 'push' });
523
- }
524
- },
525
- [navigate, to, replace, onClick]
526
- );
527
-
528
- return <a {...props} href={to} onClick={handleClick} />;
529
- }
530
-
531
- // Использование:
532
- // <Link to="/posts">Посты</Link>
533
- // <Link to="/users/123" replace>Профиль (replace)</Link>
534
- ```
535
-
536
- ## ⚙️ Установка
537
-
538
- ```bash
539
- npm i @budarin/use-route
540
-
541
- pnpm add @budarin/use-route
542
-
543
- yarn add @budarin/use-route
544
- ```
545
-
546
- TypeScript: типы включены.
547
-
548
- **`tsconfig.json` (рекомендуется):**
549
-
550
- ```json
551
- {
552
- "compilerOptions": {
553
- "lib": ["ES2021", "DOM", "DOM.Iterable"],
554
- "moduleResolution": "bundler",
555
- "jsx": "react-jsx"
556
- }
557
- }
558
- ```
559
-
560
- **Polyfills (опционально):**
561
-
562
- ```bash
563
- npm i urlpattern-polyfill
564
- ```
565
-
566
- ```typescript
567
- // src/polyfills.ts
568
- import 'urlpattern-polyfill';
569
- ```
570
-
571
- ## 🌐 Браузеры и Node.js
572
-
573
- Пакет **работает только** там, где есть **Navigation API** и **URLPattern**. Ограничивающие требования — версии ниже; без них хук не запустится.
574
-
575
- | API | Chrome/Edge | Firefox | Safari | Node.js |
576
- | -------------- | ----------- | ------- | ------ | ------- |
577
- | Navigation API | 102+ | 109+ | 16.4+ | — |
578
- | URLPattern | 110+ | 115+ | 16.4+ | 23.8+ |
579
-
580
- Для same-document навигации (без перезагрузки) хук перехватывает событие `navigate` и вызывает `event.intercept()`. Fallback на среды без Navigation API или URLPattern нет.
581
-
582
- ## 🎛 Под капотом
583
-
584
- - **Navigation API:** подписка на события `navigate`, `currententrychange`; для same-origin навигации — перехват `navigate` и вызов `event.intercept()` (same-document без перезагрузки)
585
- - `useSyncExternalStore` на navigation события
586
- - LRU кэш parsed URL (настраиваемый лимит)
587
- - Map для O(1) поиска `historyIndex`
588
- - URLPattern для `:params`
589
- - Кэш compiled patterns; `clearRouterCaches()` — очистка кэшей (тесты, смена окружения)
590
- - SSR-safe (checks `typeof window`)
591
-
592
- ## 🤝 Лицензия
593
-
594
- MIT © budarin
1
+ # @budarin/use-route
2
+
3
+ **Минимум кода. Максимум SPA-навигации.**
4
+
5
+ Инфраструктурный хук для React. <br />
6
+ **Требует Navigation API и URLPattern** — работает только в браузерах и средах, где они есть (см. таблицу версий). Без провайдеров, без контекста, без бизнес-логики.
7
+
8
+ **Сферы применения:**
9
+
10
+ - **Чистая архитектура**загрузка данных в use cases и сервисах, не в роутере; хук даёт только состояние маршрута и навигацию, без встроенной загрузки данных, без бизнес-логики.
11
+ - **Динамическое дерево компонентов** — что рендерить определяется в рантайме по URL (`pathname`, `params`, `matched`), а не статичным деревом маршрутов. Подходит, когда маршруты зависят от ролей, фич-флагов, CMS или конфига с бэка.
12
+ - **Иерархия URL без вложенных роутов** плоская, графовая или условная структура путей; один паттерн (или PathMatcher) и проверка по `params` вместо вложенных `<Route>`.
13
+ - **SPA по подпути**приложение может располагаться не в корне домена, а по подпути (например `/app/`); глобальный `base` в конфиге и опция `base` в `navigate`/`replace` для переходов «вне» этого пути.
14
+ - **SSR и гибридные сетапы**на сервере задаётся `initialLocation` в конфиге один раз перед рендером запроса; единообразная настройка без отдельного API.
15
+ - **Реальные SPA и гибридные приложения** — один хук, один конфиг, типы и тесты; применим в продакшене при опоре на современные браузеры (Navigation API + URLPattern).
16
+
17
+ История формируется динамически: при каждом переходе можно выбрать `push` или `replace`.
18
+
19
+ [![CI](https://github.com/budarin/use-route/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/budarin/use-route/actions/workflows/ci.yml)
20
+ [![npm](https://img.shields.io/npm/v/@budarin/use-route?color=cb0000)](https://www.npmjs.com/package/@budarin/use-route)
21
+ [![npm](https://img.shields.io/npm/dt/@budarin/use-route)](https://www.npmjs.com/package/@budarin/use-route)
22
+ [![bundle](https://img.shields.io/bundlephobia/minzip/@budarin/use-route)](https://bundlephobia.com/result?p=@budarin/use-route)
23
+ [![GitHub](https://img.shields.io/github/license/budarin/use-route)](https://github.com/budarin/use-route)
24
+
25
+ **Живое демо:** запуск в браузере без установки.
26
+
27
+ - [Open in StackBlitz](https://stackblitz.com/github/budarin/use-route/tree/master/demo)
28
+ - [Open in CodeSandbox](https://codesandbox.io/p/sandbox/github/budarin/use-route/tree/master/demo)
29
+
30
+ ## ✨ Особенности
31
+
32
+ - ✅ **Динамическое дерево**маршрутизация в рантайме по pathname/params, без статичного route tree
33
+ - ✅ **Динамическая история**при каждом `navigate`/`replace` выбирается `push` или `replace`
34
+ - ✅ **Navigation API** `navigation.navigate()`, `back()`, `forward()`, `traverseTo()`
35
+ - ✅ **URLPattern** для парсинга `:params`
36
+ - ✅ **useSyncExternalStore**concurrent-safe, SSR-ready
37
+ - ✅ **canGoBack(n)**, `canGoForward(n)` точная проверка по истории
38
+ - ✅ **state** чтение state текущей записи истории, установка при `navigate`/`replace`, обновление на месте через `updateState(state)`
39
+ - ✅ **LRU кэш URL** с настраиваемым лимитом (по умолчанию 50)
40
+ - ✅ **O(1) поиск** при получении `historyIndex`
41
+ - ✅ **0 провайдеров** нет необходимости - просто `useRoute()`
42
+ - ✅ **~4 kB** gzipped (исходный код; с минификацией в бандле меньше)
43
+
44
+ ## ⚠️ Когда не использовать
45
+
46
+ - **Нужна поддержка старых браузеров**хук требует Navigation API и URLPattern (см. таблицу версий). Для старых браузеров возьмите React Router, TanStack Router или роутер с полифиллами.
47
+ - **Нужны loaders или загрузка данных в роутере** здесь загрузка данных не входит в зону ответственности; её делают use cases и сервисы. Если вы хотите loaders/данные «из коробки» в маршруте подойдут React Router (loaders) или TanStack Router.
48
+ - **Нужно декларативное дерево маршрутов**хук не предоставляет `<Route>` / `<Routes>`; что рендерить, вы решаете в коде по `pathname`/`params`. Если важна именно декларативная вложенная структура маршрутов — используйте один из перечисленных роутеров.
49
+ - **Нужны встроенные guards, redirects, lazy-роуты** — этого в пакете нет; реализуется в приложении поверх хука.
50
+
51
+ В остальных случаях (современные браузеры, чистая архитектура, динамические маршруты) пакет подходит.
52
+
53
+ ## 🚀 Быстрый старт
54
+
55
+ ```bash
56
+ npm i @budarin/use-route
57
+ ```
58
+
59
+ ```typescript
60
+ import { useRoute, configureRouter } from '@budarin/use-route';
61
+
62
+
63
+ function App() {
64
+ const {
65
+ pathname,
66
+ params,
67
+ searchParams,
68
+ navigate,
69
+ go,
70
+ canGoBack
71
+ } = useRoute('/users/:id'); // опционально: паттерн для парсинга params
72
+
73
+ return (
74
+ <div>
75
+ <h1>Current: {pathname}</h1>
76
+ <p>User ID: {params.id}</p>
77
+
78
+ <button onClick={() => navigate('/users/123')}>
79
+ To Profile
80
+ </button>
81
+
82
+ <button onClick={() => go(-1)} disabled={!canGoBack()}>
83
+ ← Back
84
+ </button>
85
+ </div>
86
+ );
87
+ }
88
+ ```
89
+
90
+ ## 📖 API
91
+
92
+ ### `useRoute(pattern?: string | PathMatcher, options?: UseRouteOptions)` / `useRoute(options: UseRouteOptions)`
93
+
94
+ **Формы вызова:**
95
+
96
+ - **`useRoute()`** — без pattern и опций.
97
+ - **`useRoute(pattern)`** — только pattern (строка или PathMatcher).
98
+ - **`useRoute(pattern, options)`** — pattern и опции (например `section`).
99
+ - **`useRoute({ section: '/dashboard' })`** — только опции, без pattern (раздел под глобальным base; pathname и navigate относительно раздела).
100
+
101
+ **Параметры:**
102
+
103
+ - **`pattern`** (опционально) — строка-паттерн (URLPattern) или PathMatcher для парсинга `params` и `matched`.
104
+ - **`options`** (опционально)
105
+
106
+ **`section`**: путь раздела под глобальным base (например `/dashboard`). `navigate(to)` по умолчанию добавляет к путям полный префикс (base + section). Комбинируется с глобальным `base` из `configureRouter`, не заменяет его. В компонентах раздела вызывайте `useRoute({ section: '/dashboard' })` и работайте с путями относительно раздела.
107
+
108
+ **Возвращает:**
109
+
110
+ ```typescript
111
+ {
112
+ // Текущее состояние
113
+ location: string;
114
+ pathname: string;
115
+ searchParams: URLSearchParams; // только чтение, не мутировать
116
+ params: Record<string, string>;
117
+ historyIndex: number;
118
+ state?: unknown; // state текущей записи истории (getState() / history.state)
119
+ matched?: boolean; // true/false при переданном pattern, иначе undefined
120
+
121
+ // Навигация
122
+ navigate: (to: string | URL, options?) => Promise<void>; // Navigation API; same-document при перехвате navigate + intercept()
123
+ back: () => void;
124
+ forward: () => void;
125
+ go: (delta: number) => void;
126
+ replace: (to: string | URL, options?: NavigateOptions) => Promise<void>;
127
+ updateState: (state: unknown) => void; // обновить state текущей записи без навигации
128
+ canGoBack: (steps?: number) => boolean;
129
+ canGoForward: (steps?: number) => boolean;
130
+ }
131
+ ```
132
+
133
+ **Опции методов `navigate` и `replace`** (один интерфейс **NavigateOptions**):
134
+
135
+ ```typescript
136
+ {
137
+ history?: 'push' | 'replace' | 'auto'; // по умолчанию из configureRouter или 'auto'
138
+ state?: unknown; // опциональные данные перехода (только подсказки для UX); подробнее — раздел про state ниже
139
+ base?: string; // полная подстановка префикса для этого вызова: '' или '/' без префикса (другое приложение); иначе полный путь (напр. '/auth')
140
+ section?: string; // переопределение секции для этого вызова: '' — корень приложения (только global base); '/path' — другая секция
141
+ }
142
+ ```
143
+
144
+ **`state`** — произвольные данные, которые вы передаёте вместе с переходом в `navigate(to, { state })` или `replace(to, { state })`. Используйте только для опциональных подсказок (скролл, откуда пришли, префилл формы): страница должна корректно работать и при заходе по прямой ссылке без state. Подробно: см. ниже «Параметр state: когда добавлять в историю».
145
+
146
+ **`replace(to, options?)`** — то же, что `navigate(to, { ...options, history: 'replace' })`. Опции те же, что у **navigate** (state, base, section); поле **history** игнорируется (всегда замена записи).
147
+
148
+ **`updateState(state)`** — обновляет state **текущей** записи истории без навигации. Подписчики хука получают новый state; URL не меняется, новая запись в истории не создаётся. Удобно для черновика формы, позиции скролла и т.п.
149
+
150
+ **Параметр state: когда добавлять в историю и какое состояние можно передавать**
151
+
152
+ Многие разработчики ни разу не используют state при переходах это нормально. State нужен только в узких сценариях. Ниже когда его стоит добавлять, какое состояние можно передавать и какое нельзя, чтобы не было недопониманий.
153
+
154
+ **Что такое state и откуда он берётся.** State это произвольные данные, которые вы передаёте в `navigate(to, { state })` или `replace(to, { state })`. Они сохраняются в записи истории (Navigation API) и доступны в хуке через поле **`state`** в возвращаемом объекте. **Важно:** state появляется только при программном переходе (ваш вызов navigate/replace). Если пользователь попал на тот же URL извне — ввёл адрес в строке, перешёл по букмарку, по ссылке с другого сайта, обновил страницу — для этой записи истории state нет. Поэтому поведение страницы не должно от state критически зависеть.
155
+
156
+ **Когда нужно добавлять state в историю.** Добавляйте state только когда вы хотите передать «подсказку» для целевой страницы, которая улучшает UX при программном переходе, но не обязательна для корректной работы:
157
+
158
+ - **Подсказка для скролла** уходя со страницы списка, сохраняете в state позицию скролла; по «Назад» можно вернуть пользователя на то же место. Если зашли по прямой ссылке — state нет, показываете список с начала.
159
+ - **Подсказка «откуда пришли»** — переход с поиска на карточку: в state передаёте `{ from: 'search', highlight: 'keyword' }`; на карточке можно подсветить слово. При заходе по прямой ссылке подсветки нет — страница остаётся корректной.
160
+ - **Опциональный префилл формы**переход «Редактировать» из списка: в state передаёте черновик; на странице редактирования при наличии state подставляете его, при отсутствии грузите данные по id из URL/сервера.
161
+ - **Черновик формы на текущей странице** — при вводе в форму можно периодически сохранять черновик в state текущей записи через `updateState(draft)`; по «Назад» пользователь вернётся на эту страницу с тем же state, и вы подставите черновик. Без state показываете пустую форму или грузите данные по URL.
162
+ - **Источник перехода (аналитика, UI)**в state передаёте `{ source: 'dashboard' }`; целевая страница может отправить это в аналитику или чуть изменить UI. При заходе по ссылке без state считаете источник «прямой» или «unknown».
163
+
164
+ **Какое state можно передавать.** Только то, что является **опциональным улучшением**: подсказки для скролла, флаги «откуда пришли», опциональный префилл, метаданные для аналитики. Правило: целевая страница должна корректно работать и без state (при прямом заходе по URL).
165
+
166
+ **Какое state нельзя передавать.** Не используйте state для того, без чего страница работает некорректно или неполно:
167
+
168
+ - **Обязательные данные страницы** — например, результаты поиска только из state. При переходе по ссылке `/search?q=foo` state нет — экран пустой. Результаты должны браться из query или сервера.
169
+ - **То, что должно быть в URL (шаринг, букмарк)** — state не попадает в URL. Если поведение страницы должно воспроизводиться по одной ссылке — используйте pathname и query, не state.
170
+ - **Авторизация, права, критичные данные** — не опирайтесь на state: пользователь может открыть URL напрямую. Проверки — по сессии/серверу.
171
+ - **Основной контент страницы** что показывать, определяется URL и данными с бэкенда. State — только для подсказок, не источник правды.
172
+
173
+ **Итог.** State в истории — опциональный инструмент для «передать что-то вместе с переходом», когда это улучшение, а не требование. Если сомневаетесь — можно не использовать; в большинстве приложений достаточно pathname, query и запросов к API.
174
+
175
+ **`configureRouter(config)`** глобальная настройка один раз при старте приложения:
176
+
177
+ ```typescript
178
+ configureRouter({
179
+ urlCacheLimit: 50, // лимит LRU-кэша URL (по умолчанию 50)
180
+ defaultHistory: 'replace', // history по умолчанию для всех navigate()
181
+ logger: myLogger, // логгер (дефолт: console)
182
+ base: '/app', // базовый путь: pathname без base, navigate(to) добавляет base к относительным путям
183
+ initialLocation: request.url, // для SSR: начальный URL при рендере на сервере (нет window)
184
+ });
185
+ ```
186
+
187
+ **`base`** (глобальный) — нужен только когда **приложение располагается не в корне домена**, а по подпути. Пример: сайт `https://example.com/` — корень; ваше приложение отдаётся по `https://example.com/app/`, то есть все его маршруты физически лежат под путём `/app`. В этом случае задайте `base: '/app'`: `navigate('/dashboard')` переходит на `/app/dashboard`. Если приложение в корне домена (`https://example.com/`), глобальный `base` задавать не нужно — префикс не используется.
188
+
189
+ **`initialLocation`** при SSR (нет `window`) хук не знает URL запроса. Задайте `initialLocation: request.url` (или полный URL страницы) один раз перед рендером запроса — тогда `pathname` и `searchParams` будут соответствовать запросу. На клиенте не используется. **По умолчанию задавать не нужно:** если на SSR `initialLocation` не задан, используется `'/'` (pathname и searchParams для корня).
190
+
191
+ **`logger`** тип `Logger` объект с методами `trace`, `debug`, `info`, `warn`, `error` (как у `console`). Уровни: `LoggerLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'`. Если не передан — используется `console`.
192
+
193
+ **`pattern` (опционально):** строка-шаблон пути (нативный **URLPattern**) или функция **PathMatcher**. См. ниже.
194
+
195
+ **Строка (URLPattern).** Поддерживается:
196
+
197
+ - **Именованные параметры** — `:name` (имя как в JS: буквы, цифры, `_`). Значение сегмента попадает в `params[name]`.
198
+ - **Опциональные группы** — `{ ... }?`: часть пути можно сделать необязательной. Один паттерн покрывает пути разной глубины; в `params` только те ключи, для которых есть сегмент в URL.
199
+ - **Wildcard** — `*`: совпадает с «хвостом» пути; в `params` не попадает (числовые ключи из `groups` отфильтрованы).
200
+ - **Regexp в параметре** — `:name(регулярка)` для ограничения формата сегмента (например только цифры). В `params` по-прежнему строка.
201
+
202
+ ```typescript
203
+ useRoute('/users/:id');
204
+ useRoute('/elements/:elementId/*/:subElementId'); // wildcard
205
+
206
+ // Опциональные группы
207
+ useRoute('/users/:id{/posts/:postId}?');
208
+
209
+ // Ограничение формата параметра (regexp)
210
+ useRoute('/blog/:year(\\d+)/:month(\\d+)');
211
+
212
+ // Функция-матчер (иерархия, кастомный разбор)
213
+ const matchPost = (pathname: string) => ({ matched: pathname.startsWith('/posts/'), params: {} });
214
+ useRoute(matchPost);
215
+ ```
216
+
217
+ Полный синтаксис URLPattern: [URL Pattern API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API), [WHATWG URL Pattern](https://urlpattern.spec.whatwg.org/).
218
+
219
+ **PathMatcher** — функция, которую можно передать вместо строки, когда одного URLPattern недостаточно (иерархия сегментов, кастомная валидация, разбор через `split` или RegExp). Хук вызывает её с текущим `pathname` и подставляет возвращённые `matched` и `params` в состояние.
220
+
221
+ - **Параметр:** `pathname: string` — текущий pathname (без origin и query).
222
+ - **Возвращаемый тип:** `{ matched: boolean; params: RouteParams }`.
223
+ `matched` совпал ли путь с вашей логикой; `params` — объект «имя параметра → значение сегмента» (тип `RouteParams` = `Record<RouteParamName, RouteParamValue>`).
224
+ - **Где использовать:** иерархические маршруты (например, `postId` только при наличии `userId`), пути с жёстким порядком сегментов, кастомные правила, которые не выразить одним URLPattern.
225
+
226
+ ## 🛠 Примеры
227
+
228
+ ### 1. Базовая навигация (pathname, navigate)
229
+
230
+ ```tsx
231
+ import { useRoute } from '@budarin/use-route';
232
+
233
+ function BasicNavigationExample() {
234
+ const { pathname, navigate } = useRoute();
235
+
236
+ return (
237
+ <div>
238
+ <p>Текущий путь: {pathname}</p>
239
+ <button type="button" onClick={() => navigate('/posts')}>
240
+ К постам
241
+ </button>
242
+ <button type="button" onClick={() => navigate('/')}>
243
+ На главную
244
+ </button>
245
+ </div>
246
+ );
247
+ }
248
+ ```
249
+
250
+ ### 2. Параметры пути (useRoute('/users/:id'), params)
251
+
252
+ ```tsx
253
+ import { useRoute } from '@budarin/use-route';
254
+
255
+ function ParamsExample() {
256
+ const { params, pathname, navigate } = useRoute('/users/:id');
257
+
258
+ return (
259
+ <div>
260
+ <p>Pathname: {pathname}</p>
261
+ <p>User ID из params: {params.id ?? '—'}</p>
262
+ <button type="button" onClick={() => navigate('/users/123')}>
263
+ User 123
264
+ </button>
265
+ <button type="button" onClick={() => navigate('/users/456')}>
266
+ User 456
267
+ </button>
268
+ </div>
269
+ );
270
+ }
271
+ ```
272
+
273
+ ### 3. Search params (query)
274
+
275
+ ```tsx
276
+ import { useRoute } from '@budarin/use-route';
277
+
278
+ function SearchParamsExample() {
279
+ const { searchParams, navigate, pathname } = useRoute('/posts');
280
+ const pageParam = searchParams.get('page') ?? '1';
281
+ const currentPage = Number.parseInt(pageParam, 10) || 1;
282
+
283
+ return (
284
+ <div>
285
+ <p>Путь: {pathname}</p>
286
+ <p>Страница: {currentPage}</p>
287
+ <button
288
+ type="button"
289
+ onClick={() => navigate(`/posts?page=${currentPage - 1}`)}
290
+ disabled={currentPage <= 1}
291
+ >
292
+ Пред. страница
293
+ </button>
294
+ <button type="button" onClick={() => navigate(`/posts?page=${currentPage + 1}`)}>
295
+ След. страница
296
+ </button>
297
+ </div>
298
+ );
299
+ }
300
+ ```
301
+
302
+ ### 4. История (back, forward, go, canGoBack, canGoForward)
303
+
304
+ ```tsx
305
+ import { useRoute } from '@budarin/use-route';
306
+
307
+ function HistoryExample() {
308
+ const { go, back, forward, canGoBack, canGoForward } = useRoute();
309
+
310
+ return (
311
+ <div>
312
+ <button type="button" onClick={() => back()} disabled={!canGoBack()}>
313
+ Назад
314
+ </button>
315
+ <button type="button" onClick={() => go(-2)} disabled={!canGoBack(2)}>
316
+ ← 2 шага
317
+ </button>
318
+ <button type="button" onClick={() => go(1)} disabled={!canGoForward()}>
319
+ Вперёд →
320
+ </button>
321
+ <button type="button" onClick={() => forward()} disabled={!canGoForward()}>
322
+ Forward
323
+ </button>
324
+ </div>
325
+ );
326
+ }
327
+ ```
328
+
329
+ ### 5. Push и replace (и метод replace())
330
+
331
+ ```tsx
332
+ import { useRoute } from '@budarin/use-route';
333
+
334
+ function PushReplaceExample() {
335
+ const { navigate, replace, pathname } = useRoute();
336
+
337
+ return (
338
+ <div>
339
+ <p>Текущий путь: {pathname}</p>
340
+ <button type="button" onClick={() => navigate('/step-push', { history: 'push' })}>
341
+ Перейти (push) — в истории появится запись
342
+ </button>
343
+ <button type="button" onClick={() => navigate('/step-replace', { history: 'replace' })}>
344
+ Перейти (replace через navigate)
345
+ </button>
346
+ <button type="button" onClick={() => replace('/step-replace-method')}>
347
+ Перейти через replace() — то же, что history: 'replace'
348
+ </button>
349
+ </div>
350
+ );
351
+ }
352
+ ```
353
+
354
+ ### 6. State (чтение, установка при навигации, обновление на месте)
355
+
356
+ State текущей записи истории доступен в хуке как **`state`**. Установить state при переходе — через опцию **`state`** в `navigate` или `replace`. Обновить state **текущей** страницы без перехода — **`updateState(state)`**. Используйте только для опциональных подсказок (скролл, откуда пришли, префилл формы); страница должна корректно работать и при заходе по прямой ссылке без state.
357
+
358
+ ```tsx
359
+ import { useRoute } from '@budarin/use-route';
360
+
361
+ function StateExample() {
362
+ const { state, navigate, updateState, pathname } = useRoute();
363
+
364
+ return (
365
+ <div>
366
+ <p>Текущий путь: {pathname}</p>
367
+ <p>State записи: {state != null ? JSON.stringify(state) : '—'}</p>
368
+ <button
369
+ type="button"
370
+ onClick={() => navigate('/detail', { state: { from: 'list', scrollY: 100 } })}
371
+ >
372
+ Перейти с state
373
+ </button>
374
+ <button type="button" onClick={() => updateState({ draft: true, step: 2 })}>
375
+ Обновить state текущей записи (без навигации)
376
+ </button>
377
+ </div>
378
+ );
379
+ }
380
+ ```
381
+
382
+ ### 7. matched (совпадение pathname с pattern)
383
+
384
+ ```tsx
385
+ import { useRoute } from '@budarin/use-route';
386
+
387
+ function MatchedExample() {
388
+ const { pathname, matched, params } = useRoute('/users/:id');
389
+
390
+ return (
391
+ <div>
392
+ <p>Pathname: {pathname}</p>
393
+ <p>Pattern /users/:id совпал: {matched === true ? 'да' : 'нет'}</p>
394
+ {matched === true ? (
395
+ <p>User ID: {params.id}</p>
396
+ ) : (
397
+ <p>Это не страница пользователя (path не совпал с /users/:id).</p>
398
+ )}
399
+ </div>
400
+ );
401
+ }
402
+ ```
403
+
404
+ ### 8. Функция-матчер (PathMatcher)
405
+
406
+ Удобно, когда один URLPattern или простой regex не справляется: иерархия (например, `postId` только вместе с `userId`), кастомная валидация, разный порядок сегментов. Ниже матчер для `/users/:userId` и `/users/:userId/posts/:postId`: два параметра, причём `postId` допустим только после литерала `posts` и только при наличии `userId`.
407
+
408
+ ```tsx
409
+ import { useRoute, type PathMatcher } from '@budarin/use-route';
410
+
411
+ const matchUserPosts: PathMatcher = (pathname) => {
412
+ const segments = pathname.split('/').filter(Boolean);
413
+
414
+ if (segments[0] !== 'users' || !segments[1]) return { matched: false, params: {} };
415
+
416
+ const params: Record<string, string> = { userId: segments[1] };
417
+
418
+ if (segments[2] === 'posts' && segments[3]) {
419
+ params.postId = segments[3];
420
+ }
421
+
422
+ return { matched: true, params };
423
+ };
424
+
425
+ function UserPostsExample() {
426
+ const { pathname, matched, params } = useRoute(matchUserPosts);
427
+
428
+ if (!matched) return null;
429
+
430
+ return (
431
+ <div>
432
+ <p>Путь: {pathname}</p>
433
+ <p>User ID: {params.userId}</p>
434
+ {params.postId && <p>Post ID: {params.postId}</p>}
435
+ </div>
436
+ );
437
+ }
438
+ ```
439
+
440
+ ### 9. Глобальный base (приложение по подпути, не в корне домена)
441
+
442
+ Когда приложение располагается **не в корне домена**, а по подпути (например `https://example.com/app/` — все маршруты под `/app`), задайте в конфиге `base: '/app'`. Тогда `navigate(to)` добавляет base к относительным путям. Для одноразового перехода «вне» этого пути (например на `/login`) используйте опцию `base` в `navigate` или `replace`: `navigate('/login', { base: '' })`.
443
+
444
+ ```tsx
445
+ import { useRoute, configureRouter } from '@budarin/use-route';
446
+ configureRouter({ base: '/app' });
447
+
448
+ function AppUnderBase() {
449
+ const { pathname, navigate } = useRoute();
450
+
451
+ return (
452
+ <div>
453
+ <p>Текущий путь: {pathname}</p>
454
+
455
+ <button type="button" onClick={() => navigate('/dashboard')}>
456
+ В дашборд → /app/dashboard
457
+ </button>
458
+
459
+ <button type="button" onClick={() => navigate('/login', { base: '' })}>
460
+ На логин (/login)
461
+ </button>
462
+
463
+ <button type="button" onClick={() => navigate('/auth/profile', { base: '/auth' })}>
464
+ В другой раздел (/auth/profile)
465
+ </button>
466
+ </div>
467
+ );
468
+ }
469
+ ```
470
+
471
+ ### 10. Section в хуке (options.section)
472
+
473
+ Когда у приложения несколько разделов по своим подпутям (`/dashboard`, `/admin`, `/auth`), в компонентах раздела задайте **section**: вызовите `useRoute({ section: '/dashboard' })`. Тогда `navigate(to)` по умолчанию добавляет полный префикс (base + section). <br />
474
+ Переход в корень приложения (без секции): `navigate('/', { section: '' })`. <br />
475
+ Переход «вне» приложения: `navigate('/login', { base: '' })`.
476
+
477
+ ```tsx
478
+ import { useRoute } from '@budarin/use-route';
479
+
480
+ const DASHBOARD_BASE = '/dashboard';
481
+
482
+ function DashboardSection() {
483
+ // Section для раздела: pathname и navigate относительно /dashboard (под глобальным base, если задан)
484
+ const { pathname, navigate } = useRoute({ section: DASHBOARD_BASE });
485
+
486
+ return (
487
+ <div>
488
+ {/* При URL /dashboard/reports pathname === '/reports' */}
489
+ <p>Раздел Dashboard. Путь: {pathname}</p>
490
+
491
+ <button type="button" onClick={() => navigate('/reports')}>
492
+ Отчёты → /dashboard/reports
493
+ </button>
494
+
495
+ <button type="button" onClick={() => navigate('/settings')}>
496
+ Настройки → /dashboard/settings
497
+ </button>
498
+
499
+ {/* Переход в корень приложения (без секции) или на главную */}
500
+ <button type="button" onClick={() => navigate('/', { section: '' })}>
501
+ На главную
502
+ </button>
503
+ </div>
504
+ );
505
+ }
506
+ ```
507
+
508
+ ### 11. initialLocation (SSR)
509
+
510
+ При рендере на сервере нет `window`, поэтому хук не знает URL запроса. Задайте `initialLocation` в конфиге один раз перед рендером запроса (например `request.url`) — тогда `pathname` и `searchParams` будут соответствовать запросу. На клиенте `initialLocation` не используется.
511
+
512
+ ```tsx
513
+ // Серверный обработчик (псевдокод: Express, Fastify, Next и т.д.)
514
+ import { configureRouter } from '@budarin/use-route';
515
+ import { renderToStaticMarkup } from 'react-dom/server';
516
+ import { App } from './App';
517
+
518
+ function handleRequest(req, res) {
519
+ // Один раз перед рендером этого запроса
520
+ configureRouter({ initialLocation: req.url });
521
+
522
+ const html = renderToStaticMarkup(<App />);
523
+ res.send(html);
524
+ }
525
+
526
+ // В App компоненты используют useRoute() — на сервере получают pathname/searchParams из initialLocation
527
+ function App() {
528
+ const { pathname, searchParams } = useRoute();
529
+ return (
530
+ <div>
531
+ <p>Pathname: {pathname}</p>
532
+ <p>Query: {searchParams.toString()}</p>
533
+ </div>
534
+ );
535
+ }
536
+ ```
537
+
538
+ ### 12. Компонент Link (пример реализации)
539
+
540
+ Минимальный пример компонента-ссылки поверх хука. Можно взять за основу и развивать под себя: активное состояние, префетч, аналитика, стили.
541
+
542
+ ```tsx
543
+ import { useRoute } from '@budarin/use-route';
544
+ import { useCallback, type ComponentPropsWithoutRef } from 'react';
545
+
546
+ interface LinkProps extends ComponentPropsWithoutRef<'a'> {
547
+ to: string;
548
+ replace?: boolean;
549
+ }
550
+
551
+ function Link({ to, replace = false, onClick, ...props }: LinkProps) {
552
+ const { navigate } = useRoute();
553
+
554
+ const handleClick = useCallback(
555
+ (e: React.MouseEvent<HTMLAnchorElement>) => {
556
+ onClick?.(e);
557
+
558
+ if (!e.defaultPrevented) {
559
+ e.preventDefault();
560
+ navigate(to, { history: replace ? 'replace' : 'push' });
561
+ }
562
+ },
563
+ [navigate, to, replace, onClick]
564
+ );
565
+
566
+ return <a {...props} href={to} onClick={handleClick} />;
567
+ }
568
+
569
+ // Использование:
570
+ // <Link to="/posts">Посты</Link>
571
+ // <Link to="/users/123" replace>Профиль (replace)</Link>
572
+ ```
573
+
574
+ ## ⚙️ Установка
575
+
576
+ ```bash
577
+ npm i @budarin/use-route
578
+
579
+ pnpm add @budarin/use-route
580
+
581
+ yarn add @budarin/use-route
582
+ ```
583
+
584
+ TypeScript: типы включены.
585
+
586
+ **`tsconfig.json` (рекомендуется):**
587
+
588
+ ```json
589
+ {
590
+ "compilerOptions": {
591
+ "lib": ["ES2021", "DOM", "DOM.Iterable"],
592
+ "moduleResolution": "bundler",
593
+ "jsx": "react-jsx"
594
+ }
595
+ }
596
+ ```
597
+
598
+ **Polyfills (опционально):**
599
+
600
+ ```bash
601
+ npm i urlpattern-polyfill
602
+ ```
603
+
604
+ ```typescript
605
+ // src/polyfills.ts
606
+ import 'urlpattern-polyfill';
607
+ ```
608
+
609
+ ## 🌐 Браузеры и Node.js
610
+
611
+ Пакет **работает только** там, где есть **Navigation API** и **URLPattern**. Ограничивающие требования — версии ниже; без них хук не запустится.
612
+
613
+ | API | Chrome/Edge | Firefox | Safari | Node.js |
614
+ | -------------- | ----------- | ------- | ------ | ------- |
615
+ | Navigation API | 102+ | 109+ | 16.4+ | — |
616
+ | URLPattern | 110+ | 115+ | 16.4+ | 23.8+ |
617
+
618
+
619
+ ## 🎛 Под капотом
620
+
621
+ - **Navigation API:** подписка на события `navigate`, `currententrychange`; для same-origin навигации — перехват `navigate` и вызов `event.intercept()`
622
+ - `useSyncExternalStore` на navigation события
623
+ - LRU кэш parsed URL (настраиваемый лимит)
624
+ - Map для O(1) поиска `historyIndex`
625
+ - URLPattern для `:params`
626
+ - Кэш compiled patterns; `clearRouterCaches()` — очистка кэшей (тесты, смена окружения)
627
+ - SSR-safe (checks `typeof window`)
628
+
629
+ ## 🤝 Лицензия
630
+
631
+ MIT © budarin