Skip to content

Commit 306ff6f

Browse files
committed
Harden touch and scroll ownership for 0.1.0-alpha.1
1 parent 5eb3042 commit 306ff6f

25 files changed

Lines changed: 2014 additions & 266 deletions

‎.github/workflows/release.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ jobs:
1919
registry-url: https://registry.npmjs.org
2020
- run: npm ci
2121
- run: npm run check
22-
- run: npm publish
22+
- run: npm publish --tag alpha --access public

‎CHANGELOG.md‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,28 @@ All notable changes follow Keep a Changelog.
44

55
## [Unreleased]
66

7+
## [0.1.0-alpha.1] - 2026-09-06
8+
9+
### Fixed
10+
11+
- Correct directional touch-action to pan-down and preserve pinch zoom.
12+
- Add session-scoped touch compatibility handling with shared gesture mechanics.
13+
- Ignore bubbled child capture loss when capture transfers to Root.
14+
- Remove unintended root overflow and overscroll containment; preserve sticky and Window scrolling.
15+
- Resolve actual scrollability and nested gesture-origin consumers at gesture start and claim.
16+
- Keep explicit delayed refs unresolved until available.
17+
- Validate finite positive thresholds and scale small-threshold hysteresis.
18+
- Cap refresh hold by threshold and released distance.
19+
- Settle rejected/thrown application refreshes without logging consumer errors.
20+
- Protect editing controls; support data-pull-to-refresh-ignore surfaces.
21+
- Expand browser regressions, touch evidence, fixtures, architecture and physical-device QA documentation.
22+
723
### Changed
824

925
- Delayed pointer capture until downward intent and the scroll boundary are
1026
confirmed.
1127
- Added live pull metrics, lifecycle stress controls, browser guidance, and
1228
published-package integration proofs to the documentation site.
13-
- Preserved the upward touch-pan direction at the top boundary so scrollable
14-
content can begin scrolling normally from its first row.
1529
- Documented explicit pointer ownership for Pull to Refresh inside a draggable
1630
bottom sheet.
1731

‎README.md‎

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ Pull to refresh without owning your feed.
55
A small, accessible pull-to-refresh primitive with scroll arbitration,
66
resistance, and an async refresh lifecycle.
77

8+
Mobile pull-to-refresh depends on browser gesture ownership. See
9+
[Browser Behavior](docs/BROWSER_BEHAVIOR.md).
10+
811
```bash
912
npm install @nipe-solutions/react-pull-to-refresh
1013
```
@@ -73,22 +76,16 @@ Available root variables:
7376

7477
## Scroll ownership
7578

76-
The root resolves an explicit scroll container or the nearest ancestor with
77-
scrollable vertical overflow, then falls back to the window. A one-pixel
78-
tolerance includes fractional and negative WebKit offsets. Movement must clearly
79-
favor downward intent; horizontal and upward movement are rejected. Pointer
80-
capture begins only after intent is established and the boundary is rechecked.
81-
82-
For page-level custom pull-to-refresh, browser-native refresh can still win.
83-
Choose document containment explicitly in your application when appropriate:
84-
85-
```css
86-
html {
87-
overscroll-behavior-y: contain;
88-
}
89-
```
79+
The root resolves an explicit owner or the nearest actually scrollable ancestor,
80+
then Window. It checks nested scrollers from the gesture origin and rechecks the
81+
boundary before claim. An explicit null ref stays unresolved until a subsequent
82+
gesture. Gestures starting below the top stay browser-owned for that stream.
9083

91-
The library never modifies html or body styles.
84+
Core CSS does not set root overflow or overscroll policy. Apply optional
85+
`overscroll-behavior-y: contain` to your actual scroller. Chromium page-level
86+
containment may help suppress native refresh; it is not a universal guarantee.
87+
Page-level custom Pull to Refresh on iOS Safari is not a guaranteed configuration.
88+
Safari may retain native browser Pull-to-Refresh ownership.
9289

9390
## Accessibility
9491

@@ -99,11 +96,10 @@ keyboard gesture, or focus movement.
9996

10097
## Browser notes
10198

102-
- Chromium supports element overscroll containment and Pointer Events.
103-
- Firefox uses the same mechanics but presents overscroll differently.
104-
- Safari can report negative offsets while rubber-banding; real-device
105-
validation remains required.
106-
- Desktop primary-button dragging exists for demos and tests.
99+
Directional-capable browsers use Pointer Events with `pan-x pan-down pinch-zoom`.
100+
Firefox/Safari use a session-scoped Touch Events compatibility adapter. Automated
101+
engine tests do not verify physical iOS or Android behavior. Mobile QA remains
102+
beta-blocking; use an application refresh button as the accessible alternative.
107103

108104
See [browser behavior](docs/BROWSER_BEHAVIOR.md),
109105
[integrations](docs/INTEGRATIONS.md), and
@@ -125,7 +121,7 @@ npm run test:e2e
125121
Requires Node 24. React 18.3 and React 19 are peer-supported. Runtime
126122
dependencies are limited to React peers.
127123

128-
Current status: 0.1.0-alpha.0. Automated checks do not replace the manual device
124+
Current status: 0.1.0-alpha.1. Automated checks do not replace the manual device
129125
matrix.
130126

131127
Part of [NIPE Open Source](https://opensource.nipesolutions.com).

‎docs/API.md‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,31 @@ if disabled changes while its promise is pending.
1414

1515
onRefresh is invoked synchronously and exactly once on an armed release. A
1616
returned promise holds refreshing; void settles on the next microtask.
17-
Resolution and rejection both enter settling. Rejections are reported to the
18-
console so they are not silent; application error UI, retry, and cancellation
19-
remain outside the library.
17+
Resolution, rejection and synchronous throw all enter settling. The library
18+
does not log consumer errors, show error UI, retry, or cancel the application
19+
promise. Handle application errors in your refresh function. Unmount removes
20+
listeners and prevents UI updates from later promise completion.
2021

2122
## Motion
2223

2324
Finger travel is direct until the threshold. Overshoot follows a sublinear
2425
power curve, remaining continuous and monotonic while adding resistance.
25-
Refreshing holds at 52 pixels. Settling uses a 220 ms ease-out and becomes
26+
Refreshing holds at the minimum of 52 pixels, the gesture threshold and the
27+
released distance; release never adds extra pull. The internal 52px hold is a
28+
presentation convention, so size indicator content accordingly. Settling uses a 220 ms ease-out and becomes
2629
effectively immediate under reduced motion.
30+
31+
## Threshold and scroll container
32+
33+
Threshold must be finite and greater than zero. Intent still requires six pixels
34+
of movement; armed hysteresis is min(6px, threshold × 0.25). Threshold is snapped
35+
at gesture start, so rerenders cannot change an active gesture.
36+
37+
scrollContainer accepts an HTMLElement, Window, or element ref. A null explicit
38+
ref leaves the owner unresolved and PTR ineligible until a later gesture resolves
39+
it. Automatic detection requires actual vertical overflow and runs at gesture
40+
start. Nested scroll descendants participate in ownership even with an explicit
41+
outer owner. See [Browser Behavior](BROWSER_BEHAVIOR.md).
42+
43+
Editing controls and contenteditable origins are ignored. Mark custom gesture
44+
surfaces with `data-pull-to-refresh-ignore` to opt out of initiation.

‎docs/ARCHITECTURE.md‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Architecture and test boundaries
2+
3+
```text
4+
Pointer adapter (directional touch, mouse, pen)
5+
Touch adapter (directional values unsupported)
6+
↓
7+
start snapshot: origin, owner, adapter, coordinates, threshold
8+
↓
9+
shared intent: 6px slop → axis/direction arbitration
10+
↓
11+
recheck same owner + entire relevant top boundary chain
12+
↓
13+
claim → shared resistance → pulling ⇄ armed
14+
↓
15+
release once → application onRefresh → refreshing
16+
↓
17+
resolve / reject / synchronous throw → settling → idle or disabled
18+
```
19+
20+
Scroll ownership lives in `src/scroll-ownership.ts`. Root does not acquire
21+
scroll ownership by adding mechanical overflow. The root/ancestor owner is
22+
resolved at start; origin descendants are checked for competing scroll
23+
consumers. Invalid explicit refs remain unresolved. Browser-owned gestures are
24+
never resumed mid-stream.
25+
26+
`src/mechanics.ts` owns intent, resistance, progress and hysteresis. Visual travel
27+
remains direct through the threshold and sublinear above it. Hysteresis is
28+
`min(6, threshold * 0.25)`; every finite positive threshold is valid. The 6px
29+
intent slop still applies even for threshold 4. Armed release holds at
30+
`min(52, session threshold, released distance)`. The internal 52px resting height
31+
is an indicator presentation convention, not a measured height or public prop.
32+
Applications should size their indicator for this hold; small thresholds imply
33+
smaller available space. Progress remains 1 during refreshing and returns to 0
34+
when settling begins, independent of promise success/failure.
35+
36+
Session listeners are removed before release/abandonment; cleanup releases root
37+
capture only after clearing session state so capture-loss events cannot commit
38+
again. Touch fallback uses one non-passive root touchmove listener during a
39+
candidate only. Session-scoped window end/blur/additional-pointer detection handles
40+
abandonment outside the root. No idle RAF, document touchmove listener, generic
41+
gesture dependency, observer of content mutations, or application promise
42+
cancellation is introduced. React supplies its normal delegated start handlers.
43+
44+
A disabled change cancels an uncommitted session. Committed work finishes, then
45+
settles to disabled. Unmount removes listeners; promise completion is ignored
46+
for UI updates. Application errors are consumed for lifecycle cleanup without
47+
console logging, retries, or a second error callback. Applications retain error
48+
UI and telemetry ownership.
49+
50+
## Verification layers
51+
52+
- Pure mechanics tests: intent, resistance, progress, threshold hysteresis.
53+
- React tests: adapter invariants, null refs, nesting, lifecycle, StrictMode,
54+
cancellation, editing exclusions, ref/threshold rerenders and listener cleanup.
55+
- Browser tests: computed touch-action, actual overflow geometry, sticky headers,
56+
Window/element scroll surfaces, dynamic auto ancestors, nesting, large lists,
57+
console hygiene, and trusted Chromium CDP touch traces.
58+
- Synthetic DOM touch tests: exercise adapter integration in each engine. They
59+
cannot prove native touch delivery or arbitration.
60+
- Physical-device QA: native overscroll/refresh, selection handles, zoom,
61+
Safari/Firefox mobile fallback and actual browser chrome interactions.
62+
63+
Directly invoking React handlers or `page.mouse` cannot establish mobile touch
64+
support. Beta requires meaningful physical iOS and Android evidence.
65+
66+
Native listeners route through handlers published at React commit, so owner and
67+
refresh callback changes are observed without resubscribing active sessions.
68+
The threshold remains a gesture-start snapshot. The JS gzip budget is 3,200 B
69+
(previously 3,000 B), allowing the focused compatibility adapter and ownership
70+
checks; the measured baseline before hardening was 2,194 B. Runtime dependencies
71+
remain zero beyond React peers.

0 commit comments

Comments
 (0)