@rootnative/inertia 0.0.3 → 0.0.4

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 (74) hide show
  1. package/CHANGELOG.md +41 -5
  2. package/README.md +5 -3
  3. package/dist/{chunk-SGUHE5CX.mjs → chunk-4ASOELLA.mjs} +1 -1
  4. package/dist/{chunk-CSODMRJ7.mjs → chunk-55W7BNO2.mjs} +475 -83
  5. package/dist/chunk-5SRD2WCO.js +8 -0
  6. package/dist/{chunk-PTRF47DA.js → chunk-7A6WTXNK.js} +10 -3
  7. package/dist/{chunk-3XTVY34H.js → chunk-CENTULW6.js} +2 -2
  8. package/dist/{chunk-4PEHWDAZ.js → chunk-D5KAHWMC.js} +7 -7
  9. package/dist/{chunk-O22NXXCZ.mjs → chunk-J5Q46OPW.mjs} +1 -1
  10. package/dist/{chunk-RGNX6UZN.mjs → chunk-K3CLP74S.mjs} +2 -2
  11. package/dist/{chunk-BP3Y2SHQ.js → chunk-MYE5NMXW.js} +495 -101
  12. package/dist/{chunk-OQV66TBQ.mjs → chunk-NAOXQJ7U.mjs} +1 -1
  13. package/dist/{chunk-R63GIUNU.mjs → chunk-QKC3APP4.mjs} +1 -1
  14. package/dist/{chunk-46P57VMY.mjs → chunk-RF7J5FRM.mjs} +1 -1
  15. package/dist/{chunk-CY7Y64C3.mjs → chunk-TU2XFPAK.mjs} +10 -4
  16. package/dist/chunk-TVFNJ25Z.js +8 -0
  17. package/dist/chunk-VLDIFL3F.js +8 -0
  18. package/dist/{chunk-7AOERN53.js → chunk-WVRCKOPO.js} +3 -3
  19. package/dist/{chunk-W5MC3P4N.js → chunk-XMEVPEMH.js} +2 -2
  20. package/dist/{chunk-BQQTHG2V.mjs → chunk-YKGY22SB.mjs} +1 -1
  21. package/dist/gestureLayer/index.d.mts +2 -2
  22. package/dist/gestureLayer/index.d.ts +2 -2
  23. package/dist/gestureLayer/index.js +10 -10
  24. package/dist/gestureLayer/index.mjs +3 -3
  25. package/dist/index.d.mts +4 -4
  26. package/dist/index.d.ts +4 -4
  27. package/dist/index.js +48 -169
  28. package/dist/index.mjs +17 -137
  29. package/dist/motion/Image.d.mts +1 -1
  30. package/dist/motion/Image.d.ts +1 -1
  31. package/dist/motion/Image.js +5 -5
  32. package/dist/motion/Image.mjs +4 -4
  33. package/dist/motion/Pressable.d.mts +1 -1
  34. package/dist/motion/Pressable.d.ts +1 -1
  35. package/dist/motion/Pressable.js +5 -5
  36. package/dist/motion/Pressable.mjs +4 -4
  37. package/dist/motion/ScrollView.d.mts +1 -1
  38. package/dist/motion/ScrollView.d.ts +1 -1
  39. package/dist/motion/ScrollView.js +5 -5
  40. package/dist/motion/ScrollView.mjs +4 -4
  41. package/dist/motion/Text.d.mts +1 -1
  42. package/dist/motion/Text.d.ts +1 -1
  43. package/dist/motion/Text.js +5 -5
  44. package/dist/motion/Text.mjs +4 -4
  45. package/dist/motion/View.d.mts +1 -1
  46. package/dist/motion/View.d.ts +1 -1
  47. package/dist/motion/View.js +5 -5
  48. package/dist/motion/View.mjs +4 -4
  49. package/dist/touch/index.d.mts +1 -1
  50. package/dist/touch/index.d.ts +1 -1
  51. package/dist/touch/index.js +3 -3
  52. package/dist/touch/index.mjs +1 -1
  53. package/dist/{types-G1dBU2IQ.d.mts → types-37pN82yh.d.mts} +49 -14
  54. package/dist/{types-G1dBU2IQ.d.ts → types-37pN82yh.d.ts} +49 -14
  55. package/dist/{useGesture-D1e4ixJo.d.ts → useGesture-Cl4uzZIr.d.ts} +1 -1
  56. package/dist/{useGesture-RFY6kTL3.d.mts → useGesture-_IKkS8Dv.d.mts} +1 -1
  57. package/llms.txt +3 -1
  58. package/package.json +1 -1
  59. package/src/index.ts +2 -1
  60. package/src/internal/boxShadow.ts +177 -6
  61. package/src/internal/nonWorkletWarning.ts +4 -6
  62. package/src/internal/warnOnce.ts +28 -0
  63. package/src/layout/index.ts +10 -0
  64. package/src/layout/measureWindow.ts +135 -0
  65. package/src/layout/sharedRegistry.ts +107 -18
  66. package/src/layout/useSharedLayout.ts +256 -91
  67. package/src/motion/createMotionComponent.tsx +378 -39
  68. package/src/motion/installCheck.ts +1 -1
  69. package/src/transitions/index.ts +1 -1
  70. package/src/types.ts +52 -12
  71. package/src/values/useColorTransition.ts +1 -1
  72. package/dist/chunk-FNVFV4EY.js +0 -8
  73. package/dist/chunk-FWQOXA43.js +0 -8
  74. package/dist/chunk-KBP4LR75.js +0 -8
@@ -1,5 +1,5 @@
1
1
  import { ComponentType, ComponentProps, Ref, ReactElement } from 'react';
2
- import { StyleProp } from 'react-native';
2
+ import { StyleProp, BoxShadowValue } from 'react-native';
3
3
 
4
4
  /**
5
5
  * A single animation step's destination, optionally overriding the transition
@@ -154,6 +154,36 @@ type AnimatableTransformExtras = {
154
154
  rotateX?: AnimatableValue<number>;
155
155
  rotateY?: AnimatableValue<number>;
156
156
  };
157
+ /**
158
+ * Target shape for the `boxShadow` key on `animate` / `initial` / `exit`: a
159
+ * CSS `box-shadow` string, or RN's own array-of-layers form.
160
+ *
161
+ * Both endpoints of a `boxShadow` animation are normalized to a common layer
162
+ * count on the JS thread, so a two-layer shadow can animate to a one-layer one
163
+ * (the short side is padded with a transparent layer, as CSS transitions do).
164
+ * A layer that is `inset` on one side and not the other is not interpolable
165
+ * and throws.
166
+ */
167
+ type BoxShadowInput = string | ReadonlyArray<BoxShadowValue>;
168
+ /**
169
+ * Keys whose animatable form deliberately departs from the generic
170
+ * `AnimatableValue<S[K]>` mapping.
171
+ *
172
+ * `boxShadow` is the only member. Its natural value is already an array, which
173
+ * collides head-on with `AnimatableValue`'s keyframe-sequence form — nothing
174
+ * distinguishes `[layerA, layerB]` (one two-layer shadow) from a two-step
175
+ * sequence. The array slot is given to layers, which is the shape RN itself
176
+ * uses and by far the common case, and sequences are unsupported on this key.
177
+ * This mirrors the single-value-only contract `shadowOffset` has carried since
178
+ * `0.0.1`. Per-property transitions are unaffected: `transition={{ boxShadow:
179
+ * { type: 'timing' } }}` works as it does for every other key.
180
+ *
181
+ * Conditional on `S` so the key only appears where the underlying style
182
+ * actually has it, rather than being bolted onto every primitive.
183
+ */
184
+ type AnimatableStructuredExtras<S> = 'boxShadow' extends keyof S ? {
185
+ boxShadow?: BoxShadowInput;
186
+ } : {};
157
187
  /**
158
188
  * The animation state shape inferred from the underlying component's style
159
189
  * prop. We narrow to the value side of `style` so consumers see ViewStyle on
@@ -168,9 +198,9 @@ type AnimatableTransformExtras = {
168
198
  type _StyleValue<T> = Exclude<T, (...args: any[]) => any>;
169
199
  type AnimateStyle<C> = C extends {
170
200
  style?: infer Raw;
171
- } ? _StyleValue<Raw> extends StyleProp<infer S> ? {
201
+ } ? _StyleValue<Raw> extends StyleProp<infer S> ? Omit<{
172
202
  [K in keyof S]?: AnimatableValue<S[K]>;
173
- } & AnimatableTransformExtras : never : never;
203
+ }, 'boxShadow'> & AnimatableTransformExtras & AnimatableStructuredExtras<S> : never : never;
174
204
  interface AnimationCallbackInfo<S> {
175
205
  /**
176
206
  * The animatable key that just settled — typically a `keyof S` (e.g.
@@ -339,19 +369,24 @@ interface MotionProps<C, V extends VariantsMap<C> = VariantsMap<C>> {
339
369
  * recorded rect to its natural position via a FLIP transform stack.
340
370
  *
341
371
  * Reanimated 4 removed the `sharedTransitionTag` API — `layoutId` is the
342
- * Inertia-side measure-based replacement. Rects are recorded in
343
- * parent-relative coordinates (from `onLayout`), which composes when the
344
- * source and target screens share an outer content container (the common
345
- * stack-navigator case); nested-parent layouts need the v2
346
- * window-coordinate path.
372
+ * Inertia-side measure-based replacement. Rects are recorded in **window**
373
+ * coordinates wherever the host can be measured synchronously (Fabric),
374
+ * which is what lets a source and target sit under containers at different
375
+ * screen offsets; elsewhere both fall back to the parent-relative
376
+ * coordinates `onLayout` reports. A source and target that ended up in
377
+ * different spaces skip the animation rather than play a wrong one.
347
378
  *
348
379
  * The same `transition` prop drives the FLIP animation (spring by
349
380
  * default; `'timing'` honored; `'decay'` downgrades to spring; reduced
350
- * motion skips the transition). Out of scope for the first iteration:
351
- * style-prop interpolation (border radius, colors, etc.) only the
352
- * rect-to-rect transform is animated. Two simultaneously-mounted
353
- * primitives sharing the same `layoutId` are undefined behavior; pick a
354
- * primitive per id at a time.
381
+ * motion skips the transition). Alongside the rect, a fixed set of style
382
+ * keys is carried from the source and crossfaded out on that same
383
+ * transition: `opacity`, `borderRadius`, `backgroundColor`, `borderColor`,
384
+ * `color`, `tintColor`. Transform keys are not carried the FLIP owns
385
+ * them and a key only participates when the element already has a value
386
+ * for it, from a record or from its static `style`.
387
+ *
388
+ * Two simultaneously-mounted primitives sharing the same `layoutId` are
389
+ * undefined behavior; pick a primitive per id at a time.
355
390
  */
356
391
  layoutId?: string;
357
392
  /**
@@ -385,4 +420,4 @@ interface MotionComponent<C extends ComponentType<any>> {
385
420
  displayName?: string;
386
421
  }
387
422
 
388
- export type { AnimatableValue as A, DecayTransition as D, EasingInput as E, GestureLayerTransitions as G, MotionComponent as M, NamedTransitions as N, PerPropertyTransition as P, RegisteredTransitions as R, SpringTransition as S, TransitionConfig as T, VariantController as V, TransitionInput as a, TransitionName as b, AnimateStyle as c, AnimationCallbackInfo as d, EasingFunction as e, EasingFunctionFactory as f, GestureSubStates as g, MotionProps as h, NoAnimationTransition as i, RepeatConfig as j, SequenceStep as k, TimingTransition as l, Transition as m, VariantsMap as n };
423
+ export type { AnimatableValue as A, BoxShadowInput as B, DecayTransition as D, EasingInput as E, GestureLayerTransitions as G, MotionComponent as M, NamedTransitions as N, PerPropertyTransition as P, RegisteredTransitions as R, SpringTransition as S, TransitionConfig as T, VariantController as V, TransitionInput as a, TransitionName as b, AnimateStyle as c, AnimationCallbackInfo as d, EasingFunction as e, EasingFunctionFactory as f, GestureSubStates as g, MotionProps as h, NoAnimationTransition as i, RepeatConfig as j, SequenceStep as k, TimingTransition as l, Transition as m, VariantsMap as n };
@@ -1,5 +1,5 @@
1
1
  import { SharedValue } from 'react-native-reanimated';
2
- import { a as TransitionInput, G as GestureLayerTransitions } from './types-G1dBU2IQ.js';
2
+ import { a as TransitionInput, G as GestureLayerTransitions } from './types-37pN82yh.js';
3
3
 
4
4
  /**
5
5
  * Handler bag returned by `useGesture`. Spread on a `Pressable` to drive the
@@ -1,5 +1,5 @@
1
1
  import { SharedValue } from 'react-native-reanimated';
2
- import { a as TransitionInput, G as GestureLayerTransitions } from './types-G1dBU2IQ.mjs';
2
+ import { a as TransitionInput, G as GestureLayerTransitions } from './types-37pN82yh.mjs';
3
3
 
4
4
  /**
5
5
  * Handler bag returned by `useGesture`. Spread on a `Pressable` to drive the
package/llms.txt CHANGED
@@ -140,7 +140,9 @@ Color: `backgroundColor`, `borderColor`, `color`, `tintColor` (Image only), `sha
140
140
 
141
141
  Nested object: `shadowOffset: { width, height }`. The only nested-object style on the surface — internally decomposes into two synthetic axis SVs that the worklet recomposes into a single `shadowOffset` prop. `0.0.1` supports the **single-value form only** (`{ width: 0, height: 4 }`); sequences / array keyframes / per-axis transition splits are out of scope, drop to `useMotionValue` for those.
142
142
 
143
- Auto-layout transitions ship via the `layout` prop (`true` / `TransitionConfig`) on every `Motion.*` primitivesee Layout. Shared element transitions ship via the `layoutId` prop: pair the same id on a source and target `Motion.*` and Inertia FLIPs between them on mount (parent-relative coords, rect-only animation, 1s TTL).
143
+ Structured: `boxShadow` — the cross-platform CSS shadow form, as a CSS string (`'0px 4px 8px rgba(0,0,0,0.3)'`, px lengths only) or RN's `BoxShadowValue[]`. Endpoints are normalized on the JS thread and padded to a common layer count (CSS-transition semantics), so layer counts may differ between `initial` and `animate`; every length and color interpolates per layer. `inset` is carried as a static per-layer flag, not interpolated a layer inset on one side only throws. **No sequences on this key** (the array slot means layers, not keyframes) and **not accepted in `gesture` sub-states** (dev-warns and is ignored; drive it from `animate`, or interpolate manually with `useShadow`). Don't animate it alongside the native `shadow*` keys on one element two shadow systems at once; Inertia dev-warns.
144
+
145
+ Auto-layout transitions ship via the `layout` prop (`true` / `TransitionConfig`) on every `Motion.*` primitive — see Layout. Shared element transitions ship via the `layoutId` prop: pair the same id on a source and target `Motion.*` and Inertia FLIPs between them on mount (1s TTL). Alongside the rect, a fixed set of style keys is carried from the source and crossfaded out on the same transition — `opacity`, `borderRadius`, `backgroundColor`, `borderColor`, `color`, `tintColor` — but only for keys the element already has a value for, from a record or its static `style`; transform keys are not carried because the FLIP owns them. Rects are measured in **window** coordinates via `measureInWindow` where the host resolves it synchronously (Fabric), which is what lets source and target sit under differently-offset parents; elsewhere both fall back to parent-relative `onLayout` coords. A source and target in different coordinate spaces skip the animation rather than play a wrong one. A still-mounted source is re-measured when the target lays out, so an ancestor scroll (which never fires `onLayout`) doesn't offset the transition.
144
146
 
145
147
  ## Optional adapter packages
146
148
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rootnative/inertia",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Declarative animation primitives for React Native, built on react-native-reanimated.",
5
5
  "license": "MIT",
6
6
  "author": "RootNative",
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `@rootnative/inertia` — declarative animation primitives for React Native.
3
3
  *
4
- * v0.1 surface: `Motion.View` / `Motion.Text` / `Motion.Image` /
4
+ * Public surface: `Motion.View` / `Motion.Text` / `Motion.Image` /
5
5
  * `Motion.Pressable` / `Motion.ScrollView`, with `initial` / `animate` /
6
6
  * `exit` / `transition` / `variants` / `gesture` / `controller` /
7
7
  * `onAnimationEnd` props. Sequences, repeats (single-value and
@@ -82,6 +82,7 @@ export type {
82
82
  AnimatableValue,
83
83
  AnimateStyle,
84
84
  AnimationCallbackInfo,
85
+ BoxShadowInput,
85
86
  DecayTransition,
86
87
  EasingFunction,
87
88
  EasingFunctionFactory,
@@ -144,17 +144,24 @@ export function parseBoxShadow(input: string): ResolvedBoxShadowLayer[] {
144
144
  })
145
145
  }
146
146
 
147
- /** Resolve either input form (CSS string or structured layers) to layers. */
147
+ /**
148
+ * Resolve either input form (CSS string or structured layers) to layers.
149
+ *
150
+ * Lengths may arrive as numbers (what `useShadow`'s `BoxShadowLayer` declares)
151
+ * or as px strings (what RN's own `BoxShadowValue` permits, so what the
152
+ * `animate` surface has to accept). `coerceLength` normalizes both and rejects
153
+ * anything else, so downstream only ever sees concrete numbers.
154
+ */
148
155
  export function resolveBoxShadowInput(
149
- input: string | readonly BoxShadowLayer[] | undefined,
156
+ input: BoxShadowInput | undefined,
150
157
  ): ResolvedBoxShadowLayer[] {
151
158
  if (input === undefined) return []
152
159
  if (typeof input === 'string') return parseBoxShadow(input)
153
160
  return input.map((layer) => ({
154
- offsetX: layer.offsetX,
155
- offsetY: layer.offsetY,
156
- blurRadius: layer.blurRadius ?? 0,
157
- spreadDistance: layer.spreadDistance ?? 0,
161
+ offsetX: coerceLength(layer.offsetX, 'offsetX'),
162
+ offsetY: coerceLength(layer.offsetY, 'offsetY'),
163
+ blurRadius: coerceLength(layer.blurRadius, 'blurRadius'),
164
+ spreadDistance: coerceLength(layer.spreadDistance, 'spreadDistance'),
158
165
  color: layer.color ?? 'black',
159
166
  inset: layer.inset ?? false,
160
167
  }))
@@ -202,3 +209,167 @@ function invisibleLayer(inset: boolean): ResolvedBoxShadowLayer {
202
209
  inset,
203
210
  }
204
211
  }
212
+
213
+ /* ------------------------------------------------------------------ *
214
+ * Declarative `animate={{ boxShadow }}` support
215
+ *
216
+ * The `useShadow` path above interpolates two endpoints itself, by
217
+ * progress. The `animate` path instead hands the target to Reanimated's
218
+ * own animation drivers, which have their own rules — the helpers below
219
+ * exist to satisfy them on the JS thread so the worklet never does string
220
+ * or structural work at frame time (Principle 8).
221
+ * ------------------------------------------------------------------ */
222
+
223
+ /**
224
+ * The interpolable half of a box-shadow layer: every field Reanimated can
225
+ * drive, and nothing it can't.
226
+ *
227
+ * `inset` is deliberately absent. `withTiming` / `withSpring` recurse into
228
+ * arrays and objects and animate each leaf, and a boolean leaf falls through
229
+ * to the plain numeric path — `false + (false - false) * p` evaluates to `0`,
230
+ * so an in-flight frame hands the native shadow a number where it expects a
231
+ * boolean. Inset is instead carried alongside as a static per-layer flag (see
232
+ * `SplitBoxShadow.insets`) and reattached when the style is emitted.
233
+ */
234
+ export interface AnimatedBoxShadowLayer {
235
+ offsetX: number
236
+ offsetY: number
237
+ blurRadius: number
238
+ spreadDistance: number
239
+ color: string
240
+ }
241
+
242
+ /**
243
+ * Resolved layers split into the part Reanimated animates and the part it
244
+ * must not touch.
245
+ *
246
+ * `insets` is `null` — not an array of `false` — when no layer is inset, which
247
+ * is the overwhelmingly common case. That lets the emitting worklet skip
248
+ * reassembly entirely and pass the animated array straight through with zero
249
+ * per-frame allocation.
250
+ */
251
+ export interface SplitBoxShadow {
252
+ layers: AnimatedBoxShadowLayer[]
253
+ insets: boolean[] | null
254
+ }
255
+
256
+ /**
257
+ * Accepted target shape on the `animate` surface: a CSS string or RN's own
258
+ * `BoxShadowValue[]`, whose lengths may be numbers or px strings.
259
+ *
260
+ * Structurally this is RN's `boxShadow` style value. It is restated here
261
+ * rather than imported so this module stays dependency-free and testable as a
262
+ * pure function; `types.ts` is where the public-facing alias lives.
263
+ */
264
+ export type BoxShadowInput =
265
+ | string
266
+ | ReadonlyArray<{
267
+ offsetX: number | string
268
+ offsetY: number | string
269
+ // `unknown` because RN types this as `ColorValue | number` — a quirk of
270
+ // its own declarations rather than a real capability. Coerced (and
271
+ // rejected) by `coerceLength` like every other length.
272
+ blurRadius?: unknown
273
+ spreadDistance?: number | string | undefined
274
+ color?: string | undefined
275
+ inset?: boolean | undefined
276
+ }>
277
+
278
+ /**
279
+ * Coerce one RN length field to a number. RN's `BoxShadowValue` types its
280
+ * lengths as `number | string` so `{ offsetX: '4px' }` is legal input; the
281
+ * animated slot needs a plain number to interpolate. Unit handling matches
282
+ * `parseBoxShadow` — px and unitless only, and anything else throws rather
283
+ * than silently animating from a `NaN`.
284
+ */
285
+ function coerceLength(value: unknown, field: string): number {
286
+ if (value === undefined) return 0
287
+ if (typeof value === 'number') return value
288
+ const trimmed = typeof value === 'string' ? value.trim() : ''
289
+ if (!LENGTH.test(trimmed)) {
290
+ throw new Error(
291
+ `[inertia] boxShadow: ${field} must be a number or a px length, got ` +
292
+ `${JSON.stringify(value)}.`,
293
+ )
294
+ }
295
+ return parseFloat(trimmed)
296
+ }
297
+
298
+ /** Drop `inset`, leaving only the fields Reanimated may drive. */
299
+ function strip(layer: ResolvedBoxShadowLayer): AnimatedBoxShadowLayer {
300
+ const { inset: _inset, ...rest } = layer
301
+ return rest
302
+ }
303
+
304
+ /**
305
+ * Record layer `i` as inset, materializing the flag list on first use so the
306
+ * common all-outset case keeps `null` and costs the worklet nothing.
307
+ */
308
+ function markInset(
309
+ insets: boolean[] | null,
310
+ i: number,
311
+ count: number,
312
+ ): boolean[] {
313
+ const list = insets ?? new Array<boolean>(count).fill(false)
314
+ list[i] = true
315
+ return list
316
+ }
317
+
318
+ /**
319
+ * Normalize a target into the shared-value seed shape. Used when the slot is
320
+ * first populated (mount seed, static-style resting value).
321
+ */
322
+ export function normalizeBoxShadow(input: BoxShadowInput): SplitBoxShadow {
323
+ const resolved = resolveBoxShadowInput(input)
324
+ const layers: AnimatedBoxShadowLayer[] = []
325
+ let insets: boolean[] | null = null
326
+ for (let i = 0; i < resolved.length; i++) {
327
+ const layer = resolved[i]!
328
+ layers.push(strip(layer))
329
+ if (layer.inset) insets = markInset(insets, i, resolved.length)
330
+ }
331
+ return { layers, insets }
332
+ }
333
+
334
+ /**
335
+ * Prepare a from/to pair for a Reanimated-driven `boxShadow` animation.
336
+ *
337
+ * Both sides come back padded to the same layer count, because Reanimated's
338
+ * `arrayOnStart` walks the **current** value's indices and reads `toValue[i]`
339
+ * for each — a target with fewer layers leaves the surplus leaves with
340
+ * `toValue: undefined`, and a target with more never animates the extras at
341
+ * all. `pairBoxShadowLayers` supplies the padding (a transparent zero layer,
342
+ * matching CSS transition semantics) and rejects a genuine inset mismatch.
343
+ *
344
+ * The returned `from` is only meaningful when it differs from what the slot
345
+ * already holds; callers snap the slot to it before starting the animation so
346
+ * the interpolation has a same-shaped base to run from.
347
+ */
348
+ export function prepareBoxShadowAnimation(
349
+ current: SplitBoxShadow,
350
+ target: BoxShadowInput,
351
+ ): {
352
+ from: AnimatedBoxShadowLayer[]
353
+ to: AnimatedBoxShadowLayer[]
354
+ insets: boolean[] | null
355
+ } {
356
+ const pairs = pairBoxShadowLayers(
357
+ current.layers.map((layer, i) => ({
358
+ ...layer,
359
+ inset: current.insets?.[i] ?? false,
360
+ })),
361
+ resolveBoxShadowInput(target),
362
+ )
363
+ const from: AnimatedBoxShadowLayer[] = []
364
+ const to: AnimatedBoxShadowLayer[] = []
365
+ let insets: boolean[] | null = null
366
+ for (let i = 0; i < pairs.length; i++) {
367
+ const pair = pairs[i]!
368
+ from.push(strip(pair.from))
369
+ to.push(strip(pair.to))
370
+ // `pairBoxShadowLayers` has already established that the two sides agree
371
+ // on inset, so reading either describes the pair.
372
+ if (pair.to.inset) insets = markInset(insets, i, pairs.length)
373
+ }
374
+ return { from, to, insets }
375
+ }
@@ -1,3 +1,5 @@
1
+ import { __resetWarnOnceForTests, warnOnce } from './warnOnce'
2
+
1
3
  declare const __DEV__: boolean
2
4
 
3
5
  // The core package intentionally has no Node types — declare the minimal
@@ -5,8 +7,6 @@ declare const __DEV__: boolean
5
7
  // environments without `process` are fine).
6
8
  declare const process: { env?: Record<string, string | undefined> } | undefined
7
9
 
8
- const warned = new Set<string>()
9
-
10
10
  /**
11
11
  * Dev-only, once-per-key warning for plain (non-worklet) functions handed to
12
12
  * surfaces that need real worklets — `useTransform` transformers and custom
@@ -30,12 +30,10 @@ const warned = new Set<string>()
30
30
  export function warnNonWorkletOnce(key: string, message: string): void {
31
31
  if (!__DEV__) return
32
32
  if (typeof process !== 'undefined' && process.env?.JEST_WORKER_ID) return
33
- if (warned.has(key)) return
34
- warned.add(key)
35
- console.warn(message)
33
+ warnOnce(key, message)
36
34
  }
37
35
 
38
36
  /** @internal — test-only hook to reset the once-per-key state. */
39
37
  export function __resetNonWorkletWarningsForTests(): void {
40
- warned.clear()
38
+ __resetWarnOnceForTests()
41
39
  }
@@ -0,0 +1,28 @@
1
+ declare const __DEV__: boolean
2
+
3
+ const warned = new Set<string>()
4
+
5
+ /**
6
+ * Dev-only warning that fires at most once per `key` for the lifetime of the
7
+ * module.
8
+ *
9
+ * Inertia's warnings mostly fire from render or from a per-frame-adjacent
10
+ * resolver, so an unguarded `console.warn` would repeat on every render and
11
+ * bury the signal. Keying the guard (rather than using a single boolean)
12
+ * keeps two genuinely different misconfigurations from silencing each other.
13
+ *
14
+ * Unlike `warnNonWorkletOnce`, this is **not** suppressed under Jest — these
15
+ * warnings describe consumer-authored prop combinations, which a test suite
16
+ * should be able to assert on.
17
+ */
18
+ export function warnOnce(key: string, message: string): void {
19
+ if (!__DEV__) return
20
+ if (warned.has(key)) return
21
+ warned.add(key)
22
+ console.warn(message)
23
+ }
24
+
25
+ /** @internal — test-only hook to reset the once-per-key state. */
26
+ export function __resetWarnOnceForTests(): void {
27
+ warned.clear()
28
+ }
@@ -8,10 +8,20 @@ export {
8
8
  SHARED_LAYOUT_TTL_MS,
9
9
  __setSharedLayoutClock,
10
10
  __sharedRegistrySize,
11
+ type CoordinateSpace,
12
+ type SharedLayoutSource,
11
13
  type SharedRect,
14
+ type SharedStyleSnapshot,
12
15
  } from './sharedRegistry'
16
+ export {
17
+ __setSharedLayoutMeasurer,
18
+ measureWindowRect,
19
+ type MeasuredRect,
20
+ type WindowMeasurer,
21
+ } from './measureWindow'
13
22
  export {
14
23
  useSharedLayout,
15
24
  type SharedLayoutBindings,
25
+ type SharedLayoutStyleValues,
16
26
  type SharedLayoutValues,
17
27
  } from './useSharedLayout'
@@ -0,0 +1,135 @@
1
+ import { type CoordinateSpace, type SharedRect } from './sharedRegistry'
2
+
3
+ /**
4
+ * Window-coordinate measurement for shared-element rects.
5
+ *
6
+ * ## Synchronous or not at all
7
+ *
8
+ * `measureInWindow` is callback-shaped but not reliably asynchronous, and the
9
+ * difference decides this module's design:
10
+ *
11
+ * - **Fabric** (the default from RN 0.76, and our 0.81+ baseline) resolves it
12
+ * through a synchronous JSI call — the callback runs before
13
+ * `measureInWindow` returns.
14
+ * - **Paper** goes over the bridge, so the callback lands a tick later.
15
+ * - **A detached node calls back never at all.** Not an edge case and not a
16
+ * platform quirk: `ReactFabricHostComponent.measureInWindow` looks the node
17
+ * up and simply returns when it is missing. The Jest host mock behaves the
18
+ * same way.
19
+ *
20
+ * So this module reads the result **synchronously or treats it as
21
+ * unavailable**, and ignores a callback that arrives late. That is not a
22
+ * limitation to work around — it is what keeps the coordinate space coherent.
23
+ * An implementation that awaited the callback would put Fabric in window space
24
+ * and Paper in parent space *for the same element at different moments*, and
25
+ * since the two spaces aren't comparable, a source stored in one and a target
26
+ * measured in the other silently skips the transition. Better to be
27
+ * consistently parent-relative on a platform that can't measure synchronously —
28
+ * which is exactly how this worked before window coordinates existed.
29
+ *
30
+ * Net effect: nested-parent shared elements are fixed on Fabric, and on Paper
31
+ * the behavior is unchanged rather than intermittently broken.
32
+ *
33
+ * ## Why the result is validated rather than trusted
34
+ *
35
+ * A node that is off-screen or laid out to nothing reports zeros (or `NaN` on
36
+ * some platforms) instead of failing. Feeding that into a FLIP would fling the
37
+ * element in from the top-left corner — far worse than not animating. Anything
38
+ * unusable is reported as `undefined` so the caller falls back to the
39
+ * parent-relative rect it already has.
40
+ */
41
+
42
+ /** Raw measurement, before it is tagged with a coordinate space. */
43
+ export interface MeasuredRect {
44
+ x: number
45
+ y: number
46
+ width: number
47
+ height: number
48
+ }
49
+
50
+ /**
51
+ * Measures `node` in window coordinates, or returns `undefined` when it can't
52
+ * be measured synchronously. Swappable for tests via
53
+ * `__setSharedLayoutMeasurer`.
54
+ */
55
+ export type WindowMeasurer = (node: unknown) => MeasuredRect | undefined
56
+
57
+ type MeasureInWindowNode = {
58
+ measureInWindow?: (
59
+ callback: (x: number, y: number, width: number, height: number) => void,
60
+ ) => void
61
+ }
62
+
63
+ const defaultMeasurer: WindowMeasurer = (node) => {
64
+ const measure = (node as MeasureInWindowNode | null)?.measureInWindow
65
+ if (typeof measure !== 'function') return undefined
66
+
67
+ let result: MeasuredRect | undefined
68
+ let returned = false
69
+ try {
70
+ measure.call(node, (x, y, width, height) => {
71
+ // A callback that arrives after we've returned is Paper's asynchronous
72
+ // bridge answering too late to use. Dropping it is deliberate: accepting
73
+ // it would upgrade this element to window space while its counterpart
74
+ // stays parent-relative, and mixed spaces cancel the transition.
75
+ if (returned) return
76
+ result = { x, y, width, height }
77
+ })
78
+ } catch {
79
+ // Some hosts throw rather than no-op on a detached node.
80
+ returned = true
81
+ return undefined
82
+ }
83
+ returned = true
84
+ return result
85
+ }
86
+
87
+ let measurer: WindowMeasurer = defaultMeasurer
88
+
89
+ function usable(rect: MeasuredRect): boolean {
90
+ const { x, y, width, height } = rect
91
+ for (const v of [x, y, width, height]) {
92
+ if (typeof v !== 'number' || !Number.isFinite(v)) return false
93
+ }
94
+ // Zero-sized means "not really measurable" in practice — detached, or laid
95
+ // out to nothing. Either way there is no FLIP to compute from it.
96
+ return width !== 0 || height !== 0
97
+ }
98
+
99
+ /**
100
+ * Measure `node` in window coordinates and tag the result, or return
101
+ * `undefined` when no usable synchronous measurement is available.
102
+ *
103
+ * Validation lives here rather than in the default measurer so a swapped-in
104
+ * measurer is held to the same contract — an unusable rect can never reach a
105
+ * FLIP, however it was produced.
106
+ */
107
+ export function measureWindowRect(node: unknown): SharedRect | undefined {
108
+ const measured = measurer(node)
109
+ if (!measured || !usable(measured)) return undefined
110
+ return toSharedRect(measured, 'window')
111
+ }
112
+
113
+ /** Tag a raw measurement with its coordinate space. */
114
+ export function toSharedRect(
115
+ rect: MeasuredRect,
116
+ space: CoordinateSpace,
117
+ ): SharedRect {
118
+ return { ...rect, space }
119
+ }
120
+
121
+ /**
122
+ * Test hook: swap the measurement implementation. Pass `undefined` to restore
123
+ * the real `measureInWindow` path. Not exported from the package root —
124
+ * reachable only from inside the workspace.
125
+ *
126
+ * Needed because the Jest host mock's `measureInWindow` never invokes its
127
+ * callback, so without an override every test would exercise only the
128
+ * parent-relative fallback — which is also, deliberately, what a consumer's
129
+ * test suite sees.
130
+ */
131
+ export function __setSharedLayoutMeasurer(
132
+ fn: WindowMeasurer | undefined,
133
+ ): void {
134
+ measurer = fn ?? defaultMeasurer
135
+ }