@solidrt/router 0.0.62

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/AGENTS.md ADDED
@@ -0,0 +1,96 @@
1
+ # @solidrt/router - agent notes
2
+
3
+ Routing for SolidRT apps: a typed route tree, a stack, one back step, links
4
+ in and the location out. Optional: an app with one screen, or one that
5
+ routes by hand over core's `onBack`/`onLink`, needs none of it. Prose is in
6
+ README.md; the typed API is src/route.ts (the headless tree and matching)
7
+ and src/router.tsx (the Solid binding).
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ bun add @solidrt/router # peers: @solidrt/core, @solidjs/signals
13
+ ```
14
+
15
+ ## Shape
16
+
17
+ ```tsx
18
+ let item = createRoute({ // a value: typed params
19
+ path: "/item/$id",
20
+ params: { parse: (raw) => ({ id: Number(raw.id) }) }, // throw = no match
21
+ component: Item,
22
+ })
23
+
24
+ <Router initial="/"> // once, inside <Window>
25
+ <Route path="/" component={Shell}> // Shell renders <Outlet />
26
+ <Route path="/" component={Home} />
27
+ <Route path="/settings" component={Settings} />
28
+ <Route route={item} /> // the value, placed
29
+ <Route path="/$" component={NotFound} />
30
+ </Route>
31
+ </Router>
32
+ ```
33
+
34
+ - JSX for the tree; a route value (`createRoute`) where a param needs a
35
+ type or validation, placed with `<Route route={...}>`. A JSX `<Route>`
36
+ takes `path` and `component` only. Values can also form the whole tree
37
+ (`createRootRoute({ children: [...] })`, `createRoute({ ..., children })`)
38
+ with `createRouter({ tree })` at module scope and `<Router router={...}>`;
39
+ the player does this.
40
+ - Targets: `useNavigate()("/item/42")` by path (matched at run time, refused
41
+ with a warning when nothing matches), or by value, checked at compile
42
+ time: `navigate(home)`, `navigate({ route: item, params: { id: 42 } })`.
43
+ - `useParams()()` is the screen's raw strings; `useParams(item)()` is typed
44
+ by the value. Both reactive. `useLocation()`, `useNavigate()`,
45
+ `useRouter()`, `createLink(target)`, `useBlocker(fn)`.
46
+ - `router.entries()` is the stack as paths; pass a saved one as `initial`.
47
+ - Tabs: `<Route path="/" tabs component={Main}>` makes each child a tab
48
+ with a stack of its own, kept mounted and hidden while another shows;
49
+ full-window screens are siblings of the tabs route, above the bar. `Main`
50
+ renders `<Outlet />` and draws the bar from `useTabs()` (`{ paths,
51
+ active(), select(path) }`, e.g. wired to components' `NavShell`). A tab
52
+ is named by its literal root path. Select = show as left, re-select =
53
+ back to root; back pops above the bar, then the tab, then goes to the
54
+ first tab, then the platform. With tabs `entries()` is `{ stack, tabs }`.
55
+ - Path syntax: literal, `$name`, `$name?` (last), `$` for the rest (a `$`
56
+ route last under the root is the not-found screen). `/` is an index
57
+ route, or with children a pathless layout. Children match in
58
+ declaration order.
59
+
60
+ ## Traps
61
+
62
+ - A `<Route>` is not an element: it evaluates to a route value. Only
63
+ `<Route>` elements go under `<Router>` or another `<Route>`; anything
64
+ else there throws at mount.
65
+ - A route value has one place in one tree: placing it twice (two `<Route
66
+ route={x}>`, or a `children:` list and a `<Route route={x}>`) throws.
67
+ - With a JSX tree the router exists only inside `<Router>`: screens reach
68
+ it through `useNavigate()` / `useRouter()`, not a module-scope import.
69
+ With a value tree at module scope, screens that import route values from
70
+ the module that imports them form a cycle ESM handles as long as a
71
+ screen reads a route only inside a handler or a hook call, never at
72
+ module scope.
73
+ - Route components take no props. State a screen shares with others is a
74
+ module-scope signal (see the player's parts/app-state.ts), not a prop.
75
+ - A layout route's component must render `<Outlet />` or its child never
76
+ appears; a route without a component is an outlet alone.
77
+ - `<Router>` registers its `onBack` before anything it renders. A dialog
78
+ that registers its own `onBack` inside a screen still runs first, as
79
+ before; only use `useBlocker` for guards that must hold a navigation
80
+ (unsaved changes), not for closing overlays.
81
+ - `navigate(..., { reset: true })` for "flow finished, this is home"; a
82
+ plain push there leaves the flow's screens under home for back to revisit.
83
+ - A hidden tab is laid out to nothing: its reactive code keeps running,
84
+ but `getBoundingBox` and friends return null there and nothing in it is
85
+ painted, hit or focusable. A screen that measures itself checks
86
+ `useTabs().active()` against its tab's path, or tolerates null.
87
+ - The tabs route and each tab render in a full-size `<view>` (flex 1,
88
+ column): put the tabs route under laid-out parents only.
89
+ - Nothing above a tabs route takes a param, a tab's path is literal, one
90
+ tabs route per tree and never inside a tab: all thrown at tree build.
91
+ - Links are untrusted: put validation in `params.parse`, never in the
92
+ screen. `linkToPath` strips a custom scheme whole (`myapp://a/b` is
93
+ `/a/b`) and an http(s) host.
94
+ - Tooling: `open_link` / `POST /__control__/link` opens a screen,
95
+ `get_location` / `GET /__control__/link` reads the path the router
96
+ publishes, `srt render --link <path>` renders that screen.
package/README.md ADDED
@@ -0,0 +1,257 @@
1
+ # Router
2
+
3
+ `@solidrt/router` makes screens addressable. A route tree maps a path like
4
+ `/settings/theme` to a screen, a stack holds where the app is, and the same
5
+ path is what a link from the OS, an MCP tool, `srt render --link` or a saved
6
+ session carries. It is a layer over core: core reports links (`onLink`,
7
+ `env.launchLink`) and the back intent (`onBack`), the router decides what
8
+ they mean. An app can route by hand without it.
9
+
10
+ Not a web router. There is no address bar, no forward, no history API, no
11
+ code splitting, no data loading. What is left is small, and typed.
12
+
13
+ ## Routes
14
+
15
+ The tree is JSX, one `<Route>` per screen, nested for layouts:
16
+
17
+ ```tsx
18
+ import { Router, Route, Outlet, useParams, useNavigate } from "@solidrt/router"
19
+
20
+ render(() => (
21
+ <Window>
22
+ <Router initial="/">
23
+ <Route path="/" component={Shell}>
24
+ <Route path="/" component={Home} />
25
+ <Route path="/settings" component={SettingsLayout}>
26
+ <Route path="/$section" component={Section} />
27
+ </Route>
28
+ <Route path="/$" component={NotFound} />
29
+ </Route>
30
+ </Router>
31
+ </Window>
32
+ ))
33
+
34
+ function Section() {
35
+ let params = useParams() // () => { section: string }
36
+ return <text>{params().section}</text>
37
+ }
38
+ ```
39
+
40
+ A route's `path` is its own piece below the parent: literal segments,
41
+ `$name` for a param, `$name?` for an optional one at the end, `$` for the
42
+ rest of the path (captured as `rest`; a `$` route last under the root is
43
+ the not-found screen). `/` adds nothing: an index route under a layout, or
44
+ with children a pathless layout (the `Shell` above). A layout route's
45
+ component renders its child with `<Outlet />`; a route without a component
46
+ is an `<Outlet />` alone. Children match in declaration order, first match
47
+ wins, so a catch-all goes last.
48
+
49
+ A `<Route>` is not an element: it evaluates to a route value that its
50
+ parent places in the tree, once, when `<Router>` mounts. The router made
51
+ this way is reached through the hooks (`useNavigate`, `useRouter`).
52
+
53
+ ## Params are validated
54
+
55
+ A path arrives as strings, and from outside the app they are untrusted. A
56
+ JSX route hands them to its screen as they came (`useParams()` above). When
57
+ a param has a shape, a route is a value with a `parse`, placed in the JSX
58
+ tree where it belongs:
59
+
60
+ ```tsx
61
+ import { createRoute } from "@solidrt/router"
62
+
63
+ let item = createRoute({
64
+ path: "/item/$id",
65
+ params: { parse: (raw) => ({ id: parseId(raw.id) }) },
66
+ component: Item,
67
+ })
68
+
69
+ <Route route={item} /> // in the tree, next to the JSX routes
70
+
71
+ function Item() {
72
+ let params = useParams(item) // () => { id: number }
73
+ return <text>{params().id}</text>
74
+ }
75
+ ```
76
+
77
+ `params.parse` is the one place strings become typed values: return them,
78
+ or throw, and the route does not match (the next sibling is tried, then
79
+ the catch-all). `useParams(route)` is typed by the route it names, so there
80
+ is no way to read a value that did not pass. That is the rule: JSX for the
81
+ tree, a route value where a param needs a type. A JSX `<Route>` takes no
82
+ `params`.
83
+
84
+ Params are reactive: navigating to the same route with other params updates
85
+ `params()` without remounting the screen. An ancestor's params are in the
86
+ match too; read them through the ancestor, `useParams(settings)`.
87
+
88
+ ### The tree as values
89
+
90
+ The whole tree can be values instead, with the router made at module scope
91
+ so screens import it directly; the player app does this. Children are a
92
+ list, in matching order, and a route has one place in one tree:
93
+
94
+ ```tsx
95
+ let root = createRootRoute({
96
+ component: Shell,
97
+ children: [home, settings, item, notFound],
98
+ })
99
+ let router = createRouter({ tree: root, initial: "/" })
100
+
101
+ <Router router={router} />
102
+ ```
103
+
104
+ `<Route route={x}>` and a `children:` list are the same placement; a JSX
105
+ `<Route route={settings}>` can nest more `<Route>` elements under a value
106
+ that already has children of its own.
107
+
108
+ ## Navigation
109
+
110
+ ```tsx
111
+ let navigate = useNavigate()
112
+ navigate(home) // a route without params
113
+ navigate({ route: section, params: { section: "theme" } }) // one with params, checked
114
+ navigate("/settings/theme") // a path, as a link would carry it
115
+ navigate(home, { replace: true }) // swap the top instead of pushing
116
+ navigate(home, { reset: true }) // start the stack over here (a flow finished)
117
+ router.back() // pop
118
+ ```
119
+
120
+ The route forms are checked at compile time: a removed screen or a missing
121
+ param is a build error. The path form is matched at run time and refused,
122
+ with a warning, when nothing matches.
123
+
124
+ Every navigation is a signal write, so Solid's transition semantics apply:
125
+ a new screen whose async reads are still pending keeps the previous one on
126
+ screen, and `isPending(() => router.location())` says so. Data lives in
127
+ the screen (`createMemo(() => db.query(...))` under `<Loading>`, failures
128
+ in `<Errored>`); the router has no data layer.
129
+
130
+ `createLink(target)` gives `{ href, go }` for a pressable; styling is the
131
+ caller's.
132
+
133
+ ## Tabs
134
+
135
+ A `tabs` route makes each of its children a tab: a screen with a stack of
136
+ its own, kept mounted while another tab shows. Full-window screens (a
137
+ viewer, a login) go above the tab bar, as siblings of the tabs route.
138
+
139
+ ```tsx
140
+ import { useTabs } from "@solidrt/router"
141
+
142
+ <Router>
143
+ <Route path="/" tabs component={Main}>
144
+ <Route path="/feed">
145
+ <Route path="/" component={Feed} />
146
+ <Route path="/post/$id" component={Post} />
147
+ </Route>
148
+ <Route path="/search" component={Search} />
149
+ <Route path="/profile" component={Profile} />
150
+ </Route>
151
+ <Route path="/viewer/$id" component={Viewer} />
152
+ <Route path="/$" component={NotFound} />
153
+ </Router>
154
+
155
+ function Main() {
156
+ let tabs = useTabs() // { paths, active(), select(path) }
157
+ return (
158
+ <NavShell items={items} value={tabs.active()} onChange={(v) => tabs.select(v as string)}>
159
+ <Outlet />
160
+ </NavShell>
161
+ )
162
+ }
163
+ ```
164
+
165
+ A tab is named by its root path (`/feed`), the router's usual unit: what a
166
+ link carries, what a `NavShell` item holds. A tab's path is literal and
167
+ nothing above the tabs route takes a param; a tree holds one tabs route,
168
+ not inside another. `createRoute({ tabs: true, children })` is the value
169
+ form. The router draws no bar: `NavShell` from components does, wired in
170
+ app code as above, or any component of the app's own.
171
+
172
+ The tabs behave as native tab bars do:
173
+
174
+ - Selecting another tab shows it as it was left. Selecting the tab that
175
+ shows takes it back to its root. `navigate` to a tab's root path is the
176
+ same as selecting it.
177
+ - Navigating to a screen in a tab shows that tab and pushes the screen on
178
+ its stack. Navigating to a screen outside the tabs pushes it above the
179
+ tab bar, full window; back returns to the tabs, active tab intact.
180
+ - Back pops the screen above the tab bar if there is one, else the active
181
+ tab's stack while it is deeper than its root, else switches a non-first
182
+ tab to the first tab, and on the first tab's root leaves the event to
183
+ the platform.
184
+ - `replace` swaps the top of the stack that holds the target (the tab bar
185
+ itself, when a full-window screen replaces it); `reset` clears every
186
+ stack and lands the target with its tab's root beneath it.
187
+ - A link arriving while the app runs is a navigation. A launch link into a
188
+ tab lands with the tab's root beneath it, the other tabs on their roots.
189
+ - `useBlocker` in a tab holds the moves that change that tab's stack; in a
190
+ component outside the tabs, those that change the parent stack. Showing
191
+ another tab changes no stack and asks no blocker.
192
+
193
+ A tab mounts the first time it shows and stays mounted, hidden while
194
+ another shows: never painted, hit or focused, its reactive code still
195
+ running. Inside a tab `useLocation` and `useParams` read the tab's own
196
+ stack whether or not it shows, and `useTabs().active()` compared with the
197
+ tab's path says whether it does, for a screen to pause video or per-frame
198
+ work. The tabs route and each tab render in a full-size `<view>` (flex 1,
199
+ column) of their own. The tabs stay mounted under a full-window screen as
200
+ long as the routes above the tabs route stay matched, which they do when
201
+ that screen is a sibling of the tabs route.
202
+
203
+ With tabs, `entries()` is `{ stack, tabs: { active, stacks } }`: the parent
204
+ stack, in which the tabs route's own path stands for the tab bar, and a
205
+ stack per tab. `initial` takes that back; a path or a list of paths still
206
+ works too.
207
+
208
+ ## Back
209
+
210
+ `<Router>` registers one `onBack` step: it pops while the stack is deeper
211
+ than one entry, and at the root leaves the event to the platform default
212
+ (background on Android, exit elsewhere). With tabs the step is the one
213
+ described above. It registers before anything it
214
+ renders, so a dialog or a screen that registers its own `onBack` gets the
215
+ event first, as before.
216
+
217
+ `useBlocker(fn)` guards leaving the current entry while the component is
218
+ mounted: back, navigate and replace ask it first. Return `false` to stay,
219
+ `true` to go, or a promise that decides later (a confirm dialog). A
220
+ blocked back is prevented and never falls through to the platform.
221
+
222
+ ## Links in, location out
223
+
224
+ `createRouter` reads `env.launchLink`: a link the app was started with wins
225
+ over `initial` and lands as a stack of one. `<Router>` subscribes to
226
+ `onLink`: a link arriving while the app runs pushes onto the stack. A
227
+ custom-scheme link keeps everything after the scheme (`myapp://settings/theme`
228
+ is `/settings/theme`); an http(s) link drops its host; a query or fragment
229
+ is dropped. A link that matches nothing lands on the catch-all route, or is
230
+ ignored with a warning.
231
+
232
+ `<Router>` reports the current path through `reportLocation` from
233
+ `srt:dev`, so `GET /__control__/link` and the `get_location` MCP tool read
234
+ where the app is, and `POST /__control__/link` or `open_link` opens a
235
+ screen directly. `srt render --link /settings` renders that screen. A hot
236
+ reload starts the rebuilt app at the path it was on: the runtime hands the
237
+ last reported path to the new run as `env.launchLink`.
238
+
239
+ ## Restore
240
+
241
+ The stack is plain data:
242
+
243
+ ```tsx
244
+ onSuspend(() => file("nav.json").write(JSON.stringify(router.entries())))
245
+ ```
246
+
247
+ To resume, read it back before creating the router and pass it as
248
+ `initial` (an array is a saved stack). Whether to resume is the app's call
249
+ (`env.launch === "restored"`); a launch link still wins.
250
+
251
+ ## Reference
252
+
253
+ - `<Router initial?>` with `<Route path? component? tabs?>` / `<Route route>` children, or `<Router router />`; `<Outlet />`
254
+ - `createRoute({ path, params?, component?, children?, tabs? })`, `createRootRoute({ component?, children? })`
255
+ - `createRouter({ tree, initial? })` returns `{ entries, location, navigate, back, href }`
256
+ - `useRouter()`, `useLocation()`, `useParams()` / `useParams(route)`, `useNavigate()`, `createLink(target, options?)`, `useBlocker(fn)`, `useTabs()`
257
+ - `matchPath(root, path)`, `formatPath(route, params)`, `linkToPath(link)` for tooling and tests
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@solidrt/router",
3
+ "version": "0.0.62",
4
+ "license": "MIT",
5
+ "funding": "https://github.com/sponsors/wellawaretech",
6
+ "author": "Antoine van Wel",
7
+ "type": "module",
8
+ "main": "src/index.ts",
9
+ "exports": {
10
+ ".": "./src/index.ts"
11
+ },
12
+ "files": [
13
+ "src/",
14
+ "README.md",
15
+ "AGENTS.md"
16
+ ],
17
+ "peerDependencies": {
18
+ "@solidjs/signals": "2.0.0-rc.9",
19
+ "@solidrt/core": "0.0.62"
20
+ }
21
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ export { createRootRoute, createRoute, matchPath, formatPath, formatPattern, linkToPath } from "./route.ts"
2
+ export type { AnyRoute, ParamsOf, RouteOptions, RawParams, Match } from "./route.ts"
3
+ export {
4
+ createRouter,
5
+ Router,
6
+ Route,
7
+ Outlet,
8
+ useRouter,
9
+ useLocation,
10
+ useParams,
11
+ useNavigate,
12
+ createLink,
13
+ useBlocker,
14
+ useTabs,
15
+ } from "./router.tsx"
16
+ export type { Router as RouterInstance, RouterProps, RouteProps, Location, NavTarget, NavigateOptions, Blocker, RouterOptions, Tabs } from "./router.tsx"
17
+ export type { Entries, TabsState } from "./stack.ts"