craft-native 0.0.245 → 0.0.246

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import Foundation
2
2
  import CoreFoundation
3
+ import ImageIO
3
4
  import AVFoundation
4
5
  import AVKit
5
6
  import CryptoKit
@@ -3847,8 +3848,9 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
3847
3848
 
3848
3849
  @objc private func refreshPulled() {
3849
3850
  refreshHandler?()
3850
- // The handler's synchronous render has landed by now.
3851
- DispatchQueue.main.async { [weak self] in
3851
+ // Once the script has answered the pull (and its render has landed):
3852
+ // a script on its own thread answers a turn or two later.
3853
+ CraftNativeScriptThread.afterScript { [weak self] in
3852
3854
  guard let self, !self.refreshingProp, self.pullToRefresh.isRefreshing else { return }
3853
3855
  self.pullToRefresh.endRefreshing()
3854
3856
  }
@@ -4375,6 +4377,45 @@ private final class CraftNativeTextView: UITextView {
4375
4377
  }
4376
4378
  }
4377
4379
 
4380
+ /// Where native screens run their JavaScript.
4381
+ ///
4382
+ /// On the main thread by default. With `nativeScriptThread: "background"` in
4383
+ /// craft.config.json, every screen's script runs on one serial thread of its
4384
+ /// own (the shared virtual machine's only thread): evaluating its bundle,
4385
+ /// handling events and timers, and working out each render all leave the main
4386
+ /// thread, which only applies the results to UIKit. Messages between the two
4387
+ /// were already asynchronous JSON, so the order of everything is kept.
4388
+ enum CraftNativeScriptThread {
4389
+ static var isBackground = false
4390
+ static let queue = DispatchQueue(label: "craft.native.script", qos: .userInteractive)
4391
+
4392
+ /// `work` where the screens' JavaScript runs: now on the main thread, or
4393
+ /// queued in order on the script thread.
4394
+ static func run(_ work: @escaping () -> Void) {
4395
+ if isBackground { queue.async(execute: work) } else { work() }
4396
+ }
4397
+
4398
+ /// From the script thread, `work` on the main thread, not waited for.
4399
+ static func toMain(_ work: @escaping () -> Void) {
4400
+ if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) }
4401
+ }
4402
+
4403
+ /// From the script thread, `work` on the main thread, waited for: for the
4404
+ /// few host functions a script calls synchronously that read main-thread
4405
+ /// state (the Web Audio graph). The main thread never waits for the
4406
+ /// script thread, so this cannot deadlock.
4407
+ static func onMain<T>(_ work: () -> T) -> T {
4408
+ Thread.isMainThread ? work() : DispatchQueue.main.sync(execute: work)
4409
+ }
4410
+
4411
+ /// `work` on the main thread once a script has handled what was sent to
4412
+ /// it before now, and what it sent back has been applied.
4413
+ static func afterScript(_ work: @escaping () -> Void) {
4414
+ if isBackground { queue.async { DispatchQueue.main.async(execute: work) } }
4415
+ else { DispatchQueue.main.async(execute: work) }
4416
+ }
4417
+ }
4418
+
4378
4419
  /// The native screens' JavaScript. A screen is a JavaScriptCore context of
4379
4420
  /// its own and evaluates the whole file it is given, so each route has a file
4380
4421
  /// of its own when stx wrote one (`dist/native-screens/<route>.js`, stx's
@@ -4382,7 +4423,8 @@ private final class CraftNativeTextView: UITextView {
4382
4423
  /// screen's. The one-file `dist/native-screen.js` remains the fallback.
4383
4424
  enum CraftNativeBundle {
4384
4425
  /// Every screen's context lives in this one virtual machine. They all run
4385
- /// on the main thread, the machine's only one, and sharing it shares its
4426
+ /// on one thread (main, or the script thread: CraftNativeScriptThread),
4427
+ /// the machine's only one, and sharing it shares its
4386
4428
  /// code cache: a screen opened again (a second workout, a tab rebuilt)
4387
4429
  /// is not parsed again. Each screen keeps a global object of its own.
4388
4430
  static let virtualMachine = JSVirtualMachine()!
@@ -4409,10 +4451,39 @@ enum CraftNativeBundle {
4409
4451
  url(for: nil) != nil || Bundle.main.url(forResource: "native-screens", withExtension: nil, subdirectory: "dist") != nil
4410
4452
  }
4411
4453
 
4454
+ /// The runtime a route file runs on (stx's `__runtime.js`): evaluated
4455
+ /// first in each screen's context, as a source of its own, so the shared
4456
+ /// virtual machine parses it once for every screen. Nil for a one-file
4457
+ /// bundle, which carries its runtime itself, and for route files from an
4458
+ /// stx that wrote none.
4459
+ static func runtime(for route: URL) -> (source: String, url: URL)? {
4460
+ guard route.deletingLastPathComponent().lastPathComponent == "native-screens",
4461
+ let url = Bundle.main.url(forResource: "__runtime", withExtension: "js", subdirectory: "dist/native-screens") else { return nil }
4462
+ return read(url)
4463
+ }
4464
+
4465
+ /// A language's messages for an area, as JSON text: stx's
4466
+ /// `__messages/<language>/<area>.json` beside the route files. Nil for a
4467
+ /// language or area the build has none for, and for a name that is not
4468
+ /// one (no path outside the messages).
4469
+ static func messages(language: String, area: String) -> String? {
4470
+ guard language.range(of: #"^[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil,
4471
+ area.range(of: #"^[A-Za-z0-9_-]+(/[A-Za-z0-9_-]+)*$"#, options: .regularExpression) != nil,
4472
+ let routes = Bundle.main.url(forResource: "native-screens", withExtension: nil, subdirectory: "dist") else { return nil }
4473
+ let url = routes.appendingPathComponent("__messages", isDirectory: true)
4474
+ .appendingPathComponent(language, isDirectory: true)
4475
+ .appendingPathComponent("\(area).json")
4476
+ return read(url)?.source
4477
+ }
4478
+
4412
4479
  /// The route's source, read from disk once and kept while memory allows:
4413
4480
  /// a push read and decoded the file on the main thread every time.
4414
4481
  static func source(for route: String?) -> (source: String, url: URL)? {
4415
4482
  guard let url = url(for: route) else { return nil }
4483
+ return read(url)
4484
+ }
4485
+
4486
+ private static func read(_ url: URL) -> (source: String, url: URL)? {
4416
4487
  let key = url.path as NSString
4417
4488
  if let cached = sources.object(forKey: key) { return (cached as String, url) }
4418
4489
  guard let text = try? String(contentsOf: url, encoding: .utf8) else { return nil }
@@ -4423,7 +4494,8 @@ enum CraftNativeBundle {
4423
4494
 
4424
4495
  final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate {
4425
4496
  private final class RenderedNode {
4426
- let identity: String
4497
+ /// A recycled list row's root takes the identity of the row it shows next.
4498
+ var identity: String
4427
4499
  let type: String
4428
4500
  let view: UIView
4429
4501
  var protocolId: String?
@@ -4509,6 +4581,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4509
4581
  }
4510
4582
  }
4511
4583
  private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
4584
+ /// Rows their cells left for reuse, by list and by the row's root view.
4585
+ private var flatListRecycled: [ObjectIdentifier: [ObjectIdentifier: RenderedNode]] = [:]
4512
4586
  private var flatListOwners: [String: String] = [:]
4513
4587
  /// A drawing's shapes are not views: a change to one redraws its `Svg`.
4514
4588
  private var svgOwners: [String: String] = [:]
@@ -4528,7 +4602,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4528
4602
  private let capabilityScope = UUID().uuidString
4529
4603
  private var pendingCapabilityRequests = Set<String>()
4530
4604
  private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
4531
- private var pendingTimers: [Int: DispatchWorkItem] = [:]
4605
+ /// The script's timers. Touched only where the script runs.
4606
+ private final class Timers {
4607
+ var pending: [Int: DispatchWorkItem] = [:]
4608
+ }
4609
+ private let timers = Timers()
4532
4610
  private var lifecycleObservers: [NSObjectProtocol] = []
4533
4611
  private var deepLinkListener: UUID?
4534
4612
  private var keyboardSafeAreaInset: CGFloat = 0
@@ -4562,7 +4640,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4562
4640
  disposeRuntime()
4563
4641
  imageTasks.values.forEach { $0.cancel() }
4564
4642
  pendingCapabilityDeadlines.values.forEach { $0.cancel() }
4565
- pendingTimers.values.forEach { $0.cancel() }
4643
+ let timers = self.timers
4644
+ CraftNativeScriptThread.run { timers.pending.values.forEach { $0.cancel() } }
4566
4645
  pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
4567
4646
  lifecycleObservers.forEach(NotificationCenter.default.removeObserver)
4568
4647
  if let deepLinkListener = deepLinkListener { DeepLinkManager.shared.removeNativeListener(deepLinkListener) }
@@ -4591,8 +4670,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4591
4670
  rootTrailingToSafeArea!
4592
4671
  ])
4593
4672
  let pacing = CraftFramePacing.shared
4594
- pacing.measure("mount \(routeName ?? "screen") setup") { setupJavaScript() }
4595
- pacing.measure("mount \(routeName ?? "screen") bundle") { loadBundle() }
4673
+ let inputs = scriptInputs()
4674
+ let name = routeName ?? "screen"
4675
+ CraftNativeScriptThread.run { [self] in
4676
+ pacing.measure("mount \(name) setup") { setupJavaScript(inputs) }
4677
+ pacing.measure("mount \(name) bundle") { loadBundle() }
4678
+ }
4596
4679
  observeNativeEvents()
4597
4680
  if isAppScript {
4598
4681
  // Its bundle has run, so its listeners are in place: the app's
@@ -4727,14 +4810,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4727
4810
  func receiveRouteParams(_ params: [String: Any]) {
4728
4811
  routeParams = params
4729
4812
  guard isViewLoaded, !runtimeDisposed else { return }
4730
- jsContext.setObject(params as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
4813
+ let context = jsContext
4814
+ CraftNativeScriptThread.run { context.setObject(params as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString) }
4731
4815
  send(type: "ROUTE_PARAMS", payload: ["params": params])
4732
4816
  }
4733
4817
 
4734
4818
  private func disposeRuntime() {
4735
4819
  guard !runtimeDisposed else { return }
4736
4820
  runtimeDisposed = true
4737
- jsContext.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: [])
4821
+ let context = jsContext
4822
+ CraftNativeScriptThread.run { context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: []) }
4738
4823
  audio.close()
4739
4824
  sounds.close()
4740
4825
  streams.close()
@@ -4796,7 +4881,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4796
4881
  return value.toString()
4797
4882
  }
4798
4883
 
4799
- private func setupJavaScript() {
4884
+ /// What a screen's script starts with that only the main thread may read:
4885
+ /// the app's state, the appearance, what the device can do.
4886
+ private struct ScriptInputs {
4887
+ let capabilityJSON: String
4888
+ let appState: String
4889
+ let colorScheme: String
4890
+ let appearanceJSON: String
4891
+ let sessionTokenKey: String
4892
+ }
4893
+
4894
+ private func scriptInputs() -> ScriptInputs {
4895
+ let capabilities = CraftNativeActions.capabilities(config: config)
4896
+ let capabilityData = try? JSONSerialization.data(withJSONObject: capabilities)
4897
+ return ScriptInputs(
4898
+ capabilityJSON: capabilityData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]",
4899
+ appState: CraftNativeActions.currentAppState(),
4900
+ colorScheme: colorScheme,
4901
+ appearanceJSON: appearanceJSON(),
4902
+ sessionTokenKey: Self.jsonString(CraftSession.tokenKey(config))
4903
+ )
4904
+ }
4905
+
4906
+ private func setupJavaScript(_ inputs: ScriptInputs) {
4800
4907
  if let routeName = routeName {
4801
4908
  jsContext.setObject(routeName, forKeyedSubscript: "__stxNativeRoute" as NSString)
4802
4909
  }
@@ -4811,43 +4918,55 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4811
4918
  let stack = Self.safeJavaScriptString(exception?.objectForKeyedSubscript("stack")) ?? ""
4812
4919
  let screen = self?.screenName(in: context) ?? "screen"
4813
4920
  CraftNativeConsole.write("error", category: screen, message: stack.isEmpty || stack == "undefined" ? "Uncaught \(message)" : "Uncaught \(message)\n\(stack)")
4814
- // To the app's crash reporter too (bughq, when configured): an
4815
- // uncaught error in a shipped screen is otherwise only in a log
4816
- // nobody reads on someone else's phone.
4817
- CraftCrashReporter.shared.captureJavaScript(
4818
- name: Self.safeJavaScriptString(exception?.objectForKeyedSubscript("name")),
4819
- message: message,
4820
- stack: stack,
4821
- route: screen,
4822
- isAppScript: self?.isAppScript ?? false
4823
- )
4824
- self?.hybridFailed(message)
4921
+ let name = Self.safeJavaScriptString(exception?.objectForKeyedSubscript("name"))
4922
+ CraftNativeScriptThread.toMain { [weak self] in
4923
+ // To the app's crash reporter too (bughq, when configured):
4924
+ // an uncaught error in a shipped screen is otherwise only in
4925
+ // a log nobody reads on someone else's phone.
4926
+ CraftCrashReporter.shared.captureJavaScript(
4927
+ name: name,
4928
+ message: message,
4929
+ stack: stack,
4930
+ route: screen,
4931
+ isAppScript: self?.isAppScript ?? false
4932
+ )
4933
+ self?.hybridFailed(message)
4934
+ }
4825
4935
  }
4826
4936
  let postMessage: @convention(block) (String) -> Void = { [weak self] json in
4827
4937
  self?.receive(json)
4828
4938
  }
4829
4939
  jsContext.setObject(postMessage, forKeyedSubscript: "craftNativePostMessage" as NSString)
4940
+ // A language's messages for an area, as JSON text, the first time the
4941
+ // screen looks inside them (stx's `messages`): a screen parses only
4942
+ // the language it is shown in, and only the areas it uses. Called
4943
+ // from the screen's script, on its thread, and answered there.
4944
+ let readMessages: @convention(block) (String, String) -> Any = { language, area in
4945
+ CraftNativeBundle.messages(language: language, area: area) ?? NSNull()
4946
+ }
4947
+ jsContext.setObject(readMessages, forKeyedSubscript: "__stxNativeReadMessages" as NSString)
4830
4948
  // JavaScriptCore has no timers of its own. The callbacks stay in
4831
4949
  // JavaScript; native only owns the main-queue deadline, so route
4832
4950
  // teardown cancels every pending timer with the controller.
4833
- let scheduleTimer: @convention(block) (Int, Double) -> Void = { [weak self] id, delay in
4834
- guard let self = self else { return }
4835
- let work = DispatchWorkItem { [weak self] in
4836
- guard let self = self, self.pendingTimers.removeValue(forKey: id) != nil else { return }
4837
- self.jsContext.objectForKeyedSubscript("__craftNativeFireTimer")?.call(withArguments: [id])
4838
- }
4839
- self.pendingTimers[id] = work
4951
+ let timers = self.timers
4952
+ let context = jsContext
4953
+ let scheduleTimer: @convention(block) (Int, Double) -> Void = { id, delay in
4954
+ let work = DispatchWorkItem { [weak context] in
4955
+ guard timers.pending.removeValue(forKey: id) != nil else { return }
4956
+ context?.objectForKeyedSubscript("__craftNativeFireTimer")?.call(withArguments: [id])
4957
+ }
4958
+ timers.pending[id] = work
4840
4959
  let milliseconds = delay.isFinite ? Int(min(max(delay, 0), 2_147_483_647)) : 0
4841
- DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(milliseconds), execute: work)
4960
+ // Fired where the script runs.
4961
+ let queue = CraftNativeScriptThread.isBackground ? CraftNativeScriptThread.queue : .main
4962
+ queue.asyncAfter(deadline: .now() + .milliseconds(milliseconds), execute: work)
4842
4963
  }
4843
- let cancelTimer: @convention(block) (Int) -> Void = { [weak self] id in
4844
- self?.pendingTimers.removeValue(forKey: id)?.cancel()
4964
+ let cancelTimer: @convention(block) (Int) -> Void = { id in
4965
+ timers.pending.removeValue(forKey: id)?.cancel()
4845
4966
  }
4846
4967
  jsContext.setObject(scheduleTimer, forKeyedSubscript: "craftNativeScheduleTimer" as NSString)
4847
4968
  jsContext.setObject(cancelTimer, forKeyedSubscript: "craftNativeCancelTimer" as NSString)
4848
- let capabilities = CraftNativeActions.capabilities(config: config)
4849
- let capabilityData = try? JSONSerialization.data(withJSONObject: capabilities)
4850
- let capabilityJSON = capabilityData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
4969
+ let capabilityJSON = inputs.capabilityJSON
4851
4970
  // The app's site and build, as JSON strings: the site an all-native
4852
4971
  // app's paths and links belong to (`devServerURL`), and the version a
4853
4972
  // request or a crash report names.
@@ -4855,7 +4974,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4855
4974
  let appURL = Self.jsonString(config.devServerURL ?? "")
4856
4975
  let appVersion = Self.jsonString(info?["CFBundleShortVersionString"] as? String ?? "")
4857
4976
  let appBuild = Self.jsonString(info?["CFBundleVersion"] as? String ?? "")
4858
- let sessionTokenKey = Self.jsonString(CraftSession.tokenKey(config))
4977
+ let sessionTokenKey = inputs.sessionTokenKey
4859
4978
  jsContext.evaluateScript("""
4860
4979
  // JavaScriptCore has no DOM window, but browser-safe SDK bridges
4861
4980
  // address the host through `window.craft`. Keep native screens
@@ -4868,9 +4987,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4868
4987
  capabilityProtocolVersion: \(craftNativeCapabilityProtocolVersion),
4869
4988
  capabilityTimeoutMs: \(craftNativeCapabilityTimeoutMilliseconds),
4870
4989
  capabilities: \(capabilityJSON),
4871
- initialAppState: "\(CraftNativeActions.currentAppState())",
4872
- colorScheme: "\(colorScheme)",
4873
- appearance: \(appearanceJSON()),
4990
+ initialAppState: "\(inputs.appState)",
4991
+ colorScheme: "\(inputs.colorScheme)",
4992
+ appearance: \(inputs.appearanceJSON),
4874
4993
  appURL: \(appURL),
4875
4994
  appVersion: \(appVersion),
4876
4995
  appBuild: \(appBuild),
@@ -4968,6 +5087,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4968
5087
  /// else the one a route bundle selected for itself.
4969
5088
  private func screenName(in context: JSContext? = nil) -> String {
4970
5089
  if let routeName, !routeName.isEmpty { return routeName }
5090
+ // The script's own context is read only where the script runs.
5091
+ if context == nil, CraftNativeScriptThread.isBackground { return navigationItem.title ?? "screen" }
4971
5092
  if let selected = (context ?? jsContext).objectForKeyedSubscript("__stxNativeRoute"), selected.isString {
4972
5093
  return selected.toString()
4973
5094
  }
@@ -5017,13 +5138,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5017
5138
  }
5018
5139
  let snapshotSet: @convention(block) (String, JSValue, JSValue) -> Void = { name, value, done in
5019
5140
  CraftSnapshots.write(name, json: value.isNull || value.isUndefined ? nil : value.toString()) { ok in
5020
- done.call(withArguments: [ok])
5141
+ CraftNativeScriptThread.run { done.call(withArguments: [ok]) }
5021
5142
  }
5022
5143
  }
5023
5144
  let snapshotClear: @convention(block) (JSValue, JSValue) -> Void = { keep, done in
5024
5145
  let names = (keep.isUndefined || keep.isNull ? [] : (keep.toArray() ?? [])).compactMap { $0 as? String }
5025
5146
  CraftSnapshots.clear(keeping: Set(names)) { ok in
5026
- done.call(withArguments: [ok])
5147
+ CraftNativeScriptThread.run { done.call(withArguments: [ok]) }
5027
5148
  }
5028
5149
  }
5029
5150
  let config = self.config
@@ -5033,7 +5154,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5033
5154
  let setOptions: @convention(block) (String) -> Void = { [weak self] json in
5034
5155
  guard let data = json.data(using: .utf8),
5035
5156
  let options = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
5036
- self?.applyNavigationOptions(options)
5157
+ CraftNativeScriptThread.toMain { self?.applyNavigationOptions(options) }
5037
5158
  }
5038
5159
  jsContext.setObject(log, forKeyedSubscript: "craftNativeLog" as NSString)
5039
5160
  jsContext.setObject(storageGet, forKeyedSubscript: "craftNativeStorageGet" as NSString)
@@ -5310,19 +5431,27 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5310
5431
  """)
5311
5432
  }
5312
5433
 
5434
+ /// Runs where the script runs (CraftNativeScriptThread).
5313
5435
  private func loadBundle() {
5314
5436
  guard let bundle = CraftNativeBundle.source(for: routeName) else {
5315
- showError("Missing dist/native-screen.js. Compile a .stx screen with `stx native compile` first.")
5437
+ CraftNativeScriptThread.toMain { [weak self] in
5438
+ self?.showError("Missing dist/native-screen.js. Compile a .stx screen with `stx native compile` first.")
5439
+ }
5316
5440
  return
5317
5441
  }
5442
+ if let runtime = CraftNativeBundle.runtime(for: bundle.url) {
5443
+ jsContext.evaluateScript(runtime.source, withSourceURL: runtime.url)
5444
+ }
5318
5445
  jsContext.evaluateScript(bundle.source, withSourceURL: bundle.url)
5319
5446
  // A bundle that names no route leaves `__stxNativeRoute` undefined;
5320
5447
  // its title is the app's, not the word "undefined". A title the
5321
5448
  // script set with `navigation.setOptions` wins over both.
5322
- if routeName == nil, !titleFromOptions,
5323
- let selected = jsContext.objectForKeyedSubscript("__stxNativeRoute"), selected.isString,
5324
- let name = selected.toString(), !name.isEmpty {
5325
- navigationItem.title = name
5449
+ guard routeName == nil,
5450
+ let selected = jsContext.objectForKeyedSubscript("__stxNativeRoute"), selected.isString,
5451
+ let name = selected.toString(), !name.isEmpty else { return }
5452
+ CraftNativeScriptThread.toMain { [weak self] in
5453
+ guard let self, !self.titleFromOptions else { return }
5454
+ self.navigationItem.title = name
5326
5455
  }
5327
5456
  }
5328
5457
 
@@ -5781,7 +5910,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5781
5910
  let token = info["token"] as? String
5782
5911
  let previous = info["previous"] as? String
5783
5912
  self.send(type: "SESSION_CHANGED", payload: ["key": key, "token": token ?? NSNull(), "previous": previous ?? NSNull()])
5784
- self.jsContext.objectForKeyedSubscript("__craftSessionChanged")?.call(withArguments: [token ?? NSNull(), previous ?? NSNull()])
5913
+ let context = self.jsContext
5914
+ CraftNativeScriptThread.run { context.objectForKeyedSubscript("__craftSessionChanged")?.call(withArguments: [token ?? NSNull(), previous ?? NSNull()]) }
5785
5915
  })
5786
5916
  }
5787
5917
  if config.enablePushNotifications {
@@ -5950,10 +6080,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5950
6080
  hybridEvents?.nativeScreen(self, didFail: message)
5951
6081
  }
5952
6082
 
6083
+ /// A message from the screen's script, where the script runs: parsed
6084
+ /// there, then applied on the main thread in the order it was sent.
5953
6085
  private func receive(_ raw: String) {
5954
6086
  guard let data = raw.data(using: .utf8),
5955
6087
  let message = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
5956
- let type = message["type"] as? String else { return }
6088
+ message["type"] is String else { return }
6089
+ CraftNativeScriptThread.toMain { [weak self] in self?.handle(message) }
6090
+ }
6091
+
6092
+ private func handle(_ message: [String: Any]) {
6093
+ guard let type = message["type"] as? String else { return }
5957
6094
  let payload = message["payload"] as? [String: Any] ?? [:]
5958
6095
 
5959
6096
  switch type {
@@ -6000,11 +6137,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6000
6137
  let params = payload["params"] as? [String: Any] ?? [:]
6001
6138
  let next = CraftNativeScreenController(config: config, routeName: screen, routeParams: params)
6002
6139
  next.hybridEvents = hybridEvents
6003
- if type == "NAVIGATE" {
6004
- navigation.pushViewController(next, animated: true)
6005
- } else {
6006
- disposeRuntime()
6007
- navigation.setViewControllers(Array(navigation.viewControllers.dropLast()) + [next], animated: true)
6140
+ next.whenFirstRendered(within: 0.3) { [weak self, weak navigation] in
6141
+ guard let self, let navigation, navigation.topViewController === self else { return }
6142
+ if type == "NAVIGATE" {
6143
+ navigation.pushViewController(next, animated: true)
6144
+ } else {
6145
+ self.disposeRuntime()
6146
+ navigation.setViewControllers(Array(navigation.viewControllers.dropLast()) + [next], animated: true)
6147
+ }
6008
6148
  }
6009
6149
  case "NAVIGATION_SET_OPTIONS":
6010
6150
  applyNavigationOptions(payload)
@@ -6155,7 +6295,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6155
6295
  /// simulator-hosted XCTest of the host APIs.
6156
6296
  @discardableResult
6157
6297
  func evaluateScript(_ script: String) -> JSValue? {
6158
- jsContext.evaluateScript(script)
6298
+ // From the main thread (tests); waits for the script thread's turn.
6299
+ guard CraftNativeScriptThread.isBackground else { return jsContext.evaluateScript(script) }
6300
+ return CraftNativeScriptThread.queue.sync { jsContext.evaluateScript(script) }
6159
6301
  }
6160
6302
 
6161
6303
  // Internal so simulator-hosted XCTest can assert actual UIView identity.
@@ -6166,9 +6308,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6166
6308
  hybridRendered = true
6167
6309
  // The app script's drawing is never on screen: not a first frame.
6168
6310
  if !isAppScript { hybridEvents?.nativeScreenDidRender(self) }
6311
+ let waiting = renderWaiters
6312
+ renderWaiters.removeAll()
6313
+ waiting.forEach { $0() }
6169
6314
  }
6170
6315
  }
6171
6316
 
6317
+ private var renderWaiters: [() -> Void] = []
6318
+
6319
+ /// `work` once the screen has its first content, or after `seconds`,
6320
+ /// whichever comes first. A screen whose script runs on its own thread
6321
+ /// (CraftNativeScriptThread) draws a moment after it is made: a push
6322
+ /// waits for that, so the screen never slides in empty. Now, with the
6323
+ /// script on the main thread, which drew it as it was made.
6324
+ func whenFirstRendered(within seconds: TimeInterval, _ work: @escaping () -> Void) {
6325
+ guard CraftNativeScriptThread.isBackground, !isAppScript else { work(); return }
6326
+ loadViewIfNeeded()
6327
+ guard !hybridRendered else { work(); return }
6328
+ var done = false
6329
+ let once = {
6330
+ guard !done else { return }
6331
+ done = true
6332
+ work()
6333
+ }
6334
+ renderWaiters.append(once)
6335
+ DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: once)
6336
+ }
6337
+
6172
6338
  @discardableResult
6173
6339
  func applyMutation(_ payload: [String: Any]) throws -> CraftNativeMutationResult {
6174
6340
  let result = try mutationDocument.apply(payload)
@@ -6312,15 +6478,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6312
6478
  _ = focused.becomeFirstResponder()
6313
6479
  }
6314
6480
 
6315
- private func reconcile(_ node: [String: Any], identity: String, path: String, previous: RenderedNode?) -> RenderedNode {
6481
+ /// `adopting`: `previous` is a recycled list row's root, whose views may
6482
+ /// show this row instead (`identity` is the new row's).
6483
+ private func reconcile(_ node: [String: Any], identity: String, path: String, previous: RenderedNode?, adopting: Bool = false) -> RenderedNode {
6316
6484
  let type = canonicalType(node["type"] as? String ?? "View")
6317
6485
  let props = node["props"] as? [String: Any] ?? [:]
6318
6486
  let style = node["style"] as? [String: Any] ?? [:]
6319
6487
  let events = node["events"] as? [String: String] ?? [:]
6320
6488
  let children = node["children"] as? [Any] ?? []
6321
6489
  let current: RenderedNode
6322
- if let previous = previous, previous.identity == identity, canReuseView(node, previous: previous) {
6490
+ if let previous = previous, previous.identity == identity || adopting, canReuseView(node, previous: previous) {
6323
6491
  current = previous
6492
+ current.identity = identity
6324
6493
  } else {
6325
6494
  current = RenderedNode(identity: identity, type: type, view: makeView(type, props: props))
6326
6495
  }
@@ -7096,16 +7265,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7096
7265
  columns: Int(number(props["numColumns"]) ?? 1),
7097
7266
  inverted: props["inverted"] as? Bool == true,
7098
7267
  endReachedThreshold: threshold,
7099
- renderItem: { [weak self] node, identity, _ in
7268
+ renderItem: { [weak self] node, identity, reusable in
7100
7269
  guard let self = self else { return UIView() }
7101
- let previous = self.flatListRows[listKey]?[identity]
7270
+ var previous = self.flatListRows[listKey]?[identity]
7271
+ // A cell scrolled back into use brings the views of the row it
7272
+ // showed: the new row reuses them when it has the same shape,
7273
+ // rather than building every view of it again in the frame it
7274
+ // scrolls in (Apple's view-churn hitch).
7275
+ var adopting = false
7276
+ if previous == nil, let reusable,
7277
+ let recycled = self.flatListRecycled[listKey]?.removeValue(forKey: ObjectIdentifier(reusable)) {
7278
+ previous = recycled
7279
+ adopting = true
7280
+ }
7102
7281
  let next = self.reconcile(
7103
7282
  node,
7104
7283
  identity: "list:\(identity)",
7105
7284
  path: "\(path).\(identity)",
7106
- previous: previous
7285
+ previous: previous,
7286
+ adopting: adopting
7107
7287
  )
7108
- if let previous, previous.view !== next.view {
7288
+ // A recycled row's handlers went when it was recycled.
7289
+ if let previous, previous.view !== next.view, !adopting {
7109
7290
  self.forgetHandlers(previous)
7110
7291
  }
7111
7292
  self.flatListRows[listKey, default: [:]][identity] = next
@@ -7115,6 +7296,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7115
7296
  guard let self = self,
7116
7297
  let row = self.flatListRows[listKey]?.removeValue(forKey: identity) else { return }
7117
7298
  self.forgetHandlers(row, preservingInputDrafts: true)
7299
+ // Kept, without its handlers, for the next row its cell shows.
7300
+ var recycled = self.flatListRecycled[listKey, default: [:]]
7301
+ if recycled.count >= 64, let oldest = recycled.keys.first { recycled.removeValue(forKey: oldest) }
7302
+ recycled[ObjectIdentifier(row.view)] = row
7303
+ self.flatListRecycled[listKey] = recycled
7118
7304
  },
7119
7305
  endReached: nonEmptyHandler(events["onEndReached"]).map { [weak self] handler in
7120
7306
  { self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
@@ -7164,6 +7350,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7164
7350
  list.setRefreshHandler(nil, refreshing: false)
7165
7351
  list.discardAll()
7166
7352
  flatListRows.removeValue(forKey: listKey)
7353
+ flatListRecycled.removeValue(forKey: listKey)
7167
7354
  if let owner = node.protocolId { flatListOwners = flatListOwners.filter { $0.value != owner } }
7168
7355
  }
7169
7356
  if node.view is CraftNativeSvgView, let owner = node.protocolId { svgOwners = svgOwners.filter { $0.value != owner } }
@@ -7188,6 +7375,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7188
7375
  endEditingHandlers.removeValue(forKey: id)
7189
7376
  submitHandlers.removeValue(forKey: id)
7190
7377
  inputIdentities.removeValue(forKey: id)
7378
+ unechoedText.removeValue(forKey: id)
7191
7379
  if node.type == "TextInput" && !preservingInputDrafts { inputDrafts.removeValue(forKey: node.identity) }
7192
7380
  longPressHandlers.removeValue(forKey: id)
7193
7381
  textMaxLengths.removeValue(forKey: id)
@@ -7399,8 +7587,39 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7399
7587
  }
7400
7588
  }
7401
7589
 
7590
+ /// Text a field sent its script that the script has not echoed back yet,
7591
+ /// while the script runs on its own thread: a value that matches an older
7592
+ /// one is the script catching up, not a new value, and applying it would
7593
+ /// undo what was typed since.
7594
+ private var unechoedText: [ObjectIdentifier: [String]] = [:]
7595
+
7596
+ private func noteSentText(_ text: String, for view: UIView) {
7597
+ guard CraftNativeScriptThread.isBackground else { return }
7598
+ let id = ObjectIdentifier(view)
7599
+ var sent = unechoedText[id] ?? []
7600
+ sent.append(text)
7601
+ if sent.count > 32 { sent.removeFirst(sent.count - 32) }
7602
+ unechoedText[id] = sent
7603
+ }
7604
+
7605
+ /// Whether `value` is a stale echo of text the view has moved past: it
7606
+ /// matches text sent before the latest. Text the field never sent is the
7607
+ /// script's own value (a field cleared after sending) and applies.
7608
+ private func isStaleEcho(_ value: String, for view: UIView) -> Bool {
7609
+ let id = ObjectIdentifier(view)
7610
+ guard var sent = unechoedText[id], let index = sent.lastIndex(of: value) else {
7611
+ unechoedText[id] = nil
7612
+ return false
7613
+ }
7614
+ let later = sent.count - index - 1
7615
+ sent.removeFirst(index + 1)
7616
+ unechoedText[id] = sent.isEmpty ? nil : sent
7617
+ return later > 0
7618
+ }
7619
+
7402
7620
  private func updateField(_ field: UITextField, value: String?) {
7403
- guard let value = value, field.text != value, field.markedTextRange == nil else { return }
7621
+ guard let value = value, field.markedTextRange == nil else { return }
7622
+ if isStaleEcho(value, for: field) || field.text == value { return }
7404
7623
  let selection = field.selectedTextRange
7405
7624
  let start = selection.map { field.offset(from: field.beginningOfDocument, to: $0.start) }
7406
7625
  let end = selection.map { field.offset(from: field.beginningOfDocument, to: $0.end) }
@@ -7413,7 +7632,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7413
7632
  }
7414
7633
 
7415
7634
  private func updateTextView(_ textView: UITextView, value: String?) {
7416
- guard let value = value, textView.text != value, textView.markedTextRange == nil else { return }
7635
+ guard let value = value, textView.markedTextRange == nil else { return }
7636
+ if isStaleEcho(value, for: textView) || textView.text == value { return }
7417
7637
  let selection = textView.selectedTextRange
7418
7638
  let start = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.start) }
7419
7639
  let end = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.end) }
@@ -7897,6 +8117,64 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7897
8117
  return cache
7898
8118
  }()
7899
8119
 
8120
+ /// The pixels a picture is drawn at, when its style fixes its size (a
8121
+ /// 40-point avatar, a card's photo): nil otherwise, for a picture whose
8122
+ /// own size lays it out, which must keep its full size.
8123
+ static func displayPixels(of view: UIImageView) -> (size: CGSize, scale: CGFloat)? {
8124
+ let style = view.craftLayoutStyle
8125
+ let points: CGSize
8126
+ switch (style.width, style.height, style.aspectRatio) {
8127
+ case (.points(let width)?, .points(let height)?, _):
8128
+ points = CGSize(width: width, height: height)
8129
+ case (.points(let width)?, nil, let ratio?):
8130
+ points = CGSize(width: width, height: width / ratio)
8131
+ case (nil, .points(let height)?, let ratio?):
8132
+ points = CGSize(width: height * ratio, height: height)
8133
+ default:
8134
+ // Sized by its parent (a percentage, a stretch): its laid-out size.
8135
+ guard style.width != nil || style.height != nil || style.aspectRatio != nil,
8136
+ view.bounds.width >= 1, view.bounds.height >= 1 else { return nil }
8137
+ points = view.bounds.size
8138
+ }
8139
+ guard points.width >= 1, points.height >= 1 else { return nil }
8140
+ let scale = max(1, view.window?.screen.scale ?? view.traitCollection.displayScale)
8141
+ return (CGSize(width: ceil(points.width * scale), height: ceil(points.height * scale)), scale)
8142
+ }
8143
+
8144
+ /// Off the main thread: an image decoded at no more pixels than it is
8145
+ /// drawn at, by ImageIO's thumbnailer (Apple's downsampling recipe), so a
8146
+ /// 4000-pixel photo in a 40-point circle is never decoded whole; else
8147
+ /// decoded whole, ahead of its first draw.
8148
+ static func decodeImage(_ data: Data, fitting target: (size: CGSize, scale: CGFloat)?, fill: Bool) -> UIImage? {
8149
+ if let target,
8150
+ let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
8151
+ let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
8152
+ let rawWidth = (properties[kCGImagePropertyPixelWidth] as? NSNumber).map({ CGFloat(truncating: $0) }),
8153
+ let rawHeight = (properties[kCGImagePropertyPixelHeight] as? NSNumber).map({ CGFloat(truncating: $0) }),
8154
+ rawWidth > 0, rawHeight > 0 {
8155
+ // EXIF orientations 5–8 turn the picture a quarter.
8156
+ let turned = ((properties[kCGImagePropertyOrientation] as? NSNumber)?.intValue ?? 1) >= 5
8157
+ let width = turned ? rawHeight : rawWidth
8158
+ let height = turned ? rawWidth : rawHeight
8159
+ let pixels = target.size
8160
+ let factor = fill ? max(pixels.width / width, pixels.height / height) : min(pixels.width / width, pixels.height / height)
8161
+ if factor < 1 {
8162
+ let options = [
8163
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
8164
+ kCGImageSourceShouldCacheImmediately: true,
8165
+ kCGImageSourceCreateThumbnailWithTransform: true,
8166
+ kCGImageSourceThumbnailMaxPixelSize: ceil(max(width, height) * factor),
8167
+ ] as CFDictionary
8168
+ if let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options) {
8169
+ // At the screen's scale, so its size in points is the
8170
+ // size it is drawn at.
8171
+ return UIImage(cgImage: image, scale: target.scale, orientation: .up)
8172
+ }
8173
+ }
8174
+ }
8175
+ return UIImage(data: data).map { $0.preparingForDisplay() ?? $0 }
8176
+ }
8177
+
7900
8178
  /// An image the screen downloads: https anywhere, and http only from this
7901
8179
  /// machine (localhost, 127.0.0.1, ::1, *.localhost), the development
7902
8180
  /// server the app's ATS exception already allows. Internal for XCTest.
@@ -7948,8 +8226,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7948
8226
  return
7949
8227
  }
7950
8228
  if let url = URL(string: uri), Self.loadsImage(from: url) {
8229
+ // Decoded at the size it is drawn, when the style fixes that size.
8230
+ let pixels = Self.displayPixels(of: view)
8231
+ let fill = view.contentMode == .scaleAspectFill
8232
+ let cacheKey = pixels.map { "\(requestKey)@\(Int($0.size.width))x\(Int($0.size.height))\(fill ? "f" : "")" } ?? requestKey
7951
8233
  // Shown before: on screen in this same frame, already decoded.
7952
- if let decoded = Self.decodedImages.object(forKey: requestKey as NSString) {
8234
+ if let decoded = Self.decodedImages.object(forKey: cacheKey as NSString) {
7953
8235
  view.image = imageForDisplay(decoded, view: view)
7954
8236
  imageDidChange(view)
7955
8237
  emitImageEvent(view, handler: imageLoadHandlers[id])
@@ -7962,10 +8244,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7962
8244
  // Decoded here, off the main thread. A UIImage made from data
7963
8245
  // is only decoded when it first draws, on the main thread, in
7964
8246
  // the frame it scrolls into view: Apple's "prepare" hitch.
7965
- let decoded = data.flatMap(UIImage.init(data:)).map { $0.preparingForDisplay() ?? $0 }
8247
+ let decoded = data.flatMap { Self.decodeImage($0, fitting: pixels, fill: fill) }
7966
8248
  if let decoded {
7967
- let pixels = decoded.size.width * decoded.scale * decoded.size.height * decoded.scale
7968
- Self.decodedImages.setObject(decoded, forKey: requestKey as NSString, cost: Int(pixels * 4))
8249
+ let bytes = decoded.size.width * decoded.scale * decoded.size.height * decoded.scale * 4
8250
+ Self.decodedImages.setObject(decoded, forKey: cacheKey as NSString, cost: Int(bytes))
7969
8251
  }
7970
8252
  guard let self, let view else { return }
7971
8253
  DispatchQueue.main.async {
@@ -8215,6 +8497,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8215
8497
  inputIdentities[id].map { inputDrafts[$0] = textView.text ?? "" }
8216
8498
  (textView as? CraftNativeTextView)?.refreshPlaceholder()
8217
8499
  guard let handler = handlers[id] else { return }
8500
+ noteSentText(textView.text ?? "", for: textView)
8218
8501
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
8219
8502
  }
8220
8503
 
@@ -8242,6 +8525,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8242
8525
  inputIdentities[id].map { inputDrafts[$0] = sender.text ?? "" }
8243
8526
  let focused = sender.isFirstResponder ? sender : firstResponder(in: rootStack)
8244
8527
  guard let handler = handlers[id] else { return }
8528
+ noteSentText(sender.text ?? "", for: sender)
8245
8529
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
8246
8530
  restoreFocus(focused)
8247
8531
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak focused] in
@@ -8523,7 +8807,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8523
8807
  if let correlationId = correlationId { message["correlationId"] = correlationId }
8524
8808
  guard let data = try? JSONSerialization.data(withJSONObject: message),
8525
8809
  let json = String(data: data, encoding: .utf8) else { return }
8526
- jsContext.objectForKeyedSubscript("__stxNativeCallback")?.call(withArguments: [json])
8810
+ let context = jsContext
8811
+ CraftNativeScriptThread.run { context.objectForKeyedSubscript("__stxNativeCallback")?.call(withArguments: [json]) }
8527
8812
  }
8528
8813
 
8529
8814
  private func number(_ value: Any?) -> CGFloat? {