diff --git a/.yarnrc.yml b/.yarnrc.yml index 6f2a17b48d69..d9b0ec0726d2 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -44,6 +44,7 @@ npmPreapprovedPackages: - 'babel-plugin-syntax-hermes-parser' - 'hermes-parser' - 'hermes-estree' + - 'tstyche' tsEnableAutoTypes: false diff --git a/package.json b/package.json index fdb411ab2ffa..9fc81e7cbaa5 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "lint:workspaces": "yarn workspaces foreach --all --exclude \"react-native-reanimated-monorepo\" --parallel --topological-dev run lint", "lint:root": "eslint . --ignore-pattern 'packages/**' --ignore-pattern 'apps/**' --ignore-pattern 'docs/**' && yarn run --top-level oxfmt --check --ignore-path .prettiereslintignore .", "prepare-vscode": "bash ./scripts/prepare-vscode.sh", + "type:check:tstyche": "tstyche", "toggle-bundle-mode": "bash ./scripts/toggle-bundle-mode.sh" }, "devDependencies": { @@ -82,6 +83,7 @@ "remark-gfm": "4.0.1", "remark-mdx": "3.1.0", "shelljs": "0.10.0", + "tstyche": "7.2.1", "typescript": "5.9.3" }, "resolutions": { diff --git a/packages/react-native-reanimated/__typetests__/FeatureFlagTest.tst.ts b/packages/react-native-reanimated/__typetests__/FeatureFlagTest.tst.ts new file mode 100644 index 000000000000..dfee2d554656 --- /dev/null +++ b/packages/react-native-reanimated/__typetests__/FeatureFlagTest.tst.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from 'tstyche'; + +import { getStaticFeatureFlag } from '..'; + +describe('getStaticFeatureFlag', () => { + test('accepts a known static feature flag', () => { + expect(getStaticFeatureFlag).type.toBeCallableWith('RUNTIME_TEST_FLAG'); + }); + + test('rejects an unknown feature flag', () => { + expect(getStaticFeatureFlag).type.not.toBeCallableWith('NON_EXISTENT_FLAG'); + }); +}); diff --git a/packages/react-native-reanimated/__typetests__/FeatureFlagTest.tsx b/packages/react-native-reanimated/__typetests__/FeatureFlagTest.tsx deleted file mode 100644 index da7d1dcd12a1..000000000000 --- a/packages/react-native-reanimated/__typetests__/FeatureFlagTest.tsx +++ /dev/null @@ -1,10 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { getStaticFeatureFlag } from '..'; - -function GetStaticFeatureFlagTest() { - // ok - this flag exists - getStaticFeatureFlag('RUNTIME_TEST_FLAG'); - - // @ts-expect-error - this flag does not exist - getStaticFeatureFlag('NON_EXISTENT_FLAG'); -} diff --git a/packages/react-native-reanimated/__typetests__/animatedStyleAugmentationTest.tsx b/packages/react-native-reanimated/__typetests__/animatedStyleAugmentationTest.tst.ts similarity index 83% rename from packages/react-native-reanimated/__typetests__/animatedStyleAugmentationTest.tsx rename to packages/react-native-reanimated/__typetests__/animatedStyleAugmentationTest.tst.ts index 29e67e057900..66f03eaffca0 100644 --- a/packages/react-native-reanimated/__typetests__/animatedStyleAugmentationTest.tsx +++ b/packages/react-native-reanimated/__typetests__/animatedStyleAugmentationTest.tst.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import type { ImageStyle, StyleProp, TextStyle, ViewStyle } from 'react-native'; +import { describe, test } from 'tstyche'; import type { AnimatedStyle } from '..'; import { cubicBezier, linear, steps } from '..'; @@ -15,6 +16,9 @@ import { cubicBezier, linear, steps } from '..'; // `AnimatedStyle`, the intersection collapsed conflicting properties to // `never` and surfaced as "Type … is not assignable to 'undefined'" on inline // `style={{ ... }}` for `Animated.View`/`Animated.Text`/`Animated.Image`. +// +// These are compile-only checks: each typed assignment must type-check, which +// TSTyche enforces by type-checking the whole test file. type ExpoStyleAugmentation = { animationName?: string; @@ -42,8 +46,8 @@ type AugmentedViewStyle = ViewStyle & ExpoStyleAugmentation; type AugmentedTextStyle = TextStyle & ExpoStyleAugmentation; type AugmentedImageStyle = ImageStyle & ExpoStyleAugmentation; -function AnimatedStyleAugmentationTest() { - function ParametrizedTimingFunctionsOnAugmentedView() { +describe('AnimatedStyle with Expo-augmented RN styles', () => { + test('parametrized timing functions on an augmented view style', () => { const cubic: AnimatedStyle = { width: 100, backgroundColor: 'red', @@ -67,16 +71,16 @@ function AnimatedStyleAugmentationTest() { animationName: { from: { opacity: 0 }, to: { opacity: 1 } }, animationTimingFunction: steps(4), }; - } + }); - function PredefinedTimingFunctionsOnAugmentedView() { + test('predefined timing functions on an augmented view style', () => { const s: AnimatedStyle = { animationName: { from: { opacity: 0 }, to: { opacity: 1 } }, animationTimingFunction: 'ease-in-out', }; - } + }); - function ArrayShorthandsOnAugmentedView() { + test('array shorthands on an augmented view style', () => { const s: AnimatedStyle = { animationName: [ { from: { opacity: 0 }, to: { opacity: 1 } }, @@ -88,9 +92,9 @@ function AnimatedStyleAugmentationTest() { animationDuration: ['1s', '2s'], animationTimingFunction: [cubicBezier(0.25, 0.1, 0.25, 1), 'ease'], }; - } + }); - function CSSTransitionsOnAugmentedView() { + test('CSS transitions on an augmented view style', () => { const s: AnimatedStyle = { width: 100, transitionProperty: 'width', @@ -99,25 +103,25 @@ function AnimatedStyleAugmentationTest() { transitionDelay: 0, transitionBehavior: 'allow-discrete', }; - } + }); - function CSSAnimationOnAugmentedText() { + test('CSS animation on an augmented text style', () => { const s: AnimatedStyle = { color: 'black', animationName: { from: { opacity: 0 }, to: { opacity: 1 } }, animationTimingFunction: cubicBezier(0.25, 0.1, 0.25, 1), }; - } + }); - function CSSAnimationOnAugmentedImage() { + test('CSS animation on an augmented image style', () => { const s: AnimatedStyle = { resizeMode: 'cover', animationName: { from: { opacity: 0 }, to: { opacity: 1 } }, animationTimingFunction: cubicBezier(0.25, 0.1, 0.25, 1), }; - } + }); - function NonCSSStylesUnaffected() { + test('non-CSS styles are unaffected', () => { const s: AnimatedStyle = { width: 100, height: 100, @@ -125,24 +129,20 @@ function AnimatedStyleAugmentationTest() { transform: [{ translateX: 10 }, { scale: 2 }], opacity: 0.5, }; - } + }); - // Sanity-check: plain `ViewStyle` without the augmented CSS fields still works. - function PlainViewStyleStillWorks() { + test('plain ViewStyle without augmented fields still works', () => { const s: AnimatedStyle = { width: 100, animationName: { from: { opacity: 0 }, to: { opacity: 1 } }, animationTimingFunction: cubicBezier(0.25, 0.1, 0.25, 1), }; - } + }); - // `AnimatedStyle>` is a union (null, arrays, etc.); the Omit - // must distribute over union members to strip augmented CSS keys from the - // object member — this is the exact shape that fails in the issue's repro. - function StylePropUnionMirrorsRealRepro() { + test('StyleProp union mirrors the real repro', () => { const s: AnimatedStyle> = { animationName: { from: { opacity: 0 }, to: { opacity: 1 } }, animationTimingFunction: cubicBezier(0, 0, 0, 0), }; - } -} + }); +}); diff --git a/packages/react-native-reanimated/__typetests__/miscTest.tst.ts b/packages/react-native-reanimated/__typetests__/miscTest.tst.ts new file mode 100644 index 000000000000..c7fcc4b00c67 --- /dev/null +++ b/packages/react-native-reanimated/__typetests__/miscTest.tst.ts @@ -0,0 +1,97 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { describe, expect, test } from 'tstyche'; + +import { + createAnimatedPropAdapter, + Easing, + interpolateColor, + isSharedValue, + Keyframe, + makeMutable, + useAnimatedProps, + useAnimatedStyle, + useSharedValue, +} from '..'; + +describe('makeMutable', () => { + test('is callable with the initial value', () => { + expect(makeMutable).type.toBeCallableWith(0); + expect(makeMutable).type.toBeCallableWith(true); + }); +}); + +describe('isSharedValue', () => { + test('is callable with any value', () => { + const sv = useSharedValue(0); + expect(isSharedValue).type.toBeCallableWith(null); + expect(isSharedValue).type.toBeCallableWith(undefined); + expect(isSharedValue).type.toBeCallableWith(42); + expect(isSharedValue).type.toBeCallableWith('foo'); + expect(isSharedValue).type.toBeCallableWith({ foo: 'bar' }); + expect(isSharedValue).type.toBeCallableWith(sv); + }); +}); + +describe('interpolateColor', () => { + test('accepts numeric and string color ranges and an optional color space', () => { + const sv = useSharedValue(0); + expect(interpolateColor).type.toBeCallableWith( + sv.value, + [0, 1], + [0x00ff00, 0x0000ff] + ); + expect(interpolateColor).type.toBeCallableWith( + sv.value, + [0, 1], + ['red', 'blue'] + ); + expect(interpolateColor).type.toBeCallableWith( + sv.value, + [0, 1], + ['#00FF00', '#0000FF'], + 'RGB' + ); + expect(interpolateColor).type.toBeCallableWith( + sv.value, + [0, 1], + ['#FF0000', '#00FF99'], + 'HSV' + ); + }); +}); + +describe('animated prop adapters', () => { + test('adapters work with useAnimatedProps but not useAnimatedStyle', () => { + const adapter1 = createAnimatedPropAdapter((_props) => {}, []); + const adapter2 = createAnimatedPropAdapter( + (_props) => {}, + ['prop1', 'prop2'] + ); + const adapter3 = createAnimatedPropAdapter(() => {}); + + expect(useAnimatedProps).type.toBeCallableWith(() => ({}), null, adapter1); + expect(useAnimatedProps).type.toBeCallableWith(() => ({}), null, [ + adapter2, + adapter3, + ]); + expect(useAnimatedStyle).type.not.toBeCallableWith(() => ({}), undefined, [ + adapter1, + adapter2, + adapter3, + ]); + }); +}); + +describe('Easing and Keyframe', () => { + test('Easing.bezier is callable', () => { + expect(Easing.bezier).type.toBeCallableWith(0.1, 0.7, 1, 0.1); + }); + + test('Keyframe is constructable with from/to', () => { + const easing = Easing.bezier(0.1, 0.7, 1, 0.1); + expect(Keyframe).type.toBeConstructableWith({ + from: { opacity: 0 }, + to: { opacity: 1, easing }, + }); + }); +}); diff --git a/packages/react-native-reanimated/__typetests__/miscTest.tsx b/packages/react-native-reanimated/__typetests__/miscTest.tsx deleted file mode 100644 index 9c513f6bd85e..000000000000 --- a/packages/react-native-reanimated/__typetests__/miscTest.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/* eslint-disable @typescript-eslint/no-empty-function */ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { - createAnimatedPropAdapter, - Easing, - interpolateColor, - isSharedValue, - Keyframe, - makeMutable, - useAnimatedProps, - useAnimatedStyle, - useSharedValue, -} from '..'; - -function MakeMutableTest() { - const mut1 = makeMutable(0); - const mut2 = makeMutable(true); -} - -function IsSharedValueTest() { - const sv = useSharedValue(0); - - isSharedValue(null); - isSharedValue(undefined); - isSharedValue(42); - isSharedValue('foo'); - isSharedValue({ foo: 'bar' }); - isSharedValue(sv); -} - -function InterpolateColorTest() { - const sv = useSharedValue(0); - - interpolateColor(sv.value, [0, 1], [0x00ff00, 0x0000ff]); - interpolateColor(sv.value, [0, 1], ['red', 'blue']); - interpolateColor(sv.value, [0, 1], ['#00FF00', '#0000FF'], 'RGB'); - interpolateColor(sv.value, [0, 1], ['#FF0000', '#00FF99'], 'HSV'); - - return null; -} - -function UpdatePropsTest() { - const adapter1 = createAnimatedPropAdapter((props) => {}, []); - const adapter2 = createAnimatedPropAdapter((props) => {}, ['prop1', 'prop2']); - const adapter3 = createAnimatedPropAdapter(() => {}); - - // @ts-expect-error works only for useAnimatedProps - useAnimatedStyle(() => ({}), undefined, [adapter1, adapter2, adapter3]); - - useAnimatedProps(() => ({}), null, adapter1); - - useAnimatedProps(() => ({}), null, [adapter2, adapter3]); -} - -function EasingFactoryFunctionTest() { - const easing = Easing.bezier(0.1, 0.7, 1, 0.1); - - const keyframe = new Keyframe({ - from: { - opacity: 0, - }, - to: { - opacity: 1, - easing, - }, - }); -} diff --git a/packages/react-native-reanimated/__typetests__/useDerivedValueTest.tst.ts b/packages/react-native-reanimated/__typetests__/useDerivedValueTest.tst.ts new file mode 100644 index 000000000000..1acc7629a686 --- /dev/null +++ b/packages/react-native-reanimated/__typetests__/useDerivedValueTest.tst.ts @@ -0,0 +1,27 @@ +import { describe, expect, pick, test } from 'tstyche'; + +import type { DerivedValue } from '..'; +import { useDerivedValue, useSharedValue } from '..'; + +describe('useDerivedValue', () => { + test('infers a DerivedValue of the result type', () => { + const progress = useSharedValue(0); + const derived = useDerivedValue(() => progress.value * 250); + expect(derived).type.toBe>(); + }); + + test('value is readonly and typed as the result', () => { + const progress = useSharedValue(0); + const width = useDerivedValue(() => progress.value * 250); + expect(pick(width, 'value')).type.toBe<{ readonly value: number }>(); + }); + + test('set is still allowed for now (deprecated)', () => { + const progress = useSharedValue(0); + const width = useDerivedValue(() => progress.value * 250); + // TODO: This should be caught as an illegal operation, since DerivedValue is + // readonly. We can't enforce it yet without breaking DerivedValue -> + // SharedValue assignments, so `set` is marked deprecated and removed later. + width.set(100); + }); +}); diff --git a/packages/react-native-reanimated/__typetests__/useDerivedValueTest.tsx b/packages/react-native-reanimated/__typetests__/useDerivedValueTest.tsx deleted file mode 100644 index 5ec6c8735a7d..000000000000 --- a/packages/react-native-reanimated/__typetests__/useDerivedValueTest.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import React from 'react'; -import { Button } from 'react-native'; - -import { useDerivedValue, useSharedValue } from '..'; - -function UseDerivedValueTestOldAPI() { - const progress = useSharedValue(0); - const width = useDerivedValue(() => { - return progress.value * 250; - }); - // @ts-expect-error width is readonly - width.value = 100; - return ( -