@symbiote-native/components 3.1.1 → 3.1.2
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/behaviors/activity-indicator/shared.js +29 -71
- package/build/behaviors/button.d.ts +0 -9
- package/build/behaviors/button.js +47 -233
- package/build/behaviors/image-background.js +21 -81
- package/build/behaviors/image.js +3 -10
- package/build/behaviors/input-accessory-view.js +10 -51
- package/build/behaviors/pressable.d.ts +0 -48
- package/build/behaviors/pressable.js +59 -149
- package/build/behaviors/scroll-view/index.android.js +10 -30
- package/build/behaviors/scroll-view/shared.js +54 -181
- package/build/behaviors/scroll-view/sticky.d.ts +0 -8
- package/build/behaviors/scroll-view/sticky.js +54 -142
- package/build/behaviors/text-input.d.ts +0 -8
- package/build/behaviors/text-input.js +57 -156
- package/build/behaviors/touchable-highlight.js +14 -54
- package/build/behaviors/touchable-native-feedback.js +9 -32
- package/build/behaviors/touchable-opacity.js +3 -18
- package/build/behaviors/touchable-without-feedback.js +6 -24
- package/build/component-names/index.android.js +6 -8
- package/build/component-names/shared.js +12 -42
- package/build/index.js +13 -19
- package/build/scroll-view-commands.js +3 -11
- package/build/state/pressable.js +18 -43
- package/build/state/sticky-header-reducer.js +103 -149
- package/build/state/touchable.js +3 -5
- package/build/state/virtualized-list-reducer.js +21 -48
- package/build/state/virtualized-list.js +63 -148
- package/build/text-props.js +3 -13
- package/build/view/render-button.js +13 -44
- package/build/view/render-input-accessory-view.js +8 -24
- package/build/view/render-pressable/index.js +3 -4
- package/build/view/render-scroll-view.js +13 -23
- package/build/view/render-touchable-native-feedback.js +5 -14
- package/host-primitives.cjs +33 -207
- package/package.json +3 -3
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
// The intrinsic JSX types symbiote's host config maps to Fabric components
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// Android 'AndroidSwitch'), so the name tables live in component-names.ios.ts /
|
|
5
|
-
// .android.ts and the filename selects, no Platform.OS read. The isText flag is
|
|
6
|
-
// platform-invariant, so it lives here once and both tables share it.
|
|
1
|
+
// The intrinsic JSX types symbiote's host config maps to Fabric components. A Fabric name is
|
|
2
|
+
// platform-specific, so name tables live in component-names.ios/.android.ts, selected by
|
|
3
|
+
// filename; `isText` is platform-invariant and lives here once.
|
|
7
4
|
// The only text-laying intrinsic; drives the RCTText / RCTVirtualText nesting choice
|
|
8
5
|
// (a <Text> inside another <Text> becomes a virtual span). Platform-invariant, so it is
|
|
9
6
|
// not part of the per-platform name table.
|
|
@@ -20,32 +17,18 @@ export function buildDescriptors(names) {
|
|
|
20
17
|
}
|
|
21
18
|
return descriptors;
|
|
22
19
|
}
|
|
23
|
-
// Resolve an intrinsic type to its descriptor
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// COMPONENT_DESCRIPTORS. A `symbiote-*` miss is a typo in our own code; any other string
|
|
27
|
-
// is a raw Fabric view name from a library's codegen component and flows through untouched
|
|
28
|
-
// (the engine derives its events/processors from the view's ViewConfig, no per-library glue).
|
|
20
|
+
// Resolve an intrinsic type to its descriptor against the platform-selected map, so each
|
|
21
|
+
// platform file binds it to its own COMPONENT_DESCRIPTORS. Any string with no descriptor is a
|
|
22
|
+
// raw Fabric view name from a library's codegen component and flows through untouched.
|
|
29
23
|
export function makeDescriptorFor(descriptors) {
|
|
30
24
|
const unrewritten = publicNamesThatAreNotViewNames(descriptors);
|
|
31
25
|
return type => {
|
|
32
26
|
const descriptor = descriptors[type];
|
|
33
27
|
if (descriptor !== undefined)
|
|
34
28
|
return descriptor;
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// view · scroll-view ours
|
|
40
|
-
// counter-child · app-root an Angular app's OWN component selectors, kebab by convention
|
|
41
|
-
// RCTView · RNCSlider a Fabric view name, resolving through the fallthrough below
|
|
42
|
-
//
|
|
43
|
-
// — and the first attempt at a replacement ("lowercase is ours") threw on the second row, which
|
|
44
|
-
// is most of an Angular app. Measured: 222 tests, the primary failure being `counter-child`.
|
|
45
|
-
//
|
|
46
|
-
// What replaces it is stronger and is not runtime at all: `ISymbioteIntrinsic` is a closed
|
|
47
|
-
// union, so a misspelled tag in our own source is a compile error at every call site that names
|
|
48
|
-
// one. The guard was only ever a backstop for a name built dynamically.
|
|
29
|
+
// No "unknown tag" throw here: this namespace holds three populations no string alone can
|
|
30
|
+
// tell apart — our own tags, an Angular app's own kebab-case selectors, and a Fabric view
|
|
31
|
+
// name. `ISymbioteIntrinsic` being a closed union makes a misspelled tag a compile error.
|
|
49
32
|
if (unrewritten.has(type)) {
|
|
50
33
|
throw new Error(`"${type}" is a primitive's PUBLIC name, not a Fabric view name — a rewrite was missed. ` +
|
|
51
34
|
`Expected the intrinsic tag (e.g. "view"). Falling through would have committed ` +
|
|
@@ -54,22 +37,9 @@ export function makeDescriptorFor(descriptors) {
|
|
|
54
37
|
return { component: type, isText: false };
|
|
55
38
|
};
|
|
56
39
|
}
|
|
57
|
-
// A primitive's public name reaching here means an adapter's rewrite missed a call site
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// Both halves are DERIVED, because a hand-written list of either would be wrong within a release.
|
|
62
|
-
// The names come from the intrinsic union (kebab -> Pascal); the exclusions come from the platform's
|
|
63
|
-
// own table, and deriving them is not tidiness — two public names ARE real Fabric view names, so a
|
|
64
|
-
// hand-written block list would break an adapter's thin wrapper over a third-party native view,
|
|
65
|
-
// which resolves by view name through this same function.
|
|
66
|
-
//
|
|
67
|
-
// AND THE EXCLUSION IS PER PLATFORM, which is why it must be computed from the table rather than
|
|
68
|
-
// stated. `Switch` and `SafeAreaView` are iOS view names; Android spells them `AndroidSwitch` and
|
|
69
|
-
// `RCTSafeAreaView`, so the same public name is EXCLUDED on iOS and BLOCKED on Android. That
|
|
70
|
-
// asymmetry is correct — nothing legitimate resolves by the bare name on Android — but a reader who
|
|
71
|
-
// takes "these two are real view names" as platform-invariant will conclude the guard is broken on
|
|
72
|
-
// one side or the other.
|
|
40
|
+
// A primitive's public name reaching here means an adapter's rewrite missed a call site —
|
|
41
|
+
// otherwise it commits a view literally called `View`. Both halves are DERIVED, not hand-written:
|
|
42
|
+
// some public names (`Switch` on iOS) ARE real view names, and which is per-platform.
|
|
73
43
|
function publicNamesThatAreNotViewNames(descriptors) {
|
|
74
44
|
const viewNames = new Set(Object.values(descriptors).map(descriptor => descriptor.component));
|
|
75
45
|
const names = new Set();
|
package/build/index.js
CHANGED
|
@@ -53,10 +53,8 @@ export { canUseNativeForeground, selectableBackground, selectableBackgroundBorde
|
|
|
53
53
|
// element is a tag now, and a tag is a string that cannot carry `.Ripple`.
|
|
54
54
|
TouchableNativeFeedback, } from './view/render-touchable-native-feedback.js';
|
|
55
55
|
// Button: `resolveButtonDisabled` alone now — the `props.disabled ?? aria ?? state.disabled`
|
|
56
|
-
// precedence, which the press MACHINE reads and
|
|
57
|
-
//
|
|
58
|
-
// `SymbioteFabricProps.cpp`; the last two, `buttonViewStyle` and `resolveButtonViewStyle`, went with
|
|
59
|
-
// the Android arm of the test host that finally made their branch assertable.
|
|
56
|
+
// precedence, which the press MACHINE reads and stays JS. Every platform fold Button.js performs
|
|
57
|
+
// is `SymbioteFabricProps.cpp`'s.
|
|
60
58
|
export { resolveButtonDisabled } from './view/render-button.js';
|
|
61
59
|
// TextInput: the controlled-value / event-count handshake, and ONLY that now — the machine. The
|
|
62
60
|
// W3C->native prop resolution that used to sit beside it is the engine's (`foldTextInputAliases`,
|
|
@@ -80,25 +78,21 @@ export { registerPressableBehavior, PRESSABLE_TAG, } from './behaviors/pressable
|
|
|
80
78
|
export { registerTouchableOpacityBehavior, TOUCHABLE_OPACITY_TAG, } from './behaviors/touchable-opacity.js';
|
|
81
79
|
export { registerTouchableHighlightBehavior, TOUCHABLE_HIGHLIGHT_TAG, } from './behaviors/touchable-highlight.js';
|
|
82
80
|
export { registerButtonBehavior, BUTTON_TAG } from './behaviors/button.js';
|
|
83
|
-
// Registered by ALL FIVE adapters
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
// TouchableNativeFeedback. The ActivityIndicator and ScrollView blocks below are still in the
|
|
87
|
-
// withheld state this one just left.
|
|
81
|
+
// Registered by ALL FIVE adapters, in the same commit that deleted the five wrappers — safe
|
|
82
|
+
// only because those wrappers no longer render their own Pressable, which would put a second
|
|
83
|
+
// responder on every TouchableNativeFeedback.
|
|
88
84
|
export { registerTouchableNativeFeedbackBehavior, TOUCHABLE_NATIVE_FEEDBACK_TAG, } from './behaviors/touchable-native-feedback.js';
|
|
89
85
|
// The second tag that commits no node, and registered by all five adapters in the same commit that
|
|
90
86
|
// deleted their five wrappers — while those wrappers still rendered a `Pressable` around the app's
|
|
91
87
|
// children, registering would have put a second responder on every TouchableWithoutFeedback.
|
|
92
88
|
export { registerTouchableWithoutFeedbackBehavior, TOUCHABLE_WITHOUT_FEEDBACK_TAG, } from './behaviors/touchable-without-feedback.js';
|
|
93
|
-
// Registered by all five adapters
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
// only render this primitive had was the two nodes the behavior now builds.
|
|
89
|
+
// Registered by all five adapters, in the same commit that deleted the five wrappers — the tag
|
|
90
|
+
// builds its own spinner, so a surviving wrapper would have painted a second one. No
|
|
91
|
+
// `view/render-activity-indicator.ts`: the behavior now builds the only two nodes this had.
|
|
97
92
|
export { ACTIVITY_INDICATOR_SPINNER_TAG, ACTIVITY_INDICATOR_TAG, registerActivityIndicatorBehavior, } from './behaviors/activity-indicator';
|
|
98
93
|
export { IMAGE_TAG, registerImageBehavior } from './behaviors/image.js';
|
|
99
|
-
// Registered by all five adapters
|
|
100
|
-
//
|
|
101
|
-
// committed a second one under it.
|
|
94
|
+
// Registered by all five adapters, in the same commit that deleted the five wrappers — the tag
|
|
95
|
+
// builds the background image itself, so a surviving wrapper would have committed a second one.
|
|
102
96
|
export { IMAGE_BACKGROUND_TAG, registerImageBackgroundBehavior, } from './behaviors/image-background.js';
|
|
103
97
|
export { INPUT_ACCESSORY_VIEW_TAG, registerInputAccessoryViewBehavior, } from './behaviors/input-accessory-view.js';
|
|
104
98
|
export { registerTextInputBehavior, buildTextInputHandle, TEXT_INPUT_TAG, TEXT_INPUT_MULTILINE_TAG, } from './behaviors/text-input.js';
|
|
@@ -109,7 +103,7 @@ export { registerRefreshControlBehavior, REFRESH_CONTROL_TAG, } from './behavior
|
|
|
109
103
|
// caller of `registerScrollViewBehavior()` names it. The shape CLAUDE.md forbids is a module whose
|
|
110
104
|
// evaluation alone registers.
|
|
111
105
|
//
|
|
112
|
-
// EVERY ADAPTER CALLS IT
|
|
113
|
-
//
|
|
114
|
-
//
|
|
106
|
+
// EVERY ADAPTER CALLS IT, through `@symbiote-native/components/register`. Withheld while the
|
|
107
|
+
// wrappers built their own content node — registering then would have double-nested every
|
|
108
|
+
// ScrollView. They are gone; the engine is the single owner.
|
|
115
109
|
export { HORIZONTAL_SCROLL_VIEW_TAG, registerScrollViewBehavior, SCROLL_VIEW_TAG, STICKY_HEADER_TAG, } from './behaviors/scroll-view';
|
|
@@ -4,17 +4,9 @@
|
|
|
4
4
|
// Android RefreshControl wrap's style split moved out — see the note below. The adapter supplies
|
|
5
5
|
// the lifecycle (the node getter, the effect) and re-exports these.
|
|
6
6
|
import { attachNativeEvent, dlog, isSymbioteEvent, } from '@symbiote-native/engine';
|
|
7
|
-
//
|
|
8
|
-
// `
|
|
9
|
-
//
|
|
10
|
-
// from a `payloadFold` per node — see `behaviors/scroll-view/index.android.ts` for what made the
|
|
11
|
-
// wrapper's half possible at all.
|
|
12
|
-
//
|
|
13
|
-
// Nothing replaced them here on purpose: a JS copy kept for a caller that no longer exists is the
|
|
14
|
-
// mirror shape this migration keeps deleting, and the twenty-eight layout keys are exactly the kind
|
|
15
|
-
// of list that drifts in silence. `core/engine/cpp/tests/js/scroll-view-wrap-payload.itest.ts`
|
|
16
|
-
// asserts the split on the payload a commit actually sent, which is the only place it can now be
|
|
17
|
-
// wrong.
|
|
7
|
+
// The Android wrap's style split (`splitScrollViewStyle`, `foldRefreshWrapperProps`) is
|
|
8
|
+
// `SymbioteFabricProps.cpp`'s, reached off the tag and tree rather than a `payloadFold` per node.
|
|
9
|
+
// Contract: `scroll-view-wrap-payload.itest.ts`, asserted on the payload a commit actually sent.
|
|
18
10
|
// Re-exported so the package barrel (index.ts) can still export this guard to
|
|
19
11
|
// '@symbiote-native/components' callers, now that it lives in the engine, next to ISymbioteEvent.
|
|
20
12
|
export { isSymbioteEvent };
|
package/build/state/pressable.js
CHANGED
|
@@ -1,22 +1,14 @@
|
|
|
1
|
-
// Pressable, the logic half (framework-agnostic, zero render
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// Framework-specific, stays in the adapter: the `pressed` state cell (it drives a
|
|
9
|
-
// re-render, so each framework owns its reactive primitive) and the raw frame-measure (the host
|
|
10
|
-
// node plus its measure call). The rest (the timers, the geometry, the suppression flags,
|
|
11
|
-
// the decision of when each callback fires) is here, shared by every adapter.
|
|
1
|
+
// Pressable, the logic half (framework-agnostic, zero render). The press lifecycle RN's
|
|
2
|
+
// Pressability runs in JS lives here as a pure state machine over a mutable runtime plus an
|
|
3
|
+
// adapter-supplied host. Both React and Vue call the SAME handlers, differing only in lifecycle.
|
|
4
|
+
// Framework-specific, stays in the adapter: the `pressed` state cell (drives a re-render, so each
|
|
5
|
+
// framework owns its reactive primitive) and the raw frame-measure. The rest — timers, geometry,
|
|
6
|
+
// suppression flags, when each callback fires — is here, shared by every adapter.
|
|
12
7
|
import { dlog, Platform, SoundManager, } from '@symbiote-native/engine';
|
|
13
8
|
export const DEFAULT_DELAY_LONG_PRESS_MS = 500;
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// not fire mid-scroll. Vendor exposes it only as a rare static override
|
|
18
|
-
// (`Pressability.setLongPressDeactivationDistance`, used by e.g. gesture libraries), never as a
|
|
19
|
-
// per-component prop, so it stays a plain constant here rather than a config field.
|
|
9
|
+
// A SEPARATE, smaller radius than pressRetentionOffset/hitSlop: any move past it cancels a
|
|
10
|
+
// pending long press even while the finger is still well inside the retention rect — real presses
|
|
11
|
+
// jitter a few px, long-press must not fire mid-scroll.
|
|
20
12
|
export const LONG_PRESS_DEACTIVATION_DISTANCE = 10;
|
|
21
13
|
// Pressability's default active-visual floor for a plain Pressable. Touchable* overrides this to 0.
|
|
22
14
|
export const DEFAULT_MIN_PRESS_DURATION_MS = 130;
|
|
@@ -120,10 +112,8 @@ export function disposePressRuntime(runtime) {
|
|
|
120
112
|
runtime.delayElapsed = false;
|
|
121
113
|
}
|
|
122
114
|
// Measure the responder's on-screen frame and cache it as the retention region for the life of
|
|
123
|
-
// the press
|
|
124
|
-
//
|
|
125
|
-
// headless) the region stays undefined and the move test falls back to the radius bound. The
|
|
126
|
-
// try/catch guards that last case: a slot lacking measure throws rather than no-opping.
|
|
115
|
+
// the press. When measure is unavailable the region stays undefined and the move test falls back
|
|
116
|
+
// to the radius bound; the try/catch guards a slot lacking measure throwing rather than no-opping.
|
|
127
117
|
function measureRegion(runtime, measureFn) {
|
|
128
118
|
runtime.region = undefined;
|
|
129
119
|
if (measureFn === undefined)
|
|
@@ -157,18 +147,9 @@ export function createPressHandlers(config, runtime, host) {
|
|
|
157
147
|
const fallbackThreshold = maxEdge(hitSlopRect) + maxEdge(pressRectOffset);
|
|
158
148
|
// True iff the touch still belongs to the active press: against the measured rect when we have
|
|
159
149
|
// one (the RN-faithful path), else the symmetric radius fallback.
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
// measured — vendor treats "no measurement yet" as "do nothing until one arrives." The radius
|
|
164
|
-
// fallback below diverges: it judges drift/jitter on synthetic geometry instead of waiting.
|
|
165
|
-
// Confirmed unreachable on a real device (`Tree::measure` in `SymbioteTree.cpp` is a synchronous
|
|
166
|
-
// JSI call, so the region is always populated before `handleResponderMove` can fire), so the
|
|
167
|
-
// fallback only ever fires in this codebase's own test harness, which wires
|
|
168
|
-
// `getMeasureFn: () => undefined` everywhere (`state/pressable.test.ts:84` and every touchable
|
|
169
|
-
// test built on it). A real fix needs a synchronous mock-measure harness under 11+ existing
|
|
170
|
-
// drift/retention/long-press call sites across `pressable.test.ts`, `touchable-opacity.test.ts`
|
|
171
|
-
// and `render-pressable.test.ts`, not a one-line guard — see the audit skill for the finding.
|
|
150
|
+
// Known divergence: vendor does nothing until the region is measured, this judges drift/jitter
|
|
151
|
+
// on synthetic geometry instead. Unreachable on a real device (measure is a synchronous JSI
|
|
152
|
+
// call), so the fallback only ever fires in this codebase's own test harness.
|
|
172
153
|
function isWithinRetention(point) {
|
|
173
154
|
const region = runtime.region;
|
|
174
155
|
if (region !== undefined) {
|
|
@@ -188,12 +169,8 @@ export function createPressHandlers(config, runtime, host) {
|
|
|
188
169
|
function clearPressOut() {
|
|
189
170
|
cancelRuntimeTimer(runtime, 'pressOutCancel');
|
|
190
171
|
}
|
|
191
|
-
//
|
|
192
|
-
// (
|
|
193
|
-
// rest of the gesture). Splitting it out of `activate()` is what makes that possible: `activate`
|
|
194
|
-
// itself runs again on every drift-back-in, and used to re-arm a fresh full-duration timer each
|
|
195
|
-
// time, which is wrong on two counts — it can resurrect a long press the finger already
|
|
196
|
-
// disqualified, and if it never left, it's simply the same timer restarted for no reason.
|
|
172
|
+
// Arms ONCE, at grant; a drift out/back-in never re-arms it, only cancels. Split out of
|
|
173
|
+
// activate(), which runs again on drift-back-in and would resurrect a disqualified long press.
|
|
197
174
|
function armLongPress(event) {
|
|
198
175
|
if (!onLongPress)
|
|
199
176
|
return;
|
|
@@ -277,10 +254,8 @@ export function createPressHandlers(config, runtime, host) {
|
|
|
277
254
|
runtime.pressOrigin = readPoint(event);
|
|
278
255
|
runtime.driftedOut = false;
|
|
279
256
|
measureRegion(runtime, host.getMeasureFn());
|
|
280
|
-
// Armed at GRANT, unconditionally —
|
|
281
|
-
//
|
|
282
|
-
// its default (`configFor`), so adding it back here is what keeps the long-press threshold at
|
|
283
|
-
// a constant time-from-touch-down regardless of how long the visual is deferred.
|
|
257
|
+
// Armed at GRANT, unconditionally — the unstable_pressDelay branch below only defers the
|
|
258
|
+
// PRESSED VISUAL, keeping the long-press threshold constant from touch-down.
|
|
284
259
|
armLongPress(event);
|
|
285
260
|
if (unstable_pressDelay > 0) {
|
|
286
261
|
dlog(`Pressable pressIn deferred ${unstable_pressDelay}ms`);
|
|
@@ -1,19 +1,6 @@
|
|
|
1
|
-
// Sticky-header effect machine:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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.
|
|
1
|
+
// Sticky-header effect machine: one pure `reduceSticky(state, action, inputs)` shared by every
|
|
2
|
+
// adapter, so the zero-swallow gate, debounce pick, and rebuild decision live once, not once per
|
|
3
|
+
// framework's reactive dialect. Math leaf + debounce window live in ./view/render-scroll-sticky.
|
|
17
4
|
import { dlog } from '@symbiote-native/engine';
|
|
18
5
|
import { computeStickyInterpolation, stickyDebounceMs, } from '../view/render-scroll-sticky.js';
|
|
19
6
|
// The un-measured identity interpolation (RN: a fresh AnimatedInterpolation before the header has
|
|
@@ -43,8 +30,8 @@ function arraysEqual(a, b) {
|
|
|
43
30
|
return false;
|
|
44
31
|
return a.every((value, index) => value === b[index]);
|
|
45
32
|
}
|
|
46
|
-
// Recompute the derived
|
|
47
|
-
//
|
|
33
|
+
// Recompute the derived ranges off the current state + inputs, store them, and return them for
|
|
34
|
+
// the rebuild effect.
|
|
48
35
|
function deriveRanges(state, inputs) {
|
|
49
36
|
const { inputRange, outputRange } = computeStickyInterpolation({
|
|
50
37
|
measured: state.measured,
|
|
@@ -58,14 +45,99 @@ function deriveRanges(state, inputs) {
|
|
|
58
45
|
state.outputRange = outputRange;
|
|
59
46
|
return { inputRange, outputRange };
|
|
60
47
|
}
|
|
61
|
-
//
|
|
62
|
-
//
|
|
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).
|
|
48
|
+
// Diagnostic-only, gated: identifies which header instance a log line belongs to — layoutY
|
|
49
|
+
// doubles as a stable per-header id once measured (0 before the first layout).
|
|
66
50
|
function headerTag(state) {
|
|
67
51
|
return `y=${state.layoutY}`;
|
|
68
52
|
}
|
|
53
|
+
// Redundant-geometry guard: Yoga re-fires onLayout with the same y/height on an unrelated
|
|
54
|
+
// relayout — without this a fresh prop identity per redundant layout trips an unbounded
|
|
55
|
+
// rebuild loop.
|
|
56
|
+
function handleLayout(state, action, inputs) {
|
|
57
|
+
const alreadyAtThisGeometry = state.measured &&
|
|
58
|
+
state.layoutY === action.y &&
|
|
59
|
+
state.layoutHeight === action.height;
|
|
60
|
+
state.layoutY = action.y;
|
|
61
|
+
state.layoutHeight = action.height;
|
|
62
|
+
state.measured = true;
|
|
63
|
+
const effects = [];
|
|
64
|
+
if (inputs.index !== undefined) {
|
|
65
|
+
effects.push({ kind: 'record-header-y', index: inputs.index, y: action.y });
|
|
66
|
+
}
|
|
67
|
+
if (alreadyAtThisGeometry && state.rangesEmitted) {
|
|
68
|
+
dlog(`STICKY[reducer ${headerTag(state)}] layout: redundant geometry, skipped rebuild`);
|
|
69
|
+
return { state, effects, changed: effects.length > 0 };
|
|
70
|
+
}
|
|
71
|
+
const { inputRange, outputRange } = deriveRanges(state, inputs);
|
|
72
|
+
state.rangesEmitted = true;
|
|
73
|
+
dlog(`STICKY[reducer ${headerTag(state)}] layout: measured=true inputRange=${JSON.stringify(inputRange)} ` +
|
|
74
|
+
`outputRange=${JSON.stringify(outputRange)}`);
|
|
75
|
+
effects.push({ kind: 'rebuild-interpolation', inputRange, outputRange });
|
|
76
|
+
return { state, effects, changed: true };
|
|
77
|
+
}
|
|
78
|
+
// Redundant-ranges guard (same cause as layout's): compare the derived result, not raw
|
|
79
|
+
// inputs. `hadEmitted` is load-bearing: an unmeasured header derives the same identity
|
|
80
|
+
// ranges the initial state holds, so without it the first dispatch reads as redundant.
|
|
81
|
+
function handleInputsChanged(state, inputs) {
|
|
82
|
+
const previousInputRange = state.inputRange;
|
|
83
|
+
const previousOutputRange = state.outputRange;
|
|
84
|
+
const hadEmitted = state.rangesEmitted;
|
|
85
|
+
const { inputRange, outputRange } = deriveRanges(state, inputs);
|
|
86
|
+
state.rangesEmitted = true;
|
|
87
|
+
if (hadEmitted &&
|
|
88
|
+
arraysEqual(previousInputRange, inputRange) &&
|
|
89
|
+
arraysEqual(previousOutputRange, outputRange)) {
|
|
90
|
+
dlog(`STICKY[reducer ${headerTag(state)}] inputs-changed: ranges unchanged, skipped rebuild`);
|
|
91
|
+
return { state, effects: [], changed: false };
|
|
92
|
+
}
|
|
93
|
+
dlog(`STICKY[reducer ${headerTag(state)}] inputs-changed: inputRange ${JSON.stringify(previousInputRange)}->` +
|
|
94
|
+
`${JSON.stringify(inputRange)} outputRange ${JSON.stringify(previousOutputRange)}->` +
|
|
95
|
+
`${JSON.stringify(outputRange)}`);
|
|
96
|
+
return {
|
|
97
|
+
state,
|
|
98
|
+
effects: [{ kind: 'rebuild-interpolation', inputRange, outputRange }],
|
|
99
|
+
changed: true,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// A freshly-rebuilt interpolation re-emits 0 to its listeners; swallow that first zero once a
|
|
103
|
+
// real value has committed. Otherwise schedule the host-tuned debounce.
|
|
104
|
+
function handleAnimatedTick(state, action, inputs) {
|
|
105
|
+
if (action.value === 0 && !state.haveReceivedInitialZeroTranslateY) {
|
|
106
|
+
state.haveReceivedInitialZeroTranslateY = true;
|
|
107
|
+
dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: swallowed re-emitted zero translateY`);
|
|
108
|
+
return { state, effects: [], changed: false };
|
|
109
|
+
}
|
|
110
|
+
dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: scheduling debounce delay=${stickyDebounceMs(inputs.os)} ` +
|
|
111
|
+
`value=${action.value}`);
|
|
112
|
+
return {
|
|
113
|
+
state,
|
|
114
|
+
effects: [
|
|
115
|
+
{
|
|
116
|
+
kind: 'schedule-debounce',
|
|
117
|
+
delay: stickyDebounceMs(inputs.os),
|
|
118
|
+
value: action.value,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
changed: false,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// Already-sitting-at-this-value bails to break a re-arriving settled value's cascade
|
|
125
|
+
// (apply-passthrough -> fresh identity -> reconnect -> another tick).
|
|
126
|
+
function handleDebounceFired(state, action) {
|
|
127
|
+
if (state.translateY === action.value) {
|
|
128
|
+
dlog(`STICKY[reducer ${headerTag(state)}] debounce-fired: already at translateY=${action.value}, no-op`);
|
|
129
|
+
return { state, effects: [], changed: false };
|
|
130
|
+
}
|
|
131
|
+
dlog(`STICKY[reducer ${headerTag(state)}] debounce-fired: committing translateY=${action.value}`);
|
|
132
|
+
state.translateY = action.value;
|
|
133
|
+
if (action.value !== 0)
|
|
134
|
+
state.haveReceivedInitialZeroTranslateY = false;
|
|
135
|
+
return {
|
|
136
|
+
state,
|
|
137
|
+
effects: [{ kind: 'apply-passthrough', translateY: action.value }],
|
|
138
|
+
changed: true,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
69
141
|
export function reduceSticky(state, action, inputs) {
|
|
70
142
|
dlog(`STICKY[reducer ${headerTag(state)}] action=${action.kind}` +
|
|
71
143
|
(action.kind === 'layout'
|
|
@@ -76,131 +148,13 @@ export function reduceSticky(state, action, inputs) {
|
|
|
76
148
|
: '') +
|
|
77
149
|
` inputs={inverted=${inputs.inverted} scrollViewHeight=${inputs.scrollViewHeight} nextHeaderLayoutY=${inputs.nextHeaderLayoutY}}`);
|
|
78
150
|
switch (action.kind) {
|
|
79
|
-
case 'layout':
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// (React bails out of re-rendering on an unchanged primitive), so a redundant onLayout never
|
|
88
|
-
// reaches the rebuild `useEffect`. This reducer has no such implicit bail-out, so it must skip
|
|
89
|
-
// the rebuild explicitly — device-confirmed (2026-08-13) that omitting this guard lets a
|
|
90
|
-
// native-driven rebuild (fresh AnimatedProps/AnimatedStyle graph, fresh native connect) commit a
|
|
91
|
-
// fresh prop identity on every redundant layout, which can itself provoke another relayout pass —
|
|
92
|
-
// an unbounded same-tick rebuild ping-pong that trips Svelte's effect_update_depth_exceeded guard.
|
|
93
|
-
const alreadyAtThisGeometry = state.measured &&
|
|
94
|
-
state.layoutY === action.y &&
|
|
95
|
-
state.layoutHeight === action.height;
|
|
96
|
-
state.layoutY = action.y;
|
|
97
|
-
state.layoutHeight = action.height;
|
|
98
|
-
state.measured = true;
|
|
99
|
-
const effects = [];
|
|
100
|
-
if (inputs.index !== undefined) {
|
|
101
|
-
effects.push({
|
|
102
|
-
kind: 'record-header-y',
|
|
103
|
-
index: inputs.index,
|
|
104
|
-
y: action.y,
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
if (alreadyAtThisGeometry && state.rangesEmitted) {
|
|
108
|
-
dlog(`STICKY[reducer ${headerTag(state)}] layout: redundant geometry, skipped rebuild`);
|
|
109
|
-
return { state, effects, changed: effects.length > 0 };
|
|
110
|
-
}
|
|
111
|
-
const { inputRange, outputRange } = deriveRanges(state, inputs);
|
|
112
|
-
state.rangesEmitted = true;
|
|
113
|
-
dlog(`STICKY[reducer ${headerTag(state)}] layout: measured=true inputRange=${JSON.stringify(inputRange)} ` +
|
|
114
|
-
`outputRange=${JSON.stringify(outputRange)}`);
|
|
115
|
-
effects.push({ kind: 'rebuild-interpolation', inputRange, outputRange });
|
|
116
|
-
return { state, effects, changed: true };
|
|
117
|
-
}
|
|
118
|
-
case 'inputs-changed': {
|
|
119
|
-
// A collision/viewport input changed (RN effect deps: inverted, scrollViewHeight,
|
|
120
|
-
// nextHeaderLayoutY): recompute the ranges and rebuild.
|
|
121
|
-
//
|
|
122
|
-
// Redundant-ranges guard (sibling of 'layout's `alreadyAtThisGeometry` above, same root
|
|
123
|
-
// cause): the adapter's own mount `$effect` re-dispatches 'inputs-changed' whenever its
|
|
124
|
-
// `inverted`/`scrollViewHeight`/`nextHeaderLayoutY` derived values re-evaluate — which can
|
|
125
|
-
// happen on an unrelated parent re-render (e.g. VirtualizedList re-deriving
|
|
126
|
-
// `nextHeaderLayoutYFor(cell.index)` off its cross-talk Map on every reactive pass) even when
|
|
127
|
-
// the COMPUTED value is identical. React gets no implicit protection here either (this is a
|
|
128
|
-
// real `useEffect` with real deps), but RN's own deps array only fires on an ACTUAL primitive
|
|
129
|
-
// change; a framework-agnostic caller re-dispatching on every derive needs the reducer itself
|
|
130
|
-
// to compare the RESULT (the ranges), not the raw inputs (which may recompute to the same
|
|
131
|
-
// ranges via different intermediate values) — device-confirmed (2026-08-13) this is a second,
|
|
132
|
-
// independent source of the same unbounded same-tick rebuild loop the 'layout' guard fixed.
|
|
133
|
-
const previousInputRange = state.inputRange;
|
|
134
|
-
const previousOutputRange = state.outputRange;
|
|
135
|
-
const hadEmitted = state.rangesEmitted;
|
|
136
|
-
const { inputRange, outputRange } = deriveRanges(state, inputs);
|
|
137
|
-
state.rangesEmitted = true;
|
|
138
|
-
// `hadEmitted` is load-bearing, not defensive: an unmeasured header derives exactly the
|
|
139
|
-
// identity ranges the initial state already holds, so without it the FIRST dispatch (Angular
|
|
140
|
-
// sends one from ngOnInit, before any layout) reads as redundant and the header never emits
|
|
141
|
-
// a rebuild at all — its wrapper then never commits. Regression-covered in
|
|
142
|
-
// sticky-header-reducer.test.ts.
|
|
143
|
-
if (hadEmitted &&
|
|
144
|
-
arraysEqual(previousInputRange, inputRange) &&
|
|
145
|
-
arraysEqual(previousOutputRange, outputRange)) {
|
|
146
|
-
dlog(`STICKY[reducer ${headerTag(state)}] inputs-changed: ranges unchanged, skipped rebuild`);
|
|
147
|
-
return { state, effects: [], changed: false };
|
|
148
|
-
}
|
|
149
|
-
dlog(`STICKY[reducer ${headerTag(state)}] inputs-changed: inputRange ${JSON.stringify(previousInputRange)}->` +
|
|
150
|
-
`${JSON.stringify(inputRange)} outputRange ${JSON.stringify(previousOutputRange)}->` +
|
|
151
|
-
`${JSON.stringify(outputRange)}`);
|
|
152
|
-
return {
|
|
153
|
-
state,
|
|
154
|
-
effects: [{ kind: 'rebuild-interpolation', inputRange, outputRange }],
|
|
155
|
-
changed: true,
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
case 'animated-tick': {
|
|
159
|
-
// A freshly-rebuilt interpolation re-emits 0 to its listeners; swallow that first zero once a
|
|
160
|
-
// real value has committed (RN). Otherwise schedule the host-tuned debounce that pushes the
|
|
161
|
-
// settled value into the committed transform for hit-testing.
|
|
162
|
-
if (action.value === 0 && !state.haveReceivedInitialZeroTranslateY) {
|
|
163
|
-
state.haveReceivedInitialZeroTranslateY = true;
|
|
164
|
-
dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: swallowed re-emitted zero translateY`);
|
|
165
|
-
return { state, effects: [], changed: false };
|
|
166
|
-
}
|
|
167
|
-
dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: scheduling debounce delay=${stickyDebounceMs(inputs.os)} ` +
|
|
168
|
-
`value=${action.value}`);
|
|
169
|
-
return {
|
|
170
|
-
state,
|
|
171
|
-
effects: [
|
|
172
|
-
{
|
|
173
|
-
kind: 'schedule-debounce',
|
|
174
|
-
delay: stickyDebounceMs(inputs.os),
|
|
175
|
-
value: action.value,
|
|
176
|
-
},
|
|
177
|
-
],
|
|
178
|
-
changed: false,
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
case 'debounce-fired': {
|
|
182
|
-
// Already sitting at this value: emit nothing. Same bail-out the 'layout' case above spells
|
|
183
|
-
// out, for the same reason - React's own header gets it free from setTranslateY (an unchanged
|
|
184
|
-
// primitive bails out of the re-render), a reducer has to say it. Device-confirmed 2026-08-18:
|
|
185
|
-
// without it a re-arriving settled value emits apply-passthrough, the adapter force-renders,
|
|
186
|
-
// the passthrough prop gets a fresh identity, the animated graph reconnects and re-emits into
|
|
187
|
-
// another tick -> another debounce -> another passthrough. One header survives it; a screen of
|
|
188
|
-
// 200 trips React's "Maximum update depth exceeded" and takes the app down.
|
|
189
|
-
if (state.translateY === action.value) {
|
|
190
|
-
dlog(`STICKY[reducer ${headerTag(state)}] debounce-fired: already at translateY=${action.value}, no-op`);
|
|
191
|
-
return { state, effects: [], changed: false };
|
|
192
|
-
}
|
|
193
|
-
// The debounce completed: commit the settled translateY. Once a NON-zero value commits, re-arm
|
|
194
|
-
// the swallow gate so the next interpolation rebuild's spurious 0 is dropped (RN).
|
|
195
|
-
dlog(`STICKY[reducer ${headerTag(state)}] debounce-fired: committing translateY=${action.value}`);
|
|
196
|
-
state.translateY = action.value;
|
|
197
|
-
if (action.value !== 0)
|
|
198
|
-
state.haveReceivedInitialZeroTranslateY = false;
|
|
199
|
-
return {
|
|
200
|
-
state,
|
|
201
|
-
effects: [{ kind: 'apply-passthrough', translateY: action.value }],
|
|
202
|
-
changed: true,
|
|
203
|
-
};
|
|
204
|
-
}
|
|
151
|
+
case 'layout':
|
|
152
|
+
return handleLayout(state, action, inputs);
|
|
153
|
+
case 'inputs-changed':
|
|
154
|
+
return handleInputsChanged(state, inputs);
|
|
155
|
+
case 'animated-tick':
|
|
156
|
+
return handleAnimatedTick(state, action, inputs);
|
|
157
|
+
case 'debounce-fired':
|
|
158
|
+
return handleDebounceFired(state, action);
|
|
205
159
|
}
|
|
206
160
|
}
|
package/build/state/touchable.js
CHANGED
|
@@ -100,11 +100,9 @@ export function createTouchableFeedbackHandlers(config, runtime, callbacks) {
|
|
|
100
100
|
},
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
|
-
// ---- RN-audited additions
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
// the React, Vue, Svelte and Angular adapters call; the names below are the RN-accurate forms and
|
|
107
|
-
// are additive on purpose, so the tree stays green while each adapter migrates.
|
|
103
|
+
// ---- RN-audited additions ----
|
|
104
|
+
// Measured against .vendors/react-native. The names below are the RN-accurate forms, kept
|
|
105
|
+
// additive so the tree stays green while each adapter migrates off the names above.
|
|
108
106
|
// RN's Touchable* family OVERRIDES Pressability's own floor with 0 — TouchableOpacity.js:195,
|
|
109
107
|
// TouchableHighlight.js:203 and TouchableWithoutFeedback.js all pass `minPressDuration: 0`. So
|
|
110
108
|
// Pressability's own default of 130 (Pressability.js:264) reaches a Touchable in RN NEVER: what holds the active visual there is the Animated fade's own duration,
|