From 9bd900e5f3673ae0ccef08528610374f25b7d3de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 29 May 2026 14:51:29 +0200 Subject: [PATCH 01/12] Set up TSTyche type tests and migrate FeatureFlag test The __typetests__ suites assert types through tsc failures and ~220 `@ts-expect-error` directives, which match any error on the following line and give no per-test reporting. Start migrating them to TSTyche, which runs on the project's own TypeScript and produces named, isolated assertions. - Add the `tstyche` dev dependency and a `type:check:tstyche` script. - Add tstyche.config.json scoped to packages/*/__typetests__. - Convert FeatureFlagTest.tsx to FeatureFlagTest.tst.ts as the first test. - Make scripts/test-ts.sh skip *.tst.* so the old and new harnesses run side by side and files can be migrated one at a time. Builds on the TSTyche proof of concept from #7727. --- package.json | 2 ++ .../__typetests__/FeatureFlagTest.tst.ts | 13 +++++++++++++ .../__typetests__/FeatureFlagTest.tsx | 10 ---------- scripts/test-ts.sh | 4 ++++ tstyche.config.json | 6 ++++++ yarn.lock | 15 +++++++++++++++ 6 files changed, 40 insertions(+), 10 deletions(-) create mode 100644 packages/react-native-reanimated/__typetests__/FeatureFlagTest.tst.ts delete mode 100644 packages/react-native-reanimated/__typetests__/FeatureFlagTest.tsx create mode 100644 tstyche.config.json diff --git a/package.json b/package.json index fdb411ab2ffa..c5882a29fa8e 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": "4.1.0", "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/scripts/test-ts.sh b/scripts/test-ts.sh index fd77ebbd8967..32dcbcedb54a 100755 --- a/scripts/test-ts.sh +++ b/scripts/test-ts.sh @@ -6,6 +6,10 @@ FILES=() for path in "${ARGS[@]}"; do if [ -d "$path" ]; then for file in "$path"/*.ts "$path"/*.tsx "$path"/*.d.ts; do + # Skip TSTyche type tests; those run via the `tstyche` runner. + case "$file" in + *.tst.ts | *.tst.tsx) continue ;; + esac if [ -f "$file" ]; then FILES+=("$file") fi diff --git a/tstyche.config.json b/tstyche.config.json new file mode 100644 index 000000000000..adfd0ca04970 --- /dev/null +++ b/tstyche.config.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://tstyche.org/schemas/config.json", + "testFileMatch": [ + "packages/*/__typetests__/**/*.tst.*" + ] +} diff --git a/yarn.lock b/yarn.lock index b5289d11df55..d64cf6176758 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28184,6 +28184,7 @@ __metadata: remark-gfm: "npm:4.0.1" remark-mdx: "npm:3.1.0" shelljs: "npm:0.10.0" + tstyche: "npm:4.1.0" typescript: "npm:5.9.3" languageName: unknown linkType: soft @@ -32033,6 +32034,20 @@ __metadata: languageName: node linkType: hard +"tstyche@npm:4.1.0": + version: 4.1.0 + resolution: "tstyche@npm:4.1.0" + peerDependencies: + typescript: ">=4.7" + peerDependenciesMeta: + typescript: + optional: true + bin: + tstyche: ./build/bin.js + checksum: 10/9cc0b8b28a82ece0abc4838df08b64d200839cd832e15cb3654ba07ab38d53d2782f7cc36c94d2a4541b4d3b40a7e57ac58524bdffc990280302dbcbd4b5ac09 + languageName: node + linkType: hard + "tsutils@npm:^3.21.0": version: 3.21.0 resolution: "tsutils@npm:3.21.0" From 189c83e08d3ed81b6475783f870764bc199259ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 29 May 2026 15:04:40 +0200 Subject: [PATCH 02/12] Format tstyche.config.json --- tstyche.config.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tstyche.config.json b/tstyche.config.json index adfd0ca04970..c7cf1352a6e1 100644 --- a/tstyche.config.json +++ b/tstyche.config.json @@ -1,6 +1,4 @@ { "$schema": "https://tstyche.org/schemas/config.json", - "testFileMatch": [ - "packages/*/__typetests__/**/*.tst.*" - ] + "testFileMatch": ["packages/*/__typetests__/**/*.tst.*"] } From beb716a379a129876f66dc821e508522242b47bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 29 May 2026 17:43:38 +0200 Subject: [PATCH 03/12] Update package.json Co-authored-by: Tom Mrazauskas --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c5882a29fa8e..9fc81e7cbaa5 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "remark-gfm": "4.0.1", "remark-mdx": "3.1.0", "shelljs": "0.10.0", - "tstyche": "4.1.0", + "tstyche": "7.2.1", "typescript": "5.9.3" }, "resolutions": { From 9b04888a7995fc70df362aa3c2d67f04196f5c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 29 May 2026 18:23:12 +0200 Subject: [PATCH 04/12] Allow TSTyche 7.2.1 under the npm age gate The 7.2.1 bump could not be installed: the repo's 8-day npm age gate quarantines it (published 2026-05-27), and the bump left yarn.lock pinned to 4.1.0, which breaks `yarn install --immutable`. - Add tstyche to npmPreapprovedPackages in .yarnrc.yml. - Update yarn.lock to TSTyche 7.2.1. --- .yarnrc.yml | 1 + yarn.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) 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/yarn.lock b/yarn.lock index d64cf6176758..7f4c6033af6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28184,7 +28184,7 @@ __metadata: remark-gfm: "npm:4.0.1" remark-mdx: "npm:3.1.0" shelljs: "npm:0.10.0" - tstyche: "npm:4.1.0" + tstyche: "npm:7.2.1" typescript: "npm:5.9.3" languageName: unknown linkType: soft @@ -32034,17 +32034,17 @@ __metadata: languageName: node linkType: hard -"tstyche@npm:4.1.0": - version: 4.1.0 - resolution: "tstyche@npm:4.1.0" +"tstyche@npm:7.2.1": + version: 7.2.1 + resolution: "tstyche@npm:7.2.1" peerDependencies: - typescript: ">=4.7" + typescript: ">=5.4" peerDependenciesMeta: typescript: optional: true bin: - tstyche: ./build/bin.js - checksum: 10/9cc0b8b28a82ece0abc4838df08b64d200839cd832e15cb3654ba07ab38d53d2782f7cc36c94d2a4541b4d3b40a7e57ac58524bdffc990280302dbcbd4b5ac09 + tstyche: dist/bin.js + checksum: 10/42b21eededd6924b2cd271fb9747be50240b881e5a582d32a2e1bd2e575bd0d50d9a454d82e6ca58b7b6b500be02ac117d579d77f70da6c26dd522ca8f3e97a2 languageName: node linkType: hard From c657e79cc417bf20110edaf844932ffc4fc5a2fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 29 May 2026 18:23:30 +0200 Subject: [PATCH 05/12] Rename TSTyche config to tstyche.json for v7 TSTyche v7 changed the default configuration file name from tstyche.config.json to tstyche.json. Without the rename v7 auto-renames it at runtime and prints a warning. --- tstyche.config.json => tstyche.json | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tstyche.config.json => tstyche.json (100%) diff --git a/tstyche.config.json b/tstyche.json similarity index 100% rename from tstyche.config.json rename to tstyche.json From 18efd1f5d0e661b959d03beff9b6429f321f7ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 29 May 2026 18:24:26 +0200 Subject: [PATCH 06/12] Run TSTyche from reanimated type:check:tests CI runs type tests only through each package's `yarn type:check` -> `type:check:tests`, so the standalone `type:check:tstyche` script left the .tst.* tests unenforced now that the old harness skips them. Call `type:check:tstyche`, scoped to the package, from `type:check:tests` so CI keeps checking them. Scoped by package path to avoid cross-package build coupling; worklets stays unwired until it has its first .tst.* test. --- packages/react-native-reanimated/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-reanimated/package.json b/packages/react-native-reanimated/package.json index a7f77a67aa6f..740783b7028f 100644 --- a/packages/react-native-reanimated/package.json +++ b/packages/react-native-reanimated/package.json @@ -30,7 +30,7 @@ "type:check:src:native": "yarn tsc --noEmit", "type:check:src:web": "yarn tsc --noEmit --project tsconfig.web.json", "type:check:app": "yarn workspace common-app type:check", - "type:check:tests": "../../scripts/test-ts.sh __typetests__", + "type:check:tests": "../../scripts/test-ts.sh __typetests__ && yarn run --top-level type:check:tstyche packages/react-native-reanimated/__typetests__", "type:check:strict": "yarn type:check:strict:src && yarn type:check:strict:app", "type:check:strict:src": "yarn tsc --noEmit --customConditions react-native-strict-api", "type:check:strict:app": "yarn workspace common-app type:check:strict", From b3f6c7de873a545b1d2797b17fdc955fc556842f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 29 May 2026 20:37:36 +0200 Subject: [PATCH 07/12] Migrate value hooks and misc type tests to TSTyche - useSharedValue: read/write/compound-assignment/set/get/modify compile-checks. - useDerivedValue: readonly `.value` via a message-matched `@ts-expect-error`; the deprecated `.set` stays a compile-check. - misc: makeMutable/isSharedValue/interpolateColor/Easing callability, Keyframe constructability, and the adapter rejection on useAnimatedStyle. --- .../__typetests__/miscTest.tst.ts | 97 ++++++++++++ .../__typetests__/miscTest.tsx | 67 --------- .../__typetests__/useDerivedValueTest.tst.ts | 21 +++ .../__typetests__/useDerivedValueTest.tsx | 29 ---- .../__typetests__/useSharedValueTest.tst.ts | 52 +++++++ .../__typetests__/useSharedValueTest.tsx | 139 ------------------ 6 files changed, 170 insertions(+), 235 deletions(-) create mode 100644 packages/react-native-reanimated/__typetests__/miscTest.tst.ts delete mode 100644 packages/react-native-reanimated/__typetests__/miscTest.tsx create mode 100644 packages/react-native-reanimated/__typetests__/useDerivedValueTest.tst.ts delete mode 100644 packages/react-native-reanimated/__typetests__/useDerivedValueTest.tsx create mode 100644 packages/react-native-reanimated/__typetests__/useSharedValueTest.tst.ts delete mode 100644 packages/react-native-reanimated/__typetests__/useSharedValueTest.tsx 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..1dc04ddd2cd3 --- /dev/null +++ b/packages/react-native-reanimated/__typetests__/useDerivedValueTest.tst.ts @@ -0,0 +1,21 @@ +import { describe, test } from 'tstyche'; + +import { useDerivedValue, useSharedValue } from '..'; + +describe('useDerivedValue', () => { + test('the returned value is readonly', () => { + const progress = useSharedValue(0); + const width = useDerivedValue(() => progress.value * 250); + // @ts-expect-error Cannot assign to 'value' because it is a read-only property + width.value = 100; + }); + + 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 ( -