From 3129a847962ca566c2f1c11f375f5446e021c064 Mon Sep 17 00:00:00 2001 From: Artem Litchmanov Date: Tue, 29 Sep 2026 17:00:54 -0700 Subject: [PATCH 1/6] Page curl: one native state machine for the bake loop A settle that arrived while a bake was running superseded it. The half-baked cycle abandoned its steps, a neighbor slot stayed blank, and the next swipe curled onto blank paper. The superseding settle was usually programmatic (an image load), so nothing the reader did caused it. The controller now keeps one state: idle, animating, baking, awaitingSettle or awaitingTap. A bake runs every step to the end. A settle that arrives during a bake or a turn is kept and applied once, after. The generation counter and its guards are gone. A landed curl no longer commits the page itself. It rotates the slots so the cover shows the landed page, then asks the manager to scrollToPage like any other caller, and the manager's settle bakes again. Native never asserts page identity. While the controller is not idle a clear view above the renderer swallows touches. Whether a touch reached the webview is read from UIKit's hit test at touch-down, so a finger that landed on that view is dropped rather than waited on. Co-Authored-By: Claude Fable 5.1 --- apple/RNCWebViewPageCurl.m | 360 ++++++++++++++++--------------------- 1 file changed, 157 insertions(+), 203 deletions(-) diff --git a/apple/RNCWebViewPageCurl.m b/apple/RNCWebViewPageCurl.m index d2e9f190c..bb8d2b79e 100644 --- a/apple/RNCWebViewPageCurl.m +++ b/apple/RNCWebViewPageCurl.m @@ -42,6 +42,31 @@ typedef void (^RNCPageCurlStep)(void (^done)(BOOL ok)); +// the only state the controller keeps about the bake loop; input reaches the webview only when idle +typedef NS_ENUM(NSInteger, RNCPageCurlState) { + RNCPageCurlStateIdle, + // a finger is turning a sheet, or the release animation is playing; the DOM does not move + RNCPageCurlStateAnimating, + // the slots are being snapshotted; runs to completion, a settle that arrives meanwhile is kept for after + RNCPageCurlStateBaking, + // the manager is moving the page (a tap, a jump, or a landed curl's scrollToPage); its settle starts the bake + RNCPageCurlStateAwaitingSettle, + // a tap reached the webview; the manager answers with a settle (page turned) or touchEnd (nothing turned) + RNCPageCurlStateAwaitingTap, +}; + +static NSString *RNCPageCurlStateName(RNCPageCurlState state) +{ + switch (state) { + case RNCPageCurlStateIdle: return @"idle"; + case RNCPageCurlStateAnimating: return @"animating"; + case RNCPageCurlStateBaking: return @"baking"; + case RNCPageCurlStateAwaitingSettle: return @"awaitingSettle"; + case RNCPageCurlStateAwaitingTap: return @"awaitingTap"; + } + return @"?"; +} + // the paper faded toward the opposite extreme so it reads as the reverse side in both themes static UIColor *RNCPageCurlFadedPaper(UIColor *paper) { @@ -166,6 +191,9 @@ @implementation RNCWebViewPageCurl { __weak UIView *_hostView; __weak WKWebView *_webView; RNCPageCurlRenderer *_renderer; + // sits above the renderer and swallows every touch while the controller is not idle; the curl pan + // and touch observer live on the host, so they still see the finger through it + UIView *_inputBlocker; NSMutableDictionary *_slots; UIPanGestureRecognizer *_pan; RNCPageCurlTouchObserver *_touchObserver; @@ -173,23 +201,15 @@ @implementation RNCWebViewPageCurl { NSDictionary *_tuningValues; BOOL _enabled; BOOL _spread; - BOOL _turnInFlight; BOOL _edgeEmitted; - // curls are allowed only between a finished bake cycle and the next page change - BOOL _ready; - // a bake cycle is moving the webview under the renderer - BOOL _cycleRunning; - // bumped by every new cycle and by teardown; a cycle that finds it changed stops at its next step - NSUInteger _cycleGeneration; - // the renderer must stay visible: a cycle is running, or a turn crossed the chunk edge - // and the settle that follows will rebake - BOOL _coverHeld; - // the manager is moving the webview itself; nothing unlocks before its settle - BOOL _awaitingSettle; - // a margin tap turned the page in the webview; not ready until the manager reports - BOOL _awaitingTapResult; - // a settle arrived during a turn; once the turn is over the manager is asked to settle again - BOOL _settlePending; + RNCPageCurlState _state; + // UIKit hit-tests once, at touch-down; only a touch it delivered inside the webview is one the + // manager saw and will answer + BOOL _touchReachedWebView; + // bumped by teardown only, so a bake step still in flight from a torn-down controller stops + NSUInteger _epoch; + // the latest settle that arrived while a bake or turn was running; applied once the engine is free + NSDictionary *_pendingSettle; // the page the webview rests on; pages count from 0 inside the chunk NSInteger _page; NSInteger _lastPage; @@ -417,9 +437,15 @@ - (void)setEnabled:(BOOL)enabled _renderer = [[RNCPageCurlRenderer alloc] initWithFrame:host.bounds]; _renderer.hidden = YES; [host addSubview:_renderer]; + _inputBlocker = [[UIView alloc] initWithFrame:host.bounds]; + _inputBlocker.backgroundColor = [UIColor clearColor]; + _inputBlocker.hidden = YES; + [host addSubview:_inputBlocker]; [self applyColors]; [self applyTuning]; [self showCover]; + // nothing is baked yet; the manager's first settle starts the first bake + [self setState:RNCPageCurlStateAwaitingSettle reason:@"enabled"]; __weak __typeof(self) weakSelf = self; _touchObserver = [RNCPageCurlTouchObserver new]; @@ -431,9 +457,9 @@ - (void)setEnabled:(BOOL)enabled if (strongSelf == nil) { return; } - RNCPageCurlLog(@"[page-curl] touches began; ready=%d", strongSelf->_ready); - // a new touch supersedes the previous tap; its own touch end decides again - strongSelf->_awaitingTapResult = NO; + UIView *hit = strongSelf->_touchObserver.touch.view; + strongSelf->_touchReachedWebView = hit != nil && strongSelf->_webView != nil && [hit isDescendantOfView:strongSelf->_webView]; + RNCPageCurlLog(@"[page-curl] touches began; state=%@ reachedWebView=%d", RNCPageCurlStateName(strongSelf->_state), strongSelf->_touchReachedWebView); [strongSelf makeWebPansYieldToCurl]; [strongSelf emit:@"touch" direction:nil detail:nil]; }; @@ -443,11 +469,10 @@ - (void)setEnabled:(BOOL)enabled return; } BOOL moved = strongSelf->_touchObserver.moved; - RNCPageCurlLog(@"[page-curl] touches ended; moved=%d inFlight=%d ready=%d", moved, strongSelf->_turnInFlight, strongSelf->_ready); - if (!moved && !strongSelf->_turnInFlight) { - // the manager reports a margin tap as a settle, or as a touch end without a page change - strongSelf->_awaitingTapResult = YES; - [strongSelf markNotReady:@"tap"]; + RNCPageCurlLog(@"[page-curl] touches ended; moved=%d state=%@ reachedWebView=%d", moved, RNCPageCurlStateName(strongSelf->_state), + strongSelf->_touchReachedWebView); + if (!moved && strongSelf->_touchReachedWebView && strongSelf->_state == RNCPageCurlStateIdle) { + [strongSelf setState:RNCPageCurlStateAwaitingTap reason:@"tap"]; [strongSelf emit:@"tap" direction:nil detail:nil]; } }; @@ -523,11 +548,15 @@ - (void)setSpine:(NSString *)spine } } -// bakes no longer match the page; the manager settles again once the page has painted +// bakes no longer match the page; the manager settles again once the page has painted. A busy +// controller needs nothing: the settle the manager sends is kept and applied when the engine frees - (void)requestRebake:(NSString *)reason { - RNCPageCurlLog(@"[page-curl] rebake requested (%@)", reason); - [self markNotReady:reason]; + RNCPageCurlLog(@"[page-curl] rebake requested (%@) state=%@", reason, RNCPageCurlStateName(_state)); + if (_state == RNCPageCurlStateIdle || _state == RNCPageCurlStateAwaitingTap) { + _renderer.hidden = YES; + [self setState:RNCPageCurlStateAwaitingSettle reason:reason]; + } [self callBridge:@"invalidate" argument:nil completion:^(BOOL ok, id result) {}]; } @@ -535,7 +564,7 @@ - (void)teardown { RNCPageCurlLog(@"[page-curl] teardown"); _enabled = NO; - _cycleGeneration += 1; + _epoch += 1; [self stopAnimation]; if (_pan != nil) { [_pan.view removeGestureRecognizer:_pan]; @@ -547,15 +576,12 @@ - (void)teardown } [_renderer removeFromSuperview]; _renderer = nil; - _turnInFlight = NO; + [_inputBlocker removeFromSuperview]; + _inputBlocker = nil; _turnDirection = nil; _edgeEmitted = NO; - _ready = NO; - _cycleRunning = NO; - _coverHeld = NO; - _awaitingSettle = NO; - _awaitingTapResult = NO; - _settlePending = NO; + _pendingSettle = nil; + _state = RNCPageCurlStateIdle; } - (void)layoutWithBounds:(CGRect)bounds @@ -564,6 +590,7 @@ - (void)layoutWithBounds:(CGRect)bounds return; } _renderer.frame = bounds; + _inputBlocker.frame = bounds; BOOL resized = !CGSizeEqualToSize(_lastSize, CGSizeZero) && !CGSizeEqualToSize(_lastSize, bounds.size); _lastSize = bounds.size; if (resized) { @@ -719,7 +746,7 @@ - (void)snapshotIntoSlot:(NSString *)slot completion:(void (^)(BOOL ok))completi WKSnapshotConfiguration *config = [WKSnapshotConfiguration new]; config.afterScreenUpdates = YES; CFTimeInterval start = CACurrentMediaTime(); - NSUInteger generation = _cycleGeneration; + NSUInteger epoch = _epoch; __weak __typeof(self) weakSelf = self; [webView takeSnapshotWithConfiguration:config completionHandler:^(UIImage *image, NSError *error) { __strong __typeof(weakSelf) strongSelf = weakSelf; @@ -728,8 +755,8 @@ - (void)snapshotIntoSlot:(NSString *)slot completion:(void (^)(BOOL ok))completi } double ms = (CACurrentMediaTime() - start) * 1000.0; RNCPageCurlLog(@"[page-curl] snapshot slot=%@ took %.1fms image=%@ error=%@", slot, ms, NSStringFromCGSize(image.size), error); - if (generation != strongSelf->_cycleGeneration) { - RNCPageCurlLog(@"[page-curl] snapshot slot=%@ dropped: its cycle was abandoned", slot); + if (epoch != strongSelf->_epoch) { + RNCPageCurlLog(@"[page-curl] snapshot slot=%@ dropped: the controller was torn down", slot); completion(NO); return; } @@ -782,15 +809,14 @@ - (void)callBridge:(NSString *)fn argument:(id)argument completion:(void (^)(BOO - (RNCPageCurlStep)stepSnapshot:(NSString *)slot { __weak __typeof(self) weakSelf = self; - NSUInteger generation = _cycleGeneration; return ^(void (^done)(BOOL)) { [weakSelf snapshotIntoSlot:slot completion:^(BOOL ok) { __strong __typeof(weakSelf) strongSelf = weakSelf; - if (strongSelf == nil || generation != strongSelf->_cycleGeneration) { + if (strongSelf == nil) { done(NO); return; } - // a failed neighbor bake curls onto blank paper; a failed current bake has no cover to show + // a neighbor that failed to bake stays blank and is not curled onto; a failed current bake has no cover to show if (!ok && ![slot isEqualToString:RNCPageCurlSlotCurrent]) { [strongSelf blankSlot:slot]; done(YES); @@ -817,13 +843,11 @@ - (RNCPageCurlStep)stepBlock:(void (^)(void))block }; } -// a cycle whose generation is no longer current has been superseded by a newer settle or a teardown; -// it stops without reporting, the newer cycle owns every slot from here -- (void)runSteps:(NSArray *)steps index:(NSUInteger)index generation:(NSUInteger)generation completion:(void (^)(BOOL ok))completion +// a bake runs every step to the end; only a teardown stops it, and then it stops without reporting +- (void)runSteps:(NSArray *)steps index:(NSUInteger)index epoch:(NSUInteger)epoch completion:(void (^)(BOOL ok))completion { - if (!_enabled || generation != _cycleGeneration) { - RNCPageCurlLog(@"[page-curl] cycle %lu abandoned at step %lu (enabled=%d current=%lu)", (unsigned long)generation, (unsigned long)index, _enabled, - (unsigned long)_cycleGeneration); + if (!_enabled || epoch != _epoch) { + RNCPageCurlLog(@"[page-curl] bake stopped at step %lu: controller torn down", (unsigned long)index); return; } if (index >= steps.count) { @@ -836,15 +860,15 @@ - (void)runSteps:(NSArray *)steps index:(NSUInteger)index gener if (strongSelf == nil) { return; } - if (generation != strongSelf->_cycleGeneration) { - RNCPageCurlLog(@"[page-curl] cycle %lu abandoned after step %lu", (unsigned long)generation, (unsigned long)index); + if (epoch != strongSelf->_epoch) { + RNCPageCurlLog(@"[page-curl] bake stopped after step %lu: controller torn down", (unsigned long)index); return; } if (!ok) { completion(NO); return; } - [strongSelf runSteps:steps index:index + 1 generation:generation completion:completion]; + [strongSelf runSteps:steps index:index + 1 epoch:epoch completion:completion]; }); } @@ -868,8 +892,7 @@ - (void)addNeighborStepsForPage:(NSInteger)page direction:(NSString *)direction }]]; return; } - // peek, snapshot and unpeek are one step: an abandoned cycle must never leave the manager peeked - NSUInteger generation = _cycleGeneration; + // peek, snapshot and unpeek are one step, so the manager is never left peeked between steps [steps addObject:^(void (^done)(BOOL)) { [weakSelf callBridge:@"peek" argument:direction completion:^(BOOL ok, id result) { __strong __typeof(weakSelf) strongSelf = weakSelf; @@ -877,14 +900,14 @@ - (void)addNeighborStepsForPage:(NSInteger)page direction:(NSString *)direction return; } if (!ok || ![result isKindOfClass:[NSNumber class]] || ![result boolValue]) { - RNCPageCurlLog(@"[page-curl] peek %@ refused; curling onto blank paper", direction); + RNCPageCurlLog(@"[page-curl] peek %@ refused; that side stays blank", direction); [strongSelf blankSlot:direction]; done(YES); return; } [strongSelf snapshotIntoSlot:direction completion:^(BOOL snapped) { __strong __typeof(weakSelf) innerSelf = weakSelf; - if (!snapped && innerSelf != nil && generation == innerSelf->_cycleGeneration) { + if (!snapped && innerSelf != nil) { [innerSelf blankSlot:direction]; } [weakSelf callBridge:@"unpeek" argument:nil completion:^(BOOL unpeeked, id unpeekResult) { @@ -895,41 +918,22 @@ - (void)addNeighborStepsForPage:(NSInteger)page direction:(NSString *)direction }]; } -- (void)finishCycle:(BOOL)ok reason:(NSString *)reason +// every slot from scratch; the settle says which page the webview rests on +- (void)runBakeWithSettle:(NSDictionary *)settle { - _cycleRunning = NO; - _coverHeld = NO; - _renderer.hidden = YES; - if (!ok) { - RNCPageCurlLog(@"[page-curl] cycle failed (%@); locked until the next settle", reason); - _ready = NO; - return; - } - [self markReady:reason]; - [self runPendingSettle]; -} - -// every slot from scratch: the reader is on a page native did not curl to -- (void)runFullCycleWithSettle:(NSDictionary *)settle -{ - _settlePending = NO; + _pendingSettle = nil; _page = [settle[@"page"] integerValue]; _lastPage = [settle[@"totalPages"] integerValue]; _chunkIndex = [settle[@"chunkIndex"] integerValue]; _isLastChunk = [settle[@"isLastChunk"] boolValue]; - _cycleRunning = YES; - _coverHeld = YES; - _awaitingSettle = NO; - _awaitingTapResult = NO; - _ready = NO; - NSUInteger generation = ++_cycleGeneration; + [self setState:RNCPageCurlStateBaking reason:@"settle"]; + NSUInteger epoch = _epoch; CFTimeInterval start = CACurrentMediaTime(); - RNCPageCurlLog(@"[page-curl] full cycle %lu start page=%ld/%ld chunk=%ld last=%d", (unsigned long)generation, (long)_page, (long)_lastPage, (long)_chunkIndex, - _isLastChunk); + RNCPageCurlLog(@"[page-curl] bake start page=%ld/%ld chunk=%ld last=%d", (long)_page, (long)_lastPage, (long)_chunkIndex, _isLastChunk); __weak __typeof(self) weakSelf = self; NSMutableArray *steps = [NSMutableArray array]; - // an interrupted cycle or a relayout may have left the webview on another page + // a relayout may have left the webview on another page [steps addObject:[self stepBridge:@"jump" page:_page]]; [steps addObject:[self stepSnapshot:RNCPageCurlSlotCurrent]]; [steps addObject:[self stepBlock:^{ @@ -943,160 +947,108 @@ - (void)runFullCycleWithSettle:(NSDictionary *)settle if (moved) { [steps addObject:[self stepBridge:@"jump" page:_page]]; } - [self runSteps:steps index:0 generation:generation completion:^(BOOL ok) { + [self runSteps:steps index:0 epoch:epoch completion:^(BOOL ok) { __strong __typeof(weakSelf) strongSelf = weakSelf; if (strongSelf == nil) { return; } - RNCPageCurlLog(@"[page-curl] full cycle %lu %@ in %.1fms", (unsigned long)generation, ok ? @"done" : @"FAILED", (CACurrentMediaTime() - start) * 1000.0); - if (ok) { - [strongSelf callBridge:@"setReady" argument:@YES completion:^(BOOL sent, id result) {}]; - } - [strongSelf finishCycle:ok reason:@"full cycle"]; + RNCPageCurlLog(@"[page-curl] bake %@ in %.1fms", ok ? @"done" : @"FAILED", (CACurrentMediaTime() - start) * 1000.0); + [strongSelf finishBake:ok]; }]; } -// after a completed curl: the bakes already on screen are kept, only the far neighbor of the -// new page is fetched, then the webview is parked on the new page and the manager told -- (void)finishTurnToward:(NSString *)direction +// a settle that arrived during the bake starts the next one under the same cover +- (void)finishBake:(BOOL)ok { - BOOL next = [direction isEqualToString:RNCPageCurlSlotNext]; - NSInteger target = next ? _page + 1 : _page - 1; - _ready = NO; - _awaitingTapResult = NO; - if (target < 0 || target > _lastPage) { - // the manager runs its chunk transition and settles when done - RNCPageCurlLog(@"[page-curl] turn crossed the chunk edge to page %ld; waiting for the settle", (long)target); - _coverHeld = YES; - _awaitingSettle = YES; - __weak __typeof(self) weakSelf = self; - [self callBridge:@"commit" page:target completion:^(BOOL ok) { - if (!ok) { - [weakSelf finishCycle:NO reason:@"edge commit"]; - } - }]; + if (_pendingSettle != nil) { + RNCPageCurlLog(@"[page-curl] baking again: a settle arrived while baking"); + [self runBakeWithSettle:_pendingSettle]; return; } - _page = target; - _cycleRunning = YES; - _coverHeld = YES; - NSUInteger generation = ++_cycleGeneration; - CFTimeInterval start = CACurrentMediaTime(); - RNCPageCurlLog(@"[page-curl] turn cycle %lu start page=%ld/%ld", (unsigned long)generation, (long)_page, (long)_lastPage); + _renderer.hidden = YES; + [self setState:RNCPageCurlStateIdle reason:ok ? @"baked" : @"bake failed"]; +} - NSMutableArray *steps = [NSMutableArray array]; - [self addNeighborStepsForPage:_page direction:direction to:steps]; - [steps addObject:[self stepBridge:@"jump" page:_page]]; - [steps addObject:[self stepBridge:@"commit" page:_page]]; +// after a completed curl the cover already shows the landed page; the manager is asked to move +// there like any other caller, and its settle bakes every slot again. A page past the chunk edge +// makes the manager run its own chunk switch, which settles on its own +- (void)finishTurnToward:(NSString *)direction +{ + BOOL next = [direction isEqualToString:RNCPageCurlSlotNext]; + NSInteger target = next ? _page + 1 : _page - 1; + // a settle from during the turn describes the page just left; the landing produces a fresh one + _pendingSettle = nil; + [self setState:RNCPageCurlStateAwaitingSettle reason:@"turn landed"]; + RNCPageCurlLog(@"[page-curl] turn landed toward %@; scrollToPage %ld", direction, (long)target); __weak __typeof(self) weakSelf = self; - [self runSteps:steps index:0 generation:generation completion:^(BOOL ok) { - __strong __typeof(weakSelf) strongSelf = weakSelf; - if (strongSelf == nil) { - return; + [self callBridge:@"scrollToPage" page:target completion:^(BOOL ok) { + if (!ok) { + [weakSelf requestRebake:@"scrollToPage failed"]; } - RNCPageCurlLog(@"[page-curl] turn cycle %lu %@ in %.1fms", (unsigned long)generation, ok ? @"done" : @"FAILED", (CACurrentMediaTime() - start) * 1000.0); - [strongSelf finishCycle:ok reason:@"turn cycle"]; }]; } -// a settle that arrived during a turn is stale once the turn has changed the page, so it is never -// replayed: the manager is asked to settle again from wherever it is now -- (void)runPendingSettle -{ - if (!_settlePending || _cycleRunning || _turnInFlight || [self panActive]) { - return; - } - _settlePending = NO; - [self requestRebake:@"deferred settle"]; -} - #pragma mark - messages from the content frame - (void)handleMessage:(NSDictionary *)message { NSString *type = message[@"type"]; - RNCPageCurlLog(@"[page-curl] message %@ (ready=%d cycle=%d cover=%d awaiting=%d tap=%d)", message, _ready, _cycleRunning, _coverHeld, _awaitingSettle, _awaitingTapResult); + RNCPageCurlLog(@"[page-curl] message %@ (state=%@ pending=%d)", message, RNCPageCurlStateName(_state), _pendingSettle != nil); if (_renderer == nil) { return; } if ([type isEqualToString:@"settled"]) { [self emit:@"settled" direction:nil detail:[NSString stringWithFormat:@"page %@/%@ chunk %@", message[@"page"], message[@"totalPages"], message[@"chunkIndex"]]]; - _ready = NO; - if (_turnInFlight || [self panActive]) { - RNCPageCurlLog(@"[page-curl] settle deferred: a turn is in flight"); - _settlePending = YES; + if (_state == RNCPageCurlStateBaking || _state == RNCPageCurlStateAnimating) { + RNCPageCurlLog(@"[page-curl] settle kept until the %@ ends", RNCPageCurlStateName(_state)); + _pendingSettle = message; return; } - if (_cycleRunning) { - // the running cycle bakes a page the manager has moved away from; the new settle takes over - RNCPageCurlLog(@"[page-curl] settle supersedes the running cycle"); - } - [self runFullCycleWithSettle:message]; + [self runBakeWithSettle:message]; return; } if ([type isEqualToString:@"unsettled"]) { - _awaitingSettle = YES; - _awaitingTapResult = NO; - [self markNotReady:@"unsettled"]; - return; - } - if ([type isEqualToString:@"chunkFade"]) { - // after a turn onto blank paper the cover drops so the manager's chunk fade shows; a turn onto - // the baked edge page keeps its cover, which the rebake after the settle matches - if (_awaitingSettle && !_cycleRunning && !_turnInFlight && [self slot:RNCPageCurlSlotCurrent].texture == nil) { - RNCPageCurlLog(@"[page-curl] chunk fade: uncovering the webview"); - _coverHeld = NO; + // the manager is moving the page itself; the webview is the thing to show until it settles + if (_state == RNCPageCurlStateIdle || _state == RNCPageCurlStateAwaitingTap) { _renderer.hidden = YES; + [self setState:RNCPageCurlStateAwaitingSettle reason:@"unsettled"]; } return; } if ([type isEqualToString:@"touchEnd"]) { - if (!_awaitingTapResult) { - return; - } - _awaitingTapResult = NO; - if (_awaitingSettle || _cycleRunning || _coverHeld || _turnInFlight || [self panActive]) { - RNCPageCurlLog(@"[page-curl] touch end while busy; staying not ready"); - return; + if (_state == RNCPageCurlStateAwaitingTap) { + [self setState:RNCPageCurlStateIdle reason:@"tap turned no page"]; } - [self markReady:@"tap without a page change"]; return; } RNCPageCurlLog(@"[page-curl] unknown message type %@", type); } -#pragma mark - readiness - -- (BOOL)panActive -{ - return _pan.state == UIGestureRecognizerStateBegan || _pan.state == UIGestureRecognizerStateChanged; -} +#pragma mark - state -- (void)hideIfIdle +- (void)setState:(RNCPageCurlState)state reason:(NSString *)reason { - if (_coverHeld || _turnInFlight || [self panActive]) { - return; + if (state != _state) { + RNCPageCurlLog(@"[page-curl] %@ -> %@ (%@)", RNCPageCurlStateName(_state), RNCPageCurlStateName(state), reason); + } + _state = state; + _inputBlocker.hidden = state == RNCPageCurlStateIdle; + if (state == RNCPageCurlStateIdle) { + [self emit:@"ready" direction:nil detail:reason]; + [self beginTurnForPan]; } - _renderer.hidden = YES; } -- (void)markNotReady:(NSString *)reason +- (BOOL)panActive { - if (_ready) { - RNCPageCurlLog(@"[page-curl] ready -> 0 (%@)", reason); - } - _ready = NO; - [self hideIfIdle]; + return _pan.state == UIGestureRecognizerStateBegan || _pan.state == UIGestureRecognizerStateChanged; } -- (void)markReady:(NSString *)reason +- (void)hideIfIdle { - if (!_ready) { - RNCPageCurlLog(@"[page-curl] ready -> 1 (%@)", reason); + if (_state == RNCPageCurlStateIdle && ![self panActive]) { + _renderer.hidden = YES; } - _ready = YES; - [self emit:@"ready" direction:nil detail:reason]; - [self beginTurnForPan]; } #pragma mark - gestures @@ -1108,15 +1060,15 @@ - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer } // the curl pan claims every drag while the curl is on, so WebKit's pans never scroll the page // themselves; if the bakes are still being made the turn starts when they are ready - RNCPageCurlLog(@"[page-curl] pan should begin? ready=%d inFlight=%d", _ready, _turnInFlight); - return !_turnInFlight; + RNCPageCurlLog(@"[page-curl] pan should begin? state=%@", RNCPageCurlStateName(_state)); + return _state != RNCPageCurlStateAnimating; } // the active pan's turn, from its original touch-down: the pan's own translation restarts from zero // where recognition began, and a pan that began before the bakes were ready starts here once they are - (void)beginTurnForPan { - if (![self panActive] || _turnDirection != nil || _turnInFlight || _turnDeclined || !_ready) { + if (![self panActive] || _turnDirection != nil || _turnDeclined || _state != RNCPageCurlStateIdle) { return; } CGPoint touchDown = _touchObserver.startPoint; @@ -1135,12 +1087,12 @@ - (void)beginTurnForPan - (void)onPan:(UIPanGestureRecognizer *)pan { CGPoint location = [pan locationInView:_hostView]; - RNCPageCurlLog(@"[page-curl] pan state=%ld location=%.1f,%.1f touchDown=%@ inFlight=%d turn=%@", (long)pan.state, location.x, location.y, - NSStringFromCGPoint(_touchObserver.startPoint), _turnInFlight, _turnDirection); + RNCPageCurlLog(@"[page-curl] pan state=%ld location=%.1f,%.1f touchDown=%@ curlState=%@ turn=%@", (long)pan.state, location.x, location.y, + NSStringFromCGPoint(_touchObserver.startPoint), RNCPageCurlStateName(_state), _turnDirection); BOOL moving = pan.state == UIGestureRecognizerStateBegan || pan.state == UIGestureRecognizerStateChanged; if (_turnDirection == nil && moving) { - if (!_ready && pan.state == UIGestureRecognizerStateBegan) { - RNCPageCurlLog(@"[page-curl] pan began before the bakes are ready; holding the drag until they are"); + if (_state != RNCPageCurlStateIdle && pan.state == UIGestureRecognizerStateBegan) { + RNCPageCurlLog(@"[page-curl] pan began while %@; holding the drag until idle", RNCPageCurlStateName(_state)); } [self beginTurnForPan]; return; @@ -1149,9 +1101,8 @@ - (void)onPan:(UIPanGestureRecognizer *)pan if (pan.state == UIGestureRecognizerStateEnded || pan.state == UIGestureRecognizerStateCancelled || pan.state == UIGestureRecognizerStateFailed) { _turnDeclined = NO; - RNCPageCurlLog(@"[page-curl] pan ended without a turn (ready=%d)", _ready); + RNCPageCurlLog(@"[page-curl] pan ended without a turn (state=%@)", RNCPageCurlStateName(_state)); [self hideIfIdle]; - [self runPendingSettle]; } return; } @@ -1178,16 +1129,19 @@ - (void)beginTurn:(NSString *)direction from:(CGPoint)start RNCPageCurlLog(@"[page-curl] no page toward %@; no curl", direction); return; } - if (target.texture == nil && !_edgeEmitted) { - _edgeEmitted = YES; - [self emit:@"edge" direction:direction detail:nil]; + // a page that exists but was never baked is never curled onto: the sheet would lift onto blank paper + if (target.texture == nil) { + if (!_edgeEmitted) { + _edgeEmitted = YES; + [self emit:@"edge" direction:direction detail:nil]; + } + RNCPageCurlLog(@"[page-curl] page toward %@ not baked; no curl", direction); + return; } BOOL next = [direction isEqualToString:RNCPageCurlSlotNext]; _turnDirection = direction; _turnReversed = !next && !_spread; - _turnInFlight = YES; - // not ready for the whole turn; a cancelled turn hands the still-valid bakes back - _ready = NO; + [self setState:RNCPageCurlStateAnimating reason:@"turn begins"]; [self showTurnScene:direction]; CGFloat width = _turnSheetRect.size.width; CGFloat height = _turnSheetRect.size.height; @@ -1375,18 +1329,18 @@ - (void)finishTurnCompleted:(BOOL)completed { NSString *direction = _turnDirection; _turnDirection = nil; - _turnInFlight = NO; _edgeEmitted = NO; RNCPageCurlLog(@"[page-curl] turn %@ finished completed=%d", direction, completed); if (!completed) { [self showCover]; - [self hideIfIdle]; [self emit:@"cancel" direction:nil detail:nil]; - if (_settlePending) { - [self runPendingSettle]; - } else if (!_awaitingSettle && !_coverHeld) { - [self markReady:@"curl cancelled"]; + // the three bakes are still valid; a settle from during the turn is the only reason to bake again + if (_pendingSettle != nil) { + [self runBakeWithSettle:_pendingSettle]; + return; } + _renderer.hidden = YES; + [self setState:RNCPageCurlStateIdle reason:@"curl cancelled"]; return; } [self emit:@"turn" direction:direction detail:[NSString stringWithFormat:@"from page %ld", (long)_page]]; From a39031c54c55290e8b23977189c3ca077031d6c3 Mon Sep 17 00:00:00 2001 From: Artem Litchmanov Date: Wed, 30 Sep 2026 11:54:54 -0700 Subject: [PATCH 2/6] Page curl: review round on the state machine awaitingTap is entered after the tap happened and waits for the manager's answer, so it is awaitingTapResult, master's word for it. The teardown counter is _teardownCount, and it alone stops a stale bake step; _enabled cannot tell a setSpine teardown-and-re-enable apart from one that stayed down. The landing was a 13-line method with one caller while the cancel branch sat inline next to it; it is inline now. After scrollToPage only the manager's settle can leave awaitingSettle, so a 3s watchdog logs when it has not come. The renderer hides when the state becomes idle, the same way the blocker shows, instead of at every call site; hideIfIdle goes. Co-Authored-By: Claude Fable 5.1 --- apple/RNCWebViewPageCurl.m | 97 ++++++++++++++++++-------------------- 1 file changed, 46 insertions(+), 51 deletions(-) diff --git a/apple/RNCWebViewPageCurl.m b/apple/RNCWebViewPageCurl.m index bb8d2b79e..14636b18d 100644 --- a/apple/RNCWebViewPageCurl.m +++ b/apple/RNCWebViewPageCurl.m @@ -52,7 +52,7 @@ typedef NS_ENUM(NSInteger, RNCPageCurlState) { // the manager is moving the page (a tap, a jump, or a landed curl's scrollToPage); its settle starts the bake RNCPageCurlStateAwaitingSettle, // a tap reached the webview; the manager answers with a settle (page turned) or touchEnd (nothing turned) - RNCPageCurlStateAwaitingTap, + RNCPageCurlStateAwaitingTapResult, }; static NSString *RNCPageCurlStateName(RNCPageCurlState state) @@ -62,7 +62,7 @@ typedef NS_ENUM(NSInteger, RNCPageCurlState) { case RNCPageCurlStateAnimating: return @"animating"; case RNCPageCurlStateBaking: return @"baking"; case RNCPageCurlStateAwaitingSettle: return @"awaitingSettle"; - case RNCPageCurlStateAwaitingTap: return @"awaitingTap"; + case RNCPageCurlStateAwaitingTapResult: return @"awaitingTapResult"; } return @"?"; } @@ -206,9 +206,12 @@ @implementation RNCWebViewPageCurl { // UIKit hit-tests once, at touch-down; only a touch it delivered inside the webview is one the // manager saw and will answer BOOL _touchReachedWebView; - // bumped by teardown only, so a bake step still in flight from a torn-down controller stops - NSUInteger _epoch; - // the latest settle that arrived while a bake or turn was running; applied once the engine is free + // a bake step still in flight from a torn-down controller stops; setSpine tears down and re-enables, + // which _enabled alone cannot tell apart + NSUInteger _teardownCount; + // one per landed turn, so the settle watchdog knows which landing it is timing + NSUInteger _landingCount; + // the latest settle that arrived while a bake or turn was running; applied once the controller is free NSDictionary *_pendingSettle; // the page the webview rests on; pages count from 0 inside the chunk NSInteger _page; @@ -472,7 +475,7 @@ - (void)setEnabled:(BOOL)enabled RNCPageCurlLog(@"[page-curl] touches ended; moved=%d state=%@ reachedWebView=%d", moved, RNCPageCurlStateName(strongSelf->_state), strongSelf->_touchReachedWebView); if (!moved && strongSelf->_touchReachedWebView && strongSelf->_state == RNCPageCurlStateIdle) { - [strongSelf setState:RNCPageCurlStateAwaitingTap reason:@"tap"]; + [strongSelf setState:RNCPageCurlStateAwaitingTapResult reason:@"tap"]; [strongSelf emit:@"tap" direction:nil detail:nil]; } }; @@ -487,7 +490,6 @@ - (void)setEnabled:(BOOL)enabled RNCPageCurlLog(@"[page-curl] held still for 350ms; releasing the curl pan for this touch"); strongSelf->_pan.enabled = NO; strongSelf->_pan.enabled = YES; - [strongSelf hideIfIdle]; } }; [host addGestureRecognizer:_touchObserver]; @@ -549,11 +551,11 @@ - (void)setSpine:(NSString *)spine } // bakes no longer match the page; the manager settles again once the page has painted. A busy -// controller needs nothing: the settle the manager sends is kept and applied when the engine frees +// controller needs nothing: the settle the manager sends is kept and applied when the controller frees - (void)requestRebake:(NSString *)reason { RNCPageCurlLog(@"[page-curl] rebake requested (%@) state=%@", reason, RNCPageCurlStateName(_state)); - if (_state == RNCPageCurlStateIdle || _state == RNCPageCurlStateAwaitingTap) { + if (_state == RNCPageCurlStateIdle || _state == RNCPageCurlStateAwaitingTapResult) { _renderer.hidden = YES; [self setState:RNCPageCurlStateAwaitingSettle reason:reason]; } @@ -564,7 +566,7 @@ - (void)teardown { RNCPageCurlLog(@"[page-curl] teardown"); _enabled = NO; - _epoch += 1; + _teardownCount += 1; [self stopAnimation]; if (_pan != nil) { [_pan.view removeGestureRecognizer:_pan]; @@ -746,7 +748,7 @@ - (void)snapshotIntoSlot:(NSString *)slot completion:(void (^)(BOOL ok))completi WKSnapshotConfiguration *config = [WKSnapshotConfiguration new]; config.afterScreenUpdates = YES; CFTimeInterval start = CACurrentMediaTime(); - NSUInteger epoch = _epoch; + NSUInteger teardownCount = _teardownCount; __weak __typeof(self) weakSelf = self; [webView takeSnapshotWithConfiguration:config completionHandler:^(UIImage *image, NSError *error) { __strong __typeof(weakSelf) strongSelf = weakSelf; @@ -755,7 +757,7 @@ - (void)snapshotIntoSlot:(NSString *)slot completion:(void (^)(BOOL ok))completi } double ms = (CACurrentMediaTime() - start) * 1000.0; RNCPageCurlLog(@"[page-curl] snapshot slot=%@ took %.1fms image=%@ error=%@", slot, ms, NSStringFromCGSize(image.size), error); - if (epoch != strongSelf->_epoch) { + if (teardownCount != strongSelf->_teardownCount) { RNCPageCurlLog(@"[page-curl] snapshot slot=%@ dropped: the controller was torn down", slot); completion(NO); return; @@ -844,9 +846,9 @@ - (RNCPageCurlStep)stepBlock:(void (^)(void))block } // a bake runs every step to the end; only a teardown stops it, and then it stops without reporting -- (void)runSteps:(NSArray *)steps index:(NSUInteger)index epoch:(NSUInteger)epoch completion:(void (^)(BOOL ok))completion +- (void)runSteps:(NSArray *)steps index:(NSUInteger)index teardownCount:(NSUInteger)teardownCount completion:(void (^)(BOOL ok))completion { - if (!_enabled || epoch != _epoch) { + if (teardownCount != _teardownCount) { RNCPageCurlLog(@"[page-curl] bake stopped at step %lu: controller torn down", (unsigned long)index); return; } @@ -860,7 +862,7 @@ - (void)runSteps:(NSArray *)steps index:(NSUInteger)index epoch if (strongSelf == nil) { return; } - if (epoch != strongSelf->_epoch) { + if (teardownCount != strongSelf->_teardownCount) { RNCPageCurlLog(@"[page-curl] bake stopped after step %lu: controller torn down", (unsigned long)index); return; } @@ -868,7 +870,7 @@ - (void)runSteps:(NSArray *)steps index:(NSUInteger)index epoch completion(NO); return; } - [strongSelf runSteps:steps index:index + 1 epoch:epoch completion:completion]; + [strongSelf runSteps:steps index:index + 1 teardownCount:teardownCount completion:completion]; }); } @@ -927,7 +929,7 @@ - (void)runBakeWithSettle:(NSDictionary *)settle _chunkIndex = [settle[@"chunkIndex"] integerValue]; _isLastChunk = [settle[@"isLastChunk"] boolValue]; [self setState:RNCPageCurlStateBaking reason:@"settle"]; - NSUInteger epoch = _epoch; + NSUInteger teardownCount = _teardownCount; CFTimeInterval start = CACurrentMediaTime(); RNCPageCurlLog(@"[page-curl] bake start page=%ld/%ld chunk=%ld last=%d", (long)_page, (long)_lastPage, (long)_chunkIndex, _isLastChunk); @@ -947,7 +949,7 @@ - (void)runBakeWithSettle:(NSDictionary *)settle if (moved) { [steps addObject:[self stepBridge:@"jump" page:_page]]; } - [self runSteps:steps index:0 epoch:epoch completion:^(BOOL ok) { + [self runSteps:steps index:0 teardownCount:teardownCount completion:^(BOOL ok) { __strong __typeof(weakSelf) strongSelf = weakSelf; if (strongSelf == nil) { return; @@ -965,29 +967,9 @@ - (void)finishBake:(BOOL)ok [self runBakeWithSettle:_pendingSettle]; return; } - _renderer.hidden = YES; [self setState:RNCPageCurlStateIdle reason:ok ? @"baked" : @"bake failed"]; } -// after a completed curl the cover already shows the landed page; the manager is asked to move -// there like any other caller, and its settle bakes every slot again. A page past the chunk edge -// makes the manager run its own chunk switch, which settles on its own -- (void)finishTurnToward:(NSString *)direction -{ - BOOL next = [direction isEqualToString:RNCPageCurlSlotNext]; - NSInteger target = next ? _page + 1 : _page - 1; - // a settle from during the turn describes the page just left; the landing produces a fresh one - _pendingSettle = nil; - [self setState:RNCPageCurlStateAwaitingSettle reason:@"turn landed"]; - RNCPageCurlLog(@"[page-curl] turn landed toward %@; scrollToPage %ld", direction, (long)target); - __weak __typeof(self) weakSelf = self; - [self callBridge:@"scrollToPage" page:target completion:^(BOOL ok) { - if (!ok) { - [weakSelf requestRebake:@"scrollToPage failed"]; - } - }]; -} - #pragma mark - messages from the content frame - (void)handleMessage:(NSDictionary *)message @@ -1009,14 +991,14 @@ - (void)handleMessage:(NSDictionary *)message } if ([type isEqualToString:@"unsettled"]) { // the manager is moving the page itself; the webview is the thing to show until it settles - if (_state == RNCPageCurlStateIdle || _state == RNCPageCurlStateAwaitingTap) { + if (_state == RNCPageCurlStateIdle || _state == RNCPageCurlStateAwaitingTapResult) { _renderer.hidden = YES; [self setState:RNCPageCurlStateAwaitingSettle reason:@"unsettled"]; } return; } if ([type isEqualToString:@"touchEnd"]) { - if (_state == RNCPageCurlStateAwaitingTap) { + if (_state == RNCPageCurlStateAwaitingTapResult) { [self setState:RNCPageCurlStateIdle reason:@"tap turned no page"]; } return; @@ -1032,8 +1014,10 @@ - (void)setState:(RNCPageCurlState)state reason:(NSString *)reason RNCPageCurlLog(@"[page-curl] %@ -> %@ (%@)", RNCPageCurlStateName(_state), RNCPageCurlStateName(state), reason); } _state = state; + // both overlays follow the state: idle is the webview, bare, with touches reaching it _inputBlocker.hidden = state == RNCPageCurlStateIdle; if (state == RNCPageCurlStateIdle) { + _renderer.hidden = YES; [self emit:@"ready" direction:nil detail:reason]; [self beginTurnForPan]; } @@ -1044,13 +1028,6 @@ - (BOOL)panActive return _pan.state == UIGestureRecognizerStateBegan || _pan.state == UIGestureRecognizerStateChanged; } -- (void)hideIfIdle -{ - if (_state == RNCPageCurlStateIdle && ![self panActive]) { - _renderer.hidden = YES; - } -} - #pragma mark - gestures - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer @@ -1102,7 +1079,6 @@ - (void)onPan:(UIPanGestureRecognizer *)pan pan.state == UIGestureRecognizerStateFailed) { _turnDeclined = NO; RNCPageCurlLog(@"[page-curl] pan ended without a turn (state=%@)", RNCPageCurlStateName(_state)); - [self hideIfIdle]; } return; } @@ -1339,14 +1315,33 @@ - (void)finishTurnCompleted:(BOOL)completed [self runBakeWithSettle:_pendingSettle]; return; } - _renderer.hidden = YES; - [self setState:RNCPageCurlStateIdle reason:@"curl cancelled"]; + [self setState:RNCPageCurlStateIdle reason:@"turn cancelled"]; return; } [self emit:@"turn" direction:direction detail:[NSString stringWithFormat:@"from page %ld", (long)_page]]; + // the cover shows the landed page; the manager is asked to move there like any other caller, and + // its settle bakes every slot again. A page past the chunk edge is the manager's own chunk switch [self rotateSlotsToward:direction]; [self showCover]; - [self finishTurnToward:direction]; + NSInteger target = [direction isEqualToString:RNCPageCurlSlotNext] ? _page + 1 : _page - 1; + // a settle from during the turn describes the page just left; the landing produces a fresh one + _pendingSettle = nil; + [self setState:RNCPageCurlStateAwaitingSettle reason:@"turn landed"]; + RNCPageCurlLog(@"[page-curl] turn landed toward %@; scrollToPage %ld", direction, (long)target); + __weak __typeof(self) weakSelf = self; + [self callBridge:@"scrollToPage" page:target completion:^(BOOL ok) { + if (!ok) { + [weakSelf requestRebake:@"scrollToPage failed"]; + } + }]; + // only the manager's settle leaves this state; say so if it has not come + NSUInteger landing = ++_landingCount; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf != nil && strongSelf->_landingCount == landing && strongSelf->_state == RNCPageCurlStateAwaitingSettle) { + RNCPageCurlLog(@"[page-curl] no settle 3s after scrollToPage %ld; still awaiting it", (long)target); + } + }); } @end From 79a88340cbca0bcd19d7c492ca76961153f36748 Mon Sep 17 00:00:00 2001 From: Artem Litchmanov Date: Wed, 30 Sep 2026 12:40:43 -0700 Subject: [PATCH 3/6] Page curl: second review round A settle the manager posted during the turn can be delivered after the turn landed. It names the page the reader just left, and taking it as the landing's settle baked that page for one cycle before the scrollToPage settle corrected it. While the landing's settle is awaited, a settle for the page just left is dropped; the manager's scrollToPage always leaves that page or switches chunk. A bake that stopped early went idle with slots from another page, so the next pan curled onto the wrong one. Every slot is blanked first, which refuses every direction, and a rebake is requested. The pending-or-idle block and the wait-for-settle block each had two copies; each is one method now. The settle watchdog keys on a state serial rather than a landing count, so a later state change within its window cannot make it log. _edgeEmitted resets when a bake starts. Co-Authored-By: Claude Fable 5.1 --- apple/RNCWebViewPageCurl.m | 76 ++++++++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 24 deletions(-) diff --git a/apple/RNCWebViewPageCurl.m b/apple/RNCWebViewPageCurl.m index 14636b18d..f60918e6b 100644 --- a/apple/RNCWebViewPageCurl.m +++ b/apple/RNCWebViewPageCurl.m @@ -209,8 +209,10 @@ @implementation RNCWebViewPageCurl { // a bake step still in flight from a torn-down controller stops; setSpine tears down and re-enables, // which _enabled alone cannot tell apart NSUInteger _teardownCount; - // one per landed turn, so the settle watchdog knows which landing it is timing - NSUInteger _landingCount; + // bumped on every state change and on teardown; the settle watchdog checks it has not moved + NSUInteger _stateSerial; + // the turn just landed and the manager was asked to move; a settle for the page it left is stale + BOOL _awaitingLanding; // the latest settle that arrived while a bake or turn was running; applied once the controller is free NSDictionary *_pendingSettle; // the page the webview rests on; pages count from 0 inside the chunk @@ -555,11 +557,16 @@ - (void)setSpine:(NSString *)spine - (void)requestRebake:(NSString *)reason { RNCPageCurlLog(@"[page-curl] rebake requested (%@) state=%@", reason, RNCPageCurlStateName(_state)); + [self awaitSettleIfResting:reason]; + [self callBridge:@"invalidate" argument:nil completion:^(BOOL ok, id result) {}]; +} + +// the manager is about to move the page: a controller resting on it waits for the settle +- (void)awaitSettleIfResting:(NSString *)reason +{ if (_state == RNCPageCurlStateIdle || _state == RNCPageCurlStateAwaitingTapResult) { - _renderer.hidden = YES; [self setState:RNCPageCurlStateAwaitingSettle reason:reason]; } - [self callBridge:@"invalidate" argument:nil completion:^(BOOL ok, id result) {}]; } - (void)teardown @@ -567,6 +574,8 @@ - (void)teardown RNCPageCurlLog(@"[page-curl] teardown"); _enabled = NO; _teardownCount += 1; + _stateSerial += 1; + _awaitingLanding = NO; [self stopAnimation]; if (_pan != nil) { [_pan.view removeGestureRecognizer:_pan]; @@ -775,7 +784,7 @@ - (void)snapshotIntoSlot:(NSString *)slot completion:(void (^)(BOOL ok))completi } // window.nativePageCurl.(page) in the content frame; jump resolves once the page painted, -// commit once the manager recorded the resting page +// scrollToPage once the manager has queued the move, and its settle follows - (void)callBridge:(NSString *)fn page:(NSInteger)page completion:(void (^)(BOOL ok))completion { [self callBridge:fn argument:@(page) completion:^(BOOL ok, id result) { @@ -806,7 +815,7 @@ - (void)callBridge:(NSString *)fn argument:(id)argument completion:(void (^)(BOO }]; } -#pragma mark - bake cycles +#pragma mark - bakes - (RNCPageCurlStep)stepSnapshot:(NSString *)slot { @@ -924,6 +933,8 @@ - (void)addNeighborStepsForPage:(NSInteger)page direction:(NSString *)direction - (void)runBakeWithSettle:(NSDictionary *)settle { _pendingSettle = nil; + _awaitingLanding = NO; + _edgeEmitted = NO; _page = [settle[@"page"] integerValue]; _lastPage = [settle[@"totalPages"] integerValue]; _chunkIndex = [settle[@"chunkIndex"] integerValue]; @@ -959,15 +970,33 @@ - (void)runBakeWithSettle:(NSDictionary *)settle }]; } -// a settle that arrived during the bake starts the next one under the same cover +// a settle that arrived while the controller was busy starts the next bake under the same cover +- (void)bakePendingOrIdle:(NSString *)reason +{ + if (_pendingSettle != nil) { + RNCPageCurlLog(@"[page-curl] baking again: a settle arrived meanwhile"); + [self runBakeWithSettle:_pendingSettle]; + return; + } + [self setState:RNCPageCurlStateIdle reason:reason]; +} + +// a bake that stopped early leaves slots from another page: no direction curls until the next settle - (void)finishBake:(BOOL)ok { + if (ok) { + [self bakePendingOrIdle:@"baked"]; + return; + } + for (NSString *slot in @[RNCPageCurlSlotPrevious, RNCPageCurlSlotCurrent, RNCPageCurlSlotNext]) { + [self blankSlot:slot]; + } if (_pendingSettle != nil) { - RNCPageCurlLog(@"[page-curl] baking again: a settle arrived while baking"); [self runBakeWithSettle:_pendingSettle]; return; } - [self setState:RNCPageCurlStateIdle reason:ok ? @"baked" : @"bake failed"]; + [self setState:RNCPageCurlStateIdle reason:@"bake failed"]; + [self requestRebake:@"bake failed"]; } #pragma mark - messages from the content frame @@ -986,15 +1015,16 @@ - (void)handleMessage:(NSDictionary *)message _pendingSettle = message; return; } + // posted during the turn, delivered after it landed: it names the page the reader just left + if (_awaitingLanding && [message[@"page"] integerValue] == _page && [message[@"chunkIndex"] integerValue] == _chunkIndex) { + RNCPageCurlLog(@"[page-curl] settle for the page just left; dropped"); + return; + } [self runBakeWithSettle:message]; return; } if ([type isEqualToString:@"unsettled"]) { - // the manager is moving the page itself; the webview is the thing to show until it settles - if (_state == RNCPageCurlStateIdle || _state == RNCPageCurlStateAwaitingTapResult) { - _renderer.hidden = YES; - [self setState:RNCPageCurlStateAwaitingSettle reason:@"unsettled"]; - } + [self awaitSettleIfResting:@"unsettled"]; return; } if ([type isEqualToString:@"touchEnd"]) { @@ -1012,6 +1042,7 @@ - (void)setState:(RNCPageCurlState)state reason:(NSString *)reason { if (state != _state) { RNCPageCurlLog(@"[page-curl] %@ -> %@ (%@)", RNCPageCurlStateName(_state), RNCPageCurlStateName(state), reason); + _stateSerial += 1; } _state = state; // both overlays follow the state: idle is the webview, bare, with touches reaching it @@ -1036,13 +1067,13 @@ - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer return YES; } // the curl pan claims every drag while the curl is on, so WebKit's pans never scroll the page - // themselves; if the bakes are still being made the turn starts when they are ready + // themselves; if the controller is busy the turn starts once it is idle RNCPageCurlLog(@"[page-curl] pan should begin? state=%@", RNCPageCurlStateName(_state)); return _state != RNCPageCurlStateAnimating; } // the active pan's turn, from its original touch-down: the pan's own translation restarts from zero -// where recognition began, and a pan that began before the bakes were ready starts here once they are +// where recognition began, and a pan that began while the controller was busy starts here once it is idle - (void)beginTurnForPan { if (![self panActive] || _turnDirection != nil || _turnDeclined || _state != RNCPageCurlStateIdle) { @@ -1310,12 +1341,8 @@ - (void)finishTurnCompleted:(BOOL)completed if (!completed) { [self showCover]; [self emit:@"cancel" direction:nil detail:nil]; - // the three bakes are still valid; a settle from during the turn is the only reason to bake again - if (_pendingSettle != nil) { - [self runBakeWithSettle:_pendingSettle]; - return; - } - [self setState:RNCPageCurlStateIdle reason:@"turn cancelled"]; + // the three bakes are still valid + [self bakePendingOrIdle:@"turn cancelled"]; return; } [self emit:@"turn" direction:direction detail:[NSString stringWithFormat:@"from page %ld", (long)_page]]; @@ -1326,6 +1353,7 @@ - (void)finishTurnCompleted:(BOOL)completed NSInteger target = [direction isEqualToString:RNCPageCurlSlotNext] ? _page + 1 : _page - 1; // a settle from during the turn describes the page just left; the landing produces a fresh one _pendingSettle = nil; + _awaitingLanding = YES; [self setState:RNCPageCurlStateAwaitingSettle reason:@"turn landed"]; RNCPageCurlLog(@"[page-curl] turn landed toward %@; scrollToPage %ld", direction, (long)target); __weak __typeof(self) weakSelf = self; @@ -1335,10 +1363,10 @@ - (void)finishTurnCompleted:(BOOL)completed } }]; // only the manager's settle leaves this state; say so if it has not come - NSUInteger landing = ++_landingCount; + NSUInteger serial = _stateSerial; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ __strong __typeof(weakSelf) strongSelf = weakSelf; - if (strongSelf != nil && strongSelf->_landingCount == landing && strongSelf->_state == RNCPageCurlStateAwaitingSettle) { + if (strongSelf != nil && strongSelf->_stateSerial == serial) { RNCPageCurlLog(@"[page-curl] no settle 3s after scrollToPage %ld; still awaiting it", (long)target); } }); From 13cb73277caa6680b90ecf336a0ff15153ff6a94 Mon Sep 17 00:00:00 2001 From: Artem Litchmanov Date: Wed, 30 Sep 2026 13:38:03 -0700 Subject: [PATCH 4/6] Page curl: drive the manager on window.s The content frame already exposes the scrolling manager as window.s, so the separate window.nativePageCurl object mirrored methods the manager has. The controller calls the manager's own methods now. A landed turn calls instantlyScrollPageDown or instantlyScrollPageUp, the margin tap's own function, instead of a page number: the controller says the direction and the manager handles the chunk edge and the TTS bookkeeping the same way it does for a tap. Co-Authored-By: Claude Fable 5.1 --- apple/RNCWebViewPageCurl.m | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/apple/RNCWebViewPageCurl.m b/apple/RNCWebViewPageCurl.m index f60918e6b..63d1eba41 100644 --- a/apple/RNCWebViewPageCurl.m +++ b/apple/RNCWebViewPageCurl.m @@ -558,7 +558,7 @@ - (void)requestRebake:(NSString *)reason { RNCPageCurlLog(@"[page-curl] rebake requested (%@) state=%@", reason, RNCPageCurlStateName(_state)); [self awaitSettleIfResting:reason]; - [self callBridge:@"invalidate" argument:nil completion:^(BOOL ok, id result) {}]; + [self callBridge:@"invalidateNativePageCurl" argument:nil completion:^(BOOL ok, id result) {}]; } // the manager is about to move the page: a controller resting on it waits for the settle @@ -783,8 +783,8 @@ - (void)snapshotIntoSlot:(NSString *)slot completion:(void (^)(BOOL ok))completi }]; } -// window.nativePageCurl.(page) in the content frame; jump resolves once the page painted, -// scrollToPage once the manager has queued the move, and its settle follows +// window.s.(argument) in the content frame: the manager's own methods. A jump resolves once the +// page painted; a landing resolves once the manager has queued the move, and its settle follows - (void)callBridge:(NSString *)fn page:(NSInteger)page completion:(void (^)(BOOL ok))completion { [self callBridge:fn argument:@(page) completion:^(BOOL ok, id result) { @@ -801,8 +801,8 @@ - (void)callBridge:(NSString *)fn argument:(id)argument completion:(void (^)(BOO return; } NSString *body = [NSString stringWithFormat: - @"if (!window.nativePageCurl) { throw new Error('nativePageCurl bridge missing'); }" - "return await window.nativePageCurl.%@(argument);", fn]; + @"if (!window.s || !window.s.%@) { throw new Error('scrolling manager has no %@'); }" + "return await window.s.%@(argument);", fn, fn, fn]; CFTimeInterval start = CACurrentMediaTime(); [webView callAsyncJavaScript:body arguments:@{@"argument": argument ?: [NSNull null]} @@ -892,7 +892,7 @@ - (void)addNeighborStepsForPage:(NSInteger)page direction:(NSString *)direction BOOL exists = next ? page < _lastPage : page > 0; BOOL beyondChunk = next ? !_isLastChunk : _chunkIndex > 0; if (exists) { - [steps addObject:[self stepBridge:@"jump" page:neighbor]]; + [steps addObject:[self stepBridge:@"nativePageCurlJump" page:neighbor]]; [steps addObject:[self stepSnapshot:direction]]; return; } @@ -905,7 +905,7 @@ - (void)addNeighborStepsForPage:(NSInteger)page direction:(NSString *)direction } // peek, snapshot and unpeek are one step, so the manager is never left peeked between steps [steps addObject:^(void (^done)(BOOL)) { - [weakSelf callBridge:@"peek" argument:direction completion:^(BOOL ok, id result) { + [weakSelf callBridge:@"nativePageCurlPeek" argument:direction completion:^(BOOL ok, id result) { __strong __typeof(weakSelf) strongSelf = weakSelf; if (strongSelf == nil) { return; @@ -921,7 +921,7 @@ - (void)addNeighborStepsForPage:(NSInteger)page direction:(NSString *)direction if (!snapped && innerSelf != nil) { [innerSelf blankSlot:direction]; } - [weakSelf callBridge:@"unpeek" argument:nil completion:^(BOOL unpeeked, id unpeekResult) { + [weakSelf callBridge:@"nativePageCurlUnpeek" argument:nil completion:^(BOOL unpeeked, id unpeekResult) { done(unpeeked); }]; }]; @@ -947,7 +947,7 @@ - (void)runBakeWithSettle:(NSDictionary *)settle __weak __typeof(self) weakSelf = self; NSMutableArray *steps = [NSMutableArray array]; // a relayout may have left the webview on another page - [steps addObject:[self stepBridge:@"jump" page:_page]]; + [steps addObject:[self stepBridge:@"nativePageCurlJump" page:_page]]; [steps addObject:[self stepSnapshot:RNCPageCurlSlotCurrent]]; [steps addObject:[self stepBlock:^{ __strong __typeof(weakSelf) strongSelf = weakSelf; @@ -958,7 +958,7 @@ - (void)runBakeWithSettle:(NSDictionary *)settle [self addNeighborStepsForPage:_page direction:RNCPageCurlSlotNext to:steps]; [self addNeighborStepsForPage:_page direction:RNCPageCurlSlotPrevious to:steps]; if (moved) { - [steps addObject:[self stepBridge:@"jump" page:_page]]; + [steps addObject:[self stepBridge:@"nativePageCurlJump" page:_page]]; } [self runSteps:steps index:0 teardownCount:teardownCount completion:^(BOOL ok) { __strong __typeof(weakSelf) strongSelf = weakSelf; @@ -1346,20 +1346,20 @@ - (void)finishTurnCompleted:(BOOL)completed return; } [self emit:@"turn" direction:direction detail:[NSString stringWithFormat:@"from page %ld", (long)_page]]; - // the cover shows the landed page; the manager is asked to move there like any other caller, and - // its settle bakes every slot again. A page past the chunk edge is the manager's own chunk switch + // the cover shows the landed page; the manager turns to it the way a margin tap does, and its + // settle bakes every slot again. Past the chunk edge that is the manager's own chunk switch [self rotateSlotsToward:direction]; [self showCover]; - NSInteger target = [direction isEqualToString:RNCPageCurlSlotNext] ? _page + 1 : _page - 1; // a settle from during the turn describes the page just left; the landing produces a fresh one _pendingSettle = nil; _awaitingLanding = YES; [self setState:RNCPageCurlStateAwaitingSettle reason:@"turn landed"]; - RNCPageCurlLog(@"[page-curl] turn landed toward %@; scrollToPage %ld", direction, (long)target); + NSString *turn = [direction isEqualToString:RNCPageCurlSlotNext] ? @"instantlyScrollPageDown" : @"instantlyScrollPageUp"; + RNCPageCurlLog(@"[page-curl] turn landed toward %@; %@", direction, turn); __weak __typeof(self) weakSelf = self; - [self callBridge:@"scrollToPage" page:target completion:^(BOOL ok) { + [self callBridge:turn argument:nil completion:^(BOOL ok, id result) { if (!ok) { - [weakSelf requestRebake:@"scrollToPage failed"]; + [weakSelf requestRebake:@"landing failed"]; } }]; // only the manager's settle leaves this state; say so if it has not come @@ -1367,7 +1367,7 @@ - (void)finishTurnCompleted:(BOOL)completed dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ __strong __typeof(weakSelf) strongSelf = weakSelf; if (strongSelf != nil && strongSelf->_stateSerial == serial) { - RNCPageCurlLog(@"[page-curl] no settle 3s after scrollToPage %ld; still awaiting it", (long)target); + RNCPageCurlLog(@"[page-curl] no settle 3s after landing toward %@; still awaiting it", direction); } }); } From b3920c3325638e9680f97c41fae5d3686731040a Mon Sep 17 00:00:00 2001 From: Artem Litchmanov Date: Sat, 3 Oct 2026 20:20:47 -0700 Subject: [PATCH 5/6] Page curl: third review round A failed landing call clears the stale-settle guard before it asks for a rebake, and the landing's own unsettled message clears it too, so the guard can no longer drop a settle for the page the webview is still on. A failed bake goes straight to awaitingSettle instead of passing through idle, and after three failures in a row the controller stops asking for another bake and waits for the manager's next settle. Co-Authored-By: Claude Fable 5.1 --- apple/RNCWebViewPageCurl.m | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/apple/RNCWebViewPageCurl.m b/apple/RNCWebViewPageCurl.m index 63d1eba41..ffe9c81b1 100644 --- a/apple/RNCWebViewPageCurl.m +++ b/apple/RNCWebViewPageCurl.m @@ -7,6 +7,7 @@ static NSString *const RNCPageCurlSlotCurrent = @"current"; static NSString *const RNCPageCurlSlotPrevious = @"previous"; static NSString *const RNCPageCurlSlotNext = @"next"; +static const NSUInteger RNCPageCurlMaxFailedBakes = 3; // defaults for the tuning JSON; the Bookwise side documents each knob static NSDictionary *RNCPageCurlTuningDefaults(void) @@ -213,6 +214,8 @@ @implementation RNCWebViewPageCurl { NSUInteger _stateSerial; // the turn just landed and the manager was asked to move; a settle for the page it left is stale BOOL _awaitingLanding; + // bakes that failed back to back; past the cap the controller stops asking for another + NSUInteger _failedBakes; // the latest settle that arrived while a bake or turn was running; applied once the controller is free NSDictionary *_pendingSettle; // the page the webview rests on; pages count from 0 inside the chunk @@ -561,7 +564,8 @@ - (void)requestRebake:(NSString *)reason [self callBridge:@"invalidateNativePageCurl" argument:nil completion:^(BOOL ok, id result) {}]; } -// the manager is about to move the page: a controller resting on it waits for the settle +// the manager is about to move the page: a controller resting on it (idle, or waiting on a tap's +// result) waits for the settle - (void)awaitSettleIfResting:(NSString *)reason { if (_state == RNCPageCurlStateIdle || _state == RNCPageCurlStateAwaitingTapResult) { @@ -576,6 +580,7 @@ - (void)teardown _teardownCount += 1; _stateSerial += 1; _awaitingLanding = NO; + _failedBakes = 0; [self stopAnimation]; if (_pan != nil) { [_pan.view removeGestureRecognizer:_pan]; @@ -981,10 +986,12 @@ - (void)bakePendingOrIdle:(NSString *)reason [self setState:RNCPageCurlStateIdle reason:reason]; } -// a bake that stopped early leaves slots from another page: no direction curls until the next settle +// a finished bake opens input; one that stopped early blanks its slots, which describe another page, +// and asks the manager to settle again - (void)finishBake:(BOOL)ok { if (ok) { + _failedBakes = 0; [self bakePendingOrIdle:@"baked"]; return; } @@ -995,8 +1002,15 @@ - (void)finishBake:(BOOL)ok [self runBakeWithSettle:_pendingSettle]; return; } - [self setState:RNCPageCurlStateIdle reason:@"bake failed"]; - [self requestRebake:@"bake failed"]; + _failedBakes += 1; + if (_failedBakes >= RNCPageCurlMaxFailedBakes) { + // taps still turn pages; the next settle the manager sends on its own bakes again + RNCPageCurlLog(@"[page-curl] %lu bakes failed in a row; no curl until the next settle", (unsigned long)_failedBakes); + [self setState:RNCPageCurlStateIdle reason:@"bake failed"]; + return; + } + [self setState:RNCPageCurlStateAwaitingSettle reason:@"bake failed"]; + [self callBridge:@"invalidateNativePageCurl" argument:nil completion:^(BOOL invalidated, id result) {}]; } #pragma mark - messages from the content frame @@ -1024,6 +1038,8 @@ - (void)handleMessage:(NSDictionary *)message return; } if ([type isEqualToString:@"unsettled"]) { + // the manager posts this before it moves, so every settle after it is from after the landing + _awaitingLanding = NO; [self awaitSettleIfResting:@"unsettled"]; return; } @@ -1358,8 +1374,11 @@ - (void)finishTurnCompleted:(BOOL)completed RNCPageCurlLog(@"[page-curl] turn landed toward %@; %@", direction, turn); __weak __typeof(self) weakSelf = self; [self callBridge:turn argument:nil completion:^(BOOL ok, id result) { - if (!ok) { - [weakSelf requestRebake:@"landing failed"]; + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (!ok && strongSelf != nil) { + // the webview never moved, so the settle it sends names the page the turn started on + strongSelf->_awaitingLanding = NO; + [strongSelf requestRebake:@"landing failed"]; } }]; // only the manager's settle leaves this state; say so if it has not come From 42bbed4584c557b41f21f91574476af826c39ad3 Mon Sep 17 00:00:00 2001 From: Artem Litchmanov Date: Sat, 3 Oct 2026 21:28:50 -0700 Subject: [PATCH 6/6] Page curl: fourth review round A rebake request that fails no longer leaves the controller waiting for a settle that will not come: it blanks the slots and goes idle. The failed-bake path reuses requestRebake:, and the capped path logs its own reason. The page-number bridge wrapper had one caller, so the bake step is now stepJumpToPage:. Comments that still named scrollToPage and window.nativePageCurl are corrected. Co-Authored-By: Claude Fable 5.1 --- apple/RNCWebViewPageCurl.h | 2 +- apple/RNCWebViewPageCurl.m | 62 ++++++++++++++++++++++---------------- 2 files changed, 37 insertions(+), 27 deletions(-) diff --git a/apple/RNCWebViewPageCurl.h b/apple/RNCWebViewPageCurl.h index 823701ec5..69ef6b374 100644 --- a/apple/RNCWebViewPageCurl.h +++ b/apple/RNCWebViewPageCurl.h @@ -10,7 +10,7 @@ typedef void (^RNCPageCurlEventBlock)(NSDictionary *event); * Apple Books-style page curl over a WKWebView. Pages are bitmaps baked with * takeSnapshot; a Metal renderer draws them above the webview and is hidden at * rest. The content frame reports settles through the "pageCurl" script message - * handler; the controller drives the webview through window.nativePageCurl. + * handler; the controller drives the webview through the scrolling manager on window.s. * Events (logging only): touch, tap, turn, cancel, edge, settled, ready. */ @interface RNCWebViewPageCurl : NSObject diff --git a/apple/RNCWebViewPageCurl.m b/apple/RNCWebViewPageCurl.m index ffe9c81b1..f12024415 100644 --- a/apple/RNCWebViewPageCurl.m +++ b/apple/RNCWebViewPageCurl.m @@ -50,7 +50,7 @@ typedef NS_ENUM(NSInteger, RNCPageCurlState) { RNCPageCurlStateAnimating, // the slots are being snapshotted; runs to completion, a settle that arrives meanwhile is kept for after RNCPageCurlStateBaking, - // the manager is moving the page (a tap, a jump, or a landed curl's scrollToPage); its settle starts the bake + // the manager is moving the page (a tap, a jump, or a landed curl's page turn); its settle starts the bake RNCPageCurlStateAwaitingSettle, // a tap reached the webview; the manager answers with a settle (page turned) or touchEnd (nothing turned) RNCPageCurlStateAwaitingTapResult, @@ -214,7 +214,7 @@ @implementation RNCWebViewPageCurl { NSUInteger _stateSerial; // the turn just landed and the manager was asked to move; a settle for the page it left is stale BOOL _awaitingLanding; - // bakes that failed back to back; past the cap the controller stops asking for another + // bakes that failed back to back; at the cap the controller stops asking for another NSUInteger _failedBakes; // the latest settle that arrived while a bake or turn was running; applied once the controller is free NSDictionary *_pendingSettle; @@ -561,7 +561,17 @@ - (void)requestRebake:(NSString *)reason { RNCPageCurlLog(@"[page-curl] rebake requested (%@) state=%@", reason, RNCPageCurlStateName(_state)); [self awaitSettleIfResting:reason]; - [self callBridge:@"invalidateNativePageCurl" argument:nil completion:^(BOOL ok, id result) {}]; + NSUInteger serial = _stateSerial; + __weak __typeof(self) weakSelf = self; + [self callBridge:@"invalidateNativePageCurl" argument:nil completion:^(BOOL ok, id result) { + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (ok || strongSelf == nil || strongSelf->_stateSerial != serial || strongSelf->_state != RNCPageCurlStateAwaitingSettle) { + return; + } + // no settle is coming: open input with nothing to curl onto until the manager settles on its own + [strongSelf blankAllSlots]; + [strongSelf setState:RNCPageCurlStateIdle reason:@"rebake request failed"]; + }]; } // the manager is about to move the page: a controller resting on it (idle, or waiting on a tap's @@ -663,6 +673,13 @@ - (void)blankSlot:(NSString *)name [self slot:name].hasPage = YES; } +- (void)blankAllSlots +{ + for (NSString *slot in @[RNCPageCurlSlotPrevious, RNCPageCurlSlotCurrent, RNCPageCurlSlotNext]) { + [self blankSlot:slot]; + } +} + #pragma mark - scenes - (CGRect)fullTexRect @@ -788,15 +805,7 @@ - (void)snapshotIntoSlot:(NSString *)slot completion:(void (^)(BOOL ok))completi }]; } -// window.s.(argument) in the content frame: the manager's own methods. A jump resolves once the -// page painted; a landing resolves once the manager has queued the move, and its settle follows -- (void)callBridge:(NSString *)fn page:(NSInteger)page completion:(void (^)(BOOL ok))completion -{ - [self callBridge:fn argument:@(page) completion:^(BOOL ok, id result) { - completion(ok); - }]; -} - +// window.s.(argument) in the content frame: the manager's own methods, awaited when they return a promise - (void)callBridge:(NSString *)fn argument:(id)argument completion:(void (^)(BOOL ok, id result))completion { WKWebView *webView = _webView; @@ -843,11 +852,14 @@ - (RNCPageCurlStep)stepSnapshot:(NSString *)slot }; } -- (RNCPageCurlStep)stepBridge:(NSString *)fn page:(NSInteger)page +// parks the webview on a page of the current chunk; done once that page has painted +- (RNCPageCurlStep)stepJumpToPage:(NSInteger)page { __weak __typeof(self) weakSelf = self; return ^(void (^done)(BOOL)) { - [weakSelf callBridge:fn page:page completion:done]; + [weakSelf callBridge:@"nativePageCurlJump" argument:@(page) completion:^(BOOL ok, id result) { + done(ok); + }]; }; } @@ -897,7 +909,7 @@ - (void)addNeighborStepsForPage:(NSInteger)page direction:(NSString *)direction BOOL exists = next ? page < _lastPage : page > 0; BOOL beyondChunk = next ? !_isLastChunk : _chunkIndex > 0; if (exists) { - [steps addObject:[self stepBridge:@"nativePageCurlJump" page:neighbor]]; + [steps addObject:[self stepJumpToPage:neighbor]]; [steps addObject:[self stepSnapshot:direction]]; return; } @@ -952,7 +964,7 @@ - (void)runBakeWithSettle:(NSDictionary *)settle __weak __typeof(self) weakSelf = self; NSMutableArray *steps = [NSMutableArray array]; // a relayout may have left the webview on another page - [steps addObject:[self stepBridge:@"nativePageCurlJump" page:_page]]; + [steps addObject:[self stepJumpToPage:_page]]; [steps addObject:[self stepSnapshot:RNCPageCurlSlotCurrent]]; [steps addObject:[self stepBlock:^{ __strong __typeof(weakSelf) strongSelf = weakSelf; @@ -963,7 +975,7 @@ - (void)runBakeWithSettle:(NSDictionary *)settle [self addNeighborStepsForPage:_page direction:RNCPageCurlSlotNext to:steps]; [self addNeighborStepsForPage:_page direction:RNCPageCurlSlotPrevious to:steps]; if (moved) { - [steps addObject:[self stepBridge:@"nativePageCurlJump" page:_page]]; + [steps addObject:[self stepJumpToPage:_page]]; } [self runSteps:steps index:0 teardownCount:teardownCount completion:^(BOOL ok) { __strong __typeof(weakSelf) strongSelf = weakSelf; @@ -987,7 +999,7 @@ - (void)bakePendingOrIdle:(NSString *)reason } // a finished bake opens input; one that stopped early blanks its slots, which describe another page, -// and asks the manager to settle again +// then bakes a kept settle, asks the manager for a new one, or gives up at the cap - (void)finishBake:(BOOL)ok { if (ok) { @@ -995,9 +1007,7 @@ - (void)finishBake:(BOOL)ok [self bakePendingOrIdle:@"baked"]; return; } - for (NSString *slot in @[RNCPageCurlSlotPrevious, RNCPageCurlSlotCurrent, RNCPageCurlSlotNext]) { - [self blankSlot:slot]; - } + [self blankAllSlots]; if (_pendingSettle != nil) { [self runBakeWithSettle:_pendingSettle]; return; @@ -1006,11 +1016,11 @@ - (void)finishBake:(BOOL)ok if (_failedBakes >= RNCPageCurlMaxFailedBakes) { // taps still turn pages; the next settle the manager sends on its own bakes again RNCPageCurlLog(@"[page-curl] %lu bakes failed in a row; no curl until the next settle", (unsigned long)_failedBakes); - [self setState:RNCPageCurlStateIdle reason:@"bake failed"]; + [self setState:RNCPageCurlStateIdle reason:@"bakes kept failing"]; return; } [self setState:RNCPageCurlStateAwaitingSettle reason:@"bake failed"]; - [self callBridge:@"invalidateNativePageCurl" argument:nil completion:^(BOOL invalidated, id result) {}]; + [self requestRebake:@"bake failed"]; } #pragma mark - messages from the content frame @@ -1370,10 +1380,10 @@ - (void)finishTurnCompleted:(BOOL)completed _pendingSettle = nil; _awaitingLanding = YES; [self setState:RNCPageCurlStateAwaitingSettle reason:@"turn landed"]; - NSString *turn = [direction isEqualToString:RNCPageCurlSlotNext] ? @"instantlyScrollPageDown" : @"instantlyScrollPageUp"; - RNCPageCurlLog(@"[page-curl] turn landed toward %@; %@", direction, turn); + NSString *fn = [direction isEqualToString:RNCPageCurlSlotNext] ? @"instantlyScrollPageDown" : @"instantlyScrollPageUp"; + RNCPageCurlLog(@"[page-curl] turn landed toward %@; %@", direction, fn); __weak __typeof(self) weakSelf = self; - [self callBridge:turn argument:nil completion:^(BOOL ok, id result) { + [self callBridge:fn argument:nil completion:^(BOOL ok, id result) { __strong __typeof(weakSelf) strongSelf = weakSelf; if (!ok && strongSelf != nil) { // the webview never moved, so the settle it sends names the page the turn started on