Convert to functional component#150
Convert to functional component#150thomasttvo wants to merge 11 commits intothomas/strip-featuresfrom
Conversation
<!-- CURSOR_SUMMARY --> > [!NOTE] > Enforces import sorting via eslint-plugin-simple-import-sort and updates import order across the codebase. > > - **Tooling/ESLint**: > - Add `eslint-plugin-simple-import-sort` and enable `simple-import-sort/imports` and `simple-import-sort/exports` rules in `package.json`. > - **Codebase**: > - Reorder and group imports in `example/App.tsx`, `src/ReactNativeZoomableView.tsx`, `src/components/StaticPin.tsx`, `src/debugHelper/index.tsx`, `src/helper/index.ts`, `src/animations/index.ts`, `src/index.tsx`, and `src/typings/index.ts`. > - Minor export order adjustments in `src/index.tsx`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b5f576e. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: thomasvo <thomas.vo@openspace.ai>
| this.gestureType = null; | ||
| } | ||
|
|
||
| componentDidUpdate( |
There was a problem hiding this comment.
this function is now split into 3 layout effects, each check for a specific set of changes
| prevState: ReactNativeZoomableViewState | ||
| ) { | ||
| const { zoomEnabled, initialZoom } = this.props; | ||
| if (prevProps.zoomEnabled && !zoomEnabled && initialZoom) { |
There was a problem hiding this comment.
instead of checking prevProps and prevState, we create deps arrays for the layout effects that contain only the changes we're trying to watch. For example the equivalent effect for this block has a deps array that contains only zoomEnabled. initialZoom is assigned to and referenced from a ref because it's not being watched for changes
|
|
||
| useLayoutEffect(() => { | ||
| gestureHandlers.current = PanResponder.create({ | ||
| onStartShouldSetPanResponder: _handleStartShouldSetPanResponder, |
There was a problem hiding this comment.
all functions are wrapped in a useLatestCallback so they don't trigger reevaluation of the effects, keeping the original behavior consistent
| this.state.originalWidth === width && | ||
| this.state.originalHeight === height && | ||
| this.state.originalPageX === pageX && | ||
| this.state.originalPageY === pageY |
There was a problem hiding this comment.
this if block is totally redundant - my bad, react is capable of checking individual state variables for updates
<!-- CURSOR_SUMMARY --> > [!NOTE] > Upgrade to React Native 0.79 with Metro 0.82 and updated tooling, plus import/export reordering and regenerated artifacts. > > - **Build/Tooling**: > - Upgrade to React Native `0.79.7` (Metro `0.82.x`, new CLI/debug middleware, Gradle/codegen updates, Hermes/terser stack). > - Bump Jest/Babel/tooling (e.g., `babel-jest` 29.x), add `eslint-plugin-simple-import-sort`, and raise peer `react-native` to `>=0.79.0`. > - **Codebase tweaks**: > - Reorder/normalize imports and exports across `src/`, `lib/`, and module files to match new lint rules; regenerate sourcemaps. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e105c42. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: thomasvo <thomas.vo@openspace.ai>
|
@claude please review this PR |
|
@claude review |
## Summary - Removes lib/ from git tracking to avoid merge conflicts - Moves build step from pre-commit hook to CI/CD - PR checks now verify builds succeed - GitHub Actions handles build + npm publish on release ## Changes - Remove `bob build` from pre-commit hook - Add lib/ to .gitignore - Update CI workflow to build on PRs - Create release workflow to build and publish to npm - Configure release-it to skip npm publish (GitHub Actions handles it) - Update CONTRIBUTING.md with new process ## Release Process 1. Run `yarn release` locally (creates tag + GitHub release) 2. GitHub Actions automatically builds and publishes to npm Requires `NPM_TOKEN` secret to be set in GitHub repo settings. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Implements CI/CD for builds and releases, and removes built output from the repo. > > - **Checks workflow**: Updates `.github/workflows/lint.yml` to run on `push` (master) and `pull_request`; sets up Node 16 with yarn cache; installs deps; runs TypeScript, ESLint, and `react-native-builder-bob` build > - **Release workflow**: Adds `.github/workflows/release.yml` to build and `npm publish` on GitHub Release creation (uses `NPM_TOKEN`) > - **Changelog config**: Adds `.github/release.yml` with label-based release notes categories/exclusions > - **Repo hygiene**: Adds `lib/` to `.gitignore` and removes committed `lib/commonjs/*` build artifacts > - **Docs**: Updates `CONTRIBUTING.md` and `README.md` with new build, release, and changelog process > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 205b2df. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added an automated Release workflow to publish packages on GitHub release events. * **Chores** * CI now runs on PRs and master pushes, uses Node 20, enables Yarn cache, enforces frozen installs, and includes a build check; added build output to .gitignore and simplified pre-commit hooks; package scripts updated. * **Documentation** * CONTRIBUTING clarified build/release process and publishing requirements. * **Breaking Changes** * Several runtime components, helpers, animation utilities, and TypeScript typings were removed — imports and types will need updating. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: thomasvo <thomas.vo@openspace.ai> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
# Conflicts: # .github/workflows/lint.yml # lib/commonjs/ReactNativeZoomableView.js # lib/commonjs/ReactNativeZoomableView.js.map # lib/commonjs/animations/index.js # lib/commonjs/animations/index.js.map # lib/commonjs/components/AnimatedTouchFeedback.js # lib/commonjs/components/AnimatedTouchFeedback.js.map # lib/commonjs/components/StaticPin.js # lib/commonjs/components/StaticPin.js.map # lib/commonjs/components/index.js # lib/commonjs/components/index.js.map # lib/commonjs/debugHelper/index.js # lib/commonjs/debugHelper/index.js.map # lib/commonjs/helper/calcNewScaledOffsetForZoomCentering.js # lib/commonjs/helper/calcNewScaledOffsetForZoomCentering.js.map # lib/commonjs/helper/coordinateConversion.js # lib/commonjs/helper/coordinateConversion.js.map # lib/commonjs/helper/index.js # lib/commonjs/helper/index.js.map # lib/commonjs/index.js # lib/commonjs/index.js.map # lib/commonjs/typings/index.js # lib/commonjs/typings/index.js.map # lib/module/ReactNativeZoomableView.js # lib/module/ReactNativeZoomableView.js.map # lib/module/animations/index.js # lib/module/animations/index.js.map # lib/module/components/AnimatedTouchFeedback.js # lib/module/components/AnimatedTouchFeedback.js.map # lib/module/components/StaticPin.js # lib/module/components/StaticPin.js.map # lib/module/components/index.js # lib/module/components/index.js.map # lib/module/debugHelper/index.js # lib/module/debugHelper/index.js.map # lib/module/helper/calcNewScaledOffsetForZoomCentering.js # lib/module/helper/calcNewScaledOffsetForZoomCentering.js.map # lib/module/helper/coordinateConversion.js # lib/module/helper/coordinateConversion.js.map # lib/module/helper/index.js # lib/module/helper/index.js.map # lib/module/index.js # lib/module/index.js.map # lib/module/typings/index.js # lib/module/typings/index.js.map # lib/typescript/ReactNativeZoomableView.d.ts # lib/typescript/animations/index.d.ts # lib/typescript/components/AnimatedTouchFeedback.d.ts # lib/typescript/components/StaticPin.d.ts # lib/typescript/components/index.d.ts # lib/typescript/debugHelper/index.d.ts # lib/typescript/helper/calcNewScaledOffsetForZoomCentering.d.ts # lib/typescript/helper/coordinateConversion.d.ts # lib/typescript/helper/index.d.ts # lib/typescript/index.d.ts # lib/typescript/typings/index.d.ts # package.json # src/ReactNativeZoomableView.tsx # src/animations/index.ts # yarn.lock
PR #158 removed checked-in lib/ and added it to .gitignore. These remaining tracked files are now redundant.
| const [stateTouches, setStateTouches] = useState<TouchPoint[]>([]); | ||
|
|
||
| const zoomSubjectWrapperRef = useRef<View>(null); | ||
| const gestureHandlers = useRef<PanResponderInstance>(); |
There was a problem hiding this comment.
🔴 On the first render, the View has no pan handlers attached because gestureHandlers is initialized as useRef<PanResponderInstance>() (undefined) and PanResponder.create() only runs inside useLayoutEffect(..., []) which fires after the commit. Spreading {...gestureHandlers.current?.panHandlers} on line 1085 evaluates to spreading undefined, leaving the View unresponsive to gestures until the next state-triggered re-render. Fix by initializing gestureHandlers.current synchronously before the first render using useMemo or useState, matching the original class component behavior where PanResponder.create() ran in the constructor.
Extended reasoning...
What the bug is and how it manifests
gestureHandlers is declared as useRef<PanResponderInstance>() at line 64 with no initial value — its .current starts as undefined. On the first render, line 1085 spreads {...gestureHandlers.current?.panHandlers}. Because gestureHandlers.current is undefined, the optional chaining evaluates to undefined, and {...undefined} is a no-op in JavaScript — it spreads nothing. The native View is therefore rendered with zero pan/gesture handlers attached.
The specific code path that triggers it
- Component mounts → first render executes → line 1085 renders
<View {...undefined} />(no handlers) useLayoutEffect(..., [])fires synchronously after the commit phase and setsgestureHandlers.current = PanResponder.create(...)- However, mutating a ref does NOT schedule a re-render — React has no knowledge of the mutation
- The only subsequent re-render is triggered by
measureZoomSubject(called fromuseEffectandonLayout), which itself is deferred throughrequestAnimationFrame + setTimeoutbefore callingsetOriginalWidth/etc.
Why existing code doesn't prevent it
useLayoutEffect runs synchronously after DOM mutations but after the render output is committed. This means the ref is always undefined at render time on the first pass. The optional chaining gestureHandlers.current?.panHandlers silently produces undefined rather than throwing, masking the problem.
Impact
There is a window of at minimum 2 animation frames (~32ms at 60fps) after initial paint where any touch/pan/pinch gesture will silently fail. If the component is mounted off-screen (e.g., a tab that hasn't been focused yet), measureZoomSubject hits the early return guard if (\!pageX && \!pageY && \!width && \!height) return and never calls setState, so no re-render occurs and pan handlers are never attached — the component remains permanently unresponsive to gestures.
How to fix it
Initialize gestureHandlers.current synchronously before the first render. The cleanest approaches are:
- Use
useMemoto create thePanResponderinstance once:const gestureHandlers = useMemo(() => PanResponder.create({...}), []) - Or use
useStatewith a lazy initializer
This matches the original class component behavior where PanResponder.create() was called in the constructor, guaranteeing handlers were available on the very first render.
Step-by-step proof
const gestureHandlers = useRef<PanResponderInstance>()→gestureHandlers.current === undefined- React renders JSX:
<View {...gestureHandlers.current?.panHandlers} />
→ evaluates to<View {...undefined} />
→ evaluates to<View />(no gesture props) - Native view is registered with no touch handlers
useLayoutEffectruns:gestureHandlers.current = PanResponder.create(...)
→ ref updated, but NO re-render scheduled- User touches the screen within the first 2+ frames → no handler fires → gesture lost
- Eventually
measureZoomSubjectresolves (if on-screen) →setOriginalWidth(...)→ re-render - Re-render at line 1085:
{...gestureHandlers.current?.panHandlers}now spreads real handlers - From this point forward, gestures work — but only after the async delay
| _handlePanResponderEnd(evt, gestureState); | ||
| props.onPanResponderTerminate?.( | ||
| evt, | ||
| gestureState, | ||
| this._getZoomableViewEventObject() | ||
| _getZoomableViewEventObject() | ||
| ); | ||
| }, | ||
| onPanResponderTerminationRequest: (evt, gestureState) => | ||
| !!this.props.onPanResponderTerminationRequest?.( | ||
| !!props.onPanResponderTerminationRequest?.( | ||
| evt, | ||
| gestureState, | ||
| this._getZoomableViewEventObject() | ||
| _getZoomableViewEventObject() | ||
| ), | ||
| // Defaults to true to prevent parent components, such as React Navigation's tab view, from taking over as responder. | ||
| onShouldBlockNativeResponder: (evt, gestureState) => | ||
| this.props.onShouldBlockNativeResponder?.( | ||
| props.onShouldBlockNativeResponder?.( | ||
| evt, | ||
| gestureState, | ||
| this._getZoomableViewEventObject() | ||
| _getZoomableViewEventObject() | ||
| ) ?? true, | ||
| onStartShouldSetPanResponderCapture: (evt, gestureState) => | ||
| !!this.props.onStartShouldSetPanResponderCapture?.(evt, gestureState), | ||
| !!props.onStartShouldSetPanResponderCapture?.(evt, gestureState), | ||
| onMoveShouldSetPanResponderCapture: (evt, gestureState) => | ||
| !!this.props.onMoveShouldSetPanResponderCapture?.(evt, gestureState), | ||
| !!props.onMoveShouldSetPanResponderCapture?.(evt, gestureState), | ||
| }); | ||
|
|
There was a problem hiding this comment.
🔴 Five inline PanResponder callbacks inside the mount-only useLayoutEffect(fn, []) close over the first-render props object and never update when props change. This means onPanResponderTerminate, onPanResponderTerminationRequest, onShouldBlockNativeResponder, onStartShouldSetPanResponderCapture, and onMoveShouldSetPanResponderCapture will silently invoke stale callback references for the lifetime of the component. The fix is to wrap each of these five handlers with useLatestCallback (or extract them as named callbacks before the effect), matching the pattern already used for the other four PanResponder handlers.
Extended reasoning...
What the bug is and how it manifests
Inside useLayoutEffect(() => { gestureHandlers.current = PanResponder.create({...}) }, []) at lines 117–172 of src/ReactNativeZoomableView.tsx, five handler properties are assigned inline arrow functions that directly reference the local props variable: onPanResponderTerminate (line 131), onPanResponderTerminationRequest (line 138), onShouldBlockNativeResponder (lines 144–149), onStartShouldSetPanResponderCapture (line 150–151), and onMoveShouldSetPanResponderCapture (lines 152–153). Because the useLayoutEffect dependency array is empty ([]), the effect runs exactly once at mount and never re-runs. The closures formed at that moment permanently capture the props binding that existed during the first render.
The specific code path that triggers it
On every subsequent render, React calls the functional component again with potentially new props. The local props variable is re-assigned via defaults({}, props, {...}), but the already-created PanResponder (stored in gestureHandlers.current) still holds references to the inline lambdas from the first render closure. When a user triggers one of these gestures, the stale lambda executes props.onPanResponderTerminate (for example), reading from the original first-render props object, not the current one.
Why existing code does not prevent it
The useLatestCallback hook (defined in src/hooks/useLatestCallback.ts) works by unconditionally setting ref.current = callback on every render and returning a stable wrapper that calls ref.current(...args). This guarantees the wrapper always delegates to the latest version. The four other PanResponder handlers — _handleStartShouldSetPanResponder, _handlePanResponderGrant, _handlePanResponderMove, and _handlePanResponderEnd — are all defined via useLatestCallback and are therefore safe. The five problematic handlers were written as bare inline arrow functions, bypassing this mechanism entirely.
Impact
Concrete failure scenarios: (1) A parent adds or changes its onPanResponderTerminate callback after mount — the old (or missing) callback is invoked. (2) onPanResponderTerminationRequest is not provided at mount but later added — it is never called, permanently returning false (prevents gesture termination). (3) onShouldBlockNativeResponder is not provided at mount but later set — always returns true (the fallback) rather than the new callback's value. (4) Dynamic updates to the two capture handlers have no effect after mount. This is a regression from the original class component, where this.props.xxx always reads the current value at invocation time.
How to fix it
Extract each of the five inline lambdas as named functions wrapped with useLatestCallback — mirroring exactly how the other four handlers are created. Then reference these stable wrappers inside PanResponder.create.
Step-by-step proof
- Component mounts with
props.onPanResponderTerminationRequest = undefined(not provided by parent). useLayoutEffectruns once; the closure capturespropswhereonPanResponderTerminationRequest === undefined; the stored handler will return\!\!undefined === false.- Parent re-renders and passes
onPanResponderTerminationRequest: () => true. - Functional component re-renders;
propslocal variable is updated. ButgestureHandlers.currentstill holds the PanResponder from step 2. - User triggers a termination request. React Native calls the handler from step 2 — which still reads the first-render
props— and returnsfalse, silently blocking termination even though the parent now wants to allow it.
| const ReactNativeZoomableView: ForwardRefRenderFunction< | ||
| ReactNativeZoomableView, | ||
| ReactNativeZoomableViewProps | ||
| > = (props, ref) => { |
There was a problem hiding this comment.
🔴 The component is declared as a ForwardRefRenderFunction and uses useImperativeHandle to expose its public API, but is exported without wrapping in React.forwardRef() — meaning the ref argument is always undefined at runtime, making useImperativeHandle a no-op. All consumers using will find myRef.current === null, silently breaking every ref-based method (zoomTo, zoomBy, moveTo, moveBy, moveStaticPinTo, gestureStarted). Fix by changing the default export to: export default React.forwardRef(ReactNativeZoomableView).
Extended reasoning...
What the bug is and how it manifests
In React, a functional component only receives a ref as its second argument if it is wrapped with React.forwardRef(). Without that wrapper, the second argument is always undefined — regardless of how the function is typed. In this PR, ReactNativeZoomableView is correctly typed as ForwardRefRenderFunction<ReactNativeZoomableView, ReactNativeZoomableViewProps>, and it calls useImperativeHandle(ref, ...) to expose its imperative API. However, neither the default export nor the named export wraps the component with React.forwardRef():
export default ReactNativeZoomableView;
export { ReactNativeZoomableView };
Because of this, ref is undefined inside the function body at runtime, and useImperativeHandle(undefined, ...) is a silent no-op.
The specific code path that triggers it
A consumer attaches a ref: const ref = useRef(); . React tries to forward the ref to the component, but since the component is not wrapped in React.forwardRef(), React does not pass the ref as the second argument. Inside the component body, ref is undefined. The useImperativeHandle(ref, () => ({ zoomTo, zoomBy, moveTo, moveBy, moveStaticPinTo, gestureStarted })) call at the end of the component runs against undefined and attaches nothing. After mount, ref.current remains null.
Why existing code does not prevent it
TypeScript's ForwardRefRenderFunction type only describes the shape of the function — it does not enforce at compile time that the function is passed to React.forwardRef(). The type annotation is a developer hint, not a runtime guarantee. The lack of forwardRef() wrapping is invisible to TypeScript and produces no compile-time or runtime error; it simply silently drops the ref.
What the impact would be
Every caller that holds a ref to ReactNativeZoomableView will find ref.current === null after mount. All six imperative methods exposed via useImperativeHandle (zoomTo, zoomBy, moveTo, moveBy, moveStaticPinTo, and the gestureStarted getter) become inaccessible. Any code calling ref.current.zoomTo(...) will throw 'Cannot read properties of null'. This is a complete, silent regression of the entire ref/imperative API introduced in this PR, and it affects all consumers of the library who depend on programmatic zoom or pan control.
Step-by-step proof
- Consumer code: const zoomRef = useRef(null); .
- React renders the component. Because ReactNativeZoomableView is not wrapped in React.forwardRef(), React ignores the ref prop and does NOT pass it as the second argument to the render function.
- Inside the function body, ref (second parameter) is undefined.
- useImperativeHandle(undefined, () => ({ zoomTo, ... })) runs — React checks if ref is a valid ref object; it is not, so the callback is never used and nothing is attached.
- After mount, zoomRef.current === null.
- Calling zoomRef.current.zoomTo(2) throws: TypeError: Cannot read properties of null (reading 'zoomTo').
How to fix it
Change the exports from:
export default ReactNativeZoomableView;
export { ReactNativeZoomableView };
To:
export default React.forwardRef(ReactNativeZoomableView);
export { ReactNativeZoomableView };
The named export can remain as-is (for consumers who wrap it themselves or use it as a type), but the default export — which is what most consumers will import — must go through React.forwardRef() for refs to work correctly.
This PR aims to perform this conversion while minimizing the diff as much as possible. This means we're sacrificing code styling in some areas to prioritize keeping the diff small.
The end goal of this PR is to set the stage for the ultimate conversion to
reanimated. There's a high chance the changes here are not released, but only served as a transitional stepping stone.Tested and verified pinch/pan/staticPin behaviors to work fine.
Note
Refactors
ReactNativeZoomableViewfrom a class to a hooks-based functional component while keeping behavior and public APIs intact.useState/useRefand lifecycle withuseLayoutEffect/useEffect; wiresPanResponderhandlers via refszoomTo,zoomBy,moveTo,moveBy,moveStaticPinTo,gestureStarted) viauseImperativeHandlelodash.defaults; usesuseLatestCallbackand a debouncedonStaticPinPositionChangecalcGestureCenterPoint/calcGestureTouchDistancenow returnundefinedinstead ofnullfor invalid statesWritten by Cursor Bugbot for commit e1eae6a. Configure here.