Skip to content

Commit 8499eeb

Browse files
committed
test: make controls fan-out risk observable
Add deterministic controls fan-out and cleanup coverage. Add a manual stress path in the Expo example. Docs now frame controls as short UI text playback. Long or dense workloads stay something to measure in the target app. Constraint: Keep public API, package metadata, and lockfile unchanged Rejected: Wall-clock CI benchmark | flaky and not representative of device FPS Rejected: Document token-level fan-out as contract | would freeze renderer internals Confidence: high Scope-risk: narrow Directive: Keep descriptor listener counts test-only Directive: Revisit tests if controls batching is introduced Tested: pnpm run format:check; pnpm run lint; pnpm run typecheck Tested: pnpm run test; pnpm run build; pnpm run example:build; git diff --check Not-tested: Real-device release FPS profiling beyond manual example inspection
1 parent ead4c05 commit 8499eeb

6 files changed

Lines changed: 294 additions & 6 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ export function Headline() {
9494

9595
`controls` is a command channel, not a progress value. Use it for buttons, screen focus, onboarding steps, example replay controls, and coordinated title/subtitle replay.
9696

97+
Performance note: controls are intended for short UI text such as titles, labels, and product copy. If you plan to animate long paragraphs, grapheme-split copy, or many rows at once, measure in your target app before treating that as a supported workload.
98+
9799
Controlled progress is useful when text motion should follow a raw value outside the component, such as scroll position, gesture progress, or another Reanimated shared value. In that mode the app owns the exact progress and the text component only renders the current state.
98100

99101
Basic controlled progress usage:

example/src/App.tsx

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,78 @@ const ControlsPlaybackText = defineTextMotion()
177177
.motion({ kind: 'timing', options: { duration: 420 } })
178178
.component();
179179

180+
const ControlsStressWordsText = defineTextMotion()
181+
.split(words())
182+
.layout(nativeText())
183+
.timeline(stagger(0.018))
184+
.effect(rise({ y: 8 }).and(fade()))
185+
.motion({ kind: 'timing', options: { duration: 320 } })
186+
.component();
187+
188+
const ControlsStressGraphemeText = defineTextMotion()
189+
.split(graphemes())
190+
.layout(nativeText())
191+
.timeline(stagger(0.006))
192+
.effect(scale({ from: 0.94 }).and(fade()))
193+
.motion({ kind: 'timing', options: { duration: 260 } })
194+
.component();
195+
196+
type ControlsStressCase = {
197+
Component: TextMotionComponent;
198+
id: string;
199+
label: string;
200+
tokenNote: string;
201+
} & (
202+
| {
203+
rows?: never;
204+
text: string;
205+
}
206+
| {
207+
rows: readonly string[];
208+
text?: never;
209+
}
210+
);
211+
212+
const controlsStressCases: readonly ControlsStressCase[] = [
213+
{
214+
Component: ControlsStressWordsText,
215+
id: 'normal-words',
216+
label: 'Normal word case',
217+
text: 'Manual stress checks repeated controls replay across a longer headline',
218+
tokenNote: '10 word tokens',
219+
},
220+
{
221+
Component: ControlsStressGraphemeText,
222+
id: 'heavy-graphemes',
223+
label: 'Heavy grapheme case',
224+
text: 'MotionKit'.repeat(8),
225+
tokenNote: '72 grapheme tokens',
226+
},
227+
{
228+
Component: ControlsStressGraphemeText,
229+
id: 'extreme-graphemes',
230+
label: 'Extreme grapheme case',
231+
text: 'ControlStress'.repeat(12),
232+
tokenNote: '156 grapheme tokens',
233+
},
234+
{
235+
Component: ControlsStressWordsText,
236+
id: 'shared-rows',
237+
label: 'Shared rows case',
238+
rows: [
239+
'Replay rows without remounting',
240+
'Shared controls fan out here',
241+
'Several labels move together',
242+
'Each row owns playback',
243+
'Stress the command path',
244+
'Watch for visible jank',
245+
'Keep tapping replay quickly',
246+
'Rows should stay responsive',
247+
],
248+
tokenNote: '8 components, 33 word tokens',
249+
},
250+
];
251+
180252
const demoGroups: readonly DemoGroup[] = [
181253
{ id: 'presets', title: 'Presets' },
182254
{ id: 'splitters', title: 'Splitters' },
@@ -337,6 +409,13 @@ const demos: readonly Demo[] = [
337409
text: 'Controls replay without remounting',
338410
title: 'Playback Controls',
339411
},
412+
{
413+
caption: 'manual fan-out check',
414+
groupId: 'playback',
415+
id: 'controls-stress',
416+
text: 'Controls stress case',
417+
title: 'Controls Stress',
418+
},
340419
{
341420
caption: 'progress={sharedValue}',
342421
groupId: 'playback',
@@ -358,6 +437,10 @@ function nextDemoIndex(index: number): number {
358437
return (index + 1) % demos.length;
359438
}
360439

440+
function nextStressCaseIndex(index: number): number {
441+
return (index + 1) % controlsStressCases.length;
442+
}
443+
361444
function ControlsPlaybackDemo({ replaySignal, text }: { replaySignal: number; text: string }) {
362445
const controls = useTextMotionControls();
363446
const replaySignalRef = useRef(replaySignal);
@@ -396,6 +479,81 @@ function ControlsPlaybackDemo({ replaySignal, text }: { replaySignal: number; te
396479
);
397480
}
398481

482+
function ControlsStressDemo({ replaySignal }: { replaySignal: number }) {
483+
const controls = useTextMotionControls();
484+
const [caseIndex, setCaseIndex] = useState(0);
485+
const replaySignalRef = useRef(replaySignal);
486+
const stressCase = controlsStressCases[caseIndex] ?? controlsStressCases[0];
487+
const StressText = stressCase.Component;
488+
const stressRows = stressCase.rows;
489+
490+
useEffect(() => {
491+
if (replaySignalRef.current === replaySignal) {
492+
return;
493+
}
494+
495+
replaySignalRef.current = replaySignal;
496+
controls.replay();
497+
}, [controls, replaySignal]);
498+
499+
return (
500+
<View style={styles.controlledDemo}>
501+
<View style={styles.stressMetaGroup}>
502+
<Text style={styles.stressMeta}>{stressCase.label}</Text>
503+
<Text style={styles.stressMetaDetail}>{stressCase.tokenNote}</Text>
504+
</View>
505+
506+
{stressRows ? (
507+
<View style={styles.stressRows}>
508+
{stressRows.map((rowText) => (
509+
<StressText
510+
controls={controls}
511+
key={rowText}
512+
style={[styles.motionText, styles.stressRowText]}
513+
>
514+
{rowText}
515+
</StressText>
516+
))}
517+
</View>
518+
) : (
519+
<StressText
520+
controls={controls}
521+
key={stressCase.id}
522+
style={[styles.motionText, styles.stressMotionText]}
523+
>
524+
{stressCase.text}
525+
</StressText>
526+
)}
527+
528+
<View style={styles.progressControls}>
529+
<Pressable
530+
accessibilityRole="button"
531+
onPress={controls.replay}
532+
style={styles.progressButton}
533+
>
534+
<Text style={styles.progressButtonText}>Replay</Text>
535+
</Pressable>
536+
<Pressable
537+
accessibilityRole="button"
538+
onPress={controls.reset}
539+
style={styles.progressButton}
540+
>
541+
<Text style={styles.progressButtonText}>Reset</Text>
542+
</Pressable>
543+
<Pressable
544+
accessibilityRole="button"
545+
onPress={() => {
546+
setCaseIndex((index) => nextStressCaseIndex(index));
547+
}}
548+
style={styles.progressButton}
549+
>
550+
<Text style={styles.progressButtonText}>Case</Text>
551+
</Pressable>
552+
</View>
553+
</View>
554+
);
555+
}
556+
399557
function ControlledProgressDemo({ replaySignal, text }: { replaySignal: number; text: string }) {
400558
const progress = useSharedValue(0);
401559
const replaySignalRef = useRef(replaySignal);
@@ -455,6 +613,7 @@ export default function App() {
455613
const demo = demos[demoIndex] ?? demos[0];
456614
const MotionText = demo.Component;
457615
const controlsDemoSelected = demo.id === 'playback-controls';
616+
const controlsStressDemoSelected = demo.id === 'controls-stress';
458617
const controlledDemoSelected = demo.id === 'controlled-progress';
459618

460619
return (
@@ -505,6 +664,8 @@ export default function App() {
505664
<View style={styles.motionFrame}>
506665
{controlsDemoSelected ? (
507666
<ControlsPlaybackDemo key={demo.id} replaySignal={replayKey} text={demo.text} />
667+
) : controlsStressDemoSelected ? (
668+
<ControlsStressDemo key={demo.id} replaySignal={replayKey} />
508669
) : controlledDemoSelected ? (
509670
<ControlledProgressDemo key={demo.id} replaySignal={replayKey} text={demo.text} />
510671
) : (
@@ -714,6 +875,36 @@ const styles = StyleSheet.create({
714875
stageTitleGroup: {
715876
flex: 1,
716877
},
878+
stressMeta: {
879+
color: '#0f766e',
880+
fontSize: 12,
881+
fontWeight: '800',
882+
lineHeight: 16,
883+
textAlign: 'center',
884+
},
885+
stressMetaDetail: {
886+
color: '#64748b',
887+
fontSize: 12,
888+
fontWeight: '700',
889+
lineHeight: 16,
890+
textAlign: 'center',
891+
},
892+
stressMetaGroup: {
893+
alignItems: 'center',
894+
gap: 2,
895+
},
896+
stressMotionText: {
897+
fontSize: 18,
898+
lineHeight: 25,
899+
},
900+
stressRows: {
901+
gap: 4,
902+
width: '100%',
903+
},
904+
stressRowText: {
905+
fontSize: 15,
906+
lineHeight: 20,
907+
},
717908
summary: {
718909
color: '#475569',
719910
fontSize: 15,

packages/text-motion/README.ko.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,10 @@ export function Headline() {
343343

344344
Text Motion은 playback을 위한 context/provider API나 public component ref API를 제공하지 않습니다. 연결 관계가 JSX에서 보이도록 `controls={controls}`로 명시적으로 전달하세요.
345345

346+
성능 관점에서 `controls`는 짧은 UI 텍스트에 맞춰 설계되어 있습니다. Title, label, 짧은 product sentence라면 playback work가 작고 예측 가능합니다. 긴 문단, 많은 row, 글자 단위로 쪼개는 grapheme split 텍스트에 쓰려면 example의 stress case를 확인하고 target device에서 먼저 측정하세요.
347+
348+
큰 workload가 중요해지면 renderer 내부 구현은 바뀔 수 있습니다. 그래서 example stress case는 controls 구현 방식에 대한 public promise가 아니라 profiling aid로 보는 편이 안전합니다.
349+
346350
### Raw Progress
347351

348352
Text motion이 앱이 이미 소유한 raw value를 따라가야 한다면 `progress`를 사용하세요.

packages/text-motion/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,10 @@ One controls object may be passed to multiple text motion components. Commands b
343343

344344
Text Motion intentionally does not provide a context/provider playback API or a public component ref API. Pass controls explicitly with `controls={controls}` so the connection is visible in JSX.
345345

346+
Performance note: controls are designed for short UI text. For a title, label, or short product sentence, playback work should stay small and predictable. For long paragraphs, dense lists, or grapheme-split text with many characters, check the example stress case and measure on your target device before using it in production.
347+
348+
The renderer internals may change as larger workloads become important, so treat the example stress case as a profiling aid rather than a public promise about how controls are implemented.
349+
346350
### Raw Progress
347351

348352
Use `progress` when text motion should follow a raw value the app already owns.

packages/text-motion/src/__tests__/controls.test.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,29 @@ describe('text motion controls', () => {
5151
expect(commands).toEqual([]);
5252
});
5353

54+
it('fans out commands once per subscribed listener and stops after unsubscribe', () => {
55+
const controls = createTextMotionControlsHandle();
56+
const descriptor = readTextMotionControlsDescriptor(controls);
57+
const callsByListener = Array.from({ length: 64 }, (): TextMotionControlCommand[] => []);
58+
const unsubscribers = callsByListener.map((commands) =>
59+
descriptor.subscribe((command) => {
60+
commands.push(command);
61+
}),
62+
);
63+
64+
controls.replay();
65+
controls.stop();
66+
67+
expect(callsByListener.every((commands) => commands.length === 2)).toBe(true);
68+
expect(callsByListener.every((commands) => commands[0]?.kind === 'replay')).toBe(true);
69+
expect(callsByListener.every((commands) => commands[1]?.kind === 'stop')).toBe(true);
70+
71+
unsubscribers.forEach((unsubscribe) => unsubscribe());
72+
controls.play();
73+
74+
expect(callsByListener.every((commands) => commands.length === 2)).toBe(true);
75+
});
76+
5477
it('keeps old controls inactive after moving a listener to a different controls object', () => {
5578
const firstControls = createTextMotionControlsHandle();
5679
const secondControls = createTextMotionControlsHandle();

0 commit comments

Comments
 (0)