@wix/himalaya-cli 0.1117.0 → 0.1118.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.
@@ -282,12 +282,18 @@ function derive(s: S): S {
282
282
  };
283
283
  }
284
284
 
285
+ // Bumped by every add (queued, failed or landed): a cart count read BEFORE it — the load's badge
286
+ // read answering late — must not replace the badge the add set (#3468 review).
287
+ let cartBadgeEpoch = 0;
288
+
285
289
  const svc = defineService<S, {
286
290
  load: (s: S, arg?: unknown) => Promise<S>;
287
291
  setQuantity: (s: S, arg?: unknown) => S;
288
292
  selectOption: (s: S, arg?: unknown) => S;
289
293
  addToCart: (s: S) => Promise<S>;
290
294
  beginAdd: (s: S, arg?: unknown) => S;
295
+ paintCached: (s: S, arg?: unknown) => S;
296
+ setCartCount: (s: S, arg?: unknown) => S;
291
297
  share: (s: S) => Promise<S>;
292
298
  toggleSave: (s: S) => S;
293
299
  }>("himiStoreProduct", {
@@ -350,9 +356,46 @@ const svc = defineService<S, {
350
356
  lastLoadedId = undefined;
351
357
  return derive({ ...s, status: "ready", quantity: 1, optionGroups: [], selection: {}, optionsUnknown: false, emptyText: copyParam("product.notFoundText", "Product not found.") });
352
358
  }
353
- try {
354
- const r = await getStoreProduct(id);
355
- const p = (r.product ?? (r as unknown as StoreProduct)) as StoreProduct;
359
+ // The cart count only feeds the top-bar badge: ask for it NOW, beside the product, instead
360
+ // of after it — it used to add a whole cart round trip before the page could paint.
361
+ const cartCount = cartItemCount();
362
+ // The badge must never hold the page: remember the count if it is already in, and
363
+ // publish it separately if it lands after the product (#3468 review).
364
+ let cartKnown: number | undefined;
365
+ void cartCount.then((n) => { cartKnown = n; }, () => {});
366
+ // The live product read goes out FIRST too — the local-db read below must never delay it.
367
+ // Settled into a value so a failure is handled where it is awaited, never unhandled.
368
+ const live = getStoreProduct(id).then(
369
+ (r) => ({ ok: true as const, r }),
370
+ () => ({ ok: false as const }),
371
+ );
372
+ // Paint at once from the row the list already synced (name, price, image) — the page used
373
+ // to sit on its skeleton for the product round trip even when the list had just shown it.
374
+ // The live read then fills in the description and gallery.
375
+ const synced = await readCachedProduct(id);
376
+ let painted = false;
377
+ if (synced && s.status === "loading") {
378
+ await svc.paintCached(synced);
379
+ painted = true;
380
+ }
381
+ const outcome = await live;
382
+ // Once painted the page is INTERACTIVE while the live read is on the wire: the visitor may
383
+ // save, change the quantity or add to cart. Build on the state as it is NOW, and keep what
384
+ // they did — overwriting it would untick a stored save, reset the quantity, and drop the
385
+ // in-flight add's `adding` guard (a second tap would add the line twice).
386
+ const base: S = painted ? (svc.getState() as S) : s;
387
+ const keep = painted
388
+ ? { saved: base.saved, saveHint: base.saveHint, quantity: base.quantity, adding: base.adding, added: base.added, cartHint: base.cartHint }
389
+ : { cartHint: "", quantity: 1 };
390
+ const badge = (): number => {
391
+ if (painted && (base.adding || base.added)) return base.cartCount;
392
+ if (cartKnown !== undefined) return cartKnown;
393
+ const epoch = cartBadgeEpoch;
394
+ void cartCount.then((n) => { void svc.setCartCount({ count: n, epoch }); }, () => {});
395
+ return base.cartCount;
396
+ };
397
+ if (outcome.ok) {
398
+ const p = (outcome.r.product ?? (outcome.r as unknown as StoreProduct)) as StoreProduct;
356
399
  const name = cleanTitle(p.name) || "Product";
357
400
  const image = productImage(p, WIX_DETAIL_IMAGE);
358
401
  lastProduct = p;
@@ -373,7 +416,8 @@ const svc = defineService<S, {
373
416
  }
374
417
  lastLoadedId = id;
375
418
  return derive({
376
- ...s,
419
+ ...base,
420
+ ...keep,
377
421
  status: "ready",
378
422
  optionGroups,
379
423
  selection: { ...defaultSelection(optionGroups), ...carried },
@@ -385,46 +429,42 @@ const svc = defineService<S, {
385
429
  gallery: productGallery(p),
386
430
  initial: (name[0] || "#").toUpperCase(),
387
431
  shareUrl: productShareUrl(p),
388
- cartHint: "",
389
- cartCount: await cartItemCount(),
432
+ cartCount: badge(),
390
433
  emptyText: "",
391
- quantity: 1,
392
434
  });
393
- } catch {
394
- // Offline: fall back to the last-synced cached product row before the error state.
395
- const cached = await readCachedProduct(id);
396
- if (cached) {
397
- const name = String(cached.name ?? "") || "Product";
398
- const image = String(cached.image ?? "");
399
- // The cached list row carries no options, and guessing "none" would offer
400
- // a one-tap Add to cart for a product that needs a colour and a size. So
401
- // the offline detail shows no picker AND marks the options UNKNOWN, which
402
- // keeps `optionsIncomplete` true: the CTA stays disabled and `addToCart`
403
- // refuses. `cartHint` carries the reason, because the options card is hidden
404
- // on `noOptions` and would otherwise leave a dead button unexplained.
405
- lastProduct = undefined;
406
- lastLoadedId = undefined;
407
- return derive({
408
- ...s,
409
- status: "ready",
410
- optionGroups: [],
411
- selection: {},
412
- optionsUnknown: true,
413
- name,
414
- price: String(cached.subtitle ?? ""),
415
- description: "",
416
- image,
417
- initial: String(cached.initial ?? "") || (name[0] || "#").toUpperCase(),
418
- cartHint: copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart."),
419
- cartCount: await cartItemCount(),
420
- emptyText: "",
421
- quantity: 1,
422
- });
423
- }
435
+ }
436
+ // Offline: fall back to the last-synced cached product row before the error state.
437
+ if (synced) {
438
+ const name = String(synced.name ?? "") || "Product";
439
+ const image = String(synced.image ?? "");
440
+ // The cached list row carries no options, and guessing "none" would offer
441
+ // a one-tap Add to cart for a product that needs a colour and a size. So
442
+ // the offline detail shows no picker AND marks the options UNKNOWN, which
443
+ // keeps `optionsIncomplete` true: the CTA stays disabled and `addToCart`
444
+ // refuses. `cartHint` carries the reason, because the options card is hidden
445
+ // on `noOptions` and would otherwise leave a dead button unexplained.
424
446
  lastProduct = undefined;
425
447
  lastLoadedId = undefined;
426
- return derive({ ...s, status: "error", optionGroups: [], selection: {}, optionsUnknown: false, emptyText: copyParam("product.errorText", "Product could not load.") });
448
+ return derive({
449
+ ...base,
450
+ ...keep,
451
+ status: "ready",
452
+ optionGroups: [],
453
+ selection: {},
454
+ optionsUnknown: true,
455
+ name,
456
+ price: String(synced.subtitle ?? ""),
457
+ description: "",
458
+ image,
459
+ initial: String(synced.initial ?? "") || (name[0] || "#").toUpperCase(),
460
+ cartHint: copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart."),
461
+ cartCount: badge(),
462
+ emptyText: "",
463
+ });
427
464
  }
465
+ lastProduct = undefined;
466
+ lastLoadedId = undefined;
467
+ return derive({ ...s, status: "error", optionGroups: [], selection: {}, optionsUnknown: false, emptyText: copyParam("product.errorText", "Product could not load.") });
428
468
  },
429
469
  // The stepper fires onChange with { value } on every tap; clamp 1–99 and
430
470
  // ignore unparsable/garbage args instead of silently zeroing the quantity.
@@ -456,8 +496,41 @@ const svc = defineService<S, {
456
496
  // settle. Called from addToCart, not from the descriptor.
457
497
  beginAdd: (s, arg) => {
458
498
  const quantity = parseQuantity(argValue(arg, "quantity")) ?? 1;
499
+ cartBadgeEpoch += 1;
459
500
  return derive({ ...s, adding: true, added: false, cartHint: "", cartCount: s.cartCount + quantity });
460
501
  },
502
+ // The first paint from the list's synced row (called from load, not from the descriptor):
503
+ // name, price and image are on screen while the live product — description, gallery — loads.
504
+ // The cart count answered after the product: only the top-bar badge changes.
505
+ setCartCount: (s, arg) => {
506
+ // An add since the count was asked for owns the badge — in flight, landed or QUEUED offline
507
+ // (which leaves adding/added false): a pre-add count must not undo it.
508
+ const { count, epoch } = (arg ?? {}) as { count?: unknown; epoch?: unknown };
509
+ if (epoch !== cartBadgeEpoch || s.adding || s.added) return s;
510
+ const n = Math.max(0, Math.floor(Number(count)));
511
+ return Number.isFinite(n) ? derive({ ...s, cartCount: n }) : s;
512
+ },
513
+ paintCached: (s, arg) => {
514
+ const row = (arg ?? {}) as DbEntity;
515
+ const name = String(row.name ?? "") || "Product";
516
+ return derive({
517
+ ...s,
518
+ status: "ready",
519
+ name,
520
+ price: String(row.subtitle ?? ""),
521
+ description: "",
522
+ image: String(row.image ?? ""),
523
+ gallery: [],
524
+ initial: String(row.initial ?? "") || (name[0] || "#").toUpperCase(),
525
+ // The synced list row carries no options: until the live read says what they are,
526
+ // they are UNKNOWN, which keeps the CTA disabled — never a one-tap Add to cart for a
527
+ // product that needs a size. The live read replaces this a moment later.
528
+ optionGroups: [],
529
+ optionsUnknown: true,
530
+ emptyText: "",
531
+ quantity: 1,
532
+ });
533
+ },
461
534
  addToCart: async (s) => {
462
535
  const id = inputId();
463
536
  if (!id) return derive({ ...s, cartHint: copyParam("product.cartHintNoProduct", "No product to add.") });
@@ -282,12 +282,18 @@ function derive(s: S): S {
282
282
  };
283
283
  }
284
284
 
285
+ // Bumped by every add (queued, failed or landed): a cart count read BEFORE it — the load's badge
286
+ // read answering late — must not replace the badge the add set (#3468 review).
287
+ let cartBadgeEpoch = 0;
288
+
285
289
  const svc = defineService<S, {
286
290
  load: (s: S, arg?: unknown) => Promise<S>;
287
291
  setQuantity: (s: S, arg?: unknown) => S;
288
292
  selectOption: (s: S, arg?: unknown) => S;
289
293
  addToCart: (s: S) => Promise<S>;
290
294
  beginAdd: (s: S, arg?: unknown) => S;
295
+ paintCached: (s: S, arg?: unknown) => S;
296
+ setCartCount: (s: S, arg?: unknown) => S;
291
297
  share: (s: S) => Promise<S>;
292
298
  toggleSave: (s: S) => S;
293
299
  }>("himiStoreProduct", {
@@ -350,9 +356,46 @@ const svc = defineService<S, {
350
356
  lastLoadedId = undefined;
351
357
  return derive({ ...s, status: "ready", quantity: 1, optionGroups: [], selection: {}, optionsUnknown: false, emptyText: copyParam("product.notFoundText", "Product not found.") });
352
358
  }
353
- try {
354
- const r = await getStoreProduct(id);
355
- const p = (r.product ?? (r as unknown as StoreProduct)) as StoreProduct;
359
+ // The cart count only feeds the top-bar badge: ask for it NOW, beside the product, instead
360
+ // of after it — it used to add a whole cart round trip before the page could paint.
361
+ const cartCount = cartItemCount();
362
+ // The badge must never hold the page: remember the count if it is already in, and
363
+ // publish it separately if it lands after the product (#3468 review).
364
+ let cartKnown: number | undefined;
365
+ void cartCount.then((n) => { cartKnown = n; }, () => {});
366
+ // The live product read goes out FIRST too — the local-db read below must never delay it.
367
+ // Settled into a value so a failure is handled where it is awaited, never unhandled.
368
+ const live = getStoreProduct(id).then(
369
+ (r) => ({ ok: true as const, r }),
370
+ () => ({ ok: false as const }),
371
+ );
372
+ // Paint at once from the row the list already synced (name, price, image) — the page used
373
+ // to sit on its skeleton for the product round trip even when the list had just shown it.
374
+ // The live read then fills in the description and gallery.
375
+ const synced = await readCachedProduct(id);
376
+ let painted = false;
377
+ if (synced && s.status === "loading") {
378
+ await svc.paintCached(synced);
379
+ painted = true;
380
+ }
381
+ const outcome = await live;
382
+ // Once painted the page is INTERACTIVE while the live read is on the wire: the visitor may
383
+ // save, change the quantity or add to cart. Build on the state as it is NOW, and keep what
384
+ // they did — overwriting it would untick a stored save, reset the quantity, and drop the
385
+ // in-flight add's `adding` guard (a second tap would add the line twice).
386
+ const base: S = painted ? (svc.getState() as S) : s;
387
+ const keep = painted
388
+ ? { saved: base.saved, saveHint: base.saveHint, quantity: base.quantity, adding: base.adding, added: base.added, cartHint: base.cartHint }
389
+ : { cartHint: "", quantity: 1 };
390
+ const badge = (): number => {
391
+ if (painted && (base.adding || base.added)) return base.cartCount;
392
+ if (cartKnown !== undefined) return cartKnown;
393
+ const epoch = cartBadgeEpoch;
394
+ void cartCount.then((n) => { void svc.setCartCount({ count: n, epoch }); }, () => {});
395
+ return base.cartCount;
396
+ };
397
+ if (outcome.ok) {
398
+ const p = (outcome.r.product ?? (outcome.r as unknown as StoreProduct)) as StoreProduct;
356
399
  const name = cleanTitle(p.name) || "Product";
357
400
  const image = productImage(p, WIX_DETAIL_IMAGE);
358
401
  lastProduct = p;
@@ -373,7 +416,8 @@ const svc = defineService<S, {
373
416
  }
374
417
  lastLoadedId = id;
375
418
  return derive({
376
- ...s,
419
+ ...base,
420
+ ...keep,
377
421
  status: "ready",
378
422
  optionGroups,
379
423
  selection: { ...defaultSelection(optionGroups), ...carried },
@@ -385,46 +429,42 @@ const svc = defineService<S, {
385
429
  gallery: productGallery(p),
386
430
  initial: (name[0] || "#").toUpperCase(),
387
431
  shareUrl: productShareUrl(p),
388
- cartHint: "",
389
- cartCount: await cartItemCount(),
432
+ cartCount: badge(),
390
433
  emptyText: "",
391
- quantity: 1,
392
434
  });
393
- } catch {
394
- // Offline: fall back to the last-synced cached product row before the error state.
395
- const cached = await readCachedProduct(id);
396
- if (cached) {
397
- const name = String(cached.name ?? "") || "Product";
398
- const image = String(cached.image ?? "");
399
- // The cached list row carries no options, and guessing "none" would offer
400
- // a one-tap Add to cart for a product that needs a colour and a size. So
401
- // the offline detail shows no picker AND marks the options UNKNOWN, which
402
- // keeps `optionsIncomplete` true: the CTA stays disabled and `addToCart`
403
- // refuses. `cartHint` carries the reason, because the options card is hidden
404
- // on `noOptions` and would otherwise leave a dead button unexplained.
405
- lastProduct = undefined;
406
- lastLoadedId = undefined;
407
- return derive({
408
- ...s,
409
- status: "ready",
410
- optionGroups: [],
411
- selection: {},
412
- optionsUnknown: true,
413
- name,
414
- price: String(cached.subtitle ?? ""),
415
- description: "",
416
- image,
417
- initial: String(cached.initial ?? "") || (name[0] || "#").toUpperCase(),
418
- cartHint: copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart."),
419
- cartCount: await cartItemCount(),
420
- emptyText: "",
421
- quantity: 1,
422
- });
423
- }
435
+ }
436
+ // Offline: fall back to the last-synced cached product row before the error state.
437
+ if (synced) {
438
+ const name = String(synced.name ?? "") || "Product";
439
+ const image = String(synced.image ?? "");
440
+ // The cached list row carries no options, and guessing "none" would offer
441
+ // a one-tap Add to cart for a product that needs a colour and a size. So
442
+ // the offline detail shows no picker AND marks the options UNKNOWN, which
443
+ // keeps `optionsIncomplete` true: the CTA stays disabled and `addToCart`
444
+ // refuses. `cartHint` carries the reason, because the options card is hidden
445
+ // on `noOptions` and would otherwise leave a dead button unexplained.
424
446
  lastProduct = undefined;
425
447
  lastLoadedId = undefined;
426
- return derive({ ...s, status: "error", optionGroups: [], selection: {}, optionsUnknown: false, emptyText: copyParam("product.errorText", "Product could not load.") });
448
+ return derive({
449
+ ...base,
450
+ ...keep,
451
+ status: "ready",
452
+ optionGroups: [],
453
+ selection: {},
454
+ optionsUnknown: true,
455
+ name,
456
+ price: String(synced.subtitle ?? ""),
457
+ description: "",
458
+ image,
459
+ initial: String(synced.initial ?? "") || (name[0] || "#").toUpperCase(),
460
+ cartHint: copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart."),
461
+ cartCount: badge(),
462
+ emptyText: "",
463
+ });
427
464
  }
465
+ lastProduct = undefined;
466
+ lastLoadedId = undefined;
467
+ return derive({ ...s, status: "error", optionGroups: [], selection: {}, optionsUnknown: false, emptyText: copyParam("product.errorText", "Product could not load.") });
428
468
  },
429
469
  // The stepper fires onChange with { value } on every tap; clamp 1–99 and
430
470
  // ignore unparsable/garbage args instead of silently zeroing the quantity.
@@ -456,8 +496,41 @@ const svc = defineService<S, {
456
496
  // settle. Called from addToCart, not from the descriptor.
457
497
  beginAdd: (s, arg) => {
458
498
  const quantity = parseQuantity(argValue(arg, "quantity")) ?? 1;
499
+ cartBadgeEpoch += 1;
459
500
  return derive({ ...s, adding: true, added: false, cartHint: "", cartCount: s.cartCount + quantity });
460
501
  },
502
+ // The first paint from the list's synced row (called from load, not from the descriptor):
503
+ // name, price and image are on screen while the live product — description, gallery — loads.
504
+ // The cart count answered after the product: only the top-bar badge changes.
505
+ setCartCount: (s, arg) => {
506
+ // An add since the count was asked for owns the badge — in flight, landed or QUEUED offline
507
+ // (which leaves adding/added false): a pre-add count must not undo it.
508
+ const { count, epoch } = (arg ?? {}) as { count?: unknown; epoch?: unknown };
509
+ if (epoch !== cartBadgeEpoch || s.adding || s.added) return s;
510
+ const n = Math.max(0, Math.floor(Number(count)));
511
+ return Number.isFinite(n) ? derive({ ...s, cartCount: n }) : s;
512
+ },
513
+ paintCached: (s, arg) => {
514
+ const row = (arg ?? {}) as DbEntity;
515
+ const name = String(row.name ?? "") || "Product";
516
+ return derive({
517
+ ...s,
518
+ status: "ready",
519
+ name,
520
+ price: String(row.subtitle ?? ""),
521
+ description: "",
522
+ image: String(row.image ?? ""),
523
+ gallery: [],
524
+ initial: String(row.initial ?? "") || (name[0] || "#").toUpperCase(),
525
+ // The synced list row carries no options: until the live read says what they are,
526
+ // they are UNKNOWN, which keeps the CTA disabled — never a one-tap Add to cart for a
527
+ // product that needs a size. The live read replaces this a moment later.
528
+ optionGroups: [],
529
+ optionsUnknown: true,
530
+ emptyText: "",
531
+ quantity: 1,
532
+ });
533
+ },
461
534
  addToCart: async (s) => {
462
535
  const id = inputId();
463
536
  if (!id) return derive({ ...s, cartHint: copyParam("product.cartHintNoProduct", "No product to add.") });