@delacour/react-native-ui 0.1.0-alpha.20260928015421 → 0.1.0-alpha.20261004035233

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.1.0-alpha.20260928015421",
3
+ "version": "0.1.0-alpha.20261004035233",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -71,10 +71,12 @@
71
71
  "./textarea": "./src/components/textarea/index.ts",
72
72
  "./toggle-button": "./src/components/toggle-button/index.ts",
73
73
  "./expo/navigation-theme": "./src/expo/navigation-theme.tsx",
74
+ "./hooks/use-calm-motion": "./src/hooks/use-calm-motion.tsx",
74
75
  "./hooks/use-controllable-state": "./src/hooks/use-controllable-state.ts",
75
76
  "./hooks/use-keyboard-state-sync": "./src/hooks/use-keyboard-state-sync.tsx",
76
77
  "./hooks/use-navigation-theme": "./src/hooks/use-navigation-theme.ts",
77
78
  "./hooks/use-theme-color": "./src/hooks/use-theme-color.ts",
79
+ "./lib/calm-motion": "./src/lib/calm-motion.ts",
78
80
  "./lib/cn": "./src/lib/cn.ts",
79
81
  "./lib/color": "./src/lib/color.ts",
80
82
  "./lib/compose-refs": "./src/lib/compose-refs.ts",
@@ -98,8 +100,8 @@
98
100
  },
99
101
  "peerDependencies": {
100
102
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
101
- "@delacour/react-native-bottom-sheet": "0.1.0-alpha.20260928015421",
102
- "@delacour/react-native-charts": "0.1.0-alpha.20260928015421",
103
+ "@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261004035233",
104
+ "@delacour/react-native-charts": "0.1.0-alpha.20261004035233",
103
105
  "@legendapp/list": ">=3.3",
104
106
  "expo-linear-gradient": ">=15",
105
107
  "expo-router": ">=57",
@@ -24,7 +24,7 @@ a list of rows.
24
24
  | `accordion.context.tsx` | `AccordionContext` and `AccordionItemContext`, with their hooks |
25
25
  | `accordion.types.ts` | Prop types shared by two or more parts |
26
26
  | `accordion.variants.ts` | Pure `tv()` slots + the selection maths, no RN imports |
27
- | `accordion.variants.test.ts` | |
27
+ | `accordion.variants.test.ts` | `accordionTravelTarget`, and the sweep for a JS-thread `contentHeight.value` read (exports `jsThreadSource` and `CONTENT_HEIGHT_READ` for `Collapsible`'s) |
28
28
 
29
29
  ## Design
30
30
 
@@ -90,12 +90,23 @@ a list of rows.
90
90
  effect.** A panel that has never mounted has no height to travel against, so
91
91
  springing on the state change would run the whole animation at zero and jump
92
92
  the moment a measurement arrived. The item's effect skips exactly that one
93
- case and `accordion-content.tsx`'s `onLayout` starts it instead. Every later
94
- toggle, in either direction, is the item's.
93
+ case and `accordion-content.tsx`'s `onLayout` reports the measurement, which
94
+ flips the item's `isMeasured` state and re-runs the effect. Every later toggle,
95
+ in either direction, is the item's. The decision is `accordionTravelTarget`,
96
+ pure and tested.
97
+ - **Nothing on the JS thread reads `contentHeight.value`.** "Has this panel
98
+ measured?" is React state (`isMeasured`, on both the item and the panel), never
99
+ a read of the shared value. A JS-thread write to a shared value is *queued* onto
100
+ the UI runtime, while a JS-thread read runs there synchronously under its lock
101
+ without draining that queue — so the item's effect, running straight after the
102
+ `onLayout` that wrote the height, can read `ACCORDION_UNMEASURED`. A Release
103
+ build does, deterministically: the effect bailed, nothing re-ran it, and the
104
+ panel reported `expanded` while never opening (a Debug client is slow enough
105
+ for the write to land first, which is why it hid). `accordion.variants.test.ts`
106
+ sweeps both files for such a read outside `useAnimatedStyle`.
95
107
  - **`ACCORDION_UNMEASURED` is negative, and that is load-bearing.** A panel that
96
- measured `0` is a real answer — content that rendered nothing — and an item
97
- treating it as "still waiting" would never start its spring, leaving the
98
- indicator pointing the wrong way. Only a value no layout can produce can mean
108
+ measured `0` is a real answer — content that rendered nothing — and must still
109
+ open, indicator and all. Only a value no layout can produce can mean
99
110
  *unmeasured*, so the height style floors it.
100
111
 
101
112
  ## Selection
@@ -2,7 +2,7 @@ import { type ReactElement, type ReactNode, useCallback, useState } from "react"
2
2
  import { type LayoutChangeEvent, StyleSheet, View, type ViewProps } from "react-native";
3
3
  import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated";
4
4
  import { useAccordionItemPart, useAccordionPart } from "./accordion.context";
5
- import { ACCORDION_CONTENT_FADE, ACCORDION_UNMEASURED, accordionVariants } from "./accordion.variants";
5
+ import { ACCORDION_CONTENT_FADE, accordionVariants } from "./accordion.variants";
6
6
 
7
7
  export type AccordionContentProps = Omit<ViewProps, "style"> & {
8
8
  className?: string;
@@ -55,7 +55,6 @@ export function AccordionContent({ className, children, ...props }: AccordionCon
55
55
 
56
56
  const handleLayout = useCallback(
57
57
  (event: LayoutChangeEvent) => {
58
- const wasMeasured = contentHeight.value > ACCORDION_UNMEASURED;
59
58
  contentHeight.value = event.nativeEvent.layout.height;
60
59
 
61
60
  // The panel reports its first measurement and stops there — it must not
@@ -64,11 +63,15 @@ export function AccordionContent({ className, children, ...props }: AccordionCon
64
63
  // is sometimes cancelled a moment later by the cleanup of the item's
65
64
  // effect it raced, leaving the panel shut and the indicator pointing the
66
65
  // wrong way. Telling the item instead leaves one owner of the spring.
67
- if (wasMeasured) return;
66
+ //
67
+ // "First" is this component's own state, never `contentHeight.value`: the
68
+ // write above is queued onto the UI runtime, and a JS-thread read straight
69
+ // after it can still see the old value.
70
+ if (isMeasured) return;
68
71
  setMeasured(true);
69
72
  onMeasured();
70
73
  },
71
- [contentHeight, onMeasured]
74
+ [contentHeight, isMeasured, onMeasured]
72
75
  );
73
76
 
74
77
  // Height and opacity off the one `progress`, never off a timing of their own —
@@ -5,6 +5,7 @@ import { type AccordionItemContextValue, AccordionItemProvider, useAccordionPart
5
5
  import {
6
6
  ACCORDION_SPRING,
7
7
  ACCORDION_UNMEASURED,
8
+ accordionTravelTarget,
8
9
  accordionVariants,
9
10
  isItemExpanded,
10
11
  resolveAccordionItemAxes,
@@ -42,25 +43,26 @@ export function AccordionItem({ value, isDisabled, className, children, ...props
42
43
  const progress = useSharedValue(isExpanded ? 1 : 0);
43
44
  const contentHeight = useSharedValue(ACCORDION_UNMEASURED);
44
45
 
45
- // Bumped the first time this item's panel reports a height, purely to make the
46
- // effect below run again. See `onMeasured` on the item context for why the
47
- // panel must not start the spring itself.
48
- const [measurements, setMeasurements] = useState(0);
49
- const onMeasured = useCallback(() => setMeasurements((count) => count + 1), []);
46
+ // Whether this item's panel has ever reported a height. React state, not a read
47
+ // of `contentHeight` on the JS thread: that write is queued onto the UI runtime
48
+ // and a read straight after it can still see `ACCORDION_UNMEASURED` — which a
49
+ // Release build does, every time. See `accordionTravelTarget`.
50
+ const [isMeasured, setMeasured] = useState(false);
51
+ const onMeasured = useCallback(() => setMeasured(true), []);
50
52
 
51
- // `measurements` is not read in the body — being unread is the whole point of
52
- // it, the way `settledDrags` is for a `Switch`. The first expand of a panel
53
- // that has never been mounted has no height to travel against, so this run
54
- // bails; the measurement that follows is what brings it back.
55
- // biome-ignore lint/correctness/useExhaustiveDependencies: the extra dependency is the re-run trigger, see above
53
+ // The first expand of a panel that has never been mounted has no height to
54
+ // travel against, so this run waits; the measurement that follows flips
55
+ // `isMeasured` and brings it back. See `onMeasured` on the item context for why
56
+ // the panel must not start the spring itself.
56
57
  useEffect(() => {
57
- if (isExpanded && contentHeight.value === ACCORDION_UNMEASURED) return;
58
+ const target = accordionTravelTarget({ isExpanded, isMeasured });
59
+ if (target === null) return;
58
60
 
59
- progress.value = withSpring(isExpanded ? 1 : 0, ACCORDION_SPRING);
61
+ progress.value = withSpring(target, ACCORDION_SPRING);
60
62
 
61
63
  // Without this an item unmounted mid-travel leaves its spring running.
62
64
  return () => cancelAnimation(progress);
63
- }, [contentHeight, isExpanded, measurements, progress]);
65
+ }, [isExpanded, isMeasured, progress]);
64
66
 
65
67
  const context = useMemo<AccordionItemContextValue>(
66
68
  () => ({ contentHeight, isDisabled: axes.isDisabled, isExpanded, onMeasured, progress, value }),
@@ -35,8 +35,9 @@ export type AccordionItemContextValue = {
35
35
  * The panel's natural height in points, from its own `onLayout`.
36
36
  *
37
37
  * Holds {@link ACCORDION_UNMEASURED} until a panel has reported, which is not
38
- * the same as a panel that measured zero: the item's spring waits on the first
39
- * measurement rather than travelling against a height that is not there yet.
38
+ * the same as a panel that measured zero. Read on the UI runtime only — by the
39
+ * height style — and written from the panel's `onLayout`; nothing on the JS
40
+ * thread reads it, because a JS read can lag a JS write. See {@link onMeasured}.
40
41
  */
41
42
  contentHeight: SharedValue<number>;
42
43
  /**
@@ -46,9 +47,14 @@ export type AccordionItemContextValue = {
46
47
  * spring. The panel cannot start the travel itself: `onLayout` is dispatched
47
48
  * from the native side and can land either side of React's effects, so a panel
48
49
  * that started its own spring would sometimes have it cancelled a moment later
49
- * by the cleanup of the effect it raced. This hands the item a reason to re-run
50
- * instead — `Slider`'s and `Switch`'s `settledDrags`, for the same reason: a
51
- * counter whose only job is to give an effect something to fire on.
50
+ * by the cleanup of the effect it raced. This flips the item's own
51
+ * `isMeasured` state instead, which is what re-runs the item's effect.
52
+ *
53
+ * That state, not a JS-thread read of {@link contentHeight}, is how the item
54
+ * knows a panel has measured. The panel's write is queued onto the UI runtime
55
+ * and a read straight after it can still see `ACCORDION_UNMEASURED` — a Release
56
+ * build does, every time, and the panel stayed shut. See
57
+ * `accordionTravelTarget`.
52
58
  */
53
59
  onMeasured: () => void;
54
60
  };
@@ -16,6 +16,7 @@ import {
16
16
  ACCORDION_SPRING,
17
17
  ACCORDION_UNMEASURED,
18
18
  ACCORDION_VARIANTS,
19
+ accordionTravelTarget,
19
20
  accordionVariants,
20
21
  isItemExpanded,
21
22
  resolveAccordionItemAxes,
@@ -432,3 +433,64 @@ describe("accordion animation", () => {
432
433
  expect(end).toBeLessThan(1);
433
434
  });
434
435
  });
436
+
437
+ describe("accordionTravelTarget", () => {
438
+ test("an expand waits for the panel's first measurement", () => {
439
+ expect(accordionTravelTarget({ isExpanded: true, isMeasured: false })).toBeNull();
440
+ });
441
+
442
+ test("an expand travels to 1 once the panel has measured", () => {
443
+ expect(accordionTravelTarget({ isExpanded: true, isMeasured: true })).toBe(1);
444
+ });
445
+
446
+ test("a collapse never waits, measured or not", () => {
447
+ expect(accordionTravelTarget({ isExpanded: false, isMeasured: false })).toBe(0);
448
+ expect(accordionTravelTarget({ isExpanded: false, isMeasured: true })).toBe(0);
449
+ });
450
+ });
451
+
452
+ /**
453
+ * A file's source with comments and every `useAnimatedStyle(...)` call stripped — the one
454
+ * place a read of a shared value runs on the UI runtime — leaving only code that runs on the
455
+ * JS thread.
456
+ */
457
+ export function jsThreadSource(path: string): string {
458
+ const source = readFileSync(path, "utf8")
459
+ .replace(/\/\*[\s\S]*?\*\//g, "")
460
+ .replace(/\/\/.*$/gm, "");
461
+ let out = "";
462
+ let index = 0;
463
+ for (;;) {
464
+ const start = source.indexOf("useAnimatedStyle(", index);
465
+ if (start === -1) return out + source.slice(index);
466
+ out += source.slice(index, start);
467
+ let depth = 0;
468
+ let cursor = start + "useAnimatedStyle".length;
469
+ do {
470
+ if (source[cursor] === "(") depth++;
471
+ if (source[cursor] === ")") depth--;
472
+ cursor++;
473
+ } while (depth > 0 && cursor < source.length);
474
+ index = cursor;
475
+ }
476
+ }
477
+
478
+ /** `contentHeight.value` as a read: anything but the left side of a plain `=`. */
479
+ export const CONTENT_HEIGHT_READ = /\bcontentHeight\.value\b(?!\s*=(?!=))/;
480
+
481
+ describe("the measured height is never read on the JS thread", () => {
482
+ // A JS-thread write to a shared value is queued onto the UI runtime, while a JS-thread read
483
+ // runs there synchronously under its lock without draining that queue. A read straight after
484
+ // the write — the item's effect after the panel's `onLayout` — can see the old value. In a
485
+ // Release build it does, every time: the item read `ACCORDION_UNMEASURED`, bailed, and nothing
486
+ // re-ran it, so the panel said `expanded` and stayed shut.
487
+ test.each(["accordion-item.tsx", "accordion-content.tsx"])("%s", (file) => {
488
+ expect(jsThreadSource(join(import.meta.dirname, file))).not.toMatch(CONTENT_HEIGHT_READ);
489
+ });
490
+
491
+ test("the guard sees a read and ignores a write", () => {
492
+ expect("if (contentHeight.value === -1) return;").toMatch(CONTENT_HEIGHT_READ);
493
+ expect("const was = contentHeight.value > -1;").toMatch(CONTENT_HEIGHT_READ);
494
+ expect("contentHeight.value = event.nativeEvent.layout.height;").not.toMatch(CONTENT_HEIGHT_READ);
495
+ });
496
+ });
@@ -76,17 +76,41 @@ export const ACCORDION_INDICATOR_ROTATION = { collapsed: 0, expanded: 180 } as c
76
76
  * What {@link AccordionItemContextValue.contentHeight} holds before a panel has
77
77
  * ever reported its own layout.
78
78
  *
79
- * Negative rather than zero, because the two mean different things and the
80
- * difference is load-bearing. A panel that measured `0` is a real answer — a
81
- * panel whose content rendered nothing — and an item that treated it as "still
82
- * waiting" would never start its spring, leaving the indicator stuck pointing the
83
- * wrong way for an empty panel. Only a value no layout can produce can mean
84
- * *unmeasured*.
79
+ * Negative rather than zero, because the two mean different things. A panel that
80
+ * measured `0` is a real answer — a panel whose content rendered nothing — and
81
+ * still opens, indicator and all. Only a value no layout can produce can mean
82
+ * *unmeasured*. Whether a panel *has* measured is never decided by comparing
83
+ * against this on the JS thread — see {@link accordionTravelTarget}.
85
84
  *
86
85
  * The height style therefore floors it: `progress * max(contentHeight, 0)`.
87
86
  */
88
87
  export const ACCORDION_UNMEASURED = -1;
89
88
 
89
+ /**
90
+ * Where an item's `progress` should spring to, or `null` to wait.
91
+ *
92
+ * Only an expand waits, and only for a panel that has never measured: it has no
93
+ * height to travel against yet, and the measurement is what starts it. A collapse
94
+ * never waits.
95
+ *
96
+ * `isMeasured` is **React state**, never a read of `contentHeight` on the JS
97
+ * thread. A JS-thread write to a shared value is queued onto the UI runtime, while
98
+ * a JS-thread read runs there synchronously without draining that queue, so a read
99
+ * straight after the panel's `onLayout` wrote the height can still see
100
+ * {@link ACCORDION_UNMEASURED}. A Release build does, every time — the item bailed
101
+ * and nothing re-ran it, so the panel said `expanded` and stayed shut.
102
+ */
103
+ export function accordionTravelTarget({
104
+ isExpanded,
105
+ isMeasured,
106
+ }: {
107
+ isExpanded: boolean;
108
+ isMeasured: boolean;
109
+ }): 0 | 1 | null {
110
+ if (!isExpanded) return 0;
111
+ return isMeasured ? 1 : null;
112
+ }
113
+
90
114
  /**
91
115
  * The window of the travel the panel's opacity ramps across.
92
116
  *
@@ -23,7 +23,7 @@ section first. What follows is only where a standalone disclosure differs.
23
23
  | `collapsible.context.tsx` | `CollapsibleContext`, `useCollapsible` and the internal part hook |
24
24
  | `collapsible.types.ts` | `CollapsibleTextProps`, shared by the title, description and trigger |
25
25
  | `collapsible.variants.ts` | Pure `tv()` slots, the constants, the toggle and the accessibility resolver — no RN imports |
26
- | `collapsible.variants.test.ts` | |
26
+ | `collapsible.variants.test.ts` | `collapsibleTravelTarget`, pinned equal to the accordion's, and the JS-thread `contentHeight.value` sweep |
27
27
 
28
28
  ## Design
29
29
 
@@ -41,9 +41,14 @@ section first. What follows is only where a standalone disclosure differs.
41
41
  is the only thing that imports both, and tests are not shipped.
42
42
  - **The root owns the state and the travel.** With no item layer, what
43
43
  `Accordion.Item` owns — `progress`, `contentHeight`, the spring, the
44
- `onMeasured` re-run counter — lives on the root. The race that counter closes
44
+ `isMeasured` state `onMeasured` flips — lives on the root. The race it closes
45
45
  (`onLayout` landing either side of React's effects) is the same one, so the
46
46
  panel reports its first measurement and never starts the spring itself.
47
+ - **Nothing on the JS thread reads `contentHeight.value`** — the accordion's
48
+ Release-build bug, in the same shape: a JS read can lag the panel's queued
49
+ write, see `COLLAPSIBLE_UNMEASURED`, and leave the panel shut. "Measured" is
50
+ React state and the decision is `collapsibleTravelTarget`; the test sweeps both
51
+ files. See the accordion's AGENTS.md.
47
52
  - **The disabled fade lands on the root.** Never on the trigger, which is a
48
53
  `Pressable` whose `Animated.View` writes `opacity` every frame; the accordion
49
54
  fades its item for the same reason, and a collapsible's root is its item.
@@ -2,12 +2,7 @@ import { type ReactElement, type ReactNode, useCallback, useState } from "react"
2
2
  import { type LayoutChangeEvent, StyleSheet, View, type ViewProps } from "react-native";
3
3
  import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated";
4
4
  import { useCollapsiblePart } from "./collapsible.context";
5
- import {
6
- COLLAPSIBLE_CONTENT_FADE,
7
- COLLAPSIBLE_UNMEASURED,
8
- collapsibleVariants,
9
- resolveCollapsibleAccessibility,
10
- } from "./collapsible.variants";
5
+ import { COLLAPSIBLE_CONTENT_FADE, collapsibleVariants, resolveCollapsibleAccessibility } from "./collapsible.variants";
11
6
 
12
7
  export type CollapsibleContentProps = Omit<ViewProps, "style"> & {
13
8
  className?: string;
@@ -47,13 +42,15 @@ export function CollapsibleContent({ className, children, ...props }: Collapsibl
47
42
 
48
43
  const handleLayout = useCallback(
49
44
  (event: LayoutChangeEvent) => {
50
- const wasMeasured = contentHeight.value > COLLAPSIBLE_UNMEASURED;
51
45
  contentHeight.value = event.nativeEvent.layout.height;
52
- if (wasMeasured) return;
46
+ // "First" is this component's own state, never `contentHeight.value`: the
47
+ // write above is queued onto the UI runtime, and a read straight after it
48
+ // can still see the old value.
49
+ if (isMeasured) return;
53
50
  setMeasured(true);
54
51
  onMeasured();
55
52
  },
56
- [contentHeight, onMeasured]
53
+ [contentHeight, isMeasured, onMeasured]
57
54
  );
58
55
 
59
56
  const clipStyle = useAnimatedStyle(() => ({
@@ -18,13 +18,18 @@ export type CollapsibleContextValue = {
18
18
  * and the indicator's rotation — so they cannot drift out of step by a frame.
19
19
  */
20
20
  progress: SharedValue<number>;
21
- /** The panel's natural height in points, or {@link COLLAPSIBLE_UNMEASURED} until it has reported. */
21
+ /**
22
+ * The panel's natural height in points, or {@link COLLAPSIBLE_UNMEASURED} until it
23
+ * has reported. Read on the UI runtime only, by the height style — a JS-thread read
24
+ * can lag the panel's write. See {@link onMeasured}.
25
+ */
22
26
  contentHeight: SharedValue<number>;
23
27
  /**
24
28
  * Told by the panel that it has measured itself for the first time.
25
29
  *
26
- * Internal machinery: the root is the only owner of the spring, and this gives
27
- * its effect a reason to re-run once there is a height to travel against. The
30
+ * Internal machinery: the root is the only owner of the spring, and this flips
31
+ * its `isMeasured` state — the reason its effect re-runs once there is a height
32
+ * to travel against, and how it knows, never by reading `contentHeight`. The
28
33
  * panel cannot start the spring itself — `onLayout` lands either side of
29
34
  * React's effects, and a spring started there is sometimes cancelled by the
30
35
  * cleanup of the effect it raced. `Accordion.Item`'s `onMeasured`, verbatim.
@@ -10,6 +10,7 @@ import {
10
10
  COLLAPSIBLE_UNMEASURED,
11
11
  type CollapsibleSize,
12
12
  type CollapsibleVariant,
13
+ collapsibleTravelTarget,
13
14
  collapsibleVariants,
14
15
  toggleCollapsibleOpen,
15
16
  } from "./collapsible.variants";
@@ -79,21 +80,22 @@ function CollapsibleRoot({
79
80
  const progress = useSharedValue(isOpen ? 1 : 0);
80
81
  const contentHeight = useSharedValue(COLLAPSIBLE_UNMEASURED);
81
82
 
82
- // Bumped the first time the panel reports a height, purely to re-run the
83
- // effect below. See `onMeasured` on the context.
84
- const [measurements, setMeasurements] = useState(0);
85
- const onMeasured = useCallback(() => setMeasurements((count) => count + 1), []);
83
+ // Whether the panel has ever reported a height — React state, never a JS-thread
84
+ // read of `contentHeight`, which can lag the write. See `collapsibleTravelTarget`
85
+ // and `onMeasured` on the context.
86
+ const [isMeasured, setMeasured] = useState(false);
87
+ const onMeasured = useCallback(() => setMeasured(true), []);
86
88
 
87
89
  // The first open of a panel that has never mounted has no height to travel
88
- // against, so that run bails and the measurement that follows brings it back.
89
- // biome-ignore lint/correctness/useExhaustiveDependencies: `measurements` is the re-run trigger, see above
90
+ // against, so that run waits and the measurement that follows brings it back.
90
91
  useEffect(() => {
91
- if (isOpen && contentHeight.value === COLLAPSIBLE_UNMEASURED) return;
92
+ const target = collapsibleTravelTarget({ isMeasured, isOpen });
93
+ if (target === null) return;
92
94
 
93
- progress.value = withSpring(isOpen ? 1 : 0, COLLAPSIBLE_SPRING);
95
+ progress.value = withSpring(target, COLLAPSIBLE_SPRING);
94
96
 
95
97
  return () => cancelAnimation(progress);
96
- }, [contentHeight, isOpen, measurements, progress]);
98
+ }, [isMeasured, isOpen, progress]);
97
99
 
98
100
  const context = useMemo<CollapsibleContextValue>(
99
101
  () => ({ contentHeight, isDisabled, isOpen, onMeasured, progress, size, toggle, variant }),
@@ -9,8 +9,10 @@ import {
9
9
  ACCORDION_SIZES,
10
10
  ACCORDION_SPRING,
11
11
  ACCORDION_VARIANTS,
12
+ accordionTravelTarget,
12
13
  accordionVariants,
13
14
  } from "../accordion/accordion.variants";
15
+ import { CONTENT_HEIGHT_READ, jsThreadSource } from "../accordion/accordion.variants.test";
14
16
  import { ICON_SIZES } from "../icon/icon.variants";
15
17
  import {
16
18
  COLLAPSIBLE_CONTENT_FADE,
@@ -24,6 +26,7 @@ import {
24
26
  COLLAPSIBLE_SPRING,
25
27
  COLLAPSIBLE_UNMEASURED,
26
28
  COLLAPSIBLE_VARIANTS,
29
+ collapsibleTravelTarget,
27
30
  collapsibleVariants,
28
31
  resolveCollapsibleAccessibility,
29
32
  toggleCollapsibleOpen,
@@ -300,3 +303,36 @@ describe("collapsible and accordion move alike", () => {
300
303
  }
301
304
  });
302
305
  });
306
+
307
+ describe("collapsibleTravelTarget", () => {
308
+ test("an open waits for the panel's first measurement", () => {
309
+ expect(collapsibleTravelTarget({ isOpen: true, isMeasured: false })).toBeNull();
310
+ });
311
+
312
+ test("an open travels to 1 once the panel has measured", () => {
313
+ expect(collapsibleTravelTarget({ isOpen: true, isMeasured: true })).toBe(1);
314
+ });
315
+
316
+ test("a close never waits, measured or not", () => {
317
+ expect(collapsibleTravelTarget({ isOpen: false, isMeasured: false })).toBe(0);
318
+ expect(collapsibleTravelTarget({ isOpen: false, isMeasured: true })).toBe(0);
319
+ });
320
+
321
+ test("agrees with the accordion's rule on every input", () => {
322
+ for (const isOpen of [false, true]) {
323
+ for (const isMeasured of [false, true]) {
324
+ expect(collapsibleTravelTarget({ isOpen, isMeasured })).toBe(
325
+ accordionTravelTarget({ isExpanded: isOpen, isMeasured })
326
+ );
327
+ }
328
+ }
329
+ });
330
+ });
331
+
332
+ describe("the measured height is never read on the JS thread", () => {
333
+ // `Accordion`'s Release-build bug, in the same two files: a JS-thread read straight after the
334
+ // panel's `onLayout` wrote the height can still see `COLLAPSIBLE_UNMEASURED`.
335
+ test.each(["collapsible.tsx", "collapsible-content.tsx"])("%s", (file) => {
336
+ expect(jsThreadSource(join(import.meta.dirname, file))).not.toMatch(CONTENT_HEIGHT_READ);
337
+ });
338
+ });
@@ -55,12 +55,33 @@ export const COLLAPSIBLE_INDICATOR_ROTATION = { collapsed: 0, expanded: 180 } as
55
55
  /**
56
56
  * What the measured height holds before the panel has ever reported its layout.
57
57
  *
58
- * Negative rather than zero: a panel that measured `0` is a real answer, and
59
- * treating it as "still waiting" would never start the spring. Only a value no
60
- * layout can produce can mean *unmeasured*, so the height style floors it.
58
+ * Negative rather than zero: a panel that measured `0` is a real answer and still
59
+ * opens. Only a value no layout can produce can mean *unmeasured*, so the height
60
+ * style floors it. Whether the panel *has* measured is never decided by comparing
61
+ * against this on the JS thread — see {@link collapsibleTravelTarget}.
61
62
  */
62
63
  export const COLLAPSIBLE_UNMEASURED = -1;
63
64
 
65
+ /**
66
+ * Where the root's `progress` should spring to, or `null` to wait — only an open,
67
+ * and only before the panel's first measurement.
68
+ *
69
+ * `isMeasured` is React state, never a JS-thread read of the measured height: the
70
+ * panel's write is queued onto the UI runtime, and a read straight after it can
71
+ * still see {@link COLLAPSIBLE_UNMEASURED} — a Release build does, every time, and
72
+ * the panel stays shut. `accordionTravelTarget`, restated.
73
+ */
74
+ export function collapsibleTravelTarget({
75
+ isOpen,
76
+ isMeasured,
77
+ }: {
78
+ isOpen: boolean;
79
+ isMeasured: boolean;
80
+ }): 0 | 1 | null {
81
+ if (!isOpen) return 0;
82
+ return isMeasured ? 1 : null;
83
+ }
84
+
64
85
  /**
65
86
  * The window of the travel the panel's opacity ramps across — ahead of the
66
87
  * height, so the content is legible for most of an expand rather than half
@@ -14,9 +14,9 @@ everything — a root layout, an `App.tsx`.
14
14
 
15
15
  ## Design
16
16
 
17
- - **Four layers, outermost first**: `GestureHandlerRootView` →
18
- `SafeAreaProvider` → `KeyboardProvider` → `<KeyboardStateSync />` beside the
19
- children. The order is not stylistic. The gesture root has to be an ancestor
17
+ - **Five layers, outermost first**: `GestureHandlerRootView` →
18
+ `SafeAreaProvider` → `KeyboardProvider` → `<KeyboardStateSync />` beside
19
+ `CalmMotionProvider`, which wraps the children. The order is not stylistic. The gesture root has to be an ancestor
20
20
  native view of every handler a `Pressable` creates, and its absence is
21
21
  *silent* — no error, no warning, presses simply stop landing.
22
22
  `KeyboardStateSync` has to be a CHILD of `KeyboardProvider`, because it calls
@@ -67,6 +67,15 @@ everything — a root layout, an `App.tsx`.
67
67
  `BottomSheet.Portal` still renders, where it is written, as an inline sheet.
68
68
  The previous sheet library was a required peer while this component mounted
69
69
  its modal provider; nothing imports it any more.
70
+ - **`isMotionCalm` is a behaviour prop, not a layer-named one.** It feeds
71
+ `CalmMotionProvider` (`hooks/use-calm-motion.tsx`), which `useCalmMotion()`
72
+ reads beside the OS reduce-motion setting. It exists for E2E builds: a runner
73
+ that waits for the screen to settle before each gesture (Argent waits up to
74
+ 3 s) pays the whole wait on a screen holding a never-ending loop. Only
75
+ decorative loops ask — `Skeleton` today; `Spinner` and an indeterminate
76
+ `Progress` are the behaviour and keep moving. The context defaults to `false`,
77
+ so a component used without this provider (a `delacour add` copy in an app
78
+ with its own stack) still honours reduce motion.
70
79
  - **Deliberately not idempotent.** It does not detect an enclosing copy of
71
80
  itself. Nesting `GestureHandlerRootView` costs a `View`; nesting
72
81
  `SafeAreaProvider` seeds from the parent's insets and costs a native view;
@@ -87,7 +96,7 @@ everything — a root layout, an `App.tsx`.
87
96
  per-subpath and survives intact: `/button` still pulls nothing
88
97
  keyboard-related.
89
98
  - **Nothing here for `bun test`, and no `provider.variants.ts` to give it
90
- some.** The component is four nested elements and one default parameter;
99
+ some.** The component is five nested elements and one default parameter;
91
100
  extracting a `resolveInitialMetrics()` would be a unit test of `??`. The rule
92
101
  that pure decisions live in a `*.variants.ts` has no decision here to
93
102
  relocate.
@@ -3,6 +3,7 @@ import type { StyleProp, ViewStyle } from "react-native";
3
3
  import { GestureHandlerRootView } from "react-native-gesture-handler";
4
4
  import { KeyboardProvider } from "react-native-keyboard-controller";
5
5
  import { initialWindowMetrics, type Metrics, SafeAreaProvider } from "react-native-safe-area-context";
6
+ import { CalmMotionProvider } from "../../hooks/use-calm-motion";
6
7
  import { KeyboardStateSync } from "../../hooks/use-keyboard-state-sync";
7
8
 
8
9
  export type DelacourProviderProps = {
@@ -20,6 +21,16 @@ export type DelacourProviderProps = {
20
21
  * `null` is a value here rather than an absence.
21
22
  */
22
23
  initialMetrics?: Metrics | null;
24
+ /**
25
+ * Hold every decorative loop still — a skeleton's shimmer or pulse — whatever
26
+ * the OS reduce-motion setting says. Defaults to `false`.
27
+ *
28
+ * For an E2E build. A test runner that waits for the screen to stop moving
29
+ * before each gesture (Argent, Detox, Maestro) pays its whole timeout on any
30
+ * screen holding a loop that never ends. Motion that *is* the behaviour — a
31
+ * `Spinner`, an indeterminate `Progress` — keeps moving either way.
32
+ */
33
+ isMotionCalm?: boolean;
23
34
  /**
24
35
  * Style for the outermost `GestureHandlerRootView`.
25
36
  *
@@ -37,7 +48,7 @@ export type DelacourProviderProps = {
37
48
  * Mount it ONCE, around everything — a root layout, an `App.tsx`. It is not
38
49
  * idempotent and does not detect an enclosing copy of itself; see AGENTS.md.
39
50
  *
40
- * Four layers, outermost first, and the order is not stylistic:
51
+ * Five layers, outermost first, and the order is not stylistic:
41
52
  *
42
53
  * 1. `GestureHandlerRootView` — an ancestor native view every gesture handler
43
54
  * `Pressable` creates has to attach to. Its absence is silent: no error, no
@@ -51,6 +62,9 @@ export type DelacourProviderProps = {
51
62
  * `will` events, so a keyboard that vanishes without one — an interactive
52
63
  * dismiss interrupted by navigation, a stack pop, an app suspend — leaves
53
64
  * every screen in the app believing it is still open.
65
+ * 5. `CalmMotionProvider` — the app's `isMotionCalm`, for `useCalmMotion()`.
66
+ * Pure context, no native view, so it wraps `{children}` alone — innermost,
67
+ * where a new layer goes.
54
68
  *
55
69
  * **`BottomSheetProvider` is not here, and the app mounts it.** The sheet's
56
70
  * engine, `@delacour/react-native-bottom-sheet`, is an optional peer of this
@@ -92,10 +106,16 @@ export type DelacourProviderProps = {
92
106
  * // app that launches into a rotated or split-screen window and cannot
93
107
  * // tolerate one stale frame.
94
108
  * <DelacourProvider initialMetrics={null}>{children}</DelacourProvider>
109
+ *
110
+ * @example
111
+ * // An E2E build: decorative loops hold still so the test runner never waits
112
+ * // out a shimmer. `IS_E2E` is the app's own build flag.
113
+ * <DelacourProvider isMotionCalm={IS_E2E}>{children}</DelacourProvider>
95
114
  */
96
115
  export function DelacourProvider({
97
116
  children,
98
117
  initialMetrics = initialWindowMetrics,
118
+ isMotionCalm = false,
99
119
  style,
100
120
  }: DelacourProviderProps): ReactElement {
101
121
  return (
@@ -103,7 +123,7 @@ export function DelacourProvider({
103
123
  <SafeAreaProvider initialMetrics={initialMetrics}>
104
124
  <KeyboardProvider>
105
125
  <KeyboardStateSync />
106
- {children}
126
+ <CalmMotionProvider isMotionCalm={isMotionCalm}>{children}</CalmMotionProvider>
107
127
  </KeyboardProvider>
108
128
  </SafeAreaProvider>
109
129
  </GestureHandlerRootView>
@@ -78,9 +78,12 @@ with a glint sweeping across it, or a pulse, on the UI thread. Compound root plu
78
78
  line into a full one, that line's glint sat off-centre and swept visibly out
79
79
  of step with the rest. `StyleSheet.absoluteFill` plus
80
80
  `preserveAspectRatio="none"` makes the native view follow the band.
81
- - **Reduce-motion stills the skeleton, it does not hide it.**
82
- `resolveSkeletonAnimation` returns `none` while the OS setting is on, and the
83
- clock is never started. The shape is the message: a placeholder that stops
81
+ - **Calm motion stills the skeleton, it does not hide it.**
82
+ `resolveSkeletonAnimation` returns `none` while `useCalmMotion()` is true — the
83
+ OS reduce-motion setting, or `DelacourProvider`'s `isMotionCalm` — and the
84
+ clock is never started. The second half is for E2E builds: a test runner that
85
+ waits for the screen to settle before each gesture pays its whole timeout on a
86
+ screen holding a shimmer that never ends. The shape is the message: a placeholder that stops
84
87
  moving still reads as content on its way. The clock's own timing sets
85
88
  `ReduceMotion.Never` because the decision has already been made one level up —
86
89
  under the default `System` policy `withTiming` completes instantly and
@@ -1,6 +1,6 @@
1
1
  import { type ReactElement, type ReactNode, useMemo } from "react";
2
2
  import { View, type ViewProps } from "react-native";
3
- import { useReducedMotion } from "react-native-reanimated";
3
+ import { useCalmMotion } from "../../hooks/use-calm-motion";
4
4
  import { cn } from "../../lib/cn";
5
5
  import { type SkeletonGroupContextValue, SkeletonGroupProvider } from "./skeleton.context";
6
6
  import { resolveSkeletonAccessibility, resolveSkeletonAnimation, type SkeletonAnimation } from "./skeleton.variants";
@@ -38,8 +38,8 @@ export function SkeletonGroup({
38
38
  children,
39
39
  ...props
40
40
  }: SkeletonGroupProps): ReactElement {
41
- const isReduceMotion = useReducedMotion();
42
- const resolvedAnimation = resolveSkeletonAnimation(animation, isReduceMotion);
41
+ const isCalm = useCalmMotion();
42
+ const resolvedAnimation = resolveSkeletonAnimation(animation, isCalm);
43
43
  const isRunning = isLoading && resolvedAnimation !== "none";
44
44
  const progress = useSkeletonClock(isRunning);
45
45
 
@@ -1,12 +1,7 @@
1
1
  import { type ReactElement, type ReactNode, useEffect } from "react";
2
2
  import { View, type ViewProps } from "react-native";
3
- import Animated, {
4
- useAnimatedRef,
5
- useAnimatedStyle,
6
- useReducedMotion,
7
- useSharedValue,
8
- withTiming,
9
- } from "react-native-reanimated";
3
+ import Animated, { useAnimatedRef, useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
4
+ import { useCalmMotion } from "../../hooks/use-calm-motion";
10
5
  import { cn } from "../../lib/cn";
11
6
  import { useSkeletonGroup } from "./skeleton.context";
12
7
  import {
@@ -29,7 +24,7 @@ export type SkeletonProps = Omit<ViewProps, "children"> & {
29
24
  /**
30
25
  * `shimmer` sweeps a glint across the placeholder, `pulse` breathes its
31
26
  * opacity, `none` holds it still. Inherited from an enclosing `Skeleton.Group`,
32
- * then `shimmer`. Every animation stills under the OS reduce-motion setting.
27
+ * then `shimmer`. Every animation stills under the OS reduce-motion setting, and under `DelacourProvider`'s `isMotionCalm`.
33
28
  */
34
29
  animation?: SkeletonAnimation;
35
30
  /**
@@ -63,10 +58,10 @@ function SkeletonRoot({
63
58
  ...props
64
59
  }: SkeletonProps): ReactElement {
65
60
  const group = useSkeletonGroup();
66
- const isReduceMotion = useReducedMotion();
61
+ const isCalm = useCalmMotion();
67
62
 
68
63
  const loading = isLoading ?? group?.isLoading ?? true;
69
- const resolvedAnimation = resolveSkeletonAnimation(animation ?? group?.animation, isReduceMotion);
64
+ const resolvedAnimation = resolveSkeletonAnimation(animation ?? group?.animation, isCalm);
70
65
  const isAnimating = loading && resolvedAnimation !== "none";
71
66
 
72
67
  // One clock drives either animation, so a group's clock serves every
@@ -61,16 +61,14 @@ export const SKELETON_SHIMMER_STOPS: readonly SkeletonShimmerStop[] = [
61
61
  /**
62
62
  * The animation a skeleton actually runs.
63
63
  *
64
- * Reduce-motion stills every animation rather than hiding the placeholder: the
65
- * shape is the message, and a shape that stops moving still reads as content on
66
- * its way. The clock then never starts, which is also why the timing itself can
67
- * set `ReduceMotion.Never` — see AGENTS.md.
64
+ * Calm motion — the OS reduce-motion setting, or an app's `isMotionCalm` — stills
65
+ * every animation rather than hiding the placeholder: the shape is the message,
66
+ * and a shape that stops moving still reads as content on its way. The clock then
67
+ * never starts, which is also why the timing itself can set `ReduceMotion.Never` —
68
+ * see AGENTS.md.
68
69
  */
69
- export function resolveSkeletonAnimation(
70
- animation: SkeletonAnimation | undefined,
71
- isReduceMotion: boolean
72
- ): SkeletonAnimation {
73
- if (isReduceMotion) return "none";
70
+ export function resolveSkeletonAnimation(animation: SkeletonAnimation | undefined, isCalm: boolean): SkeletonAnimation {
71
+ if (isCalm) return "none";
74
72
  return animation ?? SKELETON_FALLBACK_ANIMATION;
75
73
  }
76
74
 
@@ -0,0 +1,31 @@
1
+ import { createContext, type ReactElement, type ReactNode, useContext } from "react";
2
+ import { useReducedMotion } from "react-native-reanimated";
3
+ import { calmMotion } from "../lib/calm-motion";
4
+
5
+ const CalmMotionContext = createContext(false);
6
+
7
+ export type CalmMotionProviderProps = {
8
+ /** Hold every decorative loop beneath this still, whatever the OS setting. */
9
+ isMotionCalm: boolean;
10
+ children: ReactNode;
11
+ };
12
+
13
+ /**
14
+ * Asks every decorative loop beneath it to hold still — for an E2E build, whose test runner waits
15
+ * for the screen to stop moving before each gesture. `DelacourProvider` mounts it from its own
16
+ * `isMotionCalm`; mount it by hand only in an app that composes its providers itself.
17
+ *
18
+ * Optional: without one, {@link useCalmMotion} follows the OS reduce-motion setting alone.
19
+ */
20
+ export function CalmMotionProvider({ isMotionCalm, children }: CalmMotionProviderProps): ReactElement {
21
+ return <CalmMotionContext.Provider value={isMotionCalm}>{children}</CalmMotionContext.Provider>;
22
+ }
23
+
24
+ /**
25
+ * Whether decorative motion should hold still, for this device and this app: the one place a
26
+ * component asks whether to loop. See `calmMotion` for what counts as decorative.
27
+ */
28
+ export function useCalmMotion(): boolean {
29
+ return calmMotion({ isMotionCalm: useContext(CalmMotionContext), isReduceMotion: useReducedMotion() });
30
+ }
31
+ CalmMotionProvider.displayName = "DelacourUI.CalmMotionProvider";
@@ -0,0 +1,17 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { calmMotion } from "./calm-motion";
3
+
4
+ describe("calmMotion", () => {
5
+ test("moves when neither the OS nor the app asks for stillness", () => {
6
+ expect(calmMotion({ isReduceMotion: false, isMotionCalm: false })).toBe(false);
7
+ });
8
+
9
+ test("holds still under the OS reduce-motion setting", () => {
10
+ expect(calmMotion({ isReduceMotion: true, isMotionCalm: false })).toBe(true);
11
+ });
12
+
13
+ test("holds still when the app asks, whatever the OS says", () => {
14
+ expect(calmMotion({ isReduceMotion: false, isMotionCalm: true })).toBe(true);
15
+ expect(calmMotion({ isReduceMotion: true, isMotionCalm: true })).toBe(true);
16
+ });
17
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Whether decorative motion holds still: under the OS reduce-motion setting, or whenever the app
3
+ * asks — `DelacourProvider`'s `isMotionCalm`.
4
+ *
5
+ * The app's half is for automation, not accessibility. A UI test runner that waits for the screen
6
+ * to stop moving before every gesture (Argent waits up to 3 s, Detox and Maestro idle-wait the
7
+ * same way) pays the whole wait on any screen holding a loop that never ends — a shimmering
8
+ * skeleton turns every tap there into a timeout. An E2E build passes `isMotionCalm` and the loops
9
+ * hold still.
10
+ *
11
+ * Only motion that is decoration asks. Motion that *is* the behaviour — a spinner saying work is
12
+ * in flight, a progress bar a reader reads — keeps moving: stilling it would change what the
13
+ * screen says.
14
+ */
15
+ export function calmMotion({
16
+ isReduceMotion,
17
+ isMotionCalm,
18
+ }: {
19
+ isReduceMotion: boolean;
20
+ isMotionCalm: boolean;
21
+ }): boolean {
22
+ return isReduceMotion || isMotionCalm;
23
+ }