fix(journals): resolve reactivity loop and hang during privacy toggle
- Refactored effects to use 'untrack' for store isolation. - Implemented content normalization in 'has_unsaved_changes' to avoid circular triggers. - Ensured atomic state sync after successful PATCH updates. - Added comprehensive flow diagnostics to track effect execution.
This commit is contained in:
@@ -58,16 +58,20 @@
|
||||
// *** Derived
|
||||
let has_unsaved_changes = $derived.by(() => {
|
||||
if (!tmp_entry_obj || !orig_entry_obj || is_processing) return false;
|
||||
return (
|
||||
(tmp_entry_obj.content ?? null) !== (orig_entry_obj.content ?? null) ||
|
||||
(tmp_entry_obj.name ?? null) !== (orig_entry_obj.name ?? null) ||
|
||||
|
||||
// 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);
|
||||
const name_changed = normalize(tmp_entry_obj.name) !== normalize(orig_entry_obj.name);
|
||||
const private_changed = (tmp_entry_obj.private ?? false) !== (orig_entry_obj.private ?? false);
|
||||
|
||||
return content_changed || name_changed || private_changed ||
|
||||
(tmp_entry_obj.tags ?? null) !== (orig_entry_obj.tags ?? null) ||
|
||||
(tmp_entry_obj.category_code ?? null) !== (orig_entry_obj.category_code ?? null) ||
|
||||
(tmp_entry_obj.private ?? false) !== (orig_entry_obj.private ?? false) ||
|
||||
(tmp_entry_obj.public ?? false) !== (orig_entry_obj.public ?? false) ||
|
||||
(tmp_entry_obj.personal ?? false) !== (orig_entry_obj.personal ?? false) ||
|
||||
(tmp_entry_obj.professional ?? false) !== (orig_entry_obj.professional ?? false)
|
||||
);
|
||||
(tmp_entry_obj.professional ?? false) !== (orig_entry_obj.professional ?? false);
|
||||
});
|
||||
|
||||
// *** Effects
|
||||
@@ -77,21 +81,24 @@
|
||||
const entry = $lq__journal_entry_obj;
|
||||
const journal = $lq__journal_obj;
|
||||
if (entry && (entry.updated_on || entry.created_on)) {
|
||||
// Determine if we are in a decrypted state for this journal
|
||||
const session_kv = $journals_sess?.journal_kv[journal?.id];
|
||||
const is_decrypted = session_kv?.journal_passcode_decrypted === true;
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
console.log(`ae_view: Effect[Sync] trigger. Entry updated_on: ${entry.updated_on}. Decrypted in session: ${is_decrypted}`);
|
||||
console.log(`ae_view: Effect[Sync] trigger. Decrypted: ${is_decrypted}. Status: ${save_status}`);
|
||||
|
||||
// Only sync if saved and not currently processing/editing
|
||||
if (save_status === 'saved' && !has_unsaved_changes && !is_processing) {
|
||||
// If the journal is already decrypted, we MUST NOT overwrite the decrypted content with null from DB
|
||||
// If decrypted, don't let DB's null content overwrite local recovered text
|
||||
if (is_decrypted && tmp_entry_obj.content && !entry.content) {
|
||||
console.log('ae_view: Effect[Sync] SKIPPED to preserve decrypted content.');
|
||||
console.log('ae_view: Effect[Sync] preserving decrypted content.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('ae_view: Effect[Sync] PROCEEDING. Updating local state from DB.');
|
||||
console.log('ae_view: Effect[Sync] update local state.');
|
||||
const base = {
|
||||
...entry,
|
||||
content: entry.content ?? null,
|
||||
@@ -99,23 +106,25 @@
|
||||
};
|
||||
orig_entry_obj = { ...base };
|
||||
tmp_entry_obj = { ...base };
|
||||
} else {
|
||||
console.log(`ae_view: Effect[Sync] BLOCKED. status: ${save_status}, has_changes: ${has_unsaved_changes}, processing: ${is_processing}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Auto-Save Debounce
|
||||
$effect(() => {
|
||||
// Explicitly depend on content but DO NOT react to other tmp_entry_obj properties here
|
||||
const _trigger = tmp_entry_obj.content;
|
||||
const _trigger_priv = tmp_entry_obj.private;
|
||||
|
||||
if (has_unsaved_changes && !is_processing) {
|
||||
console.log('ae_view: Effect[AutoSave] trigger. Changes detected.');
|
||||
console.log('ae_view: Effect[AutoSave] trigger.');
|
||||
if (save_status !== 'saving') save_status = 'unsaved';
|
||||
if ($journals_loc.entry.auto_save) {
|
||||
|
||||
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)) {
|
||||
console.log('ae_view: Effect[AutoSave] Timer fired. Updating...');
|
||||
if (untrack(() => has_unsaved_changes && save_status !== 'saving')) {
|
||||
update_journal_entry();
|
||||
}
|
||||
}, 2000);
|
||||
@@ -135,10 +144,8 @@
|
||||
const decrypted_status = session?.journal_passcode_decrypted;
|
||||
const has_encrypted_content = !!$lq__journal_entry_obj?.content_encrypted;
|
||||
|
||||
console.log(`ae_view: Effect[Decryption] check. Encrypted: ${has_encrypted_content}, Verified: ${is_verified}, Status: ${decrypted_status}`);
|
||||
|
||||
if (has_encrypted_content && is_verified && decrypted_status !== true && decrypted_status !== 'processing') {
|
||||
console.log('ae_view: Effect[Decryption] PROCEEDING to run workflow.');
|
||||
console.log('ae_view: Effect[Decryption] auto-running.');
|
||||
run_decryption_workflow();
|
||||
}
|
||||
});
|
||||
@@ -147,7 +154,7 @@
|
||||
|
||||
async function run_decryption_workflow() {
|
||||
const journal = $lq__journal_obj;
|
||||
if (!journal?.id) return;
|
||||
if (!journal?.id || is_processing) return;
|
||||
|
||||
let journal_key = $journals_sess.journal_kv[journal.id]?.typed_journal_passcode;
|
||||
|
||||
@@ -174,16 +181,17 @@
|
||||
}
|
||||
|
||||
// SUCCESS
|
||||
console.log(`ae_view: Decryption SUCCESS. Updating state.`);
|
||||
tmp_entry_obj.content = result.content;
|
||||
tmp_entry_obj.content_md_html = handle_marked(result.content || '');
|
||||
console.log(`ae_view: Decryption SUCCESS.`);
|
||||
|
||||
// CRITICAL: Clear encrypted fields in local state so UI doesn't think it's still locked
|
||||
// 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);
|
||||
tmp_entry_obj.content_encrypted = null;
|
||||
tmp_entry_obj.history_encrypted = null;
|
||||
|
||||
if (orig_entry_obj) {
|
||||
orig_entry_obj.content = result.content;
|
||||
orig_entry_obj.content = content;
|
||||
orig_entry_obj.content_encrypted = null;
|
||||
orig_entry_obj.history_encrypted = null;
|
||||
}
|
||||
@@ -201,7 +209,7 @@
|
||||
}
|
||||
|
||||
async function update_journal_entry() {
|
||||
if (!$ae_loc.trusted_access) return;
|
||||
if (!$ae_loc.trusted_access || save_status === 'saving') return;
|
||||
save_status = 'saving';
|
||||
|
||||
// Reference Standard: Explicit whitelist of editable fields
|
||||
@@ -251,7 +259,8 @@
|
||||
data_kv: data_kv,
|
||||
log_lvl: 1
|
||||
});
|
||||
orig_entry_obj = { ...tmp_entry_obj };
|
||||
// CRITICAL: Sync ORIG after save to clear unsaved changes
|
||||
orig_entry_obj = JSON.parse(JSON.stringify(tmp_entry_obj));
|
||||
save_status = 'saved';
|
||||
} catch (error) {
|
||||
console.error('Update failed:', error);
|
||||
|
||||
Reference in New Issue
Block a user