@unrulysystems/native-motion 0.1.0-alpha.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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/unrulysystems/nativemotion/GestureExclusionModule.kt +55 -0
- package/android/src/main/java/com/unrulysystems/nativemotion/NativeMotionPackage.kt +27 -0
- package/package.json +57 -0
- package/react-native.config.js +14 -0
- package/src/native/driver/instantWindow.ts +31 -0
- package/src/native/driver/strictModeReplay.ts +20 -0
- package/src/native/driver/uiLayoutEngine.ts +845 -0
- package/src/native/driver/uiLayoutGraph.ts +338 -0
- package/src/native/driver/uiValueChannel.ts +4640 -0
- package/src/native/driver/workletDriver.ts +3860 -0
- package/src/native/motion/AnimatePresence.tsx +1605 -0
- package/src/native/motion/LayoutGroup.tsx +163 -0
- package/src/native/motion/MotionConfig.tsx +400 -0
- package/src/native/motion/MotionRoot.tsx +250 -0
- package/src/native/motion/MotionView.tsx +6761 -0
- package/src/native/motion/addScaleCorrector.ts +97 -0
- package/src/native/motion/colorEndpointUiFeed.ts +32 -0
- package/src/native/motion/colorProjection.ts +39 -0
- package/src/native/motion/composeTransform.ts +271 -0
- package/src/native/motion/constraintMeasure.ts +157 -0
- package/src/native/motion/deferredPendingSnapshots.ts +25 -0
- package/src/native/motion/discreteProjection.ts +36 -0
- package/src/native/motion/dragAncestorPanContext.ts +28 -0
- package/src/native/motion/dragControls.ts +152 -0
- package/src/native/motion/dragGestureWiring.ts +1168 -0
- package/src/native/motion/dragHandoffBinding.ts +179 -0
- package/src/native/motion/dragHubStream.ts +756 -0
- package/src/native/motion/dragPropagationLock.ts +57 -0
- package/src/native/motion/driverValueChannel.ts +3288 -0
- package/src/native/motion/externalDragDriver.ts +214 -0
- package/src/native/motion/frameData.ts +31 -0
- package/src/native/motion/gestureBinding.ts +107 -0
- package/src/native/motion/gestureStateGate.ts +563 -0
- package/src/native/motion/gestureStateResolver.ts +286 -0
- package/src/native/motion/identityValueChannelControllerAdapter.ts +2043 -0
- package/src/native/motion/identityValueChannelControllerReconciliation.ts +176 -0
- package/src/native/motion/identityValueChannelLaneMarker.ts +14 -0
- package/src/native/motion/imperativeAnimate.ts +816 -0
- package/src/native/motion/keyframeTiming.ts +7 -0
- package/src/native/motion/layoutIdBinding.ts +261 -0
- package/src/native/motion/layoutIdFlightConfig.ts +51 -0
- package/src/native/motion/layoutProjection.ts +89 -0
- package/src/native/motion/layoutScroll.ts +204 -0
- package/src/native/motion/layoutTransition.ts +1302 -0
- package/src/native/motion/lengthLayoutContext.tsx +100 -0
- package/src/native/motion/lengthLayoutHost.ts +44 -0
- package/src/native/motion/mappedKeys.ts +184 -0
- package/src/native/motion/motionViewController.ts +3180 -0
- package/src/native/motion/nativeHostMarker.ts +22 -0
- package/src/native/motion/panSessionWiring.ts +127 -0
- package/src/native/motion/pathTransition.ts +101 -0
- package/src/native/motion/popLayout.ts +176 -0
- package/src/native/motion/presenceBinding.ts +382 -0
- package/src/native/motion/scaleCorrectorRegistry.ts +123 -0
- package/src/native/motion/scrollValues.ts +260 -0
- package/src/native/motion/serializablePayload.ts +151 -0
- package/src/native/motion/severity.ts +16 -0
- package/src/native/motion/shippedSurface.ts +843 -0
- package/src/native/motion/staticLengthGate.ts +86 -0
- package/src/native/motion/styleBaseGate.ts +72 -0
- package/src/native/motion/styleValueBinding.ts +657 -0
- package/src/native/motion/systemGestureExclusion.ts +61 -0
- package/src/native/motion/tapGestureWiring.ts +215 -0
- package/src/native/motion/transformOrder.ts +38 -0
- package/src/native/motion/transformStringBinding.ts +200 -0
- package/src/native/motion/transformTemplateGate.ts +45 -0
- package/src/native/motion/transitionGate.ts +435 -0
- package/src/native/motion/useAnimate.ts +52 -0
- package/src/native/motion/useCycle.ts +44 -0
- package/src/native/motion/useInstantTransition.ts +68 -0
- package/src/native/motion/useReducedMotion.ts +58 -0
- package/src/native/motion/useScroll.ts +162 -0
- package/src/native/motion/useViewportScroll.ts +29 -0
- package/src/native/motion/valueChannel.ts +7121 -0
- package/src/native/motion/valueHooks.ts +901 -0
- package/src/native/motion/variantChildRegistry.ts +67 -0
- package/src/native/motion/variantContext.tsx +172 -0
- package/src/native/motion/variantProps.ts +598 -0
- package/src/native.ts +159 -0
- package/src/verification/harnessMetrics.ts +78 -0
- package/src/verification/probe/LayoutIdentityWorkletProbe.tsx +251 -0
- package/src/verification/probe/WorkletParityProbe.tsx +162 -0
- package/src/verification/probe/layoutIdentityProbeEngine.ts +429 -0
- package/src/verification/screens/ArcPathChecksScreen.tsx +273 -0
- package/src/verification/screens/BooleanAnimateChecksScreen.tsx +358 -0
- package/src/verification/screens/ChoreographyGalleryScreen.tsx +1010 -0
- package/src/verification/screens/ColorBindingChecksScreen.tsx +568 -0
- package/src/verification/screens/CompletionChecksScreen.tsx +294 -0
- package/src/verification/screens/ConformanceScreen.tsx +573 -0
- package/src/verification/screens/ContentionProbeScreen.tsx +224 -0
- package/src/verification/screens/DriverSmokeScreen.tsx +99 -0
- package/src/verification/screens/DurationOnlyTweenChecksScreen.tsx +294 -0
- package/src/verification/screens/DynamicDragConfigChecksScreen.tsx +237 -0
- package/src/verification/screens/FrameDataChecksScreen.tsx +252 -0
- package/src/verification/screens/GestureChecksScreen.tsx +685 -0
- package/src/verification/screens/InstantTransitionChecksScreen.tsx +839 -0
- package/src/verification/screens/LayoutAnimationStartChecksScreen.tsx +648 -0
- package/src/verification/screens/LayoutChecksScreen.tsx +822 -0
- package/src/verification/screens/LayoutCommitSpikeScreen.tsx +142 -0
- package/src/verification/screens/MotionViewChecksScreen.tsx +930 -0
- package/src/verification/screens/PresenceChecksScreen.tsx +614 -0
- package/src/verification/screens/ReducedMotionChecksScreen.tsx +614 -0
- package/src/verification/screens/RestThresholdChecksScreen.tsx +634 -0
- package/src/verification/screens/ScaleCorrectorChecksScreen.tsx +425 -0
- package/src/verification/screens/SharedLayoutContinuityChecksScreen.tsx +338 -0
- package/src/verification/screens/SharedLayoutCrossfadeChecksScreen.tsx +425 -0
- package/src/verification/screens/TransitionDefaultSelectionChecksScreen.tsx +514 -0
- package/src/verification/screens/ViewportScrollAliasChecksScreen.tsx +303 -0
- package/src/verification/screens/conformanceBanner.ts +21 -0
- package/src/verification/screens/proofConsoleTap.ts +18 -0
- package/src/verification.ts +47 -0
- package/src/web.ts +98 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
// The presence binding's brain (specs/M2.3-BUILD-PACKET.md): the non-React layer AnimatePresence
|
|
2
|
+
// wraps thinly — the same controller/component split MotionView uses. It owns (a) the boundary
|
|
3
|
+
// guards (core validation FIRST, then the exit-keys ⊆ animate-keys spike guard — the M2.2 lessons:
|
|
4
|
+
// guard every phase, validation-first ordering), and (b) the pass-through to core's
|
|
5
|
+
// createPresenceController, which owns retention/settle/cancel/mode ordering. Checks drive THIS
|
|
6
|
+
// surface deterministically with a ManualClock/ManualScheduler graph; the React container adds only
|
|
7
|
+
// keyed-children plumbing and the exits-in-flight rAF stepper.
|
|
8
|
+
//
|
|
9
|
+
import {
|
|
10
|
+
InvalidTransitionError,
|
|
11
|
+
captureTransition,
|
|
12
|
+
validateTransitionRefusal,
|
|
13
|
+
createPresenceController,
|
|
14
|
+
springKeyframeCountRefusal,
|
|
15
|
+
type MotionGraph,
|
|
16
|
+
type PresenceChild,
|
|
17
|
+
type PresenceController,
|
|
18
|
+
type PresenceMode,
|
|
19
|
+
type PresenceState,
|
|
20
|
+
type ResolvedTargetValue,
|
|
21
|
+
type ResolvedValue,
|
|
22
|
+
type Target,
|
|
23
|
+
type Transition,
|
|
24
|
+
TARGET_PROPERTY_KEYS,
|
|
25
|
+
} from '@unrulysystems/native-motion-core'
|
|
26
|
+
import { resolveTransitionForKey } from '@unrulysystems/native-motion-core/internal-driver'
|
|
27
|
+
import { materializeTargetArrays, validateAgainstNativeHost } from './motionViewController'
|
|
28
|
+
import { NATIVE_PROPERTY_TRANSITION_OPTIONS, exitLaneInexecutableOption } from './shippedSurface'
|
|
29
|
+
import { keyframeTimingRefusal } from './keyframeTiming'
|
|
30
|
+
|
|
31
|
+
export type PresenceLiveValues = Readonly<Record<string, ResolvedValue>>
|
|
32
|
+
|
|
33
|
+
// Host-RESOLVED px endpoints for measure-length exit arrays (review 2f3a8e6c1d90), captured at the
|
|
34
|
+
// removal boundary from the child's own layout context — the same resolution the property lane
|
|
35
|
+
// performs. Mirrors core's PresenceResolvedExits structurally (the presence barrel does not export
|
|
36
|
+
// it, same as PresenceLiveValues above).
|
|
37
|
+
export type PresenceResolvedExits = Readonly<Record<string, readonly (number | null)[]>>
|
|
38
|
+
|
|
39
|
+
const TRANSITION_MAP_KEYS = new Set<string>(['default', 'layout', ...TARGET_PROPERTY_KEYS])
|
|
40
|
+
|
|
41
|
+
function transitionMapSourceKey(transition: Transition, key: string): string {
|
|
42
|
+
const source = transition as Record<PropertyKey, unknown>
|
|
43
|
+
if (
|
|
44
|
+
TRANSITION_MAP_KEYS.has(key) &&
|
|
45
|
+
Object.hasOwn(source, key) &&
|
|
46
|
+
source[key] !== undefined &&
|
|
47
|
+
source[key] !== null
|
|
48
|
+
)
|
|
49
|
+
return key
|
|
50
|
+
if (Object.hasOwn(source, 'default') && source.default !== undefined && source.default !== null)
|
|
51
|
+
return 'default'
|
|
52
|
+
return 'root'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hasTransitionMap(transition: Transition): boolean {
|
|
56
|
+
return Reflect.ownKeys(transition).some(
|
|
57
|
+
(key) => typeof key === 'string' && TRANSITION_MAP_KEYS.has(key),
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PresenceBindingOptions {
|
|
62
|
+
readonly graph: MotionGraph
|
|
63
|
+
// Passed through verbatim — popLayout shares core's sync ordering; native geometry composition
|
|
64
|
+
// is applied by the presence renderer around the retained child.
|
|
65
|
+
readonly mode?: PresenceMode
|
|
66
|
+
readonly initial?: boolean
|
|
67
|
+
readonly onExitComplete?: () => void
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The controller surface the container consumes, plus the binding-level guards. Mirrors the
|
|
71
|
+
// controller names 1:1 so nothing is re-invented (REQ-PRESENCE-010 altitude).
|
|
72
|
+
export interface PresenceBinding {
|
|
73
|
+
// Boundary-guarded commit: validates every child's exit target through core FIRST
|
|
74
|
+
// (InvalidTargetError for unknown/off-host keys), then enforces exit-keys ⊆ animate-keys (an
|
|
75
|
+
// un-mounted exit key has no shared value at the MotionView altitude — fail loud HERE, never late
|
|
76
|
+
// in the driver), then hands the set to the controller. `resolvedExitsByKey` carries the host's
|
|
77
|
+
// px-resolved endpoints for measure-length exit arrays (review 2f3a8e6c1d90).
|
|
78
|
+
sync(
|
|
79
|
+
children: readonly PresenceChild[],
|
|
80
|
+
liveValuesByKey?: ReadonlyMap<string, PresenceLiveValues>,
|
|
81
|
+
resolvedExitsByKey?: ReadonlyMap<string, PresenceResolvedExits>,
|
|
82
|
+
): void
|
|
83
|
+
mounted(): readonly string[]
|
|
84
|
+
stateOf(key: string): PresenceState
|
|
85
|
+
isPresent(key: string): boolean
|
|
86
|
+
safeToRemove(key: string): void
|
|
87
|
+
// Mid-exit manual-authority adoption (REQ-PRESENCE-001/013/014): a consumer registering into a
|
|
88
|
+
// live exit episode upgrades the retained controller record — see PresenceController.deferExit.
|
|
89
|
+
deferExit(key: string): void
|
|
90
|
+
resolvedExitTarget(key: string): Readonly<Record<string, ResolvedTargetValue>> | undefined
|
|
91
|
+
enterSuppressed(key: string): boolean
|
|
92
|
+
exitProgress(key: string): number | undefined
|
|
93
|
+
// True when the graph has no in-flight presence work — the container's stepper stop signal.
|
|
94
|
+
idle(): boolean
|
|
95
|
+
// Exposed for the container/checks; never re-wrapped.
|
|
96
|
+
readonly controller: PresenceController
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Boundary guard for one child, run BEFORE any controller mutation so an invalid commit never
|
|
100
|
+
// partially applies (fail-closed, REQ-API-013 altitude). Ordering is the contract (M2.2 review
|
|
101
|
+
// lessons): core validation FIRST — an unknown/off-host key in animate OR exit throws the pipeline's
|
|
102
|
+
// InvalidTargetError — then the spike's exit-keys ⊆ animate-keys guard (an un-mounted exit key has
|
|
103
|
+
// no shared value at the MotionView altitude; letting it through dies late in the driver).
|
|
104
|
+
/**
|
|
105
|
+
* The exit lane's transition law, exported for the container's severity gate (r11 major
|
|
106
|
+
* 1c3f4f0b4b77): core validateTransition plus the exit-lane vocabulary — the presence exit
|
|
107
|
+
* generator never reads delay or velocity; explicitly-undefined options read as absent
|
|
108
|
+
* (core semantics, r10 major 6ac18d94e2f7). Loud by design; severity callers wrap it.
|
|
109
|
+
*/
|
|
110
|
+
export function exitLaneTransitionRefusal(
|
|
111
|
+
transition: PresenceChild['transition'],
|
|
112
|
+
componentId: string,
|
|
113
|
+
propertyKeys?: readonly string[],
|
|
114
|
+
): Error | null {
|
|
115
|
+
if (transition === undefined) return null
|
|
116
|
+
const coreRefusal = validateTransitionRefusal(transition, { componentId })
|
|
117
|
+
if (coreRefusal !== null) return coreRefusal
|
|
118
|
+
if (hasTransitionMap(transition)) {
|
|
119
|
+
// Generic core validation above checks every authored branch's shape and ranges. The exit lane's
|
|
120
|
+
// executable vocabulary is narrower, so apply it only after the actual exit properties select a
|
|
121
|
+
// branch; unused root/default/named bags cannot reject a reachable exit.
|
|
122
|
+
if (propertyKeys === undefined || propertyKeys.length === 0) return null
|
|
123
|
+
const source = transition as Transition
|
|
124
|
+
const seen = new Set<string>()
|
|
125
|
+
for (const key of propertyKeys) {
|
|
126
|
+
const sourceKey = transitionMapSourceKey(source, key)
|
|
127
|
+
if (seen.has(sourceKey)) continue
|
|
128
|
+
seen.add(sourceKey)
|
|
129
|
+
const refusal = exitLaneTransitionRefusal(resolveTransitionForKey(source, key), componentId)
|
|
130
|
+
if (refusal !== null) return refusal
|
|
131
|
+
}
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
// The exit generator's executable vocabulary is defined over every authored own field, not just
|
|
135
|
+
// enumerable ones. Core has already closed symbols/unknowns; inspect all retained string keys here
|
|
136
|
+
// so a non-enumerable lane-inert option cannot survive into the exit progress factory.
|
|
137
|
+
for (const option of Reflect.ownKeys(transition)) {
|
|
138
|
+
if (typeof option !== 'string') continue
|
|
139
|
+
const value = (transition as Record<string, unknown>)[option]
|
|
140
|
+
if (value === undefined) continue
|
|
141
|
+
// U7a: the authored instant lane (`type: false`) is property-lane only. The exit progress
|
|
142
|
+
// factory advances a 0→1 progress curve, not a per-value animation, so `type: false` would
|
|
143
|
+
// ride along as a default-duration tween — the silently-inert class this gate exists to
|
|
144
|
+
// refuse. Demand-gated divergence, same shape as the repeat family below.
|
|
145
|
+
if (option === 'type' && value === false) {
|
|
146
|
+
return new InvalidTransitionError(
|
|
147
|
+
componentId,
|
|
148
|
+
'type',
|
|
149
|
+
false,
|
|
150
|
+
'the exit lane cannot execute type: false — the exit generator advances a PROGRESS ' +
|
|
151
|
+
'factory, not a per-value animation, so the authored instant lane never reaches it ' +
|
|
152
|
+
'(U7a / G-INV-8). Instant exit is a scoped successor rung; drive it on the property ' +
|
|
153
|
+
'lane instead.',
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
if (exitLaneInexecutableOption(option)) {
|
|
157
|
+
return new InvalidTransitionError(
|
|
158
|
+
componentId,
|
|
159
|
+
option,
|
|
160
|
+
value,
|
|
161
|
+
`is not executable by the exit lane — the exit generator advances a PROGRESS factory, ` +
|
|
162
|
+
`not a sampled generator, so it never reads '${option}' and the option would ride ` +
|
|
163
|
+
'along inert (G-INV-8). This family on the presence exit lane is a scoped successor ' +
|
|
164
|
+
`rung; drive it on the property lane instead (T18-a repeat, T18-b delay).`,
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
if (!NATIVE_PROPERTY_TRANSITION_OPTIONS.has(option)) {
|
|
168
|
+
return new InvalidTransitionError(
|
|
169
|
+
componentId,
|
|
170
|
+
option,
|
|
171
|
+
value,
|
|
172
|
+
`not executable by the exit lane (it drives: ` +
|
|
173
|
+
`${NATIVE_PROPERTY_TRANSITION_OPTIONS.options.join(', ')}) — a silently inert ` +
|
|
174
|
+
'option is refused (G-INV-8, shippedSurface.ts)',
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function validateExitLaneTransition(
|
|
182
|
+
transition: PresenceChild['transition'],
|
|
183
|
+
componentId: string,
|
|
184
|
+
propertyKeys?: readonly string[],
|
|
185
|
+
): void {
|
|
186
|
+
const refusal = exitLaneTransitionRefusal(transition, componentId, propertyKeys)
|
|
187
|
+
if (refusal !== null) throw refusal
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// R8-F1 for the exit/animate arrays under a VALID exit-lane spring (review major 46 — the direct-
|
|
191
|
+
// consumer backstop the public container gate mirrors): a spring interpolates exactly two keyframes.
|
|
192
|
+
// Runs only AFTER exitLaneTransitionRefusal passed, so an invalid transition (falls back to default)
|
|
193
|
+
// never triggers it. Loud by design — a container consumer refuses/reports before reaching here.
|
|
194
|
+
function assertExitArraySpringCompat(child: PresenceChild): void {
|
|
195
|
+
for (const target of [child.animate, child.exit]) {
|
|
196
|
+
if (target === undefined) continue
|
|
197
|
+
for (const [key, value] of Object.entries(target)) {
|
|
198
|
+
if (!Array.isArray(value)) continue
|
|
199
|
+
const transition =
|
|
200
|
+
child.transition === undefined
|
|
201
|
+
? undefined
|
|
202
|
+
: resolveTransitionForKey(child.transition as Transition, key)
|
|
203
|
+
if (transition?.type !== 'spring') continue
|
|
204
|
+
const refusal = springKeyframeCountRefusal(value.length)
|
|
205
|
+
if (refusal !== null) throw refusal
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// R8 M2-B (REQ-API-033 law d): the direct-consumer keyframe-timing backstop (mirrors the spring one
|
|
211
|
+
// above, review major 46). It validates offset/easing shapes for directly-`sync`'d children; count-
|
|
212
|
+
// mismatched `times` fall back to even offsets, missing segment easings are linear, extras are
|
|
213
|
+
// ignored, and keyframe-only timing still refuses as inert on a scalar member.
|
|
214
|
+
// The public container's exit/animate lanes route through gateTargetWithSeverity (keyBoundaryRefusal),
|
|
215
|
+
// so this covers only the DIRECT `createPresenceBinding` consumer. Loud by design (after the transition
|
|
216
|
+
// validated), per key.
|
|
217
|
+
function assertExitArrayKeyframeTiming(child: PresenceChild): void {
|
|
218
|
+
for (const target of [child.animate, child.exit]) {
|
|
219
|
+
if (target === undefined) continue
|
|
220
|
+
for (const [key, value] of Object.entries(target)) {
|
|
221
|
+
const transition =
|
|
222
|
+
child.transition === undefined
|
|
223
|
+
? undefined
|
|
224
|
+
: resolveTransitionForKey(child.transition as Transition, key)
|
|
225
|
+
const refusal = keyframeTimingRefusal(
|
|
226
|
+
`<AnimatePresence child '${child.key}'>`,
|
|
227
|
+
key,
|
|
228
|
+
value,
|
|
229
|
+
transition,
|
|
230
|
+
)
|
|
231
|
+
if (refusal !== null) throw refusal
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function guardChild(
|
|
237
|
+
child: PresenceChild,
|
|
238
|
+
consumesTransition: boolean,
|
|
239
|
+
reenteringActiveExit = false,
|
|
240
|
+
): void {
|
|
241
|
+
// The captured transition seeds the presence graph's exit progress factory (r9 major
|
|
242
|
+
// 4fe88c31a121) — consumed when the child carries an exit target OR re-enters an ACTIVE
|
|
243
|
+
// exit episode (r11 major 6f0d2b6721ad: re-entry hands the incoming transition to
|
|
244
|
+
// progressFactory regardless of the current exit prop). Outside those cases presence
|
|
245
|
+
// never consumes it (r10 major b2e74f1309ac) and the child's own MotionView gate owns it.
|
|
246
|
+
if (consumesTransition) {
|
|
247
|
+
const consumedKeys = reenteringActiveExit
|
|
248
|
+
? [...new Set([...Object.keys(child.animate ?? {}), ...Object.keys(child.exit ?? {})])]
|
|
249
|
+
: Object.keys(child.exit ?? {})
|
|
250
|
+
validateExitLaneTransition(child.transition, `<AnimatePresence child '${child.key}'>`, [
|
|
251
|
+
...consumedKeys,
|
|
252
|
+
])
|
|
253
|
+
}
|
|
254
|
+
if (child.animate !== undefined) validateAgainstNativeHost(child.animate)
|
|
255
|
+
if (child.exit !== undefined) {
|
|
256
|
+
validateAgainstNativeHost(child.exit)
|
|
257
|
+
// R7 law g (M2 r2 major 18): the mounted set is the UNION of the animate keys and every
|
|
258
|
+
// dictionary-named key the container derived — a dictionary key has a shared value even
|
|
259
|
+
// when no animate target names it. The backstop stays fail-closed outside the union.
|
|
260
|
+
const mountedKeys = new Set([...Object.keys(child.animate ?? {}), ...(child.mountedKeys ?? [])])
|
|
261
|
+
for (const key of Object.keys(child.exit)) {
|
|
262
|
+
if (!mountedKeys.has(key)) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`M2.3 scope: exit key '${key}' on child '${child.key}' was never mounted ` +
|
|
265
|
+
`(mounted keys: ${[...mountedKeys].join(', ') || 'none'}) — declare it in animate ` +
|
|
266
|
+
'or the variants dictionary so a shared value exists ' +
|
|
267
|
+
'(specs/M2.3-BUILD-PACKET.md §semantics 6; REQ-API-032 law g).',
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// R8-F1 backstop LAST (review major 46/58): the direct-consumer spring/keyframe-array compat check
|
|
273
|
+
// runs only AFTER core validated the transition AND the target SHAPE — a malformed target (e.g.
|
|
274
|
+
// `exit:{x:[]}`) throws its TYPED InvalidTargetError first (validation-first contract), never the
|
|
275
|
+
// generic cross-field spring error. An invalid transition already threw above; a well-formed array is
|
|
276
|
+
// all that reaches here.
|
|
277
|
+
if (consumesTransition) {
|
|
278
|
+
assertExitArraySpringCompat(child)
|
|
279
|
+
assertExitArrayKeyframeTiming(child)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Hand the controller an IMMUTABLE snapshot of every keyframe array + the transition (review major 64):
|
|
284
|
+
// guardChild validates the caller-owned child, but `beginExit` RE-reads its arrays during exit-target
|
|
285
|
+
// resolution — an accessor-backed `[0,100]` returning `100` to the validator and `null` on the next read
|
|
286
|
+
// would forward `[0,null]` into retained state. The child is validated by the time it reaches here, so
|
|
287
|
+
// the snapshot mirrors exactly what validation saw (one-read / one-truth, the major-30/40 lineage).
|
|
288
|
+
function materializeChild(child: PresenceChild): PresenceChild {
|
|
289
|
+
const { animate, exit, transition, ...rest } = child
|
|
290
|
+
return {
|
|
291
|
+
...rest,
|
|
292
|
+
...(animate !== undefined ? { animate: materializeIfPlain(animate) } : {}),
|
|
293
|
+
...(exit !== undefined ? { exit: materializeIfPlain(exit) } : {}),
|
|
294
|
+
...(transition !== undefined ? { transition: captureTransitionIfPlain(transition) } : {}),
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Materialize a plain-object target's keyframe arrays; a MALFORMED target (null, primitive, array, or a
|
|
299
|
+
// PROTOTYPE-carrying object like `Date`/a class instance) passes through UNTOUCHED so guardChild's core
|
|
300
|
+
// validation throws the TYPED InvalidTargetError (materialize runs BEFORE validation for the one-read, so
|
|
301
|
+
// it must never crash on — nor LAUNDER into an empty record — the input validation is meant to reject).
|
|
302
|
+
function materializeIfPlain(target: Target): Target {
|
|
303
|
+
if (typeof target !== 'object' || target === null || Array.isArray(target)) return target
|
|
304
|
+
const proto = Object.getPrototypeOf(target) as unknown
|
|
305
|
+
if (proto !== Object.prototype && proto !== null) return target
|
|
306
|
+
return materializeTargetArrays(target)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Capture the transition ONCE (review major 64): guardChild's validateExitLaneTransition + the controller's
|
|
310
|
+
// beginExit both read it, so a caller-owned accessor `duration` would be observed repeatedly and could
|
|
311
|
+
// change after sync. A MALFORMED shape (null, primitive, array, prototype-carrying) passes through so
|
|
312
|
+
// validateExitLaneTransition throws the TYPED refusal — the null-proto capture keeps an own `__proto__`.
|
|
313
|
+
function captureTransitionIfPlain(
|
|
314
|
+
transition: NonNullable<PresenceChild['transition']>,
|
|
315
|
+
): NonNullable<PresenceChild['transition']> {
|
|
316
|
+
const t = transition as unknown
|
|
317
|
+
const proto = typeof t === 'object' && t !== null ? Object.getPrototypeOf(t) : undefined
|
|
318
|
+
if (
|
|
319
|
+
typeof t !== 'object' ||
|
|
320
|
+
t === null ||
|
|
321
|
+
Array.isArray(t) ||
|
|
322
|
+
(proto !== Object.prototype && proto !== null)
|
|
323
|
+
) {
|
|
324
|
+
return transition
|
|
325
|
+
}
|
|
326
|
+
return captureTransition(transition)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function createPresenceBinding(options: PresenceBindingOptions): PresenceBinding {
|
|
330
|
+
// Mode passes through verbatim: popLayout's geometry behavior is owned by the native presence
|
|
331
|
+
// renderer while the controller retains the same keyed lifecycle ordering.
|
|
332
|
+
const controller = createPresenceController({
|
|
333
|
+
graph: options.graph,
|
|
334
|
+
...(options.mode === undefined ? {} : { mode: options.mode }),
|
|
335
|
+
...(options.initial === undefined ? {} : { initial: options.initial }),
|
|
336
|
+
...(options.onExitComplete === undefined ? {} : { onExitComplete: options.onExitComplete }),
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
sync(children, liveValuesByKey, resolvedExitsByKey) {
|
|
341
|
+
// Guard EVERY child before the controller sees ANY of them — no partial commits. The
|
|
342
|
+
// consumption predicate reads PRE-sync state: a key currently exiting consumes the
|
|
343
|
+
// incoming transition on re-entry (r11 major 6f0d2b6721ad).
|
|
344
|
+
const mounted = controller.mountedKeys()
|
|
345
|
+
// Materialize each child into an IMMUTABLE snapshot FIRST, then validate + forward THAT (review
|
|
346
|
+
// major 64): validating the caller-owned child before snapshotting reads its accessor arrays twice
|
|
347
|
+
// (validation, then materialization), so the value validated could differ from the value retained.
|
|
348
|
+
// The snapshot is the ONE truth guardChild validates AND the controller resolves.
|
|
349
|
+
const snapshots = children.map(materializeChild)
|
|
350
|
+
for (const child of snapshots) {
|
|
351
|
+
const reenteringActiveExit =
|
|
352
|
+
mounted.includes(child.key) && controller.stateOf(child.key) === 'exiting'
|
|
353
|
+
guardChild(child, child.exit !== undefined || reenteringActiveExit, reenteringActiveExit)
|
|
354
|
+
}
|
|
355
|
+
const capturedLiveValues = new Map<string, PresenceLiveValues>()
|
|
356
|
+
for (const [key, values] of liveValuesByKey ?? []) {
|
|
357
|
+
capturedLiveValues.set(key, Object.freeze({ ...values }))
|
|
358
|
+
}
|
|
359
|
+
// The same one-read capture for host-resolved endpoints (review 2f3a8e6c1d90): clone + freeze
|
|
360
|
+
// every endpoint array so a caller-side mutation cannot rewrite the retained measurement.
|
|
361
|
+
const capturedResolvedExits = new Map<string, PresenceResolvedExits>()
|
|
362
|
+
for (const [key, exits] of resolvedExitsByKey ?? []) {
|
|
363
|
+
const frozen: Record<string, readonly (number | null)[]> = {}
|
|
364
|
+
for (const [property, endpoints] of Object.entries(exits)) {
|
|
365
|
+
frozen[property] = Object.freeze([...endpoints])
|
|
366
|
+
}
|
|
367
|
+
capturedResolvedExits.set(key, Object.freeze(frozen))
|
|
368
|
+
}
|
|
369
|
+
controller.syncChildren(snapshots, capturedLiveValues, capturedResolvedExits)
|
|
370
|
+
},
|
|
371
|
+
mounted: () => controller.mountedKeys(),
|
|
372
|
+
stateOf: (key) => controller.stateOf(key),
|
|
373
|
+
isPresent: (key) => controller.isPresent(key),
|
|
374
|
+
safeToRemove: (key) => controller.safeToRemove(key),
|
|
375
|
+
deferExit: (key) => controller.deferExit(key),
|
|
376
|
+
resolvedExitTarget: (key) => controller.resolvedExitTarget(key),
|
|
377
|
+
enterSuppressed: (key) => controller.enterSuppressed(key),
|
|
378
|
+
exitProgress: (key) => controller.exitProgress(key),
|
|
379
|
+
idle: () => options.graph.isSettled(),
|
|
380
|
+
controller,
|
|
381
|
+
}
|
|
382
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// UI-global user scale-corrector map (REQ-LAYOUT-024). Named module-level worklet + primitive
|
|
2
|
+
// payloads only (AB-BA hang-class: no worklet-capturing-worklet). The correct function is
|
|
3
|
+
// passed as a worklet argument, never closed over.
|
|
4
|
+
|
|
5
|
+
import type { ScaleCorrector } from './addScaleCorrector'
|
|
6
|
+
|
|
7
|
+
interface MutableScaleCorrectionContext {
|
|
8
|
+
targetDelta: { x: { scale: number }; y: { scale: number } }
|
|
9
|
+
treeScale: { x: number; y: number }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface InstalledCorrector {
|
|
13
|
+
readonly correct: ScaleCorrector
|
|
14
|
+
readonly applyTo: readonly string[] | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ScaleCorrectorRegistryUiGlobal {
|
|
18
|
+
__nativeMotionScaleCorrectors?: Record<string, InstalledCorrector>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function scaleCorrectorRegistry(): NonNullable<
|
|
22
|
+
ScaleCorrectorRegistryUiGlobal['__nativeMotionScaleCorrectors']
|
|
23
|
+
> {
|
|
24
|
+
'worklet'
|
|
25
|
+
const global = globalThis as ScaleCorrectorRegistryUiGlobal
|
|
26
|
+
if (global.__nativeMotionScaleCorrectors === undefined) {
|
|
27
|
+
global.__nativeMotionScaleCorrectors = {}
|
|
28
|
+
}
|
|
29
|
+
return global.__nativeMotionScaleCorrectors
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function installScaleCorrectorOnUI(
|
|
33
|
+
key: string,
|
|
34
|
+
correct: ScaleCorrector,
|
|
35
|
+
applyTo: readonly string[] | null,
|
|
36
|
+
): void {
|
|
37
|
+
'worklet'
|
|
38
|
+
scaleCorrectorRegistry()[key] = { correct, applyTo }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// One reused context object (REQ-DRIVER-021): correct() reads it synchronously, so per-call
|
|
42
|
+
// field mutation is safe. A fresh object per frame would break that law.
|
|
43
|
+
function scaleCorrectionContext(): MutableScaleCorrectionContext {
|
|
44
|
+
'worklet'
|
|
45
|
+
const global = globalThis as ScaleCorrectorRegistryUiGlobal & {
|
|
46
|
+
__nativeMotionScaleCorrectionContext?: MutableScaleCorrectionContext
|
|
47
|
+
}
|
|
48
|
+
if (global.__nativeMotionScaleCorrectionContext === undefined) {
|
|
49
|
+
global.__nativeMotionScaleCorrectionContext = {
|
|
50
|
+
targetDelta: { x: { scale: 1 }, y: { scale: 1 } },
|
|
51
|
+
treeScale: { x: 1, y: 1 },
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return global.__nativeMotionScaleCorrectionContext
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Apply a registered corrector to one authored latest. `transformActive` is the pin's
|
|
59
|
+
* `transform !== "none"` gate. Missing corrector or inactive transform returns `latest`.
|
|
60
|
+
*/
|
|
61
|
+
export function applyInstalledScaleCorrector(
|
|
62
|
+
key: string,
|
|
63
|
+
latest: number | string,
|
|
64
|
+
targetDeltaScaleX: number,
|
|
65
|
+
targetDeltaScaleY: number,
|
|
66
|
+
treeScaleX: number,
|
|
67
|
+
treeScaleY: number,
|
|
68
|
+
transformActive: boolean,
|
|
69
|
+
): number | string {
|
|
70
|
+
'worklet'
|
|
71
|
+
if (!transformActive) return latest
|
|
72
|
+
const installed = scaleCorrectorRegistry()[key]
|
|
73
|
+
if (installed === undefined) return latest
|
|
74
|
+
const context = scaleCorrectionContext()
|
|
75
|
+
context.targetDelta.x.scale = targetDeltaScaleX
|
|
76
|
+
context.targetDelta.y.scale = targetDeltaScaleY
|
|
77
|
+
context.treeScale.x = treeScaleX
|
|
78
|
+
context.treeScale.y = treeScaleY
|
|
79
|
+
return installed.correct(latest, context)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Pin create-projection-node.ts:2102-2106 — when `applyTo` is set, write the
|
|
84
|
+
* corrected value onto those keys and skip the source key; otherwise write source.
|
|
85
|
+
*/
|
|
86
|
+
export function writeInstalledScaleCorrector(
|
|
87
|
+
out: Record<string, unknown>,
|
|
88
|
+
key: string,
|
|
89
|
+
latest: number | string,
|
|
90
|
+
targetDeltaScaleX: number,
|
|
91
|
+
targetDeltaScaleY: number,
|
|
92
|
+
treeScaleX: number,
|
|
93
|
+
treeScaleY: number,
|
|
94
|
+
transformActive: boolean,
|
|
95
|
+
): void {
|
|
96
|
+
'worklet'
|
|
97
|
+
const corrected = applyInstalledScaleCorrector(
|
|
98
|
+
key,
|
|
99
|
+
latest,
|
|
100
|
+
targetDeltaScaleX,
|
|
101
|
+
targetDeltaScaleY,
|
|
102
|
+
treeScaleX,
|
|
103
|
+
treeScaleY,
|
|
104
|
+
transformActive,
|
|
105
|
+
)
|
|
106
|
+
const applyTo = scaleCorrectorRegistry()[key]?.applyTo ?? null
|
|
107
|
+
if (applyTo !== null) {
|
|
108
|
+
for (let i = 0; i < applyTo.length; i++) {
|
|
109
|
+
out[applyTo[i]!] = corrected
|
|
110
|
+
}
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
out[key] = corrected
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function resetScaleCorrectorRegistryForTests(): void {
|
|
117
|
+
delete (globalThis as ScaleCorrectorRegistryUiGlobal).__nativeMotionScaleCorrectors
|
|
118
|
+
delete (
|
|
119
|
+
globalThis as ScaleCorrectorRegistryUiGlobal & {
|
|
120
|
+
__nativeMotionScaleCorrectionContext?: unknown
|
|
121
|
+
}
|
|
122
|
+
).__nativeMotionScaleCorrectionContext
|
|
123
|
+
}
|