@wix/himalaya-cli 0.1619.0 → 0.1620.0

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.mjs CHANGED
@@ -29373,7 +29373,6 @@ function resolveIdiom(tokens2, ctx = {}) {
29373
29373
  var IDIOM_CONTEXTS, UNSAFE_KEYS;
29374
29374
  var init_idiom = __esm({
29375
29375
  "../../core/ts/src/idiom.ts"() {
29376
- "use strict";
29377
29376
  init_define_HHP_CATALOG();
29378
29377
  IDIOM_CONTEXTS = {
29379
29378
  ios: { native: ["ios"] },
@@ -209067,18 +209066,25 @@ var init_run2 = __esm({
209067
209066
  // src/fidelity/native.ts
209068
209067
  var native_exports = {};
209069
209068
  __export(native_exports, {
209069
+ BLANK_CONTRAST: () => BLANK_CONTRAST,
209070
209070
  defaultRunner: () => defaultRunner3,
209071
209071
  deviceFor: () => deviceFor,
209072
209072
  nativeCapture: () => nativeCapture,
209073
209073
  parseAppLocales: () => parseAppLocales,
209074
209074
  parseNightMode: () => parseNightMode,
209075
209075
  parsePlistArray: () => parsePlistArray,
209076
+ resumedPackages: () => resumedPackages,
209076
209077
  sameLocalOrigin: () => sameLocalOrigin,
209077
209078
  screenLink: () => screenLink
209078
209079
  });
209079
209080
  import { spawn as spawn9 } from "node:child_process";
209080
209081
  import { readFileSync as readFileSync143, writeFileSync as writeFileSync60 } from "node:fs";
209081
209082
  import { join as join135 } from "node:path";
209083
+ function resumedPackages(dumpsys) {
209084
+ const lines = dumpsys.split("\n").filter((l2) => /\b(?:topResumedActivity|mResumedActivity|ResumedActivity)\b/.test(l2));
209085
+ if (!lines.length) return void 0;
209086
+ return lines.flatMap((l2) => [...l2.matchAll(/\s([A-Za-z0-9_.]+)\/[A-Za-z0-9_.$]+/g)].map((m2) => m2[1]));
209087
+ }
209082
209088
  function deviceFor(devices, platform, variant) {
209083
209089
  if (variant === "tablet") return platform === "ios" ? devices.iosTablet : devices.androidTablet;
209084
209090
  return platform === "ios" ? devices.ios : devices.android;
@@ -209100,12 +209106,18 @@ function nativeCapture(devices, opts) {
209100
209106
  const maxWaitMs = opts.maxWaitMs ?? 3e4;
209101
209107
  const iosBundle = opts.iosBundleId ?? "dev.himalaya.player.dev";
209102
209108
  const androidPackage = opts.androidPackage ?? "dev.himalaya.player.dev";
209109
+ const packages = /* @__PURE__ */ new Map();
209103
209110
  const rtl = opts.rtlLanguage ?? "he";
209104
209111
  const picker = opts.pickerAppId ?? "himi-player";
209105
209112
  const blackFraction = opts.blackFraction ?? (async (file2) => {
209106
209113
  const r3 = await run2("magick", [file2, "-colorspace", "Gray", "-threshold", "5%", "-format", "%[fx:1-mean]", "info:"]);
209107
209114
  return r3.code === 0 ? Number(r3.stdout.toString().trim()) || 0 : 0;
209108
209115
  });
209116
+ const contrast = opts.contrast ?? (async (file2) => {
209117
+ const r3 = await run2("magick", [file2, "-gravity", "South", "-crop", "100%x90%+0+0", "+repage", "-colorspace", "Gray", "-format", "%[fx:standard_deviation]", "info:"]);
209118
+ const v2 = r3.code === 0 ? Number.parseFloat(r3.stdout.toString()) : NaN;
209119
+ return Number.isFinite(v2) ? v2 : 1;
209120
+ });
209109
209121
  const saved = /* @__PURE__ */ new Map();
209110
209122
  const applied = /* @__PURE__ */ new Map();
209111
209123
  const originChecked = /* @__PURE__ */ new Map();
@@ -209114,6 +209126,18 @@ function nativeCapture(devices, opts) {
209114
209126
  if (r3.code !== 0) throw new Error(`${cmd} ${args.join(" ")} exited ${r3.code}`);
209115
209127
  return r3.stdout.toString().trim();
209116
209128
  };
209129
+ async function playerPackage(id) {
209130
+ const known = packages.get(id);
209131
+ if (known) return known;
209132
+ let pkg = androidPackage;
209133
+ if (!opts.androidPackage) {
209134
+ const r3 = await run2("adb", ["-s", id, "shell", "pm", "list", "packages", androidPackage]);
209135
+ const listed = r3.code === 0 ? r3.stdout.toString().split("\n").map((l2) => l2.trim().replace(/^package:/, "")).filter(Boolean) : [];
209136
+ pkg = listed.includes(androidPackage) ? androidPackage : listed.find((l2) => l2.startsWith(`${androidPackage}.`)) ?? androidPackage;
209137
+ }
209138
+ packages.set(id, pkg);
209139
+ return pkg;
209140
+ }
209117
209141
  async function remember(platform, id) {
209118
209142
  const key3 = `${platform}:${id}`;
209119
209143
  if (saved.has(key3)) return;
@@ -209123,7 +209147,7 @@ function nativeCapture(devices, opts) {
209123
209147
  saved.set(key3, { appearance, language: languages.code === 0 ? languages.stdout.toString().trim() : void 0 });
209124
209148
  } else {
209125
209149
  const night = await ok3("adb", ["-s", id, "shell", "cmd", "uimode", "night"]);
209126
- const locales = await run2("adb", ["-s", id, "shell", "cmd", "locale", "get-app-locales", androidPackage]);
209150
+ const locales = await run2("adb", ["-s", id, "shell", "cmd", "locale", "get-app-locales", await playerPackage(id)]);
209127
209151
  saved.set(key3, { appearance: parseNightMode(night), language: locales.code === 0 ? parseAppLocales(locales.stdout.toString()) : void 0 });
209128
209152
  }
209129
209153
  }
@@ -209140,7 +209164,7 @@ function nativeCapture(devices, opts) {
209140
209164
  } else {
209141
209165
  await ok3("adb", ["-s", id, "shell", "cmd", "uimode", "night", dark ? "yes" : "no"]);
209142
209166
  const wasRtl = applied.get(key3) === "rtl";
209143
- if (right || wasRtl) await ok3("adb", ["-s", id, "shell", "cmd", "locale", "set-app-locales", androidPackage, "--locales", right ? rtl : ""]);
209167
+ if (right || wasRtl) await ok3("adb", ["-s", id, "shell", "cmd", "locale", "set-app-locales", await playerPackage(id), "--locales", right ? rtl : ""]);
209144
209168
  }
209145
209169
  applied.set(key3, want);
209146
209170
  }
@@ -209169,19 +209193,31 @@ function nativeCapture(devices, opts) {
209169
209193
  writeFileSync60(file2, r3.stdout);
209170
209194
  return r3.stdout;
209171
209195
  }
209196
+ async function notInFront(platform, id) {
209197
+ if (platform !== "android") return "";
209198
+ const r3 = await run2("adb", ["-s", id, "shell", "dumpsys", "activity", "activities"]);
209199
+ const resumed = r3.code === 0 ? resumedPackages(r3.stdout.toString()) : void 0;
209200
+ const player = await playerPackage(id);
209201
+ return !resumed || resumed.includes(player) ? "" : `the Player was not in front (${resumed.join(", ") || "nothing resumed"})`;
209202
+ }
209172
209203
  async function settle2(platform, id, file2, notFrame) {
209173
209204
  let previous;
209205
+ let why = "the screen did not stop changing";
209174
209206
  for (let waited = 0; ; waited += settleMs) {
209175
209207
  const frame = await screenshot(platform, id, file2);
209176
- if (previous && frame.equals(previous) && !(notFrame && frame.equals(notFrame))) return frame;
209177
- if (waited >= maxWaitMs) return frame;
209208
+ if (previous && frame.equals(previous)) {
209209
+ const front = await notInFront(platform, id);
209210
+ why = notFrame && frame.equals(notFrame) ? "the Player still showed the picker" : front ? front : await contrast(file2) < BLANK_CONTRAST ? "the screen was still blank or loading" : "";
209211
+ if (!why) return { frame };
209212
+ } else why = "the screen did not stop changing";
209213
+ if (waited >= maxWaitMs) return { frame, unsettled: `${why} within ${Math.round(maxWaitMs / 1e3)}s` };
209178
209214
  previous = frame;
209179
209215
  await sleep(settleMs);
209180
209216
  }
209181
209217
  }
209182
209218
  async function open(platform, id, link) {
209183
209219
  if (platform === "ios") await ok3("xcrun", ["simctl", "openurl", id, link]);
209184
- else await ok3("adb", ["-s", id, "shell", "am", "start", "-W", "-a", "android.intent.action.VIEW", "-d", `'${link.replace(/'/g, "%27")}'`]);
209220
+ else await ok3("adb", ["-s", id, "shell", "am", "start", "-W", "-a", "android.intent.action.VIEW", "-p", await playerPackage(id), "-d", `'${link.replace(/'/g, "%27")}'`]);
209185
209221
  }
209186
209222
  const capture = (async (screen, entry, variant, outDir) => {
209187
209223
  const frames = [];
@@ -209205,11 +209241,19 @@ function nativeCapture(devices, opts) {
209205
209241
  await apply(platform, id, variant);
209206
209242
  await open(platform, id, `himi://app/${encodeURIComponent(picker)}`);
209207
209243
  const file2 = join135(outDir, `${screen}.${variant}.${platform}.png`);
209208
- const pickerFrame = await settle2(platform, id, file2);
209244
+ const pickerFrame = (await settle2(platform, id, file2)).frame;
209209
209245
  await open(platform, id, screenLink(opts.appId, screen, entry, opts.release));
209210
- await settle2(platform, id, file2, pickerFrame);
209246
+ const { unsettled } = await settle2(platform, id, file2, pickerFrame);
209247
+ if (unsettled) {
209248
+ frames.push({ surface: platform, screen, variant, skipped: { reason: `capture_unsettled: ${unsettled}` } });
209249
+ continue;
209250
+ }
209211
209251
  if (platform === "android" && await blackFraction(file2) > 0.95) {
209212
- await settle2(platform, id, file2);
209252
+ const retry = await settle2(platform, id, file2, pickerFrame);
209253
+ if (retry.unsettled) {
209254
+ frames.push({ surface: platform, screen, variant, skipped: { reason: `capture_unsettled: ${retry.unsettled}` } });
209255
+ continue;
209256
+ }
209213
209257
  if (await blackFraction(file2) > 0.95) {
209214
209258
  frames.push({ surface: platform, screen, variant, skipped: { reason: "black frame" } });
209215
209259
  continue;
@@ -209233,7 +209277,7 @@ function nativeCapture(devices, opts) {
209233
209277
  await run2("xcrun", ["simctl", "terminate", id, iosBundle]);
209234
209278
  } else {
209235
209279
  if (was.appearance) await run2("adb", ["-s", id, "shell", "cmd", "uimode", "night", was.appearance]);
209236
- await run2("adb", ["-s", id, "shell", "cmd", "locale", "set-app-locales", androidPackage, "--locales", was.language ?? ""]);
209280
+ await run2("adb", ["-s", id, "shell", "cmd", "locale", "set-app-locales", packages.get(id) ?? androidPackage, "--locales", was.language ?? ""]);
209237
209281
  }
209238
209282
  } catch {
209239
209283
  }
@@ -209265,10 +209309,11 @@ function parseAppLocales(out2) {
209265
209309
  function parsePlistArray(out2) {
209266
209310
  return out2.replace(/^\s*\(|\)\s*$/g, "").split(",").map((s2) => s2.trim().replace(/^"|"$/g, "")).filter(Boolean);
209267
209311
  }
209268
- var defaultRunner3;
209312
+ var BLANK_CONTRAST, defaultRunner3;
209269
209313
  var init_native = __esm({
209270
209314
  "src/fidelity/native.ts"() {
209271
209315
  init_define_HHP_CATALOG();
209316
+ BLANK_CONTRAST = 0.03;
209272
209317
  defaultRunner3 = (cmd, args) => new Promise((resolve95) => {
209273
209318
  const child = spawn9(cmd, args, { stdio: ["ignore", "pipe", "ignore"] });
209274
209319
  const chunks = [];
package/guides/hhp.md CHANGED
@@ -218,7 +218,7 @@ himi fidelity --content <dir> [--screen a,b] [--variant light,dark,rtl,tablet] [
218
218
  For each screen and variant (`light`, `dark`, `rtl`, `tablet`), the command does four things:
219
219
 
220
220
  1. **Captures the mockup.** It draws the page in Chromium at the variant's width. Dark mode is drawn by emulating `prefers-color-scheme`. RTL is drawn by setting `dir=rtl` on the page.
221
- 2. **Captures the web render** from the app's recorded data, as `himi render` does by default. Native frames come from Himi Player on the devices you pass: `--ios <udid>` and `--android <serial>`, plus `--ios-tablet` and `--android-tablet` for the tablet variant. Only those devices are touched. For each variant the command sets dark or light appearance and, for `rtl`, the app's right-to-left language (iOS `AppleLanguages`, Android per-app locale, API 33+). It opens `himi://app/<app>/screen/<screen>` and captures once two frames match. Every setting it changed is restored afterwards, also on Ctrl-C. A variant with no device is reported `skipped`, never passed. Without `--release <id>` or `--serve <port>` the Player shows the app's **live** release, not your working copy. `--serve <port>` serves the working copy on `127.0.0.1:<port>` as `himi run` does, so native and web render the same release. The Player reads a local origin only from a Debug build that compiled it in (see [View your package on a device](himi-player-implementation.md#view-your-package-on-a-device)): build it with `HIMALAYA_API_BASE_URL_INFO=http://127.0.0.1:<port>` on iOS and `-PhimalayaDevServeBaseUrl=http://10.0.2.2:<port>` on Android. An iOS device whose Player reads another origin is skipped with the rebuild to run. Android's origin cannot be read back from the installed app, so check that build yourself. A screen that declares `params` is skipped natively for now, because the Player's deep link does not carry them yet. On a fresh iOS simulator, open one `himi://` link by hand first and accept iOS's "Open in Himi Player?" prompt: the command cannot answer it, and until it is accepted every iOS capture shows the prompt.
221
+ 2. **Captures the web render** from the app's recorded data, as `himi render` does by default. Native frames come from Himi Player on the devices you pass: `--ios <udid>` and `--android <serial>`, plus `--ios-tablet` and `--android-tablet` for the tablet variant. Only those devices are touched. For each variant the command sets dark or light appearance and, for `rtl`, the app's right-to-left language (iOS `AppleLanguages`, Android per-app locale, API 33+). It opens `himi://app/<app>/screen/<screen>` and captures once two frames match, the frame is not a blank or loading page, and, on Android, the Player is the app in front rather than the launcher. A screen that has not settled that way after 30 seconds is reported `skipped` as `capture_unsettled`, never judged. Every setting it changed is restored afterwards, also on Ctrl-C. A variant with no device is reported `skipped`, never passed. Without `--release <id>` or `--serve <port>` the Player shows the app's **live** release, not your working copy. `--serve <port>` serves the working copy on `127.0.0.1:<port>` as `himi run` does, so native and web render the same release. The Player reads a local origin only from a Debug build that compiled it in (see [View your package on a device](himi-player-implementation.md#view-your-package-on-a-device)): build it with `HIMALAYA_API_BASE_URL_INFO=http://127.0.0.1:<port>` on iOS and `-PhimalayaDevServeBaseUrl=http://10.0.2.2:<port>` on Android. An iOS device whose Player reads another origin is skipped with the rebuild to run. Android's origin cannot be read back from the installed app, so check that build yourself. A screen that declares `params` is skipped natively for now, because the Player's deep link does not carry them yet. On a fresh iOS simulator, open one `himi://` link by hand first and accept iOS's "Open in Himi Player?" prompt: the command cannot answer it, and until it is accepted every iOS capture shows the prompt.
222
222
  3. **Scores mockup↔web** with ImageMagick SSIM, globally and per tile. The worst tile says where the screens differ most.
223
223
  4. **Asks a fresh model** two questions, using the repo's fidelity judge prompt. The model never ran in the session that wrote the app.
224
224
  - **Design:** does WEB look like the MOCKUP? This judges the port.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.1619.0",
3
+ "version": "0.1620.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -72,5 +72,5 @@
72
72
  "artifactId": "himalaya-cli"
73
73
  }
74
74
  },
75
- "falconPackageHash": "d9d834feab74f884aec00e986ae13ed5dcb2f5fea15f6ea9ac2e5796"
75
+ "falconPackageHash": "69fc7303ef0eb0de93d7760ffde1fb553ca5a667e515c46fac02efc3"
76
76
  }
@@ -652,6 +652,20 @@ const register = withIapPresentation<S>({
652
652
  },
653
653
  });
654
654
 
655
+ /** A read that answers at once (a cache, a local fixture, a headless run whose transport never
656
+ * suspends) is over before the action that started it returns. Its `readChanged` is a dispatch
657
+ * of this same service from a detached job, which a headless run never services after the active
658
+ * action returns, and which on a device would commit under the skeleton the action is about to
659
+ * return. Wait a bounded number of turns and fold the settled status into the action's own
660
+ * result instead; a read that is still in flight keeps the skeleton and lands through
661
+ * `readChanged` as before. `undefined` means a newer action took over while this one waited. */
662
+ async function settledWithin(next: S, category: string, gen: number): Promise<S | undefined> {
663
+ const controller = reader(category);
664
+ for (let i = 0; i < 500 && controller.getStatus().refreshing; i++) await Promise.resolve();
665
+ if (gen !== generation) return undefined;
666
+ return controller.getStatus().refreshing ? next : currentReadState(next);
667
+ }
668
+
655
669
  type A = (s: S, arg?: unknown) => S | undefined | Promise<S | undefined>;
656
670
  const svc = register<S, Record<string, A>>("himiBrandedShop", {
657
671
  state: BOOT,
@@ -659,8 +673,12 @@ const svc = register<S, Record<string, A>>("himiBrandedShop", {
659
673
  actions: {
660
674
  load: (s, arg) => load(s, arg),
661
675
  // Pull to refresh: the categories and the scope again, from Wix.
662
- refresh: (s, arg) => load(s, arg, true),
663
- retry: (s) => showScope(s, s.categorySelected, true),
676
+ refresh: async (s, arg) => {
677
+ const next = await load(s, arg, true);
678
+ // The result owns its scope; another action may have changed the globals during load.
679
+ return next ? settledWithin(next, next.categorySelected, next.scopeGen) : undefined;
680
+ },
681
+ retry: (s) => settledWithin(showScope(s, s.categorySelected, true), s.categorySelected, generation),
664
682
  recover: async () => { await fetchScope(selectedCategory, generation, true); return undefined; },
665
683
  readChanged: (s, arg) => {
666
684
  if (!arg || typeof arg !== 'object') return undefined;
@@ -683,7 +701,7 @@ const svc = register<S, Record<string, A>>("himiBrandedShop", {
683
701
  // A background page landed for the current scope.
684
702
  scopeGrew: (s) => derive(s),
685
703
  // Single-select: one chip is always chosen.
686
- selectCategory: (s, arg) => {
704
+ selectCategory: async (s, arg) => {
687
705
  // The chip row sends { selectedIds }; a deselected chip falls back to All.
688
706
  const selection = arg as { selectedIds?: unknown; refresh?: boolean } | undefined;
689
707
  const id = selection?.selectedIds !== undefined ? selected(arg)[0] ?? ALL : idOf(arg);
@@ -691,12 +709,15 @@ const svc = register<S, Record<string, A>>("himiBrandedShop", {
691
709
  const cancelsPendingRoute = !!pendingLink || selectedCategory !== id;
692
710
  categoryIntentVersion++;
693
711
  pendingLink = undefined;
694
- if (id !== ALL && !categories.some((c) => c.id === id)) return s;
712
+ // undefined, not s: s is the snapshot from before any in-flight load, loading flag included.
713
+ if (id !== ALL && !categories.some((c) => c.id === id)) return undefined;
695
714
  // showScope compares the prior selection before committing the requested category.
696
715
  // categorySelected is committed with the tap (showScope), so a tap back to the chip
697
716
  // on screen while another category loads is a no-op on the right scope.
698
- if (id === s.categorySelected) return cancelsPendingRoute || refresh ? showScope(s, id, refresh) : s;
699
- const next = showScope(s, id, refresh);
717
+ if (id === s.categorySelected) return cancelsPendingRoute || refresh ? settledWithin(showScope(s, id, refresh), id, generation) : undefined;
718
+ const shown = showScope(s, id, refresh);
719
+ const next = await settledWithin(shown, id, generation);
720
+ if (!next) return undefined;
700
721
  const pending = pendingAuxiliary;
701
722
  if (pending && pending.version === auxiliaryVersion && pending.cartCount >= 0) {
702
723
  pendingAuxiliary = undefined;
@@ -652,6 +652,20 @@ const register = withIapPresentation<S>({
652
652
  },
653
653
  });
654
654
 
655
+ /** A read that answers at once (a cache, a local fixture, a headless run whose transport never
656
+ * suspends) is over before the action that started it returns. Its `readChanged` is a dispatch
657
+ * of this same service from a detached job, which a headless run never services after the active
658
+ * action returns, and which on a device would commit under the skeleton the action is about to
659
+ * return. Wait a bounded number of turns and fold the settled status into the action's own
660
+ * result instead; a read that is still in flight keeps the skeleton and lands through
661
+ * `readChanged` as before. `undefined` means a newer action took over while this one waited. */
662
+ async function settledWithin(next: S, category: string, gen: number): Promise<S | undefined> {
663
+ const controller = reader(category);
664
+ for (let i = 0; i < 500 && controller.getStatus().refreshing; i++) await Promise.resolve();
665
+ if (gen !== generation) return undefined;
666
+ return controller.getStatus().refreshing ? next : currentReadState(next);
667
+ }
668
+
655
669
  type A = (s: S, arg?: unknown) => S | undefined | Promise<S | undefined>;
656
670
  const svc = register<S, Record<string, A>>("himiBrandedShop", {
657
671
  state: BOOT,
@@ -659,8 +673,12 @@ const svc = register<S, Record<string, A>>("himiBrandedShop", {
659
673
  actions: {
660
674
  load: (s, arg) => load(s, arg),
661
675
  // Pull to refresh: the categories and the scope again, from Wix.
662
- refresh: (s, arg) => load(s, arg, true),
663
- retry: (s) => showScope(s, s.categorySelected, true),
676
+ refresh: async (s, arg) => {
677
+ const next = await load(s, arg, true);
678
+ // The result owns its scope; another action may have changed the globals during load.
679
+ return next ? settledWithin(next, next.categorySelected, next.scopeGen) : undefined;
680
+ },
681
+ retry: (s) => settledWithin(showScope(s, s.categorySelected, true), s.categorySelected, generation),
664
682
  recover: async () => { await fetchScope(selectedCategory, generation, true); return undefined; },
665
683
  readChanged: (s, arg) => {
666
684
  if (!arg || typeof arg !== 'object') return undefined;
@@ -683,7 +701,7 @@ const svc = register<S, Record<string, A>>("himiBrandedShop", {
683
701
  // A background page landed for the current scope.
684
702
  scopeGrew: (s) => derive(s),
685
703
  // Single-select: one chip is always chosen.
686
- selectCategory: (s, arg) => {
704
+ selectCategory: async (s, arg) => {
687
705
  // The chip row sends { selectedIds }; a deselected chip falls back to All.
688
706
  const selection = arg as { selectedIds?: unknown; refresh?: boolean } | undefined;
689
707
  const id = selection?.selectedIds !== undefined ? selected(arg)[0] ?? ALL : idOf(arg);
@@ -691,12 +709,15 @@ const svc = register<S, Record<string, A>>("himiBrandedShop", {
691
709
  const cancelsPendingRoute = !!pendingLink || selectedCategory !== id;
692
710
  categoryIntentVersion++;
693
711
  pendingLink = undefined;
694
- if (id !== ALL && !categories.some((c) => c.id === id)) return s;
712
+ // undefined, not s: s is the snapshot from before any in-flight load, loading flag included.
713
+ if (id !== ALL && !categories.some((c) => c.id === id)) return undefined;
695
714
  // showScope compares the prior selection before committing the requested category.
696
715
  // categorySelected is committed with the tap (showScope), so a tap back to the chip
697
716
  // on screen while another category loads is a no-op on the right scope.
698
- if (id === s.categorySelected) return cancelsPendingRoute || refresh ? showScope(s, id, refresh) : s;
699
- const next = showScope(s, id, refresh);
717
+ if (id === s.categorySelected) return cancelsPendingRoute || refresh ? settledWithin(showScope(s, id, refresh), id, generation) : undefined;
718
+ const shown = showScope(s, id, refresh);
719
+ const next = await settledWithin(shown, id, generation);
720
+ if (!next) return undefined;
700
721
  const pending = pendingAuxiliary;
701
722
  if (pending && pending.version === auxiliaryVersion && pending.cartCount >= 0) {
702
723
  pendingAuxiliary = undefined;