Files
software-mansion__argent/packages/tool-server/test/react-profiler-animated-pattern.test.ts
Ignacy Łątka 0f4d5e51cc fix(react-profiler): stop ANIMATED_PATTERN matching 'motion' inside ordinary names (#433)
## Defect

In `packages/tool-server/src/utils/react-profiler/pipeline/03-tag.ts`,
the animation-detection regex was both unanchored and case-insensitive:

```ts
const ANIMATED_PATTERN = /(Animated|Animation|Transition|Motion)/i;
```

Because of the `/i` flag, the token `Motion` matches the lowercase
substring "motion" that appears inside perfectly ordinary PascalCase
component names: `PromotionCard`, `EmotionThemeCard`, `CommotionList`,
`LocomotionView`, and so on. Those components get tagged `isAnimated =
true` in stage 3, and stage 4 (`04-rank.ts`) then unconditionally drops
every animated component from the findings (`if (comp.isAnimated)
continue;`).

The result: a genuinely slow or frequently re-rendering component such
as `PromotionCard` silently disappears from the report's Top Components
/ Suggested Improvements, even though it is exactly the kind of
component the profiler exists to surface. `ProductCard` (no "motion"
substring) is handled correctly, which is what makes the bug easy to
miss.

## Fix

Dropping only the `/i` flag removes the lowercase-embedded false
positives, but still mis-tags acronym/number-glued names such as
`SVGAnimatedPath`, `RNAnimatedView`, and `MotionlessIndicator`. So the
committed pattern anchors the token to a genuine PascalCase segment
boundary:

```ts
const ANIMATED_PATTERN = /(?<![A-Z0-9])(Animated|Animation|Transition|Motion)(?=[A-Z0-9_(.]|$)/;
```

- **Case-sensitive**, so a lowercase-embedded "motion" (`PromotionCard`,
`Emotion`, `Locomotion`) never contains the capitalized token.
- The leading `(?<![A-Z0-9])` rejects acronym/number-glued prefixes
(`SVGAnimatedPath`, `RNAnimatedView`, `CMMotionManager`,
`G2MotionSensor`) where the token is not a real PascalCase word-start.
- The trailing `(?=[A-Z0-9_(.]|$)` requires the token to end a segment —
another capitalized word, a digit/underscore, `(` or `.` (HOC/dotted
display names like `Animated(View)`, `Animated.View`), or end-of-string
— so it still matches real names while rejecting tokens that bleed into
more lowercase (`MotionlessIndicator`).

**Known limitation:** a bare `Motion` starting a PascalCase word
(`MotionSensor`, `MotionManager`) still matches — it is structurally
indistinguishable from a real animation name of the same shape
(`MotionView`, and `@legendapp/motion`'s `Motion.View`) that we intend
to tag. This over-tag is accepted collateral (it only excludes a
component from perf findings); a hardcoded device-motion denylist would
be more fragile than the rare false positive it removes. The behavior is
documented in the code comment and pinned by a test.

## Tests

`packages/tool-server/test/react-profiler-animated-pattern.test.ts`
covers:
- lowercase-embedded false positives (`PromotionCard`,
`EmotionThemeCard`, `CommotionList`, `ProductCard`) → `isAnimated =
false`, and `PromotionCard` reappears in `rank()` findings;
- real animation components (`Animated`, `AnimatedView`, `MotionView`,
`FadeTransition`, dotted/HOC forms) → `isAnimated = true`;
- acronym-prefixed and digit-suffixed device-motion names
(`CMMotionManager`, `G2MotionSensor`, …) → `isAnimated = false`;
- the accepted bare-`Motion` device-name over-tag (`MotionSensor`,
`MotionManager`, `MotionDetector`, `MotionTracker` → `isAnimated =
true`), pinned so any future change is deliberate.

The suite fails against the old `/i` regex and passes with the fix. `tsc
--build` and ESLint on the changed files are clean.
2026-07-03 16:19:31 +02:00

219 lines
8.6 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { tag } from "../src/utils/react-profiler/pipeline/03-tag";
import { rank } from "../src/utils/react-profiler/pipeline/04-rank";
import type {
EnrichOutput,
EnrichedComponent,
SessionContext,
} from "../src/utils/react-profiler/types/pipeline";
const sessionContext: SessionContext = {
reactCompilerEnabled: false,
strictModeEnabled: false,
buildMode: "dev",
rnArchitecture: "bridge",
projectRoot: "/proj",
platform: "ios",
};
function slow(name: string): EnrichedComponent {
return {
name,
n: 10,
normalizedRenderCount: 10,
mean: 20,
min: 5,
max: 40,
totalRenderMs: 200,
dominantReason: "props",
topChangedProps: ["data"],
topChangedHooks: [],
isCompilerOptimized: false,
firstCommitTs: 0,
lastCommitTs: 100,
};
}
function enrich(names: string[]): EnrichOutput {
const components = new Map<string, EnrichedComponent>();
for (const n of names) components.set(n, slow(n));
return {
components,
sessionContext,
reactCommits: 50,
fiberRenders: 100,
anyRuntimeCompilerDetected: false,
totalFirstMounts: 0,
firstMountOnlyComponents: [],
recordingMs: 10_000,
};
}
describe("ANIMATED_PATTERN matches only real animation segments", () => {
it("does NOT tag non-animation names containing 'motion' as a substring", () => {
const tagged = tag(
enrich(["PromotionCard", "EmotionThemeCard", "CommotionList", "ProductCard"])
);
for (const n of ["PromotionCard", "EmotionThemeCard", "CommotionList", "ProductCard"]) {
expect(tagged.components.get(n)!.isAnimated).toBe(false);
}
const ranked = rank(tagged).map((f) => f.component);
expect(ranked).toContain("PromotionCard");
});
it("DOES still tag real animation components", () => {
const tagged = tag(enrich(["Animated", "AnimatedView", "MotionView", "FadeTransition"]));
for (const n of ["Animated", "AnimatedView", "MotionView", "FadeTransition"]) {
expect(tagged.components.get(n)!.isAnimated).toBe(true);
}
});
it("does NOT tag a capitalized token that merely trails into lowercase letters", () => {
// Case-sensitivity alone isn't a boundary: without one, "MotionlessIndicator"
// still matches "Motion" as a bare substring, same bug class as the
// original lowercase false positives. One case per token so each token's
// trailing boundary is pinned independently — otherwise a regression that
// reopened this bug for a single token (Transition especially: it is a heavy
// non-animation word — state machines, `TransitionalScreen`) would hide
// behind the other tokens sharing the same regex and leave the suite green.
const names = [
"MotionlessIndicator", // Motion
"AnimationsDisabledBanner", // Animation
"TransitionalScreen", // Transition
"TransitionerRow", // Transition
"AnimatedlyBanner", // Animated ("animatedly" adverb) trailing into lowercase
];
const tagged = tag(enrich(names));
for (const n of names) {
expect(tagged.components.get(n)!.isAnimated, n).toBe(false);
}
});
it("DOES still tag real animation components with a digit/underscore suffix", () => {
// A digit/underscore right after the token is a legitimate PascalCase
// continuation (numbered/keyed variants) and must still be recognized.
const names = ["Animated2", "Animated_View", "Motion360Player"];
const tagged = tag(enrich(names));
for (const n of names) {
expect(tagged.components.get(n)!.isAnimated).toBe(true);
}
});
it("does NOT tag a real, non-animation acronym-prefixed name", () => {
// Without a leading-boundary requirement, an acronym immediately followed
// by the token (no lowercase-to-uppercase transition) reads as a match
// with nothing to reject it. CMMotionManager/CMMotionActivity are real
// Apple CoreMotion SDK class names with nothing to do with animation —
// a React Native wrapper/bridge component plausibly named after them must
// not be silently excluded from profiler findings.
const names = [
"CMMotionManager",
"CMMotionActivity",
"IOMotionSensor",
"RNMotionDetector",
"GPSMotionTracker",
];
const tagged = tag(enrich(names));
for (const n of names) {
expect(tagged.components.get(n)!.isAnimated).toBe(false);
}
});
it("DOES tag a bare Motion-prefixed device name (accepted collateral of matching MotionView)", () => {
// KNOWN LIMITATION: a bare `Motion` word-start (no acronym/digit prefix) is
// structurally indistinguishable from a real animation name of the same
// shape (MotionView, legend-motion's Motion.View), which we DO tag — so
// these CoreMotion-style device names are over-tagged as animated. Only the
// acronym/digit-GLUED forms (above) are rejected. Pinned here so any future
// change to this behavior is a deliberate one, not an accident.
const names = ["MotionSensor", "MotionManager", "MotionDetector", "MotionTracker"];
const tagged = tag(enrich(names));
for (const n of names) {
expect(tagged.components.get(n)!.isAnimated, n).toBe(true);
}
});
it("does NOT tag a digit-suffixed acronym prefix either", () => {
// The leading boundary must reject digits too, not just uppercase — a
// digit immediately before the token is the tail of an acronym/model
// number (G2, IMU2, BLE4, L3), not a real PascalCase word start. Allowing
// digits here (matching the trailing side's digit allowance) reopened the
// same CMMotionManager-class false positive with a different acronym shape.
const names = ["G2MotionSensor", "IMU2MotionTracker", "BLE4MotionBeacon", "L3MotionFilter"];
const tagged = tag(enrich(names));
for (const n of names) {
expect(tagged.components.get(n)!.isAnimated).toBe(false);
}
});
// Full match/no-match matrix, covering every token's wrapped/dotted forms —
// not just Animated* — so a token-specific boundary regression in Motion,
// Transition, or Animation can't ride on the Animated cases staying green.
// The paren/dot/HOC-wrapped forms are the display names React DevTools produces
// for animated subtrees. Only the parenthesized `Animated(...)` /
// `AnimatedComponent(...)` forms are what skip-rules.ts / component-tree.ts
// hard-code as skips; the dotted (`Animated.View`, `Motion.View`), other-token,
// and nested-HOC (`Memo(AnimatedComponent(View))`) forms go beyond those two
// prefixes. An intermediate, over-tightened version of this regex failed to
// match the wrapped forms, leaking them back into findings.
const MUST_MATCH = [
"Animated",
"AnimatedView",
"Animated(View)",
"Animated.View",
"AnimatedComponent(View)",
"Memo(AnimatedComponent(View))",
"ForwardRef(AnimatedComponent(View))",
"Forget(AnimatedComponent(View))",
"Animated(ScrollView)",
"MotionView",
"FadeTransition",
"SlideTransition",
"FadeInAnimation",
"Animation",
"Transition",
"Animated2",
"Animated_View",
// Other-token wrapped/dotted forms — asserted so Motion/Transition/Animation
// are pinned symmetrically with Animated (Motion.View = @legendapp/motion;
// Transition(...) = react-transition-group HOC display name).
"Motion.View",
"Motion(View)",
"Transition(View)",
"Transition.View",
"Animation.View",
"Animation(View)",
];
const MUST_NOT_MATCH = [
"PromotionCard",
"Promotion",
"EmotionThemeCard",
"Emotion",
"CommotionList",
"Locomotion",
"LocomotionView",
"SVGAnimatedPath",
"RNAnimatedView",
];
it("tags every animation name in the match matrix (incl. paren/dot/HOC-wrapped)", () => {
const tagged = tag(enrich(MUST_MATCH));
for (const n of MUST_MATCH) {
expect(tagged.components.get(n)!.isAnimated, n).toBe(true);
}
});
it("does NOT tag any name in the no-match matrix (incl. acronym-glued Animated)", () => {
const tagged = tag(enrich(MUST_NOT_MATCH));
for (const n of MUST_NOT_MATCH) {
expect(tagged.components.get(n)!.isAnimated, n).toBe(false);
}
});
it("excludes wrapped animation names from rank() findings while keeping real slow components", () => {
const wrappers = [
"Animated(View)",
"Animated.View",
"Memo(AnimatedComponent(View))",
"Motion.View",
"Transition(View)",
];
const tagged = tag(enrich([...wrappers, "PromotionCard"]));
const ranked = rank(tagged).map((f) => f.component);
for (const n of wrappers) {
expect(ranked, n).not.toContain(n);
}
expect(ranked).toContain("PromotionCard");
});
});