@sigmela/router 0.0.11 → 0.0.13

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.ts DELETED
@@ -1,684 +0,0 @@
1
- import { NavigationStack } from './NavigationStack';
2
- import { nanoid } from 'nanoid/non-secure';
3
- import { TabBar } from './TabBar/TabBar';
4
- import qs from 'query-string';
5
- import type {
6
- CompiledRoute,
7
- HistoryItem,
8
- Scope,
9
- ScreenOptions,
10
- VisibleRoute,
11
- } from './types';
12
-
13
- type Listener = () => void;
14
-
15
- export interface RouterConfig {
16
- root: TabBar | NavigationStack;
17
- global?: NavigationStack;
18
- screenOptions?: ScreenOptions; // global overrides
19
- }
20
-
21
- // Root transition option: allow string shorthand like 'fade'
22
- export type RootTransition = ScreenOptions['stackAnimation'];
23
-
24
- function isTabBarLike(obj: any): obj is TabBar {
25
- return (
26
- obj != null &&
27
- typeof obj === 'object' &&
28
- 'onIndexChange' in obj &&
29
- 'getState' in obj &&
30
- 'subscribe' in obj &&
31
- 'stacks' in obj
32
- );
33
- }
34
-
35
- function isNavigationStackLike(obj: any): obj is NavigationStack {
36
- return (
37
- obj != null &&
38
- typeof obj === 'object' &&
39
- 'getRoutes' in obj &&
40
- 'getId' in obj
41
- );
42
- }
43
-
44
- type RouterState = { history: HistoryItem[]; activeTabIndex?: number };
45
-
46
- const EMPTY_ARRAY: HistoryItem[] = [];
47
-
48
- export class Router {
49
- public tabBar: TabBar | null = null;
50
- public root: NavigationStack | TabBar | null = null;
51
- public global: NavigationStack | null = null;
52
-
53
- private readonly listeners: Set<Listener> = new Set();
54
- private readonly registry: CompiledRoute[] = [];
55
- private state: RouterState = {
56
- history: [],
57
- activeTabIndex: undefined,
58
- };
59
-
60
- private readonly routerScreenOptions: ScreenOptions | undefined;
61
-
62
- // per-stack slices and listeners
63
- private stackSlices = new Map<string, HistoryItem[]>();
64
- private stackListeners = new Map<string, Set<Listener>>();
65
- private activeTabListeners = new Set<Listener>();
66
- private stackById = new Map<string, NavigationStack>();
67
- private routeById = new Map<
68
- string,
69
- { path: string; stackId: string; tabIndex?: number; scope: Scope }
70
- >();
71
- private visibleRoute: VisibleRoute = null;
72
- // Root structure listeners (TabBar ↔ NavigationStack changes)
73
- private rootListeners: Set<Listener> = new Set();
74
- private rootTransition?: RootTransition = undefined;
75
-
76
- constructor(config: RouterConfig) {
77
- this.routerScreenOptions = config.screenOptions;
78
-
79
- if (isTabBarLike(config.root)) {
80
- this.tabBar = config.root as TabBar;
81
- }
82
- if (config.global) {
83
- this.global = config.global;
84
- }
85
-
86
- this.root = config.root;
87
-
88
- if (this.tabBar) {
89
- this.state = {
90
- history: [],
91
- activeTabIndex: this.tabBar.getState().index,
92
- };
93
- }
94
-
95
- this.buildRegistry();
96
- this.seedInitialHistory();
97
- this.recomputeVisibleRoute();
98
- }
99
-
100
- // Public API
101
- public navigate = (path: string): void => {
102
- this.performNavigation(path, 'push');
103
- };
104
-
105
- public replace = (path: string): void => {
106
- this.performNavigation(path, 'replace');
107
- };
108
-
109
- public goBack = (): void => {
110
- // Global layer wins
111
- if (this.global) {
112
- const gid = this.global.getId();
113
- const gslice = this.getStackHistory(gid);
114
- const gtop = gslice.length ? gslice[gslice.length - 1] : undefined;
115
- if (gtop) {
116
- this.applyHistoryChange('pop', gtop);
117
- return;
118
- }
119
- }
120
-
121
- // Tab layer
122
- if (this.tabBar) {
123
- const idx = this.getActiveTabIndex();
124
- const state = this.tabBar.getState();
125
- const route = state.tabs[idx];
126
- if (!route) return;
127
- const stack = this.tabBar.stacks[route.tabKey];
128
- if (!stack) return;
129
- const sid = stack.getId();
130
- const slice = this.getStackHistory(sid);
131
- if (slice.length > 1) {
132
- const top = slice[slice.length - 1];
133
- if (top) {
134
- this.applyHistoryChange('pop', top);
135
- }
136
- }
137
- return;
138
- }
139
-
140
- // Root layer
141
- if (isNavigationStackLike(this.root)) {
142
- const sid = this.root.getId();
143
- const slice = this.getStackHistory(sid);
144
- if (slice.length > 1) {
145
- const top = slice[slice.length - 1];
146
- if (top) {
147
- this.applyHistoryChange('pop', top);
148
- }
149
- }
150
- }
151
- };
152
-
153
- public onTabIndexChange = (index: number): void => {
154
- if (this.tabBar) {
155
- this.tabBar.onIndexChange(index);
156
- this.setState({ activeTabIndex: index });
157
- this.emit(this.activeTabListeners);
158
- this.recomputeVisibleRoute();
159
- this.emit(this.listeners);
160
- }
161
- };
162
-
163
- public setActiveTabIndex = (index: number): void => {
164
- this.onTabIndexChange(index);
165
- };
166
-
167
- public ensureTabSeed = (index: number): void => {
168
- if (!this.tabBar) return;
169
- const state = this.tabBar.getState();
170
- const route = state.tabs[index];
171
- if (!route) return;
172
- const key = route.tabKey;
173
- const stack = this.tabBar.stacks[key];
174
- if (!stack) return;
175
- const hasAny = this.getStackHistory(stack.getId()).length > 0;
176
- if (hasAny) return;
177
- const first = stack.getFirstRoute();
178
- if (!first) return;
179
-
180
- const newItem: HistoryItem = {
181
- key: this.generateKey(),
182
- scope: 'tab',
183
- routeId: first.routeId,
184
- component: first.component,
185
- options: this.mergeOptions(first.options, stack.getId()),
186
- params: {},
187
- tabIndex: index,
188
- stackId: stack.getId(),
189
- };
190
- this.applyHistoryChange('push', newItem);
191
- };
192
-
193
- public getState = () => {
194
- return this.state;
195
- };
196
-
197
- public subscribe(listener: Listener): () => void {
198
- this.listeners.add(listener);
199
- return () => {
200
- this.listeners.delete(listener);
201
- };
202
- }
203
-
204
- // Per-stack subscriptions
205
- public getStackHistory = (stackId: string): HistoryItem[] => {
206
- return this.stackSlices.get(stackId) ?? EMPTY_ARRAY;
207
- };
208
-
209
- public subscribeStack = (stackId: string, cb: Listener): (() => void) => {
210
- if (!stackId) return () => {};
211
- let set = this.stackListeners.get(stackId);
212
- if (!set) {
213
- set = new Set();
214
- this.stackListeners.set(stackId, set);
215
- }
216
- set.add(cb);
217
- return () => {
218
- set!.delete(cb);
219
- };
220
- };
221
-
222
- public getActiveTabIndex = (): number => {
223
- return this.state.activeTabIndex ?? 0;
224
- };
225
-
226
- public subscribeActiveTab = (cb: Listener): (() => void) => {
227
- this.activeTabListeners.add(cb);
228
- return () => this.activeTabListeners.delete(cb);
229
- };
230
-
231
- public getRootStackId(): string | undefined {
232
- return isNavigationStackLike(this.root) ? this.root.getId() : undefined;
233
- }
234
-
235
- public getGlobalStackId(): string | undefined {
236
- return this.global?.getId();
237
- }
238
-
239
- public hasTabBar(): boolean {
240
- return !!this.tabBar;
241
- }
242
-
243
- public subscribeRoot(listener: Listener): () => void {
244
- this.rootListeners.add(listener);
245
- return () => this.rootListeners.delete(listener);
246
- }
247
-
248
- private emitRootChange(): void {
249
- this.rootListeners.forEach((l) => l());
250
- }
251
-
252
- public getRootTransition(): RootTransition | undefined {
253
- return this.rootTransition;
254
- }
255
-
256
- public setRoot(
257
- nextRoot: TabBar | NavigationStack,
258
- options?: { transition?: RootTransition }
259
- ): void {
260
- // Update root/tabBar references
261
- this.tabBar = isTabBarLike(nextRoot) ? (nextRoot as TabBar) : null;
262
- this.root = nextRoot;
263
-
264
- // Save requested transition (stackAnimation string)
265
- this.rootTransition = options?.transition ?? undefined;
266
-
267
- // If switching to TabBar, reset selected tab to the first one to avoid
268
- // leaking previously selected tab across auth flow changes.
269
- if (this.tabBar) {
270
- this.tabBar.onIndexChange(0);
271
- }
272
-
273
- // Reset core structures (keep global reference as-is)
274
- this.registry.length = 0;
275
- this.stackSlices.clear();
276
- this.stackById.clear();
277
- this.routeById.clear();
278
-
279
- // Reset state (activeTabIndex from tabBar if present)
280
- this.state = {
281
- history: [],
282
- activeTabIndex: this.tabBar ? this.tabBar.getState().index : undefined,
283
- };
284
-
285
- // Rebuild registry and seed new root
286
- this.buildRegistry();
287
- this.seedInitialHistory();
288
- this.recomputeVisibleRoute();
289
- this.emitRootChange();
290
- this.emit(this.listeners);
291
- }
292
-
293
- // Visible route (global top if present, else active tab/root top)
294
- public getVisibleRoute = (): VisibleRoute => {
295
- return this.visibleRoute;
296
- };
297
-
298
- private recomputeVisibleRoute(): void {
299
- // Global top
300
- if (this.global) {
301
- const gid = this.global.getId();
302
- const gslice = this.getStackHistory(gid);
303
- const gtop = gslice.length ? gslice[gslice.length - 1] : undefined;
304
- if (gtop) {
305
- const meta = this.routeById.get(gtop.routeId);
306
- this.visibleRoute = meta
307
- ? {
308
- ...meta,
309
- routeId: gtop.routeId,
310
- params: gtop.params,
311
- query: gtop.query,
312
- }
313
- : {
314
- routeId: gtop.routeId,
315
- stackId: gtop.stackId,
316
- params: gtop.params,
317
- query: gtop.query,
318
- scope: 'global',
319
- };
320
- return;
321
- }
322
- }
323
-
324
- // TabBar
325
- if (this.tabBar) {
326
- const idx = this.getActiveTabIndex();
327
- const state = this.tabBar.getState();
328
- const route = state.tabs[idx];
329
- if (route) {
330
- const stack = this.tabBar.stacks[route.tabKey];
331
- if (stack) {
332
- const sid = stack.getId();
333
- const slice = this.getStackHistory(sid);
334
- const top = slice.length ? slice[slice.length - 1] : undefined;
335
- if (top) {
336
- const meta = this.routeById.get(top.routeId);
337
- this.visibleRoute = meta
338
- ? {
339
- ...meta,
340
- routeId: top.routeId,
341
- params: top.params,
342
- query: top.query,
343
- }
344
- : {
345
- routeId: top.routeId,
346
- stackId: sid,
347
- tabIndex: idx,
348
- params: top.params,
349
- query: top.query,
350
- scope: 'tab',
351
- };
352
- return;
353
- }
354
- } else {
355
- this.visibleRoute = {
356
- routeId: `tab-screen-${idx}`,
357
- tabIndex: idx,
358
- scope: 'tab',
359
- };
360
- return;
361
- }
362
- }
363
- }
364
-
365
- // Root stack
366
- if (this.root && isNavigationStackLike(this.root)) {
367
- const sid = this.root.getId();
368
- const slice = this.getStackHistory(sid);
369
- const top = slice.length ? slice[slice.length - 1] : undefined;
370
- if (top) {
371
- const meta = this.routeById.get(top.routeId);
372
- this.visibleRoute = meta
373
- ? {
374
- ...meta,
375
- routeId: top.routeId,
376
- params: top.params,
377
- query: top.query,
378
- }
379
- : {
380
- routeId: top.routeId,
381
- stackId: sid,
382
- params: top.params,
383
- query: top.query,
384
- scope: 'root',
385
- };
386
- return;
387
- }
388
- }
389
- this.visibleRoute = null;
390
- }
391
-
392
- // Internal navigation logic
393
- private performNavigation(path: string, action: 'push' | 'replace'): void {
394
- const { pathname, query } = this.parsePath(path);
395
- const matched = this.matchRoute(pathname);
396
- if (!matched) {
397
- return;
398
- }
399
-
400
- if (
401
- matched.scope === 'tab' &&
402
- this.tabBar &&
403
- matched.tabIndex !== undefined
404
- ) {
405
- this.onTabIndexChange(matched.tabIndex);
406
- }
407
-
408
- const matchResult = matched.match(pathname);
409
- const params = matchResult ? matchResult.params : undefined;
410
-
411
- // Prevent duplicate push when navigating to the same screen already on top of its stack
412
- if (action === 'push') {
413
- const top = this.getTopForTarget(matched.stackId);
414
- if (top && top.routeId === matched.routeId) {
415
- const prev = top.params ? JSON.stringify(top.params) : '';
416
- const next = params ? JSON.stringify(params) : '';
417
- if (prev === next) {
418
- return;
419
- }
420
- }
421
- }
422
-
423
- // If there's a controller, execute it first
424
- if (matched.controller) {
425
- const controllerInput = { params, query };
426
- const present = (passProps?: Record<string, unknown>) => {
427
- const newItem = this.createHistoryItem(
428
- matched,
429
- params,
430
- query,
431
- pathname,
432
- passProps
433
- );
434
- this.applyHistoryChange(action, newItem);
435
- };
436
-
437
- matched.controller(controllerInput, present);
438
- return;
439
- }
440
-
441
- const newItem = this.createHistoryItem(matched, params, query, pathname);
442
- this.applyHistoryChange(action, newItem);
443
- }
444
-
445
- private createHistoryItem(
446
- matched: CompiledRoute,
447
- params: Record<string, any> | undefined,
448
- query: Record<string, unknown>,
449
- pathname: string,
450
- passProps?: any
451
- ): HistoryItem {
452
- return {
453
- key: this.generateKey(),
454
- scope: matched.scope,
455
- routeId: matched.routeId,
456
- component: matched.component,
457
- options: this.mergeOptions(matched.options, matched.stackId),
458
- params,
459
- query: query as any,
460
- passProps,
461
- tabIndex: matched.tabIndex,
462
- stackId: matched.stackId,
463
- pattern: matched.path,
464
- path: pathname,
465
- };
466
- }
467
-
468
- // Internal helpers
469
- private buildRegistry(): void {
470
- this.registry.length = 0;
471
-
472
- const addFromStack = (
473
- stack: NavigationStack,
474
- scope: Scope,
475
- extras: { tabIndex?: number }
476
- ) => {
477
- const stackId = stack.getId();
478
- this.stackById.set(stackId, stack);
479
- for (const r of stack.getRoutes()) {
480
- this.registry.push({
481
- routeId: r.routeId,
482
- scope,
483
- path: r.path,
484
- match: r.match,
485
- component: r.component,
486
- controller: r.controller,
487
- options: r.options,
488
- tabIndex: extras.tabIndex,
489
- stackId,
490
- });
491
- this.routeById.set(r.routeId, {
492
- path: r.path,
493
- stackId,
494
- tabIndex: extras.tabIndex,
495
- scope,
496
- });
497
- }
498
- // init empty slice
499
- if (!this.stackSlices.has(stackId))
500
- this.stackSlices.set(stackId, EMPTY_ARRAY);
501
- };
502
-
503
- if (isNavigationStackLike(this.root)) {
504
- addFromStack(this.root, 'root', {});
505
- } else if (this.tabBar) {
506
- const state = this.tabBar.getState();
507
- state.tabs.forEach((tab, idx) => {
508
- const stack = this.tabBar!.stacks[tab.tabKey];
509
- if (stack) {
510
- addFromStack(stack, 'tab', { tabIndex: idx });
511
- }
512
- });
513
- }
514
-
515
- if (this.global) {
516
- addFromStack(this.global, 'global', {});
517
- }
518
- }
519
-
520
- private seedInitialHistory(): void {
521
- if (this.state.history.length > 0) return;
522
-
523
- if (this.tabBar) {
524
- const state = this.tabBar.getState();
525
- const activeIdx = state.index ?? 0;
526
- const route = state.tabs[activeIdx];
527
- if (!route) return;
528
- const stack = this.tabBar.stacks[route.tabKey];
529
- if (stack) {
530
- const first = stack.getFirstRoute();
531
- if (first) {
532
- const newItem: HistoryItem = {
533
- key: this.generateKey(),
534
- scope: 'tab',
535
- routeId: first.routeId,
536
- component: first.component,
537
- options: this.mergeOptions(first.options, stack.getId()),
538
- params: {},
539
- tabIndex: activeIdx,
540
- stackId: stack.getId(),
541
- };
542
- this.applyHistoryChange('push', newItem);
543
- }
544
- }
545
- return;
546
- }
547
-
548
- if (isNavigationStackLike(this.root)) {
549
- const first = this.root.getFirstRoute();
550
- if (first) {
551
- const newItem: HistoryItem = {
552
- key: this.generateKey(),
553
- scope: 'root',
554
- routeId: first.routeId,
555
- component: first.component,
556
- options: this.mergeOptions(first.options, this.root.getId()),
557
- params: {},
558
- stackId: this.root.getId(),
559
- };
560
- this.applyHistoryChange('push', newItem);
561
- }
562
- }
563
- }
564
-
565
- private matchRoute(path: string): CompiledRoute | undefined {
566
- for (const r of this.registry) {
567
- if (r.match(path)) return r;
568
- }
569
- return undefined;
570
- }
571
-
572
- private generateKey(): string {
573
- return `route-${nanoid()}`;
574
- }
575
-
576
- private parsePath(path: string): {
577
- pathname: string;
578
- query: Record<string, unknown>;
579
- } {
580
- const parsed = qs.parseUrl(path);
581
- return { pathname: parsed.url, query: parsed.query };
582
- }
583
-
584
- private applyHistoryChange(
585
- action: 'push' | 'replace' | 'pop',
586
- item: HistoryItem
587
- ): void {
588
- const sid = item.stackId!;
589
- if (action === 'push') {
590
- this.setState({ history: [...this.state.history, item] });
591
- const prevSlice = this.stackSlices.get(sid) ?? EMPTY_ARRAY;
592
- const nextSlice = [...prevSlice, item];
593
- this.stackSlices.set(sid, nextSlice);
594
- this.emit(this.stackListeners.get(sid));
595
- this.recomputeVisibleRoute();
596
- this.emit(this.listeners);
597
- } else if (action === 'replace') {
598
- const prevTop = this.state.history[this.state.history.length - 1];
599
- const prevSid = prevTop?.stackId;
600
- this.setState({ history: [...this.state.history.slice(0, -1), item] });
601
- if (prevSid && prevSid !== sid) {
602
- const prevSlice = this.stackSlices.get(prevSid) ?? EMPTY_ARRAY;
603
- const trimmed = prevSlice.slice(0, -1);
604
- this.stackSlices.set(prevSid, trimmed);
605
- this.emit(this.stackListeners.get(prevSid));
606
- const newSlice = this.stackSlices.get(sid) ?? EMPTY_ARRAY;
607
- const appended = [...newSlice, item];
608
- this.stackSlices.set(sid, appended);
609
- this.emit(this.stackListeners.get(sid));
610
- } else {
611
- const prevSlice = this.stackSlices.get(sid) ?? EMPTY_ARRAY;
612
- const nextSlice = prevSlice.length
613
- ? [...prevSlice.slice(0, -1), item]
614
- : [item];
615
- this.stackSlices.set(sid, nextSlice);
616
- this.emit(this.stackListeners.get(sid));
617
- }
618
- this.recomputeVisibleRoute();
619
- this.emit(this.listeners);
620
- } else if (action === 'pop') {
621
- // Remove specific item by key from global history
622
- const nextHist = this.state.history.filter((h) => h.key !== item.key);
623
- this.setState({ history: nextHist });
624
-
625
- // Update slice only if the last item matches the popped one
626
- const prevSlice = this.stackSlices.get(sid) ?? EMPTY_ARRAY;
627
- const last = prevSlice.length
628
- ? prevSlice[prevSlice.length - 1]
629
- : undefined;
630
- if (last && last.key === item.key) {
631
- const nextSlice = prevSlice.slice(0, -1);
632
- this.stackSlices.set(sid, nextSlice);
633
- this.emit(this.stackListeners.get(sid));
634
- }
635
-
636
- this.recomputeVisibleRoute();
637
- this.emit(this.listeners);
638
- }
639
- }
640
-
641
- private setState(next: Partial<RouterState>): void {
642
- const prev = this.state;
643
- const nextState: RouterState = {
644
- history: next.history ?? prev.history,
645
- activeTabIndex: next.activeTabIndex ?? prev.activeTabIndex,
646
- };
647
- this.state = nextState;
648
- // Callers will emit updates explicitly.
649
- }
650
-
651
- private emit(set?: Set<Listener> | null): void {
652
- if (!set) return;
653
- set.forEach((l) => l());
654
- }
655
-
656
- private getTopForTarget(stackId?: string): HistoryItem | undefined {
657
- if (!stackId) return undefined;
658
- const slice = this.stackSlices.get(stackId) ?? EMPTY_ARRAY;
659
- return slice.length ? slice[slice.length - 1] : undefined;
660
- }
661
-
662
- private mergeOptions(
663
- routeOptions?: ScreenOptions,
664
- stackId?: string
665
- ): ScreenOptions | undefined {
666
- const stackDefaults = stackId
667
- ? (this.findStackById(stackId)?.getDefaultOptions() as any)
668
- : undefined;
669
- const routerDefaults = this.routerScreenOptions as any;
670
- if (!routerDefaults && !stackDefaults && !routeOptions) return undefined;
671
-
672
- const merged: any = {
673
- ...(stackDefaults ?? {}),
674
- ...(routeOptions ?? {}),
675
- ...(routerDefaults ?? {}),
676
- };
677
-
678
- return merged;
679
- }
680
-
681
- private findStackById(stackId: string): NavigationStack | undefined {
682
- return this.stackById.get(stackId);
683
- }
684
- }