Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions dom/focus-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,26 @@ export class FocusManager {
return false;
}

shouldSkipUpdate(el: Element): boolean {
if (el !== document.activeElement) {
return false;
}

if (el.hasAttribute("data-lvt-force-update")) {
return false;
}

if (this.isTextualInput(el)) {
return true;
}

if (el instanceof HTMLSelectElement) {
return true;
}

return false;
}

getLastFocusedElement(): HTMLElement | null {
return this.lastFocusedElement;
}
Expand Down
25 changes: 18 additions & 7 deletions livetemplate-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,13 +734,11 @@ export class LiveTemplateClient {
}
},
onBeforeElUpdated: (fromEl, toEl) => {
// Preserve value for the last focused textual input
const lastFocused = this.focusManager.getLastFocusedElement();
if (lastFocused && this.focusManager.isTextualInput(fromEl)) {
if (fromEl === lastFocused) {
// Preserve the current value being typed
(toEl as any).value = (fromEl as any).value;
}
// Skip update entirely for focused form elements to preserve user
// input. This also skips attribute updates (class, disabled, aria-*)
// and the lvt-updated hook — use data-lvt-force-update to override.
if (this.focusManager.shouldSkipUpdate(fromEl)) {
return false;
}

// Only update if content actually changed
Expand All @@ -751,7 +749,20 @@ export class LiveTemplateClient {
this.executeLifecycleHook(fromEl, "lvt-updated");
return true;
},
onElUpdated: (el) => {
// Textarea-specific: morphdom patches child text nodes but browsers
// ignore textContent changes to "dirty" textareas (ones the user
// has typed in), so we explicitly set .value. Inputs don't need
// this — morphdom sets .value directly for input elements.
if (el instanceof HTMLTextAreaElement) {
el.value = el.textContent ?? "";
}
},
onNodeAdded: (node) => {
// Sync textarea value for newly inserted textarea elements
if (node instanceof HTMLTextAreaElement) {
node.value = node.textContent ?? "";
}
// Execute lvt-mounted lifecycle hook
if (node.nodeType === Node.ELEMENT_NODE) {
this.executeLifecycleHook(node as Element, "lvt-mounted");
Expand Down
100 changes: 100 additions & 0 deletions tests/focus-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { FocusManager } from "../dom/focus-manager";
import { FOCUSABLE_INPUTS } from "../constants";
import { createLogger } from "../utils/logger";

function createManager(): FocusManager {
return new FocusManager(
createLogger({ scope: "FocusManagerTest", level: "silent" })
);
}

describe("FocusManager", () => {
beforeEach(() => {
document.body.innerHTML = "";
Expand Down Expand Up @@ -58,4 +65,97 @@ describe("FocusManager", () => {
expect(() => manager.restoreFocusedElement()).not.toThrow();
expect(document.activeElement).not.toBe(input);
});

describe("shouldSkipUpdate", () => {
let manager: FocusManager;

beforeEach(() => {
manager = createManager();
});

it("returns true for focused text input", () => {
const input = document.createElement("input");
input.type = "text";
document.body.appendChild(input);
input.focus();

expect(manager.shouldSkipUpdate(input)).toBe(true);
});

it("returns true for focused textarea", () => {
const textarea = document.createElement("textarea");
document.body.appendChild(textarea);
textarea.focus();

expect(manager.shouldSkipUpdate(textarea)).toBe(true);
});
Comment on lines +76 to +91

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shouldSkipUpdate tests mark elements as “focused” by mutating the private lastFocusedElement, but they don’t actually focus/blur elements in the DOM. This won’t catch cases where shouldSkipUpdate should depend on real focus state (e.g., document.activeElement / :focus) or ensure updates resume after blur. Consider adding tests that call .focus() / dispatch focus+blur events and assert the method’s behavior across focus transitions.

Copilot uses AI. Check for mistakes.

it("returns true for focused select", () => {
const select = document.createElement("select");
document.body.appendChild(select);
select.focus();

expect(manager.shouldSkipUpdate(select)).toBe(true);
});

it("returns false for non-focused element", () => {
const input1 = document.createElement("input");
input1.type = "text";
const input2 = document.createElement("input");
input2.type = "text";
document.body.appendChild(input1);
document.body.appendChild(input2);
input1.focus();

expect(manager.shouldSkipUpdate(input2)).toBe(false);
});

it("returns false when no element is focused", () => {
const input = document.createElement("input");
input.type = "text";
document.body.appendChild(input);

expect(manager.shouldSkipUpdate(input)).toBe(false);
});

it("returns false for focused element with data-lvt-force-update", () => {
const input = document.createElement("input");
input.type = "text";
input.setAttribute("data-lvt-force-update", "");
document.body.appendChild(input);
input.focus();

expect(manager.shouldSkipUpdate(input)).toBe(false);
});

it("returns false for focused button", () => {
const button = document.createElement("button");
document.body.appendChild(button);
button.focus();

expect(manager.shouldSkipUpdate(button)).toBe(false);
});

it("returns false after element is blurred", () => {
const input = document.createElement("input");
input.type = "text";
document.body.appendChild(input);
input.focus();
expect(manager.shouldSkipUpdate(input)).toBe(true);

input.blur();
expect(manager.shouldSkipUpdate(input)).toBe(false);
});

it.each(
FOCUSABLE_INPUTS.filter((t) => t !== "textarea").map((type) => [type])
)("returns true for focused input[type=%s]", (type) => {
const input = document.createElement("input");
input.type = type;
document.body.appendChild(input);
input.focus();

expect(manager.shouldSkipUpdate(input)).toBe(true);
});
});
});
Loading