Skip to content

Commit cf53290

Browse files
authored
complain when trying to bind an element to a second view (#3805)
This could happen when a project's app.js is loaded twice. See #3721. See https://elixirforum.com/t/how-do-you-debug-state-changes-using-liveviews/70044/21.
1 parent 05e3635 commit cf53290

File tree

4 files changed

+166
-49
lines changed

4 files changed

+166
-49
lines changed

assets/js/phoenix_live_view/view.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,23 @@ export default class View {
167167
this.parent = parentView;
168168
this.root = parentView ? parentView.root : this;
169169
this.el = el;
170+
// see https://github.com/phoenixframework/phoenix_live_view/pull/3721
171+
// check if the element is already bound to a view
172+
const boundView = DOM.private(this.el, "view");
173+
if (boundView !== undefined && boundView.isDead !== true) {
174+
logError(
175+
`The DOM element for this view has already been bound to a view.
176+
177+
An element can only ever be associated with a single view!
178+
Please ensure that you are not trying to initialize multiple LiveSockets on the same page.
179+
This could happen if you're accidentally trying to render your root layout more than once.
180+
Ensure that the template set on the LiveView is different than the root layout.
181+
`,
182+
{ view: boundView },
183+
);
184+
throw new Error("Cannot bind multiple views to the same DOM element.");
185+
}
186+
// bind the view to the element
170187
DOM.putPrivate(this.el, "view", this);
171188
this.id = this.el.id;
172189
this.ref = 0;
@@ -252,6 +269,7 @@ export default class View {
252269
destroy(callback = function () {}) {
253270
this.destroyAllChildren();
254271
this.destroyed = true;
272+
DOM.deletePrivate(this.el, "view");
255273
delete this.root.children[this.id];
256274
if (this.parent) {
257275
delete this.root.children[this.parent.id][this.id];

assets/test/live_socket_test.ts

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,23 @@ const prepareLiveViewDOM = (document) => {
2626
};
2727

2828
describe("LiveSocket", () => {
29+
let liveSocket;
30+
2931
beforeEach(() => {
3032
prepareLiveViewDOM(global.document);
3133
});
3234

35+
afterEach(() => {
36+
liveSocket && liveSocket.destroyAllViews();
37+
liveSocket = null;
38+
});
39+
3340
afterAll(() => {
3441
global.document.body.innerHTML = "";
3542
});
3643

3744
test("sets defaults", async () => {
38-
const liveSocket = new LiveSocket("/live", Socket);
45+
liveSocket = new LiveSocket("/live", Socket);
3946
expect(liveSocket.socket).toBeDefined();
4047
expect(liveSocket.socket.onOpen).toBeDefined();
4148
expect(liveSocket.viewLogger).toBeUndefined();
@@ -45,7 +52,7 @@ describe("LiveSocket", () => {
4552
});
4653

4754
test("sets defaults with socket", async () => {
48-
const liveSocket = new LiveSocket(new Socket("//example.org/chat"), Socket);
55+
liveSocket = new LiveSocket(new Socket("//example.org/chat"), Socket);
4956
expect(liveSocket.socket).toBeDefined();
5057
expect(liveSocket.socket.onOpen).toBeDefined();
5158
expect(liveSocket.unloaded).toBe(false);
@@ -54,27 +61,28 @@ describe("LiveSocket", () => {
5461
});
5562

5663
test("viewLogger", async () => {
57-
const viewLogger = (view, kind, msg, obj) => {
58-
expect(view.id).toBe("container1");
59-
expect(kind).toBe("updated");
60-
expect(msg).toBe("");
61-
expect(obj).toBe('"<div>"');
62-
};
63-
const liveSocket = new LiveSocket("/live", Socket, { viewLogger });
64+
const viewLogger = jest.fn();
65+
liveSocket = new LiveSocket("/live", Socket, { viewLogger });
6466
expect(liveSocket.viewLogger).toBe(viewLogger);
6567
liveSocket.connect();
6668
const view = liveSocket.getViewByEl(container(1));
6769
liveSocket.log(view, "updated", () => ["", JSON.stringify("<div>")]);
70+
expect(viewLogger).toHaveBeenCalledWith(
71+
view,
72+
"updated",
73+
"",
74+
JSON.stringify("<div>"),
75+
);
6876
});
6977

7078
test("connect", async () => {
71-
const liveSocket = new LiveSocket("/live", Socket);
79+
liveSocket = new LiveSocket("/live", Socket);
7280
const _socket = liveSocket.connect();
7381
expect(liveSocket.getViewByEl(container(1))).toBeDefined();
7482
});
7583

7684
test("disconnect", async () => {
77-
const liveSocket = new LiveSocket("/live", Socket);
85+
liveSocket = new LiveSocket("/live", Socket);
7886

7987
liveSocket.connect();
8088
liveSocket.disconnect();
@@ -83,7 +91,7 @@ describe("LiveSocket", () => {
8391
});
8492

8593
test("channel", async () => {
86-
const liveSocket = new LiveSocket("/live", Socket);
94+
liveSocket = new LiveSocket("/live", Socket);
8795

8896
liveSocket.connect();
8997
const channel = liveSocket.channel("lv:def456", function () {
@@ -94,7 +102,7 @@ describe("LiveSocket", () => {
94102
});
95103

96104
test("getViewByEl", async () => {
97-
const liveSocket = new LiveSocket("/live", Socket);
105+
liveSocket = new LiveSocket("/live", Socket);
98106

99107
liveSocket.connect();
100108

@@ -113,7 +121,7 @@ describe("LiveSocket", () => {
113121
`;
114122
document.body.appendChild(secondLiveView);
115123

116-
const liveSocket = new LiveSocket("/live", Socket);
124+
liveSocket = new LiveSocket("/live", Socket);
117125
liveSocket.connect();
118126

119127
const el = container(1);
@@ -129,27 +137,27 @@ describe("LiveSocket", () => {
129137
});
130138

131139
test("binding", async () => {
132-
const liveSocket = new LiveSocket("/live", Socket);
140+
liveSocket = new LiveSocket("/live", Socket);
133141

134142
expect(liveSocket.binding("value")).toBe("phx-value");
135143
});
136144

137145
test("getBindingPrefix", async () => {
138-
const liveSocket = new LiveSocket("/live", Socket);
146+
liveSocket = new LiveSocket("/live", Socket);
139147

140148
expect(liveSocket.getBindingPrefix()).toEqual("phx-");
141149
});
142150

143151
test("getBindingPrefix custom", async () => {
144-
const liveSocket = new LiveSocket("/live", Socket, {
152+
liveSocket = new LiveSocket("/live", Socket, {
145153
bindingPrefix: "company-",
146154
});
147155

148156
expect(liveSocket.getBindingPrefix()).toEqual("company-");
149157
});
150158

151159
test("owner", async () => {
152-
const liveSocket = new LiveSocket("/live", Socket);
160+
liveSocket = new LiveSocket("/live", Socket);
153161
liveSocket.connect();
154162

155163
const _view = liveSocket.getViewByEl(container(1));
@@ -161,7 +169,7 @@ describe("LiveSocket", () => {
161169
});
162170

163171
test("getActiveElement default before LiveSocket activeElement is set", async () => {
164-
const liveSocket = new LiveSocket("/live", Socket);
172+
liveSocket = new LiveSocket("/live", Socket);
165173

166174
const input = document.querySelector("input");
167175
input.focus();
@@ -170,7 +178,7 @@ describe("LiveSocket", () => {
170178
});
171179

172180
test("blurActiveElement", async () => {
173-
const liveSocket = new LiveSocket("/live", Socket);
181+
liveSocket = new LiveSocket("/live", Socket);
174182

175183
const input = document.querySelector("input");
176184
input.focus();
@@ -184,7 +192,7 @@ describe("LiveSocket", () => {
184192
});
185193

186194
test("restorePreviouslyActiveFocus", async () => {
187-
const liveSocket = new LiveSocket("/live", Socket);
195+
liveSocket = new LiveSocket("/live", Socket);
188196

189197
const input = document.querySelector("input");
190198
input.focus();
@@ -201,7 +209,7 @@ describe("LiveSocket", () => {
201209
});
202210

203211
test("dropActiveElement unsets prevActive", async () => {
204-
const liveSocket = new LiveSocket("/live", Socket);
212+
liveSocket = new LiveSocket("/live", Socket);
205213

206214
liveSocket.connect();
207215

@@ -225,7 +233,7 @@ describe("LiveSocket", () => {
225233
},
226234
};
227235

228-
const liveSocket = new LiveSocket("/live", Socket, {
236+
liveSocket = new LiveSocket("/live", Socket, {
229237
sessionStorage: override,
230238
});
231239
liveSocket.getLatencySim();
@@ -252,6 +260,8 @@ describe("liveSocket.js()", () => {
252260
});
253261

254262
afterEach(() => {
263+
liveSocket && liveSocket.destroyAllViews();
264+
liveSocket = null;
255265
jest.useRealTimers();
256266
});
257267

assets/test/test_helpers.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ export const stubChannel = (view) => {
6666
},
6767
};
6868
view.channel.push = () => fakePush;
69+
view.channel.leave = () => ({
70+
receive(kind, cb) {
71+
if (kind === "ok") {
72+
cb();
73+
}
74+
return this;
75+
},
76+
});
6977
};
7078

7179
export function liveViewDOM(content?: string) {

0 commit comments

Comments
 (0)