fix(web): break the effect feedback loop, and stop serving HTML as JavaScript
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Two unrelated console errors.

effect_update_depth_exceeded — mine, from the previous commit. The live-update
effects both read and wrote the same state: FleetMap's health patch builds a
new `graph` object every run, and EntityDetailContent's refreshExecutions()
assigns a fresh `executions` array. Svelte tracked those reads, so each write
re-triggered the effect, which wrote again, until it gave up. The effects now
depend on liveEvents alone and do their work inside untrack(). Applied to all
four live effects, including the two that happened to settle on their own —
relying on "applyHealthEvent returns the same reference when nothing changed"
to break a feedback loop is far too subtle to leave implicit.

SyntaxError: expected expression, got '<' — pre-existing, and unrelated to the
live-update work. index.html loads /wails/runtime.js unconditionally; that file
only exists inside the Wails desktop wrapper, which serves the same dist/ from
its own asset handler. In a browser it is missing, and the SPA fallback
answered it with index.html — so the browser parsed "<!doctype html>" as
JavaScript on every single page load. The web Caddyfile now returns a real 404
for /wails/*, and more generally serves asset extensions without the SPA
fallback: a missing .js or .css answered with HTML is always a confusing parse
error rather than an honest 404.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-28 21:22:03 +02:00
parent 6ed9dc39e8
commit af450dac2a
5 changed files with 72 additions and 28 deletions

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, tick } from 'svelte'
import { onMount, tick, untrack } from 'svelte'
import uPlot from 'uplot'
import 'uplot/dist/uPlot.min.css'
import { marked } from 'marked'
@@ -199,10 +199,17 @@
// Lifecycle events (execution.completed/failed) refresh the list instead:
// without that the row keeps its `running` badge and empty duration forever,
// which only became visible once running executions were shown at all.
// untrack: this effect must depend ONLY on liveEvents. It reads `entity`,
// `executions` and `expandedExecution` and writes all three — refreshExecutions()
// assigns a fresh array, which re-triggered the effect, which refetched
// again, until Svelte aborted with effect_update_depth_exceeded.
$effect(() => {
const ev = $liveEvents[0]
if (!ev) return
untrack(() => handleEvent(ev))
})
function handleEvent(ev: OikosEvent) {
if (ev.type.startsWith('execution.')) {
if (ev.type === 'execution.output') {
const target = expandedExecution
@@ -235,7 +242,7 @@
if (ev.type.startsWith('signal.') || ev.type.startsWith('coverage.')) {
refreshSignals()
}
})
}
async function refreshSignals() {
if (!entity) return

View File

@@ -11,7 +11,7 @@
</script>
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { onMount, onDestroy, untrack } from 'svelte'
import { fetchGraph, type GraphView, type Entity } from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { isHealthEvent, applyHealthEvent, healthFromEvent } from '$lib/health'
@@ -61,26 +61,32 @@
// exist. Health does not: the event carries the new value, so the node is
// patched in place instead of pulling the entire fleet graph (and its
// layout) down again for one colour change.
// untrack: this effect must depend ONLY on liveEvents. It both reads and
// writes `graph`, and the health patch builds a new object every time — so
// tracking that read made each write re-trigger the effect, which wrote
// again, until Svelte aborted with effect_update_depth_exceeded.
$effect(() => {
const ev = $liveEvents[0]
if (!ev) return
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.')) {
load()
return
}
if (isHealthEvent(ev) && graph) {
const health = healthFromEvent(ev)
if (!health || !ev.entity_id) return
// healthOf() reads graph.health[id] in preference to the node's own
// field (include=status attaches it as a side map), so patching only
// the nodes would leave the rendered colour unchanged. Patch both.
const nodes = applyHealthEvent(graph.nodes, ev)
graph = {
...graph,
nodes,
health: { ...(graph.health ?? {}), [ev.entity_id]: health as Health }
untrack(() => {
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.')) {
load()
return
}
}
if (isHealthEvent(ev) && graph) {
const health = healthFromEvent(ev)
if (!health || !ev.entity_id) return
// healthOf() reads graph.health[id] in preference to the node's own
// field (include=status attaches it as a side map), so patching only
// the nodes would leave the rendered colour unchanged. Patch both.
const nodes = applyHealthEvent(graph.nodes, ev)
graph = {
...graph,
nodes,
health: { ...(graph.health ?? {}), [ev.entity_id]: health as Health }
}
}
})
})
// ─── interaction state ─────────────────────────────────────────────────

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte'
import { slide } from 'svelte/transition'
import type { ActivityEntry } from '$lib/stores/activity'
import type { PlanStep } from '$lib/api'
@@ -58,11 +59,16 @@
// newest one is ever actually streaming.
let liveOutputEls = $state<Record<string, HTMLPreElement | null>>({})
$effect(() => {
for (const e of entries) {
if (!e.liveOutput) continue
const el = liveOutputEls[e.id]
if (el) el.scrollTop = el.scrollHeight
}
// Depend on entries only. liveOutputEls is written by bind:this, so
// tracking it here would let a re-render re-trigger this effect.
const current = entries
untrack(() => {
for (const e of current) {
if (!e.liveOutput) continue
const el = liveOutputEls[e.id]
if (el) el.scrollTop = el.scrollHeight
}
})
})
function toggleTool(id: string) {