@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
@@ -0,0 +1,247 @@
1
+ <script lang="ts" module>
2
+ // Tab, the Svelte lifecycle half. The focused-index router (tab-router-state) and the tab-bar
3
+ // Descriptor builder (render-tabs) live in @symbiote-native/navigation core, shared verbatim
4
+ // with the React/Vue/Angular entries; here Svelte supplies the lifecycle - `$state.raw` for the
5
+ // router (its twin of useReducer), a module counter for route-key generation, `export
6
+ // function`s for the jumpTo/setParams handle - plus the Descriptor bridge for the tab-bar leaf.
7
+ // Unlike Stack, a bottom-tabs bar is a PURE-JS UI: it paints ordinary `symbiote-view`/
8
+ // `symbiote-text` primitives via the shared render fn, so there is no react-native-screens
9
+ // ViewConfig to register here - Tab needs no `../../register` import.
10
+ //
11
+ // Screens are discovered through the context collector (../screen-registry.ts), not by reading
12
+ // `children` - see stack/index.svelte's header for why, and ../registry-host.ts for where the
13
+ // markers are rendered.
14
+ let tabInstanceCounter = 0;
15
+
16
+ const TAB_ROOT_PROPS: Record<string, unknown> = { style: { flex: 1 } };
17
+ const TAB_CONTENT_PROPS: Record<string, unknown> = { style: { flex: 1 } };
18
+ </script>
19
+
20
+ <script lang="ts">
21
+ import type { Component } from 'svelte';
22
+ import { onDestroy, tick } from 'svelte';
23
+ import { dlog } from '@symbiote-native/engine';
24
+ import type { ShimElement } from '@symbiote-native/svelte/native-view-bridge';
25
+ import {
26
+ NAVIGATION_EVENT_BLUR,
27
+ NAVIGATION_EVENT_FOCUS,
28
+ createInitialTabState,
29
+ createNavigationEmitter,
30
+ diffFocusedRoute,
31
+ isFocusedRoute,
32
+ reconcileTabRoutes,
33
+ renderTabBar,
34
+ tabRouterReducer,
35
+ } from '../../core';
36
+ import type {
37
+ IDescriptor,
38
+ INavigationEmitter,
39
+ IRoute,
40
+ ITabBarItemView,
41
+ ITabNavigatorHandle,
42
+ ITabOptions,
43
+ ITabRouterAction,
44
+ ITabRouterState,
45
+ } from '../../core';
46
+ import { createDescriptorSubtreeSync } from '../descriptor-subtree';
47
+ import { getNavigationScope } from '../navigation-context';
48
+ import type { INavigationScopeValue } from '../navigation-context';
49
+ import NavigationScope from '../navigation-scope.svelte';
50
+ import { SCREEN_REGISTRY_HOST_PROPS } from '../registry-host';
51
+ import { setScreenCollector, toRegistry, withoutScreen } from '../screen-registry';
52
+ import type { IRegisteredScreen } from '../screen-registry';
53
+ import type { ITabScreenProps } from '../tab-screen-props';
54
+ import type { ITabProps } from './tab-props';
55
+
56
+ let { initialRouteName, screenOptions, children }: ITabProps = $props();
57
+
58
+ // Read BEFORE this Tab establishes its own per-screen NavigationScope - becomes the `parent`
59
+ // link a nested screen's useNavigation().getParent() walks. undefined at the nesting root.
60
+ const parentScope = getNavigationScope();
61
+
62
+ const routeIdPrefix = `tab-${(tabInstanceCounter += 1)}`;
63
+
64
+ let screens = $state.raw<IRegisteredScreen<ITabScreenProps['options']>[]>([]);
65
+ setScreenCollector<ITabScreenProps['options']>({
66
+ kind: 'tab',
67
+ register: screen => {
68
+ screens = [...screens, screen];
69
+ },
70
+ unregister: screen => {
71
+ screens = withoutScreen(screens, screen);
72
+ },
73
+ });
74
+
75
+ const registry = $derived(toRegistry(screens));
76
+
77
+ // A tab's route list is a PROJECTION of the registry, not navigation history the way Stack's
78
+ // is: a marker registering or unregistering must add or drop its tab. Unlike Stack, a route key
79
+ // here is derived from the route NAME rather than an incrementing sequence, so re-deriving this
80
+ // list on every registry change re-keys nothing.
81
+ const registeredRoutes = $derived.by<IRoute<unknown>[]>(() =>
82
+ [...registry.entries()].map(([name, entry]) => ({
83
+ key: `${routeIdPrefix}-${name}`,
84
+ name,
85
+ params: entry.initialParams,
86
+ })),
87
+ );
88
+
89
+ // The dispatched half is the only STATE here; the registry half is re-derived and reconciled
90
+ // against it on every change (reconcileTabRoutes, core - it is what preserves each surviving
91
+ // route's key/params and keeps the focus on the same route NAME). Nothing needs memoizing
92
+ // inside the derivation, so there is no state write during a derivation to avoid in the first
93
+ // place. Until something dispatches there is nothing to reconcile against, hence the seed.
94
+ let dispatchedState = $state.raw<ITabRouterState | null>(null);
95
+
96
+ const state = $derived.by<ITabRouterState>(() => {
97
+ if (registeredRoutes.length === 0) dlog('Tab: no <Tab.Screen> children registered');
98
+ const dispatched = dispatchedState;
99
+ return dispatched === null
100
+ ? createInitialTabState(registeredRoutes, initialRouteName)
101
+ : reconcileTabRoutes(dispatched, registeredRoutes);
102
+ });
103
+
104
+ function dispatch(action: ITabRouterAction): void {
105
+ dispatchedState = tabRouterReducer(state, action);
106
+ }
107
+
108
+ export function jumpTo(name: string, params?: unknown): void {
109
+ dispatch({ type: 'jumpTo', name, params });
110
+ }
111
+ export function setParams(params: unknown, key: string): void {
112
+ dispatch({ type: 'setParams', key, params });
113
+ }
114
+
115
+ const handle: ITabNavigatorHandle = { jumpTo, setParams };
116
+
117
+ // One emitter per route.key, created lazily and cached for the navigator's whole lifetime -
118
+ // mirrors Stack's own `emitters` map. This decouples emitter IDENTITY (stable, looked up by
119
+ // key, read below when a route's NavigationScope is built) from emit TIMING (must wait until
120
+ // the focused screen has actually mounted and subscribed).
121
+ const emitters = new Map<string, INavigationEmitter>();
122
+ function emitterFor(routeKey: string): INavigationEmitter {
123
+ let emitter = emitters.get(routeKey);
124
+ if (emitter === undefined) {
125
+ emitter = createNavigationEmitter();
126
+ emitters.set(routeKey, emitter);
127
+ }
128
+ return emitter;
129
+ }
130
+
131
+ // Tab paints its own bar in pure JS - there is no native onAppear/onDisappear to hook (unlike
132
+ // Stack's RNSScreen), so focus/blur is synthesized here. Keyed on the route KEY (not the route
133
+ // object) so a setParams-only change doesn't spuriously re-fire focus/blur.
134
+ //
135
+ // The bookkeeping updates immediately, but the actual emit is deferred to `tick()` - Svelte's
136
+ // twin of Vue's nextTick, and needed for the same reason: the newly-focused screen's own
137
+ // `$effect`s (its useIsFocused/useFocusEffect subscriptions) are created during THIS flush,
138
+ // after this effect was, so emitting synchronously here would reach zero subscribers.
139
+ let previousFocusedKey: string | undefined;
140
+ $effect(() => {
141
+ const nextKey = state.routes[state.index]?.key;
142
+ const { blurKey, focusKey } = diffFocusedRoute(previousFocusedKey, nextKey);
143
+ if (blurKey === undefined && focusKey === undefined) return;
144
+ previousFocusedKey = nextKey;
145
+ void tick().then(() => {
146
+ if (blurKey !== undefined) {
147
+ dlog(`Tab: route "${blurKey}" blurred at t=${Date.now()}`);
148
+ emitterFor(blurKey).emit(NAVIGATION_EVENT_BLUR);
149
+ }
150
+ if (focusKey !== undefined) {
151
+ dlog(`Tab: route "${focusKey}" focused at t=${Date.now()}`);
152
+ emitterFor(focusKey).emit(NAVIGATION_EVENT_FOCUS);
153
+ }
154
+ });
155
+ });
156
+
157
+ onDestroy(() => {
158
+ if (previousFocusedKey !== undefined) {
159
+ emitterFor(previousFocusedKey).emit(NAVIGATION_EVENT_BLUR);
160
+ }
161
+ });
162
+
163
+ function resolveTabOptions(
164
+ entry: IRegisteredScreen<ITabScreenProps['options']> | undefined,
165
+ route: IRoute<unknown>,
166
+ ): ITabOptions {
167
+ const own =
168
+ entry === undefined
169
+ ? undefined
170
+ : typeof entry.options === 'function'
171
+ ? entry.options({ route, navigation: handle })
172
+ : entry.options;
173
+ return { ...screenOptions, ...own };
174
+ }
175
+
176
+ const items = $derived.by<ITabBarItemView[]>(() =>
177
+ state.routes.map((route, index) => {
178
+ const entry = registry.get(route.name);
179
+ const focused = isFocusedRoute(index, state.index);
180
+ if (entry === undefined) {
181
+ dlog(`Tab: no screen registered for route name "${route.name}"`);
182
+ return { key: route.key, focused, label: route.name, passthrough: {} };
183
+ }
184
+ const options = resolveTabOptions(entry, route);
185
+ return {
186
+ key: route.key,
187
+ focused,
188
+ label: options.tabBarLabel ?? options.title ?? route.name,
189
+ icon: options.tabBarIcon,
190
+ badge: options.tabBarBadge,
191
+ activeTintColor: options.tabBarActiveTintColor,
192
+ inactiveTintColor: options.tabBarInactiveTintColor,
193
+ passthrough: {
194
+ onPress: () => jumpTo(route.name),
195
+ accessibilityRole: 'tab',
196
+ accessibilityState: { selected: focused },
197
+ },
198
+ };
199
+ }),
200
+ );
201
+
202
+ const focusedRoute = $derived(state.routes[state.index]);
203
+ const focusedOptions = $derived(
204
+ focusedRoute === undefined
205
+ ? screenOptions
206
+ : resolveTabOptions(registry.get(focusedRoute.name), focusedRoute),
207
+ );
208
+
209
+ const tabBar = $derived.by<IDescriptor>(() =>
210
+ renderTabBar({ items, style: focusedOptions?.tabBarStyle, passthrough: {} }),
211
+ );
212
+
213
+ // Only the focused route's screen is ever mounted (unlike Stack, which keeps every pushed route
214
+ // alive), so a fresh NavigationScope per focus change is sufficient - the previous screen's
215
+ // whole subtree (and any listeners it registered) is torn down by an ordinary unmount when
216
+ // focus moves on.
217
+ const focusedScreen = $derived.by<{ scope: INavigationScopeValue; component: Component } | undefined>(
218
+ () => {
219
+ if (focusedRoute === undefined) return undefined;
220
+ const entry = registry.get(focusedRoute.name);
221
+ if (entry === undefined) return undefined;
222
+ return {
223
+ component: entry.component,
224
+ scope: {
225
+ route: focusedRoute,
226
+ navigation: handle,
227
+ emitter: emitterFor(focusedRoute.key),
228
+ parent: parentScope?.current,
229
+ },
230
+ };
231
+ },
232
+ );
233
+
234
+ // The bar's ROOT stays a literal template tag (so `bind:this` has a statically known tag) and
235
+ // only its children go through the Descriptor bridge - the uniform shape every Svelte component
236
+ // consuming a `render-*.ts` uses (svelte-adapter-dom-shim skill §19). The item count varies with
237
+ // the registry, hence the shape-change-tolerant wrapper rather than the raw bridge.
238
+ let tabBarHost = $state.raw<ShimElement | null>(null);
239
+ const syncTabBarChildren = createDescriptorSubtreeSync();
240
+ $effect(() => {
241
+ const host = tabBarHost;
242
+ const barChildren = tabBar.children;
243
+ syncTabBarChildren(host, barChildren);
244
+ });
245
+ </script>
246
+
247
+ <symbiote-view p={TAB_ROOT_PROPS}><symbiote-text p={SCREEN_REGISTRY_HOST_PROPS}>{@render children?.()}</symbiote-text><symbiote-view p={TAB_CONTENT_PROPS}>{#if focusedScreen !== undefined}{@const FocusedComponent = focusedScreen.component}<NavigationScope value={focusedScreen.scope}><FocusedComponent /></NavigationScope>{/if}</symbiote-view><symbiote-view p={tabBar.props} bind:this={tabBarHost}></symbiote-view></symbiote-view>
@@ -0,0 +1,10 @@
1
+ // `Tab.Screen` alongside the standalone `TabScreen` export, matching React's and Vue's barrels -
2
+ // see ../stack/index.ts for why Object.assign is the right mechanism on a compiled Svelte
3
+ // component.
4
+ import TabImpl from './index.svelte';
5
+ import TabScreen from '../tab-screen.svelte';
6
+
7
+ export const Tab = Object.assign(TabImpl, { Screen: TabScreen });
8
+
9
+ export type { ITabNavigatorHandle } from '../../core';
10
+ export type { ITabProps } from './tab-props';
@@ -0,0 +1,13 @@
1
+ // Tab'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 { ITabOptions } from '../../core';
6
+
7
+ // React's `children?: ReactNode` / Vue's default slot become a Svelte Snippet holding the
8
+ // `<Tab.Screen>` markers.
9
+ export type ITabProps = {
10
+ initialRouteName?: string;
11
+ screenOptions?: ITabOptions;
12
+ children?: Snippet;
13
+ };
@@ -263,13 +263,14 @@ const DrawerImpl = defineComponent<IDrawerProps>(
263
263
  dispatch({ type: 'toggleDrawer' });
264
264
  },
265
265
  jumpTo: (name: string) => {
266
- // Captured BEFORE dispatch: unlike React's isOpenRef (only refreshed at the TOP of the
267
- // next render, so it still holds the pre-dispatch value here since React batches the
268
- // re-render asynchronously), this ref's `.value` mutates SYNCHRONOUSLY inside dispatch -
269
- // reading it after dispatch would already see the reducer's own isOpen: false.
266
+ // Both sides of the dispatch, because an unregistered name is a documented reducer no-op
267
+ // that hands the SAME state back: animating off the pre-dispatch snapshot alone would
268
+ // slide the panel shut while the router still says isOpen. The ref's `.value` mutates
269
+ // SYNCHRONOUSLY inside dispatch (unlike React's isOpenRef, refreshed only at the top of
270
+ // the next render), so the second read is already the reducer's own answer.
270
271
  const wasOpen = state.value.isOpen;
271
272
  dispatch({ type: 'jumpTo', name });
272
- if (wasOpen) animateProgressTo(false);
273
+ if (wasOpen && !state.value.isOpen) animateProgressTo(false);
273
274
  },
274
275
  };
275
276
  expose(handle);
@@ -35,6 +35,7 @@ import {
35
35
  createNavigationEmitter,
36
36
  isRecord,
37
37
  navigatorReducer,
38
+ reconcileStackRoutes,
38
39
  resolveScreenRenderPlan,
39
40
  resolveStackProps,
40
41
  } from '../../core';
@@ -173,8 +174,22 @@ const StackImpl = defineComponent<IStackProps>(
173
174
 
174
175
  const state = shallowRef<INavigatorState>(initialState);
175
176
 
177
+ // The screen names the render pass below last saw in the slot. A PLAIN local, not a ref: it is
178
+ // a cache of what was just rendered, never a reactive source, so refreshing it from inside the
179
+ // render closure is not a state write during render (which would re-trigger the very render
180
+ // effect that produced it).
181
+ let registeredNames: readonly string[] = [];
182
+
183
+ // A <Stack.Screen> marker can vanish from the slot while its route is still in the pushed
184
+ // history, which would leave that entry with nothing to render (reconcileStackRoutes' header).
185
+ // Reconciling on READ rather than writing the ref keeps the repair out of the render pass; the
186
+ // dispatch below then persists it, so the next push builds on the pruned history.
187
+ function currentState(): INavigatorState {
188
+ return reconcileStackRoutes(state.value, registeredNames);
189
+ }
190
+
176
191
  function dispatch(action: Parameters<typeof navigatorReducer>[1]): void {
177
- state.value = navigatorReducer(state.value, action);
192
+ state.value = navigatorReducer(currentState(), action);
178
193
  }
179
194
 
180
195
  const handle: INavigatorHandle = {
@@ -185,7 +200,7 @@ const StackImpl = defineComponent<IStackProps>(
185
200
  replace: (name, params) => dispatch({ type: 'replace', route: createRoute(name, params) }),
186
201
  setParams: (params, key) => dispatch({ type: 'setParams', key, params }),
187
202
  reset: nextState => dispatch({ type: 'reset', state: nextState }),
188
- canGoBack: () => state.value.routes.length > 1,
203
+ canGoBack: () => currentState().routes.length > 1,
189
204
  };
190
205
  expose(handle);
191
206
 
@@ -195,18 +210,20 @@ const StackImpl = defineComponent<IStackProps>(
195
210
  // useEffect, which must be deferred past render to avoid a descendant setState-during-render):
196
211
  // Vue's provide/inject + shallowRef.value writes are safe to perform directly inside the
197
212
  // render closure below, so the broadcast happens right there, once per render.
198
- function broadcastState(): void {
199
- for (const route of state.value.routes) {
200
- emitterFor(route.key).emit(NAVIGATION_EVENT_STATE, state.value);
213
+ function broadcastState(current: INavigatorState): void {
214
+ for (const route of current.routes) {
215
+ emitterFor(route.key).emit(NAVIGATION_EVENT_STATE, current);
201
216
  }
202
217
  for (const routeKey of emitters.keys()) {
203
- if (!state.value.routes.some(route => route.key === routeKey)) emitters.delete(routeKey);
218
+ if (!current.routes.some(route => route.key === routeKey)) emitters.delete(routeKey);
204
219
  }
205
220
  }
206
221
 
207
222
  return () => {
208
- broadcastState();
209
223
  const registry = collectRegistry(slots.default?.() ?? []);
224
+ registeredNames = [...registry.keys()];
225
+ const current = currentState();
226
+ broadcastState(current);
210
227
  const screenOptions = asScreenOptions(attrs.screenOptions);
211
228
 
212
229
  // Investigation instrumentation (flicker-on-focus bug): STACK_ON_FINISH_TRANSITIONING is
@@ -221,7 +238,7 @@ const StackImpl = defineComponent<IStackProps>(
221
238
  },
222
239
  });
223
240
 
224
- const children = state.value.routes.map((route, index) => {
241
+ const children = current.routes.map((route, index) => {
225
242
  const entry = registry.get(route.name);
226
243
  if (entry === undefined) {
227
244
  dlog(`Stack: no screen registered for route name "${route.name}"`);
@@ -230,12 +247,12 @@ const StackImpl = defineComponent<IStackProps>(
230
247
 
231
248
  const screenOptionsArgs: IScreenOptionsArgs = { route, navigation: handle };
232
249
  const mergedOptions = resolveScreenOptions(entry, screenOptionsArgs, screenOptions);
233
- const activityState = computeActivityState(index, state.value.routes.length);
250
+ const activityState = computeActivityState(index, current.routes.length);
234
251
  // Investigation instrumentation (flicker-on-focus bug): fires on EVERY Stack render, not
235
252
  // just on transitions, so the log stream shows whether a route's activityState/index ever
236
253
  // changes outside of a push/pop dispatch. Kept behind DEBUG, never removed.
237
254
  dlog(
238
- `Stack: render route "${route.name}" index=${index}/${state.value.routes.length - 1} ` +
255
+ `Stack: render route "${route.name}" index=${index}/${current.routes.length - 1} ` +
239
256
  `activityState=${activityState} at t=${Date.now()}`,
240
257
  );
241
258
 
@@ -245,7 +262,7 @@ const StackImpl = defineComponent<IStackProps>(
245
262
  const plan = resolveScreenRenderPlan({
246
263
  screenId: route.key,
247
264
  index,
248
- routeCount: state.value.routes.length,
265
+ routeCount: current.routes.length,
249
266
  options: mergedOptions,
250
267
  platform: NAVIGATOR_PLATFORM,
251
268
  isAndroid: Platform.OS === 'android',
@@ -1,22 +1,13 @@
1
1
  // Tab, the Vue 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 with
3
- // the React/Angular adapters; here Vue supplies the lifecycle - a plain ref for the focused index
4
- // (Vue's twin of useReducer), useId for route-key generation, expose() for the jumpTo/setParams
5
- // handle - plus the descriptor bridge for the tab-bar leaf, exactly like Stack bridges its header
3
+ // the React/Angular adapters; here Vue supplies the lifecycle - a shallowRef for the dispatched
4
+ // router state (Vue's twin of useReducer), useId for route-key generation, expose() for the
5
+ // jumpTo/setParams handle - plus the descriptor bridge for the tab-bar leaf, like Stack's header
6
6
  // config (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
 
10
- import {
11
- defineComponent,
12
- h,
13
- nextTick,
14
- onMounted,
15
- onUnmounted,
16
- shallowRef,
17
- useId,
18
- watch,
19
- } from '@vue/runtime-core';
10
+ import { defineComponent, h, nextTick, onUnmounted, shallowRef, useId } from '@vue/runtime-core';
20
11
  import type { VNode } from '@vue/runtime-core';
21
12
  import { descriptorToVue, normalizeVueAttrs } from '@symbiote-native/vue';
22
13
  import { dlog } from '@symbiote-native/engine';
@@ -28,6 +19,7 @@ import {
28
19
  diffFocusedRoute,
29
20
  isFocusedRoute,
30
21
  isRecord,
22
+ reconcileTabRoutes,
31
23
  renderTabBar,
32
24
  tabRouterReducer,
33
25
  } from '../../core';
@@ -37,6 +29,8 @@ import type {
37
29
  ITabBarItemView,
38
30
  ITabNavigatorHandle,
39
31
  ITabOptions,
32
+ ITabRouterAction,
33
+ ITabRouterState,
40
34
  } from '../../core';
41
35
  import { NavigationScope, injectNavigationScope } from '../navigation-context';
42
36
  import { TabScreen } from '../tab-screen';
@@ -130,12 +124,30 @@ const TabImpl = defineComponent<ITabProps>(
130
124
  const initialRoutes = buildRoutes(initialRegistry);
131
125
  if (initialRoutes.length === 0) dlog('Tab: no <Tab.Screen> children registered');
132
126
 
133
- const state = shallowRef(
134
- createInitialTabState(initialRoutes, asString(attrs.initialRouteName)),
135
- );
127
+ // A <Tab.Screen> can appear or disappear after mount (a marker behind a v-if, a data-driven
128
+ // screen list), so the route list is re-collected from the slot on every render and the
129
+ // DISPATCHED state is reconciled against it (reconcileTabRoutes, core - it preserves each
130
+ // surviving route's key and accumulated params and keeps focus on the same route NAME) rather
131
+ // than staying frozen at whatever setup() first saw. Slot vnodes are not reactive state, so
132
+ // the render fn is the only place that can observe the registry change: the reconciliation
133
+ // happens there, as a pure local, and never writes back - a state write inside a derivation is
134
+ // an error in Vue, not a style choice. `null` means nothing has dispatched yet, which is what
135
+ // keeps initialRouteName honored when the markers arrive after the first render.
136
+ const dispatchedState = shallowRef<ITabRouterState | null>(null);
137
+ // Non-reactive cache of the route list the last render derived: dispatch() reduces over the
138
+ // RECONCILED state, so it needs the same list that paint used. A ref here would be a reactive
139
+ // write from the render fn, exactly what the comment above rules out.
140
+ let latestRoutes: readonly IRoute<unknown>[] = initialRoutes;
141
+
142
+ function resolveState(routes: readonly IRoute<unknown>[]): ITabRouterState {
143
+ const dispatched = dispatchedState.value;
144
+ return dispatched === null
145
+ ? createInitialTabState(routes, asString(attrs.initialRouteName))
146
+ : reconcileTabRoutes(dispatched, routes);
147
+ }
136
148
 
137
- function dispatch(action: Parameters<typeof tabRouterReducer>[1]): void {
138
- state.value = tabRouterReducer(state.value, action);
149
+ function dispatch(action: ITabRouterAction): void {
150
+ dispatchedState.value = tabRouterReducer(resolveState(latestRoutes), action);
139
151
  }
140
152
 
141
153
  const jumpTo = (name: string, params?: unknown): void =>
@@ -146,16 +158,6 @@ const TabImpl = defineComponent<ITabProps>(
146
158
  const handle: ITabNavigatorHandle = { jumpTo, setParams };
147
159
  expose(handle);
148
160
 
149
- // Tab paints its own bar in pure JS - there is no native onAppear/onDisappear to hook (unlike
150
- // Stack's RNSScreen), so focus/blur is synthesized here: emit 'focus' once the newly-focused
151
- // route's content has mounted, 'blur' when it's about to be replaced or Tab itself unmounts.
152
- // Keyed on the route KEY (not the route object) so a setParams-only change (new route object,
153
- // same key) doesn't spuriously re-fire focus/blur - mirrors tabs.ts's React twin exactly,
154
- // just expressed as a `watch` over the focused key instead of a useEffect dependency array.
155
- function focusedKeyOf(current: typeof state.value): string | undefined {
156
- return current.routes[current.index]?.key;
157
- }
158
-
159
161
  // One emitter per route.key, created lazily and cached for the navigator's whole lifetime -
160
162
  // mirrors stack.ts's own `emitters` map. This decouples emitter IDENTITY (stable, looked up
161
163
  // by key, read by the render closure below when it builds a route's NavigationScope) from
@@ -179,43 +181,39 @@ const TabImpl = defineComponent<ITabProps>(
179
181
  // Stack's RNSScreen), so focus/blur is synthesized here: emit 'focus' once the newly-focused
180
182
  // route's content has mounted, 'blur' when it's about to be replaced or Tab itself unmounts.
181
183
  // Keyed on the route KEY (not the route object) so a setParams-only change (new route object,
182
- // same key) doesn't spuriously re-fire focus/blur - mirrors tabs.ts's React twin exactly,
183
- // just expressed as a `watch` over the focused key instead of a useEffect dependency array.
184
- let focusedRouteKey = focusedKeyOf(state.value);
185
-
186
- onMounted(() => {
187
- if (focusedRouteKey !== undefined) {
188
- dlog(`Tab: route "${focusedRouteKey}" focused at t=${Date.now()}`);
189
- emitterFor(focusedRouteKey).emit(NAVIGATION_EVENT_FOCUS);
190
- }
191
- });
192
-
184
+ // same key) doesn't spuriously re-fire focus/blur.
185
+ //
186
+ // Driven from the render pass rather than a `watch` because the focused key follows the
187
+ // SLOT-collected route list too - a marker disappearing can move focus, and slot vnodes are
188
+ // not reactive state, so no watcher source can see that; only a render can. The diff therefore
189
+ // lives where the state is derived, and stays a plain local, never a reactive write.
190
+ //
193
191
  // The bookkeeping (which key is focused) updates immediately so the render closure below
194
192
  // always reads the right route; the actual emit is deferred to nextTick(), which resolves
195
193
  // only after the CURRENT flush cycle fully drains - including the newly-focused screen's own
196
- // onMounted (subscribing its useIsFocused/useFocusEffect listeners). flush:'post' alone is
197
- // NOT enough here: it only guarantees this callback runs after THIS component's own render
198
- // effect, not after an arbitrary sibling post-flush job (a just-mounted child's onMounted)
199
- // that may be queued after it in the same batch - nextTick is the one API that waits for the
200
- // WHOLE cycle, not just this watcher's own slot in it.
201
- watch(
202
- () => focusedKeyOf(state.value),
203
- nextKey => {
204
- const { blurKey, focusKey } = diffFocusedRoute(focusedRouteKey, nextKey);
205
- if (blurKey === undefined && focusKey === undefined) return;
206
- focusedRouteKey = nextKey;
207
- nextTick(() => {
208
- if (blurKey !== undefined) {
209
- dlog(`Tab: route "${blurKey}" blurred at t=${Date.now()}`);
210
- emitterFor(blurKey).emit(NAVIGATION_EVENT_BLUR);
211
- }
212
- if (focusKey !== undefined) {
213
- dlog(`Tab: route "${focusKey}" focused at t=${Date.now()}`);
214
- emitterFor(focusKey).emit(NAVIGATION_EVENT_FOCUS);
215
- }
216
- });
217
- },
218
- );
194
+ // onMounted (subscribing its useIsFocused/useFocusEffect listeners). Emitting inline would
195
+ // reach zero subscribers; flush:'post' would not have been enough either, since it only
196
+ // orders a callback after THIS component's own render effect, not after an arbitrary sibling
197
+ // post-flush job (a just-mounted child's onMounted) queued after it in the same batch.
198
+ let focusedRouteKey: string | undefined;
199
+
200
+ function syncFocus(current: ITabRouterState): void {
201
+ const nextKey = current.routes[current.index]?.key;
202
+ const { blurKey, focusKey } = diffFocusedRoute(focusedRouteKey, nextKey);
203
+ if (blurKey === undefined && focusKey === undefined) return;
204
+ focusedRouteKey = nextKey;
205
+ nextTick(() => {
206
+ if (blurKey !== undefined) {
207
+ dlog(`Tab: route "${blurKey}" blurred at t=${Date.now()}`);
208
+ emitterFor(blurKey).emit(NAVIGATION_EVENT_BLUR);
209
+ }
210
+ if (focusKey !== undefined) {
211
+ dlog(`Tab: route "${focusKey}" focused at t=${Date.now()}`);
212
+ emitterFor(focusKey).emit(NAVIGATION_EVENT_FOCUS);
213
+ }
214
+ });
215
+ }
216
+
219
217
  onUnmounted(() => {
220
218
  if (focusedRouteKey !== undefined) emitterFor(focusedRouteKey).emit(NAVIGATION_EVENT_BLUR);
221
219
  });
@@ -226,11 +224,16 @@ const TabImpl = defineComponent<ITabProps>(
226
224
 
227
225
  if (registry.size === 0) dlog('Tab: no <Tab.Screen> children registered');
228
226
 
229
- const focusedRoute: IRoute<unknown> | undefined = state.value.routes[state.value.index];
227
+ const routes = buildRoutes(registry);
228
+ latestRoutes = routes;
229
+ const state = resolveState(routes);
230
+ syncFocus(state);
231
+
232
+ const focusedRoute: IRoute<unknown> | undefined = state.routes[state.index];
230
233
 
231
- const items: ITabBarItemView[] = state.value.routes.map((route, index) => {
234
+ const items: ITabBarItemView[] = state.routes.map((route, index) => {
232
235
  const entry = registry.get(route.name);
233
- const focused = isFocusedRoute(index, state.value.index);
236
+ const focused = isFocusedRoute(index, state.index);
234
237
  if (entry === undefined) {
235
238
  dlog(`Tab: no screen registered for route name "${route.name}"`);
236
239
  return { key: route.key, focused, label: route.name, passthrough: {} };