Nexo P1 fix — stop the load-on-session effects from looping

Every product page (discover, matches, chat, profile, photos) had the
same pattern: an `$effect` reads `nexo.session()`, then calls a
`load()` / `hydrate()` helper. Inside that helper Svelte's reactivity
tracks the `loading` / `profile` / etc. cells the helper writes to,
so when the helper finishes (sets `loading = false`) the effect sees
its own write and re-runs — and triggers the same fetch all over
again. Result: the dev server console fills with continuous
`/api/discover`, `/api/matches`, `/api/profile/me` requests as long
as the page is mounted.

Fix per page:

- **Discover, Matches**: introduce an `initialised` guard plus
  `untrack(() => void load(...))`. The first session-ready transition
  triggers the fetch; the helper's writes no longer feed back into
  the effect.
- **Chat**: the trigger key is the URL `matchId` (it can change while
  the page is mounted via in-app navigation). A `loadedFor` cell
  holds the last id we fetched so a session-cell write doesn't
  re-fetch the same thread, and the actual `load()` call is wrapped
  in `untrack`.
- **Profile**: `hydrate()` populates eight reactive cells. Without
  `untrack` the effect kept hydrating the form on every keystroke,
  clobbering the user's edits. Now hydrated only when the profile id
  changes.
- **Photos**: shallow-compare profile by `id + updated` before
  re-assigning so identity-stable refreshes don't poke `$state`
  proxies that downstream code reads.

The redirect branches (anonymous → /login, no-profile → /onboarding)
keep working because `goto` unmounts the current page, so the
mid-render effect simply stops running.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 5 months ago
parent 27198b53ea
commit a713e0312d

@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { tick } from 'svelte';
import { tick, untrack } from 'svelte';
import { getNexoContext } from '../../_lib/context';
import { DatingApiError } from '../../_lib/api';
import type { DatingMatch, DatingMessage, DatingProfile } from '../../_lib/types';
@ -18,14 +18,22 @@
let topError = $state<string | null>(null);
let timeline: HTMLElement | null = null;
// Re-run only when the URL match id changes (or the session
// transitions to / from authenticated). `load()` writes to several
// reactive cells; without `untrack` the effect would observe its
// own writes and loop forever.
let loadedFor = '';
$effect(() => {
const session = nexo.session();
const id = matchId;
if (session === null) return;
if (!session.authenticated) {
void goto('/dating/login', { replaceState: true });
return;
}
if (matchId !== '') void load();
if (id === '' || loadedFor === id) return;
loadedFor = id;
untrack(() => void load());
});
async function load() {

@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { getNexoContext } from '../_lib/context';
import { DatingApiError } from '../_lib/api';
import {
@ -89,10 +90,18 @@
return caught instanceof Error ? caught.message : String(caught);
}
// Trigger the initial fetch when the session resolves to a usable
// state. `untrack` is critical: `load()` reads and writes `loading`
// reactively, so calling it inside a tracked branch would create a
// feedback loop (load → loading=true → effect re-fires → load → …)
// that hammers the API endlessly.
let initialised = false;
$effect(() => {
const session = nexo.session();
if (initialised) return;
if (session?.authenticated && session.profile?.completed) {
void load(true);
initialised = true;
untrack(() => void load(true));
}
});

@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { getNexoContext } from '../_lib/context';
import { DatingApiError } from '../_lib/api';
import type { DatingMatch, DatingProfile } from '../_lib/types';
@ -11,6 +12,10 @@
let loading = $state(true);
let error = $state<string | null>(null);
// `untrack` keeps the effect from re-firing on every reactive
// write `load()` performs (`loading` toggles true→false). Without
// it the page hits `/api/matches` continuously.
let initialised = false;
$effect(() => {
const session = nexo.session();
if (session === null) return;
@ -18,7 +23,9 @@
void goto('/dating/login', { replaceState: true });
return;
}
void load();
if (initialised) return;
initialised = true;
untrack(() => void load());
});
async function load() {

@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { getNexoContext } from '../_lib/context';
import { DatingApiError } from '../_lib/api';
import {
@ -29,6 +30,10 @@
let topError = $state<string | null>(null);
let fieldErrors = $state<Record<string, string>>({});
// `hydrate()` populates many reactive cells. Without `untrack`,
// the effect's own writes would re-trigger it and clobber any
// edits the user makes between renders.
let hydratedFor = '';
$effect(() => {
const session = nexo.session();
if (session === null) return;
@ -40,7 +45,11 @@
void goto('/dating/onboarding', { replaceState: true });
return;
}
hydrate(session.profile);
const profileId = session.profile.id;
if (hydratedFor === profileId) return;
hydratedFor = profileId;
const next = session.profile;
untrack(() => hydrate(next));
});
function hydrate(next: DatingProfile) {

@ -28,7 +28,13 @@
void goto('/dating/onboarding', { replaceState: true });
return;
}
// Mirror the session-owned profile only when it actually
// changes — assigning the same reference still triggers
// reactivity in Svelte's `$state` proxies, which would re-run
// every consumer of `profile`.
if (profile?.id !== session.profile.id || profile?.updated !== session.profile.updated) {
profile = session.profile;
}
});
async function syncSession(updated: DatingProfile) {

Loading…
Cancel
Save

Powered by TurnKey Linux.