@craft-native/ios 0.0.245 → 0.0.247

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.
package/dist/cli.js CHANGED
@@ -298,6 +298,8 @@ function hybridConfigProblems(config) {
298
298
  else if (!usesNativeBundle(config))
299
299
  problems.push('nativeApp runs in the native bundle; it needs nativeScreens or renderer: "native".');
300
300
  }
301
+ if (config.nativeScriptThread !== undefined && config.nativeScriptThread !== "main" && config.nativeScriptThread !== "background")
302
+ problems.push(`nativeScriptThread "${String(config.nativeScriptThread)}" must be "main" or "background".`);
301
303
  return problems;
302
304
  }
303
305
  function installNativeBundle(source, output) {
package/dist/index.d.ts CHANGED
@@ -240,6 +240,15 @@ export interface CraftConfig {
240
240
  * `nativeScreens` or `renderer: "native"`, and the native bundle.
241
241
  */
242
242
  nativeApp?: string;
243
+ /**
244
+ * Where native screens run their JavaScript. `"main"` (the default) runs it
245
+ * on the main thread; `"background"` gives screens a serial thread of their
246
+ * own, so a screen's script, its timers and its renders never hold the
247
+ * main thread while it scrolls or animates. UIKit still runs on main: the
248
+ * shell hands each render to it, and a pushed screen waits for its first
249
+ * render (at most 0.3 s) so it never slides in empty.
250
+ */
251
+ nativeScriptThread?: 'main' | 'background';
243
252
  /**
244
253
  * The native tab bar, shown from the first frame, before the page loads.
245
254
  * The page's own `tabBar.set` replaces it later. Each `id` is the tab's
@@ -319,13 +328,27 @@ export declare function usesNativeBundle(config: Pick<CraftConfig, 'renderer' |
319
328
  */
320
329
  export declare function nativeStartPath(config: Pick<CraftConfig, 'devServerURL'>): string;
321
330
  /** What is wrong with a config's hybrid and tab settings, one sentence each. */
322
- export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'tabs' | 'shareStorage' | 'devServerURL' | 'nativeApp'>): string[];
331
+ export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'tabs' | 'shareStorage' | 'devServerURL' | 'nativeApp' | 'nativeScriptThread'>): string[];
323
332
  /**
324
333
  * The builder installs a directory of route bundles (stx's `routesDir`) as
325
334
  * well as the one-file bundle, and the app runs each screen from its own
326
335
  * route's file. Stacks asks for route bundles only when this is true.
327
336
  */
328
337
  export declare const nativeRouteBundles = true;
338
+ /**
339
+ * The app evaluates stx's shared runtime (`__runtime.js`, stx's
340
+ * `sharedRuntime`) in each screen's context before the route's own file.
341
+ * Stacks asks stx for it only when this is true: a route file built so
342
+ * refuses to run without it.
343
+ */
344
+ export declare const nativeRouteRuntime = true;
345
+ /**
346
+ * The app gives a screen its messages a language at a time (stx's
347
+ * `messages`, `native-screens/__messages/`): read when the screen first
348
+ * looks inside them (`__stxNativeReadMessages`). Stacks leaves the languages
349
+ * out of the screens' code only when this is true.
350
+ */
351
+ export declare const nativeMessages = true;
329
352
  export interface CraftBackgroundRefresh {
330
353
  enabled: boolean;
331
354
  /** The BGTaskScheduler identifier. Default `<bundleId>.refresh`. */
package/dist/index.js CHANGED
@@ -482,9 +482,13 @@ function hybridConfigProblems(config) {
482
482
  else if (!usesNativeBundle(config))
483
483
  problems.push('nativeApp runs in the native bundle; it needs nativeScreens or renderer: "native".');
484
484
  }
485
+ if (config.nativeScriptThread !== undefined && config.nativeScriptThread !== "main" && config.nativeScriptThread !== "background")
486
+ problems.push(`nativeScriptThread "${String(config.nativeScriptThread)}" must be "main" or "background".`);
485
487
  return problems;
486
488
  }
487
489
  var nativeRouteBundles = true;
490
+ var nativeRouteRuntime = true;
491
+ var nativeMessages = true;
488
492
  function installNativeBundle(source, output) {
489
493
  if (!existsSync(source))
490
494
  throw new Error(`Native bundle not found: ${source}`);
@@ -1568,7 +1572,9 @@ export {
1568
1572
  open,
1569
1573
  normalizeNativePath,
1570
1574
  nativeStartPath,
1575
+ nativeRouteRuntime,
1571
1576
  nativeRouteBundles,
1577
+ nativeMessages,
1572
1578
  matchNativePath,
1573
1579
  isHybrid,
1574
1580
  installRuntime,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.245",
3
+ "version": "0.0.247",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1626,6 +1626,10 @@ struct CraftConfig: Codable {
1626
1626
  /// The bundle route of the app script: a screen-less script that runs for
1627
1627
  /// the app's whole life and hears the app's events (CraftHybrid.swift).
1628
1628
  var nativeApp: String? = nil
1629
+ /// Where native screens run their JavaScript: `"main"` (the default) or
1630
+ /// `"background"`, a thread of their own, so a screen's script and its
1631
+ /// renders never hold the main thread (CraftNativeScriptThread).
1632
+ var nativeScriptThread: String? = nil
1629
1633
  /// Crash and error reporting to bughq (CraftCrashReporter.swift). Off
1630
1634
  /// unless the config names a project key.
1631
1635
  var crashReporting: CrashReporting? = nil
@@ -354,6 +354,7 @@ final class CraftHybrid: NSObject {
354
354
  guard !isConfigured else { return }
355
355
  isConfigured = true
356
356
  self.config = config
357
+ CraftNativeScriptThread.isBackground = config.nativeScriptThread == "background"
357
358
  hasWeb = config.renderer != "native"
358
359
  siteURL = config.devServerURL.flatMap(URL.init(string:)).flatMap { url in
359
360
  ["http", "https"].contains((url.scheme ?? "").lowercased()) ? url : nil
@@ -1104,6 +1105,21 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1104
1105
  /// this separate from the live frame: deriving it after the drag begins
1105
1106
  /// makes the target jump when the translation crosses the midpoint.
1106
1107
  private var tabSwipeFrom: CGFloat = 0
1108
+ /// A native screen waiting for its first content before it is pushed.
1109
+ private var pendingPush: String?
1110
+ /// Where the current screen already was when this gesture took over: a
1111
+ /// swipe that caught the last one while it settled starts from there.
1112
+ private var tabSwipeBase: CGFloat = 0
1113
+ /// A screen still partly on screen when this gesture caught a settling
1114
+ /// swipe and went the other way: it keeps its distance from the current
1115
+ /// screen (`tabSwipeTrailingGap`) until it has left.
1116
+ private var tabSwipeTrailing: UINavigationController?
1117
+ private var tabSwipeTrailingGap: CGFloat = 0
1118
+ /// The end of the settle under way, run at once when a swipe catches it.
1119
+ private var settleNow: (() -> Void)?
1120
+ private var settleCompleting = false
1121
+ /// A caught settle's screens, where they were, for the gesture that caught it.
1122
+ private var caughtSettle: (other: String, otherX: CGFloat, currentX: CGFloat)?
1107
1123
 
1108
1124
  init(config: CraftConfig) {
1109
1125
  self.config = config
@@ -1160,6 +1176,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1160
1176
  }
1161
1177
 
1162
1178
  @objc private func tabSwipeRecognized(_ recognizer: UIPanGestureRecognizer) {
1179
+ // A finger that lands while the last swipe settles takes the screens
1180
+ // from where they are, as a paging scroll view does. This swipe used
1181
+ // to be ignored until the settle ended.
1182
+ if recognizer.state == .began, tabSwipeSettling { catchSettlingSwipe() }
1163
1183
  guard !tabSwipeSettling else { return }
1164
1184
  let tabs = hybrid.tabIds
1165
1185
  guard let index = tabs.firstIndex(of: currentTab), tabs.count > 1 else { return }
@@ -1177,6 +1197,9 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1177
1197
  updateInteractiveTabSwipe(translation: translation)
1178
1198
  case .ended, .cancelled, .failed:
1179
1199
  guard let target = activeTabSwipeTarget else {
1200
+ // Caught and let go without a direction (or toward the edge):
1201
+ // the caught screens finish where they were going.
1202
+ if caughtSettle != nil { settleCaughtScreens(); return }
1180
1203
  guard recognizer.state == .ended else { return }
1181
1204
  let delta = abs(translation) >= 32 ? translation : velocity
1182
1205
  guard abs(delta) >= 32 else { return }
@@ -1191,7 +1214,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1191
1214
  // jumping straight to their destination.
1192
1215
  tabSwipeSettling = true
1193
1216
  let direction: CGFloat = tabSwipeFrom > 0 ? -1 : 1
1194
- let progress = translation * direction
1217
+ let progress = (translation + tabSwipeBase) * direction
1195
1218
  let speed = velocity * direction
1196
1219
  let complete = recognizer.state == .ended && speed > -700
1197
1220
  && (progress >= max(32, view.bounds.width * 0.28) || speed > 700)
@@ -1252,7 +1275,20 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1252
1275
  target.view.isHidden = false
1253
1276
  let width = view.bounds.width
1254
1277
  tabSwipeFrom = -direction * width
1255
- target.view.frame = view.bounds.offsetBy(dx: tabSwipeFrom, dy: 0)
1278
+ // Caught from a settle: the current screen is still where the last
1279
+ // swipe left it, and the other screen it showed either is this
1280
+ // target (the finger went back to it) or trails behind.
1281
+ tabSwipeBase = 0
1282
+ tabSwipeTrailing = nil
1283
+ if let caught = caughtSettle {
1284
+ caughtSettle = nil
1285
+ tabSwipeBase = caught.currentX
1286
+ if caught.other != id, let other = stacks[caught.other] {
1287
+ tabSwipeTrailing = other
1288
+ tabSwipeTrailingGap = caught.otherX - caught.currentX
1289
+ }
1290
+ }
1291
+ target.view.frame = view.bounds.offsetBy(dx: tabSwipeFrom + tabSwipeBase, dy: 0)
1256
1292
  view.bringSubviewToFront(target.view)
1257
1293
  CraftChrome.shared.beginTabSwipe(to: id, from: currentTab)
1258
1294
  lastNavigation = CACurrentMediaTime()
@@ -1265,13 +1301,16 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1265
1301
  let target = stacks[targetId] else { return }
1266
1302
  let width = max(1, view.bounds.width)
1267
1303
  // Reversing past the start stays at zero progress, rather than
1268
- // pulling both screens away and exposing the background.
1304
+ // pulling both screens away and exposing the background; past where
1305
+ // a caught swipe left the screens, when it left them further out.
1306
+ let raw = translation + tabSwipeBase
1269
1307
  let offset = tabSwipeFrom > 0
1270
- ? min(0, max(-width, translation))
1271
- : max(0, min(width, translation))
1308
+ ? min(max(0, tabSwipeBase), max(-width, raw))
1309
+ : max(min(0, tabSwipeBase), min(width, raw))
1272
1310
  target.view.frame.origin.x = tabSwipeFrom + offset
1273
1311
  current.view.frame.origin.x = offset
1274
- CraftChrome.shared.updateTabSwipe(progress: abs(offset) / width)
1312
+ tabSwipeTrailing?.view.frame.origin.x = offset + tabSwipeTrailingGap
1313
+ CraftChrome.shared.updateTabSwipe(progress: max(0, tabSwipeFrom > 0 ? -offset : offset) / width)
1275
1314
  }
1276
1315
 
1277
1316
  private func finishInteractiveTabSwipe(completed: Bool, velocity: CGFloat, completion: @escaping () -> Void) {
@@ -1286,13 +1325,20 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1286
1325
  CraftChrome.shared.settleTabSwipe(completed: completed, duration: duration)
1287
1326
  let remaining = end - target.view.frame.minX
1288
1327
  let initialVelocity = abs(remaining) > 1 ? max(0, min(12, velocity / remaining)) : 0
1328
+ let trailing = tabSwipeTrailing
1329
+ let gap = tabSwipeTrailingGap
1289
1330
  let move = {
1290
1331
  target.view.frame.origin.x = end
1291
1332
  current.view.frame.origin.x = completed ? -direction * width : 0
1333
+ trailing?.view.frame.origin.x = current.view.frame.origin.x + gap
1292
1334
  }
1293
- let settled: (Bool) -> Void = { _ in
1294
- guard self.tabSwipeGeneration == generation,
1295
- self.activeTabSwipeTarget == targetId else { return }
1335
+ let finish = {
1336
+ if let trailing {
1337
+ trailing.view.isHidden = true
1338
+ trailing.view.frame = self.view.bounds
1339
+ }
1340
+ self.tabSwipeTrailing = nil
1341
+ self.tabSwipeBase = 0
1296
1342
  if completed {
1297
1343
  current.view.isHidden = true
1298
1344
  current.view.frame = self.view.bounds
@@ -1309,15 +1355,81 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1309
1355
  }
1310
1356
  completion()
1311
1357
  }
1358
+ settleNow = finish
1359
+ settleCompleting = completed
1360
+ let settled: (Bool) -> Void = { _ in
1361
+ guard self.tabSwipeGeneration == generation,
1362
+ self.activeTabSwipeTarget == targetId else { return }
1363
+ self.settleNow = nil
1364
+ finish()
1365
+ }
1312
1366
  UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 1,
1313
1367
  initialSpringVelocity: initialVelocity,
1314
1368
  options: [.beginFromCurrentState, .allowUserInteraction], animations: move, completion: settled)
1315
1369
  }
1316
1370
 
1371
+ /// A finger landed while the last swipe settled. Its end happens now,
1372
+ /// commit or cancel, chrome and events included, and the two screens go
1373
+ /// back exactly where they were on screen (no frame is drawn between),
1374
+ /// for this gesture to carry on from.
1375
+ private func catchSettlingSwipe() {
1376
+ guard let finish = settleNow, let targetId = activeTabSwipeTarget,
1377
+ let current = visibleStack, let target = stacks[targetId] else { return }
1378
+ let outgoing = currentTab
1379
+ let currentX = current.view.layer.presentation()?.frame.minX ?? current.view.frame.minX
1380
+ let targetX = target.view.layer.presentation()?.frame.minX ?? target.view.frame.minX
1381
+ tabSwipeGeneration &+= 1
1382
+ for nav in [current, target] + (tabSwipeTrailing.map { [$0] } ?? []) { nav.view.layer.removeAllAnimations() }
1383
+ settleNow = nil
1384
+ let completing = settleCompleting
1385
+ CraftFramePacing.shared.end("tab swipe")
1386
+ finish()
1387
+ let (stay, stayX, other, otherId, otherX) = completing
1388
+ ? (target, targetX, current, outgoing, currentX)
1389
+ : (current, currentX, target, targetId, targetX)
1390
+ guard abs(stayX) > 0.5 else { return }
1391
+ stay.view.frame = view.bounds.offsetBy(dx: stayX, dy: 0)
1392
+ other.view.isHidden = false
1393
+ other.view.frame = view.bounds.offsetBy(dx: otherX, dy: 0)
1394
+ caughtSettle = (otherId, otherX, stayX)
1395
+ }
1396
+
1397
+ /// A caught settle that no new swipe took on: its screens finish where
1398
+ /// they were going.
1399
+ private func settleCaughtScreens(animated: Bool = true) {
1400
+ guard let caught = caughtSettle else { return }
1401
+ caughtSettle = nil
1402
+ guard let current = visibleStack else { return }
1403
+ let other = stacks[caught.other]
1404
+ let gap = caught.otherX - caught.currentX
1405
+ let generation = tabSwipeGeneration
1406
+ let done = {
1407
+ guard self.tabSwipeGeneration == generation else { return }
1408
+ current.view.frame = self.view.bounds
1409
+ other?.view.frame = self.view.bounds
1410
+ if other !== current { other?.view.isHidden = true }
1411
+ }
1412
+ guard animated, CraftChrome.motion(.easeOut(duration: 0.18)) != nil else { done(); return }
1413
+ UIView.animate(withDuration: 0.18, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0,
1414
+ options: [.beginFromCurrentState, .allowUserInteraction], animations: {
1415
+ current.view.frame.origin.x = 0
1416
+ other?.view.frame.origin.x = gap
1417
+ }, completion: { _ in done() })
1418
+ }
1419
+
1317
1420
  /// A newer navigation action wins immediately, including a tap back on
1318
1421
  /// the outgoing tab. Invalidate the old animation before removing it:
1319
1422
  /// UIKit may deliver its completion while the replacement is starting.
1320
1423
  private func interruptTabSwipe(restoringSelection: Bool) {
1424
+ if caughtSettle != nil { settleCaughtScreens(animated: false) }
1425
+ if let trailing = tabSwipeTrailing {
1426
+ trailing.view.layer.removeAllAnimations()
1427
+ trailing.view.frame = view.bounds
1428
+ trailing.view.isHidden = trailing !== visibleStack
1429
+ tabSwipeTrailing = nil
1430
+ }
1431
+ tabSwipeBase = 0
1432
+ settleNow = nil
1321
1433
  guard let targetId = activeTabSwipeTarget else { return }
1322
1434
  tabSwipeGeneration &+= 1
1323
1435
  activeTabSwipeTarget = nil
@@ -1568,6 +1680,15 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1568
1680
  }
1569
1681
  (nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(false)
1570
1682
  (self.visibleStack?.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
1683
+ // A script on its own thread draws a moment later: its first
1684
+ // content is drawn ahead too, once it is there.
1685
+ if CraftNativeScriptThread.isBackground, let screen = nav.topViewController as? CraftNativeScreenController {
1686
+ screen.whenFirstRendered(within: 1) { [weak nav] in
1687
+ guard let nav, nav.view.isHidden else { return }
1688
+ nav.view.layoutIfNeeded()
1689
+ Self.drawContents(of: nav.view.layer)
1690
+ }
1691
+ }
1571
1692
  self.prepareAdjacentNativeTabs()
1572
1693
  }
1573
1694
  preparation = timer
@@ -1679,10 +1800,25 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1679
1800
  return
1680
1801
  }
1681
1802
  let screen = entry(for: raw, isTabRoot: false, depth: nil)
1682
- if replace, !nav.viewControllers.isEmpty {
1683
- nav.setViewControllers(Array(nav.viewControllers.dropLast()) + [screen], animated: true)
1684
- } else {
1685
- nav.pushViewController(screen, animated: true)
1803
+ let show = { [weak nav] in
1804
+ guard let nav else { return }
1805
+ if replace, !nav.viewControllers.isEmpty {
1806
+ nav.setViewControllers(Array(nav.viewControllers.dropLast()) + [screen], animated: true)
1807
+ } else {
1808
+ nav.pushViewController(screen, animated: true)
1809
+ }
1810
+ }
1811
+ guard let native = screen as? CraftNativeScreenController, CraftNativeScriptThread.isBackground else { show(); return }
1812
+ // Its script runs on its own thread: it slides in with its content,
1813
+ // and a second tap meanwhile does not push it twice.
1814
+ guard pendingPush != match.path else { return }
1815
+ pendingPush = match.path
1816
+ native.whenFirstRendered(within: 0.3) { [weak self, weak nav] in
1817
+ guard let self, self.pendingPush == match.path else { return }
1818
+ self.pendingPush = nil
1819
+ // Not into a tab the person has left meanwhile.
1820
+ guard let nav, nav === self.visibleStack else { return }
1821
+ show()
1686
1822
  }
1687
1823
  }
1688
1824
 
@@ -1945,7 +2081,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1945
2081
 
1946
2082
  func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
1947
2083
  if tabSwipeRecognizers.contains(where: { $0 === gestureRecognizer }) {
1948
- guard activeTabSwipeTarget == nil, !tabSwipeSettling,
2084
+ // A swipe may begin while the last one settles: it catches it.
2085
+ guard activeTabSwipeTarget == nil || tabSwipeSettling,
1949
2086
  hybrid.tabIds.count > 1, let nav = visibleStack, let top = nav.topViewController else { return false }
1950
2087
  guard CraftChrome.shared.tabSwipeEnabled else { return false }
1951
2088
  // Only between the tabs' own first screens, as the bar shows them:
@@ -52,11 +52,19 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
52
52
  var onRecycle: ((String) -> Void)?
53
53
  override func prepareForReuse() {
54
54
  super.prepareForReuse()
55
- releaseHostedView()
55
+ recycle()
56
+ }
57
+
58
+ /// The row this cell showed is gone; its views stay in the cell, and
59
+ /// the next row it shows reuses them when it has the same shape.
60
+ func recycle() {
61
+ if let identity = representedIdentity { onRecycle?(identity) }
62
+ representedIdentity = nil
63
+ onRecycle = nil
56
64
  }
57
65
 
58
66
  func host(_ view: UIView, identity: String) {
59
- if representedIdentity != identity { releaseHostedView() }
67
+ if let shown = representedIdentity, shown != identity { recycle() }
60
68
  representedIdentity = identity
61
69
  guard hostedView !== view else { return }
62
70
  hostedView?.removeFromSuperview()
@@ -72,11 +80,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
72
80
  }
73
81
 
74
82
  func releaseHostedView() {
75
- if let identity = representedIdentity { onRecycle?(identity) }
83
+ recycle()
76
84
  hostedView?.removeFromSuperview()
77
85
  hostedView = nil
78
- representedIdentity = nil
79
- onRecycle = nil
80
86
  }
81
87
 
82
88
  override func preferredLayoutAttributesFitting(
@@ -354,7 +360,8 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
354
360
 
355
361
  @objc private func refreshTriggered() {
356
362
  refreshHandler?()
357
- DispatchQueue.main.async { [weak self] in
363
+ // Once the script has answered the pull, on its own thread or not.
364
+ CraftNativeScriptThread.afterScript { [weak self] in
358
365
  guard let self, !self.refreshingProp, self.nativeRefreshControl.isRefreshing else { return }
359
366
  self.nativeRefreshControl.endRefreshing()
360
367
  }
@@ -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? {
@@ -1376,23 +1376,32 @@ final class CraftWebAudio {
1376
1376
  /// JavaScript, before its bundle runs.
1377
1377
  func install(in context: JSContext) {
1378
1378
  script = context
1379
+ // The graph lives on the main thread. A script on its own thread
1380
+ // (CraftNativeScriptThread) reaches it there, waiting for the answer
1381
+ // its synchronous calls return; arguments are read off the JSValues
1382
+ // where the script runs.
1379
1383
  let open: @convention(block) (Int, Double) -> NSDictionary? = { [weak self] id, rate in
1380
- self?.open(id, requestedRate: rate) as NSDictionary?
1384
+ CraftNativeScriptThread.onMain { self?.open(id, requestedRate: rate) as NSDictionary? }
1381
1385
  }
1382
1386
  let send: @convention(block) (JSValue) -> Void = { [weak self] batch in
1383
- self?.receive(batch.toArray() ?? [])
1387
+ let commands = batch.toArray() ?? []
1388
+ CraftNativeScriptThread.onMain { self?.receive(commands) }
1384
1389
  }
1385
1390
  let time: @convention(block) (Int) -> Double = { [weak self] id in
1386
- self?.contexts[id]?.graph.currentTime ?? 0
1391
+ CraftNativeScriptThread.onMain { self?.contexts[id]?.graph.currentTime ?? 0 }
1387
1392
  }
1388
1393
  let read: @convention(block) (Int, Int, String) -> Double = { [weak self] contextId, nodeId, key in
1389
- self?.read(contextId, nodeId, key) ?? .nan
1394
+ CraftNativeScriptThread.onMain { self?.read(contextId, nodeId, key) ?? .nan }
1390
1395
  }
1391
1396
  let state: @convention(block) (Int, String) -> Bool = { [weak self] id, state in
1392
- self?.setState(id, state) ?? false
1397
+ CraftNativeScriptThread.onMain { self?.setState(id, state) ?? false }
1393
1398
  }
1394
1399
  let buffer: @convention(block) (Int, Double, Int, JSValue) -> Bool = { [weak self] id, rate, length, channels in
1395
- self?.storeBuffer(id, rate, length, channels) ?? false
1400
+ // The samples are copied out of the script's typed arrays where
1401
+ // the script runs, then kept beside the graph.
1402
+ guard let data = Self.copyBuffer(rate, length, channels) else { return false }
1403
+ CraftNativeScriptThread.onMain { self?.buffers[id] = data }
1404
+ return true
1396
1405
  }
1397
1406
  context.setObject(open, forKeyedSubscript: "craftNativeAudioOpen" as NSString)
1398
1407
  context.setObject(send, forKeyedSubscript: "craftNativeAudioSend" as NSString)
@@ -1637,10 +1646,10 @@ final class CraftWebAudio {
1637
1646
  }
1638
1647
 
1639
1648
  /// An AudioBuffer's channels, copied straight out of its Float32Arrays.
1640
- private func storeBuffer(_ id: Int, _ rate: Double, _ length: Int, _ channels: JSValue) -> Bool {
1641
- guard length > 0, rate > 0, let context = JSContext.current() else { return false }
1649
+ private static func copyBuffer(_ rate: Double, _ length: Int, _ channels: JSValue) -> CraftAudioBufferData? {
1650
+ guard length > 0, rate > 0, let context = JSContext.current() else { return nil }
1642
1651
  let count = Int(channels.objectForKeyedSubscript("length")?.toInt32() ?? 0)
1643
- guard count > 0 else { return false }
1652
+ guard count > 0 else { return nil }
1644
1653
  let data = CraftAudioBufferData(sampleRate: rate, length: length, channelCount: count)
1645
1654
  let script = context.jsGlobalContextRef
1646
1655
  for index in 0..<count {
@@ -1651,8 +1660,7 @@ final class CraftWebAudio {
1651
1660
  let available = JSObjectGetTypedArrayLength(script, object, nil)
1652
1661
  data.channels[index].update(from: bytes.assumingMemoryBound(to: Float.self), count: min(length, available))
1653
1662
  }
1654
- buffers[id] = data
1655
- return true
1663
+ return data
1656
1664
  }
1657
1665
 
1658
1666
  // MARK: Notices
@@ -1675,8 +1683,8 @@ final class CraftWebAudio {
1675
1683
  }
1676
1684
  }
1677
1685
  }
1678
- guard !ended.isEmpty else { return }
1679
- script?.objectForKeyedSubscript("__craftNativeAudioEvents")?.call(withArguments: [ended])
1686
+ guard !ended.isEmpty, let script else { return }
1687
+ CraftNativeScriptThread.run { script.objectForKeyedSubscript("__craftNativeAudioEvents")?.call(withArguments: [ended]) }
1680
1688
  }
1681
1689
 
1682
1690
  // MARK: Tests