Files
OSIT-AE-App-Svelte/documentation/AE__Performance_Guidelines.md
Scott Idem 04a8edc6d1 [Perf] Fix liveQuery reactivity, silence debug logs, add performance guidelines
- launcher/+layout.svelte: convert lq__event_session_obj from $derived to
  $derived.by() so Svelte tracks event_session_id as a dependency; the old
  pattern read the store inside the Dexie async callback where Svelte's
  tracking is off, so the liveQuery never updated on session change
- ae_events__event_file.ts: fix hardcoded log_lvl: 2 in SWR fire-and-forget
  background refresh (always-on debug logging on every cache hit) → 0
- e_app_sign_in_out.svelte: lower 6 call-site log levels (1×log_lvl:2,
  5×log_lvl:1) to 0; sign-in runs on every page load
- element_manage_hosted_file_li.svelte: log_lvl:2 → 0 in refresh call;
  remove log_lvl=1 assignment + debug block inside click handler; log_lvl:1
  → 0 in delete call
- AE__Performance_Guidelines.md: add 5 Svelte 5 runes rules covering
  $derived.by() for reactive liveQuery, liveQuery purity, cheap equality
  guards ($id+updated_on, ID-join, shallow_equal), untrack() requirement,
  and log_lvl discipline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 15:01:42 -04:00

241 lines
8.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Performance Guidelines: Non-Blocking Load Pattern (SvelteKit + Dexie)
## Overview
To ensure instant page transitions and a high-performance feel, the Aether platform utilizes a **Non-Blocking Load Pattern** (also known as Stale-While-Revalidate or SWR). This pattern leverages Dexie's `liveQuery` for reactive UI and SvelteKit's `load` functions for background data synchronization.
## 🚀 The Core Principle
**Never block the `load` function with API calls if the data is already being observed by a `liveQuery`.**
The page should render *instantly* using cached data from IndexedDB. Fresh data from the API should settle in the background and update the UI automatically via reactivity.
---
## ❌ Anti-Pattern (Blocking)
This pattern causes a "white screen" or "frozen UI" while the browser waits for the API response.
```typescript
// +page.ts
export async function load({ params, parent }) {
const data = await parent();
const event_id = params.event_id;
// BAD: This blocks the navigation until the API responds.
const fresh_data = await events_func.load_ae_obj_id__event({
event_id: event_id,
try_cache: true
});
return { ...data, event_obj: fresh_data };
}
```
## ✅ Best Practice (Non-Blocking / SWR)
This pattern completes the navigation immediately.
```typescript
// +page.ts
export async function load({ params, parent }) {
const data = await parent();
const event_id = params.event_id;
if (browser) {
// GOOD: Fire and forget.
// This function updates IndexedDB in the background.
events_func.load_ae_obj_id__event({
event_id: event_id,
try_cache: true
});
}
return data; // Navigation completes instantly
}
```
```svelte
<!-- +page.svelte -->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
// UI reacts automatically when the background task finishes.
let lq__event_obj = $derived(
liveQuery(() => db_events.event.get(event_id))
);
</script>
{#if $lq__event_obj}
<h1>{$lq__event_obj.name}</h1>
{:else}
<p>Loading...</p>
{/if}
```
---
## 🛠️ When to use Await
Use `await` in `load` functions ONLY for:
1. **Critical Auth Checks:** If you must verify a session before even showing a layout.
2. **Parent Data:** `const data = await parent();` is necessary to build the context.
3. **Server-Side Rendering (SSR):** If the data *must* be present in the initial HTML for SEO (rare for Aether feature modules).
## 📈 Performance Gains
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
<!-- ❌ WRONG: $events_slct.event_session_id is read inside the async callback.
Svelte never tracks it. The liveQuery is created once and never recreates
when event_session_id changes. -->
let lq__session = $derived(
liveQuery(() => db_events.session.get($events_slct.event_session_id))
);
```
```svelte
<!-- ✅ CORRECT: $derived.by() captures the ID in the outer synchronous closure.
Svelte tracks it. When event_session_id changes, $derived.by() re-runs,
creating a new liveQuery with the updated ID. -->
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
<!-- ❌ WRONG: Store side-effect inside liveQuery async callback. -->
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
<!-- ✅ CORRECT: liveQuery is pure data-only. Store sync happens in a $effect. -->
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
<!-- ❌ WRONG: Reading $store.obj inside $effect creates a dependency loop. -->
$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
<!-- ✅ CORRECT: untrack() reads current store values without registering them
as reactive dependencies of the $effect. -->
$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.