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.
@@ -11,6 +11,20 @@ export type { AndroidMapProvider } from './native-map.js';
11
11
  * route's file. Stacks asks for route bundles only when this is true.
12
12
  */
13
13
  export declare const nativeRouteBundles = true;
14
+ /**
15
+ * The app evaluates stx's shared runtime (`__runtime.js`, stx's
16
+ * `sharedRuntime`) in each screen's context before the route's own file.
17
+ * Stacks asks stx for it only when this is true: a route file built so
18
+ * refuses to run without it.
19
+ */
20
+ export declare const nativeRouteRuntime = true;
21
+ /**
22
+ * The app gives a screen its messages a language at a time (stx's
23
+ * `messages`, `native-screens/__messages/`): the areas its route uses,
24
+ * evaluated ahead of it (an isolate cannot read a file when asked). Stacks leaves the languages
25
+ * out of the screens' code only when this is true.
26
+ */
27
+ export declare const nativeMessages = true;
14
28
  /** Providers this published builder can generate as real Android map views. */
15
29
  export declare const nativeMapProviders: readonly ['google', 'maplibre'];
16
30
  export { runKotlinFixture } from './testing.js';
@@ -360,6 +360,8 @@ async function createAndroidEmulator(options) {
360
360
 
361
361
  // ../android/src/index.ts
362
362
  var nativeRouteBundles = true;
363
+ var nativeRouteRuntime = true;
364
+ var nativeMessages = true;
363
365
  var nativeMapProviders = ["google", "maplibre"];
364
366
  function syncNativeFonts(fonts, output) {
365
367
  const destination = join3(output, "app/src/main/assets/NativeFonts");
@@ -1380,7 +1382,9 @@ export {
1380
1382
  renderAndroidPermissions,
1381
1383
  renderAndroidDeepLinks,
1382
1384
  open,
1385
+ nativeRouteRuntime,
1383
1386
  nativeRouteBundles,
1387
+ nativeMessages,
1384
1388
  nativeMapProviders,
1385
1389
  installRuntime,
1386
1390
  init,
@@ -71,13 +71,46 @@ internal object CraftNativeBundle {
71
71
  private fun read(context: Context, asset: String): String? = sources.get(asset)
72
72
  ?: runCatching { context.assets.open(asset).bufferedReader().use { it.readText() } }.getOrNull()?.also { sources.put(asset, it) }
73
73
 
74
+ /**
75
+ * A route's file runs on the runtime stx writes beside it
76
+ * (`native-screens/__runtime.js`), so the two go to the isolate together,
77
+ * runtime first, then the route's messages. A one-file bundle carries its
78
+ * runtime itself.
79
+ */
74
80
  fun source(context: Context, route: String?): String? {
75
81
  if (!route.isNullOrBlank() && !route.contains('/') && !route.startsWith('.')) {
76
- read(context, "native-screens/$route.js")?.let { return it }
82
+ read(context, "native-screens/$route.js")?.let { own ->
83
+ val runtime = read(context, "native-screens/__runtime.js") ?: return own
84
+ val messages = messages(context, route)
85
+ return runtime + "\n;\n" + (if (messages != null) messages + "\n;\n" else "") + own
86
+ }
77
87
  }
78
88
  return read(context, "native-screen.js")
79
89
  }
80
90
 
91
+ /**
92
+ * The messages of the areas a route uses (stx's `messages`:
93
+ * `native-screens/__messages/routes.json` names them), in every language.
94
+ * On iOS a screen reads one language's area the first time it looks
95
+ * inside it; an isolate cannot call back into the app and wait for an
96
+ * answer, so here they are evaluated ahead of the route, as
97
+ * `__stxNativeMessages`, which the runtime reads first.
98
+ */
99
+ private fun messages(context: Context, route: String): String? {
100
+ val listing = read(context, "native-screens/__messages/routes.json") ?: return null
101
+ val areas = runCatching { org.json.JSONObject(listing).optJSONArray(route) }.getOrNull() ?: return null
102
+ if (areas.length() == 0) return null
103
+ val languages = context.assets.list("native-screens/__messages")?.filter { !it.contains('.') }?.sorted() ?: return null
104
+ val tables = languages.joinToString(",") { language ->
105
+ val entries = (0 until areas.length()).mapNotNull { index ->
106
+ val area = areas.optString(index)
107
+ read(context, "native-screens/__messages/$language/$area.json")?.let { "${org.json.JSONObject.quote(area)}:$it" }
108
+ }
109
+ "${org.json.JSONObject.quote(language)}:{${entries.joinToString(",")}}"
110
+ }
111
+ return "globalThis.__stxNativeMessages={$tables};"
112
+ }
113
+
81
114
  fun isAvailable(context: Context): Boolean =
82
115
  runCatching { context.assets.open("native-screen.js").close(); true }.getOrDefault(false)
83
116
  || (context.assets.list("native-screens")?.isNotEmpty() == true)
package/dist/cli.js CHANGED
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3424
3424
  `);
3425
3425
  }
3426
3426
  // package.json
3427
- var version = "0.0.245";
3427
+ var version = "0.0.246";
3428
3428
 
3429
3429
  // bin/cli.ts
3430
3430
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.245";
313
+ var version = "0.0.246";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ function craftBinaryNotFoundMessage(triedPath) {
57
57
  `);
58
58
  }
59
59
  // package.json
60
- var version = "0.0.245";
60
+ var version = "0.0.246";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -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`. */
@@ -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,
@@ -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
  }