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)
の自動更新が次の作りだったため:
PaginatedListDataTableをkey={combinedRefreshKey}で全 remount して履歴を再取得する。- 進行中判定をセル内コンポーネント
RunningRowMarkerから親 stateactiveLogIdsへsetStateでフィードバックし、その 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.ts の ACTIVE_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;
切り分け / ローカル再現¶
- 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。
ritsubi_smile_import_logに PROCESSING 行を 1 件入れて/smile-sync/importを開くと #185 を再現できる。status='COMPLETED'に変えると消える。- コンソールで
window.setIntervalをラップして 3000ms interval の呼び出し回数を数えると、 暴走時は React の上限 (約 50 回) まで増える。正常時は (dev StrictMode の二重ぶんを含めても) 数回で安定する。
修正¶
HistorySection の remount と子→親 setState フィードバックを廃止し、PaginatedListDataTable
の registerRefresher で受け取った refetch を呼ぶ方式へ置き換えた:
key={combinedRefreshKey}を撤去 (テーブルを unmount しない)。- 進行中の有無は
transformDataでフェッチ済みデータから ref (hasActiveImportRef) に反映し、 state を介さない (再レンダーを誘発しない)。 - mount 中常設の 3 秒 interval が毎 tick で ref を見て、進行中があるときだけ refetch する。
- ボタン / 巻き戻し・キャンセル成功 / 親からの
externalRefreshNonceも refetch を呼ぶ。
再発防止¶
Dashboard の list 系で「進行中だけ自動更新」を実装するときは、key 差し替えによる remount や
セル → 親への setState フィードバックで polling を制御しない。PaginatedListDataTable は
registerRefresher (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 で隠すのではなく、customizeColumns の
meta.disabled: true を付ける。meta.disabled を付けた列は useGeneratedColumns が
return 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.ts の
buildDependencyOnlyColumnOverrides(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.mts の ritsubi:dashboard-chunk-reload plugin が、
vite:preloadError および chunk 取得失敗系の error / unhandledrejection を検知して
一度だけ自動 reload するハンドラを index.html に注入している (reload ループ防止の時間ガード
付き、dev / prod build 双方)。新しい index + chunk を取り直して復帰する。