前端

編輯輸入框的按鈕也會使其失焦,破壞了焦點守衛

當按鈕寫入輸入欄位時,基於焦點的髒狀態守衛會靜默地失敗。本文將說明為何 blur 事件會先觸發,以及在即時更新的表單中該使用何種替代方案。

本文由 AI 模型從英文原文翻譯而來,用字可能與原文有所出入。 閱讀英文原文

如果一個表單會從伺服器接收即時更新,你需要某種方法來避免覆寫使用者正在輸入的內容。通常的解決方法是使用一個焦點旗標:在 focus 事件時設定 editing = true,在 blur 事件時清除它,並在旗標為 true 時跳過同步。這對打字來說是有效的。但當你加入一個會寫入同一個輸入欄位的按鈕時,這個方法就失效了。

原因很細微且容易忽略。點擊一個已獲取焦點的輸入欄位旁邊的按鈕,會先使該輸入欄位失焦。你的防護機制恰好在最需要它的時候關閉了,而且如果失焦處理器同時也清空了延遲的工作,你就等於在更新進來時給了它綠燈。

我在一個管理後台的到期日欄位加入「+31 天」按鈕時遇到了這個問題。這個按鈕會增加輸入欄位中的數字,並將儲存工作留給現有的「儲存」按鈕。在測試中,它看起來沒問題。但在即時資料流下,它幾乎每次都會遺失編輯的內容。

此失敗是確定性的,而非競爭所致

以下是其順序,依照瀏覽器執行的順序:

  1. 在按鈕上的 mousedown 會將焦點從輸入框移開。
  2. 輸入框觸發 blur。處理函式會設定 editing = false,並且,在許多程式碼庫中,會清空任何在編輯期間被延遲的更新。
  3. click 觸發。你的處理函式會將新值寫入輸入框。
  4. 伺服器推播在數百毫秒後到達。editingfalse,因此同步作業會執行,並用儲存的值取代輸入框的內容。

此處沒有任何事取決於時機運氣。步驟 2 總是先於步驟 3,所以當按鈕寫入時,旗標總是處於關閉狀態。唯一的變數是更新是否在使用者按下「儲存」前到達,而在一個繁忙的管理員畫面上,這幾乎是肯定的。

還有另一條更簡單的路徑。如果使用者完全沒有聚焦於輸入框,只是選取了一列並點擊按鈕,那麼 editing 從一開始就從未是 true

為何按鈕寫入時,焦點防護是關閉的 1 2 3 防護關閉 mousedown blur 點擊寫入 同步覆寫 // blur 總是在 click 前執行,因此當值寫入時,旗標是關閉的 ## Why a dirty flag beats a focus flag Focus answers "is the cursor in this field right now." That is not the question. The question is "does this field hold a value the server does not have yet." Those two coincide while someone types, which is why the focus flag survives so long, and they come apart as soon as anything other than the keyboard writes to the field. So track the second thing directly: ```js // 狀態 durationDirty: false, // 按鈕 addDays(n) { const s = String(this.durationInput ?? '').trim() if (s === '') { notify('請先輸入一個基準值'); return } if (!/^\d+$/.test(s)) { notify('僅限整數'); return } const next = Number(s) + n if (next > MAX) { notify(`最大值為 ${MAX}`); return } this.durationInput = next this.durationDirty = true // 僅在值實際變更後 } // 同步 if (!this.editingDuration && !this.durationDirty) { this.durationInput = fresh.durationDays ?? '' }

Two details in there matter more than they look.

Set the flag after the write, not before. If you set it at the top of the handler, a rejected click still marks the form dirty. The user sees an "unsaved" badge for a value that never changed, and the next sync is blocked for no reason.

Reject instead of clamping. An earlier version capped the result at the maximum. Press the button at 3640 with a cap of 3650 and the value moves to 3650. Press again and nothing happens, with no message. The button looks broken. Refusing with a reason is shorter to write and easier to trust.

Do not put the dirty flag in the refresh gate

The obvious next step is to reuse the flag everywhere you already check the focus flag. Resist that for the scheduler that decides whether to refresh at all.

A focus flag clears itself. The user clicks somewhere else and it is gone. A dirty flag is sticky by design: it stays until the value is saved or discarded. If you put a sticky flag in the "should I refresh" condition, then a user who types a value and walks away freezes the whole screen behind a "changes available" banner, indefinitely.

Keep the two scopes separate:

  • The refresh scheduler may use focus flags, so a refresh is deferred for a second or two while someone types.
  • The field sync inside the refresh uses the dirty flag, so the one input with unsaved work is skipped while everything else updates.

The list, the badges, and the rest of the panel keep moving. Only the field the user is editing holds still.

Let the stale value stay visible next to the new one

Once the input stops syncing, it will disagree with the server. Do not hide that. Show both.

In the panel I was working on, the input showed the pending value while a line under it kept showing the stored one:

[ 84 ]  [儲存]  [永久]  [+31 天]
目前 53 天,於 2026-08-30 到期    * 未儲存,請按「儲存」

Two numbers on screen sounds like a bug report waiting to happen, and the first instinct is to freeze the caption too. That instinct is wrong. While the field is dirty, that line is the only channel telling the user what the server actually holds. If another operator extends the same license to 90 days, the caption moves to 90 and the input stays at 84. The user can see that something changed underneath and decide whether to overwrite it.

Freeze the caption and you get the worst version: a screen that looks internally consistent while being wrong.

Sticky state needs a lifecycle

A flag that clears itself needs no cleanup. A sticky one does, and the cleanup is where the second round of bugs lives.

The one that bit me: the panel is rendered conditionally on a selected record. Delete the record and the subtree is removed from the DOM. Removal does not fire blur. So an input that was focused when its panel disappeared leaves its focus flag true forever, and every later update gets deferred into a banner that nobody clicks.

The fix is a single function that owns clearing the selection, so no caller sets it to null directly:

clearSelection() {
  this.selected = null
  this.durationInput = ''; this.durationDirty = false; this.editingDuration = false
  this.tierInput = '';     this.editingTier = false;   this.editingNote = false
}

Call it from delete, and from the refresh path when the selected record is no longer in the returned list. Two more rules worth writing down:

  • On failure, keep the flag. If a save returns 400 or 502, the value in the field is still the only copy of the user's intent. Clearing dirty there invites the next sync to erase it.
  • On success, order the writes. Assign the server response to the field, then clear dirty, then refetch. Clearing first opens a window where the refetch can touch the field before you have put the confirmed value in it.

What about preventing the blur?

There is a tempting one-liner: block the default action of mousedown on the button so focus never leaves the input.

<button onmousedown="event.preventDefault()" onclick="addDays(31)">+31 天</button>

It does keep focus, and for a user who was already typing it does keep the focus flag up. It is still the wrong fix, for two reasons.

It only preserves focus that already exists. It does not create it. The user who selects a row and clicks the button without ever touching the input is still unguarded, and that is the exact path that started this whole investigation.

And when it does work, it works by leaving the focus flag up for a long time. If your refresh scheduler defers on that flag, you have traded a lost edit for a screen that stops updating while a user stares at a form. That is the sticky-flag problem from the previous section, arriving through a side door.

Fix the model, not the event.

What this does not fix

A dirty flag protects the edit from your own refresh. It does nothing about a second person editing the same record.

The scenario is the mirror image of the one we started with. You raise the value to 84 and step away. Someone else extends the same record to 90 and saves. Your caption updates to 90, your input still reads 84, and if you come back and press Save without reading the caption, you overwrite their change with no warning. Before the dirty flag, the refresh would have quietly replaced your 84 and the conflict would never have existed, because your edit would already be gone.

That is not a regression so much as a trade. You moved from "the system silently discards your work" to "the system silently discards someone else's, if you ignore what is on screen." The second is better, because at least the information is visible, but it is not the same as being safe.

The real fix is a compare-and-set: capture the stored value when the edit begins, send it with the save, and let the server reject the write if the record moved. That is a change to the API contract, not a front-end tweak, so it deserves its own decision rather than being smuggled in. What is worth doing cheaply is deciding, in advance, which of the two failures you would rather have, and making the visible one visible enough that a careful person can catch it.

Two smaller traps in the same neighborhood

While wiring this up, two unrelated-looking things came from the same root of assuming the framework does more than it does.

Number inputs give you strings. <input type="number"> constrains what the user can enter. It does not change the type of the bound value. Several frameworks only cast when you ask them to, with an explicit modifier. So the value you send is "1", not 1, and a server that strictly parses integers rejects it. The irony in my case was sharp: "1" was on my own list of inputs the API should refuse, and the front end was the thing sending it.

Text-setting directives erase children. A directive that sets an element's text content replaces everything inside it on every evaluation. Put a conditional badge inside that element and it vanishes the first time the text is recomputed. Split the container: one child for the bound text, a sibling for the badge.

Neither is deep. Both cost a debugging round, because the symptom appears at runtime in a path you were not looking at.

Questions this usually raises

Does this apply outside a specific framework? Yes. The order of mousedown, blur, and click is defined by the browser, not by your view layer. Anything that clears an editing flag on blur inherits the problem.

What if I have several fields with buttons? One flag per field. A single shared flag means an unsaved edit in one place blocks syncing in another, and finding that later is unpleasant.

Is losing one edit worth this much machinery? The machinery is one boolean, one condition, and one cleanup function. The failure it prevents is silent: the value reverts, the save succeeds, and the confirmation message reports the old number as if it were the new one. The user has no signal that anything went wrong. Silent wrong is expensive to find later.

Should the button just save immediately instead? That is a real option and it removes the problem entirely. It is the wrong trade when the button sits next to other buttons with different effects, because a misclick becomes a committed change with an audit entry. Editing the field and requiring a separate confirm keeps a misclick recoverable.

How do I test it? Turn the flag off and watch the failure appear. If disabling the guard does not reproduce the loss, the guard is not the thing protecting you and something else is masking it. That check took me a minute and was worth more than the rest of the testing, because it proved the code was earning its place.

A useful second check is the refresh path you did not think about. Most live screens have more than one way to reload: the push handler, a polling fallback when the socket drops, and usually a "changes available, click to refresh" banner. In my case that banner called the reload function directly, skipping the scheduler entirely. Because the guard lives inside the sync rather than in the scheduler, it covered that path too. But I only learned that by clicking the banner while an edit was pending, not by reading the code. List every entry point into your reload function and try each one with a dirty field.

Does the guard belong in the component or in the store? Wherever the sync happens. The point of the pattern is that the field-level skip sits at the exact place where server data is written into form state. If that write lives in a store action, the flag goes there. Putting it in the component and hoping the store cooperates gives you two sources of truth about who owns the input.