fix(home): $effect-Loop bei sort=viewed via untrack

Der Profile-Switch-Refetch-Effect las allLoading in der sync tracking-
Phase. Der await fetch beendete die Sync-Phase, das finale
allLoading = false im finally lief ausserhalb → wurde als externer
Write interpretiert → Effect rerun → naechster Fetch → Endlosschleife.

2136 GETs auf /api/recipes/all?sort=viewed in 8s beobachtet.

Fix: nur profileStore.active bleibt tracked (der tatsaechliche
Trigger). allSort/allLoading werden in untrack() gelesen — die Writes
auf allLoading im finally triggern damit keinen Effect-Rerun mehr.

Verifiziert lokal: 1 Request statt 2000+ bei mount mit allSort=viewed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 15:39:02 +02:00
parent 829850aa88
commit 12f499cb98

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { onMount, tick, untrack } from 'svelte';
import { page } from '$app/stores';
import { CookingPot, X, ChevronDown } from 'lucide-svelte';
import { slide } from 'svelte/transition';
@@ -235,27 +235,31 @@
// profiles, refetch with the new profile_id so the list reflects what
// the *current* profile has viewed. Other sorts are profile-agnostic
// and don't need this.
//
// Only `profileStore.active` must be a tracked dep. `allSort` /
// `allLoading` are read inside untrack: otherwise the `allLoading = false`
// write in the fetch-finally would re-trigger the effect and start the
// next fetch → endless loop.
$effect(() => {
const active = profileStore.active;
if (allSort !== 'viewed') return;
if (allLoading) return;
// Re-fetch the first page; rehydrate would re-load the previous
// depth, but a sort-context change should reset to page 1 anyway.
void (async () => {
allLoading = true;
try {
const res = await fetch(buildAllUrl('viewed', ALL_PAGE, 0));
if (!res.ok) return;
const body = await res.json();
const hits = body.hits as SearchHit[];
allRecipes = hits;
allExhausted = hits.length < ALL_PAGE;
} finally {
allLoading = false;
}
// 'active' is referenced so $effect tracks it as a dep:
void active;
})();
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
profileStore.active;
untrack(() => {
if (allSort !== 'viewed') return;
if (allLoading) return;
void (async () => {
allLoading = true;
try {
const res = await fetch(buildAllUrl('viewed', ALL_PAGE, 0));
if (!res.ok) return;
const body = await res.json();
const hits = body.hits as SearchHit[];
allRecipes = hits;
allExhausted = hits.length < ALL_PAGE;
} finally {
allLoading = false;
}
})();
});
});
// Sync current query back into the URL as ?q=... via replaceState,