@unrulysystems/native-motion-core 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.
Files changed (326) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +48 -0
  4. package/dist/clock.cjs +71 -0
  5. package/dist/clock.d.cts +23 -0
  6. package/dist/clock.d.ts +23 -0
  7. package/dist/clock.js +66 -0
  8. package/dist/collect-reads.cjs +68 -0
  9. package/dist/collect-reads.d.cts +23 -0
  10. package/dist/collect-reads.d.ts +23 -0
  11. package/dist/collect-reads.js +62 -0
  12. package/dist/component/boundedArray.cjs +74 -0
  13. package/dist/component/boundedArray.d.cts +25 -0
  14. package/dist/component/boundedArray.d.ts +25 -0
  15. package/dist/component/boundedArray.js +68 -0
  16. package/dist/component/index.cjs +57 -0
  17. package/dist/component/index.d.cts +12 -0
  18. package/dist/component/index.d.ts +12 -0
  19. package/dist/component/index.js +22 -0
  20. package/dist/component/orchestration.cjs +129 -0
  21. package/dist/component/orchestration.d.cts +66 -0
  22. package/dist/component/orchestration.d.ts +66 -0
  23. package/dist/component/orchestration.js +124 -0
  24. package/dist/component/resolve.cjs +104 -0
  25. package/dist/component/resolve.d.cts +11 -0
  26. package/dist/component/resolve.d.ts +11 -0
  27. package/dist/component/resolve.js +97 -0
  28. package/dist/component/transition.cjs +352 -0
  29. package/dist/component/transition.d.cts +33 -0
  30. package/dist/component/transition.d.ts +33 -0
  31. package/dist/component/transition.js +339 -0
  32. package/dist/component/types.cjs +114 -0
  33. package/dist/component/types.d.cts +60 -0
  34. package/dist/component/types.d.ts +60 -0
  35. package/dist/component/types.js +111 -0
  36. package/dist/component/validate.cjs +1015 -0
  37. package/dist/component/validate.d.cts +37 -0
  38. package/dist/component/validate.d.ts +37 -0
  39. package/dist/component/validate.js +1002 -0
  40. package/dist/component/variants.cjs +333 -0
  41. package/dist/component/variants.d.cts +106 -0
  42. package/dist/component/variants.d.ts +106 -0
  43. package/dist/component/variants.js +321 -0
  44. package/dist/config/constants.cjs +41 -0
  45. package/dist/config/constants.d.cts +29 -0
  46. package/dist/config/constants.d.ts +29 -0
  47. package/dist/config/constants.js +38 -0
  48. package/dist/delay.cjs +72 -0
  49. package/dist/delay.d.cts +26 -0
  50. package/dist/delay.d.ts +26 -0
  51. package/dist/delay.js +70 -0
  52. package/dist/derived.cjs +143 -0
  53. package/dist/derived.d.cts +41 -0
  54. package/dist/derived.d.ts +41 -0
  55. package/dist/derived.js +139 -0
  56. package/dist/driver/index.cjs +22 -0
  57. package/dist/driver/index.d.cts +7 -0
  58. package/dist/driver/index.d.ts +7 -0
  59. package/dist/driver/index.js +14 -0
  60. package/dist/driver/keyframeTiming.cjs +130 -0
  61. package/dist/driver/keyframeTiming.d.cts +18 -0
  62. package/dist/driver/keyframeTiming.d.ts +18 -0
  63. package/dist/driver/keyframeTiming.js +124 -0
  64. package/dist/driver/keyframeTimingConfig.cjs +24 -0
  65. package/dist/driver/keyframeTimingConfig.d.cts +5 -0
  66. package/dist/driver/keyframeTimingConfig.d.ts +5 -0
  67. package/dist/driver/keyframeTimingConfig.js +22 -0
  68. package/dist/driver/prepare.cjs +459 -0
  69. package/dist/driver/prepare.d.cts +4 -0
  70. package/dist/driver/prepare.d.ts +4 -0
  71. package/dist/driver/prepare.js +454 -0
  72. package/dist/driver/reference.cjs +762 -0
  73. package/dist/driver/reference.d.cts +53 -0
  74. package/dist/driver/reference.d.ts +53 -0
  75. package/dist/driver/reference.js +757 -0
  76. package/dist/driver/step.cjs +55 -0
  77. package/dist/driver/step.d.cts +9 -0
  78. package/dist/driver/step.d.ts +9 -0
  79. package/dist/driver/step.js +52 -0
  80. package/dist/driver/tiers.cjs +40 -0
  81. package/dist/driver/tiers.d.cts +2 -0
  82. package/dist/driver/tiers.d.ts +2 -0
  83. package/dist/driver/tiers.js +37 -0
  84. package/dist/driver/types.cjs +9 -0
  85. package/dist/driver/types.d.cts +68 -0
  86. package/dist/driver/types.d.ts +68 -0
  87. package/dist/driver/types.js +8 -0
  88. package/dist/external-animation-ledger.cjs +1250 -0
  89. package/dist/external-animation-ledger.d.cts +416 -0
  90. package/dist/external-animation-ledger.d.ts +416 -0
  91. package/dist/external-animation-ledger.js +1243 -0
  92. package/dist/gesture/directionLock.cjs +25 -0
  93. package/dist/gesture/directionLock.d.cts +8 -0
  94. package/dist/gesture/directionLock.d.ts +8 -0
  95. package/dist/gesture/directionLock.js +21 -0
  96. package/dist/gesture/dragConfig.cjs +634 -0
  97. package/dist/gesture/dragConfig.d.cts +298 -0
  98. package/dist/gesture/dragConfig.d.ts +298 -0
  99. package/dist/gesture/dragConfig.js +624 -0
  100. package/dist/gesture/elastic.cjs +44 -0
  101. package/dist/gesture/elastic.d.cts +4 -0
  102. package/dist/gesture/elastic.d.ts +4 -0
  103. package/dist/gesture/elastic.js +39 -0
  104. package/dist/gesture/handoffSession.cjs +161 -0
  105. package/dist/gesture/handoffSession.d.cts +35 -0
  106. package/dist/gesture/handoffSession.d.ts +35 -0
  107. package/dist/gesture/handoffSession.js +158 -0
  108. package/dist/gesture/index.cjs +37 -0
  109. package/dist/gesture/index.d.cts +12 -0
  110. package/dist/gesture/index.d.ts +12 -0
  111. package/dist/gesture/index.js +11 -0
  112. package/dist/gesture/projection.cjs +95 -0
  113. package/dist/gesture/projection.d.cts +7 -0
  114. package/dist/gesture/projection.d.ts +7 -0
  115. package/dist/gesture/projection.js +87 -0
  116. package/dist/gesture/session.cjs +162 -0
  117. package/dist/gesture/session.d.cts +29 -0
  118. package/dist/gesture/session.d.ts +29 -0
  119. package/dist/gesture/session.js +158 -0
  120. package/dist/gesture/types.cjs +5 -0
  121. package/dist/gesture/types.d.cts +13 -0
  122. package/dist/gesture/types.d.ts +13 -0
  123. package/dist/gesture/types.js +4 -0
  124. package/dist/gesture/viewportConstraints.cjs +38 -0
  125. package/dist/gesture/viewportConstraints.d.cts +6 -0
  126. package/dist/gesture/viewportConstraints.d.ts +6 -0
  127. package/dist/gesture/viewportConstraints.js +34 -0
  128. package/dist/graph.cjs +226 -0
  129. package/dist/graph.d.cts +2 -0
  130. package/dist/graph.d.ts +2 -0
  131. package/dist/graph.js +223 -0
  132. package/dist/index.cjs +140 -0
  133. package/dist/index.d.cts +33 -0
  134. package/dist/index.d.ts +33 -0
  135. package/dist/index.js +70 -0
  136. package/dist/inertia.cjs +214 -0
  137. package/dist/inertia.d.cts +53 -0
  138. package/dist/inertia.d.ts +53 -0
  139. package/dist/inertia.js +212 -0
  140. package/dist/instant.cjs +66 -0
  141. package/dist/instant.d.cts +16 -0
  142. package/dist/instant.d.ts +16 -0
  143. package/dist/instant.js +63 -0
  144. package/dist/internal-driver.cjs +81 -0
  145. package/dist/internal-driver.d.cts +19 -0
  146. package/dist/internal-driver.d.ts +19 -0
  147. package/dist/internal-driver.js +35 -0
  148. package/dist/keyframes.cjs +191 -0
  149. package/dist/keyframes.d.cts +13 -0
  150. package/dist/keyframes.d.ts +13 -0
  151. package/dist/keyframes.js +188 -0
  152. package/dist/layout/commitDetector.cjs +67 -0
  153. package/dist/layout/commitDetector.d.cts +21 -0
  154. package/dist/layout/commitDetector.d.ts +21 -0
  155. package/dist/layout/commitDetector.js +64 -0
  156. package/dist/layout/compose.cjs +67 -0
  157. package/dist/layout/compose.d.cts +30 -0
  158. package/dist/layout/compose.d.ts +30 -0
  159. package/dist/layout/compose.js +65 -0
  160. package/dist/layout/constants.cjs +13 -0
  161. package/dist/layout/constants.d.cts +6 -0
  162. package/dist/layout/constants.d.ts +6 -0
  163. package/dist/layout/constants.js +10 -0
  164. package/dist/layout/identity.cjs +700 -0
  165. package/dist/layout/identity.d.cts +67 -0
  166. package/dist/layout/identity.d.ts +67 -0
  167. package/dist/layout/identity.js +698 -0
  168. package/dist/layout/index.cjs +36 -0
  169. package/dist/layout/index.d.cts +18 -0
  170. package/dist/layout/index.d.ts +18 -0
  171. package/dist/layout/index.js +13 -0
  172. package/dist/layout/measure.cjs +81 -0
  173. package/dist/layout/measure.d.cts +43 -0
  174. package/dist/layout/measure.d.ts +43 -0
  175. package/dist/layout/measure.js +78 -0
  176. package/dist/layout/projection.cjs +99 -0
  177. package/dist/layout/projection.d.cts +23 -0
  178. package/dist/layout/projection.d.ts +23 -0
  179. package/dist/layout/projection.js +97 -0
  180. package/dist/layout/scroll.cjs +27 -0
  181. package/dist/layout/scroll.d.cts +13 -0
  182. package/dist/layout/scroll.d.ts +13 -0
  183. package/dist/layout/scroll.js +24 -0
  184. package/dist/layout/session.cjs +207 -0
  185. package/dist/layout/session.d.cts +73 -0
  186. package/dist/layout/session.d.ts +73 -0
  187. package/dist/layout/session.js +205 -0
  188. package/dist/layout/tree.cjs +826 -0
  189. package/dist/layout/tree.d.cts +70 -0
  190. package/dist/layout/tree.d.ts +70 -0
  191. package/dist/layout/tree.js +823 -0
  192. package/dist/layout/types.cjs +36 -0
  193. package/dist/layout/types.d.cts +31 -0
  194. package/dist/layout/types.d.ts +31 -0
  195. package/dist/layout/types.js +35 -0
  196. package/dist/motion-arc.cjs +184 -0
  197. package/dist/motion-arc.d.cts +78 -0
  198. package/dist/motion-arc.d.ts +78 -0
  199. package/dist/motion-arc.js +183 -0
  200. package/dist/motion-mix.cjs +205 -0
  201. package/dist/motion-mix.d.cts +3 -0
  202. package/dist/motion-mix.d.ts +3 -0
  203. package/dist/motion-mix.js +202 -0
  204. package/dist/motion-value-driver-port.cjs +636 -0
  205. package/dist/motion-value-driver-port.d.cts +406 -0
  206. package/dist/motion-value-driver-port.d.ts +406 -0
  207. package/dist/motion-value-driver-port.js +627 -0
  208. package/dist/motion-value.cjs +189 -0
  209. package/dist/motion-value.d.cts +51 -0
  210. package/dist/motion-value.d.ts +51 -0
  211. package/dist/motion-value.js +185 -0
  212. package/dist/presence/controller.cjs +657 -0
  213. package/dist/presence/controller.d.cts +6 -0
  214. package/dist/presence/controller.d.ts +6 -0
  215. package/dist/presence/controller.js +652 -0
  216. package/dist/presence/index.cjs +19 -0
  217. package/dist/presence/index.d.cts +4 -0
  218. package/dist/presence/index.d.ts +4 -0
  219. package/dist/presence/index.js +12 -0
  220. package/dist/presence/machine.cjs +50 -0
  221. package/dist/presence/machine.d.cts +10 -0
  222. package/dist/presence/machine.d.ts +10 -0
  223. package/dist/presence/machine.js +46 -0
  224. package/dist/presence/types.cjs +6 -0
  225. package/dist/presence/types.d.cts +32 -0
  226. package/dist/presence/types.d.ts +32 -0
  227. package/dist/presence/types.js +5 -0
  228. package/dist/repeat.cjs +311 -0
  229. package/dist/repeat.d.cts +174 -0
  230. package/dist/repeat.d.ts +174 -0
  231. package/dist/repeat.js +300 -0
  232. package/dist/spring.cjs +128 -0
  233. package/dist/spring.d.cts +18 -0
  234. package/dist/spring.d.ts +18 -0
  235. package/dist/spring.js +125 -0
  236. package/dist/subscriptions.cjs +74 -0
  237. package/dist/subscriptions.d.cts +19 -0
  238. package/dist/subscriptions.d.ts +19 -0
  239. package/dist/subscriptions.js +69 -0
  240. package/dist/subset/index.cjs +23 -0
  241. package/dist/subset/index.d.cts +6 -0
  242. package/dist/subset/index.d.ts +6 -0
  243. package/dist/subset/index.js +13 -0
  244. package/dist/subset/normalize.cjs +90 -0
  245. package/dist/subset/normalize.d.cts +10 -0
  246. package/dist/subset/normalize.d.ts +10 -0
  247. package/dist/subset/normalize.js +85 -0
  248. package/dist/subset/registry.cjs +259 -0
  249. package/dist/subset/registry.d.cts +25 -0
  250. package/dist/subset/registry.d.ts +25 -0
  251. package/dist/subset/registry.js +256 -0
  252. package/dist/subset/resolve.cjs +98 -0
  253. package/dist/subset/resolve.d.cts +24 -0
  254. package/dist/subset/resolve.d.ts +24 -0
  255. package/dist/subset/resolve.js +90 -0
  256. package/dist/timing.cjs +206 -0
  257. package/dist/timing.d.cts +17 -0
  258. package/dist/timing.d.ts +17 -0
  259. package/dist/timing.js +201 -0
  260. package/dist/transformTemplate.cjs +219 -0
  261. package/dist/transformTemplate.d.cts +29 -0
  262. package/dist/transformTemplate.d.ts +29 -0
  263. package/dist/transformTemplate.js +215 -0
  264. package/dist/transition.cjs +231 -0
  265. package/dist/transition.d.cts +89 -0
  266. package/dist/transition.d.ts +89 -0
  267. package/dist/transition.js +223 -0
  268. package/dist/types.cjs +4 -0
  269. package/dist/types.d.cts +175 -0
  270. package/dist/types.d.ts +175 -0
  271. package/dist/types.js +3 -0
  272. package/dist/value-types/color.cjs +220 -0
  273. package/dist/value-types/color.d.cts +19 -0
  274. package/dist/value-types/color.d.ts +19 -0
  275. package/dist/value-types/color.js +218 -0
  276. package/dist/value-types/complex.cjs +160 -0
  277. package/dist/value-types/complex.d.cts +19 -0
  278. package/dist/value-types/complex.d.ts +19 -0
  279. package/dist/value-types/complex.js +155 -0
  280. package/dist/value-types/constants.cjs +15 -0
  281. package/dist/value-types/constants.d.cts +6 -0
  282. package/dist/value-types/constants.d.ts +6 -0
  283. package/dist/value-types/constants.js +12 -0
  284. package/dist/value-types/discrete.cjs +62 -0
  285. package/dist/value-types/discrete.d.cts +4 -0
  286. package/dist/value-types/discrete.d.ts +4 -0
  287. package/dist/value-types/discrete.js +56 -0
  288. package/dist/value-types/index.cjs +59 -0
  289. package/dist/value-types/index.d.cts +14 -0
  290. package/dist/value-types/index.d.ts +14 -0
  291. package/dist/value-types/index.js +24 -0
  292. package/dist/value-types/measure-resolve.cjs +325 -0
  293. package/dist/value-types/measure-resolve.d.cts +91 -0
  294. package/dist/value-types/measure-resolve.d.ts +91 -0
  295. package/dist/value-types/measure-resolve.js +313 -0
  296. package/dist/value-types/mix.cjs +90 -0
  297. package/dist/value-types/mix.d.cts +27 -0
  298. package/dist/value-types/mix.d.ts +27 -0
  299. package/dist/value-types/mix.js +85 -0
  300. package/dist/value-types/named-colors.cjs +61 -0
  301. package/dist/value-types/named-colors.d.cts +2 -0
  302. package/dist/value-types/named-colors.d.ts +2 -0
  303. package/dist/value-types/named-colors.js +58 -0
  304. package/dist/value-types/numeric.cjs +86 -0
  305. package/dist/value-types/numeric.d.cts +16 -0
  306. package/dist/value-types/numeric.d.ts +16 -0
  307. package/dist/value-types/numeric.js +79 -0
  308. package/dist/worklet-layout/config/constants.js +39 -0
  309. package/dist/worklet-layout/layout/constants.js +11 -0
  310. package/dist/worklet-layout/layout/identity.js +699 -0
  311. package/dist/worklet-layout/layout/projection.js +98 -0
  312. package/dist/worklet-layout/layout/session.js +206 -0
  313. package/dist/worklet-layout/layout/tree.js +824 -0
  314. package/dist/worklet-layout/layout/types.js +36 -0
  315. package/dist/worklet-layout/spring.js +125 -0
  316. package/dist/worklet-layout/timing.js +201 -0
  317. package/dist/worklet-layout/transition.js +223 -0
  318. package/dist/worklet-layout.cjs +47 -0
  319. package/dist/worklet-layout.d.cts +15 -0
  320. package/dist/worklet-layout.d.ts +15 -0
  321. package/dist/worklet-layout.js +28 -0
  322. package/dist/wrap.cjs +7 -0
  323. package/dist/wrap.d.cts +1 -0
  324. package/dist/wrap.d.ts +1 -0
  325. package/dist/wrap.js +4 -0
  326. package/package.json +45 -0
@@ -0,0 +1,824 @@
1
+ 'worklet';
2
+ // @frame-path — executes inside the UI-runtime frame step when hosted by native; allocation-gated there.
3
+ // SPEC-LAYOUT §4a — a host-agnostic projection-node tree. The layoutId registry deliberately owns
4
+ // shared-holder roles; this tree owns component ancestry and accumulated geometry only. Every Motion
5
+ // component can register, but only a node with a target or an explicit projection contributes output.
6
+ import { IDENTITY_TRANSFORM, projectAtProgress } from "./projection.js";
7
+ // The stale-report ring's CONTAINED bound: the dedupe set already collapses per-id spam, so the
8
+ // ring only ever holds distinct-episode reports — a contained flood past the cap is itself the
9
+ // signal (formalized as the delivered projection-report-overflow record). Lifecycle-edge only;
10
+ // the per-frame fast path never touches it.
11
+ const MAX_STALE_REPORTS = 32;
12
+ // The ring's ESCALATED bound (seal r10, F1): escalations are PRESERVED, never evicted — a
13
+ // wrong-forest publish, malformed live input, undefined scale geometry, or the overflow record
14
+ // itself is a script-bug/fail-loud signal that must reach the severity channel. 256 matches the
15
+ // escalated dedupe window: exceeding it needs 256 DISTINCT undrained escalations in one
16
+ // inter-drain window (the driver drains every frame — a catastrophic storm), and past it the
17
+ // overflow record aggregates every dropped id with its count rather than losing one silently.
18
+ const MAX_STALE_ESCALATED_REPORTS = 256;
19
+ // The overflow record's named-id bound (seal r12, F4): the first distinct dropped ids per class
20
+ // are named with their counts in the record's detail; everything past this cap aggregates as an
21
+ // honest "+N more" count. Without it, a callback-only session (no drain) grew the loss maps
22
+ // with every distinct dropped id and rebuilt a string over ALL of them per drop — the
23
+ // reviewer's 1,200-id probe produced a 12,631-character detail. Truth stays exact: the total
24
+ // count is always precise; only the NAMING is capped (the detail's length stays bounded for
25
+ // realistic id lengths).
26
+ const MAX_OVERFLOW_NAMED_IDS = 32;
27
+ // The dedupe sets' own bound (iOS AUTO SIGABRT F2): an unbounded set grows with every distinct
28
+ // stale id a session ever sees AND pins each id's suppression forever — a long session both leaks
29
+ // and loses the signal of a genuinely new episode on an ancient id. Insertion-ordered eviction
30
+ // (a Set iterates in insertion order): past the cap the oldest entry drops, so an evicted id
31
+ // reports again while an in-window id stays deduped. No refresh-on-hit — a hot stale id must not
32
+ // pin itself inside the window forever. The cap applies to EACH of the two class-partitioned
33
+ // sets (seal r9, F2), so the total stays bounded at 2× this value.
34
+ const MAX_STALE_IDS = 256;
35
+ // The known-id ledger's bound (seal r7, F4 — see the ledger comment inside createProjectionTree).
36
+ // Eviction misclassifies an ancient KNOWN id toward the severe 'projection-unregistered-node'
37
+ // code; that direction is deliberate — a publish still landing for a component registered more
38
+ // than this many mounts ago means a lane is holding a long-dead id, itself the script-bug class
39
+ // the severe code exists to surface.
40
+ const MAX_KNOWN_IDS = 4096;
41
+ // The shared empty-drain constant: a quiet frame (the overwhelming common case) allocates nothing.
42
+ const EMPTY_STALE_REPORTS = Object.freeze([]);
43
+ // The identity output for a stale read: shared and frozen, never a per-call allocation.
44
+ const STALE_IDENTITY_OUTPUT = Object.freeze({
45
+ ...IDENTITY_TRANSFORM,
46
+ borderRadius: null,
47
+ targetDeltaScaleX: 1,
48
+ targetDeltaScaleY: 1,
49
+ treeScaleX: 1,
50
+ treeScaleY: 1,
51
+ });
52
+ // The per-frame path writes into the node's persistent cell; allocating a fresh radius pair per
53
+ // frame is exactly what REQ-DRIVER-021 forbids.
54
+ // Pure correction math (REQ-LAYOUT-013: scale × corrected == radius). Callers gate scale
55
+ // validity at the boundary — project()'s report-and-skip above — so this module never throws on
56
+ // a UI-executed path: an operating error is reported at the call boundary (seal r2, F3).
57
+ function writeCorrectedRadius(cell, radius, scaleX, scaleY) {
58
+ cell.x = radius / scaleX;
59
+ cell.y = radius / scaleY;
60
+ }
61
+ // Finite-fields probe for setProjection's pre-mutation validation (seal r11, F2a): position may
62
+ // be any finite number; the dimensions carry their own sign rules at the call site (target must
63
+ // be positive — the divisor inside invert; source may be zero — a collapsed host).
64
+ function isFiniteRectGeometry(rect) {
65
+ return (Number.isFinite(rect.x) &&
66
+ Number.isFinite(rect.y) &&
67
+ Number.isFinite(rect.width) &&
68
+ Number.isFinite(rect.height));
69
+ }
70
+ // The internal-invariant refusals that STAY loud (registration-time, never a UI frame path):
71
+ // a parent-change after registration and a path cycle (refreshPathsAndStableSortAtRegistration).
72
+ // Every frame/update-path refusal converted to report+skip in Phase 1.5 (iOS AUTO SIGABRT campaign).
73
+ // The severity-class partition behind the dedupe law (seal r9, F2) and the ring's delivery
74
+ // reserve (seal r9, F3): escalated codes name script bugs on live nodes — a wrong-forest or
75
+ // mistyped publish, malformed live input, undefined scale geometry, and the ring-overflow
76
+ // record itself; contained codes name routine teardown races and operating conditions.
77
+ function isEscalatedProjectionStaleCode(code) {
78
+ return (code === 'projection-unregistered-node' ||
79
+ code === 'projection-invalid-frame-input' ||
80
+ code === 'projection-non-finite-output' ||
81
+ code === 'projection-report-overflow');
82
+ }
83
+ function resetOutput(node) {
84
+ node.output.translateX = 0;
85
+ node.output.translateY = 0;
86
+ node.output.scaleX = 1;
87
+ node.output.scaleY = 1;
88
+ node.output.borderRadius = null;
89
+ node.output.targetDeltaScaleX = 1;
90
+ node.output.targetDeltaScaleY = 1;
91
+ node.output.treeScaleX = 1;
92
+ node.output.treeScaleY = 1;
93
+ }
94
+ // alloc-ok: lifecycle-edge — runs only inside register(); the path array is built once per node.
95
+ function updateDepth(node, parent) {
96
+ node.depth = parent === undefined ? 0 : parent.depth + 1;
97
+ node.path = parent === undefined ? [node.componentId] : [...parent.path, node.componentId];
98
+ }
99
+ // Hoisted module-level comparator: the registration-edge stable sort allocates no closure.
100
+ function compareByDepth(left, right) {
101
+ return left.depth - right.depth;
102
+ }
103
+ /**
104
+ * Creates a stable, depth-sorted component tree. `project()` deliberately walks parents before
105
+ * children: a non-projecting intermediate has identity output but preserves its ancestor's scale
106
+ * in `treeScale`, which is precisely the muggle-through capability required by REQ-LAYOUT-012.
107
+ *
108
+ * iOS AUTO SIGABRT campaign: every frame/update path is NON-FATAL on a stale id, an orphaned
109
+ * path, or torn-down frame input — expected teardown races report once per id per episode into a
110
+ * bounded ring (drained by the host's severity channel); they never throw on the UI runtime.
111
+ */
112
+ // alloc-ok: construction — one tree per driver; the per-frame surface is project(), which
113
+ // mutates persistent node cells only.
114
+ export function createProjectionTree(options) {
115
+ // Config-time invariant, loud JS-side like the register/sort-at-registration asserts below
116
+ // (seal r8, F2): the delivery surface is MANDATORY — a bare tree's escalated reports would
117
+ // accumulate silently. Untyped JS callers get the same law at runtime.
118
+ const opts = options;
119
+ const onStaleProjectionNode = opts !== undefined && 'onStaleProjectionNode' in opts ? opts.onStaleProjectionNode : undefined;
120
+ const drainDeclared = opts !== undefined && 'delivery' in opts && opts.delivery === 'drain';
121
+ if (onStaleProjectionNode === undefined && !drainDeclared) {
122
+ throw new Error('projection tree: createProjectionTree requires a stale-report delivery surface — install ' +
123
+ "onStaleProjectionNode (JS hosts) or pass { delivery: 'drain' } (the UI driver's " +
124
+ 'frame-edge drain); a bare tree silently accumulates reports in an undrained ring ' +
125
+ '(seal r8, F2).');
126
+ }
127
+ const nodes = new Map();
128
+ // Depth-sorted once per register() by refreshPathsAndStableSortAtRegistration (packet §6
129
+ // projection repair): project() reads this list with NO orderDirty/reorder/sort edge in-frame.
130
+ // unregister() removes by index (order-preserving), so the invariant holds across teardown too.
131
+ const ordered = [];
132
+ // The bounded stale-report ring and its per-episode dedupe sets — partitioned by escalation
133
+ // class (seal r9, F2): a CONTAINED report must never suppress a later ESCALATED report for
134
+ // the same id (the severity channel would lose the deferred dev throw / production error),
135
+ // while same-class repeats still dedupe once per episode. Re-registration clears the id from
136
+ // BOTH sets, so a genuinely new teardown of the same id reports again.
137
+ const staleReports = [];
138
+ // Ring accounting (seal r10, F1): contained and escalated entries carry SEPARATE bounds — an
139
+ // escalation never competes with contained reports for ring space. A dropped report (past its
140
+ // class cap) is aggregated per id into the overflow record's detail, which names every dropped
141
+ // id and count; the whole window's accounting resets on drain.
142
+ let containedInRing = 0;
143
+ let escalatedInRing = 0;
144
+ let overflowPending = false;
145
+ let overflowRecordIndex = -1;
146
+ const droppedContainedCounts = new Map();
147
+ const droppedEscalatedCounts = new Map();
148
+ // The counts-only remainder past MAX_OVERFLOW_NAMED_IDS (seal r12, F4): drops of ids beyond
149
+ // the named set aggregate here, so the loss accounting AND the rebuilt detail stay bounded in
150
+ // a drain-free session.
151
+ let extraContainedDrops = 0;
152
+ let extraEscalatedDrops = 0;
153
+ const reportedContainedIds = new Set();
154
+ const reportedEscalatedIds = new Set();
155
+ // The known-id ledger behind the stale-vs-never-registered distinction (seal r7, F4): it only
156
+ // GROWS here at register() — an id that registered and later tore down stays known, so its
157
+ // teardown race keeps the contained 'projection-stale-node' code, while a never-registered id
158
+ // escalates to 'projection-unregistered-node'. Lifecycle-edge only; the frame path reads it.
159
+ const knownIds = new Set();
160
+ // alloc-ok: lifecycle-edge — the first stale hit per (class, id) per episode allocates the
161
+ // report record, the detail string (built ONLY here, after the dedupe gate), and the dedupe
162
+ // entry; the deduped fast path (every later frame) allocates nothing.
163
+ function reportStaleNode(code, componentId, subject, reason) {
164
+ const dedupeSet = isEscalatedProjectionStaleCode(code)
165
+ ? reportedEscalatedIds
166
+ : reportedContainedIds;
167
+ if (dedupeSet.has(componentId))
168
+ return;
169
+ dedupeSet.add(componentId);
170
+ if (dedupeSet.size > MAX_STALE_IDS) {
171
+ // The size check makes the first value blatantly present; iteration is insertion-ordered,
172
+ // so this evicts the oldest episode (see MAX_STALE_IDS above).
173
+ dedupeSet.delete(dedupeSet.values().next().value);
174
+ }
175
+ const detail = code === 'projection-orphaned-path'
176
+ ? `'${componentId}' orphaned: parent '${subject}' ${reason}.`
177
+ : code === 'projection-invalid-radius-correction'
178
+ ? `borderRadius correction skipped on '${componentId}' (${reason}; the last valid radius is preserved).`
179
+ : code === 'projection-invalid-frame-input'
180
+ ? `${subject} ${reason} on '${componentId}' (malformed input on a live node; update refused).`
181
+ : code === 'projection-unregistered-node'
182
+ ? `${subject} refused on '${componentId}': the id was never registered in this projection tree (a wrong-forest or mistyped publish — a script bug, not a teardown race).`
183
+ : code === 'projection-non-finite-output'
184
+ ? `${subject} ${reason} on '${componentId}' (undefined geometry on a live node; output frozen at the last valid finite state).`
185
+ : `${subject} ${reason} (teardown race — the host detached before this update).`;
186
+ const report = { source: 'projection-tree', code, detail };
187
+ pushStaleReport(report, isEscalatedProjectionStaleCode(code), componentId);
188
+ onStaleProjectionNode?.(report);
189
+ }
190
+ // The ring's fail-loud law (seal r9, F3; seal r10, F1): each class keeps its own bound — an
191
+ // ESCALATED report never competes with contained entries and is PRESERVED up to
192
+ // MAX_STALE_ESCALATED_REPORTS (nothing evicts an escalation to make room). A report dropped
193
+ // past its class cap is never silent: exactly one projection-report-overflow record per
194
+ // inter-drain window marks the loss, and its detail aggregates EVERY dropped id with its
195
+ // count, per class — the text always matches what was actually lost.
196
+ // alloc-ok: lifecycle-edge — drops happen only past a class cap (a teardown storm); the
197
+ // deduped fast path (a quiet frame) allocates nothing.
198
+ function pushStaleReport(report, escalated, componentId) {
199
+ if (escalated) {
200
+ if (escalatedInRing < MAX_STALE_ESCALATED_REPORTS) {
201
+ escalatedInRing += 1;
202
+ staleReports.push(report);
203
+ }
204
+ else {
205
+ recordDroppedReport(componentId, true);
206
+ }
207
+ return;
208
+ }
209
+ if (containedInRing < MAX_STALE_REPORTS) {
210
+ containedInRing += 1;
211
+ staleReports.push(report);
212
+ return;
213
+ }
214
+ recordDroppedReport(componentId, false);
215
+ }
216
+ // alloc-ok: lifecycle-edge — one bounded map write per dropped report (already past a class
217
+ // cap); past the named-id bound a counter increments instead (seal r12, F4).
218
+ function recordDroppedReport(componentId, escalated) {
219
+ const counts = escalated ? droppedEscalatedCounts : droppedContainedCounts;
220
+ const existing = counts.get(componentId);
221
+ if (existing !== undefined) {
222
+ counts.set(componentId, existing + 1);
223
+ }
224
+ else if (counts.size < MAX_OVERFLOW_NAMED_IDS) {
225
+ counts.set(componentId, 1);
226
+ }
227
+ else if (escalated) {
228
+ extraEscalatedDrops += 1;
229
+ }
230
+ else {
231
+ extraContainedDrops += 1;
232
+ }
233
+ markReportOverflow();
234
+ }
235
+ // alloc-ok: lifecycle-edge — detail text built only on an overflow (re)mark, over at most
236
+ // MAX_OVERFLOW_NAMED_IDS named entries plus the counts-only remainder (bounded, seal r12, F4).
237
+ function formatDroppedCounts(counts, extra) {
238
+ let total = extra;
239
+ let list = '';
240
+ for (const [id, count] of counts) {
241
+ total += count;
242
+ list += `${list === '' ? '' : ', '}${id}×${count}`;
243
+ }
244
+ if (extra > 0)
245
+ list += `${list === '' ? '' : ', '}+${extra} more`;
246
+ return `${total} [${list}]`;
247
+ }
248
+ // alloc-ok: lifecycle-edge — one record per inter-drain window (overflowPending dedupes);
249
+ // later drops in the same window REPLACE the in-ring record's detail so the drained record
250
+ // names every dropped id and count.
251
+ function markReportOverflow() {
252
+ const lossParts = [];
253
+ if (droppedContainedCounts.size > 0 || extraContainedDrops > 0)
254
+ lossParts.push(`dropped ${formatDroppedCounts(droppedContainedCounts, extraContainedDrops)} contained report(s)`);
255
+ if (droppedEscalatedCounts.size > 0 || extraEscalatedDrops > 0)
256
+ lossParts.push(`dropped ${formatDroppedCounts(droppedEscalatedCounts, extraEscalatedDrops)} escalated report(s)`);
257
+ if (droppedContainedCounts.size === 0 && extraContainedDrops === 0)
258
+ lossParts.push('no contained reports were dropped');
259
+ if (droppedEscalatedCounts.size === 0 && extraEscalatedDrops === 0)
260
+ lossParts.push('every escalated report was delivered');
261
+ const detail = `projection stale-report ring overflowed: ${lossParts.join('; ')} ` +
262
+ `(caps: ${MAX_STALE_REPORTS} contained, ${MAX_STALE_ESCALATED_REPORTS} escalated; seal r10, F1).`;
263
+ if (overflowPending) {
264
+ staleReports[overflowRecordIndex] = {
265
+ source: 'projection-tree',
266
+ code: 'projection-report-overflow',
267
+ detail,
268
+ };
269
+ return;
270
+ }
271
+ overflowPending = true;
272
+ overflowRecordIndex = staleReports.length;
273
+ const report = {
274
+ source: 'projection-tree',
275
+ code: 'projection-report-overflow',
276
+ detail,
277
+ };
278
+ staleReports.push(report);
279
+ onStaleProjectionNode?.(report);
280
+ }
281
+ // The idempotent no-op path of every stale mutator/reader below: static subject/reason parts
282
+ // only — nothing is built at the call site (REQ-DRIVER-021). Seal r7, F4: the code
283
+ // branches on the known-id ledger — a never-registered id is a wrong-forest/mistyped publish
284
+ // (escalated), a known-then-torn-down id is the contained Phase-1.5 teardown race.
285
+ function staleNode(componentId, operation) {
286
+ if (knownIds.has(componentId)) {
287
+ reportStaleNode('projection-stale-node', componentId, operation, 'on an unregistered id');
288
+ }
289
+ else {
290
+ reportStaleNode('projection-unregistered-node', componentId, operation, '');
291
+ }
292
+ return undefined;
293
+ }
294
+ // alloc-ok: lifecycle-edge — React mounts passive effects child-first. Resolve the complete
295
+ // component forest after each attach so a descendant may enter before its parent without
296
+ // weakening the frame-path's parent-before-child invariant, then perform the stable depth sort
297
+ // SYNCHRONOUSLY here (packet §6 registration-edge repair): project() never sorts in-frame.
298
+ // A stable sort retains sibling mount order, which keeps the traversal deterministic without
299
+ // encoding host tags.
300
+ function refreshPathsAndStableSortAtRegistration() {
301
+ const resolving = new Set();
302
+ const resolved = new Set();
303
+ function resolve(node) {
304
+ if (resolved.has(node.componentId))
305
+ return;
306
+ if (resolving.has(node.componentId)) {
307
+ throw new Error(`projection tree: parent cycle reaches '${node.componentId}'; a Motion component cannot be its own projection ancestor.`);
308
+ }
309
+ resolving.add(node.componentId);
310
+ const parent = node.parentId === null ? undefined : nodes.get(node.parentId);
311
+ if (parent !== undefined)
312
+ resolve(parent);
313
+ updateDepth(node, parent);
314
+ resolving.delete(node.componentId);
315
+ resolved.add(node.componentId);
316
+ }
317
+ for (const node of nodes.values())
318
+ resolve(node);
319
+ ordered.sort(compareByDepth);
320
+ }
321
+ // alloc-ok: lifecycle-edge — runs only inside unregister()/prune teardown turns.
322
+ function hasRegisteredDescendant(componentId) {
323
+ for (const candidate of nodes.values()) {
324
+ if (candidate.parentId === componentId)
325
+ return true;
326
+ }
327
+ return false;
328
+ }
329
+ // alloc-ok: lifecycle-edge — runs only inside unregister()'s teardown turn.
330
+ function removeNode(node) {
331
+ nodes.delete(node.componentId);
332
+ const index = ordered.indexOf(node);
333
+ if (index >= 0)
334
+ ordered.splice(index, 1);
335
+ }
336
+ // Passive-effect cleanup is parent-first. A parent is therefore retained as an inert ancestry
337
+ // record until its descendants have detached, then compacted in the same lifecycle turn.
338
+ // alloc-ok: lifecycle-edge — runs only inside unregister()'s teardown turn.
339
+ function prunePendingUnregisters() {
340
+ let pruned = true;
341
+ while (pruned) {
342
+ pruned = false;
343
+ for (const node of nodes.values()) {
344
+ if (node.pendingUnregister && !hasRegisteredDescendant(node.componentId)) {
345
+ removeNode(node);
346
+ pruned = true;
347
+ break;
348
+ }
349
+ }
350
+ }
351
+ }
352
+ return {
353
+ // alloc-ok: lifecycle-edge — host mount; builds the node record once, never per frame.
354
+ register(componentId, parentId, projectionParticipant = false) {
355
+ // A re-registered id opens a NEW episode in BOTH dedupe classes: its next teardown race
356
+ // (contained) and its next script-bug report (escalated) both fire again (Phase 1.5; r9 F2).
357
+ reportedContainedIds.delete(componentId);
358
+ reportedEscalatedIds.delete(componentId);
359
+ // The known-id ledger (seal r7, F4) only grows: once registered, an id's later teardown
360
+ // race stays the contained 'projection-stale-node' class forever (bounded above).
361
+ if (!knownIds.has(componentId)) {
362
+ knownIds.add(componentId);
363
+ if (knownIds.size > MAX_KNOWN_IDS)
364
+ knownIds.delete(knownIds.values().next().value);
365
+ }
366
+ const existing = nodes.get(componentId);
367
+ if (existing !== undefined) {
368
+ if (existing.parentId !== parentId) {
369
+ throw new Error(`projection tree: '${componentId}' changed parent from '${existing.parentId}' to '${parentId}' after registration; remount is required to preserve a coherent projection path.`);
370
+ }
371
+ // A re-registered node is ALIVE (seal r14, F1): the pending teardown belonged to its
372
+ // previous life — an unregister retained it as inert ancestry only because a descendant
373
+ // still lived, and this re-registration is the host returning. Leaving the flag set
374
+ // would let the next prune (when the last descendant later detaches) remove the LIVE
375
+ // node: its next update would then misreport a stale teardown and freeze at identity.
376
+ // Its later teardown is evaluated fresh by the next unregister.
377
+ existing.pendingUnregister = false;
378
+ if (existing.projectionParticipant !== projectionParticipant) {
379
+ existing.projectionParticipant = projectionParticipant;
380
+ // `layout`/active `layoutId` can be removed on a later render. Retaining either leg
381
+ // would keep a stale transform painted after the host has returned to ordinary layout.
382
+ if (!projectionParticipant) {
383
+ existing.target = null;
384
+ existing.delta = null;
385
+ existing.hasHostFrame = false;
386
+ existing.projectionPaintedByHost = false;
387
+ existing.treeScaleX = 1;
388
+ existing.treeScaleY = 1;
389
+ existing.treeTranslateX = 0;
390
+ existing.treeTranslateY = 0;
391
+ existing.frameTreeCenterOffsetX = 0;
392
+ existing.frameTreeCenterOffsetY = 0;
393
+ existing.committedInHostFrame = false;
394
+ resetOutput(existing);
395
+ }
396
+ }
397
+ return; // Same-state React rerender: never reset a live projection.
398
+ }
399
+ const node = {
400
+ componentId,
401
+ parentId,
402
+ depth: 0,
403
+ path: [],
404
+ target: null,
405
+ targetCell: { x: 0, y: 0, width: 0, height: 0 },
406
+ layoutCell: { x: 0, y: 0, width: 0, height: 0 },
407
+ hasHostFrame: false,
408
+ projectionParticipant,
409
+ projectionPaintedByHost: false,
410
+ pendingUnregister: false,
411
+ delta: null,
412
+ deltaCell: { ...IDENTITY_TRANSFORM },
413
+ borderRadius: null,
414
+ // alloc-ok: lifecycle-edge — one persistent radius cell per registered node, mutated per frame.
415
+ radiusCell: { x: 0, y: 0 },
416
+ radiusCorrected: false,
417
+ treeScaleX: 1,
418
+ treeScaleY: 1,
419
+ treeTranslateX: 0,
420
+ treeTranslateY: 0,
421
+ frameTreeCenterOffsetX: 0,
422
+ frameTreeCenterOffsetY: 0,
423
+ committedInHostFrame: false,
424
+ output: {
425
+ ...IDENTITY_TRANSFORM,
426
+ borderRadius: null,
427
+ targetDeltaScaleX: 1,
428
+ targetDeltaScaleY: 1,
429
+ treeScaleX: 1,
430
+ treeScaleY: 1,
431
+ },
432
+ };
433
+ nodes.set(componentId, node);
434
+ ordered.push(node);
435
+ refreshPathsAndStableSortAtRegistration();
436
+ },
437
+ // alloc-ok: lifecycle-edge — host unmount (React passive cleanup is parent-first).
438
+ unregister(componentId) {
439
+ const node = nodes.get(componentId);
440
+ // Double-teardown is a routine race (parent-first cleanup can detach a node twice across
441
+ // overlapping lanes): an unknown id is an idempotent no-op, never a throw (Phase 1.5).
442
+ if (node === undefined)
443
+ return;
444
+ if (hasRegisteredDescendant(componentId)) {
445
+ node.pendingUnregister = true;
446
+ return;
447
+ }
448
+ removeNode(node);
449
+ prunePendingUnregisters();
450
+ },
451
+ setTarget(componentId, target) {
452
+ const node = nodes.get(componentId) ?? staleNode(componentId, 'setTarget');
453
+ if (node === undefined)
454
+ return;
455
+ node.target = target;
456
+ if (target !== null)
457
+ node.projectionParticipant = true;
458
+ },
459
+ setProjection(componentId, source, target, progress) {
460
+ const node = nodes.get(componentId) ?? staleNode(componentId, 'setProjection');
461
+ if (node === undefined)
462
+ return;
463
+ if (!Number.isFinite(progress) || progress < 0 || progress > 1) {
464
+ reportStaleNode('projection-invalid-frame-input', componentId, 'setProjection', 'progress outside [0, 1]');
465
+ return;
466
+ }
467
+ // Seal r11, F2a: validate BOTH rects BEFORE any mutation. A zero/negative/non-finite
468
+ // TARGET dimension divides by zero inside projectAtProgress's invert (its assertMeasurable
469
+ // throws — a UI throw through an imported callee the tree scan could not see, the review's
470
+ // probe), and non-finite or negative SOURCE fields poison the delta. Refuse through the
471
+ // escalated malformed-live-input channel: no mutation, no throw, the node keeps its last
472
+ // valid projection state. A zero SOURCE dimension stays legal (a collapsed host is a
473
+ // legal measurement — the finite-zero delta publishes, seal r9, F1).
474
+ if (!isFiniteRectGeometry(source) ||
475
+ !isFiniteRectGeometry(target) ||
476
+ source.width < 0 ||
477
+ source.height < 0 ||
478
+ target.width <= 0 ||
479
+ target.height <= 0) {
480
+ reportStaleNode('projection-invalid-frame-input', componentId, 'setProjection', 'non-finite rect fields or non-positive target dimensions');
481
+ return;
482
+ }
483
+ node.target = target;
484
+ node.projectionParticipant = true;
485
+ node.hasHostFrame = false;
486
+ node.projectionPaintedByHost = false;
487
+ node.delta = projectAtProgress(source, target, progress);
488
+ },
489
+ setDirectProjection(componentId, translateX, translateY, scaleX, scaleY) {
490
+ const node = nodes.get(componentId) ?? staleNode(componentId, 'setDirectProjection');
491
+ if (node === undefined)
492
+ return;
493
+ if (!Number.isFinite(translateX) ||
494
+ !Number.isFinite(translateY) ||
495
+ !Number.isFinite(scaleX) ||
496
+ !Number.isFinite(scaleY) ||
497
+ scaleX <= 0 ||
498
+ scaleY <= 0) {
499
+ reportStaleNode('projection-invalid-frame-input', componentId, 'setDirectProjection', 'non-finite/non-positive translation or scale');
500
+ return;
501
+ }
502
+ node.projectionParticipant = true;
503
+ node.hasHostFrame = false;
504
+ node.projectionPaintedByHost = true;
505
+ node.deltaCell.translateX = translateX;
506
+ node.deltaCell.translateY = translateY;
507
+ node.deltaCell.scaleX = scaleX;
508
+ node.deltaCell.scaleY = scaleY;
509
+ node.delta = node.deltaCell;
510
+ },
511
+ // @frame-path — host layout animations report their current rect on every UI frame. Mutate
512
+ // persistent cells rather than constructing Rect/Transform records: the worklet frame path
513
+ // is allocation-gated, while targetless descendants consume this exact delta in the same step.
514
+ setHostFrame(componentId, currentX, currentY, currentWidth, currentHeight, targetX, targetY, targetWidth, targetHeight) {
515
+ const node = nodes.get(componentId) ?? staleNode(componentId, 'setHostFrame');
516
+ if (node === undefined)
517
+ return;
518
+ // Negative dimensions are nonsense geometry, not a collapsed host (seal r12, F3): they
519
+ // used to sail through and publish NEGATIVE scales into the descendant projection math
520
+ // (the reviewer's scaleX: -1 probe). Current dims may be zero (legal collapse — the
521
+ // whole-node freeze owns that path, seal r9 F1); target dims divide, so they must be
522
+ // positive (seal r11, F2a's law). Refuse BEFORE any mutation.
523
+ if (!Number.isFinite(currentX) ||
524
+ !Number.isFinite(currentY) ||
525
+ !Number.isFinite(currentWidth) ||
526
+ !Number.isFinite(currentHeight) ||
527
+ !Number.isFinite(targetX) ||
528
+ !Number.isFinite(targetY) ||
529
+ !Number.isFinite(targetWidth) ||
530
+ !Number.isFinite(targetHeight) ||
531
+ currentWidth < 0 ||
532
+ currentHeight < 0 ||
533
+ targetWidth <= 0 ||
534
+ targetHeight <= 0) {
535
+ reportStaleNode('projection-invalid-frame-input', componentId, 'setHostFrame', 'non-finite rect, negative current dimension, or non-positive target dimension');
536
+ return;
537
+ }
538
+ node.layoutCell.x = currentX;
539
+ node.layoutCell.y = currentY;
540
+ node.layoutCell.width = currentWidth;
541
+ node.layoutCell.height = currentHeight;
542
+ node.targetCell.x = targetX;
543
+ node.targetCell.y = targetY;
544
+ node.targetCell.width = targetWidth;
545
+ node.targetCell.height = targetHeight;
546
+ node.target = node.targetCell;
547
+ node.projectionParticipant = true;
548
+ node.hasHostFrame = true;
549
+ node.projectionPaintedByHost = false;
550
+ node.deltaCell.translateX = currentX - targetX;
551
+ node.deltaCell.translateY = currentY - targetY;
552
+ node.deltaCell.scaleX = currentWidth / targetWidth;
553
+ node.deltaCell.scaleY = currentHeight / targetHeight;
554
+ node.delta = node.deltaCell;
555
+ },
556
+ clearProjection(componentId) {
557
+ const node = nodes.get(componentId) ?? staleNode(componentId, 'clearProjection');
558
+ if (node === undefined)
559
+ return;
560
+ node.delta = null;
561
+ node.hasHostFrame = false;
562
+ node.projectionPaintedByHost = false;
563
+ },
564
+ setBorderRadius(componentId, radius) {
565
+ const node = nodes.get(componentId) ?? staleNode(componentId, 'setBorderRadius');
566
+ if (node === undefined)
567
+ return;
568
+ if (radius !== null && (!Number.isFinite(radius) || radius < 0)) {
569
+ reportStaleNode('projection-invalid-frame-input', componentId, 'setBorderRadius', 'not a finite non-negative number');
570
+ return;
571
+ }
572
+ if (node.borderRadius !== radius) {
573
+ // A CHANGED authored radius invalidates the stale correction (read-current ownership,
574
+ // REQ-LAYOUT-013 — seal r7, F5): the cell answers the new input, so publish the null
575
+ // sentinel until a valid correction exists for it — never the obsolete pair.
576
+ node.radiusCorrected = false;
577
+ }
578
+ node.borderRadius = radius;
579
+ },
580
+ project(visitor) {
581
+ // No orderDirty/reorder/sort edge here (packet §6): register() already performed the stable
582
+ // depth sort; unregister() removes by index, preserving the order.
583
+ // Index loop: for..of allocates an iterator per frame on Hermes (REQ-DRIVER-021).
584
+ for (let index = 0; index < ordered.length; index += 1) {
585
+ const node = ordered[index];
586
+ const parent = node.parentId === null ? undefined : nodes.get(node.parentId);
587
+ if (node.parentId !== null && parent === undefined) {
588
+ // React passive cleanup is parent-first, so a child outliving its parent mid-teardown
589
+ // is routine: skip the orphaned node for THIS frame (its output stays frozen at the
590
+ // last valid value, and descendants keep the frozen inherited tree* values) and record
591
+ // one orphaned-path report per id per episode — never a UI-thread throw (Phase 1.5).
592
+ reportStaleNode('projection-orphaned-path', node.componentId, node.parentId, 'unregistered mid-teardown');
593
+ continue;
594
+ }
595
+ const inheritedScaleX = parent?.treeScaleX ?? 1;
596
+ const inheritedScaleY = parent?.treeScaleY ?? 1;
597
+ const inheritedTranslateX = parent?.treeTranslateX ?? 0;
598
+ const inheritedTranslateY = parent?.treeTranslateY ?? 0;
599
+ const inheritedFrameCenterOffsetX = parent?.frameTreeCenterOffsetX ?? 0;
600
+ const inheritedFrameCenterOffsetY = parent?.frameTreeCenterOffsetY ?? 0;
601
+ const ownDelta = node.delta;
602
+ // Seal r9, F1: refuse undefined geometry BEFORE any division. A zero/non-finite
603
+ // inherited (accumulated) scale, a zero/non-finite current host-frame dimension, or a
604
+ // non-finite delta makes this node's output divisions undefined — at HEAD those
605
+ // produced Infinity/NaN outputs and published them into native shared transform values
606
+ // with no report (the review's zero-width-parent and nested-zero-current probes). The
607
+ // whole node freezes at its last valid finite state for this frame (skip the reset AND
608
+ // the recompute; descendants inherit the frozen — finite — tree* values) and records
609
+ // one escalated report per id per episode: every node here is registered and live by
610
+ // construction, so this is malformed live geometry, never a teardown race. A FINITE
611
+ // zero delta scale still publishes (a collapsed host is a legal measurement) — the
612
+ // descendant's inherited-zero check is what refuses it.
613
+ if (!Number.isFinite(inheritedScaleX) ||
614
+ inheritedScaleX === 0 ||
615
+ !Number.isFinite(inheritedScaleY) ||
616
+ inheritedScaleY === 0 ||
617
+ (node.hasHostFrame &&
618
+ (!Number.isFinite(node.layoutCell.width) ||
619
+ node.layoutCell.width === 0 ||
620
+ !Number.isFinite(node.layoutCell.height) ||
621
+ node.layoutCell.height === 0)) ||
622
+ (ownDelta !== null &&
623
+ (!Number.isFinite(ownDelta.translateX) ||
624
+ !Number.isFinite(ownDelta.translateY) ||
625
+ !Number.isFinite(ownDelta.scaleX) ||
626
+ !Number.isFinite(ownDelta.scaleY)))) {
627
+ reportStaleNode('projection-non-finite-output', node.componentId, 'project', 'zero/non-finite scale geometry');
628
+ // The freeze preserves the TRANSFORM at its last valid state, but the radius publish
629
+ // still follows read-current ownership (seal r7, F5): the cell answers only the
630
+ // CURRENT authored radius — a changed or removed radius publishes the null sentinel
631
+ // even on a frozen frame, never the obsolete corrected pair.
632
+ node.output.borderRadius =
633
+ node.borderRadius !== null && node.radiusCorrected ? node.radiusCell : null;
634
+ continue;
635
+ }
636
+ // Outside host frames, radius is painted inside the raw projection transform before this
637
+ // node's correction is composed. Preserve that scale separately: `treeScale*output.scale` below is the
638
+ // effective scale inherited by descendants, whereas the radius must cancel the scale
639
+ // Reanimated actually applies to this host's border paint.
640
+ const rawTreeScaleX = node.projectionPaintedByHost
641
+ ? (ownDelta?.scaleX ?? 1)
642
+ : inheritedScaleX * (ownDelta?.scaleX ?? 1);
643
+ const rawTreeScaleY = node.projectionPaintedByHost
644
+ ? (ownDelta?.scaleY ?? 1)
645
+ : inheritedScaleY * (ownDelta?.scaleY ?? 1);
646
+ resetOutput(node);
647
+ if (node.hasHostFrame) {
648
+ const target = node.target;
649
+ if (target === null) {
650
+ // ui-throw-ok: internal invariant — setHostFrame is the ONLY production writer of
651
+ // hasHostFrame and it always assigns the target cell in the same edge; setTarget has
652
+ // no production caller. A null target here is tree corruption (a programmer error),
653
+ // never an operating error — the audited assert stays loud.
654
+ throw new Error(`projection tree: '${node.componentId}' has a host frame without a target rect; host-frame projection requires coherent geometry.`);
655
+ }
656
+ // Reanimated's layout lane already paints `layoutCell` — the host's real current box,
657
+ // parent-relative — for roots and nested hosts, which is the pin's nested layout box
658
+ // (relativeTarget = mix(relativeTargetOrigin, relativeLayout, p)). The tree never
659
+ // replays this host's FLIP or rescales its lane: the host keeps its painted size.
660
+ // The one correction is positional, when the lane's own parent was committed by Yoga at
661
+ // its final place inside a flying frame: the lane then rides that misplaced parent, so
662
+ // the host is re-centred on its target centre in the ancestor's painted frame.
663
+ if (parent?.committedInHostFrame === true) {
664
+ node.output.translateX =
665
+ target.x +
666
+ target.width / 2 +
667
+ inheritedFrameCenterOffsetX -
668
+ (node.layoutCell.x + node.layoutCell.width / 2);
669
+ node.output.translateY =
670
+ target.y +
671
+ target.height / 2 +
672
+ inheritedFrameCenterOffsetY -
673
+ (node.layoutCell.y + node.layoutCell.height / 2);
674
+ }
675
+ }
676
+ else if (ownDelta !== null && node.projectionPaintedByHost) {
677
+ // layoutId already paints its own FLIP transform in the host style. The tree's output
678
+ // cancels only ancestor geometry; `tree*` below still publishes the live direct delta
679
+ // to targetless descendants through every muggle node.
680
+ node.output.translateX = -inheritedTranslateX / inheritedScaleX;
681
+ node.output.translateY = -inheritedTranslateY / inheritedScaleY;
682
+ node.output.scaleX = 1 / inheritedScaleX;
683
+ node.output.scaleY = 1 / inheritedScaleY;
684
+ }
685
+ else if (ownDelta !== null) {
686
+ // Deltas are expressed in viewport coordinates, but a nested host's transform runs
687
+ // inside its parent's already-projected coordinate system. Convert every leg through
688
+ // that parent frame: scale-only cancellation makes a targetless child drift by the
689
+ // ancestor translation (the M3.5 two-leg registration failure).
690
+ node.output.translateX = (ownDelta.translateX - inheritedTranslateX) / inheritedScaleX;
691
+ node.output.translateY = (ownDelta.translateY - inheritedTranslateY) / inheritedScaleY;
692
+ // A projecting child still renders inside its parent's transformed projection frame.
693
+ // Cancel that inherited scale before applying the child's own box delta, exactly as a
694
+ // targetless child does below. Without this, a child FLIP of 0.8 under a parent scale of
695
+ // 0.5 renders at 0.4 and passes that distortion on to every descendant.
696
+ node.output.scaleX = ownDelta.scaleX / inheritedScaleX;
697
+ node.output.scaleY = ownDelta.scaleY / inheritedScaleY;
698
+ }
699
+ else if (node.projectionParticipant) {
700
+ // A targetless node has no independently measured box delta. Under pure FLIP
701
+ // (frameTreeCenterOffset == 0) its host Yoga position is already parent-relative, so
702
+ // only counter-scale is faithful — replaying ancestor FLIP translation displaces the
703
+ // flex centre (the M3.5 two-leg registration failure). Under native host-layout frames
704
+ // the ancestor is paint-relative identity and half-size centre movement is carried in
705
+ // `frameTreeCenterOffset`: Yoga still commits children against the TARGET box, so a
706
+ // targetless (or non-interpolating) descendant freezes at the final centre unless it
707
+ // receives that offset (Android residual ≈ (targetSize − currentSize)/2).
708
+ node.output.scaleX = 1 / inheritedScaleX;
709
+ node.output.scaleY = 1 / inheritedScaleY;
710
+ node.output.translateX = inheritedFrameCenterOffsetX / inheritedScaleX;
711
+ node.output.translateY = inheritedFrameCenterOffsetY / inheritedScaleY;
712
+ }
713
+ // Descendants inherit the transform that is actually rendered, not this node's raw box
714
+ // delta. A projecting child therefore carries its own scale forward after cancelling its
715
+ // ancestor's scale; an ordinary Motion intermediary remains an exact identity conduit.
716
+ node.treeScaleX = node.projectionPaintedByHost
717
+ ? (ownDelta?.scaleX ?? 1)
718
+ : inheritedScaleX * node.output.scaleX;
719
+ node.treeScaleY = node.projectionPaintedByHost
720
+ ? (ownDelta?.scaleY ?? 1)
721
+ : inheritedScaleY * node.output.scaleY;
722
+ node.treeTranslateX = node.projectionPaintedByHost
723
+ ? (ownDelta?.translateX ?? 0)
724
+ : inheritedTranslateX + inheritedScaleX * node.output.translateX;
725
+ node.treeTranslateY = node.projectionPaintedByHost
726
+ ? (ownDelta?.translateY ?? 0)
727
+ : inheritedTranslateY + inheritedScaleY * node.output.translateY;
728
+ if (node.hasHostFrame) {
729
+ const target = node.target;
730
+ if (target === null) {
731
+ // ui-throw-ok: internal invariant — identical to the same guard earlier in this walk:
732
+ // hasHostFrame implies an assigned target by construction (setHostFrame writes both
733
+ // in one edge), so a null target here is a tree-corruption assert, never an
734
+ // operating error.
735
+ throw new Error(`projection tree: '${node.componentId}' lost its target rect during host-frame projection.`);
736
+ }
737
+ // Yoga commits this host's children against its TARGET box while the lane paints its
738
+ // current box; the half-size change shifts a child's target centre into the painted
739
+ // frame (500 → 324 yields -88), so an already-centred child remains centred. Ancestor
740
+ // offsets are not added: this host's painted frame (lane plus output) already carries
741
+ // them to everything inside it.
742
+ node.frameTreeCenterOffsetX = (node.layoutCell.width - target.width) / 2;
743
+ node.frameTreeCenterOffsetY = (node.layoutCell.height - target.height) / 2;
744
+ node.committedInHostFrame = false;
745
+ }
746
+ else {
747
+ // A muggle node carries ancestor centre movement untouched. Nodes without measured
748
+ // host geometry remain on the targetless path above, but host-framed descendants still
749
+ // correct through any number of ordinary Motion intermediaries.
750
+ node.frameTreeCenterOffsetX = inheritedFrameCenterOffsetX;
751
+ node.frameTreeCenterOffsetY = inheritedFrameCenterOffsetY;
752
+ node.committedInHostFrame =
753
+ parent !== undefined && (parent.hasHostFrame || parent.committedInHostFrame);
754
+ }
755
+ if (node.borderRadius !== null) {
756
+ // The radius cancels the scale painted on this host so the on-screen corner stays the
757
+ // authored circle (pin scale-border-radius.ts). A host frame is painted by Reanimated's
758
+ // layout lane at its real current size, so its own box delta is NOT a painted scale:
759
+ // only the inherited paint and this node's output transform are.
760
+ const radiusScaleX = node.hasHostFrame
761
+ ? inheritedScaleX * node.output.scaleX
762
+ : rawTreeScaleX;
763
+ const radiusScaleY = node.hasHostFrame
764
+ ? inheritedScaleY * node.output.scaleY
765
+ : rawTreeScaleY;
766
+ if (!Number.isFinite(radiusScaleX) ||
767
+ !Number.isFinite(radiusScaleY) ||
768
+ radiusScaleX === 0 ||
769
+ radiusScaleY === 0) {
770
+ // A zero/non-finite accumulated scale makes the correction undefined — an OPERATING
771
+ // condition (setHostFrame accepts a zero current width; only target dims are
772
+ // validated), never an invariant. Report once per id per episode through the
773
+ // stale-report channel and skip the correction, preserving the last valid radius —
774
+ // a throw here escapes the UI frame (iOS AUTO SIGABRT seal r2, F3).
775
+ reportStaleNode('projection-invalid-radius-correction', node.componentId, '', 'zero/non-finite accumulated tree scale');
776
+ }
777
+ else {
778
+ writeCorrectedRadius(node.radiusCell, node.borderRadius, radiusScaleX, radiusScaleY);
779
+ node.radiusCorrected = true;
780
+ }
781
+ // The sentinel law: publish the cell only once a valid correction exists — null
782
+ // otherwise (authored radius retained); a skipped frame keeps the last valid state.
783
+ node.output.borderRadius = node.radiusCorrected ? node.radiusCell : null;
784
+ }
785
+ const targetDeltaScaleX = ownDelta?.scaleX ?? 1;
786
+ const targetDeltaScaleY = ownDelta?.scaleY ?? 1;
787
+ node.output.targetDeltaScaleX = targetDeltaScaleX;
788
+ node.output.targetDeltaScaleY = targetDeltaScaleY;
789
+ node.output.treeScaleX =
790
+ targetDeltaScaleX === 0 ? rawTreeScaleX : rawTreeScaleX / targetDeltaScaleX;
791
+ node.output.treeScaleY =
792
+ targetDeltaScaleY === 0 ? rawTreeScaleY : rawTreeScaleY / targetDeltaScaleY;
793
+ visitor?.(node);
794
+ }
795
+ },
796
+ output(componentId) {
797
+ const node = nodes.get(componentId) ?? staleNode(componentId, 'output');
798
+ // A stale read returns the shared frozen identity output (last safe visual state), never a
799
+ // per-call allocation and never a throw (Phase 1.5).
800
+ if (node === undefined)
801
+ return STALE_IDENTITY_OUTPUT;
802
+ return node.output;
803
+ },
804
+ // alloc-ok: lifecycle-edge — a quiet frame returns the shared frozen empty array; draining
805
+ // actual reports is a bounded per-episode operation, never per-frame work.
806
+ drainStaleReports() {
807
+ if (staleReports.length === 0)
808
+ return EMPTY_STALE_REPORTS;
809
+ const drained = staleReports.slice();
810
+ staleReports.length = 0;
811
+ // The inter-drain window rolls (seal r10, F1): class counters, the overflow marker, and
812
+ // the per-id loss ledger all reset.
813
+ containedInRing = 0;
814
+ escalatedInRing = 0;
815
+ overflowPending = false;
816
+ overflowRecordIndex = -1;
817
+ droppedContainedCounts.clear();
818
+ droppedEscalatedCounts.clear();
819
+ extraContainedDrops = 0;
820
+ extraEscalatedDrops = 0;
821
+ return drained;
822
+ },
823
+ };
824
+ }