diff --git a/documentation/GUIDE__SvelteKit2_Svelte5_DexieJS.md b/documentation/GUIDE__SvelteKit2_Svelte5_DexieJS.md index f53dc4f8..d7aa3828 100644 --- a/documentation/GUIDE__SvelteKit2_Svelte5_DexieJS.md +++ b/documentation/GUIDE__SvelteKit2_Svelte5_DexieJS.md @@ -280,6 +280,66 @@ If you must use non-blocking loads, you must pass the initial data to the compon {/if} ``` +## The `untrack()` Reactive-Tracking Trap + +`untrack()` is used inside `$effect` to read reactive values without registering them as tracked dependencies of that effect. This is correct for most "read-once" values (params, IDs) where you don't want the effect re-running on every change. But it has a silent failure mode: if a value you *need* to re-read is consumed inside `untrack()`, the effect becomes a one-shot and never retries when that value changes. + +### Symptom + +An effect runs once, reads a store value inside `untrack()`, takes an early-exit path (e.g. "no API key → skip"), and never retries — even after the store value is updated by a background process. + +### Real Example (IDAA Novi Verification Bug — 2026-03-25) + +The IDAA layout verifies Novi UUIDs. `site_cfg_json` (which contains the Novi API key) was read **inside** `untrack()`: + +```typescript +// BUG: site_cfg_json read inside untrack → one-shot, never retries +$effect(() => { + if (!browser) return; + const uuid = data.url.searchParams.get('uuid'); // tracked ✓ + + untrack(() => { + const site_cfg_json = $ae_loc.site_cfg_json; // ← NOT tracked ✗ + const api_key = site_cfg_json?.novi_idaa_api_key ?? null; + if (!api_key) return; // exits silently on first load with stale cache + verify_novi_uuid(uuid, api_key, ...); + }); +}); +``` + +On first load, the Dexie cache returned a stale `site_cfg_json` missing the API key. The effect exited early. The background refresh later updated `$ae_loc.site_cfg_json`, but because `site_cfg_json` was consumed inside `untrack()`, the effect never re-ran. + +**Fix:** Move the dependency read **outside** `untrack()`: + +```typescript +// FIX: site_cfg_json tracked outside untrack → effect re-runs when it changes +$effect(() => { + if (!browser) return; + const uuid = data.url.searchParams.get('uuid'); // tracked ✓ + const site_cfg_json = $ae_loc.site_cfg_json; // tracked ✓ — effect re-runs on change + + untrack(() => { + // Guard: already verified for this UUID — don't repeat the round-trip + if ($idaa_loc.novi_verified && $idaa_loc.novi_uuid === uuid) return; + + const api_key = site_cfg_json?.novi_idaa_api_key ?? null; + if (!api_key) return; + verify_novi_uuid(uuid, api_key, ...); + }); +}); +``` + +The guard inside `untrack()` is important: without it, every unrelated change to `$ae_loc` would re-trigger verification. + +### Rule of Thumb + +Before wrapping a store read in `untrack()`, ask: **"Do I need this effect to re-run if this value changes?"** + +- If yes → read it **outside** `untrack()`, and add a guard inside to prevent redundant work. +- If no → `untrack()` is correct. + +--- + ## Svelte 5 Binding Pitfalls ### 1. `props_invalid_value` (The "Expression Binding" Error) diff --git a/tests/README.md b/tests/README.md index debe7b9e..7a761097 100644 --- a/tests/README.md +++ b/tests/README.md @@ -289,6 +289,106 @@ await page.route(`**/v3/crud/event/${event_id}`, async (route) => { ``` +--- + +## Hard-Won Lessons — IDAA Auth Tests + +These lessons came from writing and debugging `tests/idaa_novi_auth.test.ts`. + +### Seed the Full `ae_idaa_loc` Structure — Not Just the Novi Fields + +**Symptom:** Test asserts "Access Denied" is not visible (passes) but then fails asserting the UUID span is visible. The page appears to show content (no "Access Denied" heading), but the Novi UUID is nowhere in the DOM. + +**Cause:** The `ae_idaa_loc` seed in `addInitScript` was minimal — it included only the Novi-related fields and omitted the nested `bb`, `archives`, and `recovery_meetings` objects. After a successful Novi API call, `verify_novi_uuid()` in `(idaa)/+layout.svelte` sets `$idaa_loc.novi_uuid` and upgrades `$ae_loc` to trusted access, then tries to reset BB query filters: + +```typescript +$idaa_loc.bb.qry__hidden = 'not_hidden'; +$idaa_loc.bb.qry__enabled = 'enabled'; +``` + +Because `bb` is `undefined` in the seeded store (svelte-persisted-store uses the stored value as-is, not a deep merge with defaults), this throws `TypeError: Cannot set properties of undefined`. The `try/catch` catches it and resets `novi_uuid = null` and `novi_verified = false`. The `$ae_loc.trusted_access` flag was already set before the throw, so the gate passes — but the UUID span uses `{#if $idaa_loc.novi_uuid}` which is now null. Silent failure, no visible error. + +**Fix:** Seed `ae_idaa_loc` with the full structure from `idaa_local_data_struct` in `ae_idaa_stores.ts`, including at minimum the `bb` object: + +```typescript +const ae_idaa_loc_data = { + ver: '2024-08-21_1646', + novi_uuid: null, + novi_verified: false, + // ... other Novi fields ... + // REQUIRED: layout writes to bb.qry__hidden and bb.qry__enabled after verification + bb: { + enabled: 'enabled', + hidden: 'not_hidden', + limit: 50, + offset: 0, + edit_kv: {}, + edit__post_obj: null, + edit__post_comment_obj: null, + show_list__post_obj_li: true, + qry__enabled: 'enabled', + qry__hidden: 'not_hidden', + qry__limit: 25, + qry__offset: 0, + qry__order_by: 'updated_on', + qry__order_by_li: { updated_on: 'DESC', created_on: 'DESC' } + }, + archives: { /* ... */ } +}; +``` + +If `ae_idaa_stores.ts` ever adds new post-verification writes to other nested objects, those must be added to the seed too. + +--- + +### Testing Reactive Persisted-Store Updates — The StorageEvent Approach + +**Context:** `$ae_loc.site_cfg_json` is tracked by the IDAA layout Effect 2 outside `untrack()`. When it changes, the effect re-runs and retries Novi verification. Testing this without pre-seeding Dexie (which requires an extra navigate-then-reload cycle) can be done by dispatching a synthetic `StorageEvent`. + +**Why:** In the test environment, Dexie is empty on first load, so `lookup_site_domain` takes the slow path — one API call, no background refresh. The two-phase mock approach (stale call 1, fresh call 2) cannot be exercised directly without a Dexie cache hit. The StorageEvent approach directly tests the reactive store update path in isolation. + +**Pattern:** + +```typescript +// After initial Access Denied (stale cfg, no api_key): +await page.evaluate( + ({ fresh_cfg }: { fresh_cfg: any }) => { + const raw = window.localStorage.getItem('ae_loc'); + const current = raw ? JSON.parse(raw) : {}; + const updated = { ...current, site_cfg_json: fresh_cfg }; + const newValue = JSON.stringify(updated); + window.localStorage.setItem('ae_loc', newValue); + // svelte-persisted-store listens to 'storage' events. + // The native browser event only fires in OTHER tabs — but + // window.dispatchEvent() reaches same-tab listeners too. + window.dispatchEvent( + new StorageEvent('storage', { + key: 'ae_loc', + newValue, + storageArea: window.localStorage + }) + ); + }, + { fresh_cfg: fresh_site_cfg_json() } +); +// Now assert that Effect 2 re-ran and access was granted +await expect(page.getByRole('heading', { name: 'Access Denied' })).not.toBeVisible({ timeout: 8000 }); +``` + +**When to use:** Any time you need to simulate `$ae_loc` (or another persisted store) being updated mid-test by an external source, without navigating away or reloading. + +--- + +### `getByText` Partial Match for UUID in Longer Spans + +The layout renders the Novi UUID inside a span that also contains the user's name and email. `page.getByText(uuid)` uses whole-text matching by default and won't find it. Use `{ exact: false }`: + +```typescript +await expect(page.getByText(TEST_NOVI_UUID, { exact: false })).toBeVisible(); +``` + +--- + ## Development / Testing / Demo environment information * Use snake_case (or Snake_Case or Snake_case or test_NASA_example or test_API_key) * Aether test/demo base URL: `http://demo.localhost:5173`