@symbiote-native/components 0.2.6 → 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.
package/build/index.d.ts CHANGED
@@ -12,12 +12,14 @@ export type { ISwitchProps, ISwitchViewProps, ISwitchPlatform, ISwitchTrackColor
12
12
  export type { IResponderProps } from './responder-props';
13
13
  export { switchReducer, createInitialSwitchState, shouldSnapBack, valueFromChange, } from './state/switch';
14
14
  export type { ISwitchState, ISwitchAction } from './state/switch';
15
- export { resolveDecelerationRate, selectScrollIntrinsics, readLayoutDimension, didContentSizeChange, SCROLL_VIEW_BASE_HORIZONTAL, SCROLL_VIEW_BASE_VERTICAL, } from './view/render-scroll-view';
16
- export type { IScrollIntrinsics, IContentSize } from './view/render-scroll-view';
15
+ export { resolveDecelerationRate, selectScrollIntrinsics, readLayoutDimension, didContentSizeChange, resolveScrollForwarding, SCROLL_VIEW_BASE_HORIZONTAL, SCROLL_VIEW_BASE_VERTICAL, } from './view/render-scroll-view';
16
+ export type { IScrollIntrinsics, IContentSize, IScrollForwarding, IScrollForwardingInputs, IScrollForwardMode, } from './view/render-scroll-view';
17
17
  export { buildScrollViewHandle, splitLayoutProps, attachStickyScroll, isSymbioteEvent, forwardScrollEvent, } from './scroll-view-commands';
18
18
  export type { IScrollViewHandle } from './scroll-view-commands';
19
19
  export { computeStickyInterpolation, nextStickyHeaderY, stickyDebounceMs, readLayoutNumber, STICKY_HEADER_Z_INDEX, } from './view/render-scroll-sticky';
20
20
  export type { IStickyHeaderProps, IStickyInterpolationParams } from './view/render-scroll-sticky';
21
+ export { reduceSticky, createInitialStickyState, stickyEffectSignature, } from './state/sticky-header-reducer';
22
+ export type { IStickyHeaderState, IStickyReducerInputs, IStickyAction, IStickyEffect, IStickyReduceResult, } from './state/sticky-header-reducer';
21
23
  export type { IImageSource, IImageSourceProp, IResizeMode, IImageProps, IImageViewProps, } from './view/render-image';
22
24
  export { renderImage } from './view/render-image';
23
25
  export type { IImageStatics, IImageSize, IImageCacheStatus } from '@symbiote-native/engine';
@@ -35,8 +37,9 @@ export type { IKeyboardAvoidingBehavior, IMeasuredFrame, IKeyboardFrame, IKeyboa
35
37
  export { createPressHandlers, createPressRuntime, normalizeRect, maxEdge, isTouchWithinRegion, readPoint, computeRegion, rippleProps, DEFAULT_DELAY_LONG_PRESS_MS, DEFAULT_PRESS_RECT_OFFSETS, } from './state/pressable';
36
38
  export type { IPressState, IPressHandler, IPressHandlers, IPressHost, IPressRuntime, IPressMachineConfig, IFrameCallback, IRectOffset, IEdgeInsets, IResponderRegion, IPressableAndroidRippleConfig, IRippleBackground as IPressableRippleBackground, } from './state/pressable';
37
39
  export { buildPressableListeners, resolveDisabledAccessibilityState, noteHoverNoop, shouldSuppressPress, shouldClaimResponder, isTerminationAllowed, } from './view/render-pressable';
38
- export { computePressOutWait, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, RESTING_OPACITY, DEFAULT_HIGHLIGHT_CHILD_OPACITY, DEFAULT_UNDERLAY_COLOR, DEFAULT_MIN_PRESS_DURATION_MS, } from './state/touchable';
39
- export type { IPressTimingProps, ITouchableHandler } from './state/touchable';
40
+ export { computePressOutWait, createTouchableFeedbackRuntime, createTouchableFeedbackHandlers, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, RESTING_OPACITY, DEFAULT_HIGHLIGHT_CHILD_OPACITY, DEFAULT_UNDERLAY_COLOR, DEFAULT_MIN_PRESS_DURATION_MS, } from './state/touchable';
41
+ export type { IPressTimingProps, ITouchableHandler, ITouchableFeedbackRuntime, ITouchableFeedbackConfig, ITouchableFeedbackCallbacks, ITouchableFeedbackHandlers, } from './state/touchable';
42
+ export { highlightPressedStyle } from './view/render-touchable-highlight';
40
43
  export { backgroundProps, canUseNativeForeground, selectableBackground, selectableBackgroundBorderless, rippleBackground, } from './view/render-touchable-native-feedback';
41
44
  export type { INativeFeedbackBackground, IThemeAttrBackground, IRippleBackground, } from './view/render-touchable-native-feedback';
42
45
  export { BUTTON_ACCESSIBILITY_ROLE, buttonTextStyle, resolveButtonTextStyle, } from './view/render-button';
@@ -45,8 +48,10 @@ export { resolveTextInputProps, foldText, foldAutoComplete, foldSubmitBehavior,
45
48
  export type { ITextInputProps, ITextInputHandle, ITextInputSelection, ITextInputEventHandler, ITextInputFoldInput, IFoldedTextInputProps, IInputMode, IEnterKeyHint, ISubmitBehavior, } from './state/text-input';
46
49
  export { renderTextInput } from './view/render-text-input';
47
50
  export type { ITextInputViewProps } from './view/render-text-input';
48
- export { DEFAULT_WINDOW_SIZE, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_END_REACHED_THRESHOLD, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_VIEW_AREA_COVERAGE_PERCENT_THRESHOLD, DEFAULT_START_REACHED_THRESHOLD, FIRST_INDEX, EMPTY_OFFSET, NO_INDEX, NO_CONTENT_LENGTH_SENT, INVERTED_Y_STYLE, INVERTED_X_STYLE, readScrollOffset, readLayoutLength, buildOffsets, computeWindow, throttleWindow, visiblePercent, isCellViewable, offsetForIndex, averageMeasuredLength, highestMeasuredIndex, computeEndReached, computeStartReached, buildViewabilityPairs, computeViewableSet, diffViewable, maxMinimumViewTime, buildListPlan, } from './state/virtualized-list';
49
- export type { ICellLayout, ISeparators, ISeparatorProps, IViewToken, IViewableItemsChangedInfo, IViewabilityConfig, IViewabilityConfigCallbackPair, IVirtualizedListHandle, IViewableSetParams, IListCellPlan, IListPlan, IListPlanParams, } from './state/virtualized-list';
51
+ export { DEFAULT_WINDOW_SIZE, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_END_REACHED_THRESHOLD, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_VIEW_AREA_COVERAGE_PERCENT_THRESHOLD, DEFAULT_START_REACHED_THRESHOLD, FIRST_INDEX, EMPTY_OFFSET, NO_INDEX, NO_CONTENT_LENGTH_SENT, INVERTED_Y_STYLE, INVERTED_X_STYLE, readScrollOffset, readLayoutLength, buildOffsets, computeWindow, throttleWindow, visiblePercent, isCellViewable, offsetForIndex, averageMeasuredLength, highestMeasuredIndex, computeEndReached, computeStartReached, buildViewabilityPairs, computeViewableSet, diffViewable, maxMinimumViewTime, buildListPlan, computeMvcpAdjustment, resolveItemKey, indexOfItem, offsetForEnd, isSeparatorGapInRange, decideEdgeReached, resolveStickySectionHeaders, wrapFixedLayout, resolveAverageLength, } from './state/virtualized-list';
52
+ export type { IMvcpAction, IMvcpAdjustmentParams, IMvcpAdjustmentResult, ICellLayout, ISeparators, ISeparatorProps, IViewToken, IViewableItemsChangedInfo, IViewabilityConfig, IViewabilityConfigCallbackPair, IVirtualizedListHandle, IViewableSetParams, IListCellPlan, IListPlan, IListPlanParams, } from './state/virtualized-list';
53
+ export { reduceList, createInitialListState, listEffectSignature, } from './state/virtualized-list-reducer';
54
+ export type { IListState, IListMetrics, IListReducerInputs, IListAction, IListEffect, IListReduceResult, } from './state/virtualized-list-reducer';
50
55
  export { SINGLE_COLUMN, chunkIntoRows, rowKeyExtractor, expandRowToken, expandRowViewability, lastItemOfRow, firstItemOfRow, } from './state/flat-list';
51
56
  export type { IRow } from './state/flat-list';
52
57
  export { flattenSections, unwrapEntryItem, sectionEntryKey, scrollLocationToFlatIndex, } from './state/section-list';
package/build/index.js CHANGED
@@ -21,9 +21,14 @@ export { switchReducer, createInitialSwitchState, shouldSnapBack, valueFromChang
21
21
  // ScrollView: pure render/command helpers (no state machine, no 3-layer split). The adapter
22
22
  // owns the refs/effects/element assembly and the sticky-header component; these supply the
23
23
  // platform-invariant math and plumbing every adapter shares.
24
- export { resolveDecelerationRate, selectScrollIntrinsics, readLayoutDimension, didContentSizeChange, SCROLL_VIEW_BASE_HORIZONTAL, SCROLL_VIEW_BASE_VERTICAL, } from './view/render-scroll-view.js';
24
+ export { resolveDecelerationRate, selectScrollIntrinsics, readLayoutDimension, didContentSizeChange, resolveScrollForwarding, SCROLL_VIEW_BASE_HORIZONTAL, SCROLL_VIEW_BASE_VERTICAL, } from './view/render-scroll-view.js';
25
25
  export { buildScrollViewHandle, splitLayoutProps, attachStickyScroll, isSymbioteEvent, forwardScrollEvent, } from './scroll-view-commands.js';
26
26
  export { computeStickyInterpolation, nextStickyHeaderY, stickyDebounceMs, readLayoutNumber, STICKY_HEADER_Z_INDEX, } from './view/render-scroll-sticky.js';
27
+ // The per-header sticky effect machine: the one pure state machine every sticky-header consumer drives
28
+ // (React/Vue header component, Angular header component, Angular projection wrapper), folding the
29
+ // zero-swallow gate + debounce-delay pick + rebuild-on-input-change decision out of each adapter's
30
+ // reactive glue. The adapter maps events to actions and executes the effects with its own primitives.
31
+ export { reduceSticky, createInitialStickyState, stickyEffectSignature, } from './state/sticky-header-reducer.js';
27
32
  export { renderImage } from './view/render-image/index.js';
28
33
  export { imageStatics, setImageSourceResolver } from '@symbiote-native/engine';
29
34
  // ImageBackground: render-only composition (absolute-fill image behind, children on top).
@@ -43,7 +48,8 @@ export { createPressHandlers, createPressRuntime, normalizeRect, maxEdge, isTouc
43
48
  export { buildPressableListeners, resolveDisabledAccessibilityState, noteHoverNoop, shouldSuppressPress, shouldClaimResponder, isTerminationAllowed, } from './view/render-pressable/index.js';
44
49
  // Touchable*: shared press-timing constants + the deactivation-floor math (the Animated feedback
45
50
  // itself stays per-adapter), and TouchableNativeFeedback's pure static factories + background map.
46
- export { computePressOutWait, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, RESTING_OPACITY, DEFAULT_HIGHLIGHT_CHILD_OPACITY, DEFAULT_UNDERLAY_COLOR, DEFAULT_MIN_PRESS_DURATION_MS, } from './state/touchable.js';
51
+ export { computePressOutWait, createTouchableFeedbackRuntime, createTouchableFeedbackHandlers, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, RESTING_OPACITY, DEFAULT_HIGHLIGHT_CHILD_OPACITY, DEFAULT_UNDERLAY_COLOR, DEFAULT_MIN_PRESS_DURATION_MS, } from './state/touchable.js';
52
+ export { highlightPressedStyle } from './view/render-touchable-highlight.js';
47
53
  export { backgroundProps, canUseNativeForeground, selectableBackground, selectableBackgroundBorderless, rippleBackground, } from './view/render-touchable-native-feedback.js';
48
54
  // Button: shared base text style, role constant, and color fold (the adapter composes its own
49
55
  // TouchableOpacity + Text).
@@ -59,6 +65,10 @@ export { renderTextInput } from './view/render-text-input.js';
59
65
  // have NO view/render-*.ts (the cell content is the framework's own children, so there is
60
66
  // no Descriptor render fn) — the shared layer for lists is this STATE/logic, reused
61
67
  // verbatim by React and Vue.
62
- export { DEFAULT_WINDOW_SIZE, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_END_REACHED_THRESHOLD, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_VIEW_AREA_COVERAGE_PERCENT_THRESHOLD, DEFAULT_START_REACHED_THRESHOLD, FIRST_INDEX, EMPTY_OFFSET, NO_INDEX, NO_CONTENT_LENGTH_SENT, INVERTED_Y_STYLE, INVERTED_X_STYLE, readScrollOffset, readLayoutLength, buildOffsets, computeWindow, throttleWindow, visiblePercent, isCellViewable, offsetForIndex, averageMeasuredLength, highestMeasuredIndex, computeEndReached, computeStartReached, buildViewabilityPairs, computeViewableSet, diffViewable, maxMinimumViewTime, buildListPlan, } from './state/virtualized-list.js';
68
+ export { DEFAULT_WINDOW_SIZE, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_END_REACHED_THRESHOLD, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_VIEW_AREA_COVERAGE_PERCENT_THRESHOLD, DEFAULT_START_REACHED_THRESHOLD, FIRST_INDEX, EMPTY_OFFSET, NO_INDEX, NO_CONTENT_LENGTH_SENT, INVERTED_Y_STYLE, INVERTED_X_STYLE, readScrollOffset, readLayoutLength, buildOffsets, computeWindow, throttleWindow, visiblePercent, isCellViewable, offsetForIndex, averageMeasuredLength, highestMeasuredIndex, computeEndReached, computeStartReached, buildViewabilityPairs, computeViewableSet, diffViewable, maxMinimumViewTime, buildListPlan, computeMvcpAdjustment, resolveItemKey, indexOfItem, offsetForEnd, isSeparatorGapInRange, decideEdgeReached, resolveStickySectionHeaders, wrapFixedLayout, resolveAverageLength, } from './state/virtualized-list.js';
69
+ // The orchestration reducer: the one pure state machine every list adapter drives, folding the
70
+ // after-commit effect skeleton (window recompute -> edge -> viewability -> initial-scroll -> MVCP)
71
+ // out of each adapter's reactive glue. The adapter maps events to actions and runs the effects.
72
+ export { reduceList, createInitialListState, listEffectSignature, } from './state/virtualized-list-reducer.js';
63
73
  export { SINGLE_COLUMN, chunkIntoRows, rowKeyExtractor, expandRowToken, expandRowViewability, lastItemOfRow, firstItemOfRow, } from './state/flat-list.js';
64
74
  export { flattenSections, unwrapEntryItem, sectionEntryKey, scrollLocationToFlatIndex, } from './state/section-list.js';
@@ -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
  }
@@ -0,0 +1,54 @@
1
+ export interface IStickyHeaderState {
2
+ measured: boolean;
3
+ layoutY: number;
4
+ layoutHeight: number;
5
+ translateY: number | null;
6
+ haveReceivedInitialZeroTranslateY: boolean;
7
+ inputRange: number[];
8
+ outputRange: number[];
9
+ rangesEmitted: boolean;
10
+ }
11
+ export interface IStickyReducerInputs {
12
+ os: string;
13
+ inverted: boolean | undefined;
14
+ scrollViewHeight: number | undefined;
15
+ nextHeaderLayoutY: number | undefined;
16
+ index?: number;
17
+ }
18
+ export type IStickyAction = {
19
+ kind: 'layout';
20
+ y: number;
21
+ height: number;
22
+ } | {
23
+ kind: 'inputs-changed';
24
+ } | {
25
+ kind: 'animated-tick';
26
+ value: number;
27
+ } | {
28
+ kind: 'debounce-fired';
29
+ value: number;
30
+ };
31
+ export type IStickyEffect = {
32
+ kind: 'rebuild-interpolation';
33
+ inputRange: number[];
34
+ outputRange: number[];
35
+ } | {
36
+ kind: 'schedule-debounce';
37
+ delay: number;
38
+ value: number;
39
+ } | {
40
+ kind: 'apply-passthrough';
41
+ translateY: number;
42
+ } | {
43
+ kind: 'record-header-y';
44
+ index: number;
45
+ y: number;
46
+ };
47
+ export interface IStickyReduceResult {
48
+ state: IStickyHeaderState;
49
+ effects: IStickyEffect[];
50
+ changed: boolean;
51
+ }
52
+ export declare function createInitialStickyState(): IStickyHeaderState;
53
+ export declare function stickyEffectSignature(state: IStickyHeaderState): string;
54
+ export declare function reduceSticky(state: IStickyHeaderState, action: IStickyAction, inputs: IStickyReducerInputs): IStickyReduceResult;
@@ -0,0 +1,183 @@
1
+ // Sticky-header effect machine: the framework-agnostic STATE MACHINE that folds every per-adapter
2
+ // sticky-header effect skeleton into one place. Before this, each adapter (React useState/useEffect,
3
+ // Vue refs/watchEffect, Angular fields/ngOnChanges — and, in Angular, a SECOND copy inside the
4
+ // projection wrapper) re-wrote the same per-header sequence — gate the freshly-rebuilt
5
+ // interpolation's spurious zero, debounce the settled translateY, rebuild the top/inverted ranges on
6
+ // a layout/collision-input change — in its own reactive dialect, so the zero-swallow gate, the
7
+ // debounce-delay pick, and the rebuild decision lived FOUR times and quietly drifted.
8
+ //
9
+ // Here the whole decision half is one pure `reduceSticky(state, action, inputs) -> {state, effects}`,
10
+ // PER sticky header. The adapter keeps only what is genuinely framework-bound: translate a native
11
+ // event / animated tick / timer fire into an ACTION, hold ONE state cell, and EXECUTE the returned
12
+ // EFFECTS with its own primitives (build the interpolation node + wire addListener/removeListener,
13
+ // hold the debounce setTimeout, trigger its own re-render, record the cross-talk y). The math leaf
14
+ // (computeStickyInterpolation) and the debounce-window pick (stickyDebounceMs) still live in
15
+ // ./view/render-scroll-sticky; this module composes them into the ordered transition every adapter
16
+ // shares. Ported from ScrollViewStickyHeader.js's effect.
17
+ import { dlog } from '@symbiote-native/engine';
18
+ import { computeStickyInterpolation, stickyDebounceMs } from '../view/render-scroll-sticky.js';
19
+ // The un-measured identity interpolation (RN: a fresh AnimatedInterpolation before the header has
20
+ // measured its own y/height). Kept as the reset the initial state and every rebuild start from.
21
+ const IDENTITY_INPUT_RANGE = [-1, 0];
22
+ const IDENTITY_OUTPUT_RANGE = [0, 0];
23
+ const NO_TRANSLATE = null;
24
+ export function createInitialStickyState() {
25
+ return {
26
+ measured: false,
27
+ layoutY: 0,
28
+ layoutHeight: 0,
29
+ translateY: NO_TRANSLATE,
30
+ haveReceivedInitialZeroTranslateY: true,
31
+ inputRange: [...IDENTITY_INPUT_RANGE],
32
+ outputRange: [...IDENTITY_OUTPUT_RANGE],
33
+ rangesEmitted: false,
34
+ };
35
+ }
36
+ // A cheap signature over the render-relevant state (the ranges + the committed translateY). The
37
+ // adapter can skip re-wiring when it is unchanged. Shared so the key CANNOT drift between adapters.
38
+ export function stickyEffectSignature(state) {
39
+ return `${state.inputRange.join(',')}|${state.outputRange.join(',')}|${state.translateY}`;
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
+ }
46
+ // Recompute the derived interpolation ranges off the current state + inputs (wrapping the load-bearing
47
+ // computeStickyInterpolation math), store them, and return them for the rebuild effect.
48
+ function deriveRanges(state, inputs) {
49
+ const { inputRange, outputRange } = computeStickyInterpolation({
50
+ measured: state.measured,
51
+ inverted: inputs.inverted,
52
+ scrollViewHeight: inputs.scrollViewHeight,
53
+ layoutY: state.layoutY,
54
+ layoutHeight: state.layoutHeight,
55
+ nextHeaderLayoutY: inputs.nextHeaderLayoutY,
56
+ });
57
+ state.inputRange = inputRange;
58
+ state.outputRange = outputRange;
59
+ return { inputRange, outputRange };
60
+ }
61
+ // The single transition every sticky-header adapter shares. The adapter maps a native event / animated
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
+ }
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}}`);
76
+ switch (action.kind) {
77
+ case 'layout': {
78
+ // Record own y/height, mark measured, rebuild the interpolation, and (when the reducer owns the
79
+ // cross-talk index) hand the parent this header's y so the PREVIOUS header learns its collision
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;
92
+ state.layoutY = action.y;
93
+ state.layoutHeight = action.height;
94
+ state.measured = true;
95
+ const effects = [];
96
+ if (inputs.index !== undefined) {
97
+ effects.push({ kind: 'record-header-y', index: inputs.index, y: action.y });
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)}`);
107
+ effects.push({ kind: 'rebuild-interpolation', inputRange, outputRange });
108
+ return { state, effects, changed: true };
109
+ }
110
+ case 'inputs-changed': {
111
+ // A collision/viewport input changed (RN effect deps: inverted, scrollViewHeight,
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;
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)}`);
144
+ return {
145
+ state,
146
+ effects: [{ kind: 'rebuild-interpolation', inputRange, outputRange }],
147
+ changed: true,
148
+ };
149
+ }
150
+ case 'animated-tick': {
151
+ // A freshly-rebuilt interpolation re-emits 0 to its listeners; swallow that first zero once a
152
+ // real value has committed (RN). Otherwise schedule the host-tuned debounce that pushes the
153
+ // settled value into the committed transform for hit-testing.
154
+ if (action.value === 0 && !state.haveReceivedInitialZeroTranslateY) {
155
+ state.haveReceivedInitialZeroTranslateY = true;
156
+ dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: swallowed re-emitted zero translateY`);
157
+ return { state, effects: [], changed: false };
158
+ }
159
+ dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: scheduling debounce delay=${stickyDebounceMs(inputs.os)} ` +
160
+ `value=${action.value}`);
161
+ return {
162
+ state,
163
+ effects: [
164
+ { kind: 'schedule-debounce', delay: stickyDebounceMs(inputs.os), value: action.value },
165
+ ],
166
+ changed: false,
167
+ };
168
+ }
169
+ case 'debounce-fired': {
170
+ // The debounce completed: commit the settled translateY. Once a NON-zero value commits, re-arm
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}`);
173
+ state.translateY = action.value;
174
+ if (action.value !== 0)
175
+ state.haveReceivedInitialZeroTranslateY = false;
176
+ return {
177
+ state,
178
+ effects: [{ kind: 'apply-passthrough', translateY: action.value }],
179
+ changed: true,
180
+ };
181
+ }
182
+ }
183
+ }
@@ -1,4 +1,4 @@
1
- import type { ISymbioteEvent } from '@symbiote-native/engine';
1
+ import { type ISymbioteEvent } from '@symbiote-native/engine';
2
2
  export declare const DEFAULT_ACTIVE_OPACITY = 0.2;
3
3
  export declare const OPACITY_ACTIVE_DURATION_MS = 150;
4
4
  export declare const OPACITY_INACTIVE_DURATION_MS = 250;
@@ -13,3 +13,24 @@ export interface IPressTimingProps {
13
13
  minPressDuration?: number;
14
14
  }
15
15
  export declare function computePressOutWait(heldFor: number, minPressDuration: number, delayPressOut: number): number;
16
+ export interface ITouchableFeedbackRuntime {
17
+ pressInTimerCancel: (() => void) | undefined;
18
+ activatedAt: number | undefined;
19
+ }
20
+ export declare function createTouchableFeedbackRuntime(): ITouchableFeedbackRuntime;
21
+ export interface ITouchableFeedbackCallbacks {
22
+ activate: (event: ISymbioteEvent) => void;
23
+ deactivate: (event: ISymbioteEvent) => void;
24
+ }
25
+ export interface ITouchableFeedbackConfig {
26
+ delayPressIn: number;
27
+ delayPressOut: number;
28
+ minPressDuration: number;
29
+ schedule: (callback: () => void, ms: number) => () => void;
30
+ now: () => number;
31
+ }
32
+ export interface ITouchableFeedbackHandlers {
33
+ handlePressIn: ITouchableHandler;
34
+ handlePressOut: ITouchableHandler;
35
+ }
36
+ export declare function createTouchableFeedbackHandlers(config: ITouchableFeedbackConfig, runtime: ITouchableFeedbackRuntime, callbacks: ITouchableFeedbackCallbacks): ITouchableFeedbackHandlers;
@@ -3,6 +3,7 @@
3
3
  // (delayPressIn/delayPressOut/minPressDuration) and the deactivation floor math. The Animated
4
4
  // feedback itself is framework (each adapter's Animated namespace), so it stays in the adapter;
5
5
  // only the timing constants + the pure wait computation live here.
6
+ import { dlog } from '@symbiote-native/engine';
6
7
  // TouchableOpacity.js: _opacityActive(150)/_opacityInactive(250), activeOpacity 0.2.
7
8
  export const DEFAULT_ACTIVE_OPACITY = 0.2;
8
9
  export const OPACITY_ACTIVE_DURATION_MS = 150;
@@ -20,3 +21,64 @@ export const DEFAULT_MIN_PRESS_DURATION_MS = 130;
20
21
  export function computePressOutWait(heldFor, minPressDuration, delayPressOut) {
21
22
  return Math.max(minPressDuration - heldFor, delayPressOut);
22
23
  }
24
+ export function createTouchableFeedbackRuntime() {
25
+ return { pressInTimerCancel: undefined, activatedAt: undefined };
26
+ }
27
+ // Build the two press handlers over config + runtime + the adapter's activate/deactivate callbacks.
28
+ // The whole TouchableOpacity press-scheduling dance (delayPressIn defer, flush-on-early-release,
29
+ // activatedAt tracking, the minPressDuration/delayPressOut hold) lives here, shared by every
30
+ // adapter. The adapter rebuilds/holds the callbacks (they capture live config + the Animated Value)
31
+ // while the runtime persists across renders. Twin of Pressable's createPressHandlers.
32
+ export function createTouchableFeedbackHandlers(config, runtime, callbacks) {
33
+ const { delayPressIn, delayPressOut, minPressDuration, schedule, now } = config;
34
+ const { activate, deactivate } = callbacks;
35
+ function clearPressInTimer() {
36
+ if (runtime.pressInTimerCancel !== undefined) {
37
+ runtime.pressInTimerCancel();
38
+ runtime.pressInTimerCancel = undefined;
39
+ }
40
+ }
41
+ // The real activation: stamp the activation clock (for the press-out floor) then run the adapter's
42
+ // opacity fade + onPressIn. Split out so delayPressIn can defer it behind a timer that an early
43
+ // release still flushes.
44
+ function doActivate(event) {
45
+ runtime.activatedAt = now();
46
+ activate(event);
47
+ }
48
+ function doDeactivate(event) {
49
+ runtime.activatedAt = undefined;
50
+ deactivate(event);
51
+ }
52
+ return {
53
+ // RN's _createPressabilityConfig forwards delayPressIn: defer the active visual and onPressIn
54
+ // behind the delay (a release before it elapses flushes it synchronously).
55
+ handlePressIn(event) {
56
+ if (delayPressIn > 0) {
57
+ dlog(`TouchableOpacity pressIn deferred ${delayPressIn}ms`);
58
+ runtime.pressInTimerCancel = schedule(() => {
59
+ runtime.pressInTimerCancel = undefined;
60
+ doActivate(event);
61
+ }, delayPressIn);
62
+ return;
63
+ }
64
+ doActivate(event);
65
+ },
66
+ // delayPressOut + minPressDuration (RN _deactivate): the press-out waits at least
67
+ // minPressDuration past activation (so a fast tap holds the active visual) and at least
68
+ // delayPressOut, whichever is longer.
69
+ handlePressOut(event) {
70
+ if (runtime.pressInTimerCancel !== undefined) {
71
+ clearPressInTimer();
72
+ doActivate(event);
73
+ }
74
+ const heldFor = runtime.activatedAt === undefined ? 0 : now() - runtime.activatedAt;
75
+ const wait = computePressOutWait(heldFor, minPressDuration, delayPressOut);
76
+ if (wait > 0) {
77
+ dlog(`TouchableOpacity pressOut deferred ${wait}ms`);
78
+ schedule(() => doDeactivate(event), wait);
79
+ return;
80
+ }
81
+ doDeactivate(event);
82
+ },
83
+ };
84
+ }
@@ -0,0 +1,129 @@
1
+ import { type ICellLayout, type IViewToken, type IViewabilityConfigCallbackPair, type IViewableItemsChangedInfo } from './virtualized-list';
2
+ export interface IListMetrics {
3
+ count: number;
4
+ offsets: number[];
5
+ lengths: number[];
6
+ total: number;
7
+ first: number;
8
+ last: number;
9
+ target: {
10
+ first: number;
11
+ last: number;
12
+ };
13
+ averageLength: number;
14
+ fixedLayout: ((index: number) => ICellLayout) | undefined;
15
+ }
16
+ export interface IListState<ItemT> {
17
+ scrollOffset: number;
18
+ viewportLength: number;
19
+ measured: Map<number, number>;
20
+ committedWindow: {
21
+ first: number;
22
+ last: number;
23
+ };
24
+ sentEndForContentLength: number;
25
+ sentStartForContentLength: number;
26
+ lastViewable: Map<string, IViewToken<ItemT>>;
27
+ hasInteracted: boolean;
28
+ firstVisibleKey: string | null;
29
+ appliedInitialScroll: boolean;
30
+ metrics: IListMetrics;
31
+ }
32
+ export interface IListReducerInputs<ItemT> {
33
+ data: unknown;
34
+ getItem: (data: unknown, index: number) => ItemT;
35
+ getItemCount: (data: unknown) => number;
36
+ keyExtractor?: (item: ItemT, index: number) => string;
37
+ getItemLayout?: (data: unknown, index: number) => {
38
+ length: number;
39
+ offset: number;
40
+ index: number;
41
+ };
42
+ horizontal: boolean;
43
+ windowSize: number;
44
+ initialNumToRender: number;
45
+ maxToRenderPerBatch: number;
46
+ updateCellsBatchingPeriod: number;
47
+ onEndReachedThreshold: number;
48
+ onStartReachedThreshold: number;
49
+ onEndReachedActive: boolean;
50
+ onStartReachedActive: boolean;
51
+ viewabilityPairs: IViewabilityConfigCallbackPair<ItemT>[];
52
+ maintainVisibleContentPosition?: {
53
+ minIndexForVisible: number;
54
+ autoscrollToTopThreshold?: number;
55
+ };
56
+ initialScrollIndex?: number;
57
+ }
58
+ export type IListAction<ItemT> = {
59
+ kind: 'scroll';
60
+ offset: number;
61
+ } | {
62
+ kind: 'layout';
63
+ length: number;
64
+ } | {
65
+ kind: 'measure';
66
+ index: number;
67
+ length: number;
68
+ } | {
69
+ kind: 'refresh-metrics';
70
+ } | {
71
+ kind: 'batch-tick';
72
+ } | {
73
+ kind: 'record-interaction';
74
+ } | {
75
+ kind: 'viewable-fired';
76
+ map: Map<string, IViewToken<ItemT>>;
77
+ } | {
78
+ kind: 'commit';
79
+ } | {
80
+ kind: 'scroll-to-offset';
81
+ offset: number;
82
+ animated: boolean;
83
+ } | {
84
+ kind: 'scroll-to-index';
85
+ index: number;
86
+ animated: boolean;
87
+ viewPosition: number;
88
+ viewOffset: number;
89
+ } | {
90
+ kind: 'scroll-to-item';
91
+ item: unknown;
92
+ animated: boolean;
93
+ viewPosition: number;
94
+ } | {
95
+ kind: 'scroll-to-end';
96
+ animated: boolean;
97
+ };
98
+ export type IListEffect<ItemT> = {
99
+ kind: 'scroll-to';
100
+ offset: number;
101
+ animated: boolean;
102
+ } | {
103
+ kind: 'fire-end-reached';
104
+ distanceFromEnd: number;
105
+ } | {
106
+ kind: 'fire-start-reached';
107
+ distanceFromStart: number;
108
+ } | {
109
+ kind: 'fire-viewable';
110
+ info: IViewableItemsChangedInfo<ItemT>;
111
+ delay: number;
112
+ map: Map<string, IViewToken<ItemT>>;
113
+ } | {
114
+ kind: 'schedule-refill';
115
+ delay: number;
116
+ } | {
117
+ kind: 'fire-scroll-to-index-failed';
118
+ index: number;
119
+ highestMeasuredFrameIndex: number;
120
+ averageItemLength: number;
121
+ };
122
+ export interface IListReduceResult<ItemT> {
123
+ state: IListState<ItemT>;
124
+ effects: IListEffect<ItemT>[];
125
+ changed: boolean;
126
+ }
127
+ export declare function createInitialListState<ItemT>(): IListState<ItemT>;
128
+ export declare function listEffectSignature<ItemT>(state: IListState<ItemT>): string;
129
+ export declare function reduceList<ItemT>(state: IListState<ItemT>, action: IListAction<ItemT>, inputs: IListReducerInputs<ItemT>): IListReduceResult<ItemT>;
@@ -0,0 +1,292 @@
1
+ // VirtualizedList orchestration reducer: the framework-agnostic STATE MACHINE that folds every
2
+ // per-adapter effect skeleton into one place. Before this, each adapter (React useEffect, Vue
3
+ // watch, Angular ngAfterViewChecked) re-wrote the same sequence — recompute the window, gate
4
+ // onEndReached on `last === count - 1`, dedup by content length, run viewability, apply MVCP — in
5
+ // its own reactive dialect, and the predicates in that glue (`last === count - 1`, `first === 0`,
6
+ // the batch-fill catch-up test, the viewability guards) lived THREE times and quietly drifted.
7
+ //
8
+ // Here the whole decision half is one pure `reduceList(state, action, inputs) -> {state, effects}`.
9
+ // The adapter keeps only what is genuinely framework-bound: translate a native event into an
10
+ // ACTION, hold ONE state cell, and EXECUTE the returned EFFECTS with its own primitives (a native
11
+ // scrollTo, a callback/emit, a setTimeout, a re-render). The geometry leaves (buildOffsets /
12
+ // computeWindow / computeMvcpAdjustment / …) still live in ./virtualized-list; this module composes
13
+ // them into the ordered transition every adapter shares.
14
+ //
15
+ // Effect EXECUTION stays per-adapter by design: which framework hook fires the commit, how a
16
+ // native scrollTo is dispatched, how a debounce timer is held — an effect-list DESCRIBES the work,
17
+ // it does not run it. State TRANSITIONS (including the derived window metrics) are owned entirely
18
+ // here, so a windowing / edge / viewability / MVCP bug — and the drift between three copies of it —
19
+ // is fixed once for all adapters.
20
+ import { dlog } from '@symbiote-native/engine';
21
+ import { EMPTY_OFFSET, FIRST_INDEX, NO_CONTENT_LENGTH_SENT, NO_INDEX, buildOffsets, computeEndReached, computeMvcpAdjustment, computeStartReached, computeViewableSet, computeWindow, decideEdgeReached, diffViewable, highestMeasuredIndex, indexOfItem, maxMinimumViewTime, offsetForEnd, offsetForIndex, resolveAverageLength, resolveItemKey, throttleWindow, wrapFixedLayout, } from './virtualized-list.js';
22
+ export function createInitialListState() {
23
+ return {
24
+ scrollOffset: EMPTY_OFFSET,
25
+ viewportLength: EMPTY_OFFSET,
26
+ measured: new Map(),
27
+ committedWindow: { first: FIRST_INDEX, last: NO_INDEX },
28
+ sentEndForContentLength: NO_CONTENT_LENGTH_SENT,
29
+ sentStartForContentLength: NO_CONTENT_LENGTH_SENT,
30
+ lastViewable: new Map(),
31
+ hasInteracted: false,
32
+ firstVisibleKey: null,
33
+ appliedInitialScroll: false,
34
+ metrics: {
35
+ count: EMPTY_OFFSET,
36
+ offsets: [],
37
+ lengths: [],
38
+ total: EMPTY_OFFSET,
39
+ first: FIRST_INDEX,
40
+ last: NO_INDEX,
41
+ target: { first: FIRST_INDEX, last: NO_INDEX },
42
+ averageLength: EMPTY_OFFSET,
43
+ fixedLayout: undefined,
44
+ },
45
+ };
46
+ }
47
+ // A cheap signature over the render-relevant state. The adapter skips the after-commit pass when it
48
+ // is unchanged (the same dedup Angular's lastEffectSignature did), so the batch-fill timer is not
49
+ // thrashed by unrelated re-renders. Shared so the key CANNOT drift between adapters.
50
+ export function listEffectSignature(state) {
51
+ const m = state.metrics;
52
+ return `${state.scrollOffset}|${state.viewportLength}|${m.first}|${m.last}|${m.count}|${m.total}`;
53
+ }
54
+ // Recompute the derived window metrics off the current state + inputs. Owns the controlled
55
+ // committedWindow throttle (the side effect React ran during render): committedWindow is plain
56
+ // state, so growing it toward target one batch-step at a time triggers no reactivity loop.
57
+ function deriveMetrics(state, inputs) {
58
+ const count = inputs.getItemCount(inputs.data);
59
+ const fixedLayout = wrapFixedLayout(inputs.data, inputs.getItemLayout);
60
+ const averageLength = resolveAverageLength(fixedLayout, count, state.measured);
61
+ const { offsets, lengths, total } = buildOffsets(count, state.measured, fixedLayout, averageLength);
62
+ const target = computeWindow(count, offsets, lengths, state.scrollOffset, state.viewportLength, inputs.windowSize, inputs.initialNumToRender);
63
+ const throttled = throttleWindow(target, state.committedWindow, inputs.maxToRenderPerBatch);
64
+ state.committedWindow = throttled;
65
+ state.metrics = {
66
+ count,
67
+ offsets,
68
+ lengths,
69
+ total,
70
+ first: throttled.first,
71
+ last: throttled.last,
72
+ target,
73
+ averageLength,
74
+ fixedLayout,
75
+ };
76
+ return state;
77
+ }
78
+ function keyForOf(inputs) {
79
+ return (index) => resolveItemKey(inputs.getItem(inputs.data, index), index, inputs.keyExtractor);
80
+ }
81
+ // The after-render pass: every deferred effect, in the order 2 of 3 adapters already ran them
82
+ // (batch-fill -> end -> start -> viewability -> initial-scroll -> MVCP). Each is guarded by its own
83
+ // dedup state (sent*ForContentLength, lastViewable, appliedInitialScroll, firstVisibleKey), so
84
+ // running commit on every render is safe — the guards prevent a redundant fire.
85
+ function commitList(state, inputs) {
86
+ const effects = [];
87
+ const m = state.metrics;
88
+ // Batch fill: when the throttled window has not reached the target, ask for another render tick so
89
+ // it keeps filling toward target (RN's incremental fill).
90
+ if (!(m.first <= m.target.first && m.last >= m.target.last)) {
91
+ effects.push({ kind: 'schedule-refill', delay: inputs.updateCellsBatchingPeriod });
92
+ }
93
+ // onEndReached: fire only when the actual last cell is rendered AND within threshold; dedup by
94
+ // content length; re-arm on scroll away from the end (RN _maybeCallOnEdgeReached).
95
+ if (inputs.onEndReachedActive && state.viewportLength > EMPTY_OFFSET) {
96
+ const { distanceFromEnd, withinThreshold } = computeEndReached(m.total, state.scrollOffset, state.viewportLength, inputs.onEndReachedThreshold);
97
+ const decision = decideEdgeReached({
98
+ withinThreshold,
99
+ edgeCellRendered: m.last === m.count - 1,
100
+ total: m.total,
101
+ sentForContentLength: state.sentEndForContentLength,
102
+ });
103
+ state.sentEndForContentLength = decision.nextSentForContentLength;
104
+ if (decision.shouldFire) {
105
+ dlog(`VirtualizedList onEndReached distanceFromEnd=${distanceFromEnd} ` +
106
+ `(last=${m.last} of ${m.count}, contentLength=${m.total})`);
107
+ effects.push({ kind: 'fire-end-reached', distanceFromEnd });
108
+ }
109
+ }
110
+ // onStartReached: the top-edge twin of onEndReached.
111
+ if (inputs.onStartReachedActive && state.viewportLength > EMPTY_OFFSET) {
112
+ const { distanceFromStart, withinThreshold } = computeStartReached(state.scrollOffset, state.viewportLength, inputs.onStartReachedThreshold);
113
+ const decision = decideEdgeReached({
114
+ withinThreshold,
115
+ edgeCellRendered: m.first === FIRST_INDEX,
116
+ total: m.total,
117
+ sentForContentLength: state.sentStartForContentLength,
118
+ });
119
+ state.sentStartForContentLength = decision.nextSentForContentLength;
120
+ if (decision.shouldFire) {
121
+ dlog(`VirtualizedList onStartReached distanceFromStart=${distanceFromStart} ` +
122
+ `(first=${m.first}, contentLength=${m.total})`);
123
+ effects.push({ kind: 'fire-start-reached', distanceFromStart });
124
+ }
125
+ }
126
+ // Viewability: reclassify the rendered cells; if the viewable set changed, hand the adapter an
127
+ // info payload to fire (after minimumViewTime, if any). lastViewable is folded back only when the
128
+ // fire actually lands (the 'viewable-fired' action), so a debounce that is superseded mid-flight
129
+ // still diffs against the last COMMITTED set.
130
+ if (inputs.viewabilityPairs.length > EMPTY_OFFSET &&
131
+ state.viewportLength > EMPTY_OFFSET &&
132
+ m.count !== FIRST_INDEX) {
133
+ const { tokens, map } = computeViewableSet({
134
+ first: m.first,
135
+ last: m.last,
136
+ count: m.count,
137
+ offsets: m.offsets,
138
+ lengths: m.lengths,
139
+ scrollOffset: state.scrollOffset,
140
+ viewportLength: state.viewportLength,
141
+ data: inputs.data,
142
+ getItem: inputs.getItem,
143
+ keyExtractor: inputs.keyExtractor,
144
+ pairs: inputs.viewabilityPairs,
145
+ hasInteracted: state.hasInteracted,
146
+ });
147
+ const diff = diffViewable(state.lastViewable, map, tokens);
148
+ if (diff.hasChanged) {
149
+ dlog(`VirtualizedList viewable=${tokens.length} changed=${diff.changed.length} ` +
150
+ `(window [${m.first}, ${m.last}])`);
151
+ effects.push({
152
+ kind: 'fire-viewable',
153
+ info: { viewableItems: tokens, changed: diff.changed },
154
+ delay: maxMinimumViewTime(inputs.viewabilityPairs),
155
+ map,
156
+ });
157
+ }
158
+ }
159
+ // initialScrollIndex: once the first viewport is known, jump to that index a single time.
160
+ if (inputs.initialScrollIndex !== undefined &&
161
+ !state.appliedInitialScroll &&
162
+ state.viewportLength > EMPTY_OFFSET &&
163
+ m.count !== FIRST_INDEX) {
164
+ state.appliedInitialScroll = true;
165
+ const offset = offsetForIndex(inputs.initialScrollIndex, FIRST_INDEX, EMPTY_OFFSET, m.count, m.offsets, m.lengths, state.viewportLength);
166
+ // The initial jump is instant (RN does not animate initialScrollIndex).
167
+ effects.push({ kind: 'scroll-to', offset, animated: false });
168
+ }
169
+ // maintainVisibleContentPosition: shift for the prepended items collapsed into the leading spacer
170
+ // that native MVCP cannot see (RN getDerivedStateFromProps). computeMvcpAdjustment owns the pure
171
+ // decision; here it becomes a scroll-to effect.
172
+ const mvcp = computeMvcpAdjustment({
173
+ minIndexForVisible: inputs.maintainVisibleContentPosition?.minIndexForVisible,
174
+ autoscrollToTopThreshold: inputs.maintainVisibleContentPosition?.autoscrollToTopThreshold,
175
+ count: m.count,
176
+ committedFirst: state.committedWindow.first,
177
+ offsets: m.offsets,
178
+ scrollOffset: state.scrollOffset,
179
+ prevFirstVisibleKey: state.firstVisibleKey,
180
+ keyFor: keyForOf(inputs),
181
+ });
182
+ if (mvcp.action.kind === 'autoscroll-top') {
183
+ effects.push({ kind: 'scroll-to', offset: EMPTY_OFFSET, animated: true });
184
+ }
185
+ else if (mvcp.action.kind === 'shift') {
186
+ effects.push({ kind: 'scroll-to', offset: mvcp.action.offset, animated: false });
187
+ }
188
+ state.firstVisibleKey = mvcp.firstVisibleKey;
189
+ return { state, effects, changed: false };
190
+ }
191
+ // Resolve an imperative scrollToIndex: report the failure when there is no getItemLayout and the
192
+ // target is past the last measured cell (RN VirtualizedList.js), else scroll to the resolved offset.
193
+ function resolveScrollToIndex(state, inputs, action) {
194
+ const m = state.metrics;
195
+ const measuredCeiling = highestMeasuredIndex(state.measured);
196
+ if (inputs.getItemLayout === undefined && action.index > measuredCeiling) {
197
+ dlog(`VirtualizedList onScrollToIndexFailed index=${action.index} ` +
198
+ `highestMeasured=${measuredCeiling} (no getItemLayout)`);
199
+ return {
200
+ state,
201
+ effects: [
202
+ {
203
+ kind: 'fire-scroll-to-index-failed',
204
+ index: action.index,
205
+ highestMeasuredFrameIndex: measuredCeiling,
206
+ averageItemLength: m.averageLength,
207
+ },
208
+ ],
209
+ changed: false,
210
+ };
211
+ }
212
+ const offset = offsetForIndex(action.index, action.viewPosition, action.viewOffset, m.count, m.offsets, m.lengths, state.viewportLength);
213
+ return {
214
+ state,
215
+ effects: [{ kind: 'scroll-to', offset, animated: action.animated }],
216
+ changed: false,
217
+ };
218
+ }
219
+ // The single transition every adapter shares. The adapter maps a native event / imperative call to
220
+ // an action, calls this, stores the returned state, and executes the returned effects.
221
+ export function reduceList(state, action, inputs) {
222
+ switch (action.kind) {
223
+ // Scalar transitions never recompute the window — they set the input and ask for a render; the
224
+ // metrics derive runs exactly ONCE per render, in the 'refresh-metrics' the adapter fires from
225
+ // its render body. That single-derive-per-render invariant is what advances the throttled
226
+ // committedWindow one step per frame (deriving here too would advance it twice).
227
+ case 'scroll':
228
+ // First scroll is the interaction that ungates waitForInteraction viewability configs.
229
+ state.hasInteracted = true;
230
+ state.scrollOffset = action.offset;
231
+ return { state, effects: [], changed: true };
232
+ case 'layout':
233
+ state.viewportLength = action.length;
234
+ return { state, effects: [], changed: true };
235
+ case 'measure':
236
+ // A fixed getItemLayout owns cell sizes, so a measured length is ignored; a repeat of a known
237
+ // length changes nothing. Both guards keep an idle onLayout from forcing a render.
238
+ if (inputs.getItemLayout !== undefined)
239
+ return { state, effects: [], changed: false };
240
+ if (state.measured.get(action.index) === action.length) {
241
+ return { state, effects: [], changed: false };
242
+ }
243
+ state.measured.set(action.index, action.length);
244
+ return { state, effects: [], changed: true };
245
+ case 'batch-tick':
246
+ // The refill timer fired: ask for a render, whose refresh-metrics grows the window one step.
247
+ return { state, effects: [], changed: true };
248
+ case 'refresh-metrics':
249
+ return { state: deriveMetrics(state, inputs), effects: [], changed: true };
250
+ case 'record-interaction':
251
+ state.hasInteracted = true;
252
+ return { state, effects: [], changed: false };
253
+ case 'viewable-fired':
254
+ state.lastViewable = action.map;
255
+ return { state, effects: [], changed: false };
256
+ case 'commit':
257
+ return commitList(state, inputs);
258
+ case 'scroll-to-offset':
259
+ return {
260
+ state,
261
+ effects: [{ kind: 'scroll-to', offset: action.offset, animated: action.animated }],
262
+ changed: false,
263
+ };
264
+ case 'scroll-to-index':
265
+ return resolveScrollToIndex(state, inputs, action);
266
+ case 'scroll-to-item': {
267
+ const index = indexOfItem(inputs.data, inputs.getItem, state.metrics.count, action.item);
268
+ if (index === NO_INDEX) {
269
+ dlog('VirtualizedList scrollToItem: item not found');
270
+ return { state, effects: [], changed: false };
271
+ }
272
+ const offset = offsetForIndex(index, action.viewPosition, EMPTY_OFFSET, state.metrics.count, state.metrics.offsets, state.metrics.lengths, state.viewportLength);
273
+ return {
274
+ state,
275
+ effects: [{ kind: 'scroll-to', offset, animated: action.animated }],
276
+ changed: false,
277
+ };
278
+ }
279
+ case 'scroll-to-end':
280
+ return {
281
+ state,
282
+ effects: [
283
+ {
284
+ kind: 'scroll-to',
285
+ offset: offsetForEnd(state.metrics.total, state.viewportLength),
286
+ animated: action.animated,
287
+ },
288
+ ],
289
+ changed: false,
290
+ };
291
+ }
292
+ }
@@ -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 {
@@ -153,3 +155,46 @@ export interface IListPlanParams {
153
155
  hasSeparators: boolean;
154
156
  }
155
157
  export declare function buildListPlan(params: IListPlanParams): IListPlan;
158
+ export type IMvcpAction = {
159
+ kind: 'none';
160
+ } | {
161
+ kind: 'autoscroll-top';
162
+ } | {
163
+ kind: 'shift';
164
+ offset: number;
165
+ };
166
+ export interface IMvcpAdjustmentParams {
167
+ minIndexForVisible: number | undefined;
168
+ autoscrollToTopThreshold: number | undefined;
169
+ count: number;
170
+ committedFirst: number;
171
+ offsets: number[];
172
+ scrollOffset: number;
173
+ prevFirstVisibleKey: string | null;
174
+ keyFor: (index: number) => string;
175
+ }
176
+ export interface IMvcpAdjustmentResult {
177
+ firstVisibleKey: string | null;
178
+ action: IMvcpAction;
179
+ }
180
+ export declare function computeMvcpAdjustment(params: IMvcpAdjustmentParams): IMvcpAdjustmentResult;
181
+ export declare function resolveItemKey<ItemT>(item: ItemT, index: number, keyExtractor: ((item: ItemT, index: number) => string) | undefined): string;
182
+ export declare function indexOfItem(data: unknown, getItem: (data: unknown, index: number) => unknown, count: number, item: unknown): number;
183
+ export declare function offsetForEnd(total: number, viewportLength: number): number;
184
+ export declare function isSeparatorGapInRange(gapIndex: number, count: number): boolean;
185
+ export declare function decideEdgeReached(params: {
186
+ withinThreshold: boolean;
187
+ edgeCellRendered: boolean;
188
+ total: number;
189
+ sentForContentLength: number;
190
+ }): {
191
+ shouldFire: boolean;
192
+ nextSentForContentLength: number;
193
+ };
194
+ export declare function resolveStickySectionHeaders(enabled: boolean | undefined, headerIndices: number[], platformOS: string): number[] | undefined;
195
+ export declare function wrapFixedLayout(data: unknown, getItemLayout: ((data: unknown, index: number) => {
196
+ length: number;
197
+ offset: number;
198
+ index: number;
199
+ }) | undefined): ((index: number) => ICellLayout) | undefined;
200
+ export declare function resolveAverageLength(fixedLayout: ((index: number) => ICellLayout) | undefined, count: number, measured: Map<number, number>): number;
@@ -19,6 +19,7 @@
19
19
  // framework's own children (renderItem -> ReactNode / VNode), so there is no
20
20
  // Descriptor render fn for a list - the shared layer for lists is this STATE/logic
21
21
  // module, not a view/render-*.ts.
22
+ import { dlog } from '@symbiote-native/engine';
22
23
  // Defaults match RN. windowSize is measured in viewport-lengths (21 => ten screens
23
24
  // of buffer on each side of the visible region). onEndReachedThreshold is a multiple
24
25
  // of the visible length (RN's onEndReachedThresholdOrDefault returns `?? 2`).
@@ -55,8 +56,6 @@ function readNumber(source, key) {
55
56
  function asRecord(value) {
56
57
  return typeof value === 'object' && value !== null ? { ...value } : undefined;
57
58
  }
58
- // onScroll -> the offset along the scroll axis. Vertical reads contentOffset.y,
59
- // horizontal reads contentOffset.x.
60
59
  export function readScrollOffset(event, horizontal) {
61
60
  const native = asRecord(event.nativeEvent);
62
61
  if (native === undefined)
@@ -66,7 +65,6 @@ export function readScrollOffset(event, horizontal) {
66
65
  return undefined;
67
66
  return readNumber(offset, horizontal ? 'x' : 'y');
68
67
  }
69
- // onLayout -> the cross-section length of the box along the scroll axis.
70
68
  export function readLayoutLength(event, horizontal) {
71
69
  const native = asRecord(event.nativeEvent);
72
70
  if (native === undefined)
@@ -278,22 +276,50 @@ export function maxMinimumViewTime(pairs) {
278
276
  }
279
277
  return max;
280
278
  }
281
- // Compute the windowed child PLAN: the two spacer extents, the in-window cells (index +
282
- // key), and the sticky child positions. The adapter walks this plan and creates the host
283
- // elements (createElement / h) plus the framework cell content. This is the shared half of
284
- // 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.
285
294
  export function buildListPlan(params) {
286
295
  const cells = [];
287
- 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];
288
309
  const renderedExtent = params.last >= params.first
289
310
  ? params.offsets[params.last] + params.lengths[params.last] - params.offsets[params.first]
290
311
  : EMPTY_OFFSET;
291
- const trailingExtent = params.total - leadingExtent - renderedExtent;
312
+ const trailingExtent = params.total - windowLeadingExtent - renderedExtent;
292
313
  const stickyChildPositions = [];
293
314
  // The header (when present) is child 0; the leading spacer (when non-empty) is the next
294
- // child. Each cell is one child; a separator after it (when ItemSeparatorComponent is set
295
- // 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.
296
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
+ }
297
323
  for (let index = params.first; index <= params.last; index += 1) {
298
324
  cells.push({ index, key: params.keyFor(index) });
299
325
  if (params.stickyIndices?.has(index) === true)
@@ -302,5 +328,120 @@ export function buildListPlan(params) {
302
328
  if (params.hasSeparators && index < params.last)
303
329
  childPosition += 1;
304
330
  }
305
- return { leadingExtent, trailingExtent, cells, stickyChildPositions };
331
+ return {
332
+ leadingExtent,
333
+ gapExtent,
334
+ trailingExtent,
335
+ cells,
336
+ forcedStickyCell,
337
+ stickyChildPositions,
338
+ };
339
+ }
340
+ export function computeMvcpAdjustment(params) {
341
+ const { minIndexForVisible, count, keyFor } = params;
342
+ if (minIndexForVisible === undefined || count === FIRST_INDEX) {
343
+ return { firstVisibleKey: null, action: { kind: 'none' } };
344
+ }
345
+ const newFirstVisibleKey = count > minIndexForVisible ? keyFor(minIndexForVisible) : null;
346
+ const prevKey = params.prevFirstVisibleKey;
347
+ const settled = {
348
+ firstVisibleKey: newFirstVisibleKey,
349
+ action: { kind: 'none' },
350
+ };
351
+ if (prevKey === null || newFirstVisibleKey === null || prevKey === newFirstVisibleKey) {
352
+ return settled;
353
+ }
354
+ let anchorIndex = NO_INDEX;
355
+ for (let index = minIndexForVisible; index < count; index += 1) {
356
+ if (keyFor(index) === prevKey) {
357
+ anchorIndex = index;
358
+ break;
359
+ }
360
+ }
361
+ if (anchorIndex <= minIndexForVisible)
362
+ return settled;
363
+ // Native MVCP shifts in-window cells itself; the JS shift covers ONLY the inserted items in the
364
+ // leading SPACER (above the first rendered index). Counting the full inserted extent would
365
+ // double-correct.
366
+ const spacerEnd = Math.min(anchorIndex, params.committedFirst);
367
+ const insertedExtent = spacerEnd > minIndexForVisible
368
+ ? params.offsets[spacerEnd] - params.offsets[minIndexForVisible]
369
+ : EMPTY_OFFSET;
370
+ if (insertedExtent <= EMPTY_OFFSET)
371
+ return settled;
372
+ const autoThreshold = params.autoscrollToTopThreshold;
373
+ const anchoredNearTop = autoThreshold !== undefined && params.scrollOffset <= autoThreshold;
374
+ if (anchoredNearTop) {
375
+ dlog(`VirtualizedList MVCP autoscroll-to-top (offset=${params.scrollOffset} <= ${autoThreshold})`);
376
+ return { firstVisibleKey: newFirstVisibleKey, action: { kind: 'autoscroll-top' } };
377
+ }
378
+ dlog(`VirtualizedList MVCP adjust +${insertedExtent}px ` +
379
+ `(anchor "${prevKey}" moved ${minIndexForVisible}->${anchorIndex})`);
380
+ return {
381
+ firstVisibleKey: newFirstVisibleKey,
382
+ action: { kind: 'shift', offset: params.scrollOffset + insertedExtent },
383
+ };
384
+ }
385
+ // The default key extractor: the caller's keyExtractor when set, else the index as a string (RN's
386
+ // default). Centralized so every adapter's keyForIndex resolves keys identically.
387
+ export function resolveItemKey(item, index, keyExtractor) {
388
+ return keyExtractor ? keyExtractor(item, index) : String(index);
389
+ }
390
+ // Linear item -> index lookup for scrollToItem (RN scans by reference identity). NO_INDEX when the
391
+ // item is not in data.
392
+ export function indexOfItem(data, getItem, count, item) {
393
+ for (let index = FIRST_INDEX; index < count; index += 1) {
394
+ if (getItem(data, index) === item)
395
+ return index;
396
+ }
397
+ return NO_INDEX;
398
+ }
399
+ // The pixel offset that scrolls the last content to the bottom edge (scrollToEnd). Never negative
400
+ // when the content is shorter than the viewport.
401
+ export function offsetForEnd(total, viewportLength) {
402
+ return Math.max(EMPTY_OFFSET, total - viewportLength);
403
+ }
404
+ // A gap index addresses a real separator only inside [0, count-2]; outside it there is no gap and
405
+ // the write is a no-op (RN bails on the same bounds).
406
+ export function isSeparatorGapInRange(gapIndex, count) {
407
+ return gapIndex >= FIRST_INDEX && gapIndex <= count - 2;
408
+ }
409
+ // onEndReached / onStartReached fire decision + content-length dedup (RN _maybeCallOnEdgeReached).
410
+ // The geometry (withinThreshold) comes from computeEndReached/computeStartReached; this folds in the
411
+ // "edge cell actually rendered" gate, the dedup against the last-fired content length, and the
412
+ // re-arm once scrolled away from the edge. Returns whether to fire plus the next dedup sentinel.
413
+ export function decideEdgeReached(params) {
414
+ const { withinThreshold, edgeCellRendered, total, sentForContentLength } = params;
415
+ if (withinThreshold && edgeCellRendered && sentForContentLength !== total) {
416
+ return { shouldFire: true, nextSentForContentLength: total };
417
+ }
418
+ // Re-arm once out of threshold so the next approach can fire again.
419
+ if (!withinThreshold) {
420
+ return { shouldFire: false, nextSentForContentLength: NO_CONTENT_LENGTH_SENT };
421
+ }
422
+ return { shouldFire: false, nextSentForContentLength: sentForContentLength };
423
+ }
424
+ // Section headers stick by default only on iOS (RN); the explicit prop overrides. Returns the
425
+ // header indices to stick, or undefined when sticking is off.
426
+ export function resolveStickySectionHeaders(enabled, headerIndices, platformOS) {
427
+ const stickyEnabled = enabled ?? platformOS === 'ios';
428
+ return stickyEnabled ? headerIndices : undefined;
429
+ }
430
+ // Wrap the user's getItemLayout into an (index) => ICellLayout resolver (dropping the `index` field
431
+ // RN's getItemLayout returns), or undefined when there is no getItemLayout.
432
+ export function wrapFixedLayout(data, getItemLayout) {
433
+ if (getItemLayout === undefined)
434
+ return undefined;
435
+ return (index) => {
436
+ const layout = getItemLayout(data, index);
437
+ return { length: layout.length, offset: layout.offset };
438
+ };
439
+ }
440
+ // The average cell length that sizes unmeasured cells and the trailing spacer: the fixed layout's
441
+ // first cell length when getItemLayout is set (guarded against an empty list so it never calls
442
+ // fixedLayout on a non-existent cell), else the running average of the measured cells.
443
+ export function resolveAverageLength(fixedLayout, count, measured) {
444
+ if (fixedLayout === undefined)
445
+ return averageMeasuredLength(measured);
446
+ return count > FIRST_INDEX ? fixedLayout(FIRST_INDEX).length : EMPTY_OFFSET;
306
447
  }
@@ -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.
@@ -11,6 +11,22 @@ export type IScrollIntrinsics = {
11
11
  contentStyle: IStyleProp<IViewStyle>;
12
12
  };
13
13
  export declare function selectScrollIntrinsics(isHorizontal: boolean, contentContainerStyle: IStyleProp<IViewStyle> | undefined): IScrollIntrinsics;
14
+ export type IScrollForwardMode = 'plain' | 'sticky-native' | 'sticky-js';
15
+ export interface IScrollForwardingInputs {
16
+ hasStickyHeaders: boolean;
17
+ nativeStickyAvailable: boolean;
18
+ invertStickyHeaders: boolean | undefined;
19
+ scrollEventThrottle: number | undefined;
20
+ maintainVisibleContentPosition: unknown;
21
+ snapToAlignment: unknown;
22
+ }
23
+ export interface IScrollForwarding {
24
+ mode: IScrollForwardMode;
25
+ scrollEventThrottle: number | undefined;
26
+ capturesViewportHeight: boolean;
27
+ collapsableChildren: boolean;
28
+ }
29
+ export declare function resolveScrollForwarding(inputs: IScrollForwardingInputs): IScrollForwarding;
14
30
  export type IContentSize = {
15
31
  width: number;
16
32
  height: number;
@@ -71,6 +71,41 @@ export function selectScrollIntrinsics(isHorizontal, contentContainerStyle) {
71
71
  : contentContainerStyle;
72
72
  return { scrollViewIntrinsic, contentIntrinsic, scrollViewBaseStyle, contentStyle };
73
73
  }
74
+ // When sticky headers are active the scroll offset must reach the AnimatedValue; RN raises the scroll
75
+ // event rate for it (ScrollView.js:1798): throttle 1 on the native driver (it can afford every frame),
76
+ // 16 on the JS fallback (Animated.event drives the value in JS). Without sticky headers the user's
77
+ // throttle passes through untouched. These two magic numbers were copied into all three adapters.
78
+ const STICKY_NATIVE_SCROLL_THROTTLE = 1;
79
+ const STICKY_JS_SCROLL_THROTTLE = 16;
80
+ export function resolveScrollForwarding(inputs) {
81
+ // maintainVisibleContentPosition (and Android snapToAlignment) anchor against MOUNTED cell views;
82
+ // Android Fabric view-flattens layout-only cells away, so RN keeps them as real views with
83
+ // collapsableChildren={false} on the content container (ScrollView.js preserveChildren). No-op on iOS.
84
+ const collapsableChildren = inputs.maintainVisibleContentPosition !== undefined || inputs.snapToAlignment !== undefined;
85
+ if (!inputs.hasStickyHeaders) {
86
+ return {
87
+ mode: 'plain',
88
+ scrollEventThrottle: inputs.scrollEventThrottle,
89
+ capturesViewportHeight: false,
90
+ collapsableChildren,
91
+ };
92
+ }
93
+ const capturesViewportHeight = inputs.invertStickyHeaders === true;
94
+ if (inputs.nativeStickyAvailable) {
95
+ return {
96
+ mode: 'sticky-native',
97
+ scrollEventThrottle: inputs.scrollEventThrottle ?? STICKY_NATIVE_SCROLL_THROTTLE,
98
+ capturesViewportHeight,
99
+ collapsableChildren,
100
+ };
101
+ }
102
+ return {
103
+ mode: 'sticky-js',
104
+ scrollEventThrottle: inputs.scrollEventThrottle ?? STICKY_JS_SCROLL_THROTTLE,
105
+ capturesViewportHeight,
106
+ collapsableChildren,
107
+ };
108
+ }
74
109
  // Did the content view's measured size actually change since the last fire? RN synthesizes
75
110
  // onContentSizeChange from the inner content view's onLayout, but that fires on every layout
76
111
  // pass; RN dedupes so the user handler only sees real size changes (ScrollView.js). First
@@ -0,0 +1,2 @@
1
+ import type { IViewStyle } from '@symbiote-native/engine';
2
+ export declare function highlightPressedStyle<TStyle>(pressed: boolean, style: TStyle, underlayColor: string, activeOpacity: number): TStyle | [TStyle, IViewStyle];
@@ -0,0 +1,12 @@
1
+ // TouchableHighlight underlay: the shared render decision (framework-agnostic). RN drives the
2
+ // highlight with setState (not Animated) — while pressed it paints underlayColor + lowers the child
3
+ // opacity, at rest it is the bare style (TouchableHighlight.js). This 3-line gating was triplicated
4
+ // verbatim across adapters; it lives here once, prop-driven. Each adapter passes its own resolved
5
+ // style base (React the raw style, Angular the anchor+style array, Vue the untyped attrs style) plus
6
+ // the live `pressed`, so the base is generic — the overlay is layered on top when pressed.
7
+ export function highlightPressedStyle(pressed, style, underlayColor, activeOpacity) {
8
+ if (!pressed)
9
+ return style;
10
+ const overlay = { backgroundColor: underlayColor, opacity: activeOpacity };
11
+ return [style, overlay];
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/components",
3
- "version": "0.2.6",
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
- }