@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",
|
|
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",
|
|
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)
|
|
209177
|
-
|
|
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.
|
|
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": "
|
|
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) =>
|
|
663
|
-
|
|
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
|
-
|
|
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) :
|
|
699
|
-
const
|
|
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) =>
|
|
663
|
-
|
|
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
|
-
|
|
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) :
|
|
699
|
-
const
|
|
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;
|