carlos / todomvc-bakeoff Public
fix(zig): keep an open editor across responses; trim Unicode whitespace
app.js no longer replaces an item that is being edited when an earlier request's answer arrives; it refreshes the list around it. Titles are trimmed of Unicode White_Space (and U+FEFF), so whitespace-only adds are ignored and whitespace-only edits delete.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+77
−6
apps/zig/src/app.zig+39 −2apps/zig/static/app.js+38 −4
diff --git a/apps/zig/src/app.zig b/apps/zig/src/app.zig| index 864b88c..579194f 100644 |
| --- a/apps/zig/src/app.zig |
| +++ b/apps/zig/src/app.zig |
| @@ -169,7 +169,7 @@ fn post(app: *const App, visitor: *Visitor, arena: Allocator, path: []const u8, |
| const not_found: Response = .{ .status = .not_found, .body = "no such todo\n" }; |
| if (std.mem.eql(u8, path, "/todos")) { |
| - const title = std.mem.trim(u8, form.title orelse "", &std.ascii.whitespace); |
| + const title = trimTitle(form.title orelse ""); |
| const list = try visitor.list(); |
| if (title.len > 0) try store.add(list, title); |
| } else if (std.mem.eql(u8, path, "/todos/toggle-all")) { |
| @@ -182,7 +182,7 @@ fn post(app: *const App, visitor: *Visitor, arena: Allocator, path: []const u8, |
| .toggle => try store.toggle(list, action.id), |
| .destroy => try store.destroy(list, action.id), |
| .edit => edit: { |
| - const title = std.mem.trim(u8, form.title orelse "", &std.ascii.whitespace); |
| + const title = trimTitle(form.title orelse ""); |
| break :edit if (title.len == 0) |
| try store.destroy(list, action.id) |
| else |
| @@ -200,6 +200,35 @@ fn post(app: *const App, visitor: *Visitor, arena: Allocator, path: []const u8, |
| return .{ .status = .see_other, .location = filter.path() }; |
| } |
| +/// Trims Unicode whitespace (the White_Space property, plus U+FEFF as |
| +/// JavaScript's `trim` does) from both ends of a UTF-8 title. |
| +fn trimTitle(s: []const u8) []const u8 { |
| + var start: usize = 0; |
| + while (start < s.len) { |
| + const len = std.unicode.utf8ByteSequenceLength(s[start]) catch break; |
| + if (start + len > s.len) break; |
| + const cp = std.unicode.utf8Decode(s[start..][0..len]) catch break; |
| + if (!isSpace(cp)) break; |
| + start += len; |
| + } |
| + var end = s.len; |
| + while (end > start) { |
| + var i = end - 1; |
| + while (i > start and s[i] & 0xC0 == 0x80) i -= 1; |
| + const cp = std.unicode.utf8Decode(s[i..end]) catch break; |
| + if (!isSpace(cp)) break; |
| + end = i; |
| + } |
| + return s[start..end]; |
| +} |
| + |
| +fn isSpace(cp: u21) bool { |
| + return switch (cp) { |
| + 0x09...0x0D, 0x20, 0x85, 0xA0, 0x1680, 0x2000...0x200A, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF => true, |
| + else => false, |
| + }; |
| +} |
| + |
| const ItemAction = struct { |
| id: i64, |
| verb: enum { toggle, edit, destroy }, |
| @@ -305,6 +334,14 @@ fn originAllowed(app: *const App, h: *const Headers) bool { |
| return std.mem.eql(u8, after_scheme, host); |
| } |
| +test trimTitle { |
| + try std.testing.expectEqualStrings("", trimTitle("\u{a0}\u{2003} \t\u{3000}")); |
| + try std.testing.expectEqualStrings("café ✓", trimTitle("\u{feff} café ✓\u{a0}\n")); |
| + try std.testing.expectEqualStrings("a\u{a0}b", trimTitle("a\u{a0}b")); |
| + try std.testing.expectEqualStrings("日本語", trimTitle("日本語")); |
| + try std.testing.expectEqualStrings("\xff x", trimTitle("\xff x")); |
| +} |
| + |
| test itemAction { |
| try std.testing.expectEqual(ItemAction{ .id = 12, .verb = .toggle }, itemAction("/todos/12/toggle").?); |
| try std.testing.expectEqual(ItemAction{ .id = 3, .verb = .edit }, itemAction("/todos/3/edit").?); |
diff --git a/apps/zig/static/app.js b/apps/zig/static/app.js| index 01559c9..e3c6782 100644 |
| --- a/apps/zig/static/app.js |
| +++ b/apps/zig/static/app.js |
| @@ -20,16 +20,50 @@ function send(form) { |
| body, |
| }); |
| if (!res.ok) throw new Error(`${res.status} from ${form.action}`); |
| - const fresh = new DOMParser().parseFromString(await res.text(), 'text/html'); |
| - for (const part of ['.main', '.footer']) { |
| - app.querySelector(part).replaceWith(fresh.querySelector(part)); |
| - } |
| + apply(new DOMParser().parseFromString(await res.text(), 'text/html')); |
| }).catch((err) => { |
| console.error(err); |
| location.reload(); |
| }); |
| } |
| +// Swaps in the server's fresh list and footer. An editor that's open when |
| +// the answer arrives stays open with its unsaved text: its item is kept in |
| +// place (never detached, so it keeps focus) and only refreshed around it. |
| +function apply(fresh) { |
| + app.querySelector('.footer').replaceWith(fresh.querySelector('.footer')); |
| + const editing = app.querySelector('.todo-list li.editing'); |
| + const update = editing && fresh.querySelector(`.todo-list li[data-id="${editing.dataset.id}"]`); |
| + if (!update) { |
| + app.querySelector('.main').replaceWith(fresh.querySelector('.main')); |
| + return; |
| + } |
| + |
| + const main = app.querySelector('.main'); |
| + const freshMain = fresh.querySelector('.main'); |
| + main.hidden = freshMain.hidden; |
| + main.querySelector('form').replaceWith(freshMain.querySelector('form')); |
| + |
| + for (const item of [...main.querySelectorAll('.todo-list > li')]) { |
| + if (item !== editing) item.remove(); |
| + } |
| + let before = true; |
| + for (const item of [...freshMain.querySelectorAll('.todo-list > li')]) { |
| + if (item === update) { |
| + before = false; |
| + } else if (before) { |
| + editing.before(item); |
| + } else { |
| + editing.parentElement.append(item); |
| + } |
| + } |
| + |
| + // Bring the kept item's view up to date, leaving the editor's text alone. |
| + editing.className = `${update.className} editing`; |
| + editing.querySelector('.view').replaceWith(update.querySelector('.view')); |
| + editing.querySelector('.edit').defaultValue = update.querySelector('.edit').defaultValue; |
| +} |
| + |
| app.addEventListener('submit', (event) => { |
| event.preventDefault(); |
| const form = event.target; |