@sigx/lynx-navigation 0.1.0 → 0.1.2
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/LICENSE +1 -1
- package/README.md +355 -0
- package/dist/components/Drawer.d.ts +56 -0
- package/dist/components/Drawer.d.ts.map +1 -0
- package/dist/components/Drawer.js +74 -0
- package/dist/components/Drawer.js.map +1 -0
- package/dist/components/EdgeBackHandle.js +144 -0
- package/dist/components/EdgeBackHandle.js.map +1 -0
- package/dist/components/EntryScope.d.ts +26 -0
- package/dist/components/EntryScope.d.ts.map +1 -0
- package/dist/components/EntryScope.js +33 -0
- package/dist/components/EntryScope.js.map +1 -0
- package/dist/components/Header.d.ts +7 -0
- package/dist/components/Header.d.ts.map +1 -0
- package/dist/components/Header.js +103 -0
- package/dist/components/Header.js.map +1 -0
- package/dist/components/Link.js +1 -4
- package/dist/components/Link.js.map +1 -1
- package/dist/components/NavigationRoot.d.ts +1 -1
- package/dist/components/NavigationRoot.d.ts.map +1 -1
- package/dist/components/NavigationRoot.js +29 -3
- package/dist/components/NavigationRoot.js.map +1 -1
- package/dist/components/Screen.d.ts +98 -0
- package/dist/components/Screen.d.ts.map +1 -0
- package/dist/components/Screen.js +94 -0
- package/dist/components/Screen.js.map +1 -0
- package/dist/components/ScreenContainer.d.ts.map +1 -1
- package/dist/components/ScreenContainer.js +77 -0
- package/dist/components/ScreenContainer.js.map +1 -0
- package/dist/components/Stack.d.ts.map +1 -1
- package/dist/components/Stack.js +60 -24
- package/dist/components/Stack.js.map +1 -1
- package/dist/components/TabBar.d.ts +40 -0
- package/dist/components/TabBar.d.ts.map +1 -0
- package/dist/components/TabBar.js +63 -0
- package/dist/components/TabBar.js.map +1 -0
- package/dist/components/Tabs.d.ts +101 -0
- package/dist/components/Tabs.d.ts.map +1 -0
- package/dist/components/Tabs.js +140 -0
- package/dist/components/Tabs.js.map +1 -0
- package/dist/hooks/use-focus.d.ts +46 -0
- package/dist/hooks/use-focus.d.ts.map +1 -0
- package/dist/hooks/use-focus.js +81 -0
- package/dist/hooks/use-focus.js.map +1 -0
- package/dist/hooks/use-hardware-back.js +50 -0
- package/dist/hooks/use-hardware-back.js.map +1 -0
- package/dist/hooks/use-linking-nav.d.ts +92 -0
- package/dist/hooks/use-linking-nav.d.ts.map +1 -0
- package/dist/hooks/use-linking-nav.js +109 -0
- package/dist/hooks/use-linking-nav.js.map +1 -0
- package/dist/hooks/use-nav-internal.d.ts +38 -1
- package/dist/hooks/use-nav-internal.d.ts.map +1 -1
- package/dist/hooks/use-nav-internal.js +32 -0
- package/dist/hooks/use-nav-internal.js.map +1 -1
- package/dist/hooks/use-nav-serializer.d.ts +83 -0
- package/dist/hooks/use-nav-serializer.d.ts.map +1 -0
- package/dist/hooks/use-nav-serializer.js +181 -0
- package/dist/hooks/use-nav-serializer.js.map +1 -0
- package/dist/hooks/use-nav.js.map +1 -1
- package/dist/hooks/use-screen-options.d.ts +3 -0
- package/dist/hooks/use-screen-options.d.ts.map +1 -0
- package/dist/hooks/use-screen-options.js +43 -0
- package/dist/hooks/use-screen-options.js.map +1 -0
- package/dist/href.d.ts +16 -1
- package/dist/href.d.ts.map +1 -1
- package/dist/href.js +50 -7
- package/dist/href.js.map +1 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/screen-registry.d.ts +49 -0
- package/dist/internal/screen-registry.d.ts.map +1 -0
- package/dist/internal/screen-registry.js +59 -0
- package/dist/internal/screen-registry.js.map +1 -0
- package/dist/internal/screen-width.js +30 -0
- package/dist/internal/screen-width.js.map +1 -0
- package/dist/navigator/core.d.ts +20 -1
- package/dist/navigator/core.d.ts.map +1 -1
- package/dist/navigator/core.js +231 -36
- package/dist/navigator/core.js.map +1 -1
- package/dist/types.d.ts +56 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/url/build.d.ts +16 -0
- package/dist/url/build.d.ts.map +1 -0
- package/dist/url/build.js +30 -0
- package/dist/url/build.js.map +1 -0
- package/dist/url/compile.d.ts +35 -0
- package/dist/url/compile.d.ts.map +1 -0
- package/dist/url/compile.js +83 -0
- package/dist/url/compile.js.map +1 -0
- package/dist/url/format.d.ts +29 -0
- package/dist/url/format.d.ts.map +1 -0
- package/dist/url/format.js +102 -0
- package/dist/url/format.js.map +1 -0
- package/dist/url/index.d.ts +13 -0
- package/dist/url/index.d.ts.map +1 -0
- package/dist/url/index.js +13 -0
- package/dist/url/index.js.map +1 -0
- package/dist/url/parse.d.ts +21 -0
- package/dist/url/parse.d.ts.map +1 -0
- package/dist/url/parse.js +94 -0
- package/dist/url/parse.js.map +1 -0
- package/dist/url/registry.d.ts +41 -0
- package/dist/url/registry.d.ts.map +1 -0
- package/dist/url/registry.js +56 -0
- package/dist/url/registry.js.map +1 -0
- package/dist/url/validate.d.ts +24 -0
- package/dist/url/validate.d.ts.map +1 -0
- package/dist/url/validate.js +37 -0
- package/dist/url/validate.js.map +1 -0
- package/package.json +44 -15
- package/src/components/Drawer.tsx +119 -0
- package/src/components/EdgeBackHandle.tsx +1 -1
- package/src/components/EntryScope.tsx +38 -0
- package/src/components/Header.tsx +129 -0
- package/src/components/NavigationRoot.tsx +9 -1
- package/src/components/Screen.tsx +116 -0
- package/src/components/ScreenContainer.tsx +14 -1
- package/src/components/Stack.tsx +21 -2
- package/src/components/TabBar.tsx +104 -0
- package/src/components/Tabs.tsx +216 -0
- package/src/hooks/use-focus.ts +88 -0
- package/src/hooks/use-linking-nav.ts +159 -0
- package/src/hooks/use-nav-internal.ts +48 -1
- package/src/hooks/use-nav-serializer.ts +239 -0
- package/src/hooks/use-screen-options.ts +48 -0
- package/src/href.ts +68 -11
- package/src/index.ts +29 -0
- package/src/internal/screen-registry.ts +89 -0
- package/src/navigator/core.ts +86 -4
- package/src/types.ts +56 -0
- package/src/url/build.ts +35 -0
- package/src/url/compile.ts +109 -0
- package/src/url/format.ts +95 -0
- package/src/url/index.ts +18 -0
- package/src/url/parse.ts +102 -0
- package/src/url/registry.ts +69 -0
- package/src/url/validate.ts +67 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { effect, onMounted, onUnmounted } from '@sigx/lynx';
|
|
2
|
+
import { useNav } from './use-nav.js';
|
|
3
|
+
import { useNavRoutes } from './use-nav-internal.js';
|
|
4
|
+
import type { StackEntry } from '../types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Plain JSON snapshot of a navigator. The whole point of holding navigation
|
|
8
|
+
* state in signals is that this is a one-liner — `JSON.stringify(nav.stack)`.
|
|
9
|
+
*
|
|
10
|
+
* Shape is deliberately minimal:
|
|
11
|
+
*
|
|
12
|
+
* {
|
|
13
|
+
* version: 1,
|
|
14
|
+
* stack: [ { key, route, params, search, state, presentation }, ... ],
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* `version` lets future schema migrations (or hard breakage) reject old
|
|
18
|
+
* snapshots cleanly rather than restoring incompatible state.
|
|
19
|
+
*
|
|
20
|
+
* Per spec resolved-decisions: only the root navigator is persisted in v1.
|
|
21
|
+
* Per-tab / nested-navigator stacks are deferred until the nested-navigators
|
|
22
|
+
* follow-up slice lands.
|
|
23
|
+
*/
|
|
24
|
+
export interface NavSnapshot {
|
|
25
|
+
version: number;
|
|
26
|
+
stack: StackEntry[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const NAV_SNAPSHOT_VERSION = 1;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Adapter contract for `useNavSerializer`. Implementations bridge to whatever
|
|
33
|
+
* storage backend the host app uses — `@sigx/lynx-storage`, `localStorage`,
|
|
34
|
+
* an MMKV bridge, etc. Both methods may be async; the hook awaits load before
|
|
35
|
+
* applying anything to the stack and fires save in a debounced manner.
|
|
36
|
+
*
|
|
37
|
+
* - `load()` returns `null` (or rejects) when no snapshot exists, when the
|
|
38
|
+
* stored payload is malformed, or when the host opts not to restore on
|
|
39
|
+
* this launch.
|
|
40
|
+
* - `save(snapshot)` persists the latest stack. The hook drops save errors
|
|
41
|
+
* on the floor — losing a write is preferable to crashing the navigator.
|
|
42
|
+
*/
|
|
43
|
+
export interface NavStorageAdapter {
|
|
44
|
+
load(): Promise<NavSnapshot | null> | NavSnapshot | null;
|
|
45
|
+
save(snapshot: NavSnapshot): Promise<void> | void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface UseNavSerializerOptions {
|
|
49
|
+
storage: NavStorageAdapter;
|
|
50
|
+
/**
|
|
51
|
+
* Trailing-edge debounce in ms before pushing a stack change to storage.
|
|
52
|
+
* Defaults to 250ms — quick enough that a force-quit one tick after a
|
|
53
|
+
* push is recoverable, slow enough that rapid `pop/push` flurries
|
|
54
|
+
* coalesce into one write.
|
|
55
|
+
*/
|
|
56
|
+
debounceMs?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Optional callback after a successful restore — lets the host run
|
|
59
|
+
* post-restore wiring (analytics, focus shifts, etc.) only when we
|
|
60
|
+
* actually applied state, not on every mount.
|
|
61
|
+
*/
|
|
62
|
+
onRestored?: (snapshot: NavSnapshot) => void;
|
|
63
|
+
/**
|
|
64
|
+
* Optional callback when a snapshot is rejected (validation failed or
|
|
65
|
+
* load threw). Defaults to silent. Useful for logging during migration.
|
|
66
|
+
*/
|
|
67
|
+
onRestoreError?: (reason: 'version' | 'shape' | 'unknown-route' | 'load-threw', err?: unknown) => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Wire a navigator's stack to a storage adapter.
|
|
72
|
+
*
|
|
73
|
+
* On mount:
|
|
74
|
+
* 1. Call `storage.load()`.
|
|
75
|
+
* 2. Validate the snapshot (version match, every entry's route still
|
|
76
|
+
* registered).
|
|
77
|
+
* 3. On success, `nav.reset({ stack })` to apply.
|
|
78
|
+
* 4. On any failure, leave the stack alone (initial route remains).
|
|
79
|
+
*
|
|
80
|
+
* Then subscribe to `nav.stack` and call `storage.save(snapshot)` debounced.
|
|
81
|
+
*
|
|
82
|
+
* Why we don't validate `params` / `search` against schemas here: schemas
|
|
83
|
+
* are part of the route definition, and re-running them across all entries
|
|
84
|
+
* on every launch costs more than it's worth. The contract is "entries were
|
|
85
|
+
* validated when they were pushed; if the schema has since changed in a
|
|
86
|
+
* breaking way, bump `version` to reject old snapshots wholesale." Callers
|
|
87
|
+
* who want a stricter check can run their own validation in
|
|
88
|
+
* `storage.load()` and return `null` on mismatch.
|
|
89
|
+
*/
|
|
90
|
+
export function useNavSerializer(options: UseNavSerializerOptions): void {
|
|
91
|
+
const nav = useNav();
|
|
92
|
+
const routes = useNavRoutes();
|
|
93
|
+
const debounceMs = options.debounceMs ?? 250;
|
|
94
|
+
const onRestored = options.onRestored;
|
|
95
|
+
const onErr = options.onRestoreError;
|
|
96
|
+
|
|
97
|
+
// Mutable mount/state flags. Plain closure vars (no signals) — we don't
|
|
98
|
+
// want any of this driving a render and we don't want it tracked by the
|
|
99
|
+
// save-effect below.
|
|
100
|
+
let mounted = true;
|
|
101
|
+
let restoreDone = false;
|
|
102
|
+
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
103
|
+
let stopEffect: (() => void) | null = null;
|
|
104
|
+
|
|
105
|
+
onMounted(() => {
|
|
106
|
+
// Kick off the load synchronously — adapters that return a value
|
|
107
|
+
// immediately (sync stores, test doubles) hit the resolve branch on
|
|
108
|
+
// the same tick. Promise adapters resolve on the microtask queue;
|
|
109
|
+
// the `mounted` guard catches teardown races.
|
|
110
|
+
Promise.resolve()
|
|
111
|
+
.then(() => options.storage.load())
|
|
112
|
+
.then((snap) => {
|
|
113
|
+
if (!mounted) return;
|
|
114
|
+
if (snap == null) {
|
|
115
|
+
restoreDone = true;
|
|
116
|
+
startSaveEffect();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!isValidShape(snap)) {
|
|
120
|
+
onErr?.('shape');
|
|
121
|
+
restoreDone = true;
|
|
122
|
+
startSaveEffect();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (snap.version !== NAV_SNAPSHOT_VERSION) {
|
|
126
|
+
onErr?.('version');
|
|
127
|
+
restoreDone = true;
|
|
128
|
+
startSaveEffect();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// Drop the snapshot if any entry references a route the app
|
|
132
|
+
// no longer knows about — partial restoration is worse than
|
|
133
|
+
// no restoration (could leave the user stranded on a screen
|
|
134
|
+
// whose params won't validate when read by `useParams`).
|
|
135
|
+
for (const entry of snap.stack) {
|
|
136
|
+
if (!routes[entry.route]) {
|
|
137
|
+
onErr?.('unknown-route');
|
|
138
|
+
restoreDone = true;
|
|
139
|
+
startSaveEffect();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (snap.stack.length === 0) {
|
|
144
|
+
onErr?.('shape');
|
|
145
|
+
restoreDone = true;
|
|
146
|
+
startSaveEffect();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
nav.reset({ stack: snap.stack });
|
|
150
|
+
onRestored?.(snap);
|
|
151
|
+
restoreDone = true;
|
|
152
|
+
startSaveEffect();
|
|
153
|
+
})
|
|
154
|
+
.catch((err) => {
|
|
155
|
+
if (!mounted) return;
|
|
156
|
+
onErr?.('load-threw', err);
|
|
157
|
+
restoreDone = true;
|
|
158
|
+
startSaveEffect();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
function startSaveEffect() {
|
|
163
|
+
if (!mounted || stopEffect) return;
|
|
164
|
+
// The first effect run is just the initial subscription read — it
|
|
165
|
+
// happens immediately when `effect()` is called, before any user
|
|
166
|
+
// navigation, and represents the stack-as-restored (or the initial
|
|
167
|
+
// route when there was nothing to restore). Either way, we don't
|
|
168
|
+
// want to persist it: in the restore case it would race with the
|
|
169
|
+
// adapter that just supplied this state, and in the fresh case
|
|
170
|
+
// it's redundant.
|
|
171
|
+
let firstRun = true;
|
|
172
|
+
const runner = effect(() => {
|
|
173
|
+
const stack = nav.stack;
|
|
174
|
+
const snapshot: NavSnapshot = {
|
|
175
|
+
version: NAV_SNAPSHOT_VERSION,
|
|
176
|
+
stack: stack.map((e) => ({
|
|
177
|
+
key: e.key,
|
|
178
|
+
route: e.route,
|
|
179
|
+
params: e.params,
|
|
180
|
+
search: e.search,
|
|
181
|
+
state: e.state,
|
|
182
|
+
presentation: e.presentation,
|
|
183
|
+
})),
|
|
184
|
+
};
|
|
185
|
+
if (firstRun) {
|
|
186
|
+
firstRun = false;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
schedule(snapshot);
|
|
190
|
+
});
|
|
191
|
+
stopEffect = () => runner.stop();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function schedule(snapshot: NavSnapshot) {
|
|
195
|
+
if (pendingTimer != null) clearTimeout(pendingTimer);
|
|
196
|
+
pendingTimer = setTimeout(() => {
|
|
197
|
+
pendingTimer = null;
|
|
198
|
+
try {
|
|
199
|
+
const r = options.storage.save(snapshot);
|
|
200
|
+
if (r && typeof (r as Promise<void>).catch === 'function') {
|
|
201
|
+
(r as Promise<void>).catch(() => {
|
|
202
|
+
// Save errors are intentionally swallowed — see the
|
|
203
|
+
// hook doc-comment. Hosts that need visibility can
|
|
204
|
+
// wrap their adapter.
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
// Same rationale.
|
|
209
|
+
}
|
|
210
|
+
}, debounceMs);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
onUnmounted(() => {
|
|
214
|
+
mounted = false;
|
|
215
|
+
if (pendingTimer != null) {
|
|
216
|
+
clearTimeout(pendingTimer);
|
|
217
|
+
pendingTimer = null;
|
|
218
|
+
}
|
|
219
|
+
if (stopEffect) {
|
|
220
|
+
stopEffect();
|
|
221
|
+
stopEffect = null;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isValidShape(s: unknown): s is NavSnapshot {
|
|
227
|
+
if (!s || typeof s !== 'object') return false;
|
|
228
|
+
const obj = s as { version?: unknown; stack?: unknown };
|
|
229
|
+
if (typeof obj.version !== 'number') return false;
|
|
230
|
+
if (!Array.isArray(obj.stack)) return false;
|
|
231
|
+
for (const entry of obj.stack) {
|
|
232
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
233
|
+
const e = entry as Record<string, unknown>;
|
|
234
|
+
if (typeof e.key !== 'string') return false;
|
|
235
|
+
if (typeof e.route !== 'string') return false;
|
|
236
|
+
if (typeof e.presentation !== 'string') return false;
|
|
237
|
+
}
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useScreenOptions` — imperative merge into the current entry's options.
|
|
3
|
+
*
|
|
4
|
+
* Use this when options need to be set from an effect rather than declared
|
|
5
|
+
* statically via `<Screen title=…>`. The canonical case is "title becomes
|
|
6
|
+
* known after a fetch":
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const user = useFetchUser(id);
|
|
10
|
+
* useScreenOptions(() => ({
|
|
11
|
+
* title: user.value?.displayName ?? 'Loading…',
|
|
12
|
+
* }));
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* The callback runs in a tracked `effect` — any signals it reads cause it
|
|
16
|
+
* to re-run and re-merge. This is strictly additive: returning a partial
|
|
17
|
+
* options object only touches the keys it sets, and returning `undefined`
|
|
18
|
+
* for a key clears it.
|
|
19
|
+
*
|
|
20
|
+
* Static usage where the options never change can pass a plain object and
|
|
21
|
+
* skip the effect — internally we detect that and merge once. Hosts that
|
|
22
|
+
* pass a getter pay for the subscription; hosts that pass an object don't.
|
|
23
|
+
*/
|
|
24
|
+
import { effect, onUnmounted } from '@sigx/lynx';
|
|
25
|
+
import { useScreenRegistry } from './use-nav-internal.js';
|
|
26
|
+
import { mergeOptions } from '../internal/screen-registry.js';
|
|
27
|
+
import type { ScreenOptions } from '../types.js';
|
|
28
|
+
|
|
29
|
+
export function useScreenOptions(
|
|
30
|
+
optionsOrFn: ScreenOptions | (() => ScreenOptions),
|
|
31
|
+
): void {
|
|
32
|
+
const registry = useScreenRegistry();
|
|
33
|
+
|
|
34
|
+
if (typeof optionsOrFn !== 'function') {
|
|
35
|
+
mergeOptions(registry, optionsOrFn);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Reactive path: every signal touched inside the getter is tracked, so
|
|
40
|
+
// the merge re-runs when any of them change. `mergeOptions` does per-key
|
|
41
|
+
// writes on a deeply-reactive proxy, so consumers (HeaderBar) only
|
|
42
|
+
// re-render the parts they actually read.
|
|
43
|
+
const runner = effect(() => {
|
|
44
|
+
const next = optionsOrFn();
|
|
45
|
+
mergeOptions(registry, next);
|
|
46
|
+
});
|
|
47
|
+
onUnmounted(() => runner.stop());
|
|
48
|
+
}
|
package/src/href.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { RouteId, RouteParams, RouteSearch } from './register.js';
|
|
2
2
|
import type { RoutesWithoutParams, RoutesWithParams } from './hooks/use-nav.js';
|
|
3
|
+
import { buildUrl } from './url/build.js';
|
|
4
|
+
import { parseHrefImpl } from './url/parse.js';
|
|
5
|
+
import { getRouteRegistry } from './url/registry.js';
|
|
6
|
+
import { validateSync } from './url/validate.js';
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* A typed reference to a navigation target — what `<Link to={...}>` consumes
|
|
@@ -25,6 +29,15 @@ export interface Href<K extends RouteId = RouteId> {
|
|
|
25
29
|
* params schema, one for routes that require params. See `RoutesWithParams`
|
|
26
30
|
* in `./hooks/use-nav.js` for why this isn't expressed as a single conditional.
|
|
27
31
|
*
|
|
32
|
+
* Requires a `<NavigationRoot>` (or an explicit `_setRouteRegistry` call) to
|
|
33
|
+
* have run — the URL form is built against the active route registry. The
|
|
34
|
+
* typed pieces (`route`, `params`, `search`) are returned regardless; `url`
|
|
35
|
+
* is `null` when the route declares no `path` template.
|
|
36
|
+
*
|
|
37
|
+
* Schema validation errors throw — pass already-validated values from
|
|
38
|
+
* `useParams` / `useSearch` to round-trip safely, or wrap in try/catch when
|
|
39
|
+
* building hrefs from external input.
|
|
40
|
+
*
|
|
28
41
|
* @example
|
|
29
42
|
* ```ts
|
|
30
43
|
* hrefFor('profile', { id: '42' }); // → { route, params, search: {}, url: '/users/42' }
|
|
@@ -38,21 +51,65 @@ export function hrefFor<K extends RoutesWithParams>(
|
|
|
38
51
|
params: RouteParams<K>,
|
|
39
52
|
search?: RouteSearch<K>,
|
|
40
53
|
): Href<K>;
|
|
41
|
-
export function hrefFor(name: string, ...
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
export function hrefFor(name: string, ...args: unknown[]): Href {
|
|
55
|
+
const registry = getRouteRegistry();
|
|
56
|
+
const def = registry.routes[name];
|
|
57
|
+
if (!def) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`[lynx-navigation] hrefFor('${name}'): route is not in the active registry.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Disambiguate the overloads: routes with a params schema get
|
|
64
|
+
// (name, params, search?); routes without get (name, search?). The
|
|
65
|
+
// typed overloads enforce this at compile time, but at runtime we need
|
|
66
|
+
// to peek at the schema presence to know how to interpret `args`.
|
|
67
|
+
const hasParamsSchema = !!def.params;
|
|
68
|
+
let rawParams: Record<string, unknown> | undefined;
|
|
69
|
+
let rawSearch: Record<string, unknown> | undefined;
|
|
70
|
+
if (hasParamsSchema) {
|
|
71
|
+
rawParams = args[0] as Record<string, unknown> | undefined;
|
|
72
|
+
rawSearch = args[1] as Record<string, unknown> | undefined;
|
|
73
|
+
} else {
|
|
74
|
+
rawParams = undefined;
|
|
75
|
+
rawSearch = args[0] as Record<string, unknown> | undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const paramsOutcome = validateSync(def.params, rawParams ?? {});
|
|
79
|
+
if (!paramsOutcome.ok) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`[lynx-navigation] hrefFor('${name}'): params validation failed — ${paramsOutcome.issues.join('; ')}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const searchOutcome = validateSync(def.search, rawSearch ?? {});
|
|
85
|
+
if (!searchOutcome.ok) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`[lynx-navigation] hrefFor('${name}'): search validation failed — ${searchOutcome.issues.join('; ')}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const params = paramsOutcome.value as Record<string, unknown>;
|
|
92
|
+
const search = searchOutcome.value as Record<string, unknown>;
|
|
93
|
+
const url = buildUrl(name, params, search);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
route: name as RouteId,
|
|
97
|
+
params: params as never,
|
|
98
|
+
search: search as never,
|
|
99
|
+
url,
|
|
100
|
+
};
|
|
47
101
|
}
|
|
48
102
|
|
|
49
103
|
/**
|
|
50
104
|
* Parse a URL string into a typed Href against the registered routes.
|
|
51
|
-
* Returns `null` if no route's `path` template matches
|
|
105
|
+
* Returns `null` if no route's `path` template matches the URL or if the
|
|
106
|
+
* extracted params/search fail the route's schema validation.
|
|
107
|
+
*
|
|
108
|
+
* Accepts both absolute (`myapp://host/path?q`) and pathname-only
|
|
109
|
+
* (`/path?q`) forms — the pathname is matched against each route's
|
|
110
|
+
* compiled template. Iteration order is the registration order; first match
|
|
111
|
+
* wins.
|
|
52
112
|
*/
|
|
53
113
|
export function parseHref(url: string): Href | null {
|
|
54
|
-
|
|
55
|
-
throw new Error(
|
|
56
|
-
'parseHref() runtime is not implemented yet — this is the type spike.',
|
|
57
|
-
);
|
|
114
|
+
return parseHrefImpl(url);
|
|
58
115
|
}
|
package/src/index.ts
CHANGED
|
@@ -12,10 +12,37 @@ export type { Nav, RoutesWithoutParams, RoutesWithParams } from './hooks/use-nav
|
|
|
12
12
|
export { useParams } from './hooks/use-params.js';
|
|
13
13
|
export { useSearch } from './hooks/use-search.js';
|
|
14
14
|
export { useHardwareBack } from './hooks/use-hardware-back.js';
|
|
15
|
+
export { useLinkingNav } from './hooks/use-linking-nav.js';
|
|
16
|
+
export type { UseLinkingNavOptions } from './hooks/use-linking-nav.js';
|
|
17
|
+
export { useIsFocused, useFocusEffect } from './hooks/use-focus.js';
|
|
18
|
+
export { useScreenOptions } from './hooks/use-screen-options.js';
|
|
19
|
+
export {
|
|
20
|
+
useNavSerializer,
|
|
21
|
+
NAV_SNAPSHOT_VERSION,
|
|
22
|
+
} from './hooks/use-nav-serializer.js';
|
|
23
|
+
export type {
|
|
24
|
+
NavSnapshot,
|
|
25
|
+
NavStorageAdapter,
|
|
26
|
+
UseNavSerializerOptions,
|
|
27
|
+
} from './hooks/use-nav-serializer.js';
|
|
15
28
|
export { hrefFor, parseHref } from './href.js';
|
|
16
29
|
export type { Href } from './href.js';
|
|
30
|
+
// URL bridge internals: `_setRouteRegistry` is a leading-underscore export —
|
|
31
|
+
// intended for tests, deep-link bootstrap before a NavigationRoot mounts, and
|
|
32
|
+
// any other integration that needs to seed the registry imperatively.
|
|
33
|
+
export { _setRouteRegistry, _clearRouteRegistry } from './url/registry.js';
|
|
34
|
+
export { compilePath } from './url/compile.js';
|
|
35
|
+
export type { CompiledPath } from './url/compile.js';
|
|
17
36
|
export { NavigationRoot } from './components/NavigationRoot.js';
|
|
18
37
|
export { Stack } from './components/Stack.js';
|
|
38
|
+
export { Screen } from './components/Screen.js';
|
|
39
|
+
export { Header } from './components/Header.js';
|
|
40
|
+
export { Tabs, useTabs } from './components/Tabs.js';
|
|
41
|
+
export type { TabInfo, TabsNav } from './components/Tabs.js';
|
|
42
|
+
export { TabBar } from './components/TabBar.js';
|
|
43
|
+
export type { TabRenderContext } from './components/TabBar.js';
|
|
44
|
+
export { Drawer, useDrawer } from './components/Drawer.js';
|
|
45
|
+
export type { DrawerNav } from './components/Drawer.js';
|
|
19
46
|
export { Link } from './components/Link.js';
|
|
20
47
|
export type { LinkProps } from './components/Link.js';
|
|
21
48
|
export type {
|
|
@@ -29,6 +56,8 @@ export type {
|
|
|
29
56
|
RouteDefinition,
|
|
30
57
|
RouteMap,
|
|
31
58
|
RouteRequiresParams,
|
|
59
|
+
ScreenOptions,
|
|
60
|
+
ScreenSlotFills,
|
|
32
61
|
SearchOf,
|
|
33
62
|
StackEntry,
|
|
34
63
|
StandardSchemaV1,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-entry registry of `<Screen>` options + slot fills.
|
|
3
|
+
*
|
|
4
|
+
* Each `<EntryScope>` allocates one of these on mount and provides it to
|
|
5
|
+
* descendants via `defineProvide(useScreenRegistry, ...)`. The Screen
|
|
6
|
+
* component and its sub-components (`<Screen.Header>`, `<Screen.HeaderLeft>`,
|
|
7
|
+
* `<Screen.HeaderRight>`, `<Screen.TabBarItem>`) write into the registry as
|
|
8
|
+
* they mount.
|
|
9
|
+
*
|
|
10
|
+
* Reads track because options/slots are stored in signals — when a child
|
|
11
|
+
* re-renders and registers a new slot fill, the navigator-side consumer
|
|
12
|
+
* (HeaderBar / TabBar, shipped in later slices) reactively updates.
|
|
13
|
+
*
|
|
14
|
+
* Cross-entry lookup is exposed via the navigator's `getScreenRegistry(key)`
|
|
15
|
+
* so a persistent HeaderBar can read slots from the currently-focused entry
|
|
16
|
+
* without needing to be itself remounted on each navigation.
|
|
17
|
+
*/
|
|
18
|
+
import { signal, type Signal } from '@sigx/lynx';
|
|
19
|
+
import type {
|
|
20
|
+
ScreenOptions,
|
|
21
|
+
ScreenSlotFills,
|
|
22
|
+
StackEntry,
|
|
23
|
+
} from '../types.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reactive container for one screen's options and slot fills.
|
|
27
|
+
*
|
|
28
|
+
* `options` and `slots` are deeply-reactive object signals (sigx's `signal()`
|
|
29
|
+
* of an object returns a Proxy that tracks per-key reads and notifies
|
|
30
|
+
* per-key writes). Writers assign individual keys; readers subscribe to the
|
|
31
|
+
* keys they actually use — no whole-object reads, no read/write cycles in
|
|
32
|
+
* setup.
|
|
33
|
+
*/
|
|
34
|
+
export interface ScreenRegistry {
|
|
35
|
+
readonly entry: StackEntry;
|
|
36
|
+
/** Reactive ScreenOptions — written per-key by `<Screen>`. */
|
|
37
|
+
readonly options: Signal<ScreenOptions>;
|
|
38
|
+
/** Reactive ScreenSlotFills — written per-key by `<Screen.Header>` et al. */
|
|
39
|
+
readonly slots: Signal<ScreenSlotFills>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Create a fresh registry for an entry. Options and slots start empty. */
|
|
43
|
+
export function createScreenRegistry(entry: StackEntry): ScreenRegistry {
|
|
44
|
+
return {
|
|
45
|
+
entry,
|
|
46
|
+
options: signal<ScreenOptions>({}),
|
|
47
|
+
slots: signal<ScreenSlotFills>({}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Set a single slot fill on a registry. Pass `undefined` to clear.
|
|
53
|
+
* Per-key write on the proxy — does not read other keys, so it can't loop
|
|
54
|
+
* with effects that read different slot keys.
|
|
55
|
+
*/
|
|
56
|
+
export function setSlot<K extends keyof ScreenSlotFills>(
|
|
57
|
+
registry: ScreenRegistry,
|
|
58
|
+
name: K,
|
|
59
|
+
fill: ScreenSlotFills[K] | undefined,
|
|
60
|
+
): void {
|
|
61
|
+
if (fill === undefined) {
|
|
62
|
+
// Assigning undefined keeps the key around in the proxy; explicit
|
|
63
|
+
// delete is what consumers checking `name in slots` expect.
|
|
64
|
+
delete registry.slots[name];
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
(registry.slots as ScreenSlotFills)[name] = fill;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Merge partial options into a registry. Each option key is written
|
|
72
|
+
* independently on the proxy — `undefined` keys clear that option.
|
|
73
|
+
*/
|
|
74
|
+
export function mergeOptions(
|
|
75
|
+
registry: ScreenRegistry,
|
|
76
|
+
patch: ScreenOptions,
|
|
77
|
+
): void {
|
|
78
|
+
for (const key of Object.keys(patch) as (keyof ScreenOptions)[]) {
|
|
79
|
+
const v = patch[key];
|
|
80
|
+
if (v === undefined) {
|
|
81
|
+
delete registry.options[key];
|
|
82
|
+
} else {
|
|
83
|
+
// Property-level assignment on a deeply-reactive proxy: notifies
|
|
84
|
+
// only subscribers of this specific key, never reads the whole
|
|
85
|
+
// options object, so it can't trigger the setup that wrote it.
|
|
86
|
+
(registry.options as unknown as Record<string, unknown>)[key] = v;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/navigator/core.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
runOnMainThread,
|
|
3
3
|
signal,
|
|
4
|
+
untrack,
|
|
4
5
|
type Signal,
|
|
5
6
|
type SharedValue,
|
|
6
7
|
} from '@sigx/lynx';
|
|
7
|
-
import {
|
|
8
|
+
import { isLazyComponent } from '@sigx/lynx';
|
|
9
|
+
import { withTiming } from '@sigx/lynx-motion';
|
|
8
10
|
import type { Nav } from '../hooks/use-nav.js';
|
|
11
|
+
import type { ScreenRegistry } from '../internal/screen-registry.js';
|
|
9
12
|
import type {
|
|
10
13
|
PopOptions,
|
|
11
14
|
Presentation,
|
|
@@ -43,15 +46,50 @@ export interface NavigatorState {
|
|
|
43
46
|
commitBackGesture(): void;
|
|
44
47
|
cancelBackGesture(): void;
|
|
45
48
|
};
|
|
49
|
+
/**
|
|
50
|
+
* Internal: cross-entry `<Screen>` registry lookup.
|
|
51
|
+
*
|
|
52
|
+
* Each `<EntryScope>` registers its `ScreenRegistry` here on mount and
|
|
53
|
+
* removes it on unmount. The navigator's persistent chrome (HeaderBar /
|
|
54
|
+
* TabBar, shipped in later slices) calls `getScreenRegistry(entry.key)`
|
|
55
|
+
* to read the currently-focused screen's options/slot fills without
|
|
56
|
+
* being itself remounted on each navigation.
|
|
57
|
+
*
|
|
58
|
+
* Returns `undefined` when no screen for that key has mounted yet (or
|
|
59
|
+
* after it has unmounted) — consumers must tolerate this and render
|
|
60
|
+
* defaults.
|
|
61
|
+
*/
|
|
62
|
+
readonly _screens: {
|
|
63
|
+
register(registry: ScreenRegistry): void;
|
|
64
|
+
unregister(entryKey: string): void;
|
|
65
|
+
get(entryKey: string): ScreenRegistry | undefined;
|
|
66
|
+
};
|
|
46
67
|
}
|
|
47
68
|
|
|
48
69
|
/**
|
|
49
70
|
* Slide-from-right transition timing. Kept as constants so screen options
|
|
50
71
|
* can override per-screen later (Phase 0.5). Duration is in seconds — that's
|
|
51
|
-
* what `@sigx/motion`'s `withTiming` expects (per `with-timing.ts`).
|
|
72
|
+
* what `@sigx/lynx-motion`'s `withTiming` expects (per `with-timing.ts`).
|
|
52
73
|
*/
|
|
53
74
|
const TRANSITION_DURATION_SEC = 0.28;
|
|
54
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Kick off a lazy component's chunk fetch when its route is navigated to.
|
|
78
|
+
*
|
|
79
|
+
* Lazy routes (`component: lazy(() => import('./Heavy.js'))`) start loading
|
|
80
|
+
* the moment `push`/`replace` is called rather than waiting until render
|
|
81
|
+
* tries to instantiate them — by the time `<Stack>` swaps screens the chunk
|
|
82
|
+
* is usually already resolved, so the user sees the screen instead of the
|
|
83
|
+
* `<Suspense fallback>`. Fire-and-forget: errors here surface through
|
|
84
|
+
* `<Suspense>` at render time.
|
|
85
|
+
*/
|
|
86
|
+
function preloadRouteComponent(component: unknown): void {
|
|
87
|
+
if (isLazyComponent(component)) {
|
|
88
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
89
|
+
component.preload().catch(() => {});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
55
93
|
let entryKeyCounter = 0;
|
|
56
94
|
function nextEntryKey(): string {
|
|
57
95
|
entryKeyCounter += 1;
|
|
@@ -104,7 +142,7 @@ export interface CreateNavigatorOptions {
|
|
|
104
142
|
* SharedValue driving push/pop transition progress. Created in
|
|
105
143
|
* `<NavigationRoot>` setup via `useSharedValue(0)` so the bridge
|
|
106
144
|
* plumbing is wired (SharedValue is an MT-bridged ref). When undefined,
|
|
107
|
-
* navigations are instant — used by tests against `@sigx/testing
|
|
145
|
+
* navigations are instant — used by tests against `@sigx/lynx-testing`
|
|
108
146
|
* that don't have an MT runtime.
|
|
109
147
|
*/
|
|
110
148
|
progress?: SharedValue<number>;
|
|
@@ -181,7 +219,7 @@ export function createNavigatorState(opts: CreateNavigatorOptions): NavigatorSta
|
|
|
181
219
|
// MT-side direct write — `sv.value` is a BG-side getter/setter
|
|
182
220
|
// that emits a "read-only on BG" warning when set; the actual
|
|
183
221
|
// MT field (which `withTiming`'s animate() reads as the start
|
|
184
|
-
// value) is `sv.current.value`. See `packages/runtime
|
|
222
|
+
// value) is `sv.current.value`. See `packages/lynx-runtime/src/
|
|
185
223
|
// animated/shared-value.ts:14-44`.
|
|
186
224
|
sv.current.value = 0;
|
|
187
225
|
withTiming(sv, t, { duration: d });
|
|
@@ -201,6 +239,7 @@ export function createNavigatorState(opts: CreateNavigatorOptions): NavigatorSta
|
|
|
201
239
|
`Known routes: ${Object.keys(routes).join(', ') || '(none)'}`,
|
|
202
240
|
);
|
|
203
241
|
}
|
|
242
|
+
preloadRouteComponent(routes[name].component);
|
|
204
243
|
const newEntry = makeEntry(name, params, search, options, routes);
|
|
205
244
|
const cur = getStack();
|
|
206
245
|
const prevTop = cur[cur.length - 1];
|
|
@@ -234,6 +273,7 @@ export function createNavigatorState(opts: CreateNavigatorOptions): NavigatorSta
|
|
|
234
273
|
`[lynx-navigation] replace('${name}'): route is not registered.`,
|
|
235
274
|
);
|
|
236
275
|
}
|
|
276
|
+
preloadRouteComponent(routes[name].component);
|
|
237
277
|
const entry = makeEntry(name, params, search, options, routes);
|
|
238
278
|
const cur = getStack();
|
|
239
279
|
// Replace doesn't animate in v1 — it's a swap, not a forward/back nav.
|
|
@@ -382,5 +422,47 @@ export function createNavigatorState(opts: CreateNavigatorOptions): NavigatorSta
|
|
|
382
422
|
nav,
|
|
383
423
|
routes,
|
|
384
424
|
_gesture: { beginBackGesture, commitBackGesture, cancelBackGesture },
|
|
425
|
+
_screens: createScreenRegistries(),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Map-backed `_screens` controller. Pulled out as a tiny factory so test
|
|
431
|
+
* tooling can call it directly when asserting registry behaviour without
|
|
432
|
+
* standing up an entire navigator.
|
|
433
|
+
*
|
|
434
|
+
* Not reactive — `<EntryScope>` registers once at setup and unregisters at
|
|
435
|
+
* unmount, so reads from the navigator's chrome are point-in-time lookups,
|
|
436
|
+
* and the registry's own internal signals carry the reactive payload.
|
|
437
|
+
*/
|
|
438
|
+
function createScreenRegistries(): NavigatorState['_screens'] {
|
|
439
|
+
const byKey = new Map<string, ScreenRegistry>();
|
|
440
|
+
// Reactive version tick — bumped on every register/unregister so consumers
|
|
441
|
+
// (HeaderBar's computeds) re-evaluate their lookups when entries come and
|
|
442
|
+
// go. `Map.get` itself isn't tracked, so without this a chrome component
|
|
443
|
+
// that renders before its target entry mounts would never see the late
|
|
444
|
+
// arrival of the registry.
|
|
445
|
+
const version = signal({ v: 0 });
|
|
446
|
+
return {
|
|
447
|
+
register(reg: ScreenRegistry) {
|
|
448
|
+
byKey.set(reg.entry.key, reg);
|
|
449
|
+
// `register` is called from `<EntryScope>` setup, which itself
|
|
450
|
+
// runs inside a tracked scope. Read-then-write on `version`
|
|
451
|
+
// would self-loop, so we untrack the bump.
|
|
452
|
+
untrack(() => { version.v = version.v + 1; });
|
|
453
|
+
},
|
|
454
|
+
unregister(key: string) {
|
|
455
|
+
byKey.delete(key);
|
|
456
|
+
untrack(() => { version.v = version.v + 1; });
|
|
457
|
+
},
|
|
458
|
+
get(key: string) {
|
|
459
|
+
// Touch the version signal so the caller's reactive scope
|
|
460
|
+
// re-runs on the next register/unregister. The actual returned
|
|
461
|
+
// value still comes from the plain Map — registries themselves
|
|
462
|
+
// are signal-backed, so once a caller has one in hand they
|
|
463
|
+
// track the bits they care about (options/slots) directly.
|
|
464
|
+
void version.v;
|
|
465
|
+
return byKey.get(key);
|
|
466
|
+
},
|
|
385
467
|
};
|
|
386
468
|
}
|