Skip to content

Commit 564e973

Browse files
authored
fix(cockpit): four v-for :key collisions in Studio surfaces (#479)
* fix(cockpit): four v-for :key collisions in Studio surfaces 1) views/Studio.vue: receipts drawer used `r.service + r.correlation_id` as key — bare concat collides on prefix boundaries (e.g. "gateway"+ "run-1" vs "gate"+"wayrun-1"). Use `${r.service}:${r.correlation_id}` with an explicit separator (no dedicated id field on Receipt). 2) components/StudioQuery.vue: outer `<tr :key="i">` was index-as-key and inner `<td :key="c">` (plus `<th :key="c">`) collided on duplicate column names like `SELECT a.name, b.name FROM ...`. Switch outer to `${i}:${JSON.stringify(r).slice(0, 120)}` and both header/body cell loops to `(c, colIdx)` with `${c}:${colIdx}`. 3) components/StudioCompute.vue: session receipt chain used `unshift(res)` with `:key="i"`, so every prepend shifted every existing row's key. Use `c.receipt?.id ?? \`row-${i}\`` — chain is only fed ok/degraded results, which carry a sealed ComputeReceipt.id. 4) components/StudioGraph.vue: "How derived?" list used `:key="i"` on server-ordered `derived.derivations`, so the epistemic-mode pill would mislabel edges when the array reordered. Use `${d.direction}:${d.relation}:${d.with.id}` — direction + relation + target-node id uniquely identifies each edge. * Copilot round-2: computed row-keys, per-entry local ids, order stability Three Copilot findings on the initial patch, all real: 1. `JSON.stringify(r).slice(...)` ran on EVERY reactivity tick for every row (expensive on large result sets). Extracted to a `computed` keyed off `result.value.rows` — recomputed only when the result set itself changes, not per render. Keys are now derived via a cheap FNV-1a 32-bit rolling hash (bounded to 512 chars of JSON to keep it fast). 2. The key still included the loop index `i`, so any row reordering forced DOM recreation. Now content-only signatures with a stable disambiguator only when the SAME content appears twice — reordering distinct rows preserves each row's DOM identity. 3. StudioCompute's `row-${i}` fallback reintroduced the original `unshift` instability for entries with no receipt id. Added a per-session `localId` assigned at insertion (`local-1`, `local-2`, …) and gated all chain unshift() call sites through a new `appendToChain()` helper. Key is now `c.receipt?.id ?? c.localId` — stable in all degraded/no-receipt cases.
1 parent 493841d commit 564e973

4 files changed

Lines changed: 57 additions & 10 deletions

File tree

socioprophet-web/app-vue/src/components/StudioCompute.vue

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ async function runPlan() {
3232
try {
3333
const res = await runCompute({ kind: "workflow", spec: p.spec as Record<string, unknown>, project: props.project });
3434
result.value = res;
35-
if (res.status === "ok" || res.status === "degraded") chain.value.unshift(res);
35+
appendToChain(res);
3636
} catch (e) {
3737
runErr.value = e instanceof Error ? e.message : "run failed";
3838
} finally {
@@ -111,7 +111,18 @@ const running = ref(false);
111111
const runErr = ref("");
112112
const result = ref<ComputeResultLite | null>(null);
113113
// the receipt chain: every sealed run in this session, newest first (the tamper-evident record).
114-
const chain = ref<ComputeResultLite[]>([]);
114+
// Each entry carries a per-session localId so `v-for :key` is stable even when a run
115+
// completed without a receipt id (degraded status, or a gateway that omitted it). Without
116+
// it the fallback `row-${i}` shifted every existing key on each unshift and Vue rebuilt
117+
// the whole list, losing signed/attest transition state per row (Copilot round-2).
118+
interface ChainEntry extends ComputeResultLite { localId: string }
119+
let _chainSeq = 0;
120+
const chain = ref<ChainEntry[]>([]);
121+
function appendToChain(res: ComputeResultLite) {
122+
if (res.status === "ok" || res.status === "degraded") {
123+
chain.value.unshift({ ...res, localId: `local-${++_chainSeq}` });
124+
}
125+
}
115126
116127
const canRun = computed(() => !!selected.value && !running.value && fields.value.some((f) => (spec[f.key] ?? "").trim().length > 0 || f.type === "select"));
117128
@@ -150,7 +161,7 @@ async function run() {
150161
try {
151162
const res = await runCompute({ kind: k.kind, spec: payloadSpec, project: props.project, backend: backend.value || undefined });
152163
result.value = res;
153-
if (res.status === "ok" || res.status === "degraded") chain.value.unshift(res);
164+
appendToChain(res);
154165
} catch (e) {
155166
runErr.value = e instanceof Error ? e.message : "run failed";
156167
} finally {
@@ -382,7 +393,7 @@ function onKey(e: KeyboardEvent) { if (e.key === "Enter" && (e.shiftKey || e.ctr
382393
<!-- session receipt chain — the tamper-evident record of every run -->
383394
<div v-if="chain.length" class="chain">
384395
<div class="chain-head">⛨ Session receipt chain <span class="cn">{{ chain.length }}</span></div>
385-
<div class="crow" v-for="(c, i) in chain" :key="i">
396+
<div class="crow" v-for="c in chain" :key="c.receipt?.id ?? c.localId">
386397
<span class="cdot" :style="{ background: EPISTEMIC_COLORS[c.epistemic_status] || 'var(--idle)' }" />
387398
<span class="mono cid">{{ c.receipt ? short(c.receipt.id) : '—' }}</span>
388399
<span class="ckind">{{ c.kind }} · {{ c.backend }}</span>

socioprophet-web/app-vue/src/components/StudioGraph.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ async function loadDerived() {
306306
</div>
307307
<div v-if="derived.derivation_count" class="dlist">
308308
<div class="dlbl">Derived / co-observed with</div>
309-
<div v-for="(d, i) in derived.derivations" :key="i" class="drow">
309+
<div v-for="d in derived.derivations" :key="`${d.direction}:${d.relation}:${d.with.id}`" class="drow">
310310
<span class="rel">{{ d.direction === 'out' ? '→' : '←' }} {{ d.relation }}</span>
311311
<span class="dwith">{{ d.with.name }}</span>
312312
<span class="dpill" :style="{ borderColor: color(d.with.epistemic_mode), color: color(d.with.epistemic_mode) }">{{ d.with.epistemic_mode }}</span>

socioprophet-web/app-vue/src/components/StudioQuery.vue

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { ref } from "vue";
2+
import { ref, computed } from "vue";
33
import { runQuery, EPISTEMIC_COLORS, type QueryLang, type QueryResult } from "../services/studioApi";
44
55
const props = defineProps<{ project: string }>();
@@ -32,6 +32,42 @@ async function run() {
3232
3333
function epiOf(v: unknown): string | null { return result.value?.epistemic[String(v)] ?? null; }
3434
function color(mode: string): string { return EPISTEMIC_COLORS[mode] || "var(--faint)"; }
35+
36+
// Copilot round-2: computing `JSON.stringify(r).slice(...)` in the template ran on
37+
// every reactivity tick for every row (expensive on large result sets), and using
38+
// the loop index in the key meant DOM was recreated on any row-order change. Move
39+
// key derivation to a computed keyed off `result.value.rows` — recomputed only when
40+
// the result set itself changes, not per render. Keys are content-derived (a cheap
41+
// FNV-1a 32-bit rolling hash over the JSON serialisation, capped so a giant row
42+
// doesn't dominate) and disambiguated only when the same content appears twice, so
43+
// reordering the same rows preserves each row's DOM identity.
44+
function fnv1a32(s: string): number {
45+
let h = 0x811c9dc5;
46+
for (let i = 0; i < s.length; i++) {
47+
h ^= s.charCodeAt(i);
48+
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
49+
}
50+
return h >>> 0;
51+
}
52+
function rowSignature(r: Record<string, unknown>): string {
53+
// Bounded: an unbounded JSON.stringify would defeat the point of moving off the
54+
// template. 512 bytes of the canonical form is more than enough to distinguish
55+
// rows in practice, and duplicate content collapses onto the disambiguator below.
56+
const s = JSON.stringify(r);
57+
return fnv1a32(s.length > 512 ? s.slice(0, 512) : s).toString(36);
58+
}
59+
const rowKeys = computed<string[]>(() => {
60+
const rows = result.value?.rows ?? [];
61+
const seen = new Map<string, number>();
62+
return rows.map((r) => {
63+
const sig = rowSignature(r as Record<string, unknown>);
64+
const n = seen.get(sig) ?? 0;
65+
seen.set(sig, n + 1);
66+
// Same-content rows get a stable dup-index suffix so Vue doesn't collapse them,
67+
// but a reorder of distinct rows produces identical keys → DOM stays with the row.
68+
return n === 0 ? sig : `${sig}#${n}`;
69+
});
70+
});
3571
</script>
3672

3773
<template>
@@ -64,10 +100,10 @@ function color(mode: string): string { return EPISTEMIC_COLORS[mode] || "var(--f
64100

65101
<div v-if="result.columns.length" class="qscroll">
66102
<table class="qgrid">
67-
<thead><tr><th v-for="c in result.columns" :key="c">{{ c }}</th></tr></thead>
103+
<thead><tr><th v-for="(c, colIdx) in result.columns" :key="`${c}:${colIdx}`">{{ c }}</th></tr></thead>
68104
<tbody>
69-
<tr v-for="(r, i) in result.rows" :key="i">
70-
<td v-for="c in result.columns" :key="c">
105+
<tr v-for="(r, i) in result.rows" :key="rowKeys[i]">
106+
<td v-for="(c, colIdx) in result.columns" :key="`${c}:${colIdx}`">
71107
<span class="val">{{ r[c] }}</span>
72108
<span v-if="epiOf(r[c])" class="epi" :style="{ background: color(epiOf(r[c])!) }" :title="`epistemic: ${epiOf(r[c])}`" />
73109
</td>

socioprophet-web/app-vue/src/views/Studio.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ const actionsFor: Record<StudioSection, { label: string; hint: string }[]> = {
180180
<div class="rc-head"><b>Verified-compute receipts</b><span class="rc-sub">replayable proof-of-work · {{ receiptsData?.services_reachable ?? 0 }} services answering</span><button class="rc-x" @click="receiptsOpen = false" aria-label="Close receipts panel">✕</button></div>
181181
<div v-if="receiptsErr" class="rc-err">{{ receiptsErr }}</div>
182182
<div v-else-if="receiptsData" class="rc-list">
183-
<div v-for="r in receiptsData.receipts" :key="r.service + r.correlation_id" class="rc-row" :title="r.bundle_ref || ''">
183+
<div v-for="r in receiptsData.receipts" :key="`${r.service}:${r.correlation_id}`" class="rc-row" :title="r.bundle_ref || ''">
184184
<span class="rc-svc">{{ r.service }}</span>
185185
<span class="rc-cid mono">{{ r.correlation_id }}</span>
186186
<span v-if="r.verdict" class="rc-verdict">{{ r.verdict }}</span>

0 commit comments

Comments
 (0)