- Updated Dexie/liveQuery guide with detailed explanation of the try_cache + microtask yield bug pattern - Marked session view refactor project as RESOLVED (2026-02-26) - Added inline code comments to all three fixed loader functions explaining the critical fixes Documents the 'refresh twice' bug resolution for future reference.
18 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.
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.
Practical Patterns from Aether (Journals & Events)
-
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. -
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
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. - 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.
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}
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