@budarin/use-route 1.0.0
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.
- package/.github/workflows/ci.yml +45 -0
- package/LICENSE +21 -0
- package/README.md +582 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +398 -0
- package/dist/index.js.map +1 -0
- package/dist/native-api-types.d.ts +62 -0
- package/dist/native-api-types.d.ts.map +1 -0
- package/dist/native-api-types.js +9 -0
- package/dist/native-api-types.js.map +1 -0
- package/dist/types.d.ts +98 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +24 -0
- package/dist/types.js.map +1 -0
- package/package.json +40 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master]
|
|
6
|
+
paths:
|
|
7
|
+
- 'src/**'
|
|
8
|
+
- 'tests/**'
|
|
9
|
+
- 'package.json'
|
|
10
|
+
- 'pnpm-lock.yaml'
|
|
11
|
+
- 'tsconfig.json'
|
|
12
|
+
- 'vitest.config.ts'
|
|
13
|
+
- '.prettierrc'
|
|
14
|
+
- '.prettierignore'
|
|
15
|
+
- '.github/workflows/**'
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
check:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- uses: pnpm/action-setup@v4
|
|
24
|
+
with:
|
|
25
|
+
version: 9
|
|
26
|
+
|
|
27
|
+
- uses: actions/setup-node@v4
|
|
28
|
+
with:
|
|
29
|
+
node-version: '20'
|
|
30
|
+
cache: 'pnpm'
|
|
31
|
+
|
|
32
|
+
- name: Установка зависимостей
|
|
33
|
+
run: pnpm install --frozen-lockfile
|
|
34
|
+
|
|
35
|
+
- name: Lint
|
|
36
|
+
run: pnpm lint
|
|
37
|
+
|
|
38
|
+
- name: Type-check
|
|
39
|
+
run: pnpm type-check
|
|
40
|
+
|
|
41
|
+
- name: Тесты
|
|
42
|
+
run: pnpm test:run
|
|
43
|
+
|
|
44
|
+
- name: Сборка
|
|
45
|
+
run: pnpm build
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Вадим Бударин
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
# @budarin/use-route
|
|
2
|
+
|
|
3
|
+
**Минимум кода. Максимум SPA-навигации.**
|
|
4
|
+
|
|
5
|
+
Инфраструктурный хук для React на **Navigation API** + **URLPattern**. Без провайдеров, без контекста, без бизнес-логики.
|
|
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
|
+
[](https://github.com/budarin/use-route/actions/workflows/ci.yml)
|
|
19
|
+
[](https://www.npmjs.com/package/@budarin/use-route)
|
|
20
|
+
[](https://www.npmjs.com/package/@budarin/use-route)
|
|
21
|
+
[](https://bundlephobia.com/result?p=@budarin/use-route)
|
|
22
|
+
[](https://github.com/budarin/use-route)
|
|
23
|
+
|
|
24
|
+
## ✨ Особенности
|
|
25
|
+
|
|
26
|
+
- ✅ **Динамическое дерево** — маршрутизация в рантайме по pathname/params, без статичного route tree
|
|
27
|
+
- ✅ **Динамическая история** — при каждом `navigate`/`replace` выбирается `push` или `replace`
|
|
28
|
+
- ✅ **Navigation API** (`window.navigation.navigate()`, `traverseTo()`, `back/forward/go(n)`)
|
|
29
|
+
- ✅ **URLPattern** для парсинга `:params` (только актуальные браузеры и Node.js)
|
|
30
|
+
- ✅ `useSyncExternalStore` — concurrent-safe, SSR-ready
|
|
31
|
+
- ✅ `canGoBack(n)`, `canGoForward(n)` — точная проверка по истории
|
|
32
|
+
- ✅ **LRU кэш URL** с настраиваемым лимитом (по умолчанию 50)
|
|
33
|
+
- ✅ **O(1) поиск** `historyIndex` через Map
|
|
34
|
+
- ✅ **Только актуальные браузеры и Node.js** (Navigation API + URLPattern), без fallback
|
|
35
|
+
- ✅ **0 провайдеров** — просто `useRoute()`
|
|
36
|
+
- ✅ **~4 kB** gzipped (исходный код; с минификацией в бандле меньше)
|
|
37
|
+
|
|
38
|
+
## ⚠️ Когда не использовать
|
|
39
|
+
|
|
40
|
+
- **Нужна поддержка старых браузеров** — хук опирается на Navigation API и URLPattern, без fallback на History API. Если целевая аудитория использует браузеры без этих API, возьмите React Router, TanStack Router или другой роутер с полифиллами.
|
|
41
|
+
- **Нужны loaders или загрузка данных в роутере** — здесь загрузка данных не входит в зону ответственности; её делают use cases и сервисы. Если вы хотите loaders/данные «из коробки» в маршруте — подойдут React Router (loaders) или TanStack Router.
|
|
42
|
+
- **Нужно декларативное дерево маршрутов** — хук не предоставляет `<Route>` / `<Routes>`; что рендерить, вы решаете в коде по `pathname`/`params`. Если важна именно декларативная вложенная структура маршрутов — используйте один из перечисленных роутеров.
|
|
43
|
+
- **Нужны встроенные guards, redirects, lazy-роуты** — этого в пакете нет; реализуется в приложении поверх хука.
|
|
44
|
+
|
|
45
|
+
В остальных случаях (современные браузеры, чистая архитектура, динамические маршруты, без loaders в роутере) пакет подходит.
|
|
46
|
+
|
|
47
|
+
## 🚀 Быстрый старт
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm i @budarin/use-route
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import { useRoute, configureRouter } from '@budarin/use-route';
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
function App() {
|
|
58
|
+
const {
|
|
59
|
+
pathname,
|
|
60
|
+
params,
|
|
61
|
+
searchParams,
|
|
62
|
+
navigate,
|
|
63
|
+
go,
|
|
64
|
+
canGoBack
|
|
65
|
+
} = useRoute('/users/:id'); // опционально: паттерн для парсинга params
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div>
|
|
69
|
+
<h1>Current: {pathname}</h1>
|
|
70
|
+
<p>User ID: {params.id}</p>
|
|
71
|
+
|
|
72
|
+
<button onClick={() => navigate('/users/123')}>
|
|
73
|
+
To Profile
|
|
74
|
+
</button>
|
|
75
|
+
|
|
76
|
+
<button onClick={() => go(-1)} disabled={!canGoBack()}>
|
|
77
|
+
← Back
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## 📖 API
|
|
85
|
+
|
|
86
|
+
### `useRoute(pattern?: string | PathMatcher, options?: UseRouteOptions)` / `useRoute(options: UseRouteOptions)`
|
|
87
|
+
|
|
88
|
+
**Формы вызова:**
|
|
89
|
+
|
|
90
|
+
- **`useRoute()`** — без pattern и опций.
|
|
91
|
+
- **`useRoute(pattern)`** — только pattern (строка или PathMatcher).
|
|
92
|
+
- **`useRoute(pattern, options)`** — pattern и опции (например `base`).
|
|
93
|
+
- **`useRoute({ base: '/dashboard' })`** — только опции, без pattern (удобно для раздела с локальным base; не нужно передавать `undefined`).
|
|
94
|
+
|
|
95
|
+
**Параметры:**
|
|
96
|
+
|
|
97
|
+
- **`pattern`** (опционально) — строка-паттерн (URLPattern) или PathMatcher для парсинга `params` и `matched`.
|
|
98
|
+
- **`options`** (опционально) — **`base`**: локальный базовый путь для этого поддерева (раздел приложения под своим подпутём). Тогда `pathname` возвращается без этого префикса, а `navigate(to)` по умолчанию добавляет его к относительным путям. Приоритет над глобальным `base` из `configureRouter`. Подходит, когда у приложения несколько разделов по разным подпутям (`/dashboard`, `/admin`, `/auth`): в компонентах раздела вызывайте `useRoute({ base: '/dashboard' })` и работайте с путями относительно раздела.
|
|
99
|
+
|
|
100
|
+
**Возвращает:**
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
{
|
|
104
|
+
// Текущее состояние
|
|
105
|
+
location: string;
|
|
106
|
+
pathname: string;
|
|
107
|
+
searchParams: URLSearchParams; // только чтение, не мутировать
|
|
108
|
+
params: Record<string, string>;
|
|
109
|
+
historyIndex: number;
|
|
110
|
+
matched?: boolean; // true/false при переданном pattern, иначе undefined
|
|
111
|
+
|
|
112
|
+
// Навигация
|
|
113
|
+
navigate: (to: string | URL, options?) => Promise<void>; // резолвится при commit, см. Navigation API
|
|
114
|
+
back: () => void;
|
|
115
|
+
forward: () => void;
|
|
116
|
+
go: (delta: number) => void;
|
|
117
|
+
replace: (to: string | URL, options?: NavigateOptions) => Promise<void>;
|
|
118
|
+
canGoBack: (steps?: number) => boolean;
|
|
119
|
+
canGoForward: (steps?: number) => boolean;
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Опции `navigate` и `replace`** (один интерфейс **NavigateOptions**):
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
{
|
|
127
|
+
history?: 'push' | 'replace' | 'auto'; // по умолчанию из configureRouter или 'auto'
|
|
128
|
+
state?: unknown; // опциональные данные перехода (только подсказки для UX); подробнее — раздел про state ниже
|
|
129
|
+
base?: string; // базовый путь для этого вызова: undefined — из конфига; '' или '/' — без префикса; иначе — другой base (напр. '/auth')
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**`state`** — произвольные данные, которые вы передаёте вместе с переходом в `navigate(to, { state })` или `replace(to, { state })`. Используйте только для опциональных подсказок (скролл, откуда пришли, префилл формы): страница должна корректно работать и при заходе по прямой ссылке без state. Подробно: см. ниже «Параметр state: когда добавлять в историю».
|
|
134
|
+
|
|
135
|
+
**`replace(to, options?)`** — то же, что `navigate(to, { ...options, history: 'replace' })`. Опции те же, что у **navigate** (state, base); поле **history** игнорируется (всегда замена записи).
|
|
136
|
+
|
|
137
|
+
**Параметр state: когда добавлять в историю и какое состояние можно передавать**
|
|
138
|
+
|
|
139
|
+
Многие разработчики ни разу не используют state при переходах — это нормально. State нужен только в узких сценариях. Ниже — когда его стоит добавлять, какое состояние можно передавать и какое нельзя, чтобы не было недопониманий.
|
|
140
|
+
|
|
141
|
+
**Что такое state и откуда он берётся.** State — это произвольные данные, которые вы передаёте в `navigate(to, { state })` или `replace(to, { state })`. Они сохраняются в записи истории (Navigation API) и доступны на целевой странице через `window.navigation.currentEntry.getState()`. **Важно:** state появляется только при программном переходе (ваш вызов navigate/replace). Если пользователь попал на тот же URL извне — ввёл адрес в строке, перешёл по букмарку, по ссылке с другого сайта, обновил страницу — для этой записи истории state нет. Поэтому поведение страницы не должно от state критически зависеть.
|
|
142
|
+
|
|
143
|
+
**Когда нужно добавлять state в историю.** Добавляйте state только когда вы хотите передать «подсказку» для целевой страницы, которая улучшает UX при программном переходе, но не обязательна для корректной работы:
|
|
144
|
+
|
|
145
|
+
- **Подсказка для скролла** — уходя со страницы списка, сохраняете в state позицию скролла; по «Назад» можно вернуть пользователя на то же место. Если зашли по прямой ссылке — state нет, показываете список с начала.
|
|
146
|
+
- **Подсказка «откуда пришли»** — переход с поиска на карточку: в state передаёте `{ from: 'search', highlight: 'keyword' }`; на карточке можно подсветить слово. При заходе по прямой ссылке подсветки нет — страница остаётся корректной.
|
|
147
|
+
- **Опциональный префилл формы** — переход «Редактировать» из списка: в state передаёте черновик; на странице редактирования при наличии state подставляете его, при отсутствии — грузите данные по id из URL/сервера.
|
|
148
|
+
- **Источник перехода (аналитика, UI)** — в state передаёте `{ source: 'dashboard' }`; целевая страница может отправить это в аналитику или чуть изменить UI. При заходе по ссылке без state считаете источник «прямой» или «unknown».
|
|
149
|
+
|
|
150
|
+
**Какое state можно передавать.** Только то, что является **опциональным улучшением**: подсказки для скролла, флаги «откуда пришли», опциональный префилл, метаданные для аналитики. Правило: целевая страница должна корректно работать и без state (при прямом заходе по URL).
|
|
151
|
+
|
|
152
|
+
**Какое state нельзя передавать.** Не используйте state для того, без чего страница работает некорректно или неполно:
|
|
153
|
+
|
|
154
|
+
- **Обязательные данные страницы** — например, результаты поиска только из state. При переходе по ссылке `/search?q=foo` state нет — экран пустой. Результаты должны браться из query или сервера.
|
|
155
|
+
- **То, что должно быть в URL (шаринг, букмарк)** — state не попадает в URL. Если поведение страницы должно воспроизводиться по одной ссылке — используйте pathname и query, не state.
|
|
156
|
+
- **Авторизация, права, критичные данные** — не опирайтесь на state: пользователь может открыть URL напрямую. Проверки — по сессии/серверу.
|
|
157
|
+
- **Основной контент страницы** — что показывать, определяется URL и данными с бэкенда. State — только для подсказок, не источник правды.
|
|
158
|
+
|
|
159
|
+
**Итог.** State в истории — опциональный инструмент для «передать что-то вместе с переходом», когда это улучшение, а не требование. Если сомневаетесь — можно не использовать; в большинстве приложений достаточно pathname, query и запросов к API.
|
|
160
|
+
|
|
161
|
+
**`configureRouter(config)`** — глобальная настройка один раз при старте приложения:
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
configureRouter({
|
|
165
|
+
urlCacheLimit: 50, // лимит LRU-кэша URL (по умолчанию 50)
|
|
166
|
+
defaultHistory: 'replace', // history по умолчанию для всех navigate()
|
|
167
|
+
logger: myLogger, // логгер (дефолт: console)
|
|
168
|
+
base: '/app', // базовый путь: pathname без base, navigate(to) добавляет base к относительным путям
|
|
169
|
+
initialLocation: request.url, // для SSR: начальный URL при рендере на сервере (нет window)
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**`base`** (глобальный) — нужен только когда **приложение располагается не в корне домена**, а по подпути. Пример: сайт `https://example.com/` — корень; ваше приложение отдаётся по `https://example.com/app/`, то есть все его маршруты физически лежат под путём `/app`. В этом случае задайте `base: '/app'`: тогда `pathname` в хуке возвращается без префикса (как будто приложение в корне — `/dashboard` вместо `/app/dashboard`), а `navigate('/dashboard')` переходит на `/app/dashboard`. Если приложение в корне домена (`https://example.com/`), глобальный `base` задавать не нужно — префикс не используется.
|
|
174
|
+
|
|
175
|
+
**`initialLocation`** — при SSR (нет `window`) хук не знает URL запроса. Задайте `initialLocation: request.url` (или полный URL страницы) один раз перед рендером запроса — тогда `pathname` и `searchParams` будут соответствовать запросу. На клиенте не используется. **По умолчанию задавать не нужно:** если на SSR `initialLocation` не задан, используется `'/'` (pathname и searchParams для корня).
|
|
176
|
+
|
|
177
|
+
**Логгер:** тип `Logger` — объект с методами `trace`, `debug`, `info`, `warn`, `error` (как у `console`). Уровни: `LoggerLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'`. Если не передан — используется `console`.
|
|
178
|
+
|
|
179
|
+
**`pattern` (опционально):** строка-шаблон пути (нативный **URLPattern**) или функция **PathMatcher**. См. ниже.
|
|
180
|
+
|
|
181
|
+
**Строка (URLPattern).** Поддерживается:
|
|
182
|
+
|
|
183
|
+
- **Именованные параметры** — `:name` (имя как в JS: буквы, цифры, `_`). Значение сегмента попадает в `params[name]`.
|
|
184
|
+
- **Опциональные группы** — `{ ... }?`: часть пути можно сделать необязательной. Один паттерн покрывает пути разной глубины; в `params` только те ключи, для которых есть сегмент в URL.
|
|
185
|
+
- **Wildcard** — `*`: совпадает с «хвостом» пути; в `params` не попадает (числовые ключи из `groups` отфильтрованы).
|
|
186
|
+
- **Regexp в параметре** — `:name(регулярка)` для ограничения формата сегмента (например только цифры). В `params` по-прежнему строка.
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
useRoute('/users/:id');
|
|
190
|
+
useRoute('/elements/:elementId/*/:subElementId'); // wildcard
|
|
191
|
+
|
|
192
|
+
// Опциональные группы
|
|
193
|
+
useRoute('/users/:id{/posts/:postId}?');
|
|
194
|
+
|
|
195
|
+
// Ограничение формата параметра (regexp)
|
|
196
|
+
useRoute('/blog/:year(\\d+)/:month(\\d+)');
|
|
197
|
+
|
|
198
|
+
// Функция-матчер (иерархия, кастомный разбор)
|
|
199
|
+
const matchPost = (pathname: string) => ({ matched: pathname.startsWith('/posts/'), params: {} });
|
|
200
|
+
useRoute(matchPost);
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Полный синтаксис 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/).
|
|
204
|
+
|
|
205
|
+
**PathMatcher** — функция, которую можно передать вместо строки, когда одного URLPattern недостаточно (иерархия сегментов, кастомная валидация, разбор через `split` или RegExp). Хук вызывает её с текущим `pathname` и подставляет возвращённые `matched` и `params` в состояние.
|
|
206
|
+
|
|
207
|
+
- **Параметр:** `pathname: string` — текущий pathname (без origin и query).
|
|
208
|
+
- **Возвращаемый тип:** `{ matched: boolean; params: RouteParams }`.
|
|
209
|
+
`matched` — совпал ли путь с вашей логикой; `params` — объект «имя параметра → значение сегмента» (тип `RouteParams` = `Record<RouteParamName, RouteParamValue>`).
|
|
210
|
+
- **Где использовать:** иерархические маршруты (например, `postId` только при наличии `userId`), пути с жёстким порядком сегментов, кастомные правила, которые не выразить одним URLPattern.
|
|
211
|
+
|
|
212
|
+
## 🛠 Примеры
|
|
213
|
+
|
|
214
|
+
### 1. Базовая навигация (pathname, navigate)
|
|
215
|
+
|
|
216
|
+
```tsx
|
|
217
|
+
import { useRoute } from '@budarin/use-route';
|
|
218
|
+
|
|
219
|
+
function BasicNavigationExample() {
|
|
220
|
+
const { pathname, navigate } = useRoute();
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div>
|
|
224
|
+
<p>Текущий путь: {pathname}</p>
|
|
225
|
+
<button type="button" onClick={() => navigate('/posts')}>
|
|
226
|
+
К постам
|
|
227
|
+
</button>
|
|
228
|
+
<button type="button" onClick={() => navigate('/')}>
|
|
229
|
+
На главную
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### 2. Параметры пути (useRoute('/users/:id'), params)
|
|
237
|
+
|
|
238
|
+
```tsx
|
|
239
|
+
import { useRoute } from '@budarin/use-route';
|
|
240
|
+
|
|
241
|
+
function ParamsExample() {
|
|
242
|
+
const { params, pathname, navigate } = useRoute('/users/:id');
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div>
|
|
246
|
+
<p>Pathname: {pathname}</p>
|
|
247
|
+
<p>User ID из params: {params.id ?? '—'}</p>
|
|
248
|
+
<button type="button" onClick={() => navigate('/users/123')}>
|
|
249
|
+
User 123
|
|
250
|
+
</button>
|
|
251
|
+
<button type="button" onClick={() => navigate('/users/456')}>
|
|
252
|
+
User 456
|
|
253
|
+
</button>
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### 3. Search params (query)
|
|
260
|
+
|
|
261
|
+
```tsx
|
|
262
|
+
import { useRoute } from '@budarin/use-route';
|
|
263
|
+
|
|
264
|
+
function SearchParamsExample() {
|
|
265
|
+
const { searchParams, navigate, pathname } = useRoute('/posts');
|
|
266
|
+
const pageParam = searchParams.get('page') ?? '1';
|
|
267
|
+
const currentPage = Number.parseInt(pageParam, 10) || 1;
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<div>
|
|
271
|
+
<p>Путь: {pathname}</p>
|
|
272
|
+
<p>Страница: {currentPage}</p>
|
|
273
|
+
<button
|
|
274
|
+
type="button"
|
|
275
|
+
onClick={() => navigate(`/posts?page=${currentPage - 1}`)}
|
|
276
|
+
disabled={currentPage <= 1}
|
|
277
|
+
>
|
|
278
|
+
Пред. страница
|
|
279
|
+
</button>
|
|
280
|
+
<button type="button" onClick={() => navigate(`/posts?page=${currentPage + 1}`)}>
|
|
281
|
+
След. страница
|
|
282
|
+
</button>
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### 4. История (back, forward, go, canGoBack, canGoForward)
|
|
289
|
+
|
|
290
|
+
```tsx
|
|
291
|
+
import { useRoute } from '@budarin/use-route';
|
|
292
|
+
|
|
293
|
+
function HistoryExample() {
|
|
294
|
+
const { go, back, forward, canGoBack, canGoForward } = useRoute();
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<div>
|
|
298
|
+
<button type="button" onClick={() => back()} disabled={!canGoBack()}>
|
|
299
|
+
← Назад
|
|
300
|
+
</button>
|
|
301
|
+
<button type="button" onClick={() => go(-2)} disabled={!canGoBack(2)}>
|
|
302
|
+
← 2 шага
|
|
303
|
+
</button>
|
|
304
|
+
<button type="button" onClick={() => go(1)} disabled={!canGoForward()}>
|
|
305
|
+
Вперёд →
|
|
306
|
+
</button>
|
|
307
|
+
<button type="button" onClick={() => forward()} disabled={!canGoForward()}>
|
|
308
|
+
Forward
|
|
309
|
+
</button>
|
|
310
|
+
</div>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### 5. Push и replace (и метод replace())
|
|
316
|
+
|
|
317
|
+
```tsx
|
|
318
|
+
import { useRoute } from '@budarin/use-route';
|
|
319
|
+
|
|
320
|
+
function PushReplaceExample() {
|
|
321
|
+
const { navigate, replace, pathname } = useRoute();
|
|
322
|
+
|
|
323
|
+
return (
|
|
324
|
+
<div>
|
|
325
|
+
<p>Текущий путь: {pathname}</p>
|
|
326
|
+
<button type="button" onClick={() => navigate('/step-push', { history: 'push' })}>
|
|
327
|
+
Перейти (push) — в истории появится запись
|
|
328
|
+
</button>
|
|
329
|
+
<button type="button" onClick={() => navigate('/step-replace', { history: 'replace' })}>
|
|
330
|
+
Перейти (replace через navigate)
|
|
331
|
+
</button>
|
|
332
|
+
<button type="button" onClick={() => replace('/step-replace-method')}>
|
|
333
|
+
Перейти через replace() — то же, что history: 'replace'
|
|
334
|
+
</button>
|
|
335
|
+
</div>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### 6. matched (совпадение pathname с pattern)
|
|
341
|
+
|
|
342
|
+
```tsx
|
|
343
|
+
import { useRoute } from '@budarin/use-route';
|
|
344
|
+
|
|
345
|
+
function MatchedExample() {
|
|
346
|
+
const { pathname, matched, params } = useRoute('/users/:id');
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
<div>
|
|
350
|
+
<p>Pathname: {pathname}</p>
|
|
351
|
+
<p>Pattern /users/:id совпал: {matched === true ? 'да' : 'нет'}</p>
|
|
352
|
+
{matched === true ? (
|
|
353
|
+
<p>User ID: {params.id}</p>
|
|
354
|
+
) : (
|
|
355
|
+
<p>Это не страница пользователя (path не совпал с /users/:id).</p>
|
|
356
|
+
)}
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### 7. Функция-матчер (PathMatcher)
|
|
363
|
+
|
|
364
|
+
Удобно, когда один URLPattern или простой regex не справляется: иерархия (например, `postId` только вместе с `userId`), кастомная валидация, разный порядок сегментов. Ниже — матчер для `/users/:userId` и `/users/:userId/posts/:postId`: два параметра, причём `postId` допустим только после литерала `posts` и только при наличии `userId`.
|
|
365
|
+
|
|
366
|
+
```tsx
|
|
367
|
+
import { useRoute, type PathMatcher } from '@budarin/use-route';
|
|
368
|
+
|
|
369
|
+
const matchUserPosts: PathMatcher = (pathname) => {
|
|
370
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
371
|
+
|
|
372
|
+
if (segments[0] !== 'users' || !segments[1]) return { matched: false, params: {} };
|
|
373
|
+
|
|
374
|
+
const params: Record<string, string> = { userId: segments[1] };
|
|
375
|
+
|
|
376
|
+
if (segments[2] === 'posts' && segments[3]) {
|
|
377
|
+
params.postId = segments[3];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return { matched: true, params };
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
function UserPostsExample() {
|
|
384
|
+
const { pathname, matched, params } = useRoute(matchUserPosts);
|
|
385
|
+
|
|
386
|
+
if (!matched) return null;
|
|
387
|
+
|
|
388
|
+
return (
|
|
389
|
+
<div>
|
|
390
|
+
<p>Путь: {pathname}</p>
|
|
391
|
+
<p>User ID: {params.userId}</p>
|
|
392
|
+
{params.postId && <p>Post ID: {params.postId}</p>}
|
|
393
|
+
</div>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### 8. Глобальный base (приложение по подпути, не в корне домена)
|
|
399
|
+
|
|
400
|
+
Когда приложение располагается **не в корне домена**, а по подпути (например `https://example.com/app/` — все маршруты под `/app`), задайте в конфиге `base: '/app'`. Тогда `pathname` возвращается без префикса, а `navigate(to)` добавляет base к относительным путям. Для одноразового перехода «вне» этого пути (например на `/login`) используйте опцию `base` в `navigate` или `replace`: `navigate('/login', { base: '' })`.
|
|
401
|
+
|
|
402
|
+
```tsx
|
|
403
|
+
import { useRoute, configureRouter } from '@budarin/use-route';
|
|
404
|
+
configureRouter({ base: '/app' });
|
|
405
|
+
|
|
406
|
+
function AppUnderBase() {
|
|
407
|
+
const { pathname, navigate } = useRoute();
|
|
408
|
+
|
|
409
|
+
return (
|
|
410
|
+
<div>
|
|
411
|
+
<p>Текущий путь: {pathname}</p>
|
|
412
|
+
|
|
413
|
+
<button type="button" onClick={() => navigate('/dashboard')}>
|
|
414
|
+
В дашборд → /app/dashboard
|
|
415
|
+
</button>
|
|
416
|
+
|
|
417
|
+
<button type="button" onClick={() => navigate('/login', { base: '' })}>
|
|
418
|
+
На логин (/login)
|
|
419
|
+
</button>
|
|
420
|
+
|
|
421
|
+
<button type="button" onClick={() => navigate('/auth/profile', { base: '/auth' })}>
|
|
422
|
+
В другой раздел (/auth/profile)
|
|
423
|
+
</button>
|
|
424
|
+
</div>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### 9. Локальный base в хуке (options.base)
|
|
430
|
+
|
|
431
|
+
Когда у приложения несколько разделов по своим подпутям (`/dashboard`, `/admin`, `/auth`), в компонентах раздела можно задать **локальный** base: вызовите `useRoute({ base: '/dashboard' })` (один объект — опции без pattern). Тогда `pathname` возвращается без префикса раздела, а `navigate(to)` по умолчанию добавляет этот base — не нужно передавать `base` в каждый вызов `navigate`.
|
|
432
|
+
|
|
433
|
+
```tsx
|
|
434
|
+
import { useRoute } from '@budarin/use-route';
|
|
435
|
+
|
|
436
|
+
const DASHBOARD_BASE = '/dashboard';
|
|
437
|
+
|
|
438
|
+
function DashboardSection() {
|
|
439
|
+
// Локальный base для раздела: pathname и navigate относительно /dashboard
|
|
440
|
+
const { pathname, navigate } = useRoute({ base: DASHBOARD_BASE });
|
|
441
|
+
|
|
442
|
+
return (
|
|
443
|
+
<div>
|
|
444
|
+
{/* При URL /dashboard/reports pathname === '/reports' */}
|
|
445
|
+
<p>Раздел Dashboard. Путь: {pathname}</p>
|
|
446
|
+
<button type="button" onClick={() => navigate('/reports')}>
|
|
447
|
+
Отчёты → /dashboard/reports
|
|
448
|
+
</button>
|
|
449
|
+
<button type="button" onClick={() => navigate('/settings')}>
|
|
450
|
+
Настройки → /dashboard/settings
|
|
451
|
+
</button>
|
|
452
|
+
{/* Переход вне раздела: свой base в вызове */}
|
|
453
|
+
<button type="button" onClick={() => navigate('/', { base: '' })}>
|
|
454
|
+
На главную
|
|
455
|
+
</button>
|
|
456
|
+
</div>
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
### 10. initialLocation (SSR)
|
|
462
|
+
|
|
463
|
+
При рендере на сервере нет `window`, поэтому хук не знает URL запроса. Задайте `initialLocation` в конфиге один раз перед рендером запроса (например `request.url`) — тогда `pathname` и `searchParams` будут соответствовать запросу. На клиенте `initialLocation` не используется.
|
|
464
|
+
|
|
465
|
+
```tsx
|
|
466
|
+
// Серверный обработчик (псевдокод: Express, Fastify, Next и т.д.)
|
|
467
|
+
import { configureRouter } from '@budarin/use-route';
|
|
468
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
469
|
+
import { App } from './App';
|
|
470
|
+
|
|
471
|
+
function handleRequest(req, res) {
|
|
472
|
+
// Один раз перед рендером этого запроса
|
|
473
|
+
configureRouter({ initialLocation: req.url });
|
|
474
|
+
|
|
475
|
+
const html = renderToStaticMarkup(<App />);
|
|
476
|
+
res.send(html);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// В App компоненты используют useRoute() — на сервере получают pathname/searchParams из initialLocation
|
|
480
|
+
function App() {
|
|
481
|
+
const { pathname, searchParams } = useRoute();
|
|
482
|
+
return (
|
|
483
|
+
<div>
|
|
484
|
+
<p>Pathname: {pathname}</p>
|
|
485
|
+
<p>Query: {searchParams.toString()}</p>
|
|
486
|
+
</div>
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
### 11. Компонент Link (пример реализации)
|
|
492
|
+
|
|
493
|
+
Минимальный пример компонента-ссылки поверх хука. Можно взять за основу и развивать под себя: активное состояние, префетч, аналитика, стили.
|
|
494
|
+
|
|
495
|
+
```tsx
|
|
496
|
+
import { useRoute } from '@budarin/use-route';
|
|
497
|
+
import { useCallback, type ComponentPropsWithoutRef } from 'react';
|
|
498
|
+
|
|
499
|
+
interface LinkProps extends ComponentPropsWithoutRef<'a'> {
|
|
500
|
+
to: string;
|
|
501
|
+
replace?: boolean;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function Link({ to, replace = false, onClick, ...props }: LinkProps) {
|
|
505
|
+
const { navigate } = useRoute();
|
|
506
|
+
|
|
507
|
+
const handleClick = useCallback(
|
|
508
|
+
(e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
509
|
+
onClick?.(e);
|
|
510
|
+
|
|
511
|
+
if (!e.defaultPrevented) {
|
|
512
|
+
e.preventDefault();
|
|
513
|
+
navigate(to, { history: replace ? 'replace' : 'push' });
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
[navigate, to, replace, onClick]
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
return <a {...props} href={to} onClick={handleClick} />;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Использование:
|
|
523
|
+
// <Link to="/posts">Посты</Link>
|
|
524
|
+
// <Link to="/users/123" replace>Профиль (replace)</Link>
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
## ⚙️ Установка
|
|
528
|
+
|
|
529
|
+
```bash
|
|
530
|
+
npm i @budarin/use-route
|
|
531
|
+
|
|
532
|
+
pnpm add @budarin/use-route
|
|
533
|
+
|
|
534
|
+
yarn add @budarin/use-route
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
TypeScript: типы включены.
|
|
538
|
+
|
|
539
|
+
**`tsconfig.json` (рекомендуется):**
|
|
540
|
+
|
|
541
|
+
```json
|
|
542
|
+
{
|
|
543
|
+
"compilerOptions": {
|
|
544
|
+
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
|
545
|
+
"moduleResolution": "bundler",
|
|
546
|
+
"jsx": "react-jsx"
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
**Polyfills (опционально):**
|
|
552
|
+
|
|
553
|
+
```bash
|
|
554
|
+
npm i urlpattern-polyfill
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
```typescript
|
|
558
|
+
// src/polyfills.ts
|
|
559
|
+
import 'urlpattern-polyfill';
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
## 🌐 Браузеры и Node.js
|
|
563
|
+
|
|
564
|
+
| API | Chrome/Edge | Firefox | Safari | Node.js |
|
|
565
|
+
| -------------- | ----------- | ------- | ------ | ------- |
|
|
566
|
+
| Navigation API | 102+ | 109+ | 16.4+ | — |
|
|
567
|
+
| URLPattern | 110+ | 115+ | 16.4+ | 23.8+ |
|
|
568
|
+
|
|
569
|
+
Роутер рассчитан только на эти версии, fallback на History API нет.
|
|
570
|
+
|
|
571
|
+
## 🎛 Под капотом
|
|
572
|
+
|
|
573
|
+
- `useSyncExternalStore` на navigation события (`navigate`, `currententrychange`)
|
|
574
|
+
- LRU кэш parsed URL (настраиваемый лимит)
|
|
575
|
+
- Map для O(1) поиска `historyIndex`
|
|
576
|
+
- URLPattern для `:params`
|
|
577
|
+
- Кэш compiled patterns; `clearRouterCaches()` — очистка кэшей (тесты, смена окружения)
|
|
578
|
+
- SSR-safe (checks `typeof window`)
|
|
579
|
+
|
|
580
|
+
## 🤝 Лицензия
|
|
581
|
+
|
|
582
|
+
MIT © budarin
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PathMatcher, UseRouteReturn, UseRouteOptions } from './types';
|
|
2
|
+
export { configureRouter, type LoggerLevel, type Logger, type NavigateOptions, type UseRouteOptions, type UseRouteReturn, type ExtractRouteParams, type ParamsForPath, type PathMatcher, type Pathname, type RouteParams, } from './types';
|
|
3
|
+
/** Очищает кэши паттернов и URL. Для тестов или при смене base/origin. */
|
|
4
|
+
export declare function clearRouterCaches(): void;
|
|
5
|
+
/** Перегрузка: только опции (без pattern). Например useRoute({ base: '/dashboard' }). */
|
|
6
|
+
export declare function useRoute(options: UseRouteOptions): UseRouteReturn;
|
|
7
|
+
/** Перегрузка: pattern и опции. */
|
|
8
|
+
export declare function useRoute<P extends string | PathMatcher>(pattern: P, options?: UseRouteOptions): UseRouteReturn<P>;
|
|
9
|
+
/** Перегрузка: только pattern или без аргументов. */
|
|
10
|
+
export declare function useRoute<P extends string | PathMatcher = string>(pattern?: P): UseRouteReturn<P>;
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|