From b5d17095b580bde2c942c27736b287464708a767 Mon Sep 17 00:00:00 2001 From: Artem Litchmanov Date: Fri, 23 Oct 2026 15:30:50 -0700 Subject: [PATCH 1/2] Page curl: copy the snapshot straight into a texture when its layout allows A WKWebView snapshot comes back as 32-bit RGBA with alpha last. Metal can take those bytes as RGBA8Unorm, and little-endian alpha-first bytes as BGRA8Unorm, so the bake no longer redraws every page into a new bitmap first. Any other layout is still redrawn. Co-Authored-By: Claude Opus 5.5 --- apple/RNCPageCurlRenderer.mm | 45 ++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/apple/RNCPageCurlRenderer.mm b/apple/RNCPageCurlRenderer.mm index bd1e6da25..1b23690a6 100644 --- a/apple/RNCPageCurlRenderer.mm +++ b/apple/RNCPageCurlRenderer.mm @@ -390,9 +390,43 @@ - (void)buildMeshes return nil; } CFTimeInterval start = CACurrentMediaTime(); - // MTKTextureLoader refuses the snapshot's bitmap layout; redraw it as plain BGRA instead size_t width = CGImageGetWidth(cg); size_t height = CGImageGetHeight(cg); + + // a WKWebView snapshot is 32-bit RGBA with alpha last; a UIGraphicsImageRenderer image is BGRA with alpha + // first. Metal reads both as-is given the matching pixel format, so neither needs a redraw. + CGBitmapInfo info = CGImageGetBitmapInfo(cg); + CGImageAlphaInfo alpha = (CGImageAlphaInfo)(info & kCGBitmapAlphaInfoMask); + CGBitmapInfo order = info & kCGBitmapByteOrderMask; + MTLPixelFormat directFormat = MTLPixelFormatInvalid; + if (CGImageGetBitsPerPixel(cg) == 32 && CGImageGetBitsPerComponent(cg) == 8) { + if (order == kCGBitmapByteOrder32Little && (alpha == kCGImageAlphaPremultipliedFirst || alpha == kCGImageAlphaNoneSkipFirst)) { + directFormat = MTLPixelFormatBGRA8Unorm; + } else if ((order == kCGBitmapByteOrderDefault || order == kCGBitmapByteOrder32Big) && (alpha == kCGImageAlphaPremultipliedLast || alpha == kCGImageAlphaNoneSkipLast)) { + directFormat = MTLPixelFormatRGBA8Unorm; + } + } + + MTLTextureDescriptor *descriptor = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:directFormat != MTLPixelFormatInvalid ? directFormat : MTLPixelFormatBGRA8Unorm + width:width + height:height + mipmapped:NO]; + descriptor.usage = MTLTextureUsageShaderRead; + descriptor.storageMode = MTLStorageModeShared; + id texture = [self.device newTextureWithDescriptor:descriptor]; + + if (directFormat != MTLPixelFormatInvalid) { + CFDataRef data = CGDataProviderCopyData(CGImageGetDataProvider(cg)); + if (data != nil) { + [texture replaceRegion:MTLRegionMake2D(0, 0, width, height) mipmapLevel:0 withBytes:CFDataGetBytePtr(data) bytesPerRow:CGImageGetBytesPerRow(cg)]; + CFRelease(data); + RNCPageCurlLog(@"[page-curl] metal: texture %zux%zu in %.1fms (direct %@)", width, height, (CACurrentMediaTime() - start) * 1000.0, directFormat == MTLPixelFormatRGBA8Unorm ? @"rgba" : @"bgra"); + return texture; + } + } + + // any other layout is redrawn as plain BGRA first + RNCPageCurlLog(@"[page-curl] metal: snapshot layout bpp=%zu bpc=%zu bytesPerRow=%zu bitmapInfo=0x%x", CGImageGetBitsPerPixel(cg), CGImageGetBitsPerComponent(cg), CGImageGetBytesPerRow(cg), (unsigned)info); size_t bytesPerRow = width * 4; void *bytes = calloc(height, bytesPerRow); CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); @@ -406,16 +440,9 @@ - (void)buildMeshes } CGContextDrawImage(context, CGRectMake(0, 0, width, height), cg); CGContextRelease(context); - MTLTextureDescriptor *descriptor = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatBGRA8Unorm - width:width - height:height - mipmapped:NO]; - descriptor.usage = MTLTextureUsageShaderRead; - descriptor.storageMode = MTLStorageModeShared; - id texture = [self.device newTextureWithDescriptor:descriptor]; [texture replaceRegion:MTLRegionMake2D(0, 0, width, height) mipmapLevel:0 withBytes:bytes bytesPerRow:bytesPerRow]; free(bytes); - RNCPageCurlLog(@"[page-curl] metal: texture %zux%zu in %.1fms", width, height, (CACurrentMediaTime() - start) * 1000.0); + RNCPageCurlLog(@"[page-curl] metal: texture %zux%zu in %.1fms (redrawn)", width, height, (CACurrentMediaTime() - start) * 1000.0); return texture; } From 527998c69ca29c14d9ca4a613e14774a7e6417f4 Mon Sep 17 00:00:00 2001 From: Artem Litchmanov Date: Fri, 23 Oct 2026 15:30:50 -0700 Subject: [PATCH 2/2] Page curl: state machine interface and its first tests The bake loop moves into RNCPageCurlStateMachine, Foundation only, as events in and effects out. This commit has the interface, an empty implementation, and the first eight tests, which are expected to fail. The tests are a CocoaPods test spec, so an app opts in with :testspecs => ['Tests'] and runs them in its own workspace. Co-Authored-By: Claude Opus 5.5 --- apple-tests/RNCPageCurlStateMachineTests.m | 134 +++++++++++++++++++++ apple/RNCPageCurlStateMachine.h | 84 +++++++++++++ apple/RNCPageCurlStateMachine.m | 82 +++++++++++++ package.json | 1 + react-native-webview.podspec | 7 ++ 5 files changed, 308 insertions(+) create mode 100644 apple-tests/RNCPageCurlStateMachineTests.m create mode 100644 apple/RNCPageCurlStateMachine.h create mode 100644 apple/RNCPageCurlStateMachine.m diff --git a/apple-tests/RNCPageCurlStateMachineTests.m b/apple-tests/RNCPageCurlStateMachineTests.m new file mode 100644 index 000000000..6d73be609 --- /dev/null +++ b/apple-tests/RNCPageCurlStateMachineTests.m @@ -0,0 +1,134 @@ +#import +#import "RNCPageCurlStateMachine.h" + +static NSDictionary *Settle(NSInteger page, NSInteger chunk) +{ + return @{@"type": @"settled", @"page": @(page), @"totalPages": @10, @"chunkIndex": @(chunk), @"isLastChunk": @NO}; +} + +// one line per effect the controller has to carry out; notes are log lines only +static NSArray *Describe(NSArray *effects) +{ + NSMutableArray *lines = [NSMutableArray array]; + for (RNCPageCurlEffect *effect in effects) { + switch (effect.kind) { + case RNCPageCurlEffectEnterState: + [lines addObject:[NSString stringWithFormat:@"state %@ (%@)", RNCPageCurlStateName(effect.state), effect.reason]]; + break; + case RNCPageCurlEffectBake: + [lines addObject:[NSString stringWithFormat:@"bake page %@ chunk %@", effect.settle[@"page"], effect.settle[@"chunkIndex"]]]; + break; + case RNCPageCurlEffectBlankSlots: + [lines addObject:@"blank slots"]; + break; + case RNCPageCurlEffectShowLandedPage: + [lines addObject:[NSString stringWithFormat:@"show landed %@", effect.direction]]; + break; + case RNCPageCurlEffectShowCurrentPage: + [lines addObject:@"show current"]; + break; + case RNCPageCurlEffectLand: + [lines addObject:[NSString stringWithFormat:@"land %@", effect.direction]]; + break; + case RNCPageCurlEffectRequestSettle: + [lines addObject:@"request settle"]; + break; + case RNCPageCurlEffectNote: + break; + } + } + return lines; +} + +@interface RNCPageCurlStateMachineTests : XCTestCase +@end + +@implementation RNCPageCurlStateMachineTests { + RNCPageCurlStateMachine *_machine; +} + +- (void)setUp +{ + _machine = [RNCPageCurlStateMachine new]; +} + +// enabled, first page baked, idle +- (void)bakeFirstPage +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:YES]; +} + +#pragma mark - the bake loop + +- (void)testStateNamesMatchTheLog +{ + XCTAssertEqualObjects(RNCPageCurlStateName(RNCPageCurlStateIdle), @"idle"); + XCTAssertEqualObjects(RNCPageCurlStateName(RNCPageCurlStateAnimating), @"animating"); + XCTAssertEqualObjects(RNCPageCurlStateName(RNCPageCurlStateBaking), @"baking"); + XCTAssertEqualObjects(RNCPageCurlStateName(RNCPageCurlStateAwaitingSettle), @"awaitingSettle"); + XCTAssertEqualObjects(RNCPageCurlStateName(RNCPageCurlStateAwaitingTapResult), @"awaitingTapResult"); +} + +- (void)testEnablingWaitsForTheFirstSettle +{ + XCTAssertEqualObjects(Describe([_machine enable]), (@[@"state awaitingSettle (enabled)"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateAwaitingSettle); +} + +- (void)testASettleStartsABake +{ + [_machine enable]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(3, 1)]), (@[@"state baking (settle)", @"bake page 3 chunk 1"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateBaking); +} + +- (void)testAFinishedBakeOpensInput +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + XCTAssertEqualObjects(Describe([_machine bakeFinished:YES]), (@[@"state idle (baked)"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateIdle); +} + +- (void)testASettleWhileIdleBakesAgain +{ + [self bakeFirstPage]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(4, 1)]), (@[@"state baking (settle)", @"bake page 4 chunk 1"])); +} + +- (void)testASettleDuringABakeIsKeptAndBakedAfter +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(4, 1)]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateBaking); + XCTAssertEqualObjects(Describe([_machine bakeFinished:YES]), (@[@"bake page 4 chunk 1"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateBaking); + XCTAssertEqualObjects(Describe([_machine bakeFinished:YES]), (@[@"state idle (baked)"])); +} + +- (void)testOnlyTheLatestSettleKeptDuringABakeIsBaked +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + [_machine settled:Settle(4, 1)]; + [_machine settled:Settle(5, 1)]; + XCTAssertEqualObjects(Describe([_machine bakeFinished:YES]), (@[@"bake page 5 chunk 1"])); +} + +- (void)testEveryStateChangeMovesTheSerial +{ + NSUInteger initial = _machine.stateSerial; + [_machine enable]; + NSUInteger enabled = _machine.stateSerial; + [_machine settled:Settle(3, 1)]; + NSUInteger baking = _machine.stateSerial; + [_machine settled:Settle(4, 1)]; + XCTAssertNotEqual(initial, enabled); + XCTAssertNotEqual(enabled, baking); + XCTAssertEqual(_machine.stateSerial, baking, @"a kept settle changes no state"); +} + +@end diff --git a/apple/RNCPageCurlStateMachine.h b/apple/RNCPageCurlStateMachine.h new file mode 100644 index 000000000..80019ed35 --- /dev/null +++ b/apple/RNCPageCurlStateMachine.h @@ -0,0 +1,84 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +// 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 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, +}; + +FOUNDATION_EXPORT NSString *RNCPageCurlStateName(RNCPageCurlState state); + +typedef NS_ENUM(NSInteger, RNCPageCurlEffectKind) { + // the state changed; `state` and `reason` + RNCPageCurlEffectEnterState, + // snapshot every slot around the page `settle` names, then report bakeFinished: + RNCPageCurlEffectBake, + // the slots describe another page: no direction curls until the next bake + RNCPageCurlEffectBlankSlots, + // the turn toward `direction` landed: the cover shows that page + RNCPageCurlEffectShowLandedPage, + // the turn was cancelled: the cover shows the page it started on + RNCPageCurlEffectShowCurrentPage, + // ask the manager to turn the page toward `direction`, then report landingFinished: + RNCPageCurlEffectLand, + // ask the manager to settle again, then report settleRequestFinished:token: with `token` + RNCPageCurlEffectRequestSettle, + // nothing to do; `reason` is a line for the log + RNCPageCurlEffectNote, +}; + +@interface RNCPageCurlEffect : NSObject +@property (nonatomic, readonly) RNCPageCurlEffectKind kind; +@property (nonatomic, readonly) RNCPageCurlState state; +@property (nonatomic, readonly, copy, nullable) NSString *reason; +@property (nonatomic, readonly, copy, nullable) NSDictionary *settle; +@property (nonatomic, readonly, copy, nullable) NSString *direction; +@property (nonatomic, readonly) NSUInteger token; +@end + +/** + * The page curl's bake loop as events in and effects out, with no views and no webview. + * The controller reports what happened and carries out the effects in order. + */ +@interface RNCPageCurlStateMachine : NSObject + +@property (nonatomic, readonly) RNCPageCurlState state; +// changes on every state change and on teardown +@property (nonatomic, readonly) NSUInteger stateSerial; +@property (nonatomic, readonly) BOOL canBeginTurn; +// state and what is being held, for the log +@property (nonatomic, readonly) NSString *summary; + +- (NSArray *)enable; +- (void)teardown; + +// messages from the manager +- (NSArray *)settled:(NSDictionary *)settle; +- (NSArray *)unsettled; +- (NSArray *)touchEnded; + +// the finger +- (NSArray *)tapped; +- (NSArray *)turnBegan; +- (NSArray *)turnFinishedToward:(NSString *)direction completed:(BOOL)completed; + +// results of effects +- (NSArray *)bakeFinished:(BOOL)ok; +- (NSArray *)landingFinished:(BOOL)ok; +- (NSArray *)settleRequestFinished:(BOOL)ok token:(NSUInteger)token; + +// the bakes no longer match the page (resize, spine change) +- (NSArray *)rebakeRequested:(NSString *)reason; + +@end + +NS_ASSUME_NONNULL_END diff --git a/apple/RNCPageCurlStateMachine.m b/apple/RNCPageCurlStateMachine.m new file mode 100644 index 000000000..59da31d57 --- /dev/null +++ b/apple/RNCPageCurlStateMachine.m @@ -0,0 +1,82 @@ +#import "RNCPageCurlStateMachine.h" + +NSString *RNCPageCurlStateName(RNCPageCurlState state) +{ + return @"?"; +} + +@implementation RNCPageCurlEffect +@end + +@implementation RNCPageCurlStateMachine + +- (BOOL)canBeginTurn +{ + return NO; +} + +- (NSString *)summary +{ + return @""; +} + +- (NSArray *)enable +{ + return @[]; +} + +- (void)teardown +{ +} + +- (NSArray *)settled:(NSDictionary *)settle +{ + return @[]; +} + +- (NSArray *)unsettled +{ + return @[]; +} + +- (NSArray *)touchEnded +{ + return @[]; +} + +- (NSArray *)tapped +{ + return @[]; +} + +- (NSArray *)turnBegan +{ + return @[]; +} + +- (NSArray *)turnFinishedToward:(NSString *)direction completed:(BOOL)completed +{ + return @[]; +} + +- (NSArray *)bakeFinished:(BOOL)ok +{ + return @[]; +} + +- (NSArray *)landingFinished:(BOOL)ok +{ + return @[]; +} + +- (NSArray *)settleRequestFinished:(BOOL)ok token:(NSUInteger)token +{ + return @[]; +} + +- (NSArray *)rebakeRequested:(NSString *)reason +{ + return @[]; +} + +@end diff --git a/package.json b/package.json index d602ef8aa..c19093b53 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "android/build.gradle", "android/gradle.properties", "apple", + "apple-tests", "ios", "macos", "windows", diff --git a/react-native-webview.podspec b/react-native-webview.podspec index 24e7a1371..ebac8235d 100644 --- a/react-native-webview.podspec +++ b/react-native-webview.podspec @@ -19,6 +19,13 @@ Pod::Spec.new do |s| s.source_files = "apple/**/*.{h,m,mm,swift}" + # opt in from a Podfile with :testspecs => ['Tests'] + s.test_spec 'Tests' do |test_spec| + test_spec.source_files = "apple-tests/**/*.{h,m}" + # the tests are Objective-C, but the bundle links the pod's C++ dependencies + test_spec.library = "c++" + end + if defined?(install_modules_dependencies()) != nil install_modules_dependencies(s); else