@react-native/compatibility-check
Version:
Check a React Native app's boundary between JS and Native for incompatibilities
246 lines (189 loc) • 7.08 kB
Markdown
# **React Native compatibility-check**
Status: Experimental (stage 1)
Work In Progress. Documentation is lacking, and intended to be used by power
users at this point.
This tool enables checking the boundary between JavaScript and Native for
backwards incompatible changes to protect against crashes.
This is useful for:
- Local Development
- Over the Air updates on platforms that support it
- Theoretically: Server Components with React Native
## **Motivating Problems**
Let’s look at some motivating examples for this project.
> [!NOTE]
> The examples below are written with Flow, but the compatibility-check
> tool is agnostic to the types you write. The compatibility-check runs on JSON
> schema files, most commonly generated by the
> [@react-native/codegen](https://www.npmjs.com/package/@react-native/codegen)
> tool which supports both TypeScript and Flow.
### **Adding new methods**
You might have an Analytics Native Module in your app, and you last built the
native client a couple of days ago:
```javascript
export interface Spec extends TurboModule {
log: (eventName: string, content: string) => void;
}
```
And you are working on a change to add a new method to this Native Module:
```javascript
export interface Spec extends TurboModule {
log: (eventName: string, content: string) => void;
logError: (message: string) => void;
}
```
```
NativeAnalytics.logError('Oh No! We hit a crash')
```
Since you are working on this, you’ve built a new native client and tested the
change on your computer and everything works.
However, when your colleague pulls your latest changes and tries to run it,
they’ll get a crash `logError is not a function`. They need to rebuild their
native client\!
Using this tool, you can detect this incompatibility at build time, getting an
error that looks like:
```
NativeAnalytics: Object added required properties, which native will not provide
-- logError
```
Errors like this can occur for much more nuanced reasons than adding a method.
For example:
### **Sending native new union values**
```javascript
export interface Spec extends TurboModule {
// You add 'system' to this union
+setColorScheme: (color: 'light' | 'dark') => void;
}
```
If you add a new option of `system` and add native support for that option, when
you call this method with `system` on your commit it would work but on an older
build not expecting `system` it will crash. This tool will give you the error
message:
```
ColorManager.setColorScheme parameter 0: Union added items, but native will not expect/support them
-- position 3 system
```
### **Changing an enum value sent from native**
As another example, say you are getting the color scheme from the system as an
integer value, used in JavaScript as an enum:
```javascript
enum TestEnum {
LIGHT = 1,
DARK = 2,
SYSTEM = 3,
}
export interface Spec extends TurboModule {
getColorScheme: () => TestEnum;
}
```
And you realize you actually need native to send `-1` for System instead of 3\.
```javascript
enum TestEnum {
LIGHT = 1,
DARK = 2,
SYSTEM = -1,
}
```
If you make this change and run the JavaScript on an old build, it might still
send JavaScript the value 3, which your JavaScript isn’t handling anymore\!
This tool gives an error:
```javascript
ColorManager: Object contained a property with a type mismatch
-- getColorScheme: has conflicting type changes
--new: ()=>Enum<number>
--old: ()=>Enum<number>
Function return types do not match
--new: ()=>Enum<number>
--old: ()=>Enum<number>
Enum types do not match
--new: Enum<number> {LIGHT = 1, DARK = 2, SYSTEM = -1}
--old: Enum<number> {LIGHT = 1, DARK = 2, SYSTEM = 3}
Enum contained a member with a type mismatch
-- Member SYSTEM: has conflicting changes
--new: -1
--old: 3
Numeric literals are not equal
--new: -1
--old: 3
```
## **Avoiding Breaking Changes**
You can use this tool to either detect changes locally to warn that you need to
install a new native build, or when doing OTA you might need to guarantee that
the changes in your PR are compatible with the native client they’ll be running
in.
### **Example 1**
In example 1, when adding logError, it needs to be optional to be safe:
```javascript
export interface Spec extends TurboModule {
log: (eventName: string, content: string) => void;
logError?: (message: string) => void;
}
```
That will enforce if you are using TypeScript or Flow that you check if the
native client supports logError before calling it:
```javascript
if (NativeAnalytics.logError) {
NativeAnalytics.logError('Oh No! We hit a crash');
}
```
### **Example 2**
When you want to add '`system'` as a value to the union, modifying the existing
union is not safe. You would need to add a new optional method that has that
change. You can clean up the old method when you know that all of the builds you
ever want to run this JavaScript on have native support.
```javascript
export interface Spec extends TurboModule {
+setColorScheme: (color: 'light' | 'dark') => void
+setColorSchemeWithSystem?: (color: 'light' | 'dark' | 'system') => void
}
```
### **Example 3**
Changing a union case is similar to Example 2, you would either need a new
method, or support the existing value and the new `-1`.
```
enum TestEnum {
LIGHT = 1,
DARK = 2,
SYSTEM = 3,
SYSTEM_ALSO = -1,
}
```
## **Installation**
```
yarn add @react-native/compatibility-check
```
## **Usage**
To use this package, you’ll need a script that works something like this:
This script checks the compatibility of a React Native app's schema between two
versions. It takes into account the changes made to the schema and determines
whether they are compatible or not.
```javascript
import {compareSchemas} from '@react-native/compatibility-check';
const util = require('util');
async function run(argv: Argv, STDERR: string) {
const debug = (log: mixed) => {
argv.debug &&
console.info(util.inspect(log, {showHidden: false, depth: null}));
};
const currentSchema =
JSON.parse(/*you'll read the file generated by codegen wherever it is in your app*/);
const previousSchema =
JSON.parse(/*you'll read the schema file that you persisted from when your native app was built*/);
const safetyResult = compareSchemas(currentSchema, previousSchema);
const summary = safetyResult.getSummary();
switch (summary.status) {
case 'ok':
debug('No changes in boundary');
console.log(JSON.stringify(summary));
break;
case 'patchable':
debug('Changes in boundary, but are compatible');
debug(result.getDebugInfo());
console.log(JSON.stringify(summary));
break;
default:
debug(result.getDebugInfo());
console.error(JSON.stringify(result.getErrors()));
throw new Error(`Incompatible changes in boundary`);
}
}
```