Skip to content

Commit 98488f7

Browse files
committed
feature #1255 Migrate LiveComponent to Idiomorph (matheo, WebMamba)
This PR was merged into the 2.x branch. Discussion ---------- Migrate LiveComponent to Idiomorph | Q | A | ------------- | --- | Bug fix? | no | New feature? | no | Issues | | License | MIT This PR migrate the LiveComponent morph system from `morphdom` to `idiomorph`. I still have 4 tests that are not passing, and I am not sure how to solve it: 1 - `test/controller/child.test.ts > Component parent -> child initialization and rendering tests > replaces old child with new child if the "id" changes` In `idiomorph` they don't use the id the same way `morphdom` does. So this is the expected behavior, I am not sure if we should replicate the old behavior or not? 2 - `test/controller/child.test.ts > Component parent -> child initialization and rendering tests > tracks various children correctly, even if position changes` I think the issue comes from the behavior explained here: https://github.com/bigskysoftware/idiomorph#example-morph. `Idiomorph` is more clever than `morphdom`, and doesn't need to morph the child on every parent morph. 3 - `test/controller/render-with-external-changes.test.ts > LiveController rendering with external changes tests > will not remove an added element` Looks like a similar issue to the problem above.` Idiomorph` doesn't need to morph every child, so the child is not added to the `ExternalMutationTracker`? 4 - `test/controller/render-with-external-changes.test.ts > LiveController rendering with external changes tests > keeps external changes across multiple renders` ... I'm still digging, but ideas are welcome! 😁 Commits ------- d5fc3c3 add missing build files 317d390 rebuild other ux packages 0df4faa resolve prittier errors a9143ab resolve yarn errors e5a187c fix last bugs 3bce29e Migrate LiveComponent to Idiomorph
2 parents 8fe391d + d5fc3c3 commit 98488f7

File tree

9 files changed

+916
-857
lines changed

9 files changed

+916
-857
lines changed

src/Autocomplete/assets/dist/controller.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,19 @@ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
1515
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
1616
PERFORMANCE OF THIS SOFTWARE.
1717
***************************************************************************** */
18+
/* global Reflect, Promise, SuppressedError, Symbol */
19+
1820

1921
function __classPrivateFieldGet(receiver, state, kind, f) {
2022
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
2123
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
2224
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
23-
}
25+
}
26+
27+
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
28+
var e = new Error(message);
29+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
30+
};
2431

2532
var _default_1_instances, _default_1_getCommonConfig, _default_1_createAutocomplete, _default_1_createAutocompleteWithHtmlContents, _default_1_createAutocompleteWithRemoteData, _default_1_stripTags, _default_1_mergeObjects, _default_1_createTomSelect;
2633
class default_1 extends Controller {

src/LiveComponent/assets/dist/Component/index.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export default class Component {
5353
emitSelf(name: string, data: any): void;
5454
private performEmit;
5555
private doEmit;
56-
updateFromNewElementFromParentRender(toEl: HTMLElement): void;
56+
updateFromNewElementFromParentRender(toEl: HTMLElement): boolean;
5757
onChildComponentModelUpdate(modelName: string, value: any, childComponent: Component): void;
5858
private isTurboEnabled;
5959
private tryStartingRequest;

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 792 additions & 766 deletions
Large diffs are not rendered by default.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'idiomorph';
12
import Component from './Component';
23
import ExternalMutationTracker from './Rendering/ExternalMutationTracker';
34
export declare function executeMorphdom(rootFromElement: HTMLElement, rootToElement: HTMLElement, modifiedFieldElements: Array<HTMLElement>, getElementValue: (element: HTMLElement) => any, childComponents: Component[], findChildComponent: (id: string, element: HTMLElement) => HTMLElement | null, getKeyFromElement: (element: HTMLElement) => string | null, externalMutationTracker: ExternalMutationTracker): void;

src/LiveComponent/assets/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
}
2727
},
2828
"dependencies": {
29-
"morphdom": "^2.6.1"
29+
"idiomorph": "^0.0.9"
3030
},
3131
"peerDependencies": {
3232
"@hotwired/stimulus": "^3.0.0"

src/LiveComponent/assets/src/Component/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,13 +289,13 @@ export default class Component {
289289
*
290290
* @param toEl
291291
*/
292-
updateFromNewElementFromParentRender(toEl: HTMLElement): void {
292+
updateFromNewElementFromParentRender(toEl: HTMLElement): boolean {
293293
const props = this.elementDriver.getComponentProps(toEl);
294294

295295
// if no props are on the element, use the existing element completely
296296
// this means the parent is signaling that the child does not need to be re-rendered
297297
if (props === null) {
298-
return;
298+
return false;
299299
}
300300

301301
// push props directly down onto the value store
@@ -309,6 +309,8 @@ export default class Component {
309309
if (isChanged) {
310310
this.render();
311311
}
312+
313+
return isChanged;
312314
}
313315

314316
/**
Lines changed: 103 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { cloneHTMLElement, setValueOnElement } from './dom_utils';
2-
import morphdom from 'morphdom';
2+
import 'idiomorph';
33
import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison';
44
import Component from './Component';
55
import ExternalMutationTracker from './Rendering/ExternalMutationTracker';
@@ -19,103 +19,126 @@ export function executeMorphdom(
1919
childComponentMap.set(childComponent.element, childComponent);
2020
});
2121

22-
morphdom(rootFromElement, rootToElement, {
23-
getNodeKey: (node: Node) => {
24-
if (!(node instanceof HTMLElement)) {
25-
return;
26-
}
27-
28-
// Pretend an added element has a unique id so that morphdom treats
29-
// it like a unique element, causing it to always attempt to remove
30-
// it (which we can then prevent) instead of potentially updating
31-
// it from an element that was added by the server in the same location.
32-
if (externalMutationTracker.wasElementAdded(node)) {
33-
return 'added_element_' + Math.random();
34-
}
35-
36-
return getKeyFromElement(node);
37-
},
38-
onBeforeElUpdated: (fromEl: Element, toEl: Element) => {
39-
if (fromEl === rootFromElement) {
40-
return true;
41-
}
42-
43-
// skip special checking if this is, for example, an SVG
44-
if (fromEl instanceof HTMLElement && toEl instanceof HTMLElement) {
45-
// We assume fromEl is an Alpine component if it has `__x` property.
46-
// If it's the case, then we should morph `fromEl` to `ToEl` (thanks to https://alpinejs.dev/plugins/morph)
47-
// in order to keep the component state and UI in sync.
48-
if (typeof fromEl.__x !== 'undefined') {
49-
if (!window.Alpine) {
50-
throw new Error(
51-
'Unable to access Alpine.js though the global window.Alpine variable. Please make sure Alpine.js is loaded before Symfony UX LiveComponent.'
52-
);
22+
Idiomorph.morph(rootFromElement, rootToElement, {
23+
callbacks: {
24+
beforeNodeMorphed: (fromEl: Element, toEl: Element) => {
25+
// Idiomorph loop also over Text node
26+
if (!(fromEl instanceof Element) || !(toEl instanceof Element)) {
27+
return true;
28+
}
29+
30+
if (fromEl === rootFromElement) {
31+
return true;
32+
}
33+
34+
let idChanged = false;
35+
// Track children if data-live-id changed
36+
if (fromEl.hasAttribute('data-live-id')) {
37+
if (fromEl.getAttribute('data-live-id') !== toEl.getAttribute('data-live-id')) {
38+
for (const child of fromEl.children) {
39+
child.setAttribute('parent-live-id-changed', '');
40+
}
41+
idChanged = true;
5342
}
43+
}
5444

55-
if (typeof window.Alpine.morph !== 'function') {
56-
throw new Error(
57-
'Unable to access Alpine.js morph function. Please make sure the Alpine.js Morph plugin is installed and loaded, see https://alpinejs.dev/plugins/morph for more information.'
58-
);
45+
// skip special checking if this is, for example, an SVG
46+
if (fromEl instanceof HTMLElement && toEl instanceof HTMLElement) {
47+
// We assume fromEl is an Alpine component if it has `__x` property.
48+
// If it's the case, then we should morph `fromEl` to `ToEl` (thanks to https://alpinejs.dev/plugins/morph)
49+
// in order to keep the component state and UI in sync.
50+
if (typeof fromEl.__x !== 'undefined') {
51+
if (!window.Alpine) {
52+
throw new Error(
53+
'Unable to access Alpine.js though the global window.Alpine variable. Please make sure Alpine.js is loaded before Symfony UX LiveComponent.'
54+
);
55+
}
56+
57+
if (typeof window.Alpine.morph !== 'function') {
58+
throw new Error(
59+
'Unable to access Alpine.js morph function. Please make sure the Alpine.js Morph plugin is installed and loaded, see https://alpinejs.dev/plugins/morph for more information.'
60+
);
61+
}
62+
63+
window.Alpine.morph(fromEl.__x, toEl);
5964
}
6065

61-
window.Alpine.morph(fromEl.__x, toEl);
62-
}
66+
if (childComponentMap.has(fromEl)) {
67+
const childComponent = childComponentMap.get(fromEl) as Component;
6368

64-
if (childComponentMap.has(fromEl)) {
65-
const childComponent = childComponentMap.get(fromEl) as Component;
69+
return !childComponent.updateFromNewElementFromParentRender(toEl) && idChanged;
70+
}
6671

67-
childComponent.updateFromNewElementFromParentRender(toEl);
72+
if (externalMutationTracker.wasElementAdded(fromEl)) {
73+
fromEl.insertAdjacentElement('afterend', toEl);
74+
return false;
75+
}
6876

69-
return false;
70-
}
77+
// if this field's value has been modified since this HTML was
78+
// requested, set the toEl's value to match the fromEl
79+
if (modifiedFieldElements.includes(fromEl)) {
80+
setValueOnElement(toEl, getElementValue(fromEl));
81+
}
7182

72-
// if this field's value has been modified since this HTML was
73-
// requested, set the toEl's value to match the fromEl
74-
if (modifiedFieldElements.includes(fromEl)) {
75-
setValueOnElement(toEl, getElementValue(fromEl));
83+
// handle any external changes to this element
84+
const elementChanges = externalMutationTracker.getChangedElement(fromEl);
85+
if (elementChanges) {
86+
// apply the changes to the "to" element so it looks like the
87+
// external changes were already part of it
88+
elementChanges.applyToElement(toEl);
89+
}
90+
91+
// https://github.com/patrick-steele-idem/morphdom#can-i-make-morphdom-blaze-through-the-dom-tree-even-faster-yes
92+
if (fromEl.nodeName.toUpperCase() !== 'OPTION' && fromEl.isEqualNode(toEl)) {
93+
// the nodes are equal, but the "value" on some might differ
94+
// lets try to quickly compare a bit more deeply
95+
const normalizedFromEl = cloneHTMLElement(fromEl);
96+
normalizeAttributesForComparison(normalizedFromEl);
97+
98+
const normalizedToEl = cloneHTMLElement(toEl);
99+
normalizeAttributesForComparison(normalizedToEl);
100+
101+
if (normalizedFromEl.isEqualNode(normalizedToEl)) {
102+
// don't bother updating
103+
return false;
104+
}
105+
}
76106
}
77107

78-
// handle any external changes to this element
79-
const elementChanges = externalMutationTracker.getChangedElement(fromEl);
80-
if (elementChanges) {
81-
// apply the changes to the "to" element so it looks like the
82-
// external changes were already part of it
83-
elementChanges.applyToElement(toEl);
108+
// Update child if parent has his live-id changed
109+
if (fromEl.hasAttribute('parent-live-id-changed')) {
110+
fromEl.removeAttribute('parent-live-id-changed');
111+
112+
return true;
84113
}
85114

86-
// https://github.com/patrick-steele-idem/morphdom#can-i-make-morphdom-blaze-through-the-dom-tree-even-faster-yes
87-
if (fromEl.nodeName.toUpperCase() !== 'OPTION' && fromEl.isEqualNode(toEl)) {
88-
// the nodes are equal, but the "value" on some might differ
89-
// lets try to quickly compare a bit more deeply
90-
const normalizedFromEl = cloneHTMLElement(fromEl);
91-
normalizeAttributesForComparison(normalizedFromEl);
115+
// look for data-live-ignore, and don't update
116+
return !fromEl.hasAttribute('data-live-ignore');
117+
},
92118

93-
const normalizedToEl = cloneHTMLElement(toEl);
94-
normalizeAttributesForComparison(normalizedToEl);
119+
beforeNodeRemoved(node) {
120+
if (!(node instanceof HTMLElement)) {
121+
// text element
122+
return true;
123+
}
95124

96-
if (normalizedFromEl.isEqualNode(normalizedToEl)) {
97-
// don't bother updating
98-
return false;
99-
}
125+
if (externalMutationTracker.wasElementAdded(node)) {
126+
// this element was added by an external mutation, so we don't want to discard it
127+
return false;
100128
}
101-
}
102129

103-
// look for data-live-ignore, and don't update
104-
return !fromEl.hasAttribute('data-live-ignore');
130+
return !node.hasAttribute('data-live-ignore');
131+
},
105132
},
133+
});
106134

107-
onBeforeNodeDiscarded(node) {
108-
if (!(node instanceof HTMLElement)) {
109-
// text element
110-
return true;
111-
}
112-
113-
if (externalMutationTracker.wasElementAdded(node)) {
114-
// this element was added by an external mutation, so we don't want to discard it
115-
return false;
116-
}
135+
childComponentMap.forEach((childComponent, element) => {
136+
const childComponentInResult = findChildComponent(childComponent.id ?? '', rootFromElement);
137+
if (null === childComponentInResult || element === childComponentInResult) {
138+
return;
139+
}
117140

118-
return !node.hasAttribute('data-live-ignore');
119-
},
141+
childComponentInResult?.replaceWith(element);
142+
childComponent.updateFromNewElementFromParentRender(childComponentInResult);
120143
});
121144
}

src/LiveComponent/assets/test/controller/render.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ describe('LiveController rendering Tests', () => {
437437
const selectOption2 = test.element.querySelector('#select_option_2') as HTMLSelectElement;
438438

439439
// verify the placeholder of the select option 2 is selected
440-
expect(selectOption2.children[0].hasAttribute('selected')).toBe(true);
440+
expect(selectOption2.children[0].selected).toBe(true);
441441

442442
// verify the selectedIndex of the select option 2 is 0
443443
expect(selectOption2.selectedIndex).toBe(0);

yarn.lock

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5437,6 +5437,11 @@ icss-utils@^5.0.0:
54375437
resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae"
54385438
integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==
54395439

5440+
idiomorph@^0.0.9:
5441+
version "0.0.9"
5442+
resolved "https://registry.yarnpkg.com/idiomorph/-/idiomorph-0.0.9.tgz#938d5964031381b0713398fb283aa3754306fa1b"
5443+
integrity sha512-X7SGsldE5jQD+peLjNLAnIJDEZtGpuLrNRUBrTWMMnzrx9gwy5U+SCRhaifO2v2Z+8j09IY2J+EYaxHsOLTD0A==
5444+
54405445
ignore@^5.2.0:
54415446
version "5.2.4"
54425447
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324"
@@ -6870,11 +6875,6 @@ moo-color@^1.0.2:
68706875
dependencies:
68716876
color-name "^1.1.4"
68726877

6873-
morphdom@^2.6.1:
6874-
version "2.7.0"
6875-
resolved "https://registry.yarnpkg.com/morphdom/-/morphdom-2.7.0.tgz#9ef0c4bc15ac8725df398d127c6984f62e7f89e8"
6876-
integrity sha512-8L8DwbdjjWwM/aNqj7BSoSn4G7SQLNiDcxCnMWbf506jojR6lNQ5YOmQqXEIE8u3C492UlkN4d0hQwz97+M1oQ==
6877-
68786878
mri@^1.1.0:
68796879
version "1.2.0"
68806880
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"

0 commit comments

Comments
 (0)