The Card was specific. On a new Lesson: type a title, attach a video, hit Save — the save fails with “não pode ficar em branco” and the title box is empty. The Instructor typed a title and the screen says they did not.
It was a good report. It was also aiming at the wrong thing. The video modal was innocent; the title was gone long before anyone opened it.

Look at that screenshot for a second longer than feels necessary. The title is empty and flagged. The Markdown pane shows its placeholder, so the body is gone too. And the Preview pane, eighteen inches to the right, is cheerfully rendering Momentum / Some body text. The screen is contradicting itself in two places at once, and the Instructor is supposed to work out which half is lying.
What the browser actually sends
phx-change on a <form> serialises the whole form. Everyone knows this, and it is why the bug is so easy to walk past.
phx-change on an input does not.
// phoenix_live_view/view.js:1729
if (inputEl.getAttribute(this.binding("change"))) {
formData = serializeForm(inputEl.form, serializeOpts, [inputEl.name]);
} else {
formData = serializeForm(inputEl.form, serializeOpts);
}
That third argument is onlyNames, and thirty lines later it is a filter: if (onlyNames.length === 0 || onlyNames.indexOf(key) >= 0). An input carrying its own phx-change puts itself and nothing else on the wire.
In the Lesson editor the title input has no phx-change of its own, so it rides the form’s and arrives with a full payload. The body textarea has one. Every References input has one. So the moment the Instructor clicks from the title into the body, the server receives this:
%{"lesson" => %{"body" => "Some body text."}}
No title. Not a blank title — no key at all. And the handler did this with it:
socket.assigns.lesson
|> Courses.change_lesson(Map.put(params, "references", rows))
socket.assigns.lesson is the row from the database, or a bare %Lesson{} for a Lesson that has never been saved. Rebuild a changeset from that plus a patch with no title, and the title input re-renders from a struct that has never heard of it. On a new Lesson it goes blank. On an existing one — quieter, and worse — it silently reverts to the last saved value, with no error to notice.
flowchart TD
A["Instructor types a title"] --> B["title has no phx-change of its own
→ rides the form's → FULL payload"]
B --> C["form.params = title, body, free_preview"]
C --> D["Instructor clicks into the body and types"]
D --> E["body HAS its own phx-change
→ onlyNames = [lesson[body]]
→ PARTIAL payload: body only"]
E --> F{"what is the changeset built from?"}
F -->|"before: the struct"| G["%Lesson{} + %{body}
title was never in either
→ input re-renders EMPTY"]
F -->|"after: form.params"| H["accumulated params + %{body}
title still there
→ input keeps its value"]
The fix is the bottom branch, and it is four lines:
params =
socket
|> current_params() # socket.assigns.form.params
|> Map.merge(patch)
|> Map.put("references", rows)
The form’s params are the draft. The struct is only the baseline the draft started from. References are owned by their own assign and so are written last, after the merge, where nothing can stale them.
CharacterLive.Form already had this, comment and all — current_params/1, form_action/1, for exactly this reason. The Lesson editor never got it. One assign_form/3 now serves all three handlers that were rebuilding from the struct: validate, video_saved, and remove-video, which had the identical defect on the identical line and would have kept eating titles on its own.
The old code also reset the preview from params["body"] || "". That is the second half of the contradiction in the screenshot: on some paths the preview was blanked by a patch that simply had no body key, and on others it was not. It reads the body off the changeset now, so the two panes cannot disagree.
The test that would have lied to you
Here is the part worth carrying to the next screen.
This codebase has a standing rule, earned the hard way: hand-crafted params passed to render_submit bypass the form and give false confidence. Drive the real form.
For this bug, that rule is backwards.
LiveViewTest has no onlyNames. It sends whatever you hand it. So a test that drives the whole form — the careful, house-style, do-it-properly test — hands the server a payload containing the title, and passes against the broken code. It is not a weak test. It is a test of a code path the browser never takes.
The only test that sees this bug is the one that deliberately sends a patch:
defp type_in_body(view, text),
do: render_change(view, "validate", %{"lesson" => %{"body" => text}})
That asymmetry is the whole reason a data-loss bug this loud survived since before Paste Video Id shipped. Both rules are right. They just answer different questions: drive the real form protects you when the form is the thing under test, and send what the wire sends protects you when the wire is.
Proving the tests are not decorative
Eleven tests went in. All green. That is not evidence of anything on its own — a test written after a fix has never once been observed to fail.
So the fix came back out. git stash, run the suite: nine of eleven red. The two that stayed green guard behaviour that was already correct — References surviving a video attach (by accident, because they live in their own assign) and a blank title not being flagged prematurely — and both are things the refactor could plausibly have broken. That is a reasonable thing for a test to do; it just is not what those two were claiming to prove.
Then the same treatment in a real browser, because the class of bug this belongs to is one ExUnit is structurally blind to. Twelve checks against Playwright, fix in: twelve green. Fix stashed out: seven red, and Save times out — no navigation, because the title is blank and the save fails. The Card’s original symptom, reproduced on demand.

A save now puts all four through in one go — title, body, the normalised video_url, and the Reference.
The edit-mode case is the one to keep an eye on, because it never announced itself. Rename a Lesson, click into the body, and the title used to quietly become its old self again. No error, no flash. An Instructor who did not happen to look up saved the old title back over their own change.

What the Card got right
It said the video modal was where the Instructor met the bug, and that was true. handle_info({:video_saved, url}, ...) is where the loss becomes visible, because it is the one path where losing the title is immediately followed by a save attempt that fails and says so out loud. Typing in the body loses it just as completely and says nothing at all.
Which is a decent argument for reading a bug report as a description of a symptom rather than a diagnosis, and for spending twenty minutes probing the running system before accepting the location it hands you. The fix was four lines. Finding out that it belonged in three places instead of one, and that a whole class of test could not see any of them, took most of the day.