@sigx/lynx-navigation 0.4.4 → 0.4.5

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.
@@ -13,32 +13,38 @@
13
13
  *
14
14
  * Rules:
15
15
  *
16
- * - **Idle (no transition).** Render the topmost non-overlay entry
17
- * as the base, plus every overlay entry above it. Overlays
18
- * (`modal` / `fullScreen` / `transparent-modal`) keep their
19
- * underneath mounted; cards replace their underneath in the base
20
- * layer.
16
+ * - **Visible region.** Each branch first computes its *visible*
17
+ * region the layers the user can actually see:
18
+ * - **Idle (no transition).** The topmost non-overlay entry as
19
+ * the base, plus every overlay entry above it.
20
+ * - **Card transition.** The underneath entry (parallax) + the
21
+ * top entry (slide), both animated.
22
+ * - **Overlay transition.** The static base..underneath run plus
23
+ * the animated overlay top.
21
24
  *
22
- * - **Card transition.** Two layers: the underneath entry (animated
23
- * with the parallax-card-underneath spec) and the top entry
24
- * (animated with the slide-in-from-right spec). After the
25
- * transition completes, the idle rule kicks in the underneath
26
- * unmounts because the new top becomes the sole base.
25
+ * - **Retention (card-stack screen retention).** Every entry *below*
26
+ * the visible region's base is kept mounted as a `hidden: true`
27
+ * static layer (`display: none`), instead of unmounting. So a card
28
+ * push leaves the covered card mounted, and a pop reveals the
29
+ * already-mounted underneath no rebuild, no lost scroll/UI state
30
+ * (matching native stacks). Overlays already preserved their
31
+ * underneath; this extends the same retain-below-base behaviour to
32
+ * cards, and to entries deeper than the immediate underneath during
33
+ * a deep-stack transition.
27
34
  *
28
- * - **Overlay transition.** The full idle layer stack up through the
29
- * underneath entry stays static (no transform). The animated top
30
- * is the only layer with a transform. After the transition, the
31
- * overlay either joins the static idle stack (push) or unmounts
32
- * (pop).
35
+ * - **Bounds.** `maxRetained` (the `<Stack maxRetainedScreens>` prop)
36
+ * caps how many covered cards stay mounted; `MAX_LAYERS` is the hard
37
+ * renderer-slot cap. Both trim the *deepest* (front) layers, so the
38
+ * visible region at the end of the list is never truncated.
33
39
  *
34
- * The Layer.key for the Stack render is
35
- * `layer-${entry.key}-${animVariant(layer.animation)}`. The variant
36
- * suffix forces a remount when an entry transitions from animated to
37
- * static (or vice versa) `useAnimatedStyle` can't re-bind mid-life,
38
- * so we get a fresh `useAnimatedStyle` call per animation state.
39
- * Modal underneath layers never animate, so they stay statically
40
- * keyed across the modal lifecycle and their state (per-tab Stack,
41
- * scroll, in-flight inputs) survives.
40
+ * The Layer.key for the Stack render is `layer-${entry.key}` — stable
41
+ * across animation phases. `<Layer>` rebinds its transform reactively
42
+ * (via the reactive form of `useAnimatedStyle`) as `animation` flips
43
+ * between a spec and `null`, so the layer never remounts just to change
44
+ * its animation state and screen subtrees survive the transition. The
45
+ * reactive binding dedupes its own register/unregister by signature
46
+ * internally (see `useAnimatedStyle`), so no per-layer variant key is
47
+ * computed here.
42
48
  */
43
49
  import type { SharedValue } from '@sigx/lynx';
44
50
  import { SCREEN_HEIGHT, SCREEN_WIDTH } from './screen-width.js';
@@ -51,6 +57,17 @@ import type {
51
57
 
52
58
  const PARALLAX_FACTOR = 0.3;
53
59
 
60
+ /**
61
+ * Hard cap on how many layers `<Stack>` renders at once. The Stack body
62
+ * emits exactly this many position-stable slots (unrolled), so
63
+ * `computeLayers` must never return more — excess deepest (hidden,
64
+ * retained) layers are trimmed off the front. The slots are mechanical
65
+ * (just verbose), so this can be raised if an app legitimately stacks
66
+ * more; 24 is high enough that normal card apps never hit the trim
67
+ * boundary, while still bounding retained-screen memory.
68
+ */
69
+ export const MAX_LAYERS = 24;
70
+
54
71
  export type LayerAnimation = {
55
72
  axis: 'translateX' | 'translateY';
56
73
  inputRange: readonly [number, number];
@@ -63,25 +80,20 @@ export interface Layer {
63
80
  readonly entry: StackEntry;
64
81
  /** When non-null, the layer's host view binds a `useAnimatedStyle` mapper. */
65
82
  readonly animation: LayerAnimation | null;
83
+ /**
84
+ * Retained-but-covered layer: mounted (so its screen subtree, signals,
85
+ * and scroll offset survive), rendered with `display: none` so it costs
86
+ * no paint/layout while a higher opaque card covers it. Always paired
87
+ * with `animation: null` — a hidden layer never animates. Falsy/omitted
88
+ * = visible.
89
+ */
90
+ readonly hidden?: boolean;
66
91
  }
67
92
 
68
93
  export function isOverlayPresentation(p: Presentation): boolean {
69
94
  return p === 'modal' || p === 'fullScreen' || p === 'transparent-modal';
70
95
  }
71
96
 
72
- /**
73
- * Suffix used in a layer's render key. Stable for the layer's
74
- * lifetime (same entry, same animation kind) and changes when the
75
- * animation transitions on/off so the Layer remounts and rebinds.
76
- */
77
- export function animationVariant(animation: LayerAnimation | null): string {
78
- if (!animation) return 'static';
79
- // Output range alone identifies the transition shape — different
80
- // animations (card-top vs card-underneath vs overlay-top, push vs
81
- // pop) all land on different range tuples.
82
- return `${animation.axis}:${animation.outputRange[0]}->${animation.outputRange[1]}`;
83
- }
84
-
85
97
  /**
86
98
  * Card-presentation transition transforms. `role='top'` is the entry
87
99
  * being pushed/popped; `role='underneath'` is the one parallaxing.
@@ -120,68 +132,109 @@ function overlayTopAnimation(
120
132
  return { axis: 'translateY', inputRange: [0, 1], outputRange: [0, SCREEN_HEIGHT], progress };
121
133
  }
122
134
 
135
+ /** Walk back from `from` past overlay entries to the topmost non-overlay. */
136
+ function nonOverlayBaseIdx(stack: readonly StackEntry[], from: number): number {
137
+ let baseIdx = from;
138
+ while (baseIdx > 0 && isOverlayPresentation(stack[baseIdx].presentation)) {
139
+ baseIdx -= 1;
140
+ }
141
+ return baseIdx;
142
+ }
143
+
123
144
  /**
124
145
  * Compute the visible-layer list for one render of `<Stack>`. Pure —
125
146
  * unit-testable independently of the renderer.
147
+ *
148
+ * Each branch produces `{ visBaseIdx, visible }`: `visible` is the
149
+ * animated/painted region, and `visBaseIdx` is the stack index of that
150
+ * region's lowest layer. Everything in `stack` below `visBaseIdx` is
151
+ * then retained as hidden static layers (see the module docstring).
152
+ *
153
+ * `maxRetained` caps the retained (hidden) layers; `undefined` means
154
+ * "retain all, bounded only by `MAX_LAYERS`".
126
155
  */
127
156
  export function computeLayers(
128
157
  stack: readonly StackEntry[],
129
158
  transition: TransitionState | null,
130
159
  progress: SharedValue<number> | null,
160
+ maxRetained?: number,
131
161
  ): Layer[] {
162
+ let visBaseIdx: number;
163
+ let visible: Layer[];
164
+
132
165
  if (!transition) {
133
166
  // Idle: topmost non-overlay base + any overlays above it.
134
- let baseIdx = stack.length - 1;
135
- while (baseIdx > 0 && isOverlayPresentation(stack[baseIdx].presentation)) {
136
- baseIdx -= 1;
137
- }
138
- return stack.slice(baseIdx).map((entry) => ({ entry, animation: null }));
139
- }
140
-
141
- // A transition is in flight. `progress` may still be null when
142
- // animations are disabled — produce static layers in that case
143
- // (the animation never plays; the transition timer just ticks).
144
- const isOverlay = isOverlayPresentation(transition.topEntry.presentation);
145
- if (!isOverlay) {
146
- // Card transition: just the two participating entries, both
147
- // animated (parallax underneath + slide top).
148
- return [
167
+ visBaseIdx = nonOverlayBaseIdx(stack, stack.length - 1);
168
+ visible = stack
169
+ .slice(visBaseIdx)
170
+ .map((entry) => ({ entry, animation: null, hidden: false }));
171
+ } else if (!isOverlayPresentation(transition.topEntry.presentation)) {
172
+ // Card transition: the two participating entries, both animated
173
+ // (parallax underneath + slide top). `progress` may be null when
174
+ // animations are disabled produce static layers in that case.
175
+ const underneathIdx = stack.findIndex(
176
+ (e) => e.key === transition.underneathEntry.key,
177
+ );
178
+ // Underneath is the visible base. If it isn't on the stack (e.g.
179
+ // mid-pop where the mutation already ran), retain nothing rather
180
+ // than slicing with a negative index.
181
+ visBaseIdx = underneathIdx >= 0 ? underneathIdx : 0;
182
+ visible = [
149
183
  {
150
184
  entry: transition.underneathEntry,
151
185
  animation: progress ? cardAnimation('underneath', transition.kind, progress) : null,
186
+ hidden: false,
152
187
  },
153
188
  {
154
189
  entry: transition.topEntry,
155
190
  animation: progress ? cardAnimation('top', transition.kind, progress) : null,
191
+ hidden: false,
192
+ },
193
+ ];
194
+ } else {
195
+ // Overlay transition: the full layer stack up through the
196
+ // underneath entry stays static (no transform) plus the animated
197
+ // top.
198
+ const underneathIdx = stack.findIndex(
199
+ (e) => e.key === transition.underneathEntry.key,
200
+ );
201
+ // If the underneath isn't in the stack (e.g. mid-pop where the
202
+ // stack mutation already removed an entry), fall back to the
203
+ // current top of the stack.
204
+ const lastStaticIdx = underneathIdx >= 0 ? underneathIdx : stack.length - 1;
205
+ visBaseIdx = nonOverlayBaseIdx(stack, lastStaticIdx);
206
+ const staticLayers: Layer[] = stack
207
+ .slice(visBaseIdx, lastStaticIdx + 1)
208
+ .map((entry) => ({ entry, animation: null, hidden: false }));
209
+ visible = [
210
+ ...staticLayers,
211
+ {
212
+ entry: transition.topEntry,
213
+ animation: progress ? overlayTopAnimation(transition.kind, progress) : null,
214
+ hidden: false,
156
215
  },
157
216
  ];
158
217
  }
159
218
 
160
- // Overlay transition: render the full idle layer stack up through
161
- // the underneath entry (all static they don't animate) plus the
162
- // animated top.
163
- const underneathIdx = stack.findIndex(
164
- (e) => e.key === transition.underneathEntry.key,
165
- );
166
- // If the underneath isn't in the stack (e.g. mid-pop where the
167
- // stack mutation already removed an entry), fall back to the
168
- // current top of the stack.
169
- const lastStaticIdx = underneathIdx >= 0 ? underneathIdx : stack.length - 1;
219
+ // Retention: every entry below the visible base stays mounted as a
220
+ // hidden static layer. Ordered deepest-first so each surviving entry
221
+ // keeps a stable slot index across pushes/pops (the Stack renderer's
222
+ // fixed slots remount keyed children whose slot index shifts).
223
+ const retained: Layer[] = stack
224
+ .slice(0, visBaseIdx)
225
+ .map((entry) => ({ entry, animation: null, hidden: true }));
170
226
 
171
- let baseIdx = lastStaticIdx;
172
- while (baseIdx > 0 && isOverlayPresentation(stack[baseIdx].presentation)) {
173
- baseIdx -= 1;
174
- }
227
+ let result = [...retained, ...visible];
175
228
 
176
- const staticLayers: Layer[] = stack
177
- .slice(baseIdx, lastStaticIdx + 1)
178
- .map((entry) => ({ entry, animation: null }));
229
+ // Apply the user's retention window, then the hard renderer cap.
230
+ // Both trim the deepest (front) layers, so the visible region at the
231
+ // tail is never truncated.
232
+ if (maxRetained != null && retained.length > maxRetained) {
233
+ result = result.slice(retained.length - maxRetained);
234
+ }
235
+ if (result.length > MAX_LAYERS) {
236
+ result = result.slice(result.length - MAX_LAYERS);
237
+ }
179
238
 
180
- return [
181
- ...staticLayers,
182
- {
183
- entry: transition.topEntry,
184
- animation: progress ? overlayTopAnimation(transition.kind, progress) : null,
185
- },
186
- ];
239
+ return result;
187
240
  }
@@ -1,4 +1,5 @@
1
1
  import {
2
+ batch,
2
3
  runOnMainThread,
3
4
  signal,
4
5
  untrack,
@@ -294,21 +295,30 @@ export function createNavigatorState(opts: CreateNavigatorOptions): NavigatorSta
294
295
  const cur = getStack();
295
296
  const prevTop = cur[cur.length - 1];
296
297
 
297
- // Append eagerly — UX-wise the user just initiated a forward nav, so
298
- // the new entry should be queryable immediately (`nav.current` =
299
- // newEntry). The slide animation overlays the visual transition.
300
- setStack([...cur, newEntry]);
301
-
302
298
  const animated = options?.animated !== false && !!progress;
303
- if (!animated) return;
304
299
 
305
- setTransition({
306
- kind: 'push',
307
- topEntry: newEntry,
308
- underneathEntry: prevTop,
309
- progress,
300
+ // Commit the stack append and the transition in a single batch so the
301
+ // Stack renders once with both screens already present. Without the
302
+ // batch, `@sigx/reactivity` flushes the stack write eagerly, producing
303
+ // an intermediate render where only the new top is on the stack and
304
+ // no transition is in flight — `computeLayers` would drop the
305
+ // underneath and the Stack would remount it on the next render.
306
+ // Append eagerly so the new entry is queryable immediately
307
+ // (`nav.current` = newEntry); the slide animation overlays the visual.
308
+ batch(() => {
309
+ setStack([...cur, newEntry]);
310
+ if (animated) {
311
+ setTransition({
312
+ kind: 'push',
313
+ topEntry: newEntry,
314
+ underneathEntry: prevTop,
315
+ progress,
316
+ });
317
+ }
310
318
  });
311
319
 
320
+ if (!animated) return;
321
+
312
322
  animateProgress(1, TRANSITION_DURATION_SEC).then(
313
323
  () => setTransition(null),
314
324
  () => setTransition(null), // best-effort cleanup on animation rejection
@@ -358,15 +368,22 @@ export function createNavigatorState(opts: CreateNavigatorOptions): NavigatorSta
358
368
 
359
369
  animateProgress(1, TRANSITION_DURATION_SEC).then(
360
370
  () => {
361
- setStack(cur.slice(0, cur.length - 1));
362
- setTransition(null);
371
+ // Batch so the commit (drop the popped entry) and clearing the
372
+ // transition land in one render — no intermediate frame where
373
+ // the stack has mutated but the transition is still in flight.
374
+ batch(() => {
375
+ setStack(cur.slice(0, cur.length - 1));
376
+ setTransition(null);
377
+ });
363
378
  },
364
379
  () => {
365
380
  // On animation failure, snap to the destination state anyway —
366
381
  // leaving the popped entry rendered would be more confusing
367
382
  // than skipping the animation.
368
- setStack(cur.slice(0, cur.length - 1));
369
- setTransition(null);
383
+ batch(() => {
384
+ setStack(cur.slice(0, cur.length - 1));
385
+ setTransition(null);
386
+ });
370
387
  },
371
388
  );
372
389
  }
@@ -394,8 +411,10 @@ export function createNavigatorState(opts: CreateNavigatorOptions): NavigatorSta
394
411
  if (state.stack.length === 0) {
395
412
  throw new Error('[lynx-navigation] reset() called with empty stack.');
396
413
  }
397
- setStack([...state.stack]);
398
- setTransition(null);
414
+ batch(() => {
415
+ setStack([...state.stack]);
416
+ setTransition(null);
417
+ });
399
418
  }
400
419
 
401
420
  function dismiss(): void {
@@ -433,10 +452,12 @@ export function createNavigatorState(opts: CreateNavigatorOptions): NavigatorSta
433
452
 
434
453
  function commitBackGesture(): void {
435
454
  const cur = getStack();
436
- if (cur.length >= 2) {
437
- setStack(cur.slice(0, cur.length - 1));
438
- }
439
- setTransition(null);
455
+ batch(() => {
456
+ if (cur.length >= 2) {
457
+ setStack(cur.slice(0, cur.length - 1));
458
+ }
459
+ setTransition(null);
460
+ });
440
461
  }
441
462
 
442
463
  function cancelBackGesture(): void {