Skip to content

Page curl: run the controller on a tested state machine, grab under the finger - #22

Draft
cameronwebbable wants to merge 8 commits into
artem/page-curl-state-machine-testsfrom
cam/rw-47750-page-curl
Draft

cameronwebbable wants to merge 8 commits into
artem/page-curl-state-machine-testsfrom
cam/rw-47750-page-curl

Conversation

@cameronwebbable

@cameronwebbable cameronwebbable commented Oct 28, 2026 •

Copy link
Copy Markdown

Stacks on #20. Replaces #21, which closed when its branch was renamed; it had no reviews, so nothing was lost. Rekindled side: readwiseio/rekindled#14202 (RW-47750).

Problem

#20 added the RNCPageCurlStateMachine interface and eight tests against an empty stub. RNCWebViewPageCurl.m still carried its own state across _state, _awaitingLanding, _failedBakes and _pendingSettle, and every method touched some of them. That spread is where the four review rounds on #17 kept finding ordering bugs, and no test covered any of it.

Fix

Implement the machine and finish its tests. The machine is Foundation only: events in, effects out. Its rules are the ones the controller already followed, written down in one place.

  • EnterState is emitted only when the state changes. A settle that arrives in the state it already matches changes nothing.
  • A landing result is ignored unless a landing was requested. A late bridge callback after a cancel cannot move the machine.
  • A settle request is acted on only in awaitingSettle, and only when its token and serial match the request still outstanding.
  • A settle that arrives during a bake is kept and baked next, so a resize burst ends on the latest layout.
  • Three failed bakes in a row stop the loop instead of retrying forever.

apple-tests/RNCPageCurlStateMachineTests.m has 46 tests in nine groups. The last one, testEveryOrderingKeepsTheInvariants, feeds the machine every sequence of the 14 events to depth 5 and checks the invariants after each step. That is 537,824 sequences in about 2.2 s on an M4 Pro.

Move the controller onto it. RNCWebViewPageCurl.m goes from 1404 to 1332 lines. The local enum, RNCPageCurlStateName and RNCPageCurlMaxFailedBakes are gone with the five ivars they served. The controller keeps what only it can do: the bridge calls, the landing watchdog, the renderer, the input blocker and the JS events. It feeds the machine and carries out the effects it returns in apply:.

Let the curl pan wait on WebKit's holds. A touch that moved less than 8pt in its first 350ms was handed to WebKit for good by a timer, so a drag that began with a short pause never curled, and the manager's own swipe rule turned the page with no animation. The timer is gone. The curl pan now requires WebKit's long holds to fail: the selection loupe (0.5s, 8pt), the link lift (0.65s, 10pt) and the long press (0.75s, 10pt). Those fail the moment the finger moves past their allowance, so a drag curls about 17ms after the finger passes the pan's own threshold, measured on the Duo simulator with the native log. A finger that stays still for 0.5s still selects. The holds shorter than 0.3s (the tap highlight at 0.12s, image analysis at 0.1s) keep yielding to the pan as before; waiting on them too blocked every drag that paused more than 120ms, which is how that threshold was chosen.

Grab the page under the finger. A turn started mid-page moved the far corner, so the fold trailed well to the right of the finger: about 290pt behind it 45pt into a drag from the middle of the right page. The grab point is now the touch, held no closer to the spine than grabFloor of the page width (0.3), and the fold sweeps in from the outer edge to meet the finger at sweepGain (2) times the finger's speed, so no flap appears at the grab point on the first pixel. grabFloor 1 gives the old edge grab exactly. Both are tuning keys, so the default can be changed from JS.

Curl margin taps. A margin tap turned the page with no animation even with the curl on. The manager now hands a margin tap over as a tapTurn message. Once that tap's own touchEnd has brought the controller back to idle, it curls the page from rest: an ease in and out over tapTurnDuration (0.42 s), with the bend formed over tapBendInDistance (300 pt) instead of the drag's 30 pt. Through the drag's short bend-in a timed animation makes the fold race ahead of the page edge at 3.6 times its speed and then fall back; with the release's ease-out on top, the first frame moved the fold 134 pt. Measured on the Duo simulator, the largest frame-to-frame change in fold speed is now 20 pt per frame, down from 91. With nothing baked to curl onto, or the controller busy, it calls instantlyScrollPageDown/Up the way a tap always did. The controller calls nativePageCurlAnimateTaps on the manager when it is enabled, so the manager under an older binary keeps instant taps. No state machine change: a tap turn is turnBegan from idle, like a drag.

Verification

  • The test scheme is green: xcodebuild test -workspace bookwise.xcworkspace -scheme react-native-webview-Unit-Tests from reading-clients/bookwise/ios on the rekindled branch.
  • Grab under the finger, recorded on the Duo simulator: a mid-page drag keeps the fold within about 25pt of the finger once it has swept in (130pt and 220pt into the drag, against 226pt and 181pt with the edge grab); edge starts, starts near the spine and back turns in the spread all curl. The back turn on a single page (closed display, portrait) was traced by hand, not recorded.
  • Gesture cases on the Duo simulator after the hold change: fast and medium mid-page drags curl, an edge drag curls, a tap turns, and a finger that creeps under 8pt for 0.5s gets the selection loupe and the Define sheet, not a page turn.
  • Regression pass on the iPhone Duo simulator with the native log on, reading the machine's transitions: swipe turn, tap turn, cancelled slow drag, turns on the closed display, unfold mid-book (resize rebake, spine change teardown and re-enable, kept settles collapsed to the latest), and turns across chunk edges. Every one ended in idle on the expected page.

Not in this PR

  • The CI job that runs the test scheme. It lives in rekindled and is green there.
  • The docs/curl-spam.sh stress run and the Metal texture check from the plan.

Bridge version

RNCPageCurlInfo is a small native module that exports protocol: 2 on iOS. The app's JS turns the curl on only when it sees that value, so JS shipped to an older binary by OTA leaves its older curl alone. Bump RNCPageCurlProtocol whenever the calls between RNCWebViewPageCurl and the content frame change.

cameronwebbable and others added 5 commits October 27, 2026 16:26
The machine mirrors the controller's transitions: a settle during a bake or a turn is kept and baked after, a failed bake blanks the slots and asks for a settle up to three times, a landed turn drops the settle for the page it left until the manager moves or another page bakes. Results that arrive in the wrong state change nothing, so a bake, landing or settle request from before a teardown is inert.

Covers the nine groups of the test plan, 46 tests. The ordering sweep replays every sequence of five events from the enabled state and checks the invariants after each step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01635Bx126DsG1CbDzDxxBJx
The controller no longer keeps its own copy of the bake loop. It reports each event to RNCPageCurlStateMachine and carries out the effects it returns, so the transitions the tests cover are the ones that run. The landing watchdog and the settle request move with their effects; results from before a teardown are dropped by the teardown count as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01635Bx126DsG1CbDzDxxBJx
… timer

A touch that moved less than 8pt in its first 350ms handed itself to
WebKit for good, so a drag that began with a short pause never curled
and the manager's own swipe rule turned the page instead. Now the curl
pan requires WebKit's long holds (the selection loupe, the link lift) to
fail, which they do as soon as the finger moves, and the short tap
highlight holds keep yielding to the pan as before. A still finger still
selects; a moving one curls, however long it paused first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01635Bx126DsG1CbDzDxxBJx
It was there to diagnose the hold arbitration and has served that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01635Bx126DsG1CbDzDxxBJx
A turn started mid-page moved the far corner, so the fold trailed well
to the right of the finger. The grab point is now the touch, held no
closer to the spine than grabFloor of the page width, and the fold sweeps
in from the outer edge to meet it at sweepGain times the finger's speed.
grabFloor 1 is the old edge grab exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01635Bx126DsG1CbDzDxxBJx
@linear-code

linear-code Bot commented Oct 28, 2026

Copy link
Copy Markdown

RW-47750

cameronwebbable and others added 3 commits October 28, 2026 18:30
A margin tap turned the page with no animation even with the curl on.
The manager now hands the tap over as a tapTurn message; once the tap's
own touchEnd has brought the controller back to idle, it runs the same
turn a released flick does (tapTurnVelocity, about 450ms). With nothing
baked to curl onto, or the controller busy, it turns instantly the way
a tap always did. The controller tells the manager it does this when it
is enabled, so the manager under an older binary keeps instant taps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01635Bx126DsG1CbDzDxxBJx
…r run

A tap reused the drag release's curve, which leaves at full speed, and the
drag's 30pt bend-in, through which the fold races ahead of the page edge
at 3.6 times its speed. On the Duo simulator the first frame moved the
fold 134pt. A tap turn now eases in and out over tapTurnDuration (0.42s)
and forms its bend over tapBendInDistance (300pt). Measured frame to frame
the largest change in fold speed drops from 91pt to 20pt per frame and
the first frames move 0, 1 and 2pt. Drags and their release keep the old
curve and bend-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01635Bx126DsG1CbDzDxxBJx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant