Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f7ede69
chore(test): add jest + RTL + reanimated mock infra
May 14, 2026
dccd1f8
refactor(NonScalingOverlay): extract computeOverlayTransform + unit t…
May 14, 2026
1062eb4
test(NonScalingOverlay): component tests for EC-NSO-1, 2, 7, 8, 9
May 14, 2026
df86fbc
test(ReactNativeZoomableView): renderOverlay integration tests for EC…
May 14, 2026
2a13abc
refactor(helper): extract calcShiftDelta from _calcOffsetShiftSinceLa…
May 14, 2026
3ea1c10
refactor(helper): extract applyPinchSensitivity for pinch resistance …
May 14, 2026
bebae3c
refactor(helper): extract clampZoom for pinch min/max clamp
May 14, 2026
0129282
refactor(helper): extract shouldSkipShift predicate from _handleShifting
May 14, 2026
2214a41
chore(test): tag canvas gesture with withTestId for jest-utils
May 14, 2026
8fa390d
test(helper): unit tests for pure-helper SPEC items
May 14, 2026
1bcde4f
test(RNZV): props + imperativeHandle + callbacks (SPEC-008/011-018/03…
May 14, 2026
a39d719
test(tree+StaticPin): tree topology + StaticPin styling (SPEC-086, 08…
May 14, 2026
465e8a1
test(RNZV): static-pin + feedback + useLatestWorklet (SPEC-042-051, 0…
May 14, 2026
48f2fa2
test(gestures): tap classification — single/double/long-press (SPEC-0…
May 14, 2026
fb71f67
test(gestures): pan callbacks + onPanResponderMoveWorklet intercept (…
May 14, 2026
dd2672e
test(gestures): pinch + shift + multi-finger (SPEC-014, 058, 064, 088…
May 14, 2026
7af33be
test(e2e): real-RNGH single-tap probe (Phase E)
May 14, 2026
477d2e3
chore(test): hoist RN renderer-shim mock to jest.setup.ts
May 14, 2026
fb798ea
refactor(test): use real RNGH in gestures/singleTap.test.tsx
May 14, 2026
a08c807
refactor(test): use real RNGH in gestures/doubleTap.test.tsx
May 14, 2026
e145ed8
refactor(test): use real RNGH in gestures/longPress.test.tsx
May 14, 2026
61869de
refactor(test): use real RNGH in gestures/pinch.test.tsx
May 14, 2026
3ad9261
refactor(test): use real RNGH in gestures/shift.test.tsx
May 14, 2026
020ba07
refactor(test): use real RNGH in gestures/multiFinger.test.tsx
May 14, 2026
bb8533b
refactor(test): use real RNGH in gestures/callbacks.test.tsx
May 14, 2026
92427fa
refactor(test): use real RNGH in gestures/onPanResponderMoveWorklet.t…
May 14, 2026
26c2205
docs(specs): note real-RNGH gesture-test layer fidelity for contributors
May 14, 2026
3445157
revert: inline helper extractions back into ReactNativeZoomableView
May 14, 2026
93a8874
test(e2e): scenario tests — pinch, pan, simultaneous, event payloads,…
May 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ jobs:
run: yarn run lint
- name: Build
run: yarn build
- name: Run unit tests
run: yarn test --ci --runInBand
72 changes: 71 additions & 1 deletion SPECS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ Behavior contract for `ReactNativeZoomableView` and `StaticPin`. This document d
9. [Static pin](#static-pin)
10. [Tap handling](#tap-handling)
11. [Coordinate system](#coordinate-system)
12. [Migration from PanResponder/Animated](#migration-from-panresponderanimated)
12. [NonScalingOverlay contract](#nonscalingoverlay-contract)
13. [Migration from PanResponder/Animated](#migration-from-panresponderanimated)

---

Expand Down Expand Up @@ -284,6 +285,71 @@ In `zoomTo()` and double-tap, the zoom centre is in subject-relative pixels with

---

## NonScalingOverlay contract

`NonScalingOverlay` is a translate-only overlay that tracks the zoomable view's pan/zoom (and optional rotation) but does **not** scale its children. Use it via the `renderOverlay` prop on `ReactNativeZoomableView` (preferred); it can also be mounted directly with explicit numeric `contentWidth`/`contentHeight`/`wrapperWidth`/`wrapperHeight` plus SharedValue `zoom`/`offsetX`/`offsetY` (and optional `rotation`).

### Props

| Prop | Type | Notes |
|------|------|-------|
| `contentWidth` | `number` | Intrinsic content width in pt. |
| `contentHeight` | `number` | Intrinsic content height in pt. |
| `wrapperWidth` | `number` | Wrapper width from the outer container's `onLayout`. |
| `wrapperHeight` | `number` | Wrapper height from the outer container's `onLayout`. |
| `zoom` | `SharedValue<number>` | Current zoom level. |
| `offsetX` | `SharedValue<number>` | Current pan X. |
| `offsetY` | `SharedValue<number>` | Current pan Y. |
| `rotation` | `SharedValue<number>` (optional) | Rotation in radians. Defaults to 0. |
| `children` | `ReactNode` | Markers to render at 1:1 screen size. |

### Transform formula

The overlay's `Animated.View` applies this 5-element transform, computed on the UI thread per zoom/pan/rotation tick:

```
width = contentWidth * z
height = contentHeight * z
transform = [
{ translateX: wrapperWidth / 2 - (z * contentWidth ) / 2 },
{ translateY: wrapperHeight / 2 - (z * contentHeight) / 2 },
{ rotate: `${rotation}rad` },
{ translateX: z * offsetX },
{ translateY: z * offsetY },
]
```

The same 5-element list is used with and without rotation (rotation defaults to 0; `rotate(0) = I`). Pan offsets occupy `transform[3..4]` and are applied **in the rotated frame** — they must not be folded into `transform[0..1]`, or pan desyncs from the inner zoom layer when rotation is non-zero.

### Static style and prop rules

- `position: 'absolute'`, `top: 0`, `left: 0` — required to defeat the wrapper's `alignItems: 'center', justifyContent: 'center'`. Without these, Yoga centres the absolutely-positioned child before the transform applies, producing a doubled offset.
- `overflow: 'visible'` — child markers self-centre with negative margins; iOS clips subviews to parent bounds by default.
- `pointerEvents="none"` (prop, not style) — markers must not intercept canvas pan/pinch.

### Children pattern

- Position with `left: 'X%' / top: 'Y%'` in content-percentage space.
- Use fixed pt dimensions (e.g. `width: 16, height: 16`).
- Self-centre on anchor via `marginLeft: -size/2, marginTop: -size/2`.
- If rotation may be active, attach per-child counter-rotation via `useAnimatedStyle({ transform: [{ rotate: \`${-rotation.value}rad\` }] })`.

### Mounting rules (in `ReactNativeZoomableView`)

- The overlay is mounted as a **sibling** of `GestureDetector`'s zoom-transformed layer, not under it — both share the wrapper's coordinate frame.
- The overlay appears **before** `StaticPin` in source order, so RN paints the overlay underneath the pin (last sibling renders on top).
- When `contentWidth` or `contentHeight` is missing or zero, the overlay returns `null` — no markers render.
- `renderOverlay` is wired through automatically; the consumer never instantiates `NonScalingOverlay` directly when going through the prop.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SPECS.md documents internal implementation details for NonScalingOverlay

Low Severity

The NonScalingOverlay contract section includes implementation details that violate the SPECS.md authoring rules. Specifically: the "Transform formula" subsection documents the exact 5-element transform array structure with index positions (transform[3..4]), the "Mounting rules" subsection references internal tree topology relative to GestureDetector, and source ordering within ReactNativeZoomableView. These are implementation internals, not consumer-observable behavior.

Fix in Cursor Fix in Web

Triggered by learned rule: SPECS.md must document consumer-observable behavior only

Reviewed by Cursor Bugbot for commit dd2672e. Configure here.


### `wrapperSize` state mirror

`ReactNativeZoomableView` exposes the wrapper's measured dimensions to the overlay via a React state mirror updated in `onLayout`. Two rules:

1. **Reject 0×0 measurements** — never overwrite a real measurement with `{0,0}` (off-screen / unmounted measurement).
2. **Dedup identical sizes** — `setWrapperSize((prev) => prev.w === w && prev.h === h ? prev : {...})` to avoid spurious re-renders of the overlay's marker tree.

---

## Migration from PanResponder/Animated

This major replaces the class-component PanResponder/Animated implementation with the functional/Reanimated/RNGH stack documented above.
Expand Down Expand Up @@ -336,3 +402,7 @@ Tap classification now requires a genuine touch release. The previous stack ran
### Settle-based `onStaticPinPositionChange`

The previous stack fired `onStaticPinPositionChange` via `lodash.debounce` plus explicit synchronous flushes at gesture end / animation completion. The new stack fires once per logical settle event (~100 ms after motion stops) with epsilon-equality dedup. Natural `zoomTo` completion is observed by the same settle path — there is no separate explicit flush.

---

> **Test fidelity note (for contributors).** The gesture-test layer (`src/__tests__/gestures/*.test.tsx` and `src/__tests__/e2e/probe.test.tsx`) runs against the **real `react-native-gesture-handler`** module — actual `Gesture.Manual()` builder, `handlersRegistry`, and `withTestId` resolution — paired with the official `react-native-reanimated/mock`. Touch events are dispatched by invoking `gesture.handlers.onTouchesDown/Move/Up/Cancelled(event, stateManager)` directly, because RNGH 2.20.2's `fireGestureHandler` jest-utils helper does not support `Manual` gestures (the `AllGestures` union in `jest-utils/jestUtils.d.ts` omits `ManualGesture`). The only RN-internal mock is a minimum-surface stub of `react-native/Libraries/Renderer/shims/ReactNative` (in `jest.setup.ts`) that bypasses a `ReactNativeRenderer-dev` jest-env load crash; the renderer is not under test.
3 changes: 3 additions & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
presets: ['module:@react-native/babel-preset'],
};
45 changes: 45 additions & 0 deletions jest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import 'react-native-gesture-handler/jestSetup';

// Reanimated 3 ships an official mock that runs animated styles synchronously.
jest.mock('react-native-reanimated', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-return
return require('react-native-reanimated/mock');
});

// Stub RN's renderer shim so importing RNGH's utils.js doesn't crash on
// `ReactNativeRenderer-dev` evaluation. RNGH's `useViewRefHandler` calls
// `findNodeHandle(ref)` via `RendererImplementation.js`, which lazily
// requires `ReactNativeRenderer-dev.js` and crashes under jest with
// `Cannot read properties of undefined (reading 'S')` (the documented
// Phase A §7a crash). We hand back a stable fake nodeHandle (42) — the
// gesture isn't attached to a real native view, but `attachHandlers`
// still completes and registers the testID. This mock is additive: it
// only intercepts a render path that tests don't otherwise reach.
// Hoisted here per phase E probe §6.1 so real-RNGH tests across the
// suite inherit it without per-file repetition.
jest.mock(
'react-native/Libraries/Renderer/shims/ReactNative',
() => ({
__esModule: true,
default: {
findHostInstance_DEPRECATED: (ref: unknown) => ref,
findNodeHandle: () => 42,
render: () => null,
unmountComponentAtNodeAndRemoveContainer: () => null,
unstable_batchedUpdates: (fn: () => void) => {
fn();
},
dispatchCommand: () => null,
sendAccessibilityEvent: () => null,
isChildPublicInstance: () => false,
},
}),
{ virtual: false }
);

// Reanimated mock recommends silencing the layout-animation warning.
// (See https://docs.swmansion.com/react-native-reanimated/docs/guides/testing/)
jest.spyOn(global.console, 'warn').mockImplementation((msg: unknown) => {
if (typeof msg === 'string' && msg.includes('Reanimated 2')) return;
// fall through other warnings
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

jest.setup suppresses all console.warn, not just Reanimated

Medium Severity

The mockImplementation replaces console.warn entirely. When the message does NOT contain 'Reanimated 2', the function still returns undefined without calling the original implementation. The comment says "fall through other warnings" but there is no actual fall-through — all warnings are silently swallowed. This masks legitimate warnings emitted by the code under test, potentially hiding real issues like deprecation warnings or incorrect usage patterns.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit dd2672e. Configure here.

12 changes: 12 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,20 +66,25 @@
},
"devDependencies": {
"@commitlint/config-conventional": "^11.0.0",
"@react-native/babel-preset": "^0.79.0",
"@react-native/eslint-config": "^0.73.0",
"@release-it/conventional-changelog": "^2.0.0",
"@testing-library/react-native": "^12.5.0",
"@types/jest": "^29.5.0",
"@types/lodash": "^4.17.7",
"@types/react": "18.3.12",
"@types/react-native": "^0.65.4",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"babel-jest": "^29.7.0",
"commitlint": "^11.0.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^7.0.0",
"eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-reanimated": "^2.0.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"husky": "^4.2.5",
"jest": "^29.7.0",
"pod-install": "^0.1.0",
"prettier": "^2.0.5",
"react": "^18.3.1",
Expand All @@ -89,6 +94,7 @@
"react-native-reanimated": "~3.16.1",
"react-native-redash": "18.1.5",
"react-native-worklets": "0.5.1",
"react-test-renderer": "18.3.1",
"release-it": "^14.2.2",
"typescript": "^4.9.5"
},
Expand All @@ -100,9 +106,15 @@
},
"jest": {
"preset": "react-native",
"setupFiles": [
"./jest.setup.ts"
],
"modulePathIgnorePatterns": [
"<rootDir>/example/node_modules",
"<rootDir>/lib/"
],
"transformIgnorePatterns": [
"node_modules/(?!(react-native|@react-native|react-native-reanimated|react-native-gesture-handler|react-native-redash)/)"
]
},
"husky": {
Expand Down
4 changes: 3 additions & 1 deletion src/ReactNativeZoomableView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1632,7 +1632,8 @@ const ReactNativeZoomableViewInner: ForwardRefRenderFunction<
})
.onFinalize(() => {
firstTouch.value = undefined;
});
})
.withTestId('canvas-gesture');

const transformStyle = useAnimatedStyle(() => {
return {
Expand All @@ -1653,6 +1654,7 @@ const ReactNativeZoomableViewInner: ForwardRefRenderFunction<
// eslint-disable-next-line @typescript-eslint/no-use-before-define
style={styles.container}
ref={zoomSubjectWrapperRef}
testID="zoom-subject-wrapper"
onLayout={(e) => {
// Preserve the original measurement path (writes SharedValues
// consumed by the gesture math). The setState below is purely
Expand Down
Loading
Loading