From ec8c0cd891d24e1032986fded9e2e508cc3801d7 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 8 Jun 2026 16:06:37 +0300 Subject: [PATCH] feat(web): warm-seed cache for triggers/[id] (last detail page) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trigger detail re-fetched on every visit, flashing a skeleton. Warm-seed the trigger + config form from triggerDetailCache so the hero + form render instantly on revisit; gate the webhook + bindings panels on a detailsLoaded flag (loading row until their data lands) so they never flash a wrong "OFF"/"empty" state. The rotated webhook secret lives in the separate (uncached) webhook object and is always fetched fresh — never persisted. Hardened against reused-component nav races (review): a monotonic loadSeq token makes the latest load() the sole writer (fixes A→B→A same-id concurrency), and each cache-writing mutation handler pins the id for its round-trip so a mid-mutation nav can't poison another id's cache entry or surface its secret. --- web/src/routes/triggers/[id]/+page.svelte | 146 +++++++++++++++++----- 1 file changed, 115 insertions(+), 31 deletions(-) diff --git a/web/src/routes/triggers/[id]/+page.svelte b/web/src/routes/triggers/[id]/+page.svelte index b310d1e..38a8a96 100644 --- a/web/src/routes/triggers/[id]/+page.svelte +++ b/web/src/routes/triggers/[id]/+page.svelte @@ -2,6 +2,7 @@ import { onDestroy } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/stores'; + import { get } from 'svelte/store'; import * as api from '$lib/api'; import type { RedeployTrigger, @@ -9,6 +10,7 @@ TriggerInput, TriggerWebhook } from '$lib/api'; + import { triggerDetailCache } from '$lib/stores/caches'; import ForgeHero from '$lib/components/ForgeHero.svelte'; import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; @@ -37,10 +39,16 @@ return d.toLocaleString(); } - let trigger = $state(null); + // Warm-seed the trigger from the per-id cache so the hero + config form + // render instantly on revisit; `loading` clears as soon as the trigger is + // ready. The webhook/bindings panels gate on `detailsLoaded` instead, since + // the webhook carries a rotated secret and is never cached. + const _seed = triggerDetailCache.peek(get(page).params.id ?? '').value; + let trigger = $state(_seed); let webhook = $state(null); let bindings = $state([]); - let loading = $state(true); + let loading = $state(_seed === null); + let detailsLoaded = $state(false); let saving = $state(false); let error = $state(''); @@ -60,35 +68,77 @@ // kind tag when showKindPicker=false. let formState = $state(createTriggerKindFormState()); + // Seed the editable form ONCE per id (plain, non-reactive guard so the load + // effect tracks only `id`). On a warm fresh mount, seed from the cached + // trigger right away; load() handles cold + reused-component (A→B) nav. + let seededKey: string | null = null; + function seedFormFrom(tr: RedeployTrigger): void { + seedTriggerKindFormState( + formState, + tr.kind, + tr.name, + tr.config, + tr.webhook_enabled, + tr.webhook_require_signature + ); + } + if (_seed) { + seedFormFrom(_seed); + seededKey = get(page).params.id ?? ''; + } + + // Monotonic load token — each invocation claims the latest seq, so an + // earlier in-flight load (even for the same id on A→B→A nav, where the + // id-only guard can't tell two same-id loads apart) is stale and cannot + // write trigger / bindings / webhook / detailsLoaded. + let loadSeq = 0; + async function load(): Promise { - loading = true; + const k = id; + const my = ++loadSeq; + // New id: its secondary panels (bindings/webhook) aren't loaded yet. + detailsLoaded = false; + const cached = triggerDetailCache.peek(k); + if (cached.value) { + trigger = cached.value; + if (seededKey !== k) { + seedFormFrom(cached.value); + seededKey = k; + } + loading = false; // warm: render the hero + config form immediately + } else { + loading = true; + } error = ''; try { const tr = await api.getTrigger(id); + if (my !== loadSeq) return; // a newer load owns the state now trigger = tr; - seedTriggerKindFormState( - formState, - tr.kind, - tr.name, - tr.config, - tr.webhook_enabled, - tr.webhook_require_signature - ); - // Fetch the webhook info only when ingress is enabled — - // otherwise the secret/url panel stays in the disabled - // state. Bindings always load. - const tasks: Array> = [ - api.listBindingsForTrigger(id).then((b) => (bindings = b)) - ]; - if (tr.webhook_enabled) { - tasks.push(api.getTriggerWebhook(id).then((w) => (webhook = w))); - } else { - webhook = null; + triggerDetailCache.set(k, tr); + if (seededKey !== k) { + seedFormFrom(tr); + seededKey = k; } - await Promise.all(tasks); + loading = false; + // Secondary panels. The webhook carries the rotated secret, so it is + // fetched fresh every time and NEVER cached. Reset both first so a + // reused-component warm nav (A→B) can't show A's data under B. + webhook = null; + bindings = []; + const [b, w] = await Promise.all([ + api.listBindingsForTrigger(id), + tr.webhook_enabled ? api.getTriggerWebhook(id) : Promise.resolve(null) + ]); + if (my !== loadSeq) return; + bindings = b; + webhook = w; + detailsLoaded = true; } catch (e) { + if (my !== loadSeq) return; + // Clear so a 404 (e.g. a deleted trigger revisited from cache) shows + // the error state instead of a stale, interactive phantom. + trigger = null; error = e instanceof Error ? e.message : 'Failed to load trigger'; - } finally { loading = false; } } @@ -98,6 +148,9 @@ async function save(e?: Event): Promise { e?.preventDefault(); if (!trigger || saving || !canSave) return; + // Pin the id for the round-trip so a mid-save nav can't write another + // trigger's state or poison its cache entry (the component is reused). + const k = id; saving = true; error = ''; try { @@ -110,17 +163,22 @@ ...buildTriggerInput(formState), kind: trigger.kind }; - const updated = await api.updateTrigger(id, body); + const updated = await api.updateTrigger(k, body); + if (id !== k) return; trigger = updated; + triggerDetailCache.set(k, updated); // Webhook info comes/goes with the toggle. Keep state in // sync so the panel doesn't show stale secrets after // turning ingress off-then-on. if (updated.webhook_enabled) { - webhook = await api.getTriggerWebhook(id); + const w = await api.getTriggerWebhook(k); + if (id !== k) return; + webhook = w; } else { webhook = null; } } catch (e) { + if (id !== k) return; error = e instanceof Error ? e.message : 'Save failed'; } finally { saving = false; @@ -132,6 +190,9 @@ error = ''; try { await api.deleteTrigger(id); + // Drop the cached entry so a back-nav to this deleted id doesn't + // warm-seed a phantom detail page. + triggerDetailCache.remove(id); goto('/triggers'); } catch (e) { error = e instanceof Error ? e.message : 'Delete failed'; @@ -164,15 +225,22 @@ async function doFireNow(): Promise { if (!trigger) return; + const k = id; + const tid = trigger.id; firing = true; error = ''; try { - const res = await api.fireTriggerNow(trigger.id); + const res = await api.fireTriggerNow(tid); + if (id !== k) return; fireResult = { deployed: res.deployed, errored: res.errored, bindings: res.bindings }; scheduleFireFlashClear(); // Refresh the trigger so the "last fired" row reflects the new ts. - trigger = await api.getTrigger(trigger.id); + const fresh = await api.getTrigger(tid); + if (id !== k) return; + trigger = fresh; + triggerDetailCache.set(k, fresh); } catch (e) { + if (id !== k) return; error = e instanceof Error ? e.message : 'Fire failed'; } finally { firing = false; @@ -181,10 +249,14 @@ } async function doRotate(): Promise { + const k = id; rotating = true; error = ''; try { - const res = await api.regenerateTriggerWebhook(id); + const res = await api.regenerateTriggerWebhook(k); + // Don't surface this trigger's new secret if we've navigated to + // another trigger while the rotate was in flight. + if (id !== k) return; // regenerate returns the new url+secret but no signing // flag — preserve the current toggle state. webhook = { @@ -194,6 +266,7 @@ webhook?.webhook_require_signature ?? formState.webhookRequireSig }; } catch (e) { + if (id !== k) return; error = e instanceof Error ? e.message : 'Rotate failed'; } finally { rotating = false; @@ -202,10 +275,13 @@ } async function toggleBinding(b: TriggerBinding, next: boolean): Promise { + const k = id; try { const updated = await api.updateBinding(b.id, { enabled: next }); + if (id !== k) return; bindings = bindings.map((x) => (x.id === b.id ? { ...x, enabled: updated.enabled } : x)); } catch (e) { + if (id !== k) return; error = e instanceof Error ? e.message : 'Update failed'; } } @@ -213,14 +289,18 @@ async function doUnbind(): Promise { if (!confirmUnbindId) return; const bid = confirmUnbindId; + const k = id; try { await api.deleteBinding(bid); + if (id !== k) return; bindings = bindings.filter((b) => b.id !== bid); // Reflect the new binding count in the hero. if (trigger) { trigger = { ...trigger, binding_count: Math.max(0, trigger.binding_count - 1) }; + triggerDetailCache.set(k, trigger); } } catch (e) { + if (id !== k) return; error = e instanceof Error ? e.message : 'Unbind failed'; } finally { confirmUnbindId = null; @@ -270,7 +350,7 @@ {trigger?.name ?? $t('redeployTriggers.titleSingular')} · Tinyforge -
+
{#snippet stats()}
KIND
@@ -390,7 +470,9 @@ {$t('redeployTriggers.detail.webhookSub')} - {#if webhook && trigger.webhook_enabled} + {#if !detailsLoaded} +
+ {:else if webhook && trigger.webhook_enabled}
{$t('redeployTriggers.detail.webhookUrlLabel')}
@@ -460,7 +542,9 @@ {$t('redeployTriggers.detail.bindingsSub')} - {#if bindings.length === 0} + {#if !detailsLoaded} +
+ {:else if bindings.length === 0}

{$t('redeployTriggers.detail.bindingsEmpty')}