diff --git a/documentation/AE__Performance_Guidelines.md b/documentation/AE__Performance_Guidelines.md index 52ab9e8c..9c0f622d 100644 --- a/documentation/AE__Performance_Guidelines.md +++ b/documentation/AE__Performance_Guidelines.md @@ -39,7 +39,7 @@ export async function load({ params, parent }) { const event_id = params.event_id; if (browser) { - // GOOD: Fire and forget. + // GOOD: Fire and forget. // This function updates IndexedDB in the background. events_func.load_ae_obj_id__event({ event_id: event_id, @@ -83,3 +83,158 @@ By adopting this pattern across the Events module, we achieved: - **~200-500ms reduction** in perceived page load time. - **Elimination of waterfalls** (sequential API calls). - **Better offline support**, as the UI is always ready to show what's in the local cache. + +--- + +## Svelte 5 Runes + liveQuery: Critical Patterns + +These rules apply to all Svelte 5 runes-mode components (the entire Aether frontend). Violations here are a common source of subtle reactivity bugs and unnecessary re-renders. + +### Rule 1: Use `$derived.by()` when liveQuery depends on a reactive value + +**The problem:** `$derived(liveQuery(callback))` looks like it should re-run when a store value inside `callback` changes. It does NOT. Svelte tracks reactive dependencies synchronously during the expression evaluation. The `liveQuery` callback is called later inside Dexie's async context — Svelte's tracking is already finished. The dependency is never registered. + +```svelte + +let lq__session = $derived( + liveQuery(() => db_events.session.get($events_slct.event_session_id)) +); +``` + +```svelte + +let lq__session = $derived.by(() => { + const id = $events_slct.event_session_id; // tracked here, synchronously + return liveQuery(() => db_events.session.get(id)); +}); +``` + +**Rule of thumb:** If the liveQuery result changes based on a reactive value (store property, `$state`, `$props`), always use `$derived.by()`. Reserve `$derived(liveQuery(...))` only for liveQueries that watch a table broadly and don't filter by a reactive value. + +--- + +### Rule 2: Keep liveQuery closures pure (data-only) + +**The problem:** Writing to a Svelte store inside a liveQuery callback runs inside Dexie's async transaction context. Svelte's reactive tracking is undefined there. The write may fire at unpredictable times and create hard-to-debug reactivity loops. + +```svelte + +let lq__event_obj = liveQuery(async () => { + const obj = await db_events.event.get($events_slct.event_id); + if (obj) $events_slct.event_obj = obj; // BAD: side-effect in async context + return obj; +}); +``` + +```svelte + +let lq__event_obj = liveQuery(async () => { + const id = $events_slct.event_id; + if (!id) return null; + return await db_events.event.get(id); +}); + +$effect(() => { + const result = $lq__event_obj; + if (result) { + untrack(() => { + // Cheap equality guard — only write if something actually changed. + if (result.updated_on !== $events_slct.event_obj?.updated_on || + result.id !== $events_slct.event_obj?.id) { + $events_slct.event_obj = { ...result }; + } + }); + } +}); +``` + +--- + +### Rule 3: Use cheap equality guards in `$effect` before writing to stores + +Every store write in a `$effect` triggers downstream reactivity. Always guard with a comparison before writing. The cost of the comparison is always less than the cost of spurious re-renders. + +**For single objects** — compare `id` + `updated_on` (O(1)): +```typescript +if (result.id !== $store.obj?.id || result.updated_on !== $store.obj?.updated_on) { + $store.obj = { ...result }; +} +``` + +**For arrays** — join IDs into a string (O(n)), not `JSON.stringify` (O(n × field_count)): +```typescript +const new_ids = results.map(r => r.id).join(','); +const cur_ids = ($store.list ?? []).map(r => r.id).join(','); +if (new_ids !== cur_ids) { + $store.list = [...results]; +} +``` + +**For flat objects** (e.g., merged config) — shallow key-by-key comparison (O(n keys)): +```typescript +function shallow_equal(a, b) { + const keys_a = Object.keys(a); + const keys_b = Object.keys(b); + if (keys_a.length !== keys_b.length) return false; + for (const k of keys_a) { if (a[k] !== b[k]) return false; } + return true; +} +if (!shallow_equal(current, new_val)) { $store = new_val; } +``` + +**Never use `JSON.stringify` for equality.** It serializes the full object tree on every reactive cycle and is O(total serialized bytes). + +--- + +### Rule 4: Always use `untrack()` when writing to stores inside `$effect` + +Without `untrack()`, reading a store to check its current value inside `$effect` registers it as a dependency — the effect re-runs whenever it writes, creating an infinite loop. + +```svelte + +$effect(() => { + const result = $lq__obj; + if (result.id !== $store.obj?.id) { // Reading $store.obj here is a dependency! + $store.obj = result; // This write re-triggers the effect. + } +}); +``` + +```svelte + +$effect(() => { + const result = $lq__obj; // Tracked: effect re-runs when liveQuery emits + if (result) { + untrack(() => { + // Not tracked: reading $store.obj here won't cause a re-run. + if (result.id !== $store.obj?.id) { + $store.obj = result; + } + }); + } +}); +``` + +--- + +### Rule 5: Guard `console.log` calls with `log_lvl` + +Raw `console.log(obj)` eagerly serializes objects (even large ones) on every call, blocking the main thread. All debug logging must be guarded. + +```typescript +let log_lvl: number = $state(0); // Set to 0 in production; raise locally to debug. + +// ❌ WRONG: Always runs, always serializes. +console.log('Result:', result_obj); + +// ✅ CORRECT: Zero-cost when log_lvl is 0. +if (log_lvl) console.log('Result:', result_obj); +if (log_lvl > 1) console.log('Verbose:', result_obj); // Extra-verbose tier +``` + +**Never hardcode `log_lvl: 2` in a call-site or override `log_lvl` inside a function body.** The parameter default exists so callers can control verbosity. Overriding it forces debug logging regardless of what the caller passed. diff --git a/src/lib/ae_events/ae_events__event_file.ts b/src/lib/ae_events/ae_events__event_file.ts index d8e4ef1a..8e370e72 100644 --- a/src/lib/ae_events/ae_events__event_file.ts +++ b/src/lib/ae_events/ae_events__event_file.ts @@ -151,7 +151,7 @@ export async function load_ae_obj_li__event_file({ offset, order_by_li, try_cache, - log_lvl: 2 + log_lvl: 0 }); return cached_li; } diff --git a/src/lib/app_components/e_app_sign_in_out.svelte b/src/lib/app_components/e_app_sign_in_out.svelte index 77f16e5a..90aa70cd 100644 --- a/src/lib/app_components/e_app_sign_in_out.svelte +++ b/src/lib/app_components/e_app_sign_in_out.svelte @@ -214,7 +214,7 @@ account_id: $slct.account_id, user_id: user_id, base_url: $ae_loc.base_url, - log_lvl: 2 + log_lvl: 0 }); } @@ -229,7 +229,7 @@ account_id: $slct.account_id, null_account_id: false, email: email, - log_lvl: 1 + log_lvl: 0 }) .then((user_response) => { if (user_response?.user_id_random) { @@ -291,7 +291,7 @@ account_id: $slct.account_id, null_account_id: false, email: user_email, - log_lvl: 1 + log_lvl: 0 }); if (!ae_promises.load__user_obj_li) { @@ -465,7 +465,7 @@ // null_account_id: false, // Set to true to allow to authenticate as global user (Super or Manager) user_id: $ae_sess.auth__entered_user_id, user_auth_key: $ae_sess.auth__entered_user_key, - log_lvl: 2 + log_lvl: 0 }) .then((user_response) => { // console.log(`HERE:`, user_response); @@ -520,7 +520,7 @@ hidden: 'all', // params_json: params_json, // params: params, - log_lvl: 1 + log_lvl: 0 }) .then((person_response) => { // Safety Check: Ensure the response is valid and contains at least one record before accessing index 0. @@ -562,7 +562,7 @@ // null_account_id: false, // Set to true to allow to authenticate as global user (Super or Manager) username: $ae_sess.auth__entered_username, password: $ae_sess.auth__entered_password, - log_lvl: 1 + log_lvl: 0 }) .then((user_response) => { if (user_response?.user_id) { @@ -616,7 +616,7 @@ hidden: 'all', // params_json: params_json, // params: params, - log_lvl: 1 + log_lvl: 0 }) .then((person_response) => { // Safety Check: Ensure the response is valid and contains at least one record before accessing index 0. diff --git a/src/lib/elements/element_manage_hosted_file_li.svelte b/src/lib/elements/element_manage_hosted_file_li.svelte index ad33300e..1f527b10 100644 --- a/src/lib/elements/element_manage_hosted_file_li.svelte +++ b/src/lib/elements/element_manage_hosted_file_li.svelte @@ -89,7 +89,7 @@ limit: 250, // params: params, try_cache: true, - log_lvl: 2 + log_lvl: 0 }); // ae_tmp.show__file_li = false; @@ -158,10 +158,6 @@ slct_hosted_file_id = hosted_file_obj.hosted_file_id; slct_hosted_file_obj = hosted_file_obj; } - log_lvl = 1; - if (log_lvl) { - console.log(`slct_hosted_file_kv:`, slct_hosted_file_kv); - } }} class="btn btn-sm preset-tonal-secondary hover:preset-filled-secondary-500" title="Add/Remove file to/from the locally stored uploaded file list. This is referenced by other AE components." @@ -195,7 +191,7 @@ link_to_id: link_to_id, rm_orphan: true, fake_delete: false, - log_lvl: 1 + log_lvl: 0 }); }} class:hidden={!$ae_loc.administrator_access} diff --git a/src/routes/events/[event_id]/(launcher)/launcher/+layout.svelte b/src/routes/events/[event_id]/(launcher)/launcher/+layout.svelte index 554f56d1..910d86e5 100644 --- a/src/routes/events/[event_id]/(launcher)/launcher/+layout.svelte +++ b/src/routes/events/[event_id]/(launcher)/launcher/+layout.svelte @@ -222,9 +222,12 @@ }); // Event Session (Main View Trigger - Needed for Global Header/Idle) - let lq__event_session_obj = $derived( - liveQuery(() => db_events.session.get($events_slct.event_session_id)) - ); + // $derived.by: capture ID in outer closure so Svelte tracks it as a dependency. + // liveQuery callback runs in Dexie's async context where Svelte tracking is off. + let lq__event_session_obj = $derived.by(() => { + const id = $events_slct.event_session_id; + return liveQuery(() => db_events.session.get(id)); + }); // Store sync effects — keep liveQuery closures pure (data-only) and sync to // $events_slct here in reactive effects instead. Comparing updated_on + id is