feat(site-finder): deep-link tabs via ?tab= URL param #86

Merged
lekss361 merged 2 commits from feat/site-finder-tab-urlparam into main 2026-05-11 21:33:50 +00:00
lekss361 commented 2026-05-11 21:22:37 +00:00 (Migrated from github.com)

Summary

Tab state на /site-finder теперь синхронизирован с URL — можно делиться deep-link на конкретный таб:

  • /site-finder?tab=env — открыт таб "Окружение"
  • /site-finder?tab=land — таб "Земля"
  • /site-finder?tab=market — таб "Рынок"
  • /site-finder (без param) — таб "Обзор" (default)

Implementation

  • useRouter + useSearchParams из next/navigation (Next 15 app router, client-component)
  • router.replace (не push) — tab switches не засоряют browser history
  • useEffect on searchParams → back/forward navigation работают
  • isTabId() type guard — только valid values из URL переключают state (invalid ?tab=xxx → default overview)
  • overview tab НЕ пишется в URL (clean URLs для default state)

Test plan

  • Открыть /site-finder?tab=market, ввести cad, нажать "Анализировать" — должен открыться таб "Рынок"
  • Клик по табу "Окружение" → URL обновляется на ?tab=env, история чистая
  • Back в браузере → возвращается на предыдущий tab (через URL sync)
  • Open /site-finder?tab=invalid_value → fallback на overview без ошибки
  • npx tsc --noEmit → 0 errors (проверено)
  • npm run lint → 0 warnings (проверено)
## Summary Tab state на `/site-finder` теперь синхронизирован с URL — можно делиться deep-link на конкретный таб: - `/site-finder?tab=env` — открыт таб "Окружение" - `/site-finder?tab=land` — таб "Земля" - `/site-finder?tab=market` — таб "Рынок" - `/site-finder` (без param) — таб "Обзор" (default) ## Implementation - `useRouter` + `useSearchParams` из `next/navigation` (Next 15 app router, client-component) - `router.replace` (не `push`) — tab switches не засоряют browser history - `useEffect` on `searchParams` → back/forward navigation работают - `isTabId()` type guard — только valid values из URL переключают state (invalid `?tab=xxx` → default overview) - overview tab НЕ пишется в URL (clean URLs для default state) ## Test plan - [ ] Открыть `/site-finder?tab=market`, ввести cad, нажать "Анализировать" — должен открыться таб "Рынок" - [ ] Клик по табу "Окружение" → URL обновляется на `?tab=env`, история чистая - [ ] Back в браузере → возвращается на предыдущий tab (через URL sync) - [ ] Open `/site-finder?tab=invalid_value` → fallback на overview без ошибки - [ ] `npx tsc --noEmit` → 0 errors (проверено) - [ ] `npm run lint` → 0 warnings (проверено)
lekss361 commented 2026-05-11 21:25:12 +00:00 (Migrated from github.com)

⚠️ Auto-review: minor (non-blocking) — 3 рекомендации по корректности и 1 опциональная.

Рекомендации (исправь до merge, но не блокирует):

  1. router.replace("?", ...) fallback некорректен в Next 15 App Router — голый "?" оставляет dangling ? в URL, не во всех браузерах чисто чистит query. Использовать usePathname():

    const pathname = usePathname();
    // ...
    router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
    
  2. Stale-closure guard в useEffectif (validTab !== tab) читает tab из stale closure (его нет в deps, eslint-disable это маскирует). На быстром back/forward UI может отстать на одну навигацию. Безопасный фикс: убрать guard вообще — setState с тем же значением React сам бэйлит через Object.is:

    useEffect(() => {
      const urlTab = searchParams.get("tab");
      setTabState(isTabId(urlTab) ? urlTab : "overview");
    }, [searchParams]);
    

    Убрать // eslint-disable-next-line тоже.

  3. useSearchParams() в Next 15 App Router требует <Suspense> boundary в parent layout, иначе static rendering throw'нет. Проверь что frontend/src/app/site-finder/layout.tsx (или родитель) обёрнут в <Suspense>. Если страница всегда dynamic — можно добавить export const dynamic = "force-dynamic" в page.tsx.

Опционально:

  • initialTab IIFE считается на каждом render, useState игнорирует после mount → wasted CPU. Перевести в lazy init: useState<TabId>(() => { ... }).

Что хорошо:

  • isTabId(v) валидирует против closed enum — нет open-redirect / XSS surface.
  • useEffect для URL↔state sync — это не data-fetching, конвенция CLAUDE.md не нарушена.
  • TS strict, типы корректные.
⚠️ **Auto-review: minor (non-blocking)** — 3 рекомендации по корректности и 1 опциональная. **Рекомендации (исправь до merge, но не блокирует):** 1. **`router.replace("?", ...)` fallback некорректен в Next 15 App Router** — голый `"?"` оставляет dangling `?` в URL, не во всех браузерах чисто чистит query. Использовать `usePathname()`: ```tsx const pathname = usePathname(); // ... router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); ``` 2. **Stale-closure guard в `useEffect`** — `if (validTab !== tab)` читает `tab` из stale closure (его нет в deps, eslint-disable это маскирует). На быстром back/forward UI может отстать на одну навигацию. Безопасный фикс: убрать guard вообще — `setState` с тем же значением React сам бэйлит через `Object.is`: ```tsx useEffect(() => { const urlTab = searchParams.get("tab"); setTabState(isTabId(urlTab) ? urlTab : "overview"); }, [searchParams]); ``` Убрать `// eslint-disable-next-line` тоже. 3. **`useSearchParams()` в Next 15 App Router требует `<Suspense>` boundary** в parent layout, иначе static rendering throw'нет. Проверь что `frontend/src/app/site-finder/layout.tsx` (или родитель) обёрнут в `<Suspense>`. Если страница всегда dynamic — можно добавить `export const dynamic = "force-dynamic"` в page.tsx. **Опционально:** - `initialTab` IIFE считается на каждом render, `useState` игнорирует после mount → wasted CPU. Перевести в lazy init: `useState<TabId>(() => { ... })`. **Что хорошо:** - `isTabId(v)` валидирует против closed enum — нет open-redirect / XSS surface. - `useEffect` для URL↔state sync — это не data-fetching, конвенция CLAUDE.md не нарушена. - TS strict, типы корректные. <!-- gendesign-review-bot: sha=c47e56b verdict=minor -->
lekss361 commented 2026-05-11 21:30:40 +00:00 (Migrated from github.com)

Все 3 рекомендации + опциональная применены в 6aa8222:

  1. router.replace теперь использует usePathname() — нет dangling ? в Next 15.
  2. Stale-closure guard убран — setTabState(...) без if, React сам бэйлит через Object.is. eslint-disable удалён.
  3. Page обёрнута в <Suspense fallback={null}>useSearchParams() теперь безопасен для static rendering. Сама логика вынесена в SiteFinderContent, верхний default export — Suspense wrapper.
  4. (опц.) useState<TabId>(() => { ... }) — lazy init, нет wasted CPU на re-render.

npx tsc --noEmit + npm run lint --max-warnings 0 — clean.

PR в Auto-merge scope (frontend/src/app/** UI-only). Если bot вернёт verdict=approve с валидным SHA marker на 6aa8222 — заmerge'у автоматом per CLAUDE.md rule 6.

Все 3 рекомендации + опциональная применены в `6aa8222`: 1. ✅ `router.replace` теперь использует `usePathname()` — нет dangling `?` в Next 15. 2. ✅ Stale-closure guard убран — `setTabState(...)` без if, React сам бэйлит через `Object.is`. `eslint-disable` удалён. 3. ✅ Page обёрнута в `<Suspense fallback={null}>` — `useSearchParams()` теперь безопасен для static rendering. Сама логика вынесена в `SiteFinderContent`, верхний default export — Suspense wrapper. 4. ✅ (опц.) `useState<TabId>(() => { ... })` — lazy init, нет wasted CPU на re-render. `npx tsc --noEmit` + `npm run lint --max-warnings 0` — clean. PR в Auto-merge scope ✅ (`frontend/src/app/**` UI-only). Если bot вернёт `verdict=approve` с валидным SHA marker на `6aa8222` — заmerge'у автоматом per CLAUDE.md rule 6.
lekss361 commented 2026-05-11 21:32:32 +00:00 (Migrated from github.com)

Auto-review passed (LGTM) — все 3 must-fix исправления выполнены корректно, optional lazy init тоже сделан.

Проверено:

  • router.replace(qs ? \${pathname}?${qs}` : pathname, { scroll: false })— dangling?` фикснут.
  • useEffect без guard и без eslint-disable; deps [searchParams], setState с тем же значением React сам бэйлит через Object.is — no loop.
  • <Suspense fallback={null}> обёртывает SiteFinderContent; split на content/shell чистый, page-level state весь в content.
  • useState<TabId>(() => ...) — lazy init, считаем initialTab один раз на mount.
  • TS strict, типы корректные (isTabId — proper type guard, as readonly string[] — narrowest safe cast).
  • Double-update на user click → second setState бэйлится (Object.is), no flicker.

Можно мержить (gh pr merge --squash --delete-branch).

✅ **Auto-review passed (LGTM)** — все 3 must-fix исправления выполнены корректно, optional lazy init тоже сделан. Проверено: - `router.replace(qs ? \`${pathname}?${qs}\` : pathname, { scroll: false })` — dangling `?` фикснут. - `useEffect` без guard и без eslint-disable; deps `[searchParams]`, setState с тем же значением React сам бэйлит через `Object.is` — no loop. - `<Suspense fallback={null}>` обёртывает `SiteFinderContent`; split на content/shell чистый, page-level state весь в content. - `useState<TabId>(() => ...)` — lazy init, считаем initialTab один раз на mount. - TS strict, типы корректные (`isTabId` — proper type guard, `as readonly string[]` — narrowest safe cast). - Double-update на user click → second setState бэйлится (`Object.is`), no flicker. Можно мержить (`gh pr merge --squash --delete-branch`). <!-- gendesign-review-bot: sha=6aa8222 verdict=approve -->
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#86
No description provided.