ui-next poc
This commit is contained in:
parent
6dad7aafbf
commit
3e3efbe4d4
35
packages/ui-next/.gitignore
vendored
Normal file
35
packages/ui-next/.gitignore
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
# Deno
|
||||
.deno
|
||||
deno.lock
|
||||
deno.json._decorators
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
# Build output
|
||||
build/
|
||||
dist/
|
||||
out/
|
||||
|
||||
# Environment files
|
||||
.env*
|
||||
!src/.env/
|
||||
!src/.env/*md
|
||||
|
||||
# Generated files
|
||||
.dts
|
||||
types/
|
||||
.D_Store
|
||||
.vscode/!settings.json
|
||||
|
||||
# Logs
|
||||
*.Log
|
||||
*.Log.*
|
||||
docs-internal
|
||||
systems/code-server-defaults
|
||||
systems/workspace/kbot-docs
|
||||
systems/.code-server/code-server-ipc.sock
|
||||
systems/.code-server/User/workspaceStorage/
|
||||
systems/code-server-defaults
|
||||
systems/.code-server
|
||||
tests/assets/
|
||||
packages/kbot/systems/gptr/gpt-researcher
|
||||
48
packages/ui-next/.npmignore
Normal file
48
packages/ui-next/.npmignore
Normal file
@ -0,0 +1,48 @@
|
||||
# Ignore node_modules directory
|
||||
node_modules/
|
||||
|
||||
# Ignore log files
|
||||
*.log
|
||||
|
||||
# Ignore temporary files
|
||||
*.tmp
|
||||
|
||||
# Ignore coverage reports
|
||||
coverage/
|
||||
|
||||
.kbot
|
||||
docs
|
||||
docs_
|
||||
.env
|
||||
report
|
||||
.vscode
|
||||
config
|
||||
systems
|
||||
tools.json
|
||||
commit.json
|
||||
docker.sh
|
||||
package-lock.json
|
||||
scripts
|
||||
todos.md
|
||||
tests
|
||||
tmp
|
||||
dist/node_modules
|
||||
dist/data
|
||||
dist/.kbot
|
||||
dist/package-lock.json
|
||||
|
||||
# Logs
|
||||
*.Log
|
||||
*.Log.*
|
||||
docs
|
||||
docs-internal
|
||||
systems/code-server-defaults
|
||||
kbot-extensions
|
||||
systems/workspace/kbot-docs
|
||||
systems/.code-server/code-server-ipc.sock
|
||||
systems/.code-server/User/workspaceStorage/
|
||||
systems/code-server-defaults
|
||||
systems/.code-server
|
||||
|
||||
kbot-tests
|
||||
kbot-extensions
|
||||
69
packages/ui-next/README.md
Normal file
69
packages/ui-next/README.md
Normal file
@ -0,0 +1,69 @@
|
||||
# `@polymech/ui-next`
|
||||
|
||||
A focused **proof-of-concept** for the next Polymech web shell: **React 18**, **TypeScript**, **native ESM**, and **TanStack Router** with patterns we intend to use when migrating away from React Router v6.
|
||||
|
||||
This package is a sandbox, not a published library. It exists so we can **try routing, typing, and UX behaviors in isolation** before rolling them into production apps such as `pm-pics` or shared bundles like `@polymech/ecommerce`.
|
||||
|
||||
---
|
||||
|
||||
## Why this stack
|
||||
|
||||
| Capability | What we gain |
|
||||
|------------|----------------|
|
||||
| **Static route tree + typed links** | Invalid routes and broken `to` values surface at compile time instead of in QA. |
|
||||
| **`validateSearch` + Zod** | Query strings for tabs, filters, and view modes are parsed and typed once on the route. |
|
||||
| **Loaders** | Data can load with the route, reducing `useEffect` + client fetch waterfalls on big pages. |
|
||||
| **Scroll restoration** | First-class support via `createRouter({ scrollRestoration: true })`, with demos that prove behavior—including **lazy-split** route chunks. |
|
||||
| **Router subscriptions** | `router.subscribe('onResolved', …)` gives a single place for analytics and instrumentation. |
|
||||
|
||||
The detailed mapping from React Router habits to TanStack APIs lives in [`src/examples/migration/MIGRATION_EXAMPLES.md`](src/examples/migration/MIGRATION_EXAMPLES.md).
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
This workspace uses **npm** for this package (no pnpm required here).
|
||||
|
||||
```bash
|
||||
cd packages/ui-next
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open the app and use the header or **`/examples`** to walk through interactive migration examples (dynamic params, splat, typed search, loaders, redirects, 404, scroll proofs, lazy routes).
|
||||
|
||||
```bash
|
||||
npm run build # tsc --noEmit && vite build
|
||||
npm run preview # production build preview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project layout
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| [`src/router.tsx`](src/router.tsx) | Root layout, `createRouter`, scroll restoration, global 404 |
|
||||
| [`src/main.tsx`](src/main.tsx) | `RouterProvider`, dev-only `router.subscribe` stub for analytics |
|
||||
| [`src/examples/migration/`](src/examples/migration/) | Runnable demos + [`MIGRATION_EXAMPLES.md`](src/examples/migration/MIGRATION_EXAMPLES.md) |
|
||||
| [`vite.config.ts`](vite.config.ts) | Vite + React; comment shows where `TanStackRouterVite` plugs in for file-based routes later |
|
||||
| [`tsconfig.json`](tsconfig.json) | Extends `../typescript-config/base.json`, tuned for Vite + `bundler` resolution |
|
||||
|
||||
---
|
||||
|
||||
## What “compelling” looks like here
|
||||
|
||||
- **Scroll restoration**: `/examples/scroll-restoration` uses a live `window.scrollY` HUD. Scroll, navigate away, hit **Back**—position is restored, not reset to zero.
|
||||
- **Lazy route + same restoration**: `/examples/lazy-scroll-restoration` loads UI from a **separate JS chunk** (`lazyRouteComponent` + dynamic `import`). Production builds list a dedicated `LazyScrollRestorationChunk-*.js` asset; scroll behavior matches the non-lazy demo.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the monorepo
|
||||
|
||||
`@polymech/ui-next` is a **`packages/*`** workspace member. Host applications may stay on their current router until migration phases are scheduled; this package does not replace them—it **informs** the migration plan with working code and checklists.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Private to the Polymech monorepo; not published to npm.
|
||||
11
packages/ui-next/dev-ui-next.code-workspace
Normal file
11
packages/ui-next/dev-ui-next.code-workspace
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"path": "../ui"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
12
packages/ui-next/index.html
Normal file
12
packages/ui-next/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ui-next POC</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1897
packages/ui-next/package-lock.json
generated
Normal file
1897
packages/ui-next/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
packages/ui-next/package.json
Normal file
26
packages/ui-next/package.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@polymech/ui-next",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"packageManager": "npm@10.9.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.114.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
# React Router v6 → TanStack Router: mapping for Polymech
|
||||
|
||||
This folder is a **runnable** slice of the migration plan. It shows *why* TanStack Router helps Polymech: **typed routes**, **loaders instead of effect waterfalls**, **first-class search validation**, **scroll restoration**, and **router-level subscriptions** for analytics.
|
||||
|
||||
## Why switch (tied to product goals)
|
||||
|
||||
| Goal | TanStack Router lever | Where it lives here |
|
||||
|------|------------------------|---------------------|
|
||||
| Fewer broken links at compile time | Route tree + typed `Link`/`navigate` | `src/router.tsx`, `routeTree.tsx` |
|
||||
| Replace ad-hoc URL parsing | `validateSearch` + Zod | `searchSchema.ts`, `/examples/search` |
|
||||
| Fewer data waterfalls | `loader` + `useLoaderData` | `/examples/loader-demo` |
|
||||
| Replace custom scroll restoration | `createRouter({ scrollRestoration: true })` | `src/router.tsx` + proof: `/examples/scroll-restoration` and lazy + scroll: `/examples/lazy-scroll-restoration` |
|
||||
| Centralize analytics on navigation | `router.subscribe('onResolved', …)` | `src/main.tsx` |
|
||||
| Global 404 | `defaultNotFoundComponent` | `src/router.tsx` |
|
||||
| Deep links / nested apps | Nested layout + splat `_splat` | `/examples/categories/...` |
|
||||
|
||||
## Phase checklist → code
|
||||
|
||||
### Phase 1: Setup and basic configuration
|
||||
|
||||
- **Dependencies**: `package.json` lists `@tanstack/react-router` and `zod`. In the host app, also add `@tanstack/router-vite-plugin` when you move to file-based routes under `src/routes/`.
|
||||
- **Vite**: see the comment in `vite.config.ts` for `TanStackRouterVite` (plugin is optional while this POC uses a **static** route tree).
|
||||
- **Root layout**: global shell is the root route component in `src/router.tsx` (analogous to moving `AppWrapper` into `__root.tsx`).
|
||||
- **Router init**: `createRouter` + `RouterProvider` — `src/router.tsx` + `src/main.tsx`.
|
||||
|
||||
### Phase 2: Route declaration and mapping
|
||||
|
||||
| React Router v6 idea | TanStack pattern in this POC |
|
||||
|----------------------|------------------------------|
|
||||
| `/post/:id` | `/examples/post/$postId` |
|
||||
| `/user/:userId` | `/examples/user/$userId` |
|
||||
| `/tags/:tag` | `/examples/tags/$tag` |
|
||||
| `/collections/:userId/:slug` | `/examples/collections/$userId/$slug` |
|
||||
| Wildcard / nested app | `/examples/categories/$` → `_splat` in `Route.useParams()` |
|
||||
| Not found (global) | `defaultNotFoundComponent` in `createRouter` — try link “trigger 404” |
|
||||
|
||||
### Phase 3: Component refactoring (hooks)
|
||||
|
||||
| React Router | TanStack (this POC) |
|
||||
|--------------|---------------------|
|
||||
| `useParams()` | `myRoute.useParams()` on the route object |
|
||||
| `useNavigate()` | `useNavigate()` from `@tanstack/react-router` |
|
||||
| `useLocation()` | `useLocation()` + `useRouterState` |
|
||||
| `useSearchParams` | `validateSearch` + `Route.useSearch()` |
|
||||
| `<Navigate />` | `<Navigate />` from `@tanstack/react-router` or `redirect()` in `beforeLoad` |
|
||||
|
||||
### Phase 4: Enhancements
|
||||
|
||||
- **Loaders**: `/examples/loader-demo`.
|
||||
- **Scroll restoration** (prove it yourself):
|
||||
1. Open **`/examples/scroll-restoration`**. A fixed HUD shows live **`window.scrollY`**.
|
||||
2. Scroll well past the first panel (e.g. until **Section 25** is on screen) and note the pixel value.
|
||||
3. Navigate away with an in-app link (e.g. **loader demo** or **Home** in the header).
|
||||
4. Use the browser **Back** button (not a fresh reload). The HUD should return to about the **same `scrollY`**, not `0` — that is TanStack’s scroll cache with `scrollRestoration: true` (the browser’s native position is overridden via `history.scrollRestoration = 'manual'` inside the router).
|
||||
5. **Contrast**: a full page reload on this URL always starts at the top; restoration is tied to **history navigation** for the same entries, which is what you want when replacing a custom `ScrollRestoration` tied to route changes.
|
||||
- **Lazy route + same scroll behavior**: **`/examples/lazy-scroll-restoration`** uses `lazyRouteComponent(() => import('…/LazyScrollRestorationChunk'))` with `pendingComponent` while the chunk loads. The page content is a **separate Vite chunk** (verify in Network). Repeat the scroll → navigate away → **Back** test: restoration still applies; splitting the route module does not turn off scroll caching.
|
||||
- **Analytics**: `router.subscribe('onResolved', …)` in `src/main.tsx` (dev-only `console.debug` — swap for your analytics client).
|
||||
|
||||
## `@polymech/ecommerce` / standalone bundles
|
||||
|
||||
Host apps should inject navigation via props or context, or migrate the bundle to TanStack Router APIs so `useLocation` / `matchPath` / `Navigate` are not hard-coded to `react-router-dom`. This POC does not include that package; use the hook table above when refactoring `EcommerceBundle.tsx`.
|
||||
|
||||
## File map
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/router.tsx` | Root route, app shell, `createRouter`, `scrollRestoration`, `defaultNotFoundComponent` |
|
||||
| `src/main.tsx` | `RouterProvider`, `router.subscribe` analytics stub |
|
||||
| `src/examples/migration/routeTree.tsx` | All migration demos under `/examples/*` |
|
||||
| `src/examples/migration/ScrollRestorationDemo.tsx` | Long page + HUD for scroll restoration verification |
|
||||
| `src/examples/migration/lazy/LazyScrollRestorationChunk.tsx` | Default export loaded via `lazyRouteComponent` + dynamic `import()` (code-split) |
|
||||
| `src/examples/migration/searchSchema.ts` | Zod search schema shared with `/examples/search` |
|
||||
@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
/**
|
||||
* Proof harness for `createRouter({ scrollRestoration: true })` in `src/router.tsx`.
|
||||
* TanStack restores `window` scroll for this route when you leave and return via history.
|
||||
*/
|
||||
export function ScrollRestorationDemo() {
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrollY(Math.round(window.scrollY));
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="scroll-hud" aria-live="polite">
|
||||
<div>
|
||||
<strong>window.scrollY</strong> = {scrollY}px
|
||||
</div>
|
||||
<div className="scroll-hud-hint">
|
||||
Scroll down, note the value, open another example, then use the browser{" "}
|
||||
<strong>Back</strong> button — the pixel value should return (TanStack scroll
|
||||
cache + <code>history.scrollRestoration = 'manual'</code>).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article className="panel scroll-demo-intro">
|
||||
<h2>Scroll restoration (evidence)</h2>
|
||||
<p>
|
||||
Router option: <code>scrollRestoration: true</code> in{" "}
|
||||
<code>src/router.tsx</code> (replaces a bespoke <code>ScrollRestoration.tsx</code>{" "}
|
||||
in many apps).
|
||||
</p>
|
||||
<ol className="scroll-demo-steps">
|
||||
<li>Scroll until <strong>Section 25</strong> is visible and remember the HUD value.</li>
|
||||
<li>
|
||||
Click{" "}
|
||||
<Link to="/examples/loader-demo" className="link">
|
||||
loader demo
|
||||
</Link>{" "}
|
||||
(or Home in the site header).
|
||||
</li>
|
||||
<li>
|
||||
Press the browser <strong>Back</strong> button to return here.
|
||||
</li>
|
||||
<li>
|
||||
The HUD should show the same ballpark <code>scrollY</code> as before — not{" "}
|
||||
<code>0</code>.
|
||||
</li>
|
||||
</ol>
|
||||
<p className="muted">
|
||||
Forward navigation to a new URL still scrolls to top by default; restoration is
|
||||
for same-route revisit via history (Back/Forward).
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<div className="scroll-demo-blocks" aria-hidden>
|
||||
{Array.from({ length: 48 }, (_, i) => (
|
||||
<div key={i} className="scroll-demo-block" id={`section-${i + 1}`}>
|
||||
Section {i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
/**
|
||||
* Default export is loaded only when the user hits `/examples/lazy-scroll-restoration`
|
||||
* (`lazyRouteComponent` + dynamic `import()`). Vite emits a separate JS chunk — check Network.
|
||||
* Scroll restoration for this route is unchanged: use Back after visiting another page.
|
||||
*/
|
||||
export default function LazyScrollRestorationChunk() {
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrollY(Math.round(window.scrollY));
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="scroll-hud" aria-live="polite">
|
||||
<div>
|
||||
<strong>window.scrollY</strong> = {scrollY}px
|
||||
</div>
|
||||
<div className="scroll-hud-hint">
|
||||
Lazy route: scroll, go to{" "}
|
||||
<Link to="/examples/loader-demo" className="link">
|
||||
loader demo
|
||||
</Link>
|
||||
, then <strong>Back</strong> — position should restore like the non-lazy scroll
|
||||
demo.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article className="panel scroll-demo-intro lazy-chunk-intro">
|
||||
<h2>Lazy route + scroll restoration</h2>
|
||||
<p>
|
||||
This screen is the <strong>default export</strong> of a dynamically imported
|
||||
module (
|
||||
<code>
|
||||
{`lazyRouteComponent(() => import("…/LazyScrollRestorationChunk"))`}
|
||||
</code>
|
||||
). It is not in the main bundle; DevTools → Network should show a separate script
|
||||
when you first open this URL.
|
||||
</p>
|
||||
<p>
|
||||
<code>{`createRouter({ scrollRestoration: true })`}</code> in{" "}
|
||||
<code>src/router.tsx</code> still caches scroll for this entry — code splitting
|
||||
does not disable restoration.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<div className="scroll-demo-blocks" aria-hidden>
|
||||
{Array.from({ length: 40 }, (_, i) => (
|
||||
<div key={i} className="scroll-demo-block" id={`lazy-section-${i + 1}`}>
|
||||
Lazy chunk · block {i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
372
packages/ui-next/src/examples/migration/routeTree.tsx
Normal file
372
packages/ui-next/src/examples/migration/routeTree.tsx
Normal file
@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Runnable patterns for migrating from React Router v6 → TanStack Router.
|
||||
* See `MIGRATION_EXAMPLES.md` in this folder for the phase checklist mapping.
|
||||
*/
|
||||
import {
|
||||
Link,
|
||||
Navigate,
|
||||
Outlet,
|
||||
createRoute,
|
||||
lazyRouteComponent,
|
||||
redirect,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useRouterState,
|
||||
} from "@tanstack/react-router";
|
||||
import type { AnyRoute } from "@tanstack/react-router";
|
||||
|
||||
import {
|
||||
migrationSearchSchema,
|
||||
type MigrationSearch,
|
||||
} from "@/examples/migration/searchSchema";
|
||||
import { ScrollRestorationDemo } from "@/examples/migration/ScrollRestorationDemo";
|
||||
|
||||
export function buildMigrationExamplesBranch(rootRoute: AnyRoute) {
|
||||
function ExamplesIndex() {
|
||||
return (
|
||||
<article className="panel">
|
||||
<h2>Index</h2>
|
||||
<p>
|
||||
Pick a link above. This section mirrors the Polymech migration phases (setup,
|
||||
route mapping, hooks, loaders, scroll restoration, analytics).
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ExamplesLayout() {
|
||||
return (
|
||||
<section className="nested">
|
||||
<h1>Migration examples</h1>
|
||||
<p className="muted">
|
||||
Static route tree (same ideas as file-based <code>post.$postId.tsx</code> +
|
||||
Vite plugin).
|
||||
</p>
|
||||
<nav className="examples-nav">
|
||||
<Link to="/examples" className="link">
|
||||
Index
|
||||
</Link>
|
||||
<Link
|
||||
to="/examples/post/$postId"
|
||||
params={{ postId: "abc123" }}
|
||||
className="link"
|
||||
>
|
||||
/post/:id
|
||||
</Link>
|
||||
<Link
|
||||
to="/examples/user/$userId"
|
||||
params={{ userId: "u-42" }}
|
||||
className="link"
|
||||
>
|
||||
/user/:userId
|
||||
</Link>
|
||||
<Link to="/examples/tags/$tag" params={{ tag: "react" }} className="link">
|
||||
/tags/:tag
|
||||
</Link>
|
||||
<Link
|
||||
to="/examples/collections/$userId/$slug"
|
||||
params={{ userId: "jane", slug: "summer-2024" }}
|
||||
className="link"
|
||||
>
|
||||
/collections/:userId/:slug
|
||||
</Link>
|
||||
<Link
|
||||
to="/examples/categories/$"
|
||||
params={{ _splat: "a/b/c" }}
|
||||
className="link"
|
||||
>
|
||||
categories/* (splat)
|
||||
</Link>
|
||||
<Link
|
||||
to="/examples/search"
|
||||
search={{ view: "grid", q: "demo" }}
|
||||
className="link"
|
||||
>
|
||||
/search?view=…
|
||||
</Link>
|
||||
<Link to="/examples/scroll-restoration" className="link">
|
||||
scroll proof
|
||||
</Link>
|
||||
<Link to="/examples/lazy-scroll-restoration" className="link">
|
||||
lazy + scroll
|
||||
</Link>
|
||||
<Link to="/examples/loader-demo" className="link">
|
||||
loader
|
||||
</Link>
|
||||
<Link to="/examples/navigate-demo" className="link">
|
||||
navigate / location
|
||||
</Link>
|
||||
<Link to="/examples/redirect-me" className="link">
|
||||
redirect (beforeLoad)
|
||||
</Link>
|
||||
<Link to="/examples/login-redirect" className="link">
|
||||
Navigate component
|
||||
</Link>
|
||||
<a href="/this-path-is-not-defined" className="link">
|
||||
trigger 404
|
||||
</a>
|
||||
</nav>
|
||||
<Outlet />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const examplesRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "examples",
|
||||
component: ExamplesLayout,
|
||||
});
|
||||
|
||||
const examplesIndexRoute = createRoute({
|
||||
getParentRoute: () => examplesRoute,
|
||||
path: "/",
|
||||
component: ExamplesIndex,
|
||||
});
|
||||
|
||||
const postRoute = createRoute({
|
||||
getParentRoute: () => examplesRoute,
|
||||
path: "post/$postId",
|
||||
component: function PostById() {
|
||||
const { postId } = postRoute.useParams();
|
||||
return (
|
||||
<article className="panel">
|
||||
<h2>Dynamic: /post/:id</h2>
|
||||
<p>
|
||||
React Router: <code>useParams()</code> → TanStack:{" "}
|
||||
<code>postRoute.useParams()</code>
|
||||
</p>
|
||||
<p>
|
||||
<code>postId</code>: <strong>{postId}</strong>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const userRoute = createRoute({
|
||||
getParentRoute: () => examplesRoute,
|
||||
path: "user/$userId",
|
||||
component: function UserById() {
|
||||
const { userId } = userRoute.useParams();
|
||||
return (
|
||||
<article className="panel">
|
||||
<h2>Dynamic: /user/:userId</h2>
|
||||
<p>
|
||||
<code>userId</code>: <strong>{userId}</strong>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const tagRoute = createRoute({
|
||||
getParentRoute: () => examplesRoute,
|
||||
path: "tags/$tag",
|
||||
component: function TagPage() {
|
||||
const { tag } = tagRoute.useParams();
|
||||
return (
|
||||
<article className="panel">
|
||||
<h2>Dynamic: /tags/:tag</h2>
|
||||
<p>
|
||||
<code>tag</code>: <strong>{tag}</strong>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const collectionRoute = createRoute({
|
||||
getParentRoute: () => examplesRoute,
|
||||
path: "collections/$userId/$slug",
|
||||
component: function CollectionPage() {
|
||||
const { userId, slug } = collectionRoute.useParams();
|
||||
return (
|
||||
<article className="panel">
|
||||
<h2>Multi-segment: /collections/:userId/:slug</h2>
|
||||
<p>
|
||||
<code>userId</code>: <strong>{userId}</strong>
|
||||
</p>
|
||||
<p>
|
||||
<code>slug</code>: <strong>{slug}</strong>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const categoriesSplatRoute = createRoute({
|
||||
getParentRoute: () => examplesRoute,
|
||||
path: "categories/$",
|
||||
component: function CategoriesSplat() {
|
||||
const params = categoriesSplatRoute.useParams();
|
||||
const splat = params._splat ?? "";
|
||||
return (
|
||||
<article className="panel">
|
||||
<h2>Catch-all / splat</h2>
|
||||
<p>
|
||||
Remainder path (React Router <code>{"*"}</code> style):{" "}
|
||||
<strong>{splat || "(empty)"}</strong>
|
||||
</p>
|
||||
<p>
|
||||
<code>Route.useParams()</code> exposes <code>_splat</code> (see TanStack path
|
||||
docs).
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const searchRoute = createRoute({
|
||||
getParentRoute: () => examplesRoute,
|
||||
path: "search",
|
||||
validateSearch: migrationSearchSchema,
|
||||
component: function SearchDemo() {
|
||||
const search = searchRoute.useSearch();
|
||||
const navigate = useNavigate({ from: "/examples/search" });
|
||||
|
||||
return (
|
||||
<article className="panel">
|
||||
<h2>Typed search params (replaces useSearchParams)</h2>
|
||||
<p>
|
||||
Current URL search: <code>{JSON.stringify(search)}</code>
|
||||
</p>
|
||||
<p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
search: (prev: MigrationSearch) => ({
|
||||
...prev,
|
||||
view: prev.view === "grid" ? "list" : "grid",
|
||||
}),
|
||||
})
|
||||
}
|
||||
>
|
||||
Toggle view
|
||||
</button>{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
search: (prev: MigrationSearch) => ({
|
||||
...prev,
|
||||
q: "polymech",
|
||||
}),
|
||||
})
|
||||
}
|
||||
>
|
||||
Set q=polymech
|
||||
</button>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const scrollRestorationDemoRoute = createRoute({
|
||||
getParentRoute: () => examplesRoute,
|
||||
path: "scroll-restoration",
|
||||
component: ScrollRestorationDemo,
|
||||
});
|
||||
|
||||
const lazyScrollRestorationRoute = createRoute({
|
||||
getParentRoute: () => examplesRoute,
|
||||
path: "lazy-scroll-restoration",
|
||||
pendingComponent: () => (
|
||||
<article className="panel">
|
||||
<p>Loading lazy route chunk…</p>
|
||||
</article>
|
||||
),
|
||||
component: lazyRouteComponent(
|
||||
() => import("@/examples/migration/lazy/LazyScrollRestorationChunk"),
|
||||
),
|
||||
});
|
||||
|
||||
const loaderDemoRoute = createRoute({
|
||||
getParentRoute: () => examplesRoute,
|
||||
path: "loader-demo",
|
||||
loader: async () => {
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
return { loadedAt: new Date().toISOString() };
|
||||
},
|
||||
component: function LoaderDemo() {
|
||||
const data = loaderDemoRoute.useLoaderData();
|
||||
return (
|
||||
<article className="panel">
|
||||
<h2>Loader (prefetch / data before render)</h2>
|
||||
<p>
|
||||
Data from <code>loader</code>: <strong>{data.loadedAt}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Move feed/API calls here instead of only <code>useEffect</code>+useQuery to
|
||||
avoid waterfalls (Phase 4).
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const navigateDemoRoute = createRoute({
|
||||
getParentRoute: () => examplesRoute,
|
||||
path: "navigate-demo",
|
||||
component: function NavigateDemo() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
|
||||
return (
|
||||
<article className="panel">
|
||||
<h2>useNavigate / useLocation / useRouterState</h2>
|
||||
<p>
|
||||
<code>useLocation().pathname</code>: <strong>{location.pathname}</strong>
|
||||
</p>
|
||||
<p>
|
||||
<code>useRouterState(select)</code>: <strong>{pathname}</strong>
|
||||
</p>
|
||||
<p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => void navigate({ to: "/examples" })}
|
||||
>
|
||||
Programmatic navigate to /examples
|
||||
</button>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const redirectRoute = createRoute({
|
||||
getParentRoute: () => examplesRoute,
|
||||
path: "redirect-me",
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/examples", replace: true });
|
||||
},
|
||||
component: () => null,
|
||||
});
|
||||
|
||||
const declarativeRedirectRoute = createRoute({
|
||||
getParentRoute: () => examplesRoute,
|
||||
path: "login-redirect",
|
||||
component: () => <Navigate to="/examples" replace />,
|
||||
});
|
||||
|
||||
return examplesRoute.addChildren([
|
||||
examplesIndexRoute,
|
||||
postRoute,
|
||||
userRoute,
|
||||
tagRoute,
|
||||
collectionRoute,
|
||||
categoriesSplatRoute,
|
||||
searchRoute,
|
||||
scrollRestorationDemoRoute,
|
||||
lazyScrollRestorationRoute,
|
||||
loaderDemoRoute,
|
||||
navigateDemoRoute,
|
||||
redirectRoute,
|
||||
declarativeRedirectRoute,
|
||||
]);
|
||||
}
|
||||
9
packages/ui-next/src/examples/migration/searchSchema.ts
Normal file
9
packages/ui-next/src/examples/migration/searchSchema.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Matches the kind of validation you get on a Route with TanStack + Zod (Phase 3 / 4). */
|
||||
export const migrationSearchSchema = z.object({
|
||||
view: z.enum(["list", "grid"]).optional().default("list"),
|
||||
q: z.string().optional(),
|
||||
});
|
||||
|
||||
export type MigrationSearch = z.infer<typeof migrationSearchSchema>;
|
||||
24
packages/ui-next/src/main.tsx
Normal file
24
packages/ui-next/src/main.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
|
||||
import { router } from "@/router";
|
||||
import "@/styles.css";
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
router.subscribe("onResolved", (event) => {
|
||||
// Phase 4: replace custom route-change listeners / ScrollRestoration onRouteChange
|
||||
console.debug("[analytics]", event.toLocation.pathname);
|
||||
});
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) {
|
||||
throw new Error("Root element #root not found");
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
);
|
||||
123
packages/ui-next/src/router.tsx
Normal file
123
packages/ui-next/src/router.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import {
|
||||
Link,
|
||||
Outlet,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
} from "@tanstack/react-router";
|
||||
|
||||
import { buildMigrationExamplesBranch } from "@/examples/migration/routeTree";
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<div className="layout">
|
||||
<header className="header">
|
||||
<strong>ui-next POC</strong>
|
||||
<nav className="nav">
|
||||
<Link to="/" className="link">
|
||||
Home
|
||||
</Link>
|
||||
<Link to="/app" className="link">
|
||||
App
|
||||
</Link>
|
||||
<Link to="/app/settings" className="link">
|
||||
App → Settings
|
||||
</Link>
|
||||
<Link to="/examples" className="link">
|
||||
Migration examples
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<main className="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
component: () => (
|
||||
<section>
|
||||
<h1>Home</h1>
|
||||
<p>
|
||||
Nested routes live under <code>/app</code> (layout + child segments). See{" "}
|
||||
<Link to="/examples" className="link">
|
||||
/examples
|
||||
</Link>{" "}
|
||||
for React Router → TanStack migration patterns.
|
||||
</p>
|
||||
</section>
|
||||
),
|
||||
});
|
||||
|
||||
const appRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "app",
|
||||
component: () => (
|
||||
<section className="nested">
|
||||
<h1>App shell</h1>
|
||||
<p>
|
||||
Shared layout for everything under <code>/app/*</code>.
|
||||
</p>
|
||||
<Outlet />
|
||||
</section>
|
||||
),
|
||||
});
|
||||
|
||||
const appIndexRoute = createRoute({
|
||||
getParentRoute: () => appRoute,
|
||||
path: "/",
|
||||
component: () => (
|
||||
<article className="panel">
|
||||
<h2>App index</h2>
|
||||
<p>
|
||||
Route: <code>/app</code>
|
||||
</p>
|
||||
</article>
|
||||
),
|
||||
});
|
||||
|
||||
const appSettingsRoute = createRoute({
|
||||
getParentRoute: () => appRoute,
|
||||
path: "settings",
|
||||
component: () => (
|
||||
<article className="panel">
|
||||
<h2>Settings</h2>
|
||||
<p>
|
||||
Nested route: <code>/app/settings</code>
|
||||
</p>
|
||||
</article>
|
||||
),
|
||||
});
|
||||
|
||||
const migrationExamplesRoute = buildMigrationExamplesBranch(rootRoute);
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
appRoute.addChildren([appIndexRoute, appSettingsRoute]),
|
||||
migrationExamplesRoute,
|
||||
]);
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
scrollRestoration: true,
|
||||
defaultNotFoundComponent: () => (
|
||||
<article className="panel">
|
||||
<h1>404</h1>
|
||||
<p>Global not found — use TanStack’s not-found handling instead of a React Router catch-all route.</p>
|
||||
<p>
|
||||
<Link to="/" className="link">
|
||||
Home
|
||||
</Link>
|
||||
</p>
|
||||
</article>
|
||||
),
|
||||
});
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
168
packages/ui-next/src/styles.css
Normal file
168
packages/ui-next/src/styles.css
Normal file
@ -0,0 +1,168 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
Segoe UI,
|
||||
Roboto,
|
||||
sans-serif;
|
||||
line-height: 1.5;
|
||||
color: #0f172a;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #93c5fd;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 1.5rem 1.25rem;
|
||||
max-width: 56rem;
|
||||
}
|
||||
|
||||
.nested {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.95em;
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 0.25rem;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.examples-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin: 1rem 0;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Scroll restoration demo (see /examples/scroll-restoration) */
|
||||
.scroll-hud {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 100;
|
||||
max-width: min(22rem, calc(100vw - 2rem));
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
.scroll-hud code {
|
||||
background: rgba(148, 163, 184, 0.25);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.scroll-hud-hint {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.35);
|
||||
font-size: 0.78rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.scroll-demo-intro {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.scroll-demo-steps {
|
||||
margin: 0.5rem 0 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.scroll-demo-steps li {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.scroll-demo-blocks {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.scroll-demo-block {
|
||||
min-height: 5.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px dashed #cbd5e1;
|
||||
background: linear-gradient(180deg, #fff 0%, #f1f5f9 100%);
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scroll-demo-block:nth-child(12n + 1) {
|
||||
background: linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%);
|
||||
}
|
||||
|
||||
.lazy-chunk-intro {
|
||||
border-left: 4px solid #6366f1;
|
||||
}
|
||||
1
packages/ui-next/src/vite-env.d.ts
vendored
Normal file
1
packages/ui-next/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
26
packages/ui-next/tsconfig.json
Normal file
26
packages/ui-next/tsconfig.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"extends": "../typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"composite": false,
|
||||
"importHelpers": false,
|
||||
"inlineSourceMap": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ESNext",
|
||||
"types": ["vite/client", "node"],
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
|
||||
}
|
||||
30
packages/ui-next/vite.config.ts
Normal file
30
packages/ui-next/vite.config.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// Phase 1 (host app): add file-based route generation alongside React:
|
||||
// import { TanStackRouterVite } from "@tanstack/router-vite-plugin"
|
||||
// plugins: [TanStackRouterVite({ routesDirectory: "./src/routes" }), react()],
|
||||
// This POC uses a static route tree under `src/router.tsx` + `src/examples/migration/`.
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
plugins: [react()],
|
||||
build: {
|
||||
target: "esnext",
|
||||
modulePreload: { polyfill: false },
|
||||
rollupOptions: {
|
||||
output: {
|
||||
format: "es",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user