Skip to content
Open
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
7 changes: 7 additions & 0 deletions assets/js/phoenix_live_view/dom_patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ export default class DOMPatch {
if (isJoinPatch) {
return node.id;
}

// If ID was touched by JavaScript hook, use PHX_MAGIC_ID for matching.
// This ensures morphdom can match elements even when JS modifies their IDs.
if (DOM.private(node, "clientsideIdAttribute")) {
return node.getAttribute && node.getAttribute(PHX_MAGIC_ID);
}

return (
node.id || (node.getAttribute && node.getAttribute(PHX_MAGIC_ID))
);
Expand Down
5 changes: 5 additions & 0 deletions assets/js/phoenix_live_view/js.js
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,11 @@ const JS = {
.filter((attr) => !alteredAttrs.includes(attr))
.concat(removes);

// If element ID is touched via JavaScript, mark it for cheap lookup during morphdom
if (sets.some(([attr, _val]) => attr === "id")) {
DOM.putPrivate(el, "clientsideIdAttribute", true);
}

DOM.putSticky(el, "attrs", (currentEl) => {
newRemoves.forEach((attr) => currentEl.removeAttribute(attr));
newSets.forEach(([attr, val]) => currentEl.setAttribute(attr, val));
Expand Down
50 changes: 50 additions & 0 deletions assets/test/phx_skip_js_id_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Socket } from "phoenix";
import LiveSocket from "phoenix_live_view/live_socket";
import ViewHook from "phoenix_live_view/view_hook";
import { simulateJoinedView, liveViewDOM } from "./test_helpers";

describe("phx-skip with JavaScript-set DOM IDs", () => {
let liveSocket: LiveSocket | null;

beforeEach(() => {
global.Phoenix = { Socket };
global.document.body.innerHTML = "";
});

afterEach(() => {
liveSocket && liveSocket.destroyAllViews();
liveSocket = null;
});

test("preserves element when JS sets ID and backend sends phx-skip", () => {
liveSocket = new LiveSocket("/live", Socket);
const initialContent = `
<span data-phx-magic-id="span-magic">content</span>
`;

const el = liveViewDOM(initialContent);
document.body.appendChild(el);

const view = simulateJoinedView(el, liveSocket);
const targetEl = el.querySelector('[data-phx-magic-id="span-magic"]');

const hook = new ViewHook(view, targetEl as HTMLElement, {});
hook.js().setAttribute(targetEl as HTMLElement, "id", "js-set-id");

const updateDiff = {
s: [
`
<span data-phx-skip data-phx-magic-id="span-magic">content</span>
`,
],
fingerprint: 124,
};

view.update(updateDiff, []);

const afterUpdate = el.querySelector("#js-set-id");
expect(afterUpdate).not.toBeNull();
expect(afterUpdate!.id).toBe("js-set-id");
expect(afterUpdate!.textContent).toBe("content");
});
});
16 changes: 16 additions & 0 deletions guides/client/js-interop.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,3 +481,19 @@ The command interface returned by `js()` above offers the following functions:
- `patch(href, opts = {})` - sends a patch event to the server and updates the browser's pushState history. Options: `replace`. For more details, see `Phoenix.LiveView.JS.patch/1`.
- `exec(encodedJS)` - *only via Client hook `this.js()`*: executes encoded JS command in the context of the hook's root node. The encoded JS command should be constructed via `Phoenix.LiveView.JS` and is usually stored as an HTML attribute. Example: `this.js().exec(this.el.getAttribute('phx-remove'))`.
- `exec(el, encodedJS)` - *only via `liveSocket.js()`*: executes encoded JS command in the context of any element.

### Client-side ID manipulation

If you need to set element IDs from client-side JavaScript (for example, to auto-generate IDs for accessibility), you **must** use the `js().setAttribute()` method:

```javascript
Hooks.MyHook = {
mounted() {
this.js().setAttribute(this.el, "id", "my-generated-id")
}
}
```

Setting IDs directly via `node.id = "..."` or other direct DOM manipulation methods will cause DOM patching issues. Always use `js().setAttribute()` instead.

If the server has already assigned an ID to an element, you cannot replace it with a different ID from the client side. Client-side IDs should only be set on elements that have no server-assigned ID.
Loading