From 8ef2635a198b8a915ead0721e72e640e30225007 Mon Sep 17 00:00:00 2001 From: gre Date: Wed, 8 Apr 2026 17:15:51 +0200 Subject: [PATCH 1/7] fix: build example APK with new architecture enabled RN 0.84 doesn't reliably support old arch. The gradle.properties keeps newArchEnabled=false for Detox E2E compat, so override it with -PnewArchEnabled=true only for the CI APK artifact. Together with the react-native.config.js fix (#620), this should resolve the PlatformConstants TurboModuleRegistry crash on Android. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/build-apk.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 592c9f7..a612fde 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -54,7 +54,7 @@ jobs: - name: Build release APK working-directory: example/android - run: ./gradlew assembleRelease --no-daemon --stacktrace + run: ./gradlew assembleRelease --no-daemon --stacktrace -PnewArchEnabled=true - name: Upload APK uses: actions/upload-artifact@v4 From c32b536293054f654bd07b9d36574f9f1903e7e1 Mon Sep 17 00:00:00 2001 From: gre Date: Wed, 8 Apr 2026 17:44:31 +0200 Subject: [PATCH 2/7] fix: rename react-native.config.js to .cjs for ESM package compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package has "type": "module" in package.json, so .js files are treated as ESM. The @react-native-community/cli uses cosmiconfigSync (require()) to load the config synchronously during Android/Gradle builds, which cannot load ESM modules — it silently falls back to an empty config, causing RNViewShotPackage to be excluded from autolinking entirely. Renaming to .cjs forces CJS semantics regardless of the "type" field, so cosmiconfigSync can load it correctly via require(). Co-Authored-By: Claude Sonnet 4.6 --- react-native.config.js => react-native.config.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename react-native.config.js => react-native.config.cjs (95%) diff --git a/react-native.config.js b/react-native.config.cjs similarity index 95% rename from react-native.config.js rename to react-native.config.cjs index a7453d8..5407de2 100644 --- a/react-native.config.js +++ b/react-native.config.cjs @@ -1,4 +1,4 @@ -export default { +module.exports = { dependency: { platforms: { ios: {}, From ca451bc177af0df4657958cde6379dc7c9f9e219 Mon Sep 17 00:00:00 2001 From: gre Date: Fri, 10 Apr 2026 19:18:03 +0200 Subject: [PATCH 3/7] fix: remove unused Platform import from NativeRNViewShot.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform was imported but never used. This dead import forced eager evaluation of Platform.android.js → NativePlatformConstantsAndroid.js → TurboModuleRegistry.getEnforcing('PlatformConstants') at module load time, which could be problematic in some initialization contexts. Co-Authored-By: Claude Sonnet 4.6 --- src/specs/NativeRNViewShot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specs/NativeRNViewShot.ts b/src/specs/NativeRNViewShot.ts index 8744906..fb2607e 100644 --- a/src/specs/NativeRNViewShot.ts +++ b/src/specs/NativeRNViewShot.ts @@ -1,5 +1,5 @@ import type {TurboModule} from "react-native"; -import {TurboModuleRegistry, NativeModules, Platform} from "react-native"; +import {TurboModuleRegistry, NativeModules} from "react-native"; import {Int32, WithDefault} from "react-native/Libraries/Types/CodegenTypes"; export interface Spec extends TurboModule { From c14d8638be27654f252169f8bed4afd3d0635c5e Mon Sep 17 00:00:00 2001 From: gre Date: Sat, 11 Apr 2026 13:40:37 +0200 Subject: [PATCH 4/7] fix: force eager loading of View to prevent PlatformConstants crash on new arch Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In React Native new arch on Android, class components can render on the mqt_v_native thread. Metro's lazy require system defers module evaluation until first access — so `` in ViewShot.render() triggers the View lazy getter on that thread, which cascades through NativePlatformConstantsAndroid calling TurboModuleRegistry.getEnforcing('PlatformConstants'), crashing. Accessing `void View` at module level forces the lazy require chain to run eagerly on the main JS thread during module initialization, so by the time render() is called on mqt_v_native, View is already fully loaded. Co-Authored-By: Claude Sonnet 4.6 --- src/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/index.tsx b/src/index.tsx index 7d8ccc5..0edafd9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -12,6 +12,12 @@ import RNViewShot from "./RNViewShot"; // Global type for React Native's __DEV__ variable declare const __DEV__: boolean; +// Force eager loading of View on the main JS thread. Without this, Metro's lazy +// require system defers loading View until first render, which may happen on the +// mqt_v_native thread in new arch — triggering NativePlatformConstantsAndroid → +// TurboModuleRegistry.getEnforcing('PlatformConstants') which crashes on that thread. +void View; + const neverEndingPromise = new Promise(() => {}); // Options for capture configuration From 98daee605c4fa9f273812a6f03ca1d7694264745 Mon Sep 17 00:00:00 2001 From: gre Date: Sat, 11 Apr 2026 14:16:13 +0200 Subject: [PATCH 5/7] Revert "fix: force eager loading of View to prevent PlatformConstants crash on new arch Android" This reverts commit c14d8638be27654f252169f8bed4afd3d0635c5e. --- src/index.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 0edafd9..7d8ccc5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -12,12 +12,6 @@ import RNViewShot from "./RNViewShot"; // Global type for React Native's __DEV__ variable declare const __DEV__: boolean; -// Force eager loading of View on the main JS thread. Without this, Metro's lazy -// require system defers loading View until first render, which may happen on the -// mqt_v_native thread in new arch — triggering NativePlatformConstantsAndroid → -// TurboModuleRegistry.getEnforcing('PlatformConstants') which crashes on that thread. -void View; - const neverEndingPromise = new Promise(() => {}); // Options for capture configuration From b7805c660a55ebec7f85ce1f07777d5e15b11e7b Mon Sep 17 00:00:00 2001 From: gre Date: Sat, 11 Apr 2026 14:47:11 +0200 Subject: [PATCH 6/7] refactor: convert ViewShot class component to functional component In React Native new arch on Android, class components can trigger lazy loading of react-native's View module on a thread where PlatformConstants is not accessible, causing a crash: TurboModuleRegistry.getEnforcing( 'PlatformConstants') could not be found. Functional components render on the main JS thread where TurboModules are fully accessible. Converting ViewShot to use forwardRef + hooks avoids the problematic lazy require chain entirely. - Exposes capture() via useImperativeHandle (ref API unchanged) - Preserves static ViewShot.captureRef and ViewShot.releaseCapture - Exports ViewShotRef type for typed ref usage - Replicates componentDidMount/Update/WillUnmount semantics with effects Co-Authored-By: Claude Sonnet 4.6 --- src/index.tsx | 236 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 142 insertions(+), 94 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 7d8ccc5..db769b7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,13 @@ -import React, {Component, ReactNode, RefObject} from "react"; +import React, { + ReactNode, + RefObject, + useRef, + useCallback, + useEffect, + useMemo, + useImperativeHandle, + forwardRef, +} from "react"; import { View, Platform, @@ -262,115 +271,154 @@ function checkCompatibleProps(props: ViewShotProperties): void { } } -export default class ViewShot extends Component { - static captureRef = captureRef; - static releaseCapture = releaseCapture; - - root: any = null; - _raf: number | null = null; - lastCapturedURI: string | null = null; +/** + * Ref handle exposed by ViewShot component. + */ +export type ViewShotRef = { + capture: () => Promise; +}; - resolveFirstLayout: ((layout: any) => void) | null = null; - firstLayoutPromise: Promise = new Promise(resolve => { - this.resolveFirstLayout = resolve; - }); +const ViewShotComponent = forwardRef( + function ViewShot(props, ref) { + const { + children, + options, + captureMode, + onCapture, + onCaptureFailure, + onLayout, + style, + } = props; - capture = (): Promise => - this.firstLayoutPromise - .then(() => { - const {root} = this; - if (!root) return neverEndingPromise as Promise; // component is unmounted, you never want to hear back from the promise - return captureRef(root, this.props.options); - }) - .then( - uri => { - this.onCapture(uri); - return uri; - }, - e => { - this.onCaptureFailure(e); - throw e; - }, - ) as Promise; + const rootRef = useRef(null); + const rafRef = useRef(null); + const lastCapturedURIRef = useRef(null); + const resolveFirstLayoutRef = useRef<((layout: any) => void) | null>(null); - onCapture = (uri: string): void => { - if (!this.root) return; - if (this.lastCapturedURI) { - // schedule releasing the previous capture - setTimeout(releaseCapture, 500, this.lastCapturedURI); - } - this.lastCapturedURI = uri; - const {onCapture} = this.props; - if (onCapture) onCapture(uri); - }; + const firstLayoutPromise = useMemo( + () => + new Promise(resolve => { + resolveFirstLayoutRef.current = resolve; + }), + [], + ); - onCaptureFailure = (e: Error): void => { - if (!this.root) return; - const {onCaptureFailure} = this.props; - if (onCaptureFailure) onCaptureFailure(e); - }; + // Keep latest props in refs so stable callbacks always see fresh values + const onCaptureRef = useRef(onCapture); + onCaptureRef.current = onCapture; + const onCaptureFailureRef = useRef(onCaptureFailure); + onCaptureFailureRef.current = onCaptureFailure; + const optionsRef = useRef(options); + optionsRef.current = options; - syncCaptureLoop = (captureMode: string | null | undefined): void => { - cancelAnimationFrame(this._raf as number); - if (captureMode === "continuous") { - let previousCaptureURI: string | null = "-"; // needs to capture at least once at first, so we use "-" arbitrary string - const loop = (): void => { - this._raf = requestAnimationFrame(loop); - if (previousCaptureURI === this.lastCapturedURI) return; // previous capture has not finished, don't capture yet - previousCaptureURI = this.lastCapturedURI; - this.capture(); - }; - this._raf = requestAnimationFrame(loop); - } - }; + const capture = useCallback( + (): Promise => + firstLayoutPromise + .then(() => { + if (!rootRef.current) return neverEndingPromise as Promise; + return captureRef(rootRef.current, optionsRef.current); + }) + .then( + uri => { + if (!rootRef.current) return uri; + if (lastCapturedURIRef.current) { + setTimeout(releaseCapture, 500, lastCapturedURIRef.current); + } + lastCapturedURIRef.current = uri; + if (onCaptureRef.current) onCaptureRef.current(uri); + return uri; + }, + e => { + if (!rootRef.current) throw e; + if (onCaptureFailureRef.current) onCaptureFailureRef.current(e); + throw e; + }, + ) as Promise, + [firstLayoutPromise], + ); - onRef = (ref: any): void => { - this.root = ref; - }; + useImperativeHandle(ref, () => ({capture}), [capture]); - onLayout = (e: LayoutChangeEvent): void => { - const {onLayout} = this.props; - if (this.resolveFirstLayout) { - this.resolveFirstLayout(e.nativeEvent.layout); - } - if (onLayout) onLayout(e); - }; + const syncCaptureLoop = useCallback( + (mode: string | null | undefined): void => { + cancelAnimationFrame(rafRef.current as number); + if (mode === "continuous") { + let previousCaptureURI: string | null = "-"; + const loop = (): void => { + rafRef.current = requestAnimationFrame(loop); + if (previousCaptureURI === lastCapturedURIRef.current) return; + previousCaptureURI = lastCapturedURIRef.current; + capture(); + }; + rafRef.current = requestAnimationFrame(loop); + } + }, + [capture], + ); - componentDidMount(): void { - if (__DEV__) checkCompatibleProps(this.props); - if (this.props.captureMode === "mount") { - this.capture(); - } else { - this.syncCaptureLoop(this.props.captureMode); - } - } + // Mount + unmount lifecycle (equivalent to componentDidMount / componentWillUnmount) + useEffect(() => { + if (__DEV__) checkCompatibleProps(props); + if (captureMode === "mount") { + capture(); + } else { + syncCaptureLoop(captureMode); + } + return () => syncCaptureLoop(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - componentDidUpdate(prevProps: ViewShotProperties): void { - if (this.props.captureMode !== undefined) { - if (this.props.captureMode !== prevProps.captureMode) { - this.syncCaptureLoop(this.props.captureMode); + // Update lifecycle (equivalent to componentDidUpdate) + const prevCaptureModeRef = useRef(captureMode); + const isFirstRender = useRef(true); + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + prevCaptureModeRef.current = captureMode; + return; } - } - if (this.props.captureMode === "update") { - this.capture(); - } - } + if ( + captureMode !== undefined && + captureMode !== prevCaptureModeRef.current + ) { + syncCaptureLoop(captureMode); + } + prevCaptureModeRef.current = captureMode; + if (captureMode === "update") { + capture(); + } + }); - componentWillUnmount(): void { - this.syncCaptureLoop(null); - } + const onLayoutHandler = useCallback( + (e: LayoutChangeEvent): void => { + if (resolveFirstLayoutRef.current) { + resolveFirstLayoutRef.current(e.nativeEvent.layout); + resolveFirstLayoutRef.current = null; + } + if (onLayout) onLayout(e); + }, + [onLayout], + ); - render(): ReactNode { - const {children} = this.props; return ( {children} ); - } -} + }, +); + +// Attach static methods for backwards compatibility (ViewShot.captureRef, ViewShot.releaseCapture) +const ViewShot = ViewShotComponent as typeof ViewShotComponent & { + captureRef: typeof captureRef; + releaseCapture: typeof releaseCapture; +}; +ViewShot.captureRef = captureRef; +ViewShot.releaseCapture = releaseCapture; + +export default ViewShot; From 05f2fae1699b1131e3fdf89a08aeb5ee425f17ca Mon Sep 17 00:00:00 2001 From: gre Date: Sat, 11 Apr 2026 19:28:46 +0200 Subject: [PATCH 7/7] fix: set "react-native" field in package.json to src/index.tsx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "react-native" field was set to a Windows platform config object instead of the source entry point string. Metro uses this field to resolve the library's entry point for React Native bundles — with an object value it silently fell back to "main": "lib/index.js" (the compiled output) instead of the TypeScript source. This caused hooks to misbehave in the release bundle (TypeError: Cannot read property 'useRef' of null) because the compiled lib/ output interacts differently with Metro's inline-requires optimization than the raw source. The Windows platform config already lives in react-native.config.cjs where it belongs. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/package.json b/package.json index 798ea6b..6f23883 100644 --- a/package.json +++ b/package.json @@ -5,15 +5,7 @@ "type": "module", "main": "lib/index.js", "types": "lib/index.d.ts", - "react-native": { - "windows": { - "sourceDir": "windows", - "solutionFile": "RNViewShot.sln", - "project": { - "projectFile": "RNViewShot\\RNViewShot.csproj" - } - } - }, + "react-native": "src/index.tsx", "keywords": [ "react-native", "screenshot",