@symbiote-native/navigation 2.0.3 → 2.1.0

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.
Files changed (155) hide show
  1. package/build/angular/drawer/index.js +6 -4
  2. package/build/angular/stack/index.d.ts +2 -0
  3. package/build/angular/stack/index.js +23 -3
  4. package/build/angular/tabs/index.d.ts +1 -1
  5. package/build/angular/tabs/index.js +20 -7
  6. package/build/core/index.d.ts +2 -1
  7. package/build/core/index.js +5 -1
  8. package/build/core/stack-router-state/index.d.ts +2 -0
  9. package/build/core/stack-router-state/index.js +44 -0
  10. package/build/core/tab-router-state/index.d.ts +1 -0
  11. package/build/core/tab-router-state/index.js +19 -0
  12. package/build/react/drawer/index.js +23 -13
  13. package/build/react/stack/index.js +13 -2
  14. package/build/react/tabs/index.js +23 -9
  15. package/build/svelte/attachments.d.ts +5 -0
  16. package/build/svelte/attachments.js +61 -0
  17. package/build/svelte/descriptor-subtree.d.ts +3 -0
  18. package/build/svelte/descriptor-subtree.js +37 -0
  19. package/build/svelte/drawer/drawer-props.d.ts +14 -0
  20. package/build/svelte/drawer/drawer-props.js +3 -0
  21. package/build/svelte/drawer/index.d.ts +10 -0
  22. package/build/svelte/drawer/index.js +6 -0
  23. package/build/svelte/drawer/index.svelte +421 -0
  24. package/build/svelte/drawer/index.svelte.d.ts +10 -0
  25. package/build/svelte/drawer-screen-props.d.ts +13 -0
  26. package/build/svelte/drawer-screen-props.js +4 -0
  27. package/build/svelte/drawer-screen.svelte +24 -0
  28. package/build/svelte/drawer-screen.svelte.d.ts +4 -0
  29. package/build/svelte/index.d.ts +21 -0
  30. package/build/svelte/index.js +20 -0
  31. package/build/svelte/linking.svelte.d.ts +2 -0
  32. package/build/svelte/linking.svelte.js +55 -0
  33. package/build/svelte/navigation-context.d.ts +14 -0
  34. package/build/svelte/navigation-context.js +44 -0
  35. package/build/svelte/navigation-scope.svelte +24 -0
  36. package/build/svelte/navigation-scope.svelte.d.ts +9 -0
  37. package/build/svelte/registry-host.d.ts +1 -0
  38. package/build/svelte/registry-host.js +26 -0
  39. package/build/svelte/runes/index.d.ts +12 -0
  40. package/build/svelte/runes/index.js +12 -0
  41. package/build/svelte/runes/use-drawer-navigation.svelte.d.ts +6 -0
  42. package/build/svelte/runes/use-drawer-navigation.svelte.js +19 -0
  43. package/build/svelte/runes/use-focus-effect.svelte.d.ts +1 -0
  44. package/build/svelte/runes/use-focus-effect.svelte.js +31 -0
  45. package/build/svelte/runes/use-is-focused.svelte.d.ts +3 -0
  46. package/build/svelte/runes/use-is-focused.svelte.js +34 -0
  47. package/build/svelte/runes/use-navigation-state.svelte.d.ts +4 -0
  48. package/build/svelte/runes/use-navigation-state.svelte.js +39 -0
  49. package/build/svelte/runes/use-navigation.svelte.d.ts +9 -0
  50. package/build/svelte/runes/use-navigation.svelte.js +22 -0
  51. package/build/svelte/runes/use-route.svelte.d.ts +4 -0
  52. package/build/svelte/runes/use-route.svelte.js +12 -0
  53. package/build/svelte/runes/use-stack-navigation.svelte.d.ts +6 -0
  54. package/build/svelte/runes/use-stack-navigation.svelte.js +22 -0
  55. package/build/svelte/runes/use-tab-navigation.svelte.d.ts +6 -0
  56. package/build/svelte/runes/use-tab-navigation.svelte.js +19 -0
  57. package/build/svelte/screen-props.d.ts +21 -0
  58. package/build/svelte/screen-props.js +6 -0
  59. package/build/svelte/screen-registry.d.ts +17 -0
  60. package/build/svelte/screen-registry.js +57 -0
  61. package/build/svelte/screen.svelte +28 -0
  62. package/build/svelte/screen.svelte.d.ts +4 -0
  63. package/build/svelte/stack/index.d.ts +14 -0
  64. package/build/svelte/stack/index.js +7 -0
  65. package/build/svelte/stack/index.svelte +224 -0
  66. package/build/svelte/stack/index.svelte.d.ts +15 -0
  67. package/build/svelte/stack/stack-props.d.ts +20 -0
  68. package/build/svelte/stack/stack-props.js +4 -0
  69. package/build/svelte/stack/stack-screen.svelte +148 -0
  70. package/build/svelte/stack/stack-screen.svelte.d.ts +4 -0
  71. package/build/svelte/tab-screen-props.d.ts +13 -0
  72. package/build/svelte/tab-screen-props.js +4 -0
  73. package/build/svelte/tab-screen.svelte +26 -0
  74. package/build/svelte/tab-screen.svelte.d.ts +4 -0
  75. package/build/svelte/tabs/index.d.ts +8 -0
  76. package/build/svelte/tabs/index.js +6 -0
  77. package/build/svelte/tabs/index.svelte +247 -0
  78. package/build/svelte/tabs/index.svelte.d.ts +8 -0
  79. package/build/svelte/tabs/tab-props.d.ts +7 -0
  80. package/build/svelte/tabs/tab-props.js +3 -0
  81. package/build/vue/drawer/index.js +6 -5
  82. package/build/vue/stack/index.js +26 -12
  83. package/build/vue/tabs/index.js +48 -35
  84. package/build-ngc/angular/drawer/index.js +9 -7
  85. package/build-ngc/angular/drawer/index.js.map +1 -1
  86. package/build-ngc/angular/drawer-screen.directive.js +3 -3
  87. package/build-ngc/angular/navigation-context.service.js +3 -3
  88. package/build-ngc/angular/navigation-scope.directive.js +3 -3
  89. package/build-ngc/angular/screen.directive.js +3 -3
  90. package/build-ngc/angular/search-bar-ref.directive.js +3 -3
  91. package/build-ngc/angular/stack/index.d.ts +2 -0
  92. package/build-ngc/angular/stack/index.js +26 -6
  93. package/build-ngc/angular/stack/index.js.map +1 -1
  94. package/build-ngc/angular/tab-screen.directive.js +3 -3
  95. package/build-ngc/angular/tabs/index.d.ts +1 -1
  96. package/build-ngc/angular/tabs/index.js +23 -10
  97. package/build-ngc/angular/tabs/index.js.map +1 -1
  98. package/build-ngc/core/index.d.ts +2 -1
  99. package/build-ngc/core/index.js +5 -1
  100. package/build-ngc/core/index.js.map +1 -1
  101. package/build-ngc/core/stack-router-state/index.d.ts +2 -0
  102. package/build-ngc/core/stack-router-state/index.js +45 -0
  103. package/build-ngc/core/stack-router-state/index.js.map +1 -0
  104. package/build-ngc/core/tab-router-state/index.d.ts +1 -0
  105. package/build-ngc/core/tab-router-state/index.js +19 -0
  106. package/build-ngc/core/tab-router-state/index.js.map +1 -1
  107. package/package.json +28 -14
  108. package/src/angular/drawer/index.ts +6 -4
  109. package/src/angular/stack/index.ts +23 -2
  110. package/src/angular/tabs/index.ts +19 -5
  111. package/src/core/index.ts +11 -1
  112. package/src/core/stack-router-state/index.ts +49 -0
  113. package/src/core/tab-router-state/index.ts +25 -0
  114. package/src/react/drawer/index.ts +28 -13
  115. package/src/react/stack/index.ts +15 -1
  116. package/src/react/tabs/index.ts +43 -10
  117. package/src/svelte/attachments.ts +68 -0
  118. package/src/svelte/descriptor-subtree.ts +49 -0
  119. package/src/svelte/drawer/drawer-props.ts +28 -0
  120. package/src/svelte/drawer/index.svelte +421 -0
  121. package/src/svelte/drawer/index.ts +10 -0
  122. package/src/svelte/drawer-screen-props.ts +20 -0
  123. package/src/svelte/drawer-screen.svelte +24 -0
  124. package/src/svelte/fabric-tree.test-helper.ts +61 -0
  125. package/src/svelte/index.ts +71 -0
  126. package/src/svelte/linking.svelte.ts +66 -0
  127. package/src/svelte/navigation-context.ts +62 -0
  128. package/src/svelte/navigation-scope.svelte +24 -0
  129. package/src/svelte/registry-host.ts +26 -0
  130. package/src/svelte/runes/index.ts +17 -0
  131. package/src/svelte/runes/use-drawer-navigation.svelte.ts +29 -0
  132. package/src/svelte/runes/use-focus-effect.svelte.ts +35 -0
  133. package/src/svelte/runes/use-is-focused.svelte.ts +38 -0
  134. package/src/svelte/runes/use-navigation-state.svelte.ts +46 -0
  135. package/src/svelte/runes/use-navigation.svelte.ts +39 -0
  136. package/src/svelte/runes/use-route.svelte.ts +16 -0
  137. package/src/svelte/runes/use-stack-navigation.svelte.ts +32 -0
  138. package/src/svelte/runes/use-tab-navigation.svelte.ts +29 -0
  139. package/src/svelte/screen-props.ts +45 -0
  140. package/src/svelte/screen-registry.ts +92 -0
  141. package/src/svelte/screen.svelte +28 -0
  142. package/src/svelte/stack/index.svelte +224 -0
  143. package/src/svelte/stack/index.ts +11 -0
  144. package/src/svelte/stack/stack-props.ts +31 -0
  145. package/src/svelte/stack/stack-screen.svelte +148 -0
  146. package/src/svelte/svelte-ambient.d.ts +6 -0
  147. package/src/svelte/svelte-compile.test-helper.ts +143 -0
  148. package/src/svelte/tab-screen-props.ts +20 -0
  149. package/src/svelte/tab-screen.svelte +26 -0
  150. package/src/svelte/tabs/index.svelte +247 -0
  151. package/src/svelte/tabs/index.ts +10 -0
  152. package/src/svelte/tabs/tab-props.ts +13 -0
  153. package/src/vue/drawer/index.ts +6 -5
  154. package/src/vue/stack/index.ts +28 -11
  155. package/src/vue/tabs/index.ts +68 -65
@@ -221,12 +221,14 @@ export class Drawer implements AfterContentInit, OnChanges, OnDestroy, IDrawerNa
221
221
  this.dispatch({ type: 'toggleDrawer' });
222
222
  };
223
223
  readonly jumpTo = (name: string): void => {
224
- // Captured BEFORE dispatch, mirroring vue/drawer/index.ts's jumpTo: a signal (like Vue's ref)
225
- // mutates synchronously inside dispatch, so reading isOpen after it would already see the
226
- // reducer's own isOpen: false and never animate the panel closed.
224
+ // Both sides of the dispatch, mirroring vue/drawer/index.ts's jumpTo: an unregistered name is
225
+ // a documented reducer no-op that hands the SAME state back, so animating off the pre-dispatch
226
+ // snapshot alone would slide the panel shut while the router still says isOpen. The signal is
227
+ // set synchronously inside dispatch, so the second read is already the reducer's own answer.
227
228
  const wasOpen = this.stateSignal()?.isOpen ?? false;
228
229
  this.dispatch({ type: 'jumpTo', name });
229
- if (wasOpen) this.animateProgressTo(false);
230
+ const isOpenNow = this.stateSignal()?.isOpen ?? false;
231
+ if (wasOpen && !isOpenNow) this.animateProgressTo(false);
230
232
  };
231
233
 
232
234
  readonly panResponder = PanResponder.create({
@@ -35,7 +35,7 @@
35
35
  // self-registers instead: `adapters/angular/babel-register-composed.cjs` (a Metro babel preset
36
36
  // applied bundle-wide, not scoped to adapters/angular) scans this package's own AOT-compiled
37
37
  // (`ngc`) `ɵɵngDeclareComponent({selector: 'Stack', ...})` output and auto-calls
38
- // `registerComposedComponent('Stack')` at bundle time - same mechanism `.examples/angular`
38
+ // `registerComposedComponent('Stack')` at bundle time - same mechanism `examples/angular`
39
39
  // navigation-demo screens and `@symbiote-native/slider`'s `Slider` rely on for their own composed
40
40
  // components mounted statically or via `NgComponentOutlet`. Unregistered, `createElement('Stack')`
41
41
  // falls through to a real `createNode` call and RN paints its own "Unimplemented component"
@@ -73,6 +73,7 @@ import {
73
73
  createInitialNavigatorState,
74
74
  createNavigationEmitter,
75
75
  navigatorReducer,
76
+ reconcileStackRoutes,
76
77
  resolveHeaderInModalStackStyle,
77
78
  resolveScreenRenderPlan,
78
79
  resolveStackProps,
@@ -242,6 +243,20 @@ export class Stack implements AfterContentInit, OnDestroy, INavigatorHandle {
242
243
  for (const screen of this.screenChildren) {
243
244
  this.registry.set(screen.name, screen);
244
245
  }
246
+ this.reconcileWithRegistry();
247
+ }
248
+
249
+ // A `<ng-template symbioteScreen>` marker can leave the @ContentChildren query (a marker behind
250
+ // an `@if`, a data-driven screen list) while its route is still in the pushed history, which
251
+ // would leave that entry with nothing for componentFor() to mount (reconcileStackRoutes' header).
252
+ // The signal write lives HERE, in the query-change callback, and never in a computed/template
253
+ // accessor - reconciliation is a reaction to the registry changing, not a derivation of it.
254
+ private reconcileWithRegistry(): void {
255
+ const current = this.stateSignal();
256
+ if (current === undefined) return;
257
+ const next = reconcileStackRoutes(current, [...this.registry.keys()]);
258
+ if (next === current) return;
259
+ this.commitState(next);
245
260
  }
246
261
 
247
262
  private initializeState(): void {
@@ -269,7 +284,13 @@ export class Stack implements AfterContentInit, OnDestroy, INavigatorHandle {
269
284
  private dispatch(action: INavigatorAction): void {
270
285
  const current = this.stateSignal();
271
286
  if (current === undefined) return;
272
- const next = navigatorReducer(current, action);
287
+ this.commitState(navigatorReducer(current, action));
288
+ }
289
+
290
+ // The one write path into stateSignal, shared by a dispatched action and a registry-driven
291
+ // reconciliation: both invalidate the plan cache, broadcast to every still-live route, and prune
292
+ // the emitters of routes that are gone.
293
+ private commitState(next: INavigatorState): void {
273
294
  this.planCache.clear();
274
295
  this.stateSignal.set(next);
275
296
  const liveRouteKeys = new Set(next.routes.map(route => route.key));
@@ -39,6 +39,7 @@ import {
39
39
  createNavigationEmitter,
40
40
  diffFocusedRoute,
41
41
  isFocusedRoute,
42
+ reconcileTabRoutes,
42
43
  renderTabBar,
43
44
  tabRouterReducer,
44
45
  } from '../../core';
@@ -114,10 +115,9 @@ export class Tab implements AfterContentInit, OnDestroy, ITabNavigatorHandle {
114
115
  this.dispatch({ type: 'setParams', key, params });
115
116
 
116
117
  ngAfterContentInit(): void {
117
- this.rebuildRegistry();
118
- this.initializeState();
118
+ this.syncRegistry();
119
119
  this.tabScreenChildrenSubscription = this.tabScreenChildren.changes.subscribe(() => {
120
- this.rebuildRegistry();
120
+ this.syncRegistry();
121
121
  });
122
122
  }
123
123
 
@@ -141,10 +141,24 @@ export class Tab implements AfterContentInit, OnDestroy, ITabNavigatorHandle {
141
141
  }));
142
142
  }
143
143
 
144
- private initializeState(): void {
145
- if (this.stateSignal() !== undefined) return;
144
+ // A <ng-template symbioteTabScreen> can appear or disappear after mount (a marker behind an
145
+ // @if, a data-driven screen list), so the route list follows the LIVE query instead of staying
146
+ // frozen at whatever ngAfterContentInit first saw: reconcileTabRoutes (core) keeps each
147
+ // surviving route's key and accumulated params and moves focus only when the focused route is
148
+ // the one that left. Runs from the query's own `changes` subscription - an ordinary imperative
149
+ // callback, deliberately not a computed, since this writes the state signal.
150
+ private syncRegistry(): void {
151
+ this.rebuildRegistry();
146
152
  const routes = this.routesFromRegistry();
147
153
  if (routes.length === 0) dlog('Tab: no <ng-template symbioteTabScreen> children registered');
154
+ const current = this.stateSignal();
155
+ if (current !== undefined) {
156
+ this.stateSignal.set(reconcileTabRoutes(current, routes));
157
+ return;
158
+ }
159
+ // Seeding an empty list would resolve initialRouteName against nothing and lose it once the
160
+ // markers do arrive, so the state stays unseeded until at least one has registered.
161
+ if (routes.length === 0) return;
148
162
  this.stateSignal.set(createInitialTabState(routes, this.initialRouteName));
149
163
  }
150
164
 
package/src/core/index.ts CHANGED
@@ -16,6 +16,11 @@ export {
16
16
  } from './navigator-state';
17
17
  export type { IRoute, INavigatorState, INavigatorAction } from './navigator-state';
18
18
 
19
+ // Drops history entries whose `<Stack.Screen>` marker has unregistered - see
20
+ // stack-router-state.ts's header for why the Stack's history needs this and the Tab's projection
21
+ // does not.
22
+ export { reconcileStackRoutes } from './stack-router-state';
23
+
19
24
  export type {
20
25
  IStackAnimation,
21
26
  IStackPresentation,
@@ -89,7 +94,12 @@ export { isRecord } from './guards';
89
94
 
90
95
  // Bottom-tabs navigator: a FOCUSED-INDEX router (not a stack - see tab-router-state.ts) driving
91
96
  // a pure-JS tab bar render, framework-agnostic and shared verbatim by every adapter.
92
- export { createInitialTabState, tabRouterReducer, isFocusedRoute } from './tab-router-state';
97
+ export {
98
+ createInitialTabState,
99
+ tabRouterReducer,
100
+ reconcileTabRoutes,
101
+ isFocusedRoute,
102
+ } from './tab-router-state';
93
103
  export type { ITabRouterState, ITabRouterAction } from './tab-router-state';
94
104
 
95
105
  export type { ITabBarIcon, ITabOptions } from './tab-options';
@@ -0,0 +1,49 @@
1
+ // StackRouter: the registry-reconciliation half (framework-agnostic, zero render). A Stack's route
2
+ // list is navigation HISTORY, not a projection of the registered `<Stack.Screen>` markers the way a
3
+ // Tab's is (tab-router-state) - it seeds exactly one route and grows only through push/replace. So
4
+ // a marker that unregisters while its route still sits in the history (a marker behind an `{#if}`,
5
+ // a data-driven screen list) leaves behind an entry nothing can render: the route stays, the
6
+ // adapter's componentFor() finds no screen, and the user is parked on a blank RNSScreen with no
7
+ // way to tell what happened. Mirrors @react-navigation/routers' StackRouter.getStateForRouteNames-
8
+ // Change, which filters those routes out of the history and pulls the focused index back into
9
+ // range.
10
+ //
11
+ // Deliberately NOT a navigatorReducer action: this is not something a caller dispatches, it is a
12
+ // reaction to the registry changing underneath an unchanged history, and each adapter observes
13
+ // that change through its own lifecycle. Keeping it a separate pure function lets every adapter
14
+ // wire it its own way without teaching the reducer about a registry it has never seen.
15
+
16
+ import type { INavigatorState } from '../navigator-state';
17
+
18
+ // The contract, and the two places it deliberately parts from upstream:
19
+ //
20
+ // - SURVIVORS KEEP THEIR IDENTITY. A stack route's key is COUNTER-derived (every adapter's
21
+ // createRoute increments a per-navigator sequence), not name-derived like a tab route's, so
22
+ // re-deriving the list from the registry would hand every survivor a brand-new key and remount
23
+ // live screens. Only the phantom entries go; every other route object passes through by
24
+ // reference, params included.
25
+ //
26
+ // - THE FOCUSED ROUTE IS THE TOP ONE. This state carries no `index` field: the focused route is
27
+ // always the last one (see computeActivityState's comment for why no route can outrank it), so
28
+ // upstream's `index: Math.min(state.index, routes.length - 1)` degenerates here to "the last
29
+ // survivor". Dropping the FOCUSED route therefore lands the user on the nearest still-registered
30
+ // route beneath it - where a pop() would have left them - rather than on nothing.
31
+ //
32
+ // - THE ROUTE LIST IS NEVER EMPTIED. `routes.length - 1` IS the focused index, so an empty list
33
+ // means no focused route at all - the blank screen this function exists to prevent. When nothing
34
+ // in the history survives (every marker gone, or the whole screen set swapped for new names) the
35
+ // state is returned UNCHANGED. Upstream instead pushes a freshly nanoid()-keyed route for
36
+ // initialRouteName; we cannot, because minting a route key belongs to the caller here
37
+ // (navigator-state.ts's header) and a derivation that mints one would allocate a new key on every
38
+ // re-run. Unchanged is also the right answer for the usual cause of a momentarily empty registry
39
+ // - markers unregistering and re-registering across a re-render - since the next run reconciles
40
+ // for real.
41
+ export function reconcileStackRoutes(
42
+ state: INavigatorState,
43
+ registeredNames: readonly string[],
44
+ ): INavigatorState {
45
+ const routes = state.routes.filter(route => registeredNames.includes(route.name));
46
+ // filter preserves order, so an unchanged length means nothing was dropped.
47
+ if (routes.length === state.routes.length || routes.length === 0) return state;
48
+ return { routes };
49
+ }
@@ -34,6 +34,31 @@ export function createInitialTabState(
34
34
  return { routes, index: index === -1 ? INITIAL_FOCUSED_INDEX : index };
35
35
  }
36
36
 
37
+ // A screen marker can appear or disappear after mount (a marker behind an `{#if}`, a data-driven
38
+ // screen list), so a navigator's route list has to follow its registry instead of staying frozen
39
+ // at whatever createInitialTabState saw first. Mirrors TabRouter.getStateForRouteNamesChange:
40
+ // membership and order come from `nextRoutes`, but a route that survives keeps its IDENTITY (its
41
+ // key) and everything jumpTo/setParams accumulated onto its params - only genuinely new names
42
+ // take their entry from `nextRoutes`. Focus follows the previously focused route's NAME, so an
43
+ // unrelated screen disappearing never moves the user; when the focused route is the one that
44
+ // disappeared, focus falls back to the first route, the same landing spot every unresolvable
45
+ // focus gets here (createInitialTabState's own fallback).
46
+ export function reconcileTabRoutes(
47
+ state: ITabRouterState,
48
+ nextRoutes: readonly IRouteEntry[],
49
+ ): ITabRouterState {
50
+ const routes = nextRoutes.map(
51
+ next => state.routes.find(route => route.name === next.name) ?? next,
52
+ );
53
+ const isUnchanged =
54
+ routes.length === state.routes.length &&
55
+ routes.every((route, at) => route === state.routes[at]);
56
+ if (isUnchanged) return state;
57
+ const focusedName = state.routes[state.index]?.name;
58
+ const index = routes.findIndex(route => route.name === focusedName);
59
+ return { routes, index: index === -1 ? INITIAL_FOCUSED_INDEX : index };
60
+ }
61
+
37
62
  // jumpTo moves the focused index to an existing route by name and optionally merges params
38
63
  // (navigation.jumpTo(name, params) - the tab-actions doc's own signature); it never adds,
39
64
  // removes, or reorders routes (mirrors TabRouter: JUMP_TO is a no-op when the target name isn't
@@ -53,6 +53,7 @@ import type {
53
53
  IDrawerDescriptorMap,
54
54
  IDrawerNavigatorHandle,
55
55
  IDrawerOptions,
56
+ IDrawerRouterAction,
56
57
  IDrawerRouterState,
57
58
  IDrawerScreenOptions,
58
59
  IDrawerSlot,
@@ -163,8 +164,10 @@ const DrawerImpl = forwardRef<IDrawerNavigatorHandle, IDrawerProps>((props, forw
163
164
  optionsRef.current = options;
164
165
  const screenWidthRef = useRef(screenWidth);
165
166
  screenWidthRef.current = screenWidth;
166
- const isOpenRef = useRef(state.isOpen);
167
- isOpenRef.current = state.isOpen;
167
+ // The whole router state, not just its isOpen bit: jumpTo below has to re-run the reducer to
168
+ // learn what the dispatch actually produced, and that needs the routes too.
169
+ const stateRef = useRef(state);
170
+ stateRef.current = state;
168
171
 
169
172
  const animateProgressTo = useCallback(
170
173
  (open: boolean): void => {
@@ -190,23 +193,31 @@ const DrawerImpl = forwardRef<IDrawerNavigatorHandle, IDrawerProps>((props, forw
190
193
  const handle = useMemo<IDrawerNavigatorHandle>(
191
194
  () => ({
192
195
  openDrawer: () => {
193
- dlog(`Drawer: openDrawer() called, isOpen=${isOpenRef.current} at t=${Date.now()}`);
196
+ dlog(`Drawer: openDrawer() called, isOpen=${stateRef.current.isOpen} at t=${Date.now()}`);
194
197
  animateProgressTo(true);
195
198
  dispatch({ type: 'openDrawer' });
196
199
  },
197
200
  closeDrawer: () => {
198
- dlog(`Drawer: closeDrawer() called, isOpen=${isOpenRef.current} at t=${Date.now()}`);
201
+ dlog(`Drawer: closeDrawer() called, isOpen=${stateRef.current.isOpen} at t=${Date.now()}`);
199
202
  animateProgressTo(false);
200
203
  dispatch({ type: 'closeDrawer' });
201
204
  },
202
205
  toggleDrawer: () => {
203
- dlog(`Drawer: toggleDrawer() called, isOpen=${isOpenRef.current} at t=${Date.now()}`);
204
- animateProgressTo(!isOpenRef.current);
206
+ dlog(`Drawer: toggleDrawer() called, isOpen=${stateRef.current.isOpen} at t=${Date.now()}`);
207
+ animateProgressTo(!stateRef.current.isOpen);
205
208
  dispatch({ type: 'toggleDrawer' });
206
209
  },
207
210
  jumpTo: (name: string) => {
208
- dispatch({ type: 'jumpTo', name });
209
- if (isOpenRef.current) animateProgressTo(false);
211
+ // An unregistered name is a documented reducer no-op that hands the SAME state back, so
212
+ // animating off `isOpen` alone would slide the panel shut while the router still says
213
+ // open. useReducer's dispatch is async - stateRef only catches up on the next render - so
214
+ // the reducer (a pure function) is re-run here to learn what the dispatch produces, and
215
+ // the animation runs only on a genuine open -> closed transition.
216
+ const action: IDrawerRouterAction = { type: 'jumpTo', name };
217
+ const current = stateRef.current;
218
+ const next = drawerRouterReducer(current, action);
219
+ dispatch(action);
220
+ if (current.isOpen && !next.isOpen) animateProgressTo(false);
210
221
  },
211
222
  }),
212
223
  [animateProgressTo],
@@ -224,7 +235,7 @@ const DrawerImpl = forwardRef<IDrawerNavigatorHandle, IDrawerProps>((props, forw
224
235
  event,
225
236
  gestureState,
226
237
  screenWidthRef.current,
227
- isOpenRef.current,
238
+ stateRef.current.isOpen,
228
239
  optionsRef.current,
229
240
  'start',
230
241
  ),
@@ -236,13 +247,13 @@ const DrawerImpl = forwardRef<IDrawerNavigatorHandle, IDrawerProps>((props, forw
236
247
  event,
237
248
  gestureState,
238
249
  screenWidthRef.current,
239
- isOpenRef.current,
250
+ stateRef.current.isOpen,
240
251
  optionsRef.current,
241
252
  'move',
242
253
  ),
243
254
  onPanResponderGrant: (): void => {
244
255
  dlog('Drawer: gesture grant');
245
- dragStartProgress.current = isOpenRef.current ? 1 : 0;
256
+ dragStartProgress.current = stateRef.current.isOpen ? 1 : 0;
246
257
  },
247
258
  onPanResponderMove: (
248
259
  _event: ISymbioteEvent,
@@ -259,7 +270,11 @@ const DrawerImpl = forwardRef<IDrawerNavigatorHandle, IDrawerProps>((props, forw
259
270
  _event: ISymbioteEvent,
260
271
  gestureState: IPanResponderGestureState,
261
272
  ): void => {
262
- const intent = resolveSwipeIntent(gestureState, isOpenRef.current, optionsRef.current);
273
+ const intent = resolveSwipeIntent(
274
+ gestureState,
275
+ stateRef.current.isOpen,
276
+ optionsRef.current,
277
+ );
263
278
  const open = intent === 'open';
264
279
  dlog(`Drawer: gesture release -> ${open ? 'open' : 'close'}`);
265
280
  animateProgressTo(open);
@@ -267,7 +282,7 @@ const DrawerImpl = forwardRef<IDrawerNavigatorHandle, IDrawerProps>((props, forw
267
282
  },
268
283
  onPanResponderTerminate: (): void => {
269
284
  dlog('Drawer: gesture terminated, snapping back');
270
- animateProgressTo(isOpenRef.current);
285
+ animateProgressTo(stateRef.current.isOpen);
271
286
  },
272
287
  }),
273
288
  ).current;
@@ -46,6 +46,7 @@ import {
46
46
  createInitialNavigatorState,
47
47
  createNavigationEmitter,
48
48
  navigatorReducer,
49
+ reconcileStackRoutes,
49
50
  resolveScreenRenderPlan,
50
51
  resolveStackProps,
51
52
  } from '../../core';
@@ -122,7 +123,9 @@ const StackImpl = forwardRef<INavigatorHandle, IStackProps>((props, forwardedRef
122
123
  [routeIdPrefix],
123
124
  );
124
125
 
125
- const [state, dispatch] = useReducer(navigatorReducer, undefined, () => {
126
+ const registeredNames = useMemo(() => [...registry.keys()], [registry]);
127
+
128
+ const [dispatchedState, dispatch] = useReducer(navigatorReducer, undefined, () => {
126
129
  const initialRouteName = props.initialRouteName ?? registry.keys().next().value;
127
130
  if (initialRouteName === undefined) {
128
131
  dlog('Stack: no <Stack.Screen> children registered');
@@ -133,6 +136,17 @@ const StackImpl = forwardRef<INavigatorHandle, IStackProps>((props, forwardedRef
133
136
  );
134
137
  });
135
138
 
139
+ // A <Stack.Screen> marker can unregister while its route is still in the pushed history, which
140
+ // would leave that entry with nothing to render (reconcileStackRoutes' header). Reconciling here
141
+ // rather than in an effect repairs the CURRENT paint - the call is pure and hands back the same
142
+ // reference when nothing changed, so it is safe during render; the effect below only PERSISTS
143
+ // the repair, so the next push builds on the pruned history instead of the phantom one.
144
+ const state = reconcileStackRoutes(dispatchedState, registeredNames);
145
+
146
+ useEffect(() => {
147
+ if (state !== dispatchedState) dispatch({ type: 'reset', state });
148
+ }, [state, dispatchedState]);
149
+
136
150
  const handle = useMemo<INavigatorHandle>(
137
151
  () => ({
138
152
  push: (name, params) => dispatch({ type: 'push', route: createRoute(name, params) }),
@@ -1,9 +1,9 @@
1
1
  // Tab, the React lifecycle half. The focused-index router (tab-router-state) and the tab-bar
2
2
  // Descriptor builder (render-tabs) live in @symbiote-native/navigation core, shared verbatim
3
- // with the Vue/Angular adapters; here React supplies the lifecycle - useReducer for the focused
4
- // index, useId for route-key generation, useImperativeHandle for the jumpTo/setParams handle -
5
- // plus the descriptor bridge for the tab-bar leaf, exactly like Stack bridges its header config
6
- // (react/stack.ts). Unlike Stack, a bottom-tabs bar is a PURE-JS UI: it paints ordinary
3
+ // with the Vue/Angular adapters; here React supplies the lifecycle - useState for the dispatched
4
+ // router state, useId for route-key generation, useImperativeHandle for the jumpTo/setParams
5
+ // handle - plus the descriptor bridge for the tab-bar leaf, exactly like Stack bridges its header
6
+ // config (react/stack.ts). Unlike Stack, a bottom-tabs bar is a PURE-JS UI: it paints ordinary
7
7
  // `symbiote-view`/`symbiote-text` primitives via the shared render fn, so there is no
8
8
  // react-native-screens ViewConfig to register here - Tab needs no `../register` import.
9
9
 
@@ -17,7 +17,7 @@ import {
17
17
  useId,
18
18
  useImperativeHandle,
19
19
  useMemo,
20
- useReducer,
20
+ useState,
21
21
  } from 'react';
22
22
  import type { ReactElement, ReactNode } from 'react';
23
23
  import { descriptorToReact } from '@symbiote-native/react';
@@ -28,10 +28,18 @@ import {
28
28
  createInitialTabState,
29
29
  createNavigationEmitter,
30
30
  isFocusedRoute,
31
+ reconcileTabRoutes,
31
32
  renderTabBar,
32
33
  tabRouterReducer,
33
34
  } from '../../core';
34
- import type { IRoute, ITabBarItemView, ITabNavigatorHandle, ITabOptions } from '../../core';
35
+ import type {
36
+ IRoute,
37
+ ITabBarItemView,
38
+ ITabNavigatorHandle,
39
+ ITabOptions,
40
+ ITabRouterAction,
41
+ ITabRouterState,
42
+ } from '../../core';
35
43
  import { collectRegistry } from '../collect-registry';
36
44
  import { NavigationContext } from '../navigation-context';
37
45
  import { TabScreen } from '../tab-screen';
@@ -84,19 +92,44 @@ const TabImpl = forwardRef<ITabNavigatorHandle, ITabProps>((props, forwardedRef)
84
92
  [registry, routeIdPrefix],
85
93
  );
86
94
 
87
- const [state, dispatch] = useReducer(tabRouterReducer, undefined, () =>
88
- createInitialTabState(routes, props.initialRouteName),
95
+ // A <Tab.Screen> can appear or disappear after mount (a marker behind a conditional, a
96
+ // data-driven screen list), so the route list is re-derived from `children` on every render and
97
+ // the STORED state is reconciled against it (reconcileTabRoutes, core - it preserves each
98
+ // surviving route's key and accumulated params and keeps focus on the same route NAME) rather
99
+ // than staying frozen at whatever the first render saw. `null` means no marker had registered
100
+ // yet: seeding an empty list would resolve initialRouteName against nothing and lose it, so the
101
+ // state stays derived until either a marker arrives or something dispatches.
102
+ const [storedState, setStoredState] = useState<ITabRouterState | null>(() =>
103
+ routes.length === 0 ? null : createInitialTabState(routes, props.initialRouteName),
104
+ );
105
+
106
+ const resolveState = useCallback(
107
+ (stored: ITabRouterState | null): ITabRouterState =>
108
+ stored === null
109
+ ? createInitialTabState(routes, props.initialRouteName)
110
+ : reconcileTabRoutes(stored, routes),
111
+ [routes, props.initialRouteName],
89
112
  );
90
113
 
114
+ const state = resolveState(storedState);
115
+
91
116
  if (routes.length === 0) dlog('Tab: no <Tab.Screen> children registered');
92
117
 
118
+ // The updater re-resolves rather than closing over `state` so that two dispatches batched into
119
+ // one render still compose: the second reduces over the first's result, not a stale snapshot.
120
+ const dispatch = useCallback(
121
+ (action: ITabRouterAction) =>
122
+ setStoredState(current => tabRouterReducer(resolveState(current), action)),
123
+ [resolveState],
124
+ );
125
+
93
126
  const jumpTo = useCallback(
94
127
  (name: string, params?: unknown) => dispatch({ type: 'jumpTo', name, params }),
95
- [],
128
+ [dispatch],
96
129
  );
97
130
  const setParams = useCallback(
98
131
  (params: unknown, key: string) => dispatch({ type: 'setParams', key, params }),
99
- [],
132
+ [dispatch],
100
133
  );
101
134
 
102
135
  const handle = useMemo<ITabNavigatorHandle>(() => ({ jumpTo, setParams }), [jumpTo, setParams]);
@@ -0,0 +1,68 @@
1
+ // Svelte attachments (`{@attach ...}`) for the react-native-screens native leaves.
2
+ //
3
+ // WHY THESE EXIST AT ALL: every react-native-screens Fabric view name is capitalized and
4
+ // un-hyphenated (RNSScreen, RNSScreenStack, RNSScreenStackHeaderConfig, RNSSearchBar, ...), so a
5
+ // literal `<RNSScreen>` in a Svelte template parses as a COMPONENT reference, not an element -
6
+ // the same wall @symbiote-native/slider's 'RNCSlider' leaf hit. Slider sidestepped it by mounting
7
+ // the whole leaf through the Descriptor bridge, which works because that leaf carries no live
8
+ // framework children. A stack screen DOES (the app's own screen component lives inside
9
+ // RNSScreenContentWrapper), so the bridge is not an option here.
10
+ //
11
+ // The way through is `<svelte:element this={'RNSScreen'}>`: it creates the element via
12
+ // `document.createElement(tag)` with a plain runtime string the compiler never inspects, and it
13
+ // accepts ordinary framework children. Its one catch is documented in svelte-adapter-dom-shim
14
+ // skill §15 - a dynamic tag compiles through Svelte's generic setAttribute/property-diffing
15
+ // codegen, NOT the custom-element `p=` property-SET path, so writing `p={bag}` as an ATTRIBUTE on
16
+ // it silently fails. An attachment bypasses that entirely: it is handed the raw element and
17
+ // assigns the property from plain JS, then re-runs whenever the props it read change. Verified
18
+ // against the real compiler + the real shim before this was built on.
19
+
20
+ import { dlog, isSymbioteNode } from '@symbiote-native/engine';
21
+ import type { ShimElement } from '@symbiote-native/svelte/native-view-bridge';
22
+ import { buildSearchBarHandle } from '../core';
23
+ import type { ISearchBarCommands } from '../core';
24
+
25
+ // `unknown` rather than `Element`: the value Svelte hands an attachment is one of the DOM shim's
26
+ // own element classes, not a real DOM Element, and a function accepting `unknown` is still
27
+ // assignable everywhere Svelte expects `(element: Element) => ...`.
28
+ function isShimElement(value: unknown): value is ShimElement {
29
+ return typeof value === 'object' && value !== null && 'engineNode' in value;
30
+ }
31
+
32
+ // The object-bag prop set, the same entry point every adapter's props go through (routeProp).
33
+ // Re-runs on every change of `props`, and the shim's own `p` setter diffs per key from there.
34
+ export function hostProps(props: Record<string, unknown>): (node: unknown) => void {
35
+ return node => {
36
+ if (!isShimElement(node)) {
37
+ dlog('navigation: hostProps attached to a non-shim element, ignored');
38
+ return;
39
+ }
40
+ node.p = props;
41
+ };
42
+ }
43
+
44
+ // The Svelte twin of Angular's SearchBarRefDirective / React's and Vue's callback `ref` on the
45
+ // RNSSearchBar element: fills the app-supplied cell once the native node exists and clears it on
46
+ // teardown. buildSearchBarHandle's own getter is LAZY, so handing it the node here is safe even
47
+ // before the first commit.
48
+ export function searchBarRef(
49
+ ref: { current: ISearchBarCommands | null } | undefined,
50
+ ): (node: unknown) => (() => void) | void {
51
+ return node => {
52
+ if (ref === undefined) return;
53
+ if (!isShimElement(node)) {
54
+ ref.current = null;
55
+ return;
56
+ }
57
+ const engineNode = node.engineNode;
58
+ if (!isSymbioteNode(engineNode)) {
59
+ dlog('navigation: search bar attachment ran before the shim went live');
60
+ ref.current = null;
61
+ return;
62
+ }
63
+ ref.current = buildSearchBarHandle(() => engineNode);
64
+ return () => {
65
+ ref.current = null;
66
+ };
67
+ };
68
+ }
@@ -0,0 +1,49 @@
1
+ // A shape-CHANGE-tolerant wrapper over @symbiote-native/svelte's Descriptor bridge.
2
+ //
3
+ // `mountDescriptorChildren` (svelte-adapter-dom-shim skill §19) is deliberately shape-stable: it
4
+ // builds each shim node once and only re-sets props thereafter, throwing if the tree shape ever
5
+ // changes, because every `core/components` render-*.ts produces a constant shape. The navigation
6
+ // package's tab bar is the exception - `renderTabBar` emits one child per registered route, and
7
+ // that count genuinely varies (it is 0 until the `<Tab.Screen>` markers have registered, and
8
+ // changes again if a screen is added or removed). Same situation @symbiote-native/slider's own
9
+ // steps-indicator overlay is in, and the same resolution: keep the fast path for the common
10
+ // "same shape, new prop values" case and rebuild only when the shape actually changed.
11
+ //
12
+ // The shape signature covers types and nesting, not just the top-level count, because a tab bar
13
+ // item's own children vary too (an item gains an icon wrapper only when it has an icon or badge).
14
+
15
+ import { mountDescriptorChildren } from '@symbiote-native/svelte/native-view-bridge';
16
+ import type {
17
+ IDescriptorChildrenMount,
18
+ ShimElement,
19
+ } from '@symbiote-native/svelte/native-view-bridge';
20
+ import type { IDescriptorChild } from '@symbiote-native/components';
21
+
22
+ const TEXT_CHILD_MARK = '#';
23
+
24
+ function shapeOf(children: readonly IDescriptorChild[]): string {
25
+ return children
26
+ .map(child =>
27
+ typeof child === 'string' ? TEXT_CHILD_MARK : `${child.type}(${shapeOf(child.children)})`,
28
+ )
29
+ .join(',');
30
+ }
31
+
32
+ export function createDescriptorSubtreeSync(): (
33
+ host: ShimElement | null,
34
+ children: IDescriptorChild[],
35
+ ) => void {
36
+ let mounted: IDescriptorChildrenMount | undefined;
37
+ let shape: string | undefined;
38
+ return (host, children) => {
39
+ if (host === null) return;
40
+ const nextShape = shapeOf(children);
41
+ if (mounted === undefined || nextShape !== shape) {
42
+ for (const child of host.children.slice()) host.removeChild(child);
43
+ mounted = mountDescriptorChildren(host, children);
44
+ shape = nextShape;
45
+ return;
46
+ }
47
+ mounted.update(children);
48
+ };
49
+ }
@@ -0,0 +1,28 @@
1
+ // Drawer's own public prop surface. Plain `.ts` for the tsc-cannot-see-a-`.svelte`-named-export
2
+ // reason ../screen-props.ts documents.
3
+
4
+ import type { Snippet } from 'svelte';
5
+ import type { IStyleProp, IViewStyle } from '@symbiote-native/engine';
6
+ import type {
7
+ IDrawerDescriptorMap,
8
+ IDrawerNavigatorHandle,
9
+ IDrawerOptions,
10
+ IDrawerRouterState,
11
+ } from '../../core';
12
+
13
+ // React's `renderDrawerContent` render-PROP becomes a Snippet WITH A PARAMETER here (Vue's twin
14
+ // is a scoped slot) - the drawer panel's content is app-authored and needs the router state, the
15
+ // per-route options map, and the navigator handle to paint a menu.
16
+ export type IDrawerContentSlotProps = {
17
+ state: IDrawerRouterState;
18
+ descriptors: IDrawerDescriptorMap;
19
+ navigation: IDrawerNavigatorHandle;
20
+ };
21
+
22
+ export type IDrawerProps = IDrawerOptions & {
23
+ initialRouteName?: string;
24
+ drawerStyle?: IStyleProp<IViewStyle>;
25
+ // The `<Drawer.Screen>` markers.
26
+ children?: Snippet;
27
+ drawerContent?: Snippet<[IDrawerContentSlotProps]>;
28
+ };