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
|
// *** Derived
|
||||||
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;
|
||||||
return (
|
|
||||||
(tmp_entry_obj.content ?? null) !== (orig_entry_obj.content ?? null) ||
|
// Use normalization to avoid loop on whitespace/null differences
|
||||||
(tmp_entry_obj.name ?? null) !== (orig_entry_obj.name ?? null) ||
|
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.tags ?? null) !== (orig_entry_obj.tags ?? null) ||
|
||||||
(tmp_entry_obj.category_code ?? null) !== (orig_entry_obj.category_code ?? 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.public ?? false) !== (orig_entry_obj.public ?? false) ||
|
||||||
(tmp_entry_obj.personal ?? false) !== (orig_entry_obj.personal ?? 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
|
// *** Effects
|
||||||
@@ -77,21 +81,24 @@
|
|||||||
const entry = $lq__journal_entry_obj;
|
const entry = $lq__journal_entry_obj;
|
||||||
const journal = $lq__journal_obj;
|
const journal = $lq__journal_obj;
|
||||||
if (entry && (entry.updated_on || entry.created_on)) {
|
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];
|
// ISOLATE Store checks to avoid re-triggering this effect when stores change
|
||||||
const is_decrypted = session_kv?.journal_passcode_decrypted === true;
|
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
|
// 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 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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('ae_view: Effect[Sync] PROCEEDING. Updating local state from DB.');
|
console.log('ae_view: Effect[Sync] update local state.');
|
||||||
const base = {
|
const base = {
|
||||||
...entry,
|
...entry,
|
||||||
content: entry.content ?? null,
|
content: entry.content ?? null,
|
||||||
@@ -99,23 +106,25 @@
|
|||||||
};
|
};
|
||||||
orig_entry_obj = { ...base };
|
orig_entry_obj = { ...base };
|
||||||
tmp_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
|
// 2. Auto-Save Debounce
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
// Explicitly depend on content but DO NOT react to other tmp_entry_obj properties here
|
||||||
const _trigger = tmp_entry_obj.content;
|
const _trigger = tmp_entry_obj.content;
|
||||||
|
const _trigger_priv = tmp_entry_obj.private;
|
||||||
|
|
||||||
if (has_unsaved_changes && !is_processing) {
|
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 (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);
|
clearTimeout(auto_save_timer);
|
||||||
auto_save_timer = setTimeout(() => {
|
auto_save_timer = setTimeout(() => {
|
||||||
if (untrack(() => has_unsaved_changes)) {
|
if (untrack(() => has_unsaved_changes && save_status !== 'saving')) {
|
||||||
console.log('ae_view: Effect[AutoSave] Timer fired. Updating...');
|
|
||||||
update_journal_entry();
|
update_journal_entry();
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
@@ -135,10 +144,8 @@
|
|||||||
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 = !!$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') {
|
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();
|
run_decryption_workflow();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -147,7 +154,7 @@
|
|||||||
|
|
||||||
async function run_decryption_workflow() {
|
async function run_decryption_workflow() {
|
||||||
const journal = $lq__journal_obj;
|
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;
|
let journal_key = $journals_sess.journal_kv[journal.id]?.typed_journal_passcode;
|
||||||
|
|
||||||
@@ -174,16 +181,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SUCCESS
|
// SUCCESS
|
||||||
console.log(`ae_view: Decryption SUCCESS. Updating state.`);
|
console.log(`ae_view: Decryption SUCCESS.`);
|
||||||
tmp_entry_obj.content = result.content;
|
|
||||||
tmp_entry_obj.content_md_html = handle_marked(result.content || '');
|
|
||||||
|
|
||||||
// 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.content_encrypted = null;
|
||||||
tmp_entry_obj.history_encrypted = null;
|
tmp_entry_obj.history_encrypted = null;
|
||||||
|
|
||||||
if (orig_entry_obj) {
|
if (orig_entry_obj) {
|
||||||
orig_entry_obj.content = result.content;
|
orig_entry_obj.content = content;
|
||||||
orig_entry_obj.content_encrypted = null;
|
orig_entry_obj.content_encrypted = null;
|
||||||
orig_entry_obj.history_encrypted = null;
|
orig_entry_obj.history_encrypted = null;
|
||||||
}
|
}
|
||||||
@@ -201,7 +209,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function update_journal_entry() {
|
async function update_journal_entry() {
|
||||||
if (!$ae_loc.trusted_access) return;
|
if (!$ae_loc.trusted_access || save_status === 'saving') return;
|
||||||
save_status = 'saving';
|
save_status = 'saving';
|
||||||
|
|
||||||
// Reference Standard: Explicit whitelist of editable fields
|
// Reference Standard: Explicit whitelist of editable fields
|
||||||
@@ -251,7 +259,8 @@
|
|||||||
data_kv: data_kv,
|
data_kv: data_kv,
|
||||||
log_lvl: 1
|
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';
|
save_status = 'saved';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update failed:', error);
|
console.error('Update failed:', error);
|
||||||
|
|||||||
Reference in New Issue
Block a user