@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/src/router.tsx ADDED
@@ -0,0 +1,450 @@
1
+ // The Solid binding over route.ts and stack.ts: the state in a signal, the
2
+ // navigation, the back step, blocking, links in and the location out, the
3
+ // JSX tree (`<Route>`) and the components that render the matched routes.
4
+ // Everything here is built on what core offers every app (onBack, onLink,
5
+ // env.launchLink, reportLocation from srt:dev); core knows nothing of routes.
6
+ import { createSignal, createMemo, createContext, createEffect, useContext, untrack, onCleanup, children, Show, For } from "@solidrt/core"
7
+ import { env, onBack, onLink } from "@solidrt/core"
8
+ import { reportLocation } from "srt:dev"
9
+ import { createRootRoute, createRoute, formatPath, linkToPath, matchPath, place } from "./route"
10
+ import type { AnyRoute, Match, ParamsOf, RawParams, Route as RouteValue } from "./route"
11
+ import { back as stepBack, changed, currentPath, findTabs, initialState, push, replace, reset, resolve } from "./stack"
12
+ import type { Entries, NavState, TabsInfo } from "./stack"
13
+
14
+ /** The route value (see route.ts); `Route` the component is the JSX form. */
15
+ export type Route<P = {}> = RouteValue<P>
16
+
17
+ /** Where the app is: the top of the stack, matched. */
18
+ export interface Location {
19
+ /** The path, root-first with params filled in; the stack's own currency. */
20
+ path: string
21
+ /** The matched routes root-first; the last is the screen. */
22
+ matches: Match[]
23
+ }
24
+
25
+ /**
26
+ * A navigation target: a path (as a link or a tool would give it), a route
27
+ * with no params, or a route with the params its pattern needs. The route
28
+ * forms are checked at compile time; the path form is matched at run time.
29
+ */
30
+ export type NavTarget<R extends AnyRoute = AnyRoute> = string | Route<{}> | { route: R; params: ParamsOf<R> }
31
+
32
+ export interface NavigateOptions {
33
+ /** Swap the current entry instead of pushing over it. */
34
+ replace?: boolean
35
+ /**
36
+ * Start the stack over at this entry: the app's home after a flow is
37
+ * finished (a scan dialed, a login done), where back should leave the app
38
+ * rather than revisit the flow. With tabs, every tab returns to its root.
39
+ */
40
+ reset?: boolean
41
+ }
42
+
43
+ /**
44
+ * A guard on leaving the current entry: false keeps the app where it is;
45
+ * true, or a promise resolving to true, lets the navigation through. The
46
+ * promise form holds it while the user answers a dialog.
47
+ */
48
+ export type Blocker = () => boolean | Promise<boolean>
49
+
50
+ export interface RouterOptions {
51
+ /** The root of the route tree (createRootRoute). */
52
+ tree: AnyRoute
53
+ /**
54
+ * Where the app starts: a path, or a saved `entries()` to resume with. A
55
+ * launch link (env.launchLink) wins over it: the user just asked for that
56
+ * screen. Defaults to "/".
57
+ */
58
+ initial?: string | Entries
59
+ }
60
+
61
+ /** The tabs of a tabs route, from `useTabs()`. */
62
+ export interface Tabs {
63
+ /** The tabs' root paths, in declaration order; the first is home. */
64
+ paths: string[]
65
+ /** The tab that shows, as its root path. Reactive. */
66
+ active(): string
67
+ /**
68
+ * Show a tab: another tab as it was left, the active one back at its
69
+ * root. Resolves false when a blocker held it.
70
+ */
71
+ select(path: string): Promise<boolean>
72
+ }
73
+
74
+ export interface Router {
75
+ /**
76
+ * The stack as plain data, bottom first: an array of paths, or with
77
+ * tabs the parent stack and the tabs. Save it in onSuspend. Reactive.
78
+ */
79
+ entries(): Entries
80
+ /** The matched top of the stack, or null when nothing matches. Reactive. */
81
+ location(): Location | null
82
+ /**
83
+ * Push a target onto the stack (or replace the top, or reset to it).
84
+ * Returns false, and warns, when no route matches; a blocker may cancel it
85
+ * later, which the returned promise reports.
86
+ */
87
+ navigate(target: NavTarget, options?: NavigateOptions): Promise<boolean>
88
+ /** Pop the stack. Resolves false at the root (nothing to pop) or when blocked. */
89
+ back(): Promise<boolean>
90
+ /** The path a target names; what a link to it would carry. */
91
+ href(target: NavTarget): string
92
+ /** @internal the state as stack.ts sees it */
93
+ state(): NavState
94
+ /** @internal the tree's tabs route, or null */
95
+ tabs: TabsInfo | null
96
+ /** @internal the tab that shows, or null without tabs */
97
+ activeTab(): string | null
98
+ /** @internal a tab's own location: the top of its stack, matched */
99
+ tabLocation(tab: string): () => Location | null
100
+ /** @internal the blockers registered by useBlocker, per scope (null: the parent stack) */
101
+ blockers: Map<string | null, Set<Blocker>>
102
+ /** @internal the tree */
103
+ tree: AnyRoute
104
+ }
105
+
106
+ /**
107
+ * The router's state, created once per app (at module scope or in the app
108
+ * component) and rendered by `<Router router={...}>`. Navigation is a
109
+ * signal write, so Solid's transition semantics apply: a screen whose async
110
+ * reads are pending keeps the previous one on screen, `isPending(() =>
111
+ * router.location()` says so.
112
+ */
113
+ export function createRouter(options: RouterOptions): Router {
114
+ let tree = options.tree
115
+ let tabs = findTabs(tree)
116
+ let launch = env.launchLink
117
+ let [state, setState] = createSignal<NavState>(
118
+ initialState(tree, tabs, launch != null ? launch : options.initial, linkToPath),
119
+ )
120
+ let located = (path: string | undefined): Location | null => {
121
+ if (path === undefined) return null
122
+ let matches = matchPath(tree, path)
123
+ return matches ? { path, matches } : null
124
+ }
125
+ let location = createMemo<Location | null>(() => located(currentPath(state(), tabs)))
126
+ let tabLocations = new Map<string, () => Location | null>()
127
+ for (let root of tabs?.roots ?? []) {
128
+ tabLocations.set(
129
+ root,
130
+ createMemo<Location | null>(() => {
131
+ let stack = state().tabs?.stacks[root]
132
+ return located(stack?.[stack.length - 1])
133
+ }),
134
+ )
135
+ }
136
+ let blockers = new Map<string | null, Set<Blocker>>()
137
+
138
+ let href = (target: NavTarget): string => {
139
+ if (typeof target === "string") return linkToPath(target)
140
+ if ("route" in target) return formatPath(target.route, target.params as Record<string, unknown>)
141
+ return formatPath(target)
142
+ }
143
+
144
+ // Runs the blockers of the given scopes; a sync false cancels at once,
145
+ // promises are awaited together and any false cancels.
146
+ let allowed = async (scopes: Array<string | null>): Promise<boolean> => {
147
+ let pending: Promise<boolean>[] = []
148
+ for (let scope of scopes) {
149
+ for (let block of blockers.get(scope) ?? []) {
150
+ let verdict = block()
151
+ if (verdict === false) return false
152
+ if (verdict !== true) pending.push(verdict)
153
+ }
154
+ }
155
+ if (pending.length === 0) return true
156
+ return (await Promise.all(pending)).every(Boolean)
157
+ }
158
+
159
+ let router: Router = {
160
+ entries: () => {
161
+ let s = state()
162
+ return s.tabs ? { stack: s.stack, tabs: s.tabs } : s.stack
163
+ },
164
+ location,
165
+ href,
166
+ state,
167
+ tabs,
168
+ activeTab: () => state().tabs?.active ?? null,
169
+ tabLocation: (tab) => tabLocations.get(tab) ?? (() => null),
170
+ blockers,
171
+ tree,
172
+ async navigate(target, options = {}) {
173
+ let path = href(target)
174
+ let to = resolve(tree, tabs, path)
175
+ if (!to) {
176
+ console.warn(`Router: no route matches "${path}"`)
177
+ return false
178
+ }
179
+ let apply = (s: NavState) => (options.reset ? reset(tabs, to) : options.replace ? replace(s, tabs, to) : push(s, tabs, to))
180
+ // The blockers asked are those of the stacks the move changes; the
181
+ // state is read again after they answer, in case it moved meanwhile.
182
+ let before = untrack(state)
183
+ if (!(await allowed(changed(before, apply(before))))) return false
184
+ setState((s) => apply(s))
185
+ return true
186
+ },
187
+ async back() {
188
+ let before = untrack(state)
189
+ let next = stepBack(before, tabs)
190
+ if (!next) return false
191
+ if (!(await allowed(changed(before, next)))) return false
192
+ setState((s) => stepBack(s, tabs) ?? s)
193
+ return true
194
+ },
195
+ }
196
+ return router
197
+ }
198
+
199
+ const RouterContext = createContext<Router>()
200
+ // How deep in the matched chain the surrounding route is; an Outlet renders
201
+ // the next one.
202
+ const DepthContext = createContext<number>(0)
203
+ // The location a subtree renders from: the app's, or inside a tab that
204
+ // tab's own, so a hidden tab keeps its params and screen while another
205
+ // shows. `tab` is the blocker scope.
206
+ interface Scope {
207
+ location: () => Location | null
208
+ tab: string | null
209
+ }
210
+ const ScopeContext = createContext<Scope>()
211
+
212
+ export type RouterProps =
213
+ /** A router made with createRouter, over a tree of route values. */
214
+ | { router: Router; initial?: never; children?: never }
215
+ /**
216
+ * A tree of `<Route>` elements, in matching order, under a root with no
217
+ * component; the router is made here and reached through the hooks.
218
+ */
219
+ | { router?: never; initial?: string | Entries; children: any }
220
+
221
+ /**
222
+ * Renders the matched routes and owns the app's step of the back stack, the
223
+ * links in and the location out. Mount it once, inside the window. Its
224
+ * `onBack` registers first, so every screen and dialog above it gets the
225
+ * event before the stack pops.
226
+ */
227
+ export function Router(props: RouterProps) {
228
+ let router = untrack(
229
+ () =>
230
+ props.router ??
231
+ createRouter({ tree: createRootRoute({ children: routesOf(props.children) }), initial: props.initial }),
232
+ )
233
+
234
+ onBack((e) => {
235
+ // Nothing to pop at the root: the platform's default (background on
236
+ // Android, exit elsewhere) runs unless something above prevented it.
237
+ if (stepBack(untrack(router.state), router.tabs) === null) return
238
+ e.preventDefault()
239
+ void router.back()
240
+ })
241
+ onLink((link) => {
242
+ void router.navigate(linkToPath(link))
243
+ })
244
+ // The location out: tooling reads it, and a hot reload re-enters it.
245
+ createEffect(
246
+ () => router.location()?.path ?? null,
247
+ (path) => reportLocation(path),
248
+ )
249
+ onCleanup(() => reportLocation(null))
250
+
251
+ return (
252
+ <RouterContext value={router}>
253
+ <ScopeContext value={{ location: router.location, tab: null }}>
254
+ <RouteView depth={0} />
255
+ </ScopeContext>
256
+ </RouterContext>
257
+ )
258
+ }
259
+
260
+ export interface RouteProps {
261
+ /** This route's own path below its parent (see createRoute); `/` without one. */
262
+ path?: string
263
+ /** What renders here; without one, an outlet alone. */
264
+ component?: () => any
265
+ /** Each child is a tab with a stack of its own (see createRoute). */
266
+ tabs?: boolean
267
+ /**
268
+ * A route value to place here instead: typed or validated params live on
269
+ * the value (createRoute), the JSX gives it its place in the tree. Not
270
+ * with `path`, `component` or `tabs`.
271
+ */
272
+ route?: AnyRoute
273
+ /** Nested `<Route>` elements, in matching order. */
274
+ children?: any
275
+ }
276
+
277
+ /**
278
+ * One route of a JSX tree, under `<Router>` or another `<Route>`. Not an
279
+ * element: it evaluates to a route value that its parent places.
280
+ */
281
+ export function Route(props: RouteProps): any {
282
+ return untrack(() => {
283
+ let below = routesOf(props.children)
284
+ if (props.route) {
285
+ if (props.path !== undefined || props.component !== undefined || props.tabs !== undefined) {
286
+ throw new Error("<Route route={...}> takes no path, component or tabs: they are the route's own")
287
+ }
288
+ place(props.route, below)
289
+ return props.route
290
+ }
291
+ return createRoute({ path: props.path ?? "/", component: props.component, tabs: props.tabs, children: below })
292
+ })
293
+ }
294
+
295
+ // The route values a `children` slot holds, resolved once; anything else
296
+ // there is a mistake (a `<Route>` is not an element and cannot mix with
297
+ // them).
298
+ function routesOf(slot: any): AnyRoute[] {
299
+ let list = children(() => slot).toArray()
300
+ for (let item of list) {
301
+ if (typeof item !== "object" || item === null || !("segments" in item)) {
302
+ throw new Error("<Router> and <Route> take only <Route> elements as children")
303
+ }
304
+ }
305
+ return list as unknown as AnyRoute[]
306
+ }
307
+
308
+ // The route at `depth` of the scope's match chain. Keyed on the route, not
309
+ // the match: params changing under the same route update reactively through
310
+ // useParams, without a remount. The tabs route is the exception: once
311
+ // mounted at its depth it stays, hidden while another route shows there (a
312
+ // full-window screen over the tab bar), so the tabs keep their state.
313
+ function RouteView(props: { depth: number }) {
314
+ let router = useContext(RouterContext)
315
+ let scope = useContext(ScopeContext)
316
+ let route = createMemo(() => scope.location()?.matches[props.depth]?.route ?? null)
317
+ let tabsRoute = router.tabs?.route ?? null
318
+ let tabsSeen = createMemo(() => tabsRoute !== null && route() === tabsRoute)
319
+ let seen = false
320
+ let tabsMounted = createMemo(() => (seen ||= tabsSeen()))
321
+ let render = (r: AnyRoute) => (r.component ? <r.component /> : <Outlet />)
322
+ return (
323
+ <DepthContext value={props.depth}>
324
+ <Show when={tabsMounted()}>
325
+ <view flex={1} flexDirection="column" display={tabsSeen() ? "flex" : "none"}>
326
+ {render(tabsRoute!)}
327
+ </view>
328
+ </Show>
329
+ <Show when={tabsSeen() ? null : route()} keyed>
330
+ {render}
331
+ </Show>
332
+ </DepthContext>
333
+ )
334
+ }
335
+
336
+ /**
337
+ * Where a layout route's matched child renders. Under the tabs route it
338
+ * holds every tab visited so far, each in its own full-size view, the
339
+ * inactive ones hidden.
340
+ */
341
+ export function Outlet() {
342
+ let router = useContext(RouterContext)
343
+ let depth = useContext(DepthContext)
344
+ let scope = useContext(ScopeContext)
345
+ let here = untrack(() => scope.location()?.matches[depth]?.route ?? null)
346
+ if (here !== null && here === router.tabs?.route) return <TabsOutlet depth={depth} />
347
+ return <RouteView depth={depth + 1} />
348
+ }
349
+
350
+ // The tabs under the tabs route at `depth`: mounted on first visit, kept
351
+ // after, hidden while inactive. A hidden tab is laid out to nothing, so it
352
+ // is never painted, hit or focused; its reactive code keeps running.
353
+ function TabsOutlet(props: { depth: number }) {
354
+ let router = useContext(RouterContext)
355
+ let seen: string[] = []
356
+ let visited = createMemo(() => {
357
+ let active = router.activeTab()
358
+ if (active !== null && !seen.includes(active)) seen = [...seen, active]
359
+ return seen
360
+ })
361
+ return (
362
+ <For each={visited()}>
363
+ {(tab: string) => (
364
+ <view flex={1} flexDirection="column" display={router.activeTab() === tab ? "flex" : "none"}>
365
+ <ScopeContext value={{ location: router.tabLocation(tab), tab }}>
366
+ <RouteView depth={props.depth + 1} />
367
+ </ScopeContext>
368
+ </view>
369
+ )}
370
+ </For>
371
+ )
372
+ }
373
+
374
+ /** The router this component renders under. */
375
+ export function useRouter(): Router {
376
+ return useContext(RouterContext)
377
+ }
378
+
379
+ /**
380
+ * The current location, reactive; null when nothing matches. Inside a tab
381
+ * it is that tab's own, whether or not the tab shows.
382
+ */
383
+ export function useLocation(): () => Location | null {
384
+ return useContext(ScopeContext).location
385
+ }
386
+
387
+ /**
388
+ * The params of the current location. With a route (the one this component
389
+ * renders at, or an ancestor) they are typed by it; without one they are
390
+ * the screen's raw strings, as a JSX `<Route path>` carries them. Reactive:
391
+ * a navigation to the same route with other params updates it in place.
392
+ */
393
+ export function useParams(): () => RawParams
394
+ export function useParams<R extends AnyRoute>(route: R): () => ParamsOf<R>
395
+ export function useParams(route?: AnyRoute): () => any {
396
+ let scope = useContext(ScopeContext)
397
+ return createMemo(() => {
398
+ let matches = scope.location()?.matches ?? []
399
+ let match = (route && matches.find((m) => m.route === route)) ?? matches[matches.length - 1]
400
+ return match?.params ?? {}
401
+ })
402
+ }
403
+
404
+ /** `navigate` bound to the router this component renders under. */
405
+ export function useNavigate(): (target: NavTarget, options?: NavigateOptions) => Promise<boolean> {
406
+ let router = useContext(RouterContext)
407
+ return (target, options) => router.navigate(target, options)
408
+ }
409
+
410
+ /**
411
+ * The tabs of the tree's tabs route, for the component that draws the tab
412
+ * bar (and for a screen to learn whether its tab shows). Throws without a
413
+ * tabs route.
414
+ */
415
+ export function useTabs(): Tabs {
416
+ let router = useContext(RouterContext)
417
+ let tabs = router.tabs
418
+ if (!tabs) throw new Error("useTabs: the route tree has no tabs route")
419
+ return {
420
+ paths: tabs.roots,
421
+ active: () => router.activeTab() ?? tabs.roots[0]!,
422
+ select: (path) => router.navigate(path),
423
+ }
424
+ }
425
+
426
+ /**
427
+ * A link: its path (for display or a tool) and a `go()` for a press handler.
428
+ * Styling is the caller's; a components Link wraps it.
429
+ */
430
+ export function createLink(target: NavTarget, options?: NavigateOptions): { href: string; go: () => Promise<boolean> } {
431
+ let router = useContext(RouterContext)
432
+ return { href: router.href(target), go: () => router.navigate(target, options) }
433
+ }
434
+
435
+ /**
436
+ * Guards leaving the current entry while this component is mounted: back,
437
+ * navigate and replace ask `block` first when they change the stack this
438
+ * component is in (a tab's own, or the parent stack). Return false to stay,
439
+ * true to go, or a promise (a confirm dialog) that decides later. A
440
+ * blocked back is prevented, so it never falls through to the platform
441
+ * default. Showing another tab changes no stack, so it asks no blocker.
442
+ */
443
+ export function useBlocker(block: Blocker): void {
444
+ let router = useContext(RouterContext)
445
+ let scope = useContext(ScopeContext).tab
446
+ let set = router.blockers.get(scope)
447
+ if (!set) router.blockers.set(scope, (set = new Set()))
448
+ set.add(block)
449
+ onCleanup(() => set.delete(block))
450
+ }
package/src/stack.ts ADDED
@@ -0,0 +1,249 @@
1
+ // The navigation state and its transitions, headless: a parent stack, and
2
+ // with a tabs route in the tree a stack per tab under it. Plain functions
3
+ // over plain data, no signals; router.tsx keeps the state in a signal and
4
+ // applies these. Tested under bun without a client (tests/).
5
+ //
6
+ // Without tabs the state is the one stack of paths it always was. With
7
+ // tabs, the parent stack holds the full-window screens and one marker
8
+ // entry, the tabs route's own path, that stands for the tab bar screen;
9
+ // which tab shows and what each tab has pushed is in `tabs`. This is the
10
+ // navigator tree every native router keeps (a root stack, tabs, a stack per
11
+ // tab), written as data.
12
+ import { formatPath, formatPattern, matchPath, tabsRoutes } from "./route"
13
+ import type { AnyRoute, Match } from "./route"
14
+
15
+ export interface TabsState {
16
+ /** The tab that shows: its root path. */
17
+ active: string
18
+ /** Each tab's stack, bottom first; the bottom is always the tab's root. */
19
+ stacks: Record<string, string[]>
20
+ }
21
+
22
+ export interface NavState {
23
+ /** The parent stack, bottom first; `tabs.path` in it is the tab bar screen. */
24
+ stack: string[]
25
+ tabs: TabsState | null
26
+ }
27
+
28
+ /**
29
+ * The stack as plain data: an array of paths without tabs, the parent stack
30
+ * plus the tabs with them. What `entries()` returns and `initial` takes.
31
+ */
32
+ export type Entries = string[] | { stack: string[]; tabs: TabsState }
33
+
34
+ /** The tree's tabs route, resolved once. */
35
+ export interface TabsInfo {
36
+ route: AnyRoute
37
+ /** The route's own path: the parent-stack entry that stands for the tab bar. */
38
+ path: string
39
+ /** The tabs' root paths, in declaration order; the first is home. */
40
+ roots: string[]
41
+ }
42
+
43
+ export function findTabs(root: AnyRoute): TabsInfo | null {
44
+ let found = tabsRoutes(root)
45
+ if (found.length === 0) return null
46
+ if (found.length > 1) {
47
+ throw new Error(`Router: one tabs route per tree ("${formatPattern(found[0]!)}" and "${formatPattern(found[1]!)}")`)
48
+ }
49
+ let route = found[0]!
50
+ return { route, path: formatPath(route), roots: route.children.map((tab) => formatPath(tab)) }
51
+ }
52
+
53
+ /** A path resolved against the tree: where it lands. */
54
+ export interface Target {
55
+ path: string
56
+ matches: Match[]
57
+ /** The tab it falls under (its root path), or null for the parent stack. */
58
+ tab: string | null
59
+ }
60
+
61
+ /**
62
+ * Where `path` lands, or null when nothing matches. A path that names the
63
+ * tabs route itself, with no tab under it, lands on the first tab's root.
64
+ */
65
+ export function resolve(tree: AnyRoute, tabs: TabsInfo | null, path: string): Target | null {
66
+ let matches = matchPath(tree, path)
67
+ if (!matches) return null
68
+ if (!tabs) return { path, matches, tab: null }
69
+ let at = matches.findIndex((m) => m.route === tabs.route)
70
+ if (at < 0) return { path, matches, tab: null }
71
+ let tab = matches[at + 1]
72
+ if (!tab) return resolve(tree, tabs, tabs.roots[0]!)
73
+ return { path, matches, tab: formatPath(tab.route) }
74
+ }
75
+
76
+ // Every tab on its root, `active` showing.
77
+ function allRoots(tabs: TabsInfo, active: string): TabsState {
78
+ let stacks: Record<string, string[]> = {}
79
+ for (let root of tabs.roots) stacks[root] = [root]
80
+ return { active, stacks }
81
+ }
82
+
83
+ // The parent stack with the tab bar on top: cut back to the marker when it
84
+ // is there (the screens above it leave), appended when it is not. The same
85
+ // array when it already is on top.
86
+ function showTabs(stack: string[], marker: string): string[] {
87
+ let at = stack.lastIndexOf(marker)
88
+ if (at === stack.length - 1) return stack
89
+ return at >= 0 ? stack.slice(0, at + 1) : [...stack, marker]
90
+ }
91
+
92
+ // `state` with tab `tab` showing and its stack replaced.
93
+ function withTab(state: NavState, tabs: TabsInfo, tab: string, stack: string[]): NavState {
94
+ let current = state.tabs ?? allRoots(tabs, tab)
95
+ let same = current.stacks[tab] === stack
96
+ return {
97
+ stack: showTabs(state.stack, tabs.path),
98
+ tabs: { active: tab, stacks: same ? current.stacks : { ...current.stacks, [tab]: stack } },
99
+ }
100
+ }
101
+
102
+ // A tab's root as a target is the tab bar's tap: a tab that does not show
103
+ // is shown as it was left, the one that shows goes back to its root. The
104
+ // stack for the target's tab, or null when the target is a deeper screen.
105
+ function toRoot(state: NavState, tabs: TabsInfo, target: Target): string[] | null {
106
+ if (target.path !== target.tab) return null
107
+ let current = state.tabs?.stacks[target.tab!] ?? [target.tab!]
108
+ let showing = state.tabs?.active === target.tab && state.stack[state.stack.length - 1] === tabs.path
109
+ return showing && current.length > 1 ? [target.tab!] : current
110
+ }
111
+
112
+ /** `target` pushed: onto the parent stack, or onto its tab, which then shows. */
113
+ export function push(state: NavState, tabs: TabsInfo | null, target: Target): NavState {
114
+ if (!tabs || target.tab === null) return { ...state, stack: [...state.stack, target.path] }
115
+ let current = state.tabs?.stacks[target.tab] ?? [target.tab]
116
+ return withTab(state, tabs, target.tab, toRoot(state, tabs, target) ?? [...current, target.path])
117
+ }
118
+
119
+ /**
120
+ * `target` swapped for the top of the stack that holds it: the parent
121
+ * stack's top for a full-window screen (the tab bar itself when that is
122
+ * showing), the tab's top for a screen in a tab, which then shows. A tab's
123
+ * root is never swapped out, and a tab's root as the target is a tap on
124
+ * its tab, as for push.
125
+ */
126
+ export function replace(state: NavState, tabs: TabsInfo | null, target: Target): NavState {
127
+ if (!tabs || target.tab === null) return { ...state, stack: [...state.stack.slice(0, -1), target.path] }
128
+ let current = state.tabs?.stacks[target.tab] ?? [target.tab]
129
+ let next =
130
+ toRoot(state, tabs, target) ?? (current.length > 1 ? [...current.slice(0, -1), target.path] : [target.tab, target.path])
131
+ return withTab(state, tabs, target.tab, next)
132
+ }
133
+
134
+ /**
135
+ * The state started over at `target`: every tab on its root, and the
136
+ * target's tab root beneath it when it is in a tab. A full-window target
137
+ * stands alone, so back leaves the app (a flow that ends outside the tabs).
138
+ */
139
+ export function reset(tabs: TabsInfo | null, target: Target): NavState {
140
+ if (!tabs) return { stack: [target.path], tabs: null }
141
+ if (target.tab === null) return { stack: [target.path], tabs: allRoots(tabs, tabs.roots[0]!) }
142
+ let all = allRoots(tabs, target.tab)
143
+ all.stacks[target.tab] = target.path === target.tab ? [target.tab] : [target.tab, target.path]
144
+ return { stack: [tabs.path], tabs: all }
145
+ }
146
+
147
+ /**
148
+ * One step back: the tab bar showing pops its active tab, or switches a
149
+ * non-first tab at its root to the first; otherwise the parent stack pops.
150
+ * Null when there is nothing to pop.
151
+ */
152
+ export function back(state: NavState, tabs: TabsInfo | null): NavState | null {
153
+ if (tabs && state.tabs && state.stack[state.stack.length - 1] === tabs.path) {
154
+ let active = state.tabs.active
155
+ let current = state.tabs.stacks[active] ?? [active]
156
+ if (current.length > 1) return withTab(state, tabs, active, current.slice(0, -1))
157
+ if (active !== tabs.roots[0]) return { ...state, tabs: { ...state.tabs, active: tabs.roots[0]! } }
158
+ }
159
+ if (state.stack.length > 1) return { ...state, stack: state.stack.slice(0, -1) }
160
+ return null
161
+ }
162
+
163
+ /** The path that shows: the parent stack's top, or the active tab's. */
164
+ export function currentPath(state: NavState, tabs: TabsInfo | null): string | undefined {
165
+ let top = state.stack[state.stack.length - 1]
166
+ if (tabs && state.tabs && top === tabs.path) {
167
+ let stack = state.tabs.stacks[state.tabs.active]
168
+ return stack?.[stack.length - 1]
169
+ }
170
+ return top
171
+ }
172
+
173
+ /**
174
+ * The scopes whose stacks `next` changes from `prev`: null for the parent
175
+ * stack, a tab's root for its stack. What a navigation leaves, so whose
176
+ * blockers it asks; a bare tab switch changes no stack and asks none.
177
+ */
178
+ export function changed(prev: NavState, next: NavState): Array<string | null> {
179
+ let out: Array<string | null> = []
180
+ if (prev.stack !== next.stack) out.push(null)
181
+ if (prev.tabs && next.tabs && prev.tabs.stacks !== next.tabs.stacks) {
182
+ for (let tab of Object.keys(next.tabs.stacks)) {
183
+ if (prev.tabs.stacks[tab] !== next.tabs.stacks[tab]) out.push(tab)
184
+ }
185
+ }
186
+ return out
187
+ }
188
+
189
+ /**
190
+ * The state `initial` names, or the launch link's. A path lands where a
191
+ * push from nothing would, a tab's root beneath a screen in a tab; a list
192
+ * of paths is pushed in order; an `entries()` object is taken as saved
193
+ * when every entry still fits the tree, and not at all otherwise (what
194
+ * the app should show instead is the app's call, not a repair). Paths
195
+ * that match nothing are dropped with a warning, and with nothing left
196
+ * the app starts at "/".
197
+ */
198
+ export function initialState(
199
+ tree: AnyRoute,
200
+ tabs: TabsInfo | null,
201
+ initial: string | Entries | undefined,
202
+ normalize: (path: string) => string,
203
+ ): NavState {
204
+ let target = (path: string): Target | null => {
205
+ let t = resolve(tree, tabs, normalize(path))
206
+ if (!t) console.warn(`Router: no route matches "${path}"; dropped from the initial stack`)
207
+ return t
208
+ }
209
+ if (initial !== undefined && !Array.isArray(initial) && typeof initial !== "string") {
210
+ if (fits(tree, tabs, initial, normalize)) return { stack: initial.stack, tabs: tabs ? initial.tabs : null }
211
+ initial = undefined
212
+ }
213
+ let paths = initial === undefined ? ["/"] : typeof initial === "string" ? [initial] : initial
214
+ let state: NavState | null = null
215
+ for (let path of paths) {
216
+ let t = target(path)
217
+ if (!t) continue
218
+ state = state ? push(state, tabs, t) : reset(tabs, t)
219
+ }
220
+ return state ?? { stack: ["/"], tabs: tabs ? allRoots(tabs, tabs.roots[0]!) : null }
221
+ }
222
+
223
+ // Whether a saved `entries()` object still describes this tree: a parent
224
+ // entry is the tabs marker or lands outside the tabs, every tab is there
225
+ // with its root at the bottom and its entries landing in it, and `active`
226
+ // is a tab. The first misfit is named in a warning.
227
+ function fits(
228
+ tree: AnyRoute,
229
+ tabs: TabsInfo | null,
230
+ saved: { stack: string[]; tabs: TabsState },
231
+ normalize: (path: string) => string,
232
+ ): boolean {
233
+ let misfit = (what: string): false => {
234
+ console.warn(`Router: the saved stack does not fit the route tree (${what}); not resumed`)
235
+ return false
236
+ }
237
+ let lands = (path: string, tab: string | null) => resolve(tree, tabs, normalize(path))?.tab === tab
238
+ for (let path of saved.stack) {
239
+ if (!(tabs && path === tabs.path) && !lands(path, null)) return misfit(`"${path}"`)
240
+ }
241
+ if (!tabs) return saved.stack.length > 0 || misfit("empty")
242
+ if (!tabs.roots.includes(saved.tabs.active)) return misfit(`active tab "${saved.tabs.active}"`)
243
+ for (let root of tabs.roots) {
244
+ let entries = saved.tabs.stacks[root]
245
+ if (!entries || entries[0] !== root) return misfit(`tab "${root}"`)
246
+ for (let path of entries) if (!lands(path, root)) return misfit(`"${path}" in tab "${root}"`)
247
+ }
248
+ return saved.stack.length > 0 || misfit("empty")
249
+ }