Hand-Built Preview Payload Drifts From the Device
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
Hand-build a WYSIWYG preview's base data separately from the device and the pattern-following frames still draw correctly while their contents stay pinned empty, so preview and device disagree.
The short version
In the signage class editor, the WYSIWYG preview’s base data (boardBase) was hand-built separately from the device. Blocks teachers edit — visitor (the visitor list), callout (student call-outs) — were fetched correctly, but system-supplied automatic blocks were pinned to null regardless of pattern: news, trainStatus (rail), presenceCount (presence sensor), weatherWarnings and heatAlerts (disaster and heat alerts). The real TV builds from a single source, buildSignagePayloadForClass, which fetches every block through the PATTERN_BLOCKS gate, so on patterns 2 to 4 the editor and the device looked different. Rebuilding the preview’s base from the same buildSignagePayloadForClass resolved it.
What it looks like
On 2026-06-20, right after a production release, a user reported that “the editor’s pattern does not match the displayed pattern”. Opening the class editor (/app/app/editor/[classId]) shows a WYSIWYG board preview, and comparing it against the real signage device, the following blocks were blank depending on the pattern.
- pattern2: rail, presence sensor and news blank
- pattern3: rail and presence sensor blank
- pattern4: news (the lead block), rail, presence sensor and the disaster band blank
pattern4 leans hardest on automatic blocks, so it produced the most visible report right after the release. The editor drew the correct pattern’s frames, so this was not a broken layout but contents that were always empty — a symptom whose cause is hard to guess at.
Why
The editor’s preview base was hand-built on a path separate from the device’s fetching.
// before the fix (automatic blocks pinned to null)
const boardBase: EditorBoardBase | null = previewDaily
? {
date,
designPattern: pattern,
daily: previewDaily,
scheduleDays: previewScheduleDays,
ads: previewAds,
weather: previewWeather,
classContext: previewClassContext,
presenceCount: null,
visitors: showVisitors ? visitors : null,
callouts: showCallouts ? callouts : null,
trainStatus: null,
// News (an automatic block, ADR-043) is not shown in the edit preview, like rail/sensor (null).
news: null,
// Disaster and safety (an automatic block, ADR-044) is also not shown in the edit preview
// (null = the whole band is hidden).
weatherWarnings: null,
heatAlerts: null,
blackout,
}
: null;
Daily data teachers edit, such as previewDaily and previewAds, was fetched individually on the editor side. Following that shape, the automatic blocks were lined up as fields one by one too, and presenceCount, trainStatus, news, weatherWarnings and heatAlerts were never fetched at all — null was passed deliberately, with a comment.
Meanwhile buildSignagePayloadForClass, which assembles the board for the real TV, is a single-source fetch function that follows the PATTERN_BLOCKS gate (a mapping declaring in one place which blocks each pattern shows) and fetches every block. The editor’s preview never went through that function, so automatic blocks that should appear per pattern on the device were simply never fetched in the editor.
As types, this is within what EditorBoardBase allows, and a component given null merely draws no widget rather than erroring. With the build and the tests passing and no mechanism comparing the device’s output against the editor’s, the discrepancy stayed quietly in place.
Fixing it
We changed the preview base to be built from exactly the same buildSignagePayloadForClass call the device makes.
// The live preview base for the WYSIWYG ("edit board" tab) is built from exactly the same payload
// builder as the real signage (buildSignagePayloadForClass). That means the automatic content
// blocks (news / rail / presence sensor / disaster and safety band) are fetched and drawn through
// the same gate (PATTERN_BLOCKS) with the same fail-soft behaviour, so the editor's board and the
// real TV match.
const board = user.schoolId
? await buildSignagePayloadForClass(tx, user.schoolId, classId, date, pattern)
: null;
Because the already-resolved pattern is passed as designParam, buildSignagePayloadForClass does not resolve the pattern a second time internally. Alongside this change, the hand-built duplicate fetches (previewDaily / previewAds / previewScheduleDays / previewClassContext / previewWeather / blackout / visitors / callouts) were removed entirely, a net −55 lines.
visitor (the visitor list) and callout (student call-outs), which teachers edit, are handled separately as editable blocks, and the shape of deciding only their visibility with patternIncludesBlock is unchanged.
const showVisitors = patternIncludesBlock(pattern, "visitor");
const showCallouts = patternIncludesBlock(pattern, "callout");
Saving, validation, autosave, RLS and audit logic were untouched. What had drifted was only the fetching path the preview reads from.
Preventing a repeat
An implementation that hand-builds only the automatic blocks as individual fields relies on whoever adds a field checking, every time, that it matches the device’s fetching condition — and if that check is skipped, the type checker and the tests both let it through. Making the device and the preview call the same function means the per-pattern selection now exists only in a single source, PATTERN_BLOCKS, and adding a new pattern or automatic block no longer requires adding a branch on the editor side. Removing the hand-built duplicate is itself the guard against this kind of drift.
よくある質問
Q1Why did neither the build nor the tests catch the drift?
The hand-built boardBase was correct as TypeScript, and passing null to presenceCount or trainStatus was within what EditorBoardBase allows. Given null, the editor's components draw no widget rather than erroring, and they ran on a different path from buildSignagePayloadForClass.
Q2Did every pattern drift the same way?
No. pattern1 uses no automatic blocks and was unaffected. pattern2 lost rail, presence sensor and news; pattern3 rail and presence sensor; pattern4 news (its lead block), rail, presence sensor and the disaster band. pattern4 leans hardest on automatic blocks.
Q3Were editable blocks like the visitor list broken by the same cause?
They were not. visitor and callout are editable blocks teachers fill in daily, and even before the fix they got the snapshot already fetched. Only system-supplied automatic blocks were pinned to null, and PATTERN_BLOCKS treats the two as separate categories.
Q4Did the fix duplicate the device's fetching logic in the editor?
It did not. The editor's preview base is now built from exactly the same buildSignagePayloadForClass call the device makes. That removed the hand-built duplicate fetches (previewDaily, previewAds, previewScheduleDays, previewWeather, blackout, visitors, callouts) entirely — a net −55 lines.
確認した環境
- Next.js 16.0 / React 19.0 (apps/web/package.json, as of 2026-06-20)
- Occurred in production on 2026-06-20 and fixed the same day
この記事の根拠
- TypeScriptファイル 1〜20行目コミット 7db7a5d
- TypeScriptファイル 76〜110行目コミット 7db7a5d
- TypeScriptファイル 161〜182行目コミット d0eff93
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。