carlos / todomvc-bakeoff Public
swift: queue JS actions in order; clear new-todo on submit
Fixes review findings: a response no longer wipes a title typed while it was in flight, Enter during a pending add is no longer dropped, and an older list snapshot can't overwrite a newer one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+48
−30
apps/swift/Sources/TodoApp/Assets.swift+24 −15apps/swift/static/app.js+24 −15
diff --git a/apps/swift/Sources/TodoApp/Assets.swift b/apps/swift/Sources/TodoApp/Assets.swift| index 14ada2e..39d1a9d 100644 |
| --- a/apps/swift/Sources/TodoApp/Assets.swift |
| +++ b/apps/swift/Sources/TodoApp/Assets.swift |
| @@ -615,25 +615,26 @@ html:not(.js) .todo-list li label { |
| const app = document.querySelector('.todoapp'); |
| +// Actions run one at a time, in the order they were made, so each answer |
| +// is the newest state when it's swapped in and an older one can never |
| +// overwrite it. |
| +let queue = Promise.resolve(); |
| + |
| // Sends a form and replaces section.main and footer.footer with what the |
| -// server answers. Anything unexpected falls back to a full page load. |
| -async function send(form) { |
| - if (form.dataset.sending) return; |
| - form.dataset.sending = '1'; |
| - try { |
| - const res = await fetch(form.action, { |
| +// server answers. The form is read when it's submitted, so the visitor can |
| +// carry on typing. Anything unexpected falls back to a full page load. |
| +function send(form) { |
| + const action = form.action; |
| + const body = new URLSearchParams(new FormData(form)); |
| + queue = queue.then(async () => { |
| + const res = await fetch(action, { |
| method: 'POST', |
| headers: { 'X-Todo-Partial': '1' }, |
| - body: new URLSearchParams(new FormData(form)), |
| + body, |
| }); |
| if (!res.ok) throw new Error(`${res.status}`); |
| swap(await res.text()); |
| - if (form.closest('.header')) form.reset(); |
| - } catch { |
| - location.reload(); |
| - } finally { |
| - delete form.dataset.sending; |
| - } |
| + }).catch(() => location.reload()); |
| } |
| function swap(html) { |
| @@ -645,7 +646,13 @@ function swap(html) { |
| app.addEventListener('submit', (e) => { |
| e.preventDefault(); |
| - send(e.target); |
| + const form = e.target; |
| + send(form); |
| + // The new-todo input is ready for the next title straight away. |
| + if (form.closest('.header')) form.reset(); |
| + // An edit is saved once: the blur that follows doesn't save it again. |
| + const li = form.closest('.todo-list li'); |
| + if (li) li.dataset.saved = '1'; |
| }); |
| app.addEventListener('change', (e) => { |
| @@ -679,7 +686,9 @@ app.addEventListener('keydown', (e) => { |
| app.addEventListener('focusout', (e) => { |
| if (!e.target.matches('.edit')) return; |
| const li = e.target.closest('li'); |
| - if (li.classList.contains('editing')) send(e.target.form); |
| + if (!li.classList.contains('editing') || li.dataset.saved) return; |
| + li.dataset.saved = '1'; |
| + send(e.target.form); |
| }); |
| // The server's edit view (?edit=<id>) opens with that item's editor; put |
diff --git a/apps/swift/static/app.js b/apps/swift/static/app.js| index 4a00697..5f93e67 100644 |
| --- a/apps/swift/static/app.js |
| +++ b/apps/swift/static/app.js |
| @@ -4,25 +4,26 @@ |
| const app = document.querySelector('.todoapp'); |
| +// Actions run one at a time, in the order they were made, so each answer |
| +// is the newest state when it's swapped in and an older one can never |
| +// overwrite it. |
| +let queue = Promise.resolve(); |
| + |
| // Sends a form and replaces section.main and footer.footer with what the |
| -// server answers. Anything unexpected falls back to a full page load. |
| -async function send(form) { |
| - if (form.dataset.sending) return; |
| - form.dataset.sending = '1'; |
| - try { |
| - const res = await fetch(form.action, { |
| +// server answers. The form is read when it's submitted, so the visitor can |
| +// carry on typing. Anything unexpected falls back to a full page load. |
| +function send(form) { |
| + const action = form.action; |
| + const body = new URLSearchParams(new FormData(form)); |
| + queue = queue.then(async () => { |
| + const res = await fetch(action, { |
| method: 'POST', |
| headers: { 'X-Todo-Partial': '1' }, |
| - body: new URLSearchParams(new FormData(form)), |
| + body, |
| }); |
| if (!res.ok) throw new Error(`${res.status}`); |
| swap(await res.text()); |
| - if (form.closest('.header')) form.reset(); |
| - } catch { |
| - location.reload(); |
| - } finally { |
| - delete form.dataset.sending; |
| - } |
| + }).catch(() => location.reload()); |
| } |
| function swap(html) { |
| @@ -34,7 +35,13 @@ function swap(html) { |
| app.addEventListener('submit', (e) => { |
| e.preventDefault(); |
| - send(e.target); |
| + const form = e.target; |
| + send(form); |
| + // The new-todo input is ready for the next title straight away. |
| + if (form.closest('.header')) form.reset(); |
| + // An edit is saved once: the blur that follows doesn't save it again. |
| + const li = form.closest('.todo-list li'); |
| + if (li) li.dataset.saved = '1'; |
| }); |
| app.addEventListener('change', (e) => { |
| @@ -68,7 +75,9 @@ app.addEventListener('keydown', (e) => { |
| app.addEventListener('focusout', (e) => { |
| if (!e.target.matches('.edit')) return; |
| const li = e.target.closest('li'); |
| - if (li.classList.contains('editing')) send(e.target.form); |
| + if (!li.classList.contains('editing') || li.dataset.saved) return; |
| + li.dataset.saved = '1'; |
| + send(e.target.form); |
| }); |
| // The server's edit view (?edit=<id>) opens with that item's editor; put |