コンテンツにスキップ

Vendure Dashboard トラブルシュート

商品バリアント詳細で在庫数量の入力欄が表示されない

症状

Vendure Dashboard の Product Variant 詳細画面で、標準の在庫数量入力欄が表示されず、 「別のロケーションの在庫レベルを追加」だけが表示される。

この状態では、標準の在庫ロケーションが存在していても、バリアント側に stock_level 行がないため Dashboard が数量入力欄を描画できない。

原因

Vendure Dashboard の標準 Product Variant 詳細画面は、対象バリアントの stockLevels をもとに在庫数量入力欄を表示する。stockLevels が空の場合は、既存の stock location があっても数量入力欄は表示されない。

Ritsubi では過去の SMILE 商品 CSV import が ProductVariantService.create()stockOnHand を渡していなかったため、一部の product_variant に対応する stock_level 行が作成されなかった。

影響範囲の確認

read-only で次の SQL を実行し、関連行の欠落をまとめて確認する。

WITH checks(name, count) AS (
  SELECT 'product_variant_without_stock_level', count(*)
  FROM product_variant pv
  WHERE NOT EXISTS (
    SELECT 1 FROM stock_level sl WHERE sl."productVariantId" = pv.id
  )
  UNION ALL
  SELECT 'product_variant_without_price', count(*)
  FROM product_variant pv
  WHERE NOT EXISTS (
    SELECT 1 FROM product_variant_price pvp WHERE pvp."variantId" = pv.id
  )
  UNION ALL
  SELECT 'product_variant_without_channel', count(*)
  FROM product_variant pv
  WHERE NOT EXISTS (
    SELECT 1 FROM product_variant_channels_channel j WHERE j."productVariantId" = pv.id
  )
  UNION ALL
  SELECT 'product_variant_without_translation', count(*)
  FROM product_variant pv
  WHERE NOT EXISTS (
    SELECT 1 FROM product_variant_translation t WHERE t."baseId" = pv.id
  )
  UNION ALL
  SELECT 'product_without_channel', count(*)
  FROM product p
  WHERE NOT EXISTS (
    SELECT 1 FROM product_channels_channel j WHERE j."productId" = p.id
  )
  UNION ALL
  SELECT 'product_without_translation', count(*)
  FROM product p
  WHERE NOT EXISTS (
    SELECT 1 FROM product_translation t WHERE t."baseId" = p.id
  )
  UNION ALL
  SELECT 'product_without_variant', count(*)
  FROM product p
  WHERE NOT EXISTS (
    SELECT 1 FROM product_variant pv WHERE pv."productId" = p.id
  )
)
SELECT name, count::int
FROM checks
ORDER BY count DESC, name;

2026-05-22 の production 調査では、product_variant_without_stock_level が 143 件、価格・翻訳・channel 紐付け・商品 variant 欠落は 0 件だった。

該当 SKU のサンプルは次で確認する。

SELECT pv.id, pv.sku, pvt.name
FROM product_variant pv
LEFT JOIN product_variant_translation pvt ON pvt."baseId" = pv.id
WHERE NOT EXISTS (
  SELECT 1 FROM stock_level sl WHERE sl."productVariantId" = pv.id
)
ORDER BY pv.sku
LIMIT 20;

修復

seedRequiredBaseline() は、stock_level が 1 件もない ProductVariant に対して、 default channel の先頭 stock location に stockOnHand = 0 / stockAllocated = 0 の行を補完する。

staging では次を実行する。

pnpm exec nx run ritsubi-vendure-server:seed:required-baseline:staging

production では、同等の remote command で次を実行する。

bash apps/vendure-server/scripts/run-fly-remote-command.sh \
  ritsubi-ecommerce \
  "node dist/scripts/seed-required-baseline.js"

実行後、影響範囲確認 SQL の product_variant_without_stock_level が 0 になることを確認する。

再発防止

商品バリアントをコードから作成する場合は、在庫 0 でも stockOnHand: 0 を明示して stock_level 行を作る。SMILE 商品 CSV import では packages/plugins/src/system-integration/smile/services/csv-import-product.processor.ts の新規 variant 作成で stockOnHand: 0 を渡す。

SMILE連携インポート画面で React error #185 (Maximum update depth exceeded)

症状

/smile-sync/import を開くと画面が error boundary の「エラー」表示に落ち、コンソールに Minified React error #185 / Maximum update depth exceeded ... The above error occurred in the <SelectRoot> component が出る。hard reload しても直らない。一方、インポート履歴が 空、または完了済み行しかない状態では正常に表示される。

原因

進行中 (STARTED / PROCESSING) のインポート履歴が 1 件でも残っているときだけ発生する 無限再レンダーループ。HistorySection (packages/plugins/src/standard-extensions/admin-extensions/dashboard/smile-sync/components/HistorySection.tsx) の自動更新が次の作りだったため:

  1. PaginatedListDataTablekey={combinedRefreshKey}全 remount して履歴を再取得する。
  2. 進行中判定をセル内コンポーネント RunningRowMarker から親 state activeLogIdssetStateフィードバックし、その Set のサイズで polling interval を制御する。

進行中行があると、remount のたびに marker が unmount/mount して activeLogIds が毎 commit 変化 → polling effect ([type, activeLogIds] 依存) が毎 commit 再実行され、React の nested update 上限 (約 50) に到達。巻き込まれた base-ui <SelectRoot> (取込対象 / 文字コード / ページ送りの Select) の layout effect 内 store.update が #185 を投げる。進行中行が DB に 残る限り毎ロードで再発するため hard reload では復帰しない。

ritsubi_smile_import_log の status は STARTED / PROCESSING が active (import-progress-format.tsACTIVE_IMPORT_STATUSES)。ジョブが PROCESSING のまま滞留 (worker 停止・異常終了など) すると顕在化しやすい。

影響範囲の確認

read-only で進行中行の有無を確認する。

SELECT id, "importType", status, "startedAt", "processedRecordCount", "totalRecordCount", filename
FROM ritsubi_smile_import_log
WHERE status IN ('STARTED', 'PROCESSING')
ORDER BY "startedAt" DESC;

切り分け / ローカル再現

  1. dashboard スタックを起動する。just dev-dashboard-stack / just dev-full / just dev-vendure などの dev launcher は、dev_vendure のときだけ Storefront data invalidation を local 既定で無効化するため、Storefront 未起動でも baseline sync が通る:
just dev-dashboard-stack

raw Nx target を直叩きして CMS_REVALIDATE_SECRET is required for storefront data invalidation. が出た場合は、DB drift ではなく dev launcher を迂回した env 欠落を疑う。 local port は API=3021 / React Dashboard=6202、superadmin は ec-admin / devPassword!123

  1. ritsubi_smile_import_log に PROCESSING 行を 1 件入れて /smile-sync/import を開くと #185 を再現できる。status='COMPLETED' に変えると消える。
  2. コンソールで window.setInterval をラップして 3000ms interval の呼び出し回数を数えると、 暴走時は React の上限 (約 50 回) まで増える。正常時は (dev StrictMode の二重ぶんを含めても) 数回で安定する。

修正

HistorySection の remount と子→親 setState フィードバックを廃止し、PaginatedListDataTableregisterRefresher で受け取った refetch を呼ぶ方式へ置き換えた:

  • key={combinedRefreshKey} を撤去 (テーブルを unmount しない)。
  • 進行中の有無は transformData でフェッチ済みデータから ref (hasActiveImportRef) に反映し、 state を介さない (再レンダーを誘発しない)。
  • mount 中常設の 3 秒 interval が毎 tick で ref を見て、進行中があるときだけ refetch する。
  • ボタン / 巻き戻し・キャンセル成功 / 親からの externalRefreshNonce も refetch を呼ぶ。

再発防止

Dashboard の list 系で「進行中だけ自動更新」を実装するときは、key 差し替えによる remount や セル → 親への setState フィードバックで polling を制御しない。PaginatedListDataTableregisterRefresher (in-place refetch) を提供しており、進行中判定は transformData 経由の ref など再レンダーを誘発しない経路でフェッチ結果から導く。

履歴テーブルに意図しない列 ("Total Record Count" 等) が出る

症状

/smile-sync/import の履歴 (インポート) テーブルに、設計上は出さないはずの計算専用フィールドが 独立した列 — ヘッダ "Total Record Count" / "Processed Record Count" / "Cancel Requested At" / "Price Kind Breakdown" / "Unit Type Breakdown" 等 — として表示される。default の列可視設定で false にしている (つもり) なのに消えない。

原因

PaginatedListDataTable (@vendure/dashboard) は query の items { … } selection にある field ごとに列を自動生成する (useGeneratedColumns)。これらの field は status / 種別 / 操作 セルが meta.dependencies で fetch するためだけにクエリに含めているが、selection にある以上 列としても生成される。

getColumnVisibility(columns, defaultVisibility, customFieldColumnNames) は最後に ...defaultVisibility を spread するので、明示した false 自体は有効。しかし defaultVisibility (= IMPORT_HISTORY_DEFAULT_COLUMN_VISIBILITY) に列挙されていない field は visibility が解決されず undefined (= 可視) になる。元の map はこれらの計算専用 field を列挙 していなかったため、列として現れていた。{ ...tableSettings?.columnVisibility, ...defaultColumnVisibility } のマージ順は defaults が後勝ちなので、保存済み table settings が 原因ではない (map に無いことが原因)。

修正

「列にしない field」は defaultVisibility: false で隠すのではなく、customizeColumnsmeta.disabled: true を付ける。meta.disabled を付けた列は useGeneratedColumnsreturn null生成段階から除去するため、getColumnVisibility の解決可否に依存せず確実に 消え、列選択メニューにも出ず、保存済み table settings でも復活しない。依存先の可視列が meta.dependencies で fetch するためデータ表示は不変。

defaultVisibility: false との違い:

  • defaultVisibility: false … 列は存在し続け「初期非表示だがユーザーが再表示できる」。default map への列挙漏れや stale な保存設定で再露出し得る。
  • meta.disabled: true … 列自体が生成されない。toggle 不能・保存設定でも復活しない。

再発防止

「どの field が列か」は column visibility map を単一の正本にし、dependency 専用列の除去は手で 列挙せず導出する。smile-sync/list-column-dependencies.tsbuildDependencyOnlyColumnOverrides(document, columnFields) が query の item selection を走査し、 visibility map に無い field を meta.disabled 化した customizeColumns overrides を返す。 HistorySection は import / export 双方でこれを spread しているため、今後クエリへ dependency field を追加しても (visibility map に足さない限り) 列として露出せず、列挙漏れによる再発が起きない。 ルールは packages/plugins/AGENTS.md の「Dashboard 拡張」を正本にする。

Vendure deploy 後に #185 等が出て hard reload で直る (version skew)

症状

Vendure を deploy した後、開きっぱなしの Dashboard タブで遷移すると #185 や白画面が出るが、 hard reload すると直る。

原因

Vendure deploy には React Dashboard の再 deploy が同梱される。開いていたタブは古い hashed chunk を参照したまま遷移するため、消えた chunk の dynamic import に失敗する (version skew)。

対処

apps/vendure-server/vite.config.mtsritsubi:dashboard-chunk-reload plugin が、 vite:preloadError および chunk 取得失敗系の error / unhandledrejection を検知して 一度だけ自動 reload するハンドラを index.html に注入している (reload ループ防止の時間ガード 付き、dev / prod build 双方)。新しい index + chunk を取り直して復帰する。