commit 80cd07c5035bea095063356145123df278e7dcdf
parent 99ac9da16c7b33bb879337df1c3584e936fec1e4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 18 Sep 2026 18:24:52 -0400
e2e: fill("") is two round trips, and the second one can miss
`a correction is written for the next pass` red under load with the manifest
holding the text minus its FIRST character, which reads like the bench turning
"clear" into "delete one" — and the bench is innocent.
playwright's fill is not one operation. The injected script selects the existing
text and returns `needsinput`; the driver then presses Delete in a SECOND round
trip (playwright-core/lib/server/dom.js:529-534). A React re-render landing
between the two — hydration, on a page just navigated to — reassigns the
controlled value and collapses the selection to offset 0, so Delete removes one
character forward rather than the selection. The blur then faithfully saved
what the box actually held.
So the helper checks the value before it blurs, and retries the fill. Noted in
the e2e gotchas too: it will bite any spec that clears a controlled field, and
it blames the component every time.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 26 insertions(+), 2 deletions(-)
diff --git a/umtool/docs/e2e.md b/umtool/docs/e2e.md
@@ -77,5 +77,14 @@ prerenders — a layout or client-component change can pass every spec and 500 i
production. Two real bugs in this feature were found that way and one only by a
page render.
+**`fill("")` on a controlled input is two round trips, not one.** Playwright
+selects the text, then presses Delete separately. A React re-render in between
+(hydration, right after a `goto`) reassigns the value and collapses the selection
+to offset 0 — Delete then removes ONE character and the field saves
+`value.slice(1)`. It reds under load and passes in isolation, and it blames the
+component, which saved exactly what the box held. Assert the value after the
+fill and retry (`expect(async () => {…}).toPass()`) before triggering whatever
+the edit commits on.
+
**Kill stray dev servers by port**, not with `pkill -f`: the pattern matches your
own shell's command line.
diff --git a/umtool/e2e/clip-bench.spec.ts b/umtool/e2e/clip-bench.spec.ts
@@ -49,14 +49,29 @@ const readClip = (id: string) => {
return m.timeline.find((e) => e.id === id)!;
};
-/** Type into an attribution field and let it save the way a blur does. */
+/**
+ * Type into an attribution field and let it save the way a blur does.
+ *
+ * The fill is CHECKED before the blur, and that is not belt and braces.
+ * `fill("")` is not one operation: playwright's injected script selects the
+ * existing text and returns `needsinput`, and the driver then presses Delete in
+ * a second round trip (playwright-core/lib/server/dom.js). A React re-render
+ * landing between the two -- hydration, on a page that was just navigated to --
+ * reassigns the controlled value and COLLAPSES the selection to offset 0, so
+ * Delete eats one character forward instead of the selection. The blur then
+ * faithfully saves `value.slice(1)`, and the bench is innocent: it saved exactly
+ * what the box held.
+ */
const setField = async (
page: import("@playwright/test").Page,
field: string,
value: string,
) => {
const input = page.locator(`[data-attrib-field=${field}]`);
- await input.fill(value);
+ await expect(async () => {
+ await input.fill(value);
+ await expect(input).toHaveValue(value, { timeout: 1000 });
+ }).toPass({ timeout: 10_000 });
await input.blur();
};