@symbiote-native/components 0.3.0 → 0.4.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.
@@ -116,5 +116,9 @@ export function attachStickyScroll(node, value) {
116
116
  const attachment = attachNativeEvent(node, 'onScroll', [
117
117
  { nativeEvent: { contentOffset: { y: value } } },
118
118
  ]);
119
- return () => attachment.detach();
119
+ dlog(`STICKY[attach] attachStickyScroll onScroll -> value#${value.__getNativeTag?.() ?? 'js'}`);
120
+ return () => {
121
+ dlog('STICKY[attach] attachStickyScroll detached');
122
+ attachment.detach();
123
+ };
120
124
  }
@@ -6,6 +6,7 @@ export interface IStickyHeaderState {
6
6
  haveReceivedInitialZeroTranslateY: boolean;
7
7
  inputRange: number[];
8
8
  outputRange: number[];
9
+ rangesEmitted: boolean;
9
10
  }
10
11
  export interface IStickyReducerInputs {
11
12
  os: string;
@@ -30,6 +30,7 @@ export function createInitialStickyState() {
30
30
  haveReceivedInitialZeroTranslateY: true,
31
31
  inputRange: [...IDENTITY_INPUT_RANGE],
32
32
  outputRange: [...IDENTITY_OUTPUT_RANGE],
33
+ rangesEmitted: false,
33
34
  };
34
35
  }
35
36
  // A cheap signature over the render-relevant state (the ranges + the committed translateY). The
@@ -37,6 +38,11 @@ export function createInitialStickyState() {
37
38
  export function stickyEffectSignature(state) {
38
39
  return `${state.inputRange.join(',')}|${state.outputRange.join(',')}|${state.translateY}`;
39
40
  }
41
+ function arraysEqual(a, b) {
42
+ if (a.length !== b.length)
43
+ return false;
44
+ return a.every((value, index) => value === b[index]);
45
+ }
40
46
  // Recompute the derived interpolation ranges off the current state + inputs (wrapping the load-bearing
41
47
  // computeStickyInterpolation math), store them, and return them for the rebuild effect.
42
48
  function deriveRanges(state, inputs) {
@@ -54,27 +60,87 @@ function deriveRanges(state, inputs) {
54
60
  }
55
61
  // The single transition every sticky-header adapter shares. The adapter maps a native event / animated
56
62
  // tick / timer fire to an action, calls this, stores the returned state, and executes the effects.
63
+ // DIAGNOSTIC-only, gated: identifies which header instance a log line belongs to across a
64
+ // device dump without needing full state dumps at every call — layoutY doubles as a stable
65
+ // per-header id once measured (0 before the first layout).
66
+ function headerTag(state) {
67
+ return `y=${state.layoutY}`;
68
+ }
57
69
  export function reduceSticky(state, action, inputs) {
70
+ dlog(`STICKY[reducer ${headerTag(state)}] action=${action.kind}` +
71
+ (action.kind === 'layout' ? ` y=${action.y} height=${action.height}` : '') +
72
+ (action.kind === 'animated-tick' || action.kind === 'debounce-fired'
73
+ ? ` value=${action.value}`
74
+ : '') +
75
+ ` inputs={inverted=${inputs.inverted} scrollViewHeight=${inputs.scrollViewHeight} nextHeaderLayoutY=${inputs.nextHeaderLayoutY}}`);
58
76
  switch (action.kind) {
59
77
  case 'layout': {
60
78
  // Record own y/height, mark measured, rebuild the interpolation, and (when the reducer owns the
61
79
  // cross-talk index) hand the parent this header's y so the PREVIOUS header learns its collision
62
80
  // point. Matches RN ScrollViewStickyHeader.js._onLayout.
81
+ //
82
+ // Redundant-geometry guard: Yoga legitimately re-fires onLayout with the SAME y/height (relayout
83
+ // passes triggered by an unrelated sibling, a native-driven prop commit, ...) — every consumer
84
+ // must tolerate that. React's port gets this guard for FREE: `setLayoutY(sameValue)` is a no-op
85
+ // (React bails out of re-rendering on an unchanged primitive), so a redundant onLayout never
86
+ // reaches the rebuild `useEffect`. This reducer has no such implicit bail-out, so it must skip
87
+ // the rebuild explicitly — device-confirmed (2026-08-13) that omitting this guard lets a
88
+ // native-driven rebuild (fresh AnimatedProps/AnimatedStyle graph, fresh native connect) commit a
89
+ // fresh prop identity on every redundant layout, which can itself provoke another relayout pass —
90
+ // an unbounded same-tick rebuild ping-pong that trips Svelte's effect_update_depth_exceeded guard.
91
+ const alreadyAtThisGeometry = state.measured && state.layoutY === action.y && state.layoutHeight === action.height;
63
92
  state.layoutY = action.y;
64
93
  state.layoutHeight = action.height;
65
94
  state.measured = true;
66
- const { inputRange, outputRange } = deriveRanges(state, inputs);
67
95
  const effects = [];
68
96
  if (inputs.index !== undefined) {
69
97
  effects.push({ kind: 'record-header-y', index: inputs.index, y: action.y });
70
98
  }
99
+ if (alreadyAtThisGeometry && state.rangesEmitted) {
100
+ dlog(`STICKY[reducer ${headerTag(state)}] layout: redundant geometry, skipped rebuild`);
101
+ return { state, effects, changed: effects.length > 0 };
102
+ }
103
+ const { inputRange, outputRange } = deriveRanges(state, inputs);
104
+ state.rangesEmitted = true;
105
+ dlog(`STICKY[reducer ${headerTag(state)}] layout: measured=true inputRange=${JSON.stringify(inputRange)} ` +
106
+ `outputRange=${JSON.stringify(outputRange)}`);
71
107
  effects.push({ kind: 'rebuild-interpolation', inputRange, outputRange });
72
108
  return { state, effects, changed: true };
73
109
  }
74
110
  case 'inputs-changed': {
75
111
  // A collision/viewport input changed (RN effect deps: inverted, scrollViewHeight,
76
112
  // nextHeaderLayoutY): recompute the ranges and rebuild.
113
+ //
114
+ // Redundant-ranges guard (sibling of 'layout's `alreadyAtThisGeometry` above, same root
115
+ // cause): the adapter's own mount `$effect` re-dispatches 'inputs-changed' whenever its
116
+ // `inverted`/`scrollViewHeight`/`nextHeaderLayoutY` derived values re-evaluate — which can
117
+ // happen on an unrelated parent re-render (e.g. VirtualizedList re-deriving
118
+ // `nextHeaderLayoutYFor(cell.index)` off its cross-talk Map on every reactive pass) even when
119
+ // the COMPUTED value is identical. React gets no implicit protection here either (this is a
120
+ // real `useEffect` with real deps), but RN's own deps array only fires on an ACTUAL primitive
121
+ // change; a framework-agnostic caller re-dispatching on every derive needs the reducer itself
122
+ // to compare the RESULT (the ranges), not the raw inputs (which may recompute to the same
123
+ // ranges via different intermediate values) — device-confirmed (2026-08-13) this is a second,
124
+ // independent source of the same unbounded same-tick rebuild loop the 'layout' guard fixed.
125
+ const previousInputRange = state.inputRange;
126
+ const previousOutputRange = state.outputRange;
127
+ const hadEmitted = state.rangesEmitted;
77
128
  const { inputRange, outputRange } = deriveRanges(state, inputs);
129
+ state.rangesEmitted = true;
130
+ // `hadEmitted` is load-bearing, not defensive: an unmeasured header derives exactly the
131
+ // identity ranges the initial state already holds, so without it the FIRST dispatch (Angular
132
+ // sends one from ngOnInit, before any layout) reads as redundant and the header never emits
133
+ // a rebuild at all — its wrapper then never commits. Regression-covered in
134
+ // sticky-header-reducer.test.ts.
135
+ if (hadEmitted &&
136
+ arraysEqual(previousInputRange, inputRange) &&
137
+ arraysEqual(previousOutputRange, outputRange)) {
138
+ dlog(`STICKY[reducer ${headerTag(state)}] inputs-changed: ranges unchanged, skipped rebuild`);
139
+ return { state, effects: [], changed: false };
140
+ }
141
+ dlog(`STICKY[reducer ${headerTag(state)}] inputs-changed: inputRange ${JSON.stringify(previousInputRange)}->` +
142
+ `${JSON.stringify(inputRange)} outputRange ${JSON.stringify(previousOutputRange)}->` +
143
+ `${JSON.stringify(outputRange)}`);
78
144
  return {
79
145
  state,
80
146
  effects: [{ kind: 'rebuild-interpolation', inputRange, outputRange }],
@@ -87,9 +153,11 @@ export function reduceSticky(state, action, inputs) {
87
153
  // settled value into the committed transform for hit-testing.
88
154
  if (action.value === 0 && !state.haveReceivedInitialZeroTranslateY) {
89
155
  state.haveReceivedInitialZeroTranslateY = true;
90
- dlog('sticky-header swallowed re-emitted zero translateY');
156
+ dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: swallowed re-emitted zero translateY`);
91
157
  return { state, effects: [], changed: false };
92
158
  }
159
+ dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: scheduling debounce delay=${stickyDebounceMs(inputs.os)} ` +
160
+ `value=${action.value}`);
93
161
  return {
94
162
  state,
95
163
  effects: [
@@ -101,6 +169,7 @@ export function reduceSticky(state, action, inputs) {
101
169
  case 'debounce-fired': {
102
170
  // The debounce completed: commit the settled translateY. Once a NON-zero value commits, re-arm
103
171
  // the swallow gate so the next interpolation rebuild's spurious 0 is dropped (RN).
172
+ dlog(`STICKY[reducer ${headerTag(state)}] debounce-fired: committing translateY=${action.value}`);
104
173
  state.translateY = action.value;
105
174
  if (action.value !== 0)
106
175
  state.haveReceivedInitialZeroTranslateY = false;
@@ -136,8 +136,10 @@ export interface IListCellPlan {
136
136
  }
137
137
  export interface IListPlan {
138
138
  leadingExtent: number;
139
+ gapExtent: number;
139
140
  trailingExtent: number;
140
141
  cells: IListCellPlan[];
142
+ forcedStickyCell: IListCellPlan | undefined;
141
143
  stickyChildPositions: number[];
142
144
  }
143
145
  export interface IListPlanParams {
@@ -56,8 +56,6 @@ function readNumber(source, key) {
56
56
  function asRecord(value) {
57
57
  return typeof value === 'object' && value !== null ? { ...value } : undefined;
58
58
  }
59
- // onScroll -> the offset along the scroll axis. Vertical reads contentOffset.y,
60
- // horizontal reads contentOffset.x.
61
59
  export function readScrollOffset(event, horizontal) {
62
60
  const native = asRecord(event.nativeEvent);
63
61
  if (native === undefined)
@@ -67,7 +65,6 @@ export function readScrollOffset(event, horizontal) {
67
65
  return undefined;
68
66
  return readNumber(offset, horizontal ? 'x' : 'y');
69
67
  }
70
- // onLayout -> the cross-section length of the box along the scroll axis.
71
68
  export function readLayoutLength(event, horizontal) {
72
69
  const native = asRecord(event.nativeEvent);
73
70
  if (native === undefined)
@@ -279,22 +276,50 @@ export function maxMinimumViewTime(pairs) {
279
276
  }
280
277
  return max;
281
278
  }
282
- // Compute the windowed child PLAN: the two spacer extents, the in-window cells (index +
283
- // key), and the sticky child positions. The adapter walks this plan and creates the host
284
- // elements (createElement / h) plus the framework cell content. This is the shared half of
285
- // the render; only the element creation and the user's renderItem stay per-adapter.
279
+ // Find the nearest sticky index strictly below `first` — the RN _ensureClosestStickyHeader
280
+ // backward walk. Returns NO_INDEX when none exists (no sticky section applies yet, or the
281
+ // applicable one is already inside the window).
282
+ function findClosestStickyIndexBelow(first, stickyIndices) {
283
+ for (let index = first - 1; index >= FIRST_INDEX; index -= 1) {
284
+ if (stickyIndices.has(index))
285
+ return index;
286
+ }
287
+ return NO_INDEX;
288
+ }
289
+ // Compute the windowed child PLAN: the spacer extents, the in-window cells (index + key),
290
+ // the force-mounted sticky cell (if any) ahead of the window, and the sticky child
291
+ // positions. The adapter walks this plan and creates the host elements (createElement / h)
292
+ // plus the framework cell content. This is the shared half of the render; only the element
293
+ // creation and the user's renderItem stay per-adapter.
286
294
  export function buildListPlan(params) {
287
295
  const cells = [];
288
- const leadingExtent = params.first > FIRST_INDEX ? params.offsets[params.first] : EMPTY_OFFSET;
296
+ const closestStickyIndex = params.stickyIndices !== undefined
297
+ ? findClosestStickyIndexBelow(params.first, params.stickyIndices)
298
+ : NO_INDEX;
299
+ const forcedStickyCell = closestStickyIndex === NO_INDEX
300
+ ? undefined
301
+ : { index: closestStickyIndex, key: params.keyFor(closestStickyIndex) };
302
+ const windowLeadingExtent = params.first > FIRST_INDEX ? params.offsets[params.first] : EMPTY_OFFSET;
303
+ const leadingExtent = forcedStickyCell === undefined ? windowLeadingExtent : params.offsets[closestStickyIndex];
304
+ const gapExtent = forcedStickyCell === undefined
305
+ ? EMPTY_OFFSET
306
+ : windowLeadingExtent -
307
+ params.offsets[closestStickyIndex] -
308
+ params.lengths[closestStickyIndex];
289
309
  const renderedExtent = params.last >= params.first
290
310
  ? params.offsets[params.last] + params.lengths[params.last] - params.offsets[params.first]
291
311
  : EMPTY_OFFSET;
292
- const trailingExtent = params.total - leadingExtent - renderedExtent;
312
+ const trailingExtent = params.total - windowLeadingExtent - renderedExtent;
293
313
  const stickyChildPositions = [];
294
314
  // The header (when present) is child 0; the leading spacer (when non-empty) is the next
295
- // child. Each cell is one child; a separator after it (when ItemSeparatorComponent is set
296
- // and this is not the last cell) is another.
315
+ // child; the forced sticky cell (when present) plus its own gap spacer follow. Each cell
316
+ // is one child; a separator after it (when ItemSeparatorComponent is set and this is not
317
+ // the last cell) is another.
297
318
  let childPosition = (params.hasHeader ? 1 : 0) + (leadingExtent > EMPTY_OFFSET ? 1 : 0);
319
+ if (forcedStickyCell !== undefined) {
320
+ stickyChildPositions.push(childPosition);
321
+ childPosition += 1 + (gapExtent > EMPTY_OFFSET ? 1 : 0);
322
+ }
298
323
  for (let index = params.first; index <= params.last; index += 1) {
299
324
  cells.push({ index, key: params.keyFor(index) });
300
325
  if (params.stickyIndices?.has(index) === true)
@@ -303,7 +328,14 @@ export function buildListPlan(params) {
303
328
  if (params.hasSeparators && index < params.last)
304
329
  childPosition += 1;
305
330
  }
306
- return { leadingExtent, trailingExtent, cells, stickyChildPositions };
331
+ return {
332
+ leadingExtent,
333
+ gapExtent,
334
+ trailingExtent,
335
+ cells,
336
+ forcedStickyCell,
337
+ stickyChildPositions,
338
+ };
307
339
  }
308
340
  export function computeMvcpAdjustment(params) {
309
341
  const { minIndexForVisible, count, keyFor } = params;
@@ -1,7 +1,6 @@
1
- // Button: the shared render half (framework-agnostic). Rendered in RN's iOS shape (Button.js): a
2
- // TouchableOpacity wrapping a Text. The pure pieces: the base text style, the role constant, and
3
- // the color fold (caller color tints the label; disabled greys it) live here so every adapter
4
- // paints the identical button. The adapter only composes its TouchableOpacity + Text around them.
1
+ // Button: shared render half (framework-agnostic), matching RN's iOS shape (Button.js): a
2
+ // TouchableOpacity wrapping a Text. Each adapter composes its own TouchableOpacity + Text
3
+ // around the pieces below.
5
4
  const IOS_BUTTON_BLUE = '#007AFF';
6
5
  const IOS_DISABLED_GREY = '#cdcdcd';
7
6
  // RN's Button is accessibilityRole="button"; the role string is a native accessibility enum value.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/components",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Framework-agnostic component logic (state machines + render functions) for SymbioteNative — written once, inherited by every adapter (React, Vue, Angular, ...).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/node": "^26.0.0",
42
- "@symbiote-native/engine": "0.1.7"
42
+ "@symbiote-native/engine": "0.2.0"
43
43
  },
44
44
  "scripts": {
45
45
  "typecheck": "tsc --build",
@@ -1,10 +0,0 @@
1
- import type { ISymbioteNode } from '@symbiote-native/engine';
2
- import type { IScrollViewHandle } from '../../scroll-view-commands';
3
- export interface IScrollRoutingHandle {
4
- flashScrollIndicators(): void;
5
- getNativeScrollRef(): IScrollViewHandle | null;
6
- getScrollableNode(): IScrollViewHandle | null;
7
- getScrollResponder(): IScrollViewHandle | null;
8
- getScrollNode(): ISymbioteNode | null;
9
- recordInteraction(): void;
10
- }
@@ -1,6 +0,0 @@
1
- // The inner-scroll routing tail every windowed-list imperative handle exposes
2
- // (VirtualizedList, VirtualizedSectionList, and via those FlatList/SectionList). Each
3
- // method forwards straight to the underlying ScrollView's own handle/node; neither list
4
- // adds behavior of its own, so the 6 members live here once and both list handle types
5
- // extend this instead of hand-duplicating (and risking drift in) the signatures.
6
- export {};
@@ -1,2 +0,0 @@
1
- import type { ISymbioteEvent } from '@symbiote-native/engine';
2
- export declare function readLayoutField(event: ISymbioteEvent, key: 'width' | 'height' | 'y'): number | undefined;
@@ -1,12 +0,0 @@
1
- // Shared across render-scroll-view's content/frame dimension read (width/height) and
2
- // render-scroll-sticky's header position read (y/height): both pull a single numeric field out of
3
- // an onLayout event's nativeEvent.layout. SymbioteEvent.nativeEvent is Record<string, unknown>, so
4
- // the layout box and the requested field are narrowed at runtime, no cast. A malformed event
5
- // yields undefined (no-op).
6
- export function readLayoutField(event, key) {
7
- const layout = event.nativeEvent.layout;
8
- if (typeof layout !== 'object' || layout === null)
9
- return undefined;
10
- const value = Reflect.get(layout, key);
11
- return typeof value === 'number' ? value : undefined;
12
- }