# Contributing ## Library Development To develop react-native-skia, you can build the skia libraries on your computer. Alternatively, you can use the pre-built binaries. ### Using pre-built binaries The Skia prebuilt binaries are installed as npm dependencies (`react-native-skia-android`, `react-native-skia-apple-*`). The native build systems (Gradle, CocoaPods) automatically resolve these packages — there is no `postinstall` step. - Checkout submodules: `git submodule update --init --recursive` - Install dependencies: `yarn` - Set up the standard build: `cd packages/skia && yarn install-skia` `yarn install-skia` copies the Skia headers needed to compile against the prebuilt binaries. The binaries themselves are not copied: Gradle reads them in place from `node_modules`, and the podspec copies them in at `pod install` time. #### Switching between the standard and Graphite builds - Standard (Ganesh) build: `yarn install-skia` - [Graphite](https://skia.org/docs/user/graphite/) build: `yarn install-skia-graphite` (downloads the Graphite binaries into `libs/` and writes a `libs/.graphite` marker) Run `yarn install-skia` to switch back from Graphite to the standard build (it removes the `libs/.graphite` marker). After switching, run `pod install` again in the example app so CocoaPods picks up the matching frameworks. ### Building If you have Android Studio installed, make sure `$ANDROID_NDK` is available. `ANDROID_NDK=/Users/username/Library/Android/sdk/ndk/` for instance. If the NDK is not installed, you can install it via Android Studio by going to the menu _File > Project Structure_. And then the _SDK Location_ section. It will show you the NDK path, or the option to Download it if you don't have it installed. - Checkout submodules: `git submodule update --init --recursive` - Install dependencies: `yarn` - Go to the package folder: `cd packages/skia` - Build the Skia libraries: `yarn build-skia` (this can take a while) - Copy Skia headers: `yarn copy-skia-headers` ### Upgrading Skia Upgrading to a new Skia milestone (for example `chrome/m147` to `chrome/m150`) is a multi-stage process: bump the submodule, build locally and fix the C++ API churn, test the example app against the freshly built binaries, publish the prebuilt binaries from CI, and finally release them through the binaries repo. The steps below use `m150` as the running example; substitute the milestone you are upgrading to. #### 1. Update the Skia submodule 1. In `.gitmodules`, change the `externals/skia` submodule `branch` from `chrome/m147` to `chrome/m150`. 2. Fetch and checkout the new tip: ```sh cd externals/skia git fetch origin chrome/m150 git checkout FETCH_HEAD -- cd ../.. ``` Confirm the submodule is on the new tip with `git -C externals/skia rev-parse HEAD`. (Once `.gitmodules` points at the new branch, `git submodule update --recursive --remote` also moves it.) #### 2. Build Skia and fix the C++ API churn Make sure `$ANDROID_NDK` and `$ANDROID_HOME` are set (see [Building](#building)). 1. Bootstrap depot_tools once (otherwise `gn gen` fails with `python3_bin_reldir.txt not found`): ```sh cd externals/depot_tools && ./update_depot_tools && cd ../.. ``` 2. Clean and build from `packages/skia`: ```sh cd packages/skia yarn clean-skia yarn build-skia # all platforms, or scope it: yarn build-skia apple-ios android yarn copy-skia-headers # build-skia also runs this at the end ``` `build-skia` runs `tools/git-sync-deps` first, which fetches third-party deps from `*.googlesource.com`. This can fail with HTTP 429 ("Short term server-time rate limit exceeded"). Re-run it with backoff until it succeeds before retrying the build: ```sh cd externals/skia && PATH=../depot_tools/:$PATH python3 tools/git-sync-deps ``` 3. Fix any C++ compilation errors coming from the new headers. Skia's public API churns between milestones (APIs that start returning `std::optional`, `SkPath` becoming `SkPathBuilder`, gradients moving to `SkGradient`/`SkShaders`, new required includes, and so on). The wrapper code in `cpp/**` is what needs updating; the previous Skia bump commit is a good reference for the kinds of changes to expect. - If a vendored Skia source file starts including a header that is not copied yet (for example `src/base/SkAutoLocaleSetter.h`), add it to the copy list in `scripts/skia-configuration.ts` (`copyHeaders`) and re-run `yarn copy-skia-headers`. - The vendored headers under `cpp/skia/**` are generated by `copy-skia-headers` and are gitignored, so only the script and the wrapper sources show up as changes. #### 3. Test the example app locally Important gotcha: in the standard (Ganesh) build the example app links against the prebuilt binaries from the npm packages (`react-native-skia-android`, `react-native-skia-apple-*`), not the libs you just built in `packages/skia/libs/`. Until those packages are republished (steps 4 to 6), the app compiles against the new `m150` headers but links the old binaries, which surfaces as link errors such as `undefined symbol: vtable for SkFontMgr`. Point the local build at your fresh binaries first: - Android: overwrite the static libs in the npm package with the ones you built: ```sh for abi in armeabi-v7a arm64-v8a x86 x86_64; do cp packages/skia/libs/android/$abi/*.a node_modules/react-native-skia-android/libs/$abi/ done ``` - iOS: `pod install` copies the npm package's xcframeworks into `libs/ios`, skipping only when `libs/ios/.version` matches the npm package version. After `yarn build-skia apple-ios` rewrites `libs/ios` with your build, stamp the marker with the current npm version so `pod install` leaves your binaries in place: ```sh printf "$(node -p "require('react-native-skia-apple-ios/package.json').version")" \ > packages/skia/libs/ios/.version cd apps/example/ios && pod install && cd - ``` These `node_modules` and `.version` edits are throwaway; `yarn install` restores the published binaries. Then build both platforms: - iOS: ```sh cd apps/example/ios xcodebuild -workspace example.xcworkspace -scheme example -sdk iphonesimulator \ -configuration Debug -destination 'generic/platform=iOS Simulator' \ build CODE_SIGNING_ALLOWED=NO ``` - Android: ```sh cd apps/example/android && ./gradlew :app:assembleDebug ``` `yarn ios` / `yarn android` work too. Run the e2e tests (see [Testing](#testing)) to validate behavior, not just compilation. #### 4. Publish the prebuilt binaries (GitHub Actions) With the submodule bump merged (the workflows detect the Skia branch from the checked-in submodule), build and upload the prebuilt binaries from the Actions tab. Both workflows are `workflow_dispatch` only and share two inputs: - `tag_suffix`: appended to the tag (for example `a` produces `skia-m150a`) for re-spins of the same milestone. - `dry_run`: build and upload as workflow artifacts only, skipping the GitHub release. Use this to validate the build before cutting a real release. Run them: - Standard (Ganesh): **Build SKIA** (`.github/workflows/build-skia.yml`). Builds apple-ios, apple-tvos, apple-macos and the four Android ABIs, creates a prerelease tagged `skia-m150`, and uploads one tarball per target. - Graphite: **Build SKIA Graphite** (`.github/workflows/build-skia-graphite.yml`, `SK_GRAPHITE=1`). Builds iOS, macOS and Android (no tvOS/maccatalyst), tags `skia-graphite-m150`, and additionally uploads the Graphite headers tarball. It also accepts an optional `skia_branch` input to build a branch other than the submodule default. #### 5. Release the binaries through react-native-skia-binaries The npm packages this library consumes (`react-native-skia-android`, `react-native-skia-apple-ios`, `react-native-skia-apple-macos`, `react-native-skia-apple-tvos`, and the Graphite headers package) are produced from the release tarballs in [wcandillon/react-native-skia-binaries](https://github.com/wcandillon/react-native-skia-binaries). Update that repo to consume the new `skia-m150` and `skia-graphite-m150` release assets, bump the package versions, and publish them to npm. #### 6. Point the library at the new binaries Back in this repo, bump the prebuilt binary versions in `packages/skia/package.json` (`react-native-skia-android` and `react-native-skia-apple-*`) to the versions you just published, run `yarn`, and re-run `pod install` in the example app so it consumes the released binaries. Drop the throwaway `node_modules` and `libs/ios/.version` edits from step 3. ### Publishing - Run the commands in the [Building](#building) section - Build the Android binaries with `yarn build-skia-android` - Build the NPM package with `yarn build-npm` Publish the NPM package manually. The output is found in the `dist` folder. - Install Cocoapods in the example/ios folder `cd example/ios && pod install && cd ..` ### Testing When making contributions to the project, an important part is testing. In the `package` folder, we have several scripts set up to help you maintain the quality of the codebase and test your changes: - `yarn lint` — Lints the code for potential errors and to ensure consistency with our coding standards. - `yarn tsc` — Runs the TypeScript compiler to check for typing issues. - `yarn test` — Executes the unit tests to ensure existing features work as expected after changes. - `yarn e2e` — Runs end-to-end tests. For these tests to run properly, you need to have the example app running. Use `yarn ios` or `yarn android` in the `example` folder and navigate to the Tests screen within the app. ### Running End-to-End Tests To ensure the best reliability, we encourage running end-to-end tests before submitting your changes: 1. Start the example app: ```sh cd example yarn ios # or yarn android for Android testing ``` Once the app is open in your simulator or device, press the "Tests" item at the bottom of the list. 2. With the example app running and the Tests screen open, run the following command in the `package` folder: ```sh yarn e2e ``` This will run through the automated tests and verify that your changes have not introduced any regressions. You can also run a particular using the following command: ```sh E2E=true yarn test -i e2e/Colors ``` ### Writing End-to-End Tests Contributing end-to-end tests to React Native Skia is extremely useful. Below you'll find guidelines for writing tests using the `eval`, `draw`, and `drawOffscreen` commands. e2e tests are located in the `package/__tests__/e2e/` directory. You can create a file there or add a new test to an existing file depending on what is most sensible. When looking to contribute a new test, you can refer to existing tests to see how these can be built. The `eval` command is used to test Skia's imperative API. It requires a pure function that invokes Skia operations and returns a serialized result. ```tsx it("should generate commands properly", async () => { const result = await surface.eval((Skia) => { const path = Skia.Path.Make(); path.lineTo(30, 30); return path.toCmds(); }); expect(result).toEqual([[0, 0, 0], [1, 30, 30]]); }); ``` Both the `eval` and `draw` commands require a function that will be executed in an isolated context, so the functions must be pure (without external dependencies) and serializable. You can use the second parameter to provide extra data to that function. ```tsx it("should generate commands properly", async () => { // Referencing the SVG variable directly in the tests would fail // as the function wouldn't be able to run in an isolated context const svg = "M 0 0, L 30 30"; const result = await surface.eval((Skia, ctx) => { const path = Skia.Path.MakeFromSVGString(ctx.svg); return path.toCmds(); }, { svg }); expect(result).toEqual([[0, 0, 0], [1, 30, 30]]); }); ``` A second option is to use the `draw` command where you can test the Skia components and get the resulting image: ```tsx it("Path with default fillType", async () => { const { Skia } = importSkia(); const path = star(Skia); const img = await surface.draw( <> ); checkImage(image, "snapshots/drawings/path.png"); }); ``` Finally, you can use `drawOffscreen` to receive a canvas object as parameter. You will also get the resulting image: ```tsx it("Should draw cyan", async () => { const image = await surface.drawOffscreen( (Skia, canvas, { size }) => { canvas.drawColor(Skia.Color("cyan")); } ); checkImage(image, "snapshots/cyan.png"); }); ``` Again, since `eval`, `draw`, and `drawOffscreen` serialize the function's content, avoid any external dependencies that can't be serialized. ## Adding a Component to the Scene Graph This guide explains how to add new components to the React Native Skia scene graph system. ### 🎯 Two Types of Components **1. Drawing Commands** (like `Skottie`) - Draw content directly to the canvas - Examples: Skottie, Circle, Rect, Text **2. Context Declarations** (like `ImageFilter`) - Modify the rendering context for child components - Examples: ImageFilter, ColorFilter, MaskFilter, Shader ### 📝 Step-by-Step Implementation #### 1. **Define Component Props Interface** 📁 `src/dom/types/Drawings.ts` ```typescript // Add import for Skia types import { SkImageFilter } from "../../skia/types"; // Define props interface export interface ImageFilterProps extends GroupProps { imageFilter: SkImageFilter; } ``` #### 2. **Add Node Type** 📁 `src/dom/types/NodeType.ts` ```typescript export const enum NodeType { // ... existing types ImageFilter = "skImageFilter", } ``` #### 3. **Create React Component** 📁 `src/renderer/components/ImageFilter.tsx` ```typescript import React from "react"; import type { ImageFilterProps } from "../../dom/types"; import type { SkiaProps } from "../processors"; export const ImageFilter = (props: SkiaProps) => { return ; }; ``` #### 4. **Export Component** 📁 `src/renderer/components/index.ts` ```typescript export * from "./ImageFilter"; ``` #### 5. **Add Property Converter (if needed)** 📁 `cpp/api/recorder/Convertor.h` For components that use complex Skia types (like `SkImageFilter`, `skottie::Animation`, etc.), add a template specialization to convert JSI values to native types: ```cpp template <> sk_sp getPropertyValue(jsi::Runtime &runtime, const jsi::Value &value) { if (value.isObject() && value.asObject(runtime).isHostObject(runtime)) { auto ptr = std::dynamic_pointer_cast( value.asObject(runtime).asHostObject(runtime)); if (ptr != nullptr) { return ptr->getObject(); } } else if (value.isNull()) { return nullptr; } throw std::runtime_error( "Expected JsiSkImageFilter object or null for the imageFilter property."); } ``` #### 6. **Implement C++ Command** 📁 `cpp/api/recorder/ImageFilters.h` ##### For Context Declarations (like ImageFilter) ```cpp struct ImageFilterCmdProps { sk_sp imageFilter; }; class ImageFilterCmd : public Command { private: ImageFilterCmdProps props; public: ImageFilterCmd(jsi::Runtime &runtime, const jsi::Object &object, Variables &variables) : Command(CommandType::PushImageFilter, "skImageFilter") { convertProperty(runtime, object, "imageFilter", props.imageFilter, variables); } void pushImageFilter(DrawingCtx *ctx) { ctx->imageFilters.push_back(props.imageFilter); } }; ``` ##### For Drawing Commands (like Skottie) ```cpp struct SkottieCmdProps { sk_sp animation; float frame; }; class SkottieCmd : public Command { private: SkottieCmdProps props; public: SkottieCmd(jsi::Runtime &runtime, const jsi::Object &object, Variables &variables) : Command(CommandType::DrawSkottie) { convertProperty(runtime, object, "animation", props.animation, variables); convertProperty(runtime, object, "frame", props.frame, variables); } void draw(DrawingCtx *ctx) { props.animation->seekFrame(props.frame); props.animation->render(ctx->canvas); } }; ``` #### 7. **Register in Recorder** 📁 `cpp/api/recorder/RNRecorder.h` ```cpp // Add to appropriate push method void pushImageFilter(jsi::Runtime &runtime, const std::string &nodeType, const jsi::Object &props) { // ... existing registrations } else if (nodeType == "skImageFilter") { commands.push_back( std::make_unique(runtime, props, variables)); } } ``` #### 8. **Add Execution Logic** 📁 `cpp/api/recorder/RNRecorder.h` ```cpp // In the play method's switch statement case CommandType::PushImageFilter: { auto nodeType = cmd->nodeType; // ... existing cases } else if (nodeType == "skImageFilter") { auto *imageFilterCmd = static_cast(cmd.get()); imageFilterCmd->pushImageFilter(ctx); } break; } ``` #### 9. **Update Node Classification (if needed)** 📁 `src/sksg/Node.ts` For new general component types (like `ImageFilter`, `ColorFilter`, etc.), add them to the appropriate classification function: ```typescript // For context declarations like ImageFilter export const isImageFilter = (type: NodeType) => { "worklet"; return ( type === NodeType.ImageFilter || // Add your new general type here type === NodeType.OffsetImageFilter || // ... other specific types ); }; ``` #### 10. **Create Tests** 📁 `src/renderer/__tests__/e2e/ImageFilter.spec.tsx` ```typescript import React from "react"; import { checkImage, docPath } from "../../../__tests__/setup"; import { importSkia, surface } from "../setup"; import { ImageFilter, Circle, Group } from "../../components"; import { TileMode } from "../../../skia/types"; describe("ImageFilter", () => { it("Should render ImageFilter component with blur filter", async () => { const { Skia } = importSkia(); const blurFilter = Skia.ImageFilter.MakeBlur(10, 10, TileMode.Clamp, null); const img = await surface.draw( ); checkImage(img, docPath("image-filter/blur-filter.png")); }); }); ``` #### 11. **Verify Implementation** ```bash # Check TypeScript compilation yarn tsc --noEmit # Create test image directory mkdir -p apps/docs/static/img/your-component/ # Run tests yarn test src/renderer/__tests__/e2e/YourComponent.spec.tsx ``` This pattern allows you to add both types of components consistently to the React Native Skia scene graph system, maintaining clean separation between React component layer, type definitions, and native C++ implementation.