Skip to content

Convert to functional component#150

Open
thomasttvo wants to merge 11 commits intothomas/strip-featuresfrom
thomas/functional
Open

Convert to functional component#150
thomasttvo wants to merge 11 commits intothomas/strip-featuresfrom
thomas/functional

Conversation

@thomasttvo
Copy link
Copy Markdown
Collaborator

@thomasttvo thomasttvo commented Dec 23, 2025

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 ReactNativeZoomableView from a class to a hooks-based functional component while keeping behavior and public APIs intact.

  • Replaces class state/fields with useState/useRef and lifecycle with useLayoutEffect/useEffect; wires PanResponder handlers via refs
  • Exposes the same imperative API (zoomTo, zoomBy, moveTo, moveBy, moveStaticPinTo, gestureStarted) via useImperativeHandle
  • Centralizes prop defaults with lodash.defaults; uses useLatestCallback and a debounced onStaticPinPositionChange
  • Maintains pan/zoom animations and gesture logic; minor tweaks to tap/long-press handling and measurement interval
  • Helper calcGestureCenterPoint/calcGestureTouchDistance now return undefined instead of null for invalid states

Written by Cursor Bugbot for commit e1eae6a. Configure here.

thomasttvo and others added 5 commits December 22, 2025 11:39
<!-- 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(
Copy link
Copy Markdown
Collaborator Author

@thomasttvo thomasttvo Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Copy Markdown
Collaborator Author

@thomasttvo thomasttvo Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this if block is totally redundant - my bad, react is capable of checking individual state variables for updates

Copy link
Copy Markdown
Collaborator

@elliottkember elliottkember left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

thomasvo and others added 2 commits December 24, 2025 10:55
<!-- 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>
@thomasttvo thomasttvo added the enhancement New feature or request label Jan 2, 2026
@thomasttvo
Copy link
Copy Markdown
Collaborator Author

@claude please review this PR

@thomasttvo
Copy link
Copy Markdown
Collaborator Author

@claude review

thomasttvo and others added 3 commits April 13, 2026 15:18
## 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>();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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

  1. Component mounts → first render executes → line 1085 renders <View {...undefined} /> (no handlers)
  2. useLayoutEffect(..., []) fires synchronously after the commit phase and sets gestureHandlers.current = PanResponder.create(...)
  3. However, mutating a ref does NOT schedule a re-render — React has no knowledge of the mutation
  4. The only subsequent re-render is triggered by measureZoomSubject (called from useEffect and onLayout), which itself is deferred through requestAnimationFrame + setTimeout before calling setOriginalWidth/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 useMemo to create the PanResponder instance once: const gestureHandlers = useMemo(() => PanResponder.create({...}), [])
  • Or use useState with 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

  1. const gestureHandlers = useRef<PanResponderInstance>()gestureHandlers.current === undefined
  2. React renders JSX: <View {...gestureHandlers.current?.panHandlers} />
    → evaluates to <View {...undefined} />
    → evaluates to <View /> (no gesture props)
  3. Native view is registered with no touch handlers
  4. useLayoutEffect runs: gestureHandlers.current = PanResponder.create(...)
    → ref updated, but NO re-render scheduled
  5. User touches the screen within the first 2+ frames → no handler fires → gesture lost
  6. Eventually measureZoomSubject resolves (if on-screen) → setOriginalWidth(...) → re-render
  7. Re-render at line 1085: {...gestureHandlers.current?.panHandlers} now spreads real handlers
  8. From this point forward, gestures work — but only after the async delay

Comment on lines +130 to 155
_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),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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

  1. Component mounts with props.onPanResponderTerminationRequest = undefined (not provided by parent).
  2. useLayoutEffect runs once; the closure captures props where onPanResponderTerminationRequest === undefined; the stored handler will return \!\!undefined === false.
  3. Parent re-renders and passes onPanResponderTerminationRequest: () => true.
  4. Functional component re-renders; props local variable is updated. But gestureHandlers.current still holds the PanResponder from step 2.
  5. User triggers a termination request. React Native calls the handler from step 2 — which still reads the first-render props — and returns false, silently blocking termination even though the parent now wants to allow it.

Comment on lines +49 to +52
const ReactNativeZoomableView: ForwardRefRenderFunction<
ReactNativeZoomableView,
ReactNativeZoomableViewProps
> = (props, ref) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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

  1. Consumer code: const zoomRef = useRef(null); .
  2. 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.
  3. Inside the function body, ref (second parameter) is undefined.
  4. 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.
  5. After mount, zoomRef.current === null.
  6. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants