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