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