作成画面のプレビューに言葉が1件しか出ない、という穴を塞いだときに、繋いだ側で見つけたものの記録です。7月16日の「同じ写真が2回並ぶ — データの「単位」がズレると React の key が衝突する」で入れた畳み込みの、その先の話でもあります。
穴のほうは単純だった。プレビューは、サーバに問い合わせて組み立ててもらった結果を受け取るのではなく、手元(ブラウザ側)にあるデータだけで組み立てています。だから言葉の束がそこに乗っていなかった。材料は同じ画面にあったので、繋ぐだけ。作り忘れではなく繋ぎ忘れだった。
この穴を塞ぐ作業からは、「描く面を全部数える」と書いた同じ晩に、自分が数え漏らしたという別の記録も出ている。あちらは同じテンプレートを描く面を数え漏らした話で、こちらは繋いだ先で見つけたもの。
繋ぐ関数はそのまま AI に投げた。返ってきたものを読んでいる途中で、手が止まった。
同じデータが、2つの型で並んでいた
その画面には、同じ写真データを表す型が2つあった。
役割が違うので、2つあること自体はおかしくありません。片方は「どの写真を選んだか」を扱い、もう片方は「その写真に何を書いたか」を読ませます。
問題は、どちらも「代表の言葉」を持っていることだった。
代表の選び方が違った
FavoritePhoto 側は、同じ写真の行が何度も来たら1件にまとめます(畳み込みます)。そのコード。
// 同じ写真の行が来たら、書いた日時が古いほうを残す
// 「<」は同じ時刻を含まない=同時刻だと入れ替えが起きず、先に来たほうがそのまま残る
if (candidateWrittenAt && (!currentWrittenAt || candidateWrittenAt < currentWrittenAt)) {
collapsed[index] = memory;
}CreatePoolItem 側の並べ替え。
item.entries.sort((a, b) => {
// 戻り値が -1 なら a を前へ、1 なら後ろへ(sort の決まりごと)
if (a.writtenAt !== b.writtenAt) return a.writtenAt < b.writtenAt ? -1 : 1;
return a.meaningId - b.meaningId; // ← 同時刻のときの決着。ID の小さいほうが前
});
item.representativeWords = item.entries[0]?.text; // 先頭が代表。「?.」=1本も無ければ空どちらも「最初に綴ったもの」を代表にします。日時が違う限りは同じ結果になります。
狙いが揃っているのは偶然ではない。保存後のページと共有面はサーバ側が「最初の綴り」を代表として解決するので、画面側もそこに揃えないと、選んでいるときと保存したあとで顔が変わります。畳み込み側のコメントも「代表の言葉は backend の作品解決と揃えて『最初の綴り』(writtenAt 最小)を採用する」と宣言していて、そのコメントが本当かをサーバの実装で裏取りしたのが前の記事のほうだった。
同じ仕様を2箇所で別々に実装した、というのが実際に起きたことだった。
分かれるのは同時刻のときだった。前者は < で厳密に比較しているので、同じ時刻なら先に現れたほう(API が返した順の先)が残ります。後者は meaningId の昇順で決着します。
問題は、API 側に同時刻の決着キーが無いことだった。サーバは「書いた日時の降順」だけで並べていて、第2キーを持っていません。同じ時刻の行がどちらの順で返るかは、並べ替えを実行するデータベース任せ=決まっていません。
並べ順を決めきっていないリストの先頭を代表にすると先頭が確定しない、という同じ機構は、バックエンド側でも一度踏んでいる(「先頭の1件」は並び順を指定しないと毎回変わる — Django prefetch_related の落とし穴)。あちらは並び順そのものが無いケースで、こちらは第1キー(writtenAt)は両方にあって、同時刻という端の決着だけが無い。
実際コードには不変条件として書いてあった。
// PhotoEntry.words/mood は代表(entries[0]) の写し= words === entries[0].text が不変条件。ただしこれはコメントで、どこかが検査しているわけではありません。上流の2系統がたまたま同じ答えを出している間だけ成り立っていた。
繋ぐ前は、束が届いていないので比べようがなかった。繋いだ瞬間に、2つの規則が同じ画面に並びます。
値を配るだけでなく、どちらが正かを決める
繋ぐ関数の中で強制する形にした。
/** PhotoEntry へ全綴りを重ねる。
* ⚠️ words/mood は entries[0] で上書きする。代表の選び方が2系統あり、
* 同じ時刻に綴った2件があると食い違いうるため。
* entries を持つなら entries[0] が真実の源。 */
function withEntries(entry: PhotoEntry, entries: WordsEntry[] | undefined): PhotoEntry {
const lead = entries?.[0]; // 束の先頭=代表
if (!entries || entries.length === 0 || !lead) return entry; // 束が無ければ元のまま返す
// 「...entry」=元の中身をまるごとコピーして、後ろに書いた欄だけ上書きする
return { ...entry, words: lead.text, mood: lead.mood, entries };
}片方の畳み込みを直す手もあった。でもそれだと「今は揃っている」状態になるだけで、次に片方を触らせたときにまた分かれます。関数の中で「束を持つなら束が正」と決めてしまえば、上流がどうまとめていても結果は1つになります。
テストで固定した。
test("words が entries[0] と食い違うとき entries[0] で上書きする", () => {
const result = buildSelectedPhotos(["42"], [favA], {
"42": [{ text: "本当の1本目" }, { text: "words A" }],
});
expect(result[0]!.words).toBe("本当の1本目"); // expect(値).toBe(期待)の形。entries[0]で上書きされたか
});学び
同じデータを2つの型で持つと、値が二重になるだけだと思っていた。実際に二重化するのは規則のほうだった。
代表の選び方、並び順、既定値、空のときの扱い。こういう「暗黙のルール」は、頼む側が明示しなければ書く側がその場で決めることになります。実装は AI に書いてもらっているので、2つの型を別々に頼めば、そのたび別々の決め方が入ります。今回も仕様が食い違っていたわけではなく、どちらも「最初の綴りを代表に」を実装していて、端(同時刻)の詰め方だけが分かれた。しかも両方とも単体では正しいので、テストは通ります。
繋いだときに初めて表に出ます。だから関数の中で entries を持つなら entries[0] が真実の源 と決め切った。上流が今後どう畳んでも、ここを通れば結果は1つになります。
この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。