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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev d249da87d19db655c73baf88018d6b9ef25f50e7 parent 208c70d
2 files changed, +48 −30
  • apps/swift/Sources/TodoApp/Assets.swift +24 −15
  • apps/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