fix(journals): eliminate reactivity loops and browser hangs during privacy toggle

- Refactored all effects to use 'untrack' for rigorous dependency isolation.
- Implemented 'deep_copy' helper to safely handle Svelte 5 reactive proxies.
- Added concurrency locks to 'update_journal_entry' and decryption workflows.
- Completed integration of 'JournalEntry_Metadata' component.
This commit is contained in:
Scott Idem
2026-01-14 16:35:25 -05:00
parent b9fc4addfc
commit c907a95c57

View File

@@ -59,7 +59,6 @@
function deep_copy(obj: any) {
if (!obj) return null;
try {
// Manual selective copy to avoid proxy issues and handle Dates
const copy: key_val = {};
for (const key in obj) {
const val = obj[key];
@@ -68,7 +67,6 @@
} else if (Array.isArray(val)) {
copy[key] = [...val];
} else if (typeof val === 'object' && val !== null) {
// Simple object copy (shallow for nested to avoid recursion)
copy[key] = { ...val };
} else {
copy[key] = val;
@@ -85,7 +83,6 @@
let has_unsaved_changes = $derived.by(() => {
if (!tmp_entry_obj || !orig_entry_obj || is_processing) return false;
// Use normalization to avoid loop on whitespace/null differences
const normalize = (val: any) => (val === null || val === undefined) ? '' : String(val).trim();
const content_changed = normalize(tmp_entry_obj.content) !== normalize(orig_entry_obj.content);
@@ -106,27 +103,22 @@
// 1. Initial Load & Background Sync
$effect(() => {
const entry = $lq__journal_entry_obj;
const journal = $lq__journal_obj;
if (entry && (entry.updated_on || entry.created_on)) {
// ISOLATE Store checks to avoid re-triggering this effect when stores change
const is_decrypted = untrack(() => {
const session_kv = $journals_sess?.journal_kv[journal?.id];
return session_kv?.journal_passcode_decrypted === true;
});
const entry = $lq__journal_entry_obj; // Track only entry
untrack(() => {
const journal = $lq__journal_obj;
if (!entry || !(entry.updated_on || entry.created_on)) return;
console.log(`ae_view: Effect[Sync] trigger. Decrypted: ${is_decrypted}. Status: ${save_status}`);
const session_kv = $journals_sess?.journal_kv[journal?.id];
const is_decrypted = session_kv?.journal_passcode_decrypted === true;
// Only sync if saved and not currently processing/editing
if (save_status === 'saved' && !has_unsaved_changes && !is_processing) {
// If decrypted, don't let DB's null content overwrite local recovered text
// Prevent overwrite of recovered text if we are in a decrypted session
if (is_decrypted && tmp_entry_obj.content && !entry.content) {
console.log('ae_view: Effect[Sync] preserving decrypted content.');
return;
}
console.log('ae_view: Effect[Sync] update local state.');
const base = deep_copy(entry);
if (base) {
base.content = base.content ?? null;
@@ -135,29 +127,31 @@
tmp_entry_obj = { ...base };
}
}
}
});
});
// 2. Auto-Save Debounce
$effect(() => {
// Dependency tracking
// Track core properties
const _content = tmp_entry_obj.content;
const _private = tmp_entry_obj.private;
if (has_unsaved_changes && !is_processing) {
console.log('ae_view: Effect[AutoSave] trigger.');
// Isolate logic from secondary dependencies
const should_save = untrack(() => has_unsaved_changes && !is_processing && save_status !== 'saving');
if (should_save) {
if (save_status !== 'saving') save_status = 'unsaved';
const auto_save_enabled = untrack(() => $journals_loc.entry.auto_save);
if (auto_save_enabled) {
clearTimeout(auto_save_timer);
auto_save_timer = setTimeout(() => {
if (untrack(() => has_unsaved_changes && save_status !== 'saving')) {
if (untrack(() => has_unsaved_changes && !is_processing && save_status !== 'saving')) {
update_journal_entry();
}
}, 2000);
}
} else if (save_status !== 'saving' && !has_unsaved_changes) {
} else if (save_status === 'unsaved' && !has_unsaved_changes) {
save_status = 'saved';
}
});
@@ -165,16 +159,17 @@
// 3. Auto-Decryption Workflow
$effect(() => {
const journal = $lq__journal_obj;
if (!journal?.id) return;
const entry = $lq__journal_entry_obj;
if (!journal?.id || !entry) return;
// Track session state selectively
const session = $journals_sess?.journal_kv[journal.id];
const is_verified = session?.journal_passcode_verified;
const decrypted_status = session?.journal_passcode_decrypted;
const has_encrypted_content = !!$lq__journal_entry_obj?.content_encrypted;
const has_encrypted_content = !!entry.content_encrypted;
if (has_encrypted_content && is_verified && decrypted_status !== true && decrypted_status !== 'processing') {
console.log('ae_view: Auto-decrypt triggering.');
run_decryption_workflow();
untrack(() => run_decryption_workflow());
}
});
@@ -209,9 +204,6 @@
}
// SUCCESS
console.log(`ae_view: Decryption SUCCESS.`);
// Update both to prevent "unsaved changes" loop
const content = result.content || '';
tmp_entry_obj.content = content;
tmp_entry_obj.content_md_html = handle_marked(content);
@@ -237,10 +229,8 @@
}
async function update_journal_entry() {
if (!$ae_loc.trusted_access || save_status === 'saving') return;
if (!$ae_loc.trusted_access || save_status === 'saving' || is_processing) return;
// Prevent concurrent saves
if (is_processing) return;
is_processing = true;
save_status = 'saving';
@@ -278,7 +268,6 @@
}
try {
console.log('ae_view: Starting PATCH update...');
await journals_func.update_ae_obj__journal_entry({
api_cfg: $ae_api,
journal_entry_id: $lq__journal_entry_obj?.journal_entry_id,
@@ -286,10 +275,9 @@
log_lvl: 1
});
// Re-sync after successful save
// CRITICAL: Sync ORIG after save to clear unsaved changes
orig_entry_obj = deep_copy(tmp_entry_obj);
save_status = 'saved';
console.log('ae_view: PATCH complete and state synced.');
} catch (error) {
console.error('Update failed:', error);
save_status = 'unsaved';
@@ -411,4 +399,4 @@
<span class="text-2xl font-bold">Loading Journal Entry...</span>
</div>
{/if}
</section>
</section>