From daf68710c3223843f97840f41a52e4014a767938 Mon Sep 17 00:00:00 2001 From: Swathi Date: Tue, 21 Apr 2026 09:59:59 +0530 Subject: [PATCH] fix(web): note save was re-rendering the editor, causing content to duplicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause - POST /notes/{trade|day}/... returned the entire note_block partial, and the form's hx-swap="outerHTML" replaced #note-block wholesale. - EasyMDE's CodeMirror DOM sat *inside* #note-block. When the block was swapped, the old CodeMirror stayed attached to the detached textarea while a new one mounted on the fresh textarea. The htmx:afterSwap listener that re-wired the editor was registered inside wire() — one listener per mount — so every save added another listener and every listener re-mounted. Content visually duplicated because multiple CodeMirrors stacked on top of each other all flushed into the same hidden textarea. Fix - Save endpoint now returns just partials/note_meta.html — a single with the saved-at timestamp. 78 bytes per save versus ~1.5 KB of editor HTML. - Form's hx-target narrowed to #note-meta-stamp, hx-swap="outerHTML" replaces only that span. Editor DOM untouched. - editor.js: CodeMirror 'blur' now fires htmx.trigger(form, 'khata-save') — autosave without touching the editor DOM. - Removed the htmx:afterSwap re-mount listener (no longer needed; nothing swaps the editor). - Tests updated to assert the stamp-only response, then verify persistence via a follow-up GET. No schema change. 46/46 tests green. --- khata/web/main.py | 10 ++++---- khata/web/static/editor.js | 25 ++++++++++---------- khata/web/templates/partials/note_block.html | 6 ++--- khata/web/templates/partials/note_meta.html | 1 + tests/test_web.py | 9 +++++-- 5 files changed, 29 insertions(+), 22 deletions(-) create mode 100644 khata/web/templates/partials/note_meta.html diff --git a/khata/web/main.py b/khata/web/main.py index 3f2eafe..6153b74 100644 --- a/khata/web/main.py +++ b/khata/web/main.py @@ -176,10 +176,12 @@ def save_trade_note( if Q.trade_by_id(conn, user_id, trade_id) is None: raise HTTPException(404) note = Q.set_trade_note(conn, user_id, trade_id, body) + # Return only the saved-at stamp so HTMX doesn't re-render the editor + # DOM (which would cause EasyMDE to double-mount on every save). return TEMPLATES.TemplateResponse( request, - "partials/note_block.html", - {"note": note, "endpoint": f"/notes/trade/{trade_id}"}, + "partials/note_meta.html", + {"note": note}, ) @app.post("/notes/day/{day}", response_class=HTMLResponse) @@ -197,8 +199,8 @@ def save_daily_note( note = Q.set_daily_note(conn, user_id, d, body) return TEMPLATES.TemplateResponse( request, - "partials/note_block.html", - {"note": note, "endpoint": f"/notes/day/{day}"}, + "partials/note_meta.html", + {"note": note}, ) @app.post("/tags/trade/{trade_id}", response_class=HTMLResponse) diff --git a/khata/web/static/editor.js b/khata/web/static/editor.js index 862e2b0..a803dd8 100644 --- a/khata/web/static/editor.js +++ b/khata/web/static/editor.js @@ -60,23 +60,22 @@ previewRender: (text) => easy.markdown(text), }); - // Keep the native textarea's value in sync so HTMX form submission works. + // Keep the native textarea's value in sync. easy.codemirror.on("change", () => easy.codemirror.save()); - // Keep a handle so form submit can flush before send. - window._khataEasyMDE = easy; - - // HTMX replaces #note-block after POST → the old CodeMirror DOM gets - // unmounted. On htmx:afterSwap, re-wire whatever landed. - document.body.addEventListener("htmx:afterSwap", (ev) => { - if (ev.target && ev.target.id === "note-block") { - const t = document.querySelector("#note-block textarea.khata-editor"); - if (t) { - delete t.dataset.khataMounted; - wire(t); - } + // Trigger an HTMX save when the CodeMirror area loses focus. The form's + // hx-trigger includes 'khata-save', so this submits without re-rendering + // the editor DOM (the server returns just the saved-at stamp). + const form = textarea.closest("form"); + easy.codemirror.on("blur", () => { + easy.codemirror.save(); + if (form && window.htmx) { + window.htmx.trigger(form, "khata-save"); } }); + + // Keep a handle so any form-submit onsubmit hook can flush before send. + window._khataEasyMDE = easy; } function init() { diff --git a/khata/web/templates/partials/note_block.html b/khata/web/templates/partials/note_block.html index b120854..27d11f6 100644 --- a/khata/web/templates/partials/note_block.html +++ b/khata/web/templates/partials/note_block.html @@ -1,15 +1,15 @@
- {% if note and note.updated_at %}saved {{ note.updated_at[:19].replace('T', ' ') }} UTC{% else %}not saved yet{% endif %} + {% include "partials/note_meta.html" %}
diff --git a/khata/web/templates/partials/note_meta.html b/khata/web/templates/partials/note_meta.html new file mode 100644 index 0000000..c518986 --- /dev/null +++ b/khata/web/templates/partials/note_meta.html @@ -0,0 +1 @@ +{% if note and note.updated_at %}saved {{ note.updated_at[:19].replace('T', ' ') }} UTC{% else %}not saved yet{% endif %} diff --git a/tests/test_web.py b/tests/test_web.py index ea156fe..f5fdd8a 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -137,9 +137,13 @@ def test_trade_404(client): def test_note_save_and_reload(client): + # POST returns ONLY the saved-at stamp partial (not the full editor), + # so EasyMDE doesn't double-mount. The body itself is checked on reload. r = client.post("/notes/trade/1", data={"body": "First thoughts on this trade"}) assert r.status_code == 200 - assert "First thoughts" in r.text + assert 'id="note-meta-stamp"' in r.text + assert "saved" in r.text + assert "First thoughts" not in r.text # editor not re-rendered # Reload the page and confirm note persisted r2 = client.get("/trade/1") assert "First thoughts" in r2.text @@ -167,7 +171,8 @@ def test_tag_add_and_remove(client): def test_daily_note_save(client): r = client.post("/notes/day/2026-04-15", data={"body": "Revenge traded after the morning loss"}) assert r.status_code == 200 - assert "Revenge traded" in r.text + assert 'id="note-meta-stamp"' in r.text + assert "Revenge traded" not in r.text # meta partial only r2 = client.get("/day/2026-04-15") assert "Revenge traded" in r2.text