@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.
- package/build/scroll-view-commands.js +5 -1
- package/build/state/sticky-header-reducer.d.ts +1 -0
- package/build/state/sticky-header-reducer.js +71 -2
- package/build/state/virtualized-list.d.ts +2 -0
- package/build/state/virtualized-list.js +44 -12
- package/build/view/render-button.js +3 -4
- package/package.json +2 -2
- package/build/state/scroll-routing-handle/index.d.ts +0 -10
- package/build/state/scroll-routing-handle/index.js +0 -6
- package/build/view/layout-event/index.d.ts +0 -2
- package/build/view/layout-event/index.js +0 -12
|
@@ -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
|
-
|
|
119
|
+
dlog(`STICKY[attach] attachStickyScroll onScroll -> value#${value.__getNativeTag?.() ?? 'js'}`);
|
|
120
|
+
return () => {
|
|
121
|
+
dlog('STICKY[attach] attachStickyScroll detached');
|
|
122
|
+
attachment.detach();
|
|
123
|
+
};
|
|
120
124
|
}
|
|
@@ -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(
|
|
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
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
|
|
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
|
|
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 -
|
|
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
|
|
296
|
-
//
|
|
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 {
|
|
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:
|
|
2
|
-
// TouchableOpacity wrapping a Text.
|
|
3
|
-
//
|
|
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
|
+
"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.
|
|
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,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
|
-
}
|