build_tmp_sort() encodes priority=true as '0' for ascending sort. JS comparators were using b.localeCompare(a) (descending), inverting the encoding so priority=false items sorted first. Fixed to a.localeCompare(b) in ae_journals_search_helpers.ts (3 sites in recovery_meetings +page.svelte and wrapper component). Also fixes a Dexie anti-pattern in bb/[post_id]: .reverse() before .sortBy() is a no-op in Dexie; moved array .reverse() to after the await. Documents the encoding rule and legacy inverted-encoding modules in GUIDE__SvelteKit2_Svelte5_DexieJS.md and adds mistake #15 to BOOTSTRAP quickstart. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
32 KiB
Stability Patterns for liveQuery + Svelte 5
Dexie's liveQuery works well with Svelte 5 runes, but the combination requires a few stable patterns so queries don't get recreated unintentionally and components render correctly on a "cold start" (empty IndexedDB).
- Keep the observable instance stable: wrap
liveQueryin a stable$derivedso the observable isn't recreated on every render. Recreate theliveQueryonly when explicit dependencies change (IDs, filters, or search keys).
// stable derived wrapper — only recreated when `id` changes
let lq__obj = $derived(
(() => {
// capture the dependency(s) in a single stable closure
const id = url_id;
return liveQuery(async () => {
if (!id) return null;
console.log('[LQ] running for id=', id);
return await db.table.get(id);
});
})()
);
-
Use
$derived.by(() => ...)where available in your runes shim to build queries from a computed set of inputs (IDs list, search params). This preserves a stable observable instance while still reacting to explicit dependency changes. -
Avoid capturing mutable objects or inline expressions in the
liveQueryclosure. If the closure captures a changing reference, the query may be recreated unexpectedly or miss the first write.
Common Gotchas and Fixes (Why things sometimes need multiple refreshes)
- Cold start (IDB empty) + non-blocking API writes: If you mount a component before data is written to IDB,
liveQuerymay run against an empty DB. The API write will populate IDB later, but sometimes a chain of dependent queries (e.g., presentations -> presenters) won't all rerun in the order you expect. The symptoms you described — session shows after one refresh, presenters only after a second — are consistent with either (a) queries recreated in the wrong order or (b) dependent store values being set only after some subscriptions are already created.
Bootstrap Race: Account-scoped Loads Before account_id Is Set (2026-06)
Account-scoped liveQuery triggers can fire before +layout.svelte's bootstrap Sync Effect
has propagated the real account_id. Two failure modes:
- IDB empty: fetch runs with
account_id = null. ThelocalStoragescavenge inapi_get_object.tsreads the stale value from a previous session — possibly a different account — and caches that wrong record into IDB. - IDB has a stale record:
liveQueryreturns a cached record from a different account as a valid hit, so the trigger condition (!entry) is never true and the correct record is never fetched.
Rule: Gate any trigger $effect that loads account-scoped data on $slct.account_id,
not $ae_loc.account_id. $slct is a plain writable store (not persisted), initialized to
null and set only by the bootstrap Sync Effect. $ae_loc is a persisted store that
hydrates from localStorage before effects run and may carry a stale account_id.
Also treat a non-null, non-matching account_id in an IDB record as a cache miss:
$effect(() => {
const account_id = $slct.account_id; // null until bootstrap Sync Effect runs
const api_ready = !!$ae_api?.base_url;
const entry = $lq__obj as SomeType | null | undefined;
if (!browser || !account_id || !api_ready) return;
// null account_id on a record = global/shared fallback — still a valid hit.
const entry_is_stale_account =
entry !== undefined && entry !== null &&
entry.account_id !== null &&
entry.account_id !== account_id;
if (!entry || entry_is_stale_account) {
trigger = 'load...';
}
});
See BOOTSTRAP__AI_Agent_Quickstart.md → Section 7, entry 14 for the full incident writeup.
Critical Discovery (2026-02-26): The "try_cache: false" Bug
Symptom: Nested data (e.g., Session → Presentations → Presenters) requires multiple manual refreshes to display on cold-start, even when using blocking loads.
Root Cause: Two interconnected issues in nested data loaders:
- Disabled caching in nested loads: Parent loads were passing
try_cache: falseto child loads, meaning presentations and presenters were fetched from API but never written to IndexedDB. - Missing microtask yields: Even when caching was enabled, components would mount and subscribe to liveQuery before IndexedDB writes completed, causing race conditions.
Example of the Bug:
// Session loader (BROKEN)
await db_save_ae_obj_li__ae_obj({ table: 'session', obj_li: [session] });
// Loads presentations but disables caching ❌
return await load_presentations({ ..., try_cache: false });
// Presentations fetch from API ✅
// Presentations SKIP IndexedDB write ❌
// Presenters SKIP IndexedDB write ❌
// Component mounts, liveQuery finds only session ❌
The Fix:
// Session loader (FIXED)
await db_save_ae_obj_li__ae_obj({ table: 'session', obj_li: [session] });
await Promise.resolve(); // Yield to observers
// Preserve parent's try_cache value ✅
return await load_presentations({ ..., try_cache });
// Presentations fetch AND write to IDB ✅
await Promise.resolve(); // Yield to observers
// Presenters fetch AND write to IDB ✅
await Promise.resolve(); // Yield to observers
// Component mounts, liveQuery finds all data ✅
Key Lessons:
- Always preserve
try_cachethrough nested loads unless you have a specific reason to disable caching for that operation - Add
await Promise.resolve()after IndexedDB writes to ensure Dexie's liveQuery observers fire before the function returns - Block on nested loads with
await Promise.all()instead of fire-and-forgetforEach()when the page needs complete data for first render
Fixes:
- Prefer the "Blocking Loader" when you can:
awaitthe API call in+page.tsso IDB is populated before Svelte mounts. - If you cannot block, return an
initial_*object from+page.tsand use it as an immediate fallback in your component so the UI renders from that payload whileliveQuerytakes over for subsequent updates. Example from Aether:
<Comp_event_presentation_obj_li
lq__event_presentation_obj_li={$lq__event_presentation_obj_li ?? data.initial_session_obj?.event_presentation_li ?? []}
{log_lvl}
/>
- Ensure store IDs are set before subscribers that depend on them are created. Use
untrack()(or an equivalent non-reactive assignment) to set IDs in stores during initialization so components subscribe to the correct IDs immediately:
$effect(() => {
if (!ae_acct) return;
untrack(() => {
$events_slct.event_id = url_event_id;
$events_slct.event_session_id = url_session_id;
});
});
- When you have chains (presentations depend on session; presenters depend on presentation.person_id), make the dependent liveQuery explicitly wait for the upstream ID and log inside each query to verify the order — adding a small
await Promise.resolve()orawait 0inside theliveQueryis sometimes useful during debugging to ensure the JS microtask queue has a chance to settle after DB writes.
IDB Sort: build_tmp_sort Pattern (2026-05)
All Aether objects support priority, sort, group, and name fields. Rather than sorting in JS after a Dexie query (which requires .reverse() hacks and duplicated logic), pre-compute up to three tmp_sort_* string fields during the processing pipeline and store them in Dexie. Then .sortBy('tmp_sort_2') does the right thing in one call, with no .reverse().
Utility: src/lib/ae_core/core__idb_sort.ts — build_tmp_sort()
import { build_tmp_sort } from '$lib/ae_core/core__idb_sort';
// Inside specific_processor callback:
const { tmp_sort_1, tmp_sort_2, tmp_sort_3 } = build_tmp_sort({
prefix: [obj.group ?? '0'], // always first
priority: obj.priority, // boolean; true→'0' so ASC sorts it first
sort: obj.sort, // zero-padded to 8 chars
fields_1: [...], // module-specific tier-1 fields
fields_2: [...], // tier-2 fields (tmp_sort_2 = base + tier-1 + tier-2)
fields_3: [...] // tier-3 fields
});
obj.tmp_sort_1 = tmp_sort_1;
obj.tmp_sort_2 = tmp_sort_2;
obj.tmp_sort_3 = tmp_sort_3;
Sort chain convention: group → priority DESC → sort ASC → [module-specific] → name
Priority encoding: priority ? '0' : '1' — inverted so that priority=true sorts first in ascending order. This means:
- Dexie
.sortBy('tmp_sort_*')— always call without.reverse()before it (Dexie ignores collection-level.reverse()when using.sortBy()). If descending is needed for non-tmp_sort fields, call.reverse()on the resulting array afterawait. - JS
.sort()comparators — use ascendinga.localeCompare(b), NOTb.localeCompare(a). Using descending flips the priority encoding and putspriority=falseitems first.
// ✅ Correct — ascending; priority=true ('0') sorts before priority=false ('1')
list.sort((a, b) => (a.tmp_sort_1 ?? '').localeCompare(b.tmp_sort_1 ?? ''));
// ❌ Wrong — descending inverts the encoding; priority=false ('1') sorts first
list.sort((a, b) => (b.tmp_sort_1 ?? '').localeCompare(a.tmp_sort_1 ?? ''));
Modules using build_tmp_sort:
ae_events__event_presentation.ts—tmp_sort_1/2: group → priority → sort → start_datetime → code → nameae_events__event.ts—tmp_sort_1/2/3: group → priority → sort → name → updated_on (used by IDAA recovery meetings)ae_journals__journal.ts—tmp_sort_1/2/3: group → priority → sort → name → updated_onae_journals__journal_entry.ts— same chain as journal
Legacy encoding (not yet migrated to build_tmp_sort): ae_posts__post.ts, ae_posts__post_comment.ts, ae_archives__archive.ts, ae_archives__archive_content.ts, ae_sponsorships_functions.ts use the opposite encoding (priority ? '1' : '0', designed for descending sort). Their current route consumers sort by date/name so there is no visible priority bug today, but they must be migrated before any route starts sorting by tmp_sort_*. See TODO__Agents.md.
$derived.by Dependency Capture for Extra Filter State
When a liveQuery has a SCENARIO 2 fallback (broad search with no IDs), it may run before the debounced search fast path populates event_session_id_li. If that fallback doesn't apply the same visibility filter as the fast path, hidden items will briefly appear then disappear ("blink").
Fix: capture the filter flag as a $derived.by dependency in the outer closure so Svelte recreates the liveQuery instance whenever it changes — SCENARIO 2 then uses the correct filter from first render.
let lq__event_session_obj_li = $derived.by(() => {
const ids = event_session_id_li; // drives SCENARIO 1 vs 2
const event_id = $events_slct?.event_id;
const qry_hidden = pres_mgmt_loc.current.qry_hidden; // extra dependency
return liveQuery(async () => {
// SCENARIO 1 — specific IDs (fast path or API result)
if (Array.isArray(ids) && ids.length > 0) {
const results = await db.session.bulkGet(ids);
return results.filter(Boolean);
}
// SCENARIO 2 — broad fallback, uses captured qry_hidden
if (event_id && !someFilter) {
const all = await db.session.where('event_id').equals(event_id).sortBy('name');
return all.filter((s: any) => {
if (qry_hidden === 'not_hidden') return !s.hide;
if (qry_hidden === 'hidden') return !!s.hide;
return true; // 'all'
});
}
return [];
});
});
Key rule: anything read inside $derived.by()'s outer closure (but outside the liveQuery callback) becomes a Svelte reactive dependency. Changes to it recreate the liveQuery. Use this to synchronize filter flags that Dexie doesn't track.
Also fix the API call: use the snapshot value from params (captured at debounce time) rather than the live store, so rapid toggling doesn't create a mismatch between fast path and API results:
// Bad — uses live store value, can race if user toggles during pending call:
hidden: pres_mgmt_loc.current.qry_hidden ?? 'not_hidden'
// Good — uses snapshot captured when handle_search_refresh was called:
hidden: params.qry_hidden ?? 'not_hidden'
Practical Patterns from Aether (Journals & Events & IDAA Recovery Meetings)
-
Journals: The journaling pages use SWR-style background refreshes but reliably render because either (a) the page
+page.tsblocks to populate DB for critical views, or (b) components acceptdata.initial_*fallback values untilliveQueryemits. This hybrid approach avoids the "refresh twice" problem while keeping navigation snappy. -
Journals broad views: if text search is empty, let the local IDB result set drive the visible list. The API can revalidate the cache in the background, but it should not replace a broad "All" view with a limited slice that hides valid rows.
-
Sessions / Presentations: The session page demonstrates several best practices:
- Use
url_*constants (derived fromdata.params) so theliveQueryclosure captures a stable value instead of the reactive store directly. - Provide
initial_session_objfrom+page.tsas a first-draw fallback to child components. - Use
$derived.by(() => liveQuery(...))for presentation lists so the observable instance is stable across renders and recreated only whenevent_session_idorsearchchanges.
- Use
-
Search pages with persisted filters or saved query text should keep the auto-search trigger in a page-level
$effect, but the duplicate guard should live inside the actual search executor. That preserves the first page-load search while blocking repeated identical reruns from localStorage-backed rerenders. In practice:- derive a single
qry_keyfrom the search inputs - debounce in the
$effect - compare
qry_keyagainst alast_executed_keyinsidehandle_search_refresh() - keep transient loading flags and trigger counters in session state when the value is only used to force a refresh, not as a persisted preference
- derive a single
Example (presentation list pattern):
let lq__event_presentation_obj_li = $derived(
liveQuery(async () => {
if (!url_session_id) return [];
console.log('[LQ] Querying Presentations for Session:', url_session_id);
return await db_events.presentation.where('event_session_id').equals(url_session_id).sortBy('name');
})
);
Debugging Checklist
- Add a small
console.loginside eachliveQueryclosure to confirm when it runs and whatidit sees. - Verify that
+page.tseitherawaits critical loads or returnsinitial_*payloads for first-render hydration. - Confirm that dependent store values (selected IDs) are assigned before components subscribe — use
untrackto prevent extra reactive cycles. - If a search page stops auto-loading after a localStorage change, check whether the duplicate guard was placed in the
$effectinstead of the executor. Guarding too early can suppress the initial search; guard at execution time instead. - If a broad Dexie-backed list shows fewer rows than a narrower filter, look for a limit or revalidation step overwriting the local IDB result set. Broad views should stay unbounded unless the user is actually narrowing by text.
- Ensure your
liveQueryclosures return quickly and do not throw; any exception inside the query can stop updates. - If a dependent query appears stale, temporarily add
await 0in the upstream query or an explicitPromise.resolve()after the IDB write to force the microtask queue to flush during debugging.
Summary Recommendations
- Prefer blocking loads for primary views when first-render correctness matters.
- Use
initial_*fallback data when non-blocking loads are required. - Wrap
liveQueryin stable$derivedinstances and only recreate when explicit inputs change. - Use
untrackto set selection IDs during initialization to avoid subscribe-order bugs. - Add targeted logs inside
liveQueryclosures to diagnose ordering and subscription behavior.
These patterns are deliberately conservative — they trade minimal blocking or small explicit fallbacks for predictable first-render behaviour. The Aether app's Journals and Event session pages are working examples of these techniques in practice.
Examples in this repository
The following files demonstrate stable liveQuery usage, initial_* fallbacks, and stable $derived wrappers used across the Aether app. Inspect these for copy/paste patterns and concrete implementations.
- Journals page (stable LQ + search patterns): src/routes/journals/[journal_id]/+page.svelte
- Journals layout (blocking background loader): src/routes/journals/[journal_id]/+layout.ts
- Session page with URL capture + initial fallback: src/routes/events/[event_id]/(pres_mgmt)/session/[session_id]/+page.svelte
- Presentation management overview (stable derived + search): src/routes/events/[event_id]/(pres_mgmt)/pres_mgmt/+page.svelte
- Event settings example (simple observable): src/routes/events/[event_id]/settings/+page.svelte
- Badge/detail pages (examples of nested LQ): src/routes/events/[event_id]/(badges)/badges/+page.svelte
Refer to these files when you need concrete code examples to adopt the patterns described above.
References
This document provides a guide to integrating Svelte (with a focus on Runes) and Dexie.js for building reactive web applications. It covers key concepts and best practices for managing reactivity between Svelte components and the Dexie.js database.
Svelte 5 Migration Guide
Svelte 5 introduces "runes" as a new way to manage reactivity. This is a major change from previous versions of Svelte, and it's important to understand the breaking changes before migrating.
Key Breaking Changes
letis no longer reactive: In Svelte 4, anyletvariable declared in the top-level scope of a component was automatically reactive. In Svelte 5, you must explicitly declare reactive state using the$staterune.$:is replaced by$derivedand$effect: The$label is no longer used for reactive statements. Instead, you should use the$derivedrune for computed values and the$effectrune for side effects.export letis replaced by$props: Component props are now declared using the$propsrune, which provides a more flexible and explicit way to define component APIs.- Event handling: The
on:directive is replaced by event attributes (e.g.,onclick). Component events are now handled using callback props instead ofcreateEventDispatcher. - Slots are replaced by snippets: The
<slot>element is replaced by the{#snippet ...}block, which provides a more powerful and flexible way to pass content to components.
For a complete list of breaking changes, refer to the Svelte 5 migration guide.
Dexie.js Quick Reference
Dexie.js is a lightweight, minimalistic wrapper for IndexedDB that makes it easier to work with client-side databases.
Key Classes and Methods
Dexie: The main class for creating and managing IndexedDB databases.new Dexie(databaseName): Creates a new database instance.version(versionNumber).stores({ ... }): Defines the database schema.
Table: Represents an object store (table) in the database.add(item): Adds a new item to the table.put(item): Adds or updates an item in the table.update(key, changes): Updates an existing item.delete(key): Deletes an item by its primary key.get(key): Retrieves an item by its primary key.where(index): Starts a query using an index.toArray(): Retrieves all items from the table as an array.
Collection: Represents a collection of items resulting from a query.toArray(): Retrieves all items in the collection as an array.first(): Retrieves the first item in the collection.last(): Retrieves the last item in the collection.each(callback): Iterates over each item in the collection.modify(changes): Updates all items in the collection.delete(): Deletes all items in the collection.
For a complete list of API methods, refer to the Dexie.js API Reference.
Integrating Svelte Runes and Dexie.js
The combination of Svelte Runes and Dexie.js allows for the creation of highly reactive and efficient web applications.
The liveQuery Function
Dexie.js provides a liveQuery function that returns an observable of the query result. This observable can be used to automatically update the UI whenever the data in the database changes.
Using liveQuery with Svelte Runes
To use liveQuery with Svelte Runes, you can create a custom readable store that wraps the liveQuery observable. This store can then be used in your Svelte components to display and interact with the data.
1. Create a liveQuery store:
import { liveQuery } from 'dexie';
import { readable } from 'svelte/store';
import { db } from './db'; // Your Dexie database instance
export function createLiveQueryStore<T>(query: () => T | Promise<T>) {
return readable<T | undefined>(undefined, (set) => {
const subscription = liveQuery(query).subscribe({
next: (result) => set(result),
error: (error) => console.error(error)
});
return () => subscription.unsubscribe();
});
}
2. Use the createLiveQueryStore in your component:
<script>
import { createLiveQueryStore } from './stores';
import { db } from './db';
const friends = createLiveQueryStore(() => db.friends.toArray());
</script>
<ul>
{#if $friends} {#each $friends as friend}
<li>{friend.name}</li>
{/each} {/if}
</ul>
The createLiveQueryStore function creates a readable store that automatically updates whenever the data in the friends table changes. The $friends variable in the component will always contain the latest data from the database.
SvelteKit Layout Hierarchy: Security and Execution Order
Understanding when SvelteKit code runs is critical for private-data modules like IDAA.
Execution order on any navigation
1. +layout.ts / +page.ts ← run FIRST — before any component mounts
also fired by SvelteKit link prefetch (on hover)
2. Parent +layout.svelte mounts → its $effect blocks run
3. Child +layout.svelte mounts → only if parent called {#render children?.()}
4. +page.svelte mounts → only if every parent in the chain rendered children
5. $effect blocks in all of the above run after mount
The auth-gate consequence
A {:else if authenticated} {@render children?.()} block in a +layout.svelte
controls whether everything below it ever mounts. If the gate blocks rendering,
no child layout or page component instantiates — their $effect blocks, event
handlers, and liveQuery closures never run.
<!-- (idaa)/+layout.svelte -->
{:else if $ae_loc.trusted_access || $idaa_loc.novi_verified}
{@render children?.()} ← children only mount if this branch runs
{:else}
<p>Access Denied</p> ← children never mount; their $effects never run
{/if}
$effect blocks inside a child component cannot bypass a parent layout auth gate.
They are already inside the gate. Adding redundant auth guards to $effect blocks
that only run after a parent has already verified access is unnecessary — and misleads
future readers into thinking the parent gate alone is not sufficient.
Where the actual pre-gate risk lives: +page.ts / +layout.ts
Universal load functions run before components mount and before layout effects execute. They also fire during SvelteKit link prefetch — triggered by the user hovering a link, even if they never navigate. This makes them unsafe for private data:
User hovers an /idaa/ link →
SvelteKit prefetch fires →
+page.ts runs (no layout has mounted yet, no auth gate has run) →
API call / IDB write happens for an unauthenticated user
Rule for private modules (IDAA, Journals): +page.ts and +layout.ts files must
not call any data load functions that write to IDB. Move all data loading to $effect
blocks in the corresponding +page.svelte, gated inside the auth-checked layout render.
The comments in every +page.ts under src/routes/idaa/(idaa)/ explain this pattern.
The $effect auth guards in IDAA +page.svelte files
These ARE still useful — but for a different reason than layout bypass:
// In bb/+page.svelte
$effect(() => {
if (!$idaa_loc.novi_verified && !$ae_loc.trusted_access) return;
posts_func.load_ae_obj_li__post(...)
});
Because $ae_loc is a Svelte 4 coarse-grained store, any unrelated write to it
(iframe height, SWR reload) re-triggers this $effect. The guard prevents a spurious
API call if $idaa_loc.novi_verified has been cleared between re-runs (e.g. TTL
expiry mid-session). It is a reactivity guard, not a layout-bypass guard.
Page Load Strategies (Avoiding the "Waterfall")
When loading data for a primary page view (e.g., viewing a specific Journal, Session, or Person), you must choose a synchronization strategy to ensure the UI renders correctly on the first load.
❌ The "Fire & Forget" Anti-Pattern (AVOID)
Triggering a background load in +page.ts without await leads to race conditions.
+page.sveltemounts immediately.liveQueryruns against an empty IndexedDB.- API data arrives later and writes to IndexedDB.
- Failure: Svelte 5 + Dexie
liveQuerymay not automatically detect this first "cold start" update without a manual refresh.
✅ The "Blocking Loader" Pattern (RECOMMENDED)
Ensure the data is in IndexedDB before the component mounts.
- In
+page.ts,awaitthe API load function. - In
+page.svelte, theliveQuerywill see the data immediately upon mount.
Example (+page.ts):
export async function load({ params }) {
// Blocking await ensures IDB is populated
await journals_func.load_ae_obj_id__journal({
journal_id: params.journal_id,
try_cache: true
});
return {};
}
✅ The "Hydrate & Subscribe" Pattern (ADVANCED)
If you must use non-blocking loads, you must pass the initial data to the component to "hydrate" the state before the subscription takes over.
- In
+page.ts,awaitthe load and return the object. - In
+page.svelte, use the returned object as a fallback or initial state.
Example (+page.svelte):
<script>
let { data } = $props();
let lq__obj = $derived(liveQuery(async () => db.table.get(id)));
</script>
<!-- Use fallback to handle the gap before liveQuery emits -->
{#if $lq__obj || data.initial_obj}
<View object={$lq__obj ?? data.initial_obj} />
{/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():
// 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():
// 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)
Svelte 5's bind: directive is more restrictive than previous versions. You can only bind to a simple Identifier or MemberExpression.
❌ Invalid Pattern (Causes Compile Error): Attempting to normalize a value inside the binding will fail.
<!-- Error: Can only bind to an Identifier or MemberExpression -->
<Launcher_menu bind:slct__event_session_id={$events_slct.event_session_id || null} />
✅ Correct Pattern: Ensure the source value is already normalized before binding, or use a reactive effect to handle the fallback.
// Normalize in an effect or derivation
$effect(() => {
if ($events_slct.event_session_id === undefined) {
$events_slct.event_session_id = null;
}
});
<!-- Bind directly to the normalized property -->
<Launcher_menu bind:slct__event_session_id={$events_slct.event_session_id} />
Safe Data Processing for IndexedDB Sorting
When preparing data for IndexedDB, especially when creating composite sort keys, it is critical to handle null or undefined values safely to prevent runtime crashes that can interrupt the data synchronization process.
1. Safe String Padding
Attempting to call .toString() or .padStart() on a null or undefined value will throw a TypeError. This is a common pitfall when processing optional fields like sort or group.
Bad Pattern (Crash Risk):
// Crashes if obj.sort is null or undefined
obj.tmp_sort_1 = `${obj.sort.toString().padStart(3, '0')}`;
obj.tmp_sort_2 = `${obj.sort?.toString().padStart(3, '0') ?? ''}`; // Still risky if chaining is misunderstood
Good Pattern (Safe):
// Safely handle null/undefined by defaulting to 0 or an empty string BEFORE string manipulation
const sort_val = (obj.sort ?? 0).toString().padStart(3, '0');
2. Correct Sorting with Dexie
Dexie's sortBy() method returns a new array sorted by the specified key. It ignores previous reverse() calls on the collection. To achieve a descending sort, you must sort first and then reverse the resulting array.
Incorrect (Ascending Sort Result):
// .reverse() is ignored by .sortBy()
let results = await db.table.where('id').equals(id).reverse().sortBy('sort_key');
Correct (Descending Sort Result):
// Sort ascending first, then reverse the array
let results = await db.table.where('id').equals(id).sortBy('sort_key');
return results.reverse();
References
- https://dexie.org/llms.txt - Dexie.js and Dexie Cloud — LLM Guide and Documentation Summary