Files
pad/web/src
xarmian ef7eabaddc fix(web): admin modal reactive loop on open (#611)
* fix(web): admin modal reactive loop on open (PLAN-1542 follow-up)

User reported the modal froze the page on open. Root cause was the
UserSettingsForm hydration \$effect added in TASK-1551 — it read prop
fields then reassigned editOverrides to a fresh object and mutated
per-key inside the same effect, while the template's
{#each overrideFields} block subscribed via bind:value to those exact
keys. The reassign+mutate pattern thrashed the bind subscriptions
which scheduled the effect's tracking owner, looping until
effect_update_depth_exceeded.

Fixes (defense in depth):

1. UserSettingsForm hydration \$effect now gates on user.id only
   (a primitive read with no proxy churn) and wraps all writes in
   untrack(). The objects are built locally then assigned ONCE per
   state var — no "reassign then mutate" pattern.

   Side effect: form state isn't nuked by the parent's
   modalUser = { ...modalUser, ...updated } spread after every save.
   The form's own state is authoritative for unsaved edits; re-
   hydration only happens when the modal swaps to a different user.

2. UserModal now mounts only the active tab's body. The earlier
   {#each TABS} + hidden pattern instantiated all four tab
   components on every modal open, amplifying the UserSettingsForm
   loop and firing three lazy-fetch effects in parallel for tabs
   the admin may never click. Tabs are now mount-on-active —
   lazy fetch happens when the admin selects the tab.

3. Lazy-fetch tabs (Overview/Workspaces/Activity) now claim
   fetchedForUserId BEFORE the await, not after. A re-trigger of
   the gating effect (parent passes a new modalUser object with
   the same id) can no longer race in and fire a duplicate
   concurrent fetch. On failure, the claim is cleared so a
   retry-via-reactivation still works.

Verified via svelte-check (0 errors) and npm run build.

* fix: address Codex review on modal reactive-loop fix

1. Keep UserSettingsForm mounted across tab switches (hidden toggle
   rather than {#if}). Mount-on-active for the Settings tab meant an
   admin who tabbed away to Workspaces and back lost unsaved overrides
   and the typed-email disable input. The form has no fetch — its
   hydration $effect is now gated on user.id so the original loop
   stays fixed, and keeping it alive is cheap. Lazy-fetch tabs
   (Overview / Workspaces / Activity) still mount-on-active so their
   network calls fire only when needed.

2. UserOverviewTab now clears fetchedForUserId when EITHER metrics or
   recent-items branch fails (was: only when both failed). A partial
   failure left the failed half stuck on the next activation until
   the admin force-refreshed the page. The successful half gets
   re-fetched on retry, which is cheap.

* fix: Overview tab partial-failure retry is now explicit, not auto

Clearing fetchedForUserId on failure while the tab is still active
re-triggered the gating \$effect immediately — under a persistent
outage that meant a continuous refetch loop and the successful
half's data getting wiped on every iteration. fetchedForUserId now
stays set on failure; the failed branch renders an inline Retry
button that calls loadAll() directly.

* fix: apply the same retry-claim fix to Workspaces + Activity tabs

Codex caught the same auto-retry-loop pattern in UserWorkspacesTab and
UserActivityTab: clearing fetchedForUserId on failure while the tab is
active re-triggers the gating $effect → immediate refetch → repeat.
Both already render explicit Retry buttons (calling loadDetail() /
loadInitial() directly), so the claim can stay set on failure.
2026-05-20 20:17:51 -04:00
..
2026-03-26 01:52:36 +00:00