@scalebun/react-native 2.0.0 → 2.0.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/android/src/main/java/com/scalebun/rn/ota/BundleDownloader.kt +10 -0
- package/android/src/main/java/com/scalebun/rn/ota/DeviceIntegrity.kt +75 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +18 -3
- package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +62 -1
- package/android/src/main/java/com/scalebun/rn/ota/TlsPinning.kt +118 -0
- package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +2 -0
- package/dist/scalebun.full.js +840 -114
- package/dist/scalebun.slim.js +838 -112
- package/ios/ReplaySdk.swift +65 -2
- package/lib/commonjs/bootstrap/SDKBootstrapper.js +30 -0
- package/lib/commonjs/core/config/schema.js +16 -0
- package/lib/commonjs/core/constants/version.js +1 -1
- package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +293 -5
- package/lib/commonjs/features/journey/ScaleBunScrollView.js +28 -0
- package/lib/commonjs/features/journey/autoInstrumentScroll.js +161 -0
- package/lib/commonjs/features/journey/interactionProtocol.js +131 -10
- package/lib/commonjs/features/journey/scrollContext.js +146 -0
- package/lib/commonjs/features/journey/targetGeometry.js +164 -0
- package/lib/commonjs/features/journey/touchTarget.js +152 -24
- package/lib/commonjs/features/journey/uiState.js +81 -1
- package/lib/commonjs/features/navigation/AutoScreenDetector.js +74 -5
- package/lib/commonjs/features/ota/OtaOrchestrator.js +69 -22
- package/lib/commonjs/features/session/SessionManager.js +63 -0
- package/lib/commonjs/public/ScaleBunFacade.js +18 -16
- package/lib/module/bootstrap/SDKBootstrapper.js +30 -0
- package/lib/module/core/config/schema.js +16 -0
- package/lib/module/core/constants/version.js +1 -1
- package/lib/module/features/journey/ScaleBunDebugRoot.js +295 -7
- package/lib/module/features/journey/ScaleBunScrollView.js +28 -0
- package/lib/module/features/journey/autoInstrumentScroll.js +155 -0
- package/lib/module/features/journey/interactionProtocol.js +128 -8
- package/lib/module/features/journey/scrollContext.js +135 -0
- package/lib/module/features/journey/targetGeometry.js +155 -0
- package/lib/module/features/journey/touchTarget.js +151 -24
- package/lib/module/features/journey/uiState.js +78 -1
- package/lib/module/features/navigation/AutoScreenDetector.js +74 -5
- package/lib/module/features/ota/OtaOrchestrator.js +69 -22
- package/lib/module/features/session/SessionManager.js +63 -0
- package/lib/module/public/ScaleBunFacade.js +18 -16
- package/lib/typescript/core/config/schema.d.ts +2 -0
- package/lib/typescript/core/constants/version.d.ts +1 -1
- package/lib/typescript/features/journey/autoInstrumentScroll.d.ts +46 -0
- package/lib/typescript/features/journey/interactionProtocol.d.ts +139 -1
- package/lib/typescript/features/journey/scrollContext.d.ts +85 -0
- package/lib/typescript/features/journey/targetGeometry.d.ts +114 -0
- package/lib/typescript/features/journey/touchTarget.d.ts +148 -15
- package/lib/typescript/features/journey/uiState.d.ts +39 -0
- package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +48 -2
- package/lib/typescript/features/ota/OtaOrchestrator.d.ts +7 -0
- package/lib/typescript/features/session/SessionManager.d.ts +55 -0
- package/lib/typescript/public/types.d.ts +10 -0
- package/lib/typescript/specs/NativeScaleBunOta.d.ts +2 -0
- package/package.json +2 -2
- package/src/bootstrap/SDKBootstrapper.ts +40 -0
- package/src/core/config/schema.ts +18 -0
- package/src/core/constants/version.ts +1 -1
- package/src/features/journey/ScaleBunDebugRoot.tsx +288 -7
- package/src/features/journey/ScaleBunScrollView.tsx +29 -0
- package/src/features/journey/autoInstrumentScroll.ts +155 -0
- package/src/features/journey/interactionProtocol.ts +188 -10
- package/src/features/journey/scrollContext.ts +137 -0
- package/src/features/journey/targetGeometry.ts +176 -0
- package/src/features/journey/touchTarget.ts +237 -26
- package/src/features/journey/uiState.ts +80 -1
- package/src/features/navigation/AutoScreenDetector.ts +65 -5
- package/src/features/ota/OtaOrchestrator.ts +76 -22
- package/src/features/session/SessionManager.ts +113 -0
- package/src/public/ScaleBunFacade.ts +18 -16
- package/src/public/types.ts +10 -0
- package/src/specs/NativeScaleBunOta.ts +3 -0
|
@@ -8,5 +8,5 @@
|
|
|
8
8
|
* value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
|
|
9
9
|
* version was introduced to solve.
|
|
10
10
|
*/
|
|
11
|
-
export declare const SDK_VERSION = "2.0.
|
|
11
|
+
export declare const SDK_VERSION = "2.0.2";
|
|
12
12
|
//# sourceMappingURL=version.d.ts.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RN-3, ZERO-CONFIG — make every ScrollView report its offset, not just the wrapped ones.
|
|
3
|
+
*
|
|
4
|
+
* THE PROBLEM THIS SOLVES. Scroll offsets reach the SDK from `ScaleBunScrollView` and nowhere
|
|
5
|
+
* else, so an app using React Native's own `<ScrollView>` — which is most apps — produces taps
|
|
6
|
+
* with no content depth at all. Two taps at the same screen coordinate one viewport apart then
|
|
7
|
+
* merge into one hotspot, and the heatmap shows heavy engagement at a position where, in content
|
|
8
|
+
* terms, nothing in particular is. Asking every integration to swap its scroll views is a real
|
|
9
|
+
* migration cost and will never reach the long tail.
|
|
10
|
+
*
|
|
11
|
+
* WHY IT IS OFF BY DEFAULT, and why that is not timidity. This patches React Native's own module
|
|
12
|
+
* exports. Every scroll view in the host app — including ones inside third-party libraries the
|
|
13
|
+
* app did not write — starts routing through an SDK component. The failure mode of getting that
|
|
14
|
+
* wrong is not a wrong number in a dashboard, it is the host's UI behaving differently because an
|
|
15
|
+
* analytics SDK replaced a primitive. That is a decision an integrator makes with their eyes open,
|
|
16
|
+
* not a default they discover afterwards.
|
|
17
|
+
*
|
|
18
|
+
* THE PRECEDENT, AND ITS LESSON. The SDK already patches `@react-navigation/native` this way
|
|
19
|
+
* (`AutoScreenDetector._autoHookReactNavigation`). That hook shipped BROKEN: it assigned to module
|
|
20
|
+
* exports that are getters, which throws in strict mode, inside a `catch` that discarded the
|
|
21
|
+
* error. It reported success, captured no ref, and the whole screen-name pipeline stayed empty
|
|
22
|
+
* with nothing to indicate it. So this uses the same defence that fixed it — define the property
|
|
23
|
+
* rather than assign to it, then READ IT BACK — and refuses to claim success it has not verified.
|
|
24
|
+
*
|
|
25
|
+
* WHAT IT DELIBERATELY DOES NOT PATCH. `FlatList` and `SectionList` render a `ScrollView`
|
|
26
|
+
* internally, so patching that one export covers them without touching three. Fewer patch points
|
|
27
|
+
* is fewer ways to be wrong, and RN-4's row identity comes off the fiber regardless of which
|
|
28
|
+
* component rendered it.
|
|
29
|
+
*/
|
|
30
|
+
interface PatchResult {
|
|
31
|
+
/** True only when the replacement was verified by reading it back. */
|
|
32
|
+
installed: boolean;
|
|
33
|
+
/** Why not, when it did not install — for a diagnostic the host can act on. */
|
|
34
|
+
reason?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Patch React Native's `ScrollView` export so every scroll view reports its offset.
|
|
38
|
+
*
|
|
39
|
+
* Idempotent, no-throw, and honest about failure: returns `installed: false` with a reason rather
|
|
40
|
+
* than claiming a success it did not verify.
|
|
41
|
+
*/
|
|
42
|
+
export declare function autoInstrumentScrollViews(): PatchResult;
|
|
43
|
+
/** Test seam: forget that the patch ran, so a suite can exercise it more than once. */
|
|
44
|
+
export declare function resetAutoInstrumentForTests(): void;
|
|
45
|
+
export {};
|
|
46
|
+
//# sourceMappingURL=autoInstrumentScroll.d.ts.map
|
|
@@ -8,14 +8,152 @@ export interface InteractionStartContext {
|
|
|
8
8
|
y: number;
|
|
9
9
|
target?: string;
|
|
10
10
|
targetId?: string;
|
|
11
|
+
/**
|
|
12
|
+
* WHICH kind of identity `targetId` is: an authored `testID`, a structural component path, or
|
|
13
|
+
* a bare component name.
|
|
14
|
+
*
|
|
15
|
+
* Carried because they are not equally trustworthy — a testID is a promise from the app's
|
|
16
|
+
* authors, a path is an inference that breaks when the tree is refactored — and a consumer
|
|
17
|
+
* that cannot tell them apart renders a refactor-fragile guess as a confident target.
|
|
18
|
+
*/
|
|
19
|
+
targetSource?: 'testID' | 'path' | 'component' | 'none';
|
|
11
20
|
screenName?: string;
|
|
21
|
+
/** RN-8: where `screenName` came from, so low coverage is diagnosable rather than merely visible. */
|
|
22
|
+
screenSource?: 'manual' | 'navigation' | 'none';
|
|
23
|
+
/**
|
|
24
|
+
* RN-13: the target control's rectangle as fractions of the capture base, or undefined when it
|
|
25
|
+
* could not be measured synchronously. Comparable to the tap's own normalized point, which is
|
|
26
|
+
* what makes 'heat is on this control' checkable rather than assumed.
|
|
27
|
+
*/
|
|
28
|
+
targetRect?: {
|
|
29
|
+
x: number;
|
|
30
|
+
y: number;
|
|
31
|
+
width: number;
|
|
32
|
+
height: number;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* RN-3: the content offset of the nearest ancestor scroller at finger-down, so two taps at the
|
|
36
|
+
* same screen coordinate one viewport apart do not merge. Absent when no ancestor reported one
|
|
37
|
+
* — never defaulted to 0, which is a real scroll position (the top of a list).
|
|
38
|
+
*/
|
|
39
|
+
scrollX?: number;
|
|
40
|
+
scrollY?: number;
|
|
41
|
+
/** Whether a scroll offset was available at all. 'none' means no wrapped scroller was an ancestor. */
|
|
42
|
+
scrollSource?: 'wrapper' | 'none';
|
|
43
|
+
/**
|
|
44
|
+
* RN-4: the list row's key, taken from the app's own `keyExtractor` via the cell's `cellKey`
|
|
45
|
+
* prop. Distinguishes two taps that share a RECYCLED native view — the same slot showing a
|
|
46
|
+
* different row after scrolling — which would otherwise merge into one hotspot.
|
|
47
|
+
*/
|
|
48
|
+
itemKey?: string;
|
|
49
|
+
/** The row's index at render time. Same index + different key = a recycled slot. */
|
|
50
|
+
itemIndex?: number;
|
|
51
|
+
/**
|
|
52
|
+
* RN-15: the tap's position INSIDE the target control — (0,0) its top-left, (1,1) its
|
|
53
|
+
* bottom-right. Resolution-independent, so one control is one distribution across every device
|
|
54
|
+
* size rather than a separate cloud of heat per screen width.
|
|
55
|
+
*
|
|
56
|
+
* NOT clamped. A value outside 0..1 means the tap landed outside the control's visual box and
|
|
57
|
+
* was still attributed to it, which is what `hitSlop` does — and a control whose taps cluster
|
|
58
|
+
* outside its own bounds is drawn too small, which is a finding rather than noise.
|
|
59
|
+
*/
|
|
60
|
+
localU?: number;
|
|
61
|
+
localV?: number;
|
|
12
62
|
ui?: string;
|
|
13
63
|
stateStatus: InteractionStateStatus;
|
|
14
64
|
emitAutomaticAnalytics: boolean;
|
|
15
65
|
}
|
|
16
66
|
export declare function generateInteractionId(): string;
|
|
67
|
+
/**
|
|
68
|
+
* RN-6 — THE CORRELATION CONTRACT.
|
|
69
|
+
*
|
|
70
|
+
* WHAT IS BEING JOINED, AND WHY IT IS HARD. A physical tap is observed twice: natively, by
|
|
71
|
+
* a window callback that sees ACTION_DOWN/UP and produces the coordinates; and in JS, by a
|
|
72
|
+
* capture-phase touch handler that produces the IDENTITY — which control was hit, what UI
|
|
73
|
+
* state was declared, which screen. Both sides mint their own id and neither can derive
|
|
74
|
+
* the other's: native builds `ixn-a-<process nonce>-<MotionEvent ts>` from a clock JS
|
|
75
|
+
* cannot read, and JS builds `ixj-<Date.now()>-<random>`. So the two observations have to
|
|
76
|
+
* be JOINED, and the only shared quantity is time.
|
|
77
|
+
*
|
|
78
|
+
* WHY THE OLD JOIN WAS DANGEROUS RATHER THAN MERELY APPROXIMATE. It took whichever start
|
|
79
|
+
* was nearest in time within ±1500 ms and used it unconditionally. The fields it carries
|
|
80
|
+
* across are `target`, `targetId`, `ui`, `stateStatus` and `screenName`; the coordinates
|
|
81
|
+
* come from the native side and are always right. So a wrong pairing does not produce
|
|
82
|
+
* obviously broken data — it produces heat in the CORRECT position attributed to the
|
|
83
|
+
* WRONG control, in the wrong UI state, on the wrong screen. Nothing downstream can
|
|
84
|
+
* detect that, because every field is individually well-formed.
|
|
85
|
+
*
|
|
86
|
+
* 1500 ms is a very long time on a touch surface. A deliberate double-tap is 150–300 ms
|
|
87
|
+
* apart; an impatient tapper is faster. So "nearest within 1500 ms" routinely had more
|
|
88
|
+
* than one candidate, and picked one with no record that it had a choice.
|
|
89
|
+
*
|
|
90
|
+
* WHAT THIS REPLACES IT WITH — three outcomes instead of one, and a refusal:
|
|
91
|
+
*
|
|
92
|
+
* 'exact' one candidate is clearly closest: the runner-up is at least
|
|
93
|
+
* `marginMs` further away. The join is used.
|
|
94
|
+
* 'ambiguous' two candidates are within `marginMs` of each other, so choosing
|
|
95
|
+
* between them is a coin flip. The join is REFUSED and the caller must
|
|
96
|
+
* emit the interaction WITHOUT js-derived identity. A tap with no target
|
|
97
|
+
* is honest and visibly incomplete; a tap with the wrong target is
|
|
98
|
+
* indistinguishable from a correct one and corrupts every aggregate it
|
|
99
|
+
* enters.
|
|
100
|
+
* 'none' nothing within tolerance. Same refusal, different reason — worth
|
|
101
|
+
* separating because 'none' means the JS handler never fired (a real
|
|
102
|
+
* capture gap) while 'ambiguous' means it fired too often to attribute.
|
|
103
|
+
*
|
|
104
|
+
* It also reports `deltaMs` and `runnerUpDeltaMs` so the outcome is auditable after the
|
|
105
|
+
* fact rather than being a claim this comment makes.
|
|
106
|
+
*
|
|
107
|
+
* WHAT IT DELIBERATELY DOES NOT DO: use position to disambiguate. The native event carries
|
|
108
|
+
* raw px in the capture view and normalized 0..1 coordinates; the JS start carries its own
|
|
109
|
+
* x/y in a different space (dp in the root view). Comparing them needs a proven scale
|
|
110
|
+
* factor between the two, and getting it wrong would REJECT valid pairings — strictly
|
|
111
|
+
* worse than today. That calibration needs a device run, so it is a follow-up, not a
|
|
112
|
+
* guess made here.
|
|
113
|
+
*/
|
|
114
|
+
export type CorrelationOutcome = 'exact' | 'ambiguous' | 'none';
|
|
115
|
+
export interface CorrelationResult<T> {
|
|
116
|
+
/** Present only when `outcome` is 'exact'. Never a best-guess. */
|
|
117
|
+
start?: T;
|
|
118
|
+
/** Index into the input array, so the caller can consume it. -1 unless 'exact'. */
|
|
119
|
+
index: number;
|
|
120
|
+
outcome: CorrelationOutcome;
|
|
121
|
+
/** Distance to the chosen (or best rejected) candidate; null when there was none. */
|
|
122
|
+
deltaMs: number | null;
|
|
123
|
+
/** Distance to the second candidate — what makes 'ambiguous' auditable. */
|
|
124
|
+
runnerUpDeltaMs: number | null;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* How far apart a native event and a JS start may be and still be the same touch.
|
|
128
|
+
*
|
|
129
|
+
* Unchanged at 1500 ms from the previous implementation, deliberately: narrowing it is a
|
|
130
|
+
* separate, measurable decision about how late the JS handler can run on a slow device,
|
|
131
|
+
* and bundling it into this change would make any regression impossible to attribute.
|
|
132
|
+
*/
|
|
133
|
+
export declare const CORRELATION_TOLERANCE_MS = 1500;
|
|
134
|
+
/**
|
|
135
|
+
* How much closer the winner must be than the runner-up to count as identified.
|
|
136
|
+
*
|
|
137
|
+
* 100 ms because that is comfortably below the fastest deliberate double-tap (~150 ms):
|
|
138
|
+
* when two candidate starts are within 100 ms of each other in distance, the native event
|
|
139
|
+
* sits effectively between them and the "nearest" one is a coin flip.
|
|
140
|
+
*/
|
|
141
|
+
export declare const CORRELATION_MARGIN_MS = 100;
|
|
17
142
|
/** Match a native bridge event to the JS evidence sampled at the same finger-down. */
|
|
18
|
-
export declare function
|
|
143
|
+
export declare function correlateInteraction<T extends InteractionStartContext>(starts: readonly T[], occurredAt: number, toleranceMs?: number, marginMs?: number): CorrelationResult<T>;
|
|
144
|
+
/**
|
|
145
|
+
* Drop starts that can no longer match anything, in place.
|
|
146
|
+
*
|
|
147
|
+
* WHY IT MATTERS BEYOND MEMORY. A start older than the tolerance is unreachable, but while
|
|
148
|
+
* it sits in the list it is still a CANDIDATE for the ambiguity test — so a stale entry can
|
|
149
|
+
* turn a perfectly clear pairing into a refusal, and an accumulating list makes that more
|
|
150
|
+
* likely the longer a session runs. Pruning is therefore part of the correctness of the
|
|
151
|
+
* join, not housekeeping.
|
|
152
|
+
*
|
|
153
|
+
* Mutates rather than returning a copy because the caller holds it in a ref that the native
|
|
154
|
+
* listener and the touch handler both read.
|
|
155
|
+
*/
|
|
156
|
+
export declare function pruneInteractionStarts<T extends InteractionStartContext>(starts: T[], now: number, toleranceMs?: number): number;
|
|
19
157
|
/** Analytics is a projection of the same evidence; no second click is invented. */
|
|
20
158
|
export declare function automaticInteractionProperties(payload: Record<string, unknown>, screenName: string | undefined, canonicalMirror: boolean): Record<string, unknown>;
|
|
21
159
|
//# sourceMappingURL=interactionProtocol.d.ts.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RN-3 — WHERE IN THE CONTENT A TAP HAPPENED, not just where on the glass.
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT. A tap carries screen coordinates. Two taps at the SAME screen coordinate, one
|
|
5
|
+
* viewport apart in a scrolling list, are two different controls — and with no scroll offset they
|
|
6
|
+
* are indistinguishable, so they merge into one hotspot. The heatmap then shows heavy engagement
|
|
7
|
+
* at a position where, in content terms, nothing in particular is.
|
|
8
|
+
*
|
|
9
|
+
* That is not a rendering nicety. Scroll offset is a PLACEMENT dimension (which content was under
|
|
10
|
+
* the finger) and a CACHE dimension (two states of one route that must not share a backdrop), so
|
|
11
|
+
* its absence corrupts both the aggregate and the picture drawn beneath it.
|
|
12
|
+
*
|
|
13
|
+
* WHY A REGISTRY RATHER THAN A SINGLE NUMBER. `ScrollTracker` already accumulates
|
|
14
|
+
* `previousOffsetX/Y`, but as ONE global pair with no idea which scroller moved. A screen with a
|
|
15
|
+
* vertical list containing a horizontal carousel has two scrollers, and whichever fired last
|
|
16
|
+
* overwrites the other — so the vertical position of a tap inside the carousel is whatever the
|
|
17
|
+
* carousel's horizontal offset happened to leave behind. A single global scrollY cannot express a
|
|
18
|
+
* nested scroller, and silently reports a wrong number rather than none.
|
|
19
|
+
*
|
|
20
|
+
* So offsets are keyed by the scroller's NATIVE TAG, which is the one identity both ends of this
|
|
21
|
+
* can see: the wrapper holds a ref to its own ScrollView, and the touch handler's fiber walk
|
|
22
|
+
* passes the same host instances on its way up. Matching on anything else — a name, an index, a
|
|
23
|
+
* generated id — would require the two sides to agree on a convention, and a convention that can
|
|
24
|
+
* drift is a join that can silently attach the wrong scroller's offset.
|
|
25
|
+
*
|
|
26
|
+
* WHAT THIS DELIBERATELY DOES NOT DO: guess. If no ancestor of the tapped node has a recorded
|
|
27
|
+
* offset, the answer is null, not zero. Zero is a real scroll position — the top of a list — and
|
|
28
|
+
* reporting it for "we do not know" would place every unmeasured tap at the top of its content,
|
|
29
|
+
* which is both wrong and indistinguishable from a genuine tap there.
|
|
30
|
+
*
|
|
31
|
+
* KNOWN LIMIT, stated because it bounds what this can deliver: offsets only arrive from
|
|
32
|
+
* `ScaleBunScrollView`, which an app has to opt into. A plain `<ScrollView>` reports nothing, so
|
|
33
|
+
* `scrollSource` is `'none'` and the tap has no content depth. Making it automatic means patching
|
|
34
|
+
* React Native's own exports — the SDK already does that for `@react-navigation/native` — and that
|
|
35
|
+
* is a decision with a much larger blast radius, not one to make silently inside a data fix.
|
|
36
|
+
*/
|
|
37
|
+
export interface ScrollOffset {
|
|
38
|
+
x: number;
|
|
39
|
+
y: number;
|
|
40
|
+
/** When it was recorded; used only to expire entries, never reported. */
|
|
41
|
+
at: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* How long a recorded offset stays usable.
|
|
45
|
+
*
|
|
46
|
+
* A scroller that has not moved in this long may well have been unmounted — a list on a screen the
|
|
47
|
+
* user left — and its stale offset must not be attached to a tap on a DIFFERENT screen that happens
|
|
48
|
+
* to reuse the same native tag. Tags are recycled by the platform, which makes this a correctness
|
|
49
|
+
* bound rather than a memory one.
|
|
50
|
+
*
|
|
51
|
+
* Five minutes is far longer than any plausible gap between scrolling a list and tapping in it,
|
|
52
|
+
* and far shorter than a session.
|
|
53
|
+
*/
|
|
54
|
+
export declare const SCROLL_OFFSET_TTL_MS: number;
|
|
55
|
+
/**
|
|
56
|
+
* The native tag of a host instance, across the shapes React Native has used.
|
|
57
|
+
*
|
|
58
|
+
* Fabric exposes `__nativeTag`; the old architecture used `_nativeTag`. Probing both means this
|
|
59
|
+
* keeps working on either architecture, and returns null rather than throwing on anything else —
|
|
60
|
+
* a scroll offset is never worth a crash.
|
|
61
|
+
*/
|
|
62
|
+
export declare function nativeTagOf(node: unknown): number | null;
|
|
63
|
+
/** Record where a scroller currently sits. Called on every scroll event of a wrapped scroller. */
|
|
64
|
+
export declare function recordScrollOffset(tag: number | null, x: number, y: number, now?: number): void;
|
|
65
|
+
/**
|
|
66
|
+
* The offset of the NEAREST ancestor scroller that has one, given the tapped node's ancestor tags
|
|
67
|
+
* ordered innermost first.
|
|
68
|
+
*
|
|
69
|
+
* Nearest wins because that is the scroller the content under the finger actually moved with. A
|
|
70
|
+
* tap inside a horizontal carousel inside a vertical list belongs to the carousel's content
|
|
71
|
+
* position; attributing it to the outer list would place it by how far the PAGE had scrolled,
|
|
72
|
+
* which is the merge this whole module exists to prevent.
|
|
73
|
+
*
|
|
74
|
+
* Returns null when no ancestor has a recorded offset — never a zero.
|
|
75
|
+
*/
|
|
76
|
+
export declare function nearestRecordedOffset(ancestorTags: readonly (number | null)[], now?: number): {
|
|
77
|
+
x: number;
|
|
78
|
+
y: number;
|
|
79
|
+
tag: number;
|
|
80
|
+
} | null;
|
|
81
|
+
/** Forget everything. Called between sessions so one recording cannot inherit another's scrollers. */
|
|
82
|
+
export declare function clearScrollOffsets(): void;
|
|
83
|
+
/** How many scrollers are currently tracked — for the leak assertion in tests. */
|
|
84
|
+
export declare function trackedScrollerCount(): number;
|
|
85
|
+
//# sourceMappingURL=scrollContext.d.ts.map
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RN-13 — THE TARGET'S RECTANGLE, without which "placed on Add to Cart" is unprovable.
|
|
3
|
+
*
|
|
4
|
+
* WHAT IS MISSING WITHOUT IT. A tap carries a point and an identity. That is enough to say "this
|
|
5
|
+
* tap resolved to the control named `checkout-cta`" and NOT enough to say the heat is actually ON
|
|
6
|
+
* that control — those are different claims, and only the second is what a heatmap draws. With no
|
|
7
|
+
* rect, an off-by-a-status-bar coordinate transform, a stale layout, or a mispaired identity all
|
|
8
|
+
* produce output that looks correct: a marker somewhere on the screen, labelled with a real
|
|
9
|
+
* control's name. The rect is what makes `heat ∈ target` decidable rather than assumed, which is
|
|
10
|
+
* why the plan moved this to P0 and ships it WITH semantic identity rather than after.
|
|
11
|
+
*
|
|
12
|
+
* WHY A SYNCHRONOUS MEASUREMENT IS THE WHOLE DESIGN CONSTRAINT. The tap handler runs synchronously
|
|
13
|
+
* and the interaction is emitted shortly after. React Native's classic measurement APIs
|
|
14
|
+
* (`measure`, `measureInWindow`) are CALLBACK-based, so a rect requested at finger-down arrives
|
|
15
|
+
* after the handler has returned and possibly after the event has been emitted. An implementation
|
|
16
|
+
* built on them would attach whichever rect happened to have resolved in time — sometimes the
|
|
17
|
+
* right one, sometimes the previous tap's, with no way to tell the two apart afterwards. That is
|
|
18
|
+
* the failure this module refuses to have.
|
|
19
|
+
*
|
|
20
|
+
* The New Architecture's host components expose `unstable_getBoundingClientRect()`, which returns
|
|
21
|
+
* a DOMRect SYNCHRONOUSLY. So the rect is either read in the same tick as the touch, or it is
|
|
22
|
+
* null. There is no third option and no best-effort.
|
|
23
|
+
*
|
|
24
|
+
* THE `unstable_` PREFIX IS LOAD-BEARING and is why every access here is a probe rather than a
|
|
25
|
+
* call. The method may be renamed when it stabilises, may be absent on the old architecture
|
|
26
|
+
* entirely, and may throw on a node that has been unmounted between the touch and the read. Each
|
|
27
|
+
* of those degrades to `null` — the SDK reports that it could not measure, which is a fact, rather
|
|
28
|
+
* than a rectangle it invented, which would be indistinguishable from a measured one.
|
|
29
|
+
*/
|
|
30
|
+
/** A rectangle in the same coordinate space as `pageX`/`pageY`: the RN root view. */
|
|
31
|
+
export interface ElementRect {
|
|
32
|
+
x: number;
|
|
33
|
+
y: number;
|
|
34
|
+
width: number;
|
|
35
|
+
height: number;
|
|
36
|
+
}
|
|
37
|
+
/** A rectangle expressed as fractions of the capture base, directly comparable to a tap's normalized point. */
|
|
38
|
+
export interface NormalizedRect {
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
width: number;
|
|
42
|
+
height: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Read a node's rectangle synchronously, or return null.
|
|
46
|
+
*
|
|
47
|
+
* Both spellings are probed: `unstable_getBoundingClientRect` is what React Native 0.81 ships, and
|
|
48
|
+
* the unprefixed name is what it becomes if the API stabilises. Probing for both means the day it
|
|
49
|
+
* is renamed the SDK keeps working instead of silently losing geometry — and silently is exactly
|
|
50
|
+
* how it would go, because a missing rect is a legitimate outcome that produces no error.
|
|
51
|
+
*/
|
|
52
|
+
export declare function readRectSync(node: unknown): ElementRect | null;
|
|
53
|
+
/**
|
|
54
|
+
* Express a rect as fractions of the capture base, so it is comparable to the tap's normalized point.
|
|
55
|
+
*
|
|
56
|
+
* WHY THIS SHARES THE TAP'S BASE AND NOT A WINDOW SIZE. The tap is normalized against the measured
|
|
57
|
+
* ROOT VIEW — the exact view the screenshot captures — precisely because `Dimensions.get('window')`
|
|
58
|
+
* excludes system insets differently across devices and pushes markers about 10% off vertically.
|
|
59
|
+
* A rect normalized against a different base than the point it will be compared with produces a
|
|
60
|
+
* containment test that is wrong by that same offset, which would read as "the heat is just outside
|
|
61
|
+
* the button" on every device with a status bar. Same base, or no rect.
|
|
62
|
+
*
|
|
63
|
+
* Values are NOT clamped to 0..1. A control genuinely extending past the capture base (a row
|
|
64
|
+
* scrolled half off-screen) has a rect that runs past the edge, and clamping it would silently
|
|
65
|
+
* enlarge the containment test — turning a tap that missed the visible part of a control into a hit.
|
|
66
|
+
*/
|
|
67
|
+
export declare function normalizeRect(rect: ElementRect | null, base: {
|
|
68
|
+
w: number;
|
|
69
|
+
h: number;
|
|
70
|
+
} | null): NormalizedRect | null;
|
|
71
|
+
/**
|
|
72
|
+
* WHERE IN THE CONTROL the tap landed — RN-15.
|
|
73
|
+
*
|
|
74
|
+
* `u` and `v` are the tap's position as fractions of the target's own box: (0,0) is its top-left,
|
|
75
|
+
* (1,1) its bottom-right, (0.5,0.5) dead centre. This is what turns "they tapped Add to Cart" into
|
|
76
|
+
* "they tapped the left edge of Add to Cart", which is the difference between knowing a control is
|
|
77
|
+
* used and knowing whether its hit area is right.
|
|
78
|
+
*
|
|
79
|
+
* RESOLUTION-INDEPENDENT BY CONSTRUCTION, which is the point on mobile. A screen-space heatmap
|
|
80
|
+
* aggregates a control differently on every device size, so a button that sits at 40% width on a
|
|
81
|
+
* phone and 25% on a tablet produces two clouds of heat that never combine. Local coordinates are
|
|
82
|
+
* the same numbers on both, so one control is one distribution regardless of what it is rendered on.
|
|
83
|
+
*
|
|
84
|
+
* DELIBERATELY NOT CLAMPED, and this is the interesting part rather than an oversight. A value
|
|
85
|
+
* outside 0..1 means the tap landed OUTSIDE the control's visual box and was still attributed to
|
|
86
|
+
* it — which is exactly what `hitSlop` does, and it is real: `hitSlop={24}` makes a control
|
|
87
|
+
* respond to touches two dozen points beyond its own edge. Clamping would erase the one signal
|
|
88
|
+
* that distinguishes "tapped the button" from "tapped near the button and the button took it",
|
|
89
|
+
* and that distinction is a design finding: a control whose taps cluster outside its own bounds is
|
|
90
|
+
* drawn too small.
|
|
91
|
+
*
|
|
92
|
+
* Returns null, not a centre, when either input is missing. A fabricated (0.5, 0.5) would sit in
|
|
93
|
+
* the middle of every distribution and look exactly like a deliberate central tap.
|
|
94
|
+
*/
|
|
95
|
+
export declare function localPoint(rect: NormalizedRect | null, nx: number | null | undefined, ny: number | null | undefined): {
|
|
96
|
+
u: number;
|
|
97
|
+
v: number;
|
|
98
|
+
} | null;
|
|
99
|
+
/**
|
|
100
|
+
* Is the tap's normalized point inside the target's normalized rect?
|
|
101
|
+
*
|
|
102
|
+
* THE PREDICATE THE WHOLE MODULE EXISTS FOR. A heatmap that draws a marker labelled with a
|
|
103
|
+
* control's name is asserting this, and until now nothing checked it.
|
|
104
|
+
*
|
|
105
|
+
* Edges are INCLUSIVE. A tap on a button's exact border is a tap on the button — exclusive edges
|
|
106
|
+
* would report a sliver of every control as a miss, and at the resolution these coordinates carry
|
|
107
|
+
* that sliver is not a real distinction.
|
|
108
|
+
*
|
|
109
|
+
* Returns null, not false, when either input is missing. "The point is outside the control" and
|
|
110
|
+
* "we could not measure the control" are different findings with different fixes, and collapsing
|
|
111
|
+
* them into `false` would make an unmeasurable control look like a placement failure.
|
|
112
|
+
*/
|
|
113
|
+
export declare function containsNormalizedPoint(rect: NormalizedRect | null, nx: number | null | undefined, ny: number | null | undefined): boolean | null;
|
|
114
|
+
//# sourceMappingURL=targetGeometry.d.ts.map
|
|
@@ -9,33 +9,166 @@
|
|
|
9
9
|
* WHY THE FIBER. There is no DOM. The only place a tapped view's identity exists in JS is the React
|
|
10
10
|
* fiber hanging off the touch event (`_targetInst`), which carries `memoizedProps` — where
|
|
11
11
|
* `accessibilityLabel` and `testID` live. This is a React internal, so every access is defensive and
|
|
12
|
-
* the whole thing degrades to `null` rather than throwing:
|
|
12
|
+
* the whole thing degrades to `null` rather than throwing: an analytics label is never worth a crash.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
* RN-2 — THE IDENTITY LADDER USED TO CAPTURE USER TEXT, AND THAT IS A PRIVACY DEFECT, NOT A
|
|
16
|
+
* TRADE-OFF.
|
|
17
|
+
*
|
|
18
|
+
* The previous preference order was:
|
|
19
|
+
*
|
|
20
|
+
* 1. accessibilityLabel 2. testID 3. a literal string child 4. nearest component
|
|
21
|
+
*
|
|
22
|
+
* Ranks 1 and 3 are FREE TEXT authored for humans, and in a real app they routinely contain user
|
|
23
|
+
* data. An accessible order row is labelled "Order 1042 for mohamed@scalebun.com". A pay button
|
|
24
|
+
* reads "Pay EGP 1,442". A contact row's caption is a person's name. Rank 1 was preferred over
|
|
25
|
+
* `testID`, so the leakiest source won whenever it existed.
|
|
26
|
+
*
|
|
27
|
+
* Two separate harms, and the second is easy to miss:
|
|
28
|
+
*
|
|
29
|
+
* PRIVACY. `describeTouchTarget`'s output is persisted as the interaction's `target`. So names,
|
|
30
|
+
* email addresses and amounts were being written into an analytics store, under a field nobody
|
|
31
|
+
* would think to audit for PII. The same product already found 27 rows in `replay_events`
|
|
32
|
+
* carrying email addresses through a different route; this was a second one.
|
|
33
|
+
*
|
|
34
|
+
* CORRECTNESS. `target` is an AGGREGATION KEY. Keyed on text that varies per user, a single
|
|
35
|
+
* button produces one bucket per person — so the grouping this feature exists to provide is
|
|
36
|
+
* destroyed precisely in the screens that matter most. A leaky key is also a useless key.
|
|
37
|
+
*
|
|
38
|
+
* SO THE LADDER IS NOW DERIVED FROM CODE, NEVER FROM CONTENT:
|
|
39
|
+
*
|
|
40
|
+
* 1. `testID` — authored to identify, stable across copy edits, and by convention never
|
|
41
|
+
* user data. The strongest signal and now the first.
|
|
42
|
+
* 2. `path` — the component-name chain from the tapped node upward, e.g.
|
|
43
|
+
* `CartScreen>OrderRow>Pressable`. Structural: it changes when the code
|
|
44
|
+
* changes, not when the data does. This is what makes the ladder useful in
|
|
45
|
+
* the overwhelming majority of apps, which have no testIDs — without it,
|
|
46
|
+
* removing free text would have meant removing identity.
|
|
47
|
+
* 3. `component` — the nearest named component alone. Coarse, but a code identifier.
|
|
48
|
+
*
|
|
49
|
+
* Free text is still RESOLVABLE, because a team that has audited its own labels may legitimately
|
|
50
|
+
* want them, but it is opt-in per call (`includeText`) and it is NEVER what
|
|
51
|
+
* `describeTouchTarget` returns. Default-off matters more than the option: a default that leaks
|
|
52
|
+
* is a leak, whatever the documentation says.
|
|
53
|
+
*
|
|
54
|
+
* WHY THIS SHIPS WITH RN-1 (persistence) RATHER THAN AFTER IT. RN-1 makes the resolved target
|
|
55
|
+
* actually reach the backend. Landing that first would persist the leaky ladder and create a
|
|
56
|
+
* historical PII problem to clean up; landing the sanitised ladder first and persistence second
|
|
57
|
+
* leaves a window where the feature looks broken. They are one change.
|
|
58
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
19
59
|
*/
|
|
20
60
|
export interface TouchTarget {
|
|
21
|
-
|
|
61
|
+
/** Authored identifier. Safe to persist and to aggregate on. */
|
|
22
62
|
testID?: string;
|
|
23
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Structural path of component names, tapped node first, e.g. `Pressable<OrderRow<CartScreen`.
|
|
65
|
+
* Derived from the code's shape, so it never contains user data.
|
|
66
|
+
*/
|
|
67
|
+
path?: string;
|
|
68
|
+
/** Nearest named component. A code identifier, safe. */
|
|
24
69
|
component?: string;
|
|
70
|
+
/**
|
|
71
|
+
* FREE TEXT — may contain user data. Populated ONLY when `includeText` is requested, and never
|
|
72
|
+
* returned by `describeTouchTarget`. Treat as unsafe to persist without an audit.
|
|
73
|
+
*/
|
|
74
|
+
accessibilityLabel?: string;
|
|
75
|
+
/** FREE TEXT — the visible caption. Same warning as `accessibilityLabel`. */
|
|
76
|
+
text?: string;
|
|
77
|
+
/**
|
|
78
|
+
* RN-13 — the host node the IDENTITY was taken from, so a rect read off it describes the
|
|
79
|
+
* same element the tap was attributed to.
|
|
80
|
+
*
|
|
81
|
+
* This is the reason the node is captured during the identity walk rather than by a second,
|
|
82
|
+
* independent walk: two walks can disagree about which ancestor is "the control", and a rect
|
|
83
|
+
* belonging to a different element than the name is worse than no rect at all — it would make
|
|
84
|
+
* `heat ∈ target` answer confidently about the wrong box.
|
|
85
|
+
*
|
|
86
|
+
* Deliberately NOT serialisable and never persisted. Callers read a rectangle off it and
|
|
87
|
+
* discard it; nothing downstream should hold a React internal.
|
|
88
|
+
*/
|
|
89
|
+
node?: unknown;
|
|
90
|
+
/**
|
|
91
|
+
* RN-3 — every ancestor host instance from the tapped node upward, INNERMOST FIRST.
|
|
92
|
+
*
|
|
93
|
+
* Collected on the identity walk rather than by a second traversal, for the same reason the
|
|
94
|
+
* rect's node is: two walks can disagree about the chain, and attributing a tap to the wrong
|
|
95
|
+
* scroller's offset places it by how far a DIFFERENT container had scrolled. Order is the
|
|
96
|
+
* contract — `nearestRecordedOffset` takes the first ancestor that has an offset, because that
|
|
97
|
+
* is the scroller the content under the finger actually moved with.
|
|
98
|
+
*
|
|
99
|
+
* Like `node`, these are React internals: read a native tag off them and discard.
|
|
100
|
+
*/
|
|
101
|
+
ancestors?: unknown[];
|
|
102
|
+
/**
|
|
103
|
+
* RN-4 — the list row's own identity, for FREE.
|
|
104
|
+
*
|
|
105
|
+
* MEASURED BEFORE IT WAS DESIGNED, because the obvious fix was to make every app author a
|
|
106
|
+
* testID per row and that would have been a large, permanent burden on every integration. It is
|
|
107
|
+
* unnecessary. React Native's `VirtualizedList` builds each cell as
|
|
108
|
+
*
|
|
109
|
+
* const key = VirtualizedList._keyExtractor(item, ii, this.props); // line 797
|
|
110
|
+
* <CellRenderer cellKey={key} index={ii} key={key} ... /> // line 817
|
|
111
|
+
*
|
|
112
|
+
* so the app's OWN `keyExtractor` output — which every correct list already provides, because
|
|
113
|
+
* React requires keys — sits on the cell fiber as an ordinary prop. The walk that resolves
|
|
114
|
+
* identity already passes that fiber and simply never read it.
|
|
115
|
+
*
|
|
116
|
+
* `cellKey` is preferred over the fiber's `key` field: it is the same value, but reached
|
|
117
|
+
* through `memoizedProps` like every other prop this module reads, rather than through a React
|
|
118
|
+
* internal whose exposure is not part of any contract.
|
|
119
|
+
*
|
|
120
|
+
* WHY THIS MATTERS MORE THAN AN ORDINARY LABEL. A virtualised list REUSES native views: row 40
|
|
121
|
+
* scrolls into the view row 1 vacated. Without the cell key those two taps share a native
|
|
122
|
+
* identity and a screen position, so they merge — one hotspot on a row nobody in particular
|
|
123
|
+
* tapped. `itemIndex` is carried alongside precisely so the recycling is visible: the same index
|
|
124
|
+
* with a different key is a recycled slot, and that is a fact worth being able to prove rather
|
|
125
|
+
* than infer.
|
|
126
|
+
*/
|
|
127
|
+
itemKey?: string;
|
|
128
|
+
/** The cell's position in the list at render time. See `itemKey` for why both are kept. */
|
|
129
|
+
itemIndex?: number;
|
|
130
|
+
}
|
|
131
|
+
/** Where a resolved identity came from — so a consumer can tell an authored id from a guess. */
|
|
132
|
+
export type TargetIdentitySource = 'testID' | 'path' | 'component' | 'none';
|
|
133
|
+
export interface ResolveTouchTargetOptions {
|
|
134
|
+
/** How far up the tree to look. */
|
|
135
|
+
maxDepth?: number;
|
|
136
|
+
/**
|
|
137
|
+
* Collect `accessibilityLabel` and the visible caption.
|
|
138
|
+
*
|
|
139
|
+
* OFF BY DEFAULT AND MEANT TO STAY THAT WAY. Turning it on means user-authored text may be
|
|
140
|
+
* carried out of the app, so it belongs to a team that has audited its own labels — not to a
|
|
141
|
+
* default. Even when on, the text is never used as the identity.
|
|
142
|
+
*/
|
|
143
|
+
includeText?: boolean;
|
|
25
144
|
}
|
|
26
145
|
/**
|
|
27
146
|
* Walk up from the touched fiber collecting the best identity available.
|
|
28
147
|
*
|
|
29
|
-
* Keeps walking after a hit so a wrapper's `testID` can still be picked up when the leaf
|
|
30
|
-
*
|
|
31
|
-
* that pair.
|
|
148
|
+
* Keeps walking after a hit so a wrapper's `testID` can still be picked up when the leaf had none,
|
|
149
|
+
* and stops as soon as a `testID` is known AND the path is full — nothing above can improve on that.
|
|
32
150
|
*/
|
|
33
|
-
export declare function resolveTouchTarget(event: unknown,
|
|
151
|
+
export declare function resolveTouchTarget(event: unknown, optsOrMaxDepth?: ResolveTouchTargetOptions | number): TouchTarget | null;
|
|
34
152
|
/**
|
|
35
|
-
* One short
|
|
153
|
+
* One short, STABLE, non-identifying label for the tapped thing.
|
|
36
154
|
*
|
|
37
|
-
* This is what ends up grouping the Events explorer
|
|
38
|
-
*
|
|
155
|
+
* This is what ends up grouping the Events explorer and what gets persisted as the interaction's
|
|
156
|
+
* target, so it must satisfy two constraints at once: stable across copy edits, and free of user
|
|
157
|
+
* data. Both rule out visible text, which is why the free-text fields are not consulted here even
|
|
158
|
+
* when they have been resolved — see the RN-2 note in the header.
|
|
39
159
|
*/
|
|
40
160
|
export declare function describeTouchTarget(t: TouchTarget | null): string | undefined;
|
|
161
|
+
/**
|
|
162
|
+
* The identity plus WHERE IT CAME FROM.
|
|
163
|
+
*
|
|
164
|
+
* A consumer cannot treat an authored `testID` and a structural `path` as equally trustworthy: the
|
|
165
|
+
* first is a promise from the app's authors that this control is this control, the second is an
|
|
166
|
+
* inference that breaks when the tree is refactored. Returning the source lets the accounting lane
|
|
167
|
+
* say WHY a tap could not be attributed, instead of reporting a missing target and leaving the
|
|
168
|
+
* reader to guess whether the control had no identity or the walk failed.
|
|
169
|
+
*/
|
|
170
|
+
export declare function targetIdentity(t: TouchTarget | null): {
|
|
171
|
+
id?: string;
|
|
172
|
+
source: TargetIdentitySource;
|
|
173
|
+
};
|
|
41
174
|
//# sourceMappingURL=touchTarget.d.ts.map
|
|
@@ -50,4 +50,43 @@ export declare function clearUiState(name?: string): void;
|
|
|
50
50
|
* open"; on RN there is nothing to look at, so the honest answer is silence.
|
|
51
51
|
*/
|
|
52
52
|
export declare function uiStateSignature(): string | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* RN-11 — TELL SOMEONE THE STATE CHANGED, so a frame can exist for it.
|
|
55
|
+
*
|
|
56
|
+
* THE GAP. Declaring a state used to mutate a Map and nothing else. Nothing captured a frame, so
|
|
57
|
+
* the visual for a declared state existed only if a screenshot happened to be taken for some other
|
|
58
|
+
* reason — a navigation, a timer, or the interaction capture that fires AFTER the touch and
|
|
59
|
+
* photographs the outcome. A state could therefore be recorded as a fact with no picture of it
|
|
60
|
+
* anywhere, or with a picture of the state that replaced it.
|
|
61
|
+
*
|
|
62
|
+
* The native capture reasons are NAVIGATION, MANUAL, INTERACTION and TIMER. None of them is "the
|
|
63
|
+
* UI changed shape", which is exactly the moment a heatmap needs a frame.
|
|
64
|
+
*
|
|
65
|
+
* FIRING ON A REAL CHANGE ONLY, AND WHY THAT IS THE SDK'S JOB. The advice above says to call
|
|
66
|
+
* `setUiState` when the state changes rather than on every render — but that is advice to the
|
|
67
|
+
* host, not a guarantee. A component that declares its state inside render is ordinary React, and
|
|
68
|
+
* trusting the caller would mean a screenshot per render: a performance disaster in exactly the
|
|
69
|
+
* apps that instrument most carefully. So a redundant set — same dimension, same value — is now a
|
|
70
|
+
* no-op, and the change test lives here where it cannot be got wrong by a host.
|
|
71
|
+
*
|
|
72
|
+
* COALESCED, because a transition usually declares several dimensions in one tick ("drawer:open"
|
|
73
|
+
* and "overlay:visible" belong to one visual change). Notifying per call would capture the
|
|
74
|
+
* intermediate shapes, which are states the user never saw.
|
|
75
|
+
*
|
|
76
|
+
* The listener is deliberately NOT given the signature. A subscriber that wants it calls
|
|
77
|
+
* `uiStateSignature()` at the moment it acts, which is the value that will actually be attached
|
|
78
|
+
* to the next tap; handing over a snapshot invites acting on a state that has already moved on.
|
|
79
|
+
*/
|
|
80
|
+
export type UiStateChangeListener = () => void;
|
|
81
|
+
/**
|
|
82
|
+
* Subscribe to declared-state changes. Returns an unsubscribe.
|
|
83
|
+
*
|
|
84
|
+
* Registration is idempotent per function reference, so a component that re-subscribes on every
|
|
85
|
+
* render does not accumulate listeners — a leak that would only show up in long sessions.
|
|
86
|
+
*/
|
|
87
|
+
export declare function onUiStateChange(listener: UiStateChangeListener): () => void;
|
|
88
|
+
/** Drop every listener and any pending notification. For teardown between sessions. */
|
|
89
|
+
export declare function resetUiStateListeners(): void;
|
|
90
|
+
/** For assertions that a re-subscribing caller has not leaked. */
|
|
91
|
+
export declare function uiStateListenerCount(): number;
|
|
53
92
|
//# sourceMappingURL=uiState.d.ts.map
|