Skip to content

Commit 7b6cca6

Browse files
Implement script tag loading from newly added child components
1 parent 4ec17cf commit 7b6cca6

File tree

2 files changed

+319
-0
lines changed

2 files changed

+319
-0
lines changed

src/django_unicorn/static/unicorn/js/component.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,54 @@ export class Component {
643643
Unicorn.deleteComponent(id);
644644
});
645645

646+
// Execute script tags in newly added child components.
647+
// Browsers do not execute scripts that are added to the DOM via morphdom's
648+
// internal diffing process (scripts created via innerHTML are not executed
649+
// when inserted into the document). Replacing each script element with a
650+
// freshly-created one forces the browser to fetch/run it, matching the
651+
// expected behaviour when a conditionally-rendered child component first
652+
// appears in the DOM.
653+
//
654+
// This step is skipped when RELOAD_SCRIPT_ELEMENTS is enabled on the
655+
// morpher, because morphdom's own onNodeAdded hook already handles script
656+
// execution in that case and running it here too would cause each script
657+
// to execute twice.
658+
if (!this.morpher.options?.RELOAD_SCRIPT_ELEMENTS) {
659+
const newComponentIds = [...componentIdsAfterMorph].filter(
660+
(id) => !componentIdsBeforeMorph.has(id)
661+
);
662+
663+
newComponentIds.forEach((id) => {
664+
const componentEl = targetDom.querySelector(`[unicorn\\:id="${id}"]`);
665+
666+
if (componentEl) {
667+
const ownerDocument = componentEl.ownerDocument;
668+
669+
componentEl.querySelectorAll("script").forEach((script) => {
670+
// Skip scripts that belong to a nested child component of this
671+
// component — those are handled when that nested component's own
672+
// id is processed in this loop.
673+
let ancestor = script.parentElement;
674+
while (ancestor && ancestor !== componentEl) {
675+
if (ancestor.hasAttribute("unicorn:id")) {
676+
return;
677+
}
678+
ancestor = ancestor.parentElement;
679+
}
680+
681+
const newScript = ownerDocument.createElement("script");
682+
683+
[...script.attributes].forEach((attr) => {
684+
newScript.setAttribute(attr.nodeName, attr.nodeValue);
685+
});
686+
687+
newScript.innerHTML = script.innerHTML;
688+
script.replaceWith(newScript);
689+
});
690+
}
691+
});
692+
}
693+
646694
// Populate Unicorn with new components
647695
findUnicorns().forEach((el) => {
648696
Unicorn.insertComponentFromDom(el);

tests/js/component/morph.test.js

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import test from "ava";
2+
import { getDocument, walkDOM } from "../utils.js";
3+
import { Component } from "../../../src/django_unicorn/static/unicorn/js/component.js";
4+
5+
// Provide a minimal global Unicorn stub so that Component.morph() can call
6+
// Unicorn.deleteComponent / Unicorn.insertComponentFromDom without errors.
7+
test.before(() => {
8+
global.Unicorn = {
9+
deleteComponent: () => {},
10+
insertComponentFromDom: () => {},
11+
};
12+
});
13+
14+
/**
15+
* Builds a Component instance suited for testing morph() behaviour.
16+
*
17+
* @param {string} html - HTML for the parent component root element.
18+
* @param {Function} morphFn - Function used as the morpher (receives targetDom and rerenderedHtml).
19+
* @param {Object} morpherOptions - Optional options forwarded to morpher.options (e.g. {RELOAD_SCRIPT_ELEMENTS: true}).
20+
*/
21+
function getMorphTestComponent(html, morphFn, morpherOptions) {
22+
const doc = getDocument(html);
23+
24+
const component = new Component({
25+
id: "parent-id",
26+
name: "parent",
27+
data: {},
28+
calls: [],
29+
document: doc,
30+
messageUrl: "test",
31+
walker: walkDOM,
32+
morpher: {
33+
options: morpherOptions || {},
34+
morph: morphFn || ((a) => a),
35+
},
36+
window: {
37+
document: { title: "" },
38+
history: { pushState: () => {} },
39+
location: { href: "" },
40+
addEventListener: () => {},
41+
},
42+
});
43+
44+
// Keep component.document in sync (mirrors what getComponent() in utils.js does)
45+
component.document = doc;
46+
47+
return component;
48+
}
49+
50+
/**
51+
* Returns a morpher mock that appends a single child component element to the
52+
* target DOM. The child element (and any script tags inside it) is created via
53+
* innerHTML, replicating the behaviour of morphdom which produces non-executing
54+
* script elements.
55+
*
56+
* @param {string} childId - The unicorn:id value for the new child.
57+
* @param {string} scriptSrc - Optional external script src to add.
58+
* @param {string} inlineCode - Optional inline script content.
59+
*/
60+
function makeChildAddingMorpher(childId, { scriptSrc, inlineCode } = {}) {
61+
return function mockMorph(targetDom) {
62+
const doc = targetDom.ownerDocument;
63+
64+
const childDiv = doc.createElement("div");
65+
childDiv.setAttribute("unicorn:id", childId);
66+
childDiv.setAttribute("unicorn:name", "child");
67+
childDiv.setAttribute("unicorn:meta", "XYZ");
68+
childDiv.setAttribute("unicorn:data", "{}");
69+
childDiv.setAttribute("unicorn:calls", "[]");
70+
71+
if (inlineCode) {
72+
// Create the script via innerHTML so that it is NOT auto-executed when
73+
// appended — this mimics the morphdom diffing behaviour.
74+
const temp = doc.createElement("div");
75+
temp.innerHTML = `<script>${inlineCode}</script>`;
76+
childDiv.appendChild(temp.firstChild);
77+
}
78+
79+
if (scriptSrc) {
80+
const temp = doc.createElement("div");
81+
temp.innerHTML = `<script src="${scriptSrc}"></script>`;
82+
childDiv.appendChild(temp.firstChild);
83+
}
84+
85+
targetDom.appendChild(childDiv);
86+
};
87+
}
88+
89+
// ---------------------------------------------------------------------------
90+
// Tests
91+
// ---------------------------------------------------------------------------
92+
93+
test("scripts in a newly added child component are replaced with fresh elements", (t) => {
94+
const html = `
95+
<div unicorn:id="parent-id" unicorn:name="parent" unicorn:meta="ABC"
96+
unicorn:data="{}" unicorn:calls="[]">
97+
<p>Parent content</p>
98+
</div>`;
99+
100+
const component = getMorphTestComponent(
101+
html,
102+
makeChildAddingMorpher("child-id", { inlineCode: "window.__ran = true;" })
103+
);
104+
105+
const doc = component.document;
106+
107+
// Spy: count how many times createElement("script") is called during morph.
108+
// Our fix creates one fresh script element per script found in new child
109+
// components, so the counter must be >= 1 after morph().
110+
let scriptsCreatedByFix = 0;
111+
const origCreate = doc.createElement.bind(doc);
112+
doc.createElement = (tag) => {
113+
if (tag.toLowerCase() === "script") {
114+
scriptsCreatedByFix += 1;
115+
}
116+
return origCreate(tag);
117+
};
118+
119+
component.morph(component.root, "<rerendered>");
120+
121+
t.is(scriptsCreatedByFix, 1, "One fresh script element should be created for the new child component's script tag");
122+
});
123+
124+
test("external script src in a newly added child component is preserved on the fresh element", (t) => {
125+
const html = `
126+
<div unicorn:id="parent-id" unicorn:name="parent" unicorn:meta="ABC"
127+
unicorn:data="{}" unicorn:calls="[]">
128+
</div>`;
129+
130+
const component = getMorphTestComponent(
131+
html,
132+
makeChildAddingMorpher("child-id", { scriptSrc: "https://example.com/lib.js" })
133+
);
134+
135+
component.morph(component.root, "<rerendered>");
136+
137+
// After morph the child's script element should exist with its src attribute
138+
const childEl = component.root.querySelector('[unicorn\\:id="child-id"]');
139+
t.truthy(childEl, "Child component element should be in the DOM");
140+
141+
const scriptEl = childEl && childEl.querySelector("script");
142+
t.truthy(scriptEl, "A script element should exist inside the new child component");
143+
t.is(
144+
scriptEl && scriptEl.getAttribute("src"),
145+
"https://example.com/lib.js",
146+
"The src attribute should be preserved on the fresh script element"
147+
);
148+
});
149+
150+
test("scripts in an already-existing child component are not re-executed on re-render", (t) => {
151+
// The child component is present in the initial HTML, so it should be in
152+
// componentIdsBeforeMorph and our fix must NOT touch its scripts.
153+
const html = `
154+
<div unicorn:id="parent-id" unicorn:name="parent" unicorn:meta="ABC"
155+
unicorn:data="{}" unicorn:calls="[]">
156+
<div unicorn:id="existing-child-id" unicorn:name="child" unicorn:meta="XYZ"
157+
unicorn:data="{}" unicorn:calls="[]">
158+
<script>window.__existing = true;</script>
159+
</div>
160+
</div>`;
161+
162+
// Morpher that does nothing (existing child remains unchanged)
163+
const component = getMorphTestComponent(html, () => {});
164+
165+
const doc = component.document;
166+
let scriptsCreatedByFix = 0;
167+
const origCreate = doc.createElement.bind(doc);
168+
doc.createElement = (tag) => {
169+
if (tag.toLowerCase() === "script") {
170+
scriptsCreatedByFix += 1;
171+
}
172+
return origCreate(tag);
173+
};
174+
175+
component.morph(component.root, "<rerendered>");
176+
177+
t.is(scriptsCreatedByFix, 0, "Scripts in a pre-existing child component must not be replaced");
178+
});
179+
180+
test("script replacement is skipped when RELOAD_SCRIPT_ELEMENTS is enabled on the morpher", (t) => {
181+
// When RELOAD_SCRIPT_ELEMENTS is true, morphdom's onNodeAdded hook already
182+
// replaces scripts — our code must not create duplicates.
183+
const html = `
184+
<div unicorn:id="parent-id" unicorn:name="parent" unicorn:meta="ABC"
185+
unicorn:data="{}" unicorn:calls="[]">
186+
</div>`;
187+
188+
const component = getMorphTestComponent(
189+
html,
190+
makeChildAddingMorpher("child-id", { inlineCode: "window.__ran = true;" }),
191+
{ RELOAD_SCRIPT_ELEMENTS: true }
192+
);
193+
194+
const doc = component.document;
195+
let scriptsCreatedByFix = 0;
196+
const origCreate = doc.createElement.bind(doc);
197+
doc.createElement = (tag) => {
198+
if (tag.toLowerCase() === "script") {
199+
scriptsCreatedByFix += 1;
200+
}
201+
return origCreate(tag);
202+
};
203+
204+
component.morph(component.root, "<rerendered>");
205+
206+
t.is(
207+
scriptsCreatedByFix,
208+
0,
209+
"Our fix should not run when RELOAD_SCRIPT_ELEMENTS is enabled to avoid double execution"
210+
);
211+
});
212+
213+
test("scripts in a deeply nested new child component are executed exactly once", (t) => {
214+
// Scenario: parent → new child A → new child B (nested)
215+
// Child B's scripts must be replaced exactly once (when processing B),
216+
// not again when processing A (the ancestor-check must skip B's scripts
217+
// when iterating over A).
218+
const html = `
219+
<div unicorn:id="parent-id" unicorn:name="parent" unicorn:meta="ABC"
220+
unicorn:data="{}" unicorn:calls="[]">
221+
</div>`;
222+
223+
// Morpher that adds a child A which itself contains a child B with a script
224+
function nestedMorph(targetDom) {
225+
const doc = targetDom.ownerDocument;
226+
227+
const childA = doc.createElement("div");
228+
childA.setAttribute("unicorn:id", "child-a");
229+
childA.setAttribute("unicorn:name", "child-a");
230+
childA.setAttribute("unicorn:meta", "A");
231+
childA.setAttribute("unicorn:data", "{}");
232+
childA.setAttribute("unicorn:calls", "[]");
233+
234+
// Own script for A
235+
const tempA = doc.createElement("div");
236+
tempA.innerHTML = "<script>window.__a = true;</script>";
237+
childA.appendChild(tempA.firstChild);
238+
239+
// Nested child B with its own script
240+
const childB = doc.createElement("div");
241+
childB.setAttribute("unicorn:id", "child-b");
242+
childB.setAttribute("unicorn:name", "child-b");
243+
childB.setAttribute("unicorn:meta", "B");
244+
childB.setAttribute("unicorn:data", "{}");
245+
childB.setAttribute("unicorn:calls", "[]");
246+
247+
const tempB = doc.createElement("div");
248+
tempB.innerHTML = "<script>window.__b = true;</script>";
249+
childB.appendChild(tempB.firstChild);
250+
251+
childA.appendChild(childB);
252+
targetDom.appendChild(childA);
253+
}
254+
255+
const component = getMorphTestComponent(html, nestedMorph);
256+
257+
const doc = component.document;
258+
let scriptsCreatedByFix = 0;
259+
const origCreate = doc.createElement.bind(doc);
260+
doc.createElement = (tag) => {
261+
if (tag.toLowerCase() === "script") {
262+
scriptsCreatedByFix += 1;
263+
}
264+
return origCreate(tag);
265+
};
266+
267+
component.morph(component.root, "<rerendered>");
268+
269+
// 2 new child components (A and B), each with 1 script → exactly 2 replacements
270+
t.is(scriptsCreatedByFix, 2, "Each new child component's script should be replaced exactly once");
271+
});

0 commit comments

Comments
 (0)