fix(web): break the effect feedback loop, and stop serving HTML as JavaScript
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:
@@ -1,5 +1,27 @@
|
|||||||
:80 {
|
:80 {
|
||||||
root * /srv
|
root * /srv
|
||||||
file_server
|
|
||||||
try_files {path} /index.html
|
# /wails/runtime.js is injected by the Wails desktop wrapper, which serves
|
||||||
|
# the same dist/ from its own asset handler. In a browser it does not
|
||||||
|
# exist, and the SPA fallback below answered it with index.html — so the
|
||||||
|
# browser parsed "<!doctype html>" as JavaScript and threw
|
||||||
|
# "SyntaxError: expected expression, got '<'" on every page load.
|
||||||
|
# Return a real 404 instead: the tag fails quietly, and the desktop app is
|
||||||
|
# unaffected because it never reaches this server.
|
||||||
|
handle /wails/* {
|
||||||
|
error 404
|
||||||
|
}
|
||||||
|
|
||||||
|
# Same reasoning for any other asset: a missing .js/.css/.map answered with
|
||||||
|
# HTML is always a confusing parse error rather than an honest 404. Only
|
||||||
|
# real routes should fall through to the SPA.
|
||||||
|
@asset path_regexp \.(js|mjs|css|map|json|png|jpg|svg|ico|woff2?)$
|
||||||
|
handle @asset {
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
file_server
|
||||||
|
try_files {path} /index.html
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, tick } from 'svelte'
|
import { onMount, tick, untrack } from 'svelte'
|
||||||
import uPlot from 'uplot'
|
import uPlot from 'uplot'
|
||||||
import 'uplot/dist/uPlot.min.css'
|
import 'uplot/dist/uPlot.min.css'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
@@ -199,10 +199,17 @@
|
|||||||
// Lifecycle events (execution.completed/failed) refresh the list instead:
|
// Lifecycle events (execution.completed/failed) refresh the list instead:
|
||||||
// without that the row keeps its `running` badge and empty duration forever,
|
// without that the row keeps its `running` badge and empty duration forever,
|
||||||
// which only became visible once running executions were shown at all.
|
// 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(() => {
|
$effect(() => {
|
||||||
const ev = $liveEvents[0]
|
const ev = $liveEvents[0]
|
||||||
if (!ev) return
|
if (!ev) return
|
||||||
|
untrack(() => handleEvent(ev))
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleEvent(ev: OikosEvent) {
|
||||||
if (ev.type.startsWith('execution.')) {
|
if (ev.type.startsWith('execution.')) {
|
||||||
if (ev.type === 'execution.output') {
|
if (ev.type === 'execution.output') {
|
||||||
const target = expandedExecution
|
const target = expandedExecution
|
||||||
@@ -235,7 +242,7 @@
|
|||||||
if (ev.type.startsWith('signal.') || ev.type.startsWith('coverage.')) {
|
if (ev.type.startsWith('signal.') || ev.type.startsWith('coverage.')) {
|
||||||
refreshSignals()
|
refreshSignals()
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
async function refreshSignals() {
|
async function refreshSignals() {
|
||||||
if (!entity) return
|
if (!entity) return
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte'
|
import { onMount, onDestroy, untrack } from 'svelte'
|
||||||
import { fetchGraph, type GraphView, type Entity } from '$lib/api'
|
import { fetchGraph, type GraphView, type Entity } from '$lib/api'
|
||||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||||
import { isHealthEvent, applyHealthEvent, healthFromEvent } from '$lib/health'
|
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
|
// 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
|
// patched in place instead of pulling the entire fleet graph (and its
|
||||||
// layout) down again for one colour change.
|
// 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(() => {
|
$effect(() => {
|
||||||
const ev = $liveEvents[0]
|
const ev = $liveEvents[0]
|
||||||
if (!ev) return
|
if (!ev) return
|
||||||
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.')) {
|
untrack(() => {
|
||||||
load()
|
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.')) {
|
||||||
return
|
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 }
|
|
||||||
}
|
}
|
||||||
}
|
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 ─────────────────────────────────────────────────
|
// ─── interaction state ─────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte'
|
||||||
import { slide } from 'svelte/transition'
|
import { slide } from 'svelte/transition'
|
||||||
import type { ActivityEntry } from '$lib/stores/activity'
|
import type { ActivityEntry } from '$lib/stores/activity'
|
||||||
import type { PlanStep } from '$lib/api'
|
import type { PlanStep } from '$lib/api'
|
||||||
@@ -58,11 +59,16 @@
|
|||||||
// newest one is ever actually streaming.
|
// newest one is ever actually streaming.
|
||||||
let liveOutputEls = $state<Record<string, HTMLPreElement | null>>({})
|
let liveOutputEls = $state<Record<string, HTMLPreElement | null>>({})
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
for (const e of entries) {
|
// Depend on entries only. liveOutputEls is written by bind:this, so
|
||||||
if (!e.liveOutput) continue
|
// tracking it here would let a re-render re-trigger this effect.
|
||||||
const el = liveOutputEls[e.id]
|
const current = entries
|
||||||
if (el) el.scrollTop = el.scrollHeight
|
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) {
|
function toggleTool(id: string) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
import { onMount, untrack } from 'svelte'
|
||||||
import {
|
import {
|
||||||
fetchAllEntities,
|
fetchAllEntities,
|
||||||
fetchOntology,
|
fetchOntology,
|
||||||
@@ -194,7 +194,10 @@
|
|||||||
loadEntities()
|
loadEntities()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isHealthEvent(ev)) allEntities = applyHealthEvent(allEntities, ev)
|
// untrack: reads and writes allEntities. applyHealthEvent returns the
|
||||||
|
// same reference when nothing changed so it would settle, but relying on
|
||||||
|
// that to break a feedback loop is too subtle to leave implicit.
|
||||||
|
if (isHealthEvent(ev)) untrack(() => (allEntities = applyHealthEvent(allEntities, ev)))
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredEntities = $derived.by(() => {
|
const filteredEntities = $derived.by(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user