Skip to content

Commit 07f8517

Browse files
committed
Work on child rendering tests
1 parent e5cc4e0 commit 07f8517

File tree

14 files changed

+489
-123
lines changed

14 files changed

+489
-123
lines changed

src/LiveComponent/assets/src/Component/ValueStore.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export default class {
66
private props: any = {};
77
private data: any = {};
88

9+
// TODO: consider removing props from ValueStore & component: only leaving in live_controller
910
constructor(props: any, data: any) {
1011
this.props = props;
1112
this.data = data;
@@ -52,7 +53,20 @@ export default class {
5253
return { ...this.props, ...this.data };
5354
}
5455

55-
reinitializeData(data: any) {
56+
reinitializeData(data: any): void {
5657
this.data = data;
5758
}
59+
60+
/**
61+
* Returns true if any of the props changed.
62+
*/
63+
reinitializeProps(props: any): boolean {
64+
if (JSON.stringify(props) == JSON.stringify(this.props)) {
65+
return false;
66+
}
67+
68+
this.props = props;
69+
70+
return true;
71+
}
5872
}

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

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,32 @@ export default class Component {
178178
return new Map(this.children);
179179
}
180180

181+
updateFromNewElement(toEl: HTMLElement): boolean {
182+
// TODO: need a driver here to be agnostic of markup
183+
const propsString = toEl.dataset.livePropsValue;
184+
185+
// if no props are on the element, use the existing element completely
186+
// this means the parent is signaling that the child does not need to be re-rendered
187+
if (propsString === undefined) {
188+
return false;
189+
}
190+
191+
// push props directly down onto the value store
192+
const props = JSON.parse(propsString);
193+
const isChanged = this.valueStore.reinitializeProps(props);
194+
195+
const fingerprint = toEl.dataset.liveFingerprintValue;
196+
if (fingerprint !== undefined) {
197+
this.fingerprint = fingerprint;
198+
}
199+
200+
if (isChanged) {
201+
this.render();
202+
}
203+
204+
return false;
205+
}
206+
181207
private tryStartingRequest(): void {
182208
if (!this.backendRequest) {
183209
this.performRequest()
@@ -277,6 +303,7 @@ export default class Component {
277303
newElement,
278304
this.unsyncedInputsTracker.getUnsyncedInputs(),
279305
(element: HTMLElement) => getValueFromElement(element, this.valueStore),
306+
Array.from(this.getChildren().values())
280307
);
281308
// TODO: could possibly do this by listening to the dataValue value change
282309
this.valueStore.reinitializeData(newDataFromServer);
@@ -297,7 +324,7 @@ export default class Component {
297324
}));
298325
}
299326

300-
private caculateDebounce(debounce: number|boolean): number {
327+
private calculateDebounce(debounce: number|boolean): number {
301328
if (debounce === true) {
302329
return this.defaultDebounce;
303330
}
@@ -320,7 +347,7 @@ export default class Component {
320347
this.clearRequestDebounceTimeout();
321348
this.requestDebounceTimeout = window.setTimeout(() => {
322349
this.render();
323-
}, this.caculateDebounce(debounce));
350+
}, this.calculateDebounce(debounce));
324351
}
325352

326353
// inspired by Livewire!

src/LiveComponent/assets/src/dom_utils.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import ValueStore from './Component/ValueStore';
22
import { Directive, parseDirectives } from './directives_parser';
33
import { normalizeModelName } from './string_utils';
4-
import Component from "./Component";
4+
import Component from './Component';
55

66
/**
77
* Return the "value" of any given element.
@@ -210,6 +210,21 @@ export function htmlToElement(html: string): HTMLElement {
210210
return child;
211211
}
212212

213+
// Inspired by https://stackoverflow.com/questions/13389751/change-tag-using-javascript
214+
export function cloneElementWithNewTagName(element: Element, newTag: string): HTMLElement {
215+
const originalTag = element.tagName
216+
const startRX = new RegExp('^<'+originalTag, 'i')
217+
const endRX = new RegExp(originalTag+'>$', 'i')
218+
const startSubst = '<'+newTag
219+
const endSubst = newTag+'>'
220+
221+
const newHTML = element.outerHTML
222+
.replace(startRX, startSubst)
223+
.replace(endRX, endSubst);
224+
225+
return htmlToElement(newHTML);
226+
}
227+
213228
/**
214229
* Returns just the outer element's HTML as a string - useful for error messages.
215230
*

src/LiveComponent/assets/src/morphdom.ts

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,62 @@
11
import {
2-
cloneHTMLElement,
2+
cloneElementWithNewTagName,
3+
cloneHTMLElement,
34
setValueOnElement
4-
} from "./dom_utils";
5-
import morphdom from "morphdom";
6-
import { normalizeAttributesForComparison } from "./normalize_attributes_for_comparison";
5+
} from './dom_utils';
6+
import morphdom from 'morphdom';
7+
import {
8+
normalizeAttributesForComparison
9+
} from './normalize_attributes_for_comparison';
10+
import Component from './Component';
711

812
export function executeMorphdom(
913
rootFromElement: HTMLElement,
1014
rootToElement: HTMLElement,
1115
modifiedElements: Array<HTMLElement>,
1216
getElementValue: (element: HTMLElement) => any,
17+
childComponents: Component[],
1318
) {
14-
// make sure everything is in non-loading state, the same as the HTML currently on the page
19+
const childComponentMap: Map<HTMLElement, Component> = new Map();
20+
childComponents.forEach((childComponent) => {
21+
childComponentMap.set(childComponent.element, childComponent);
22+
// TODO: add driver to make this agnostic
23+
const childComponentToElement = rootToElement.querySelector(`[data-live-id-value=${childComponent.id}]`)
24+
if (childComponentToElement && childComponentToElement.tagName !== childComponent.element.tagName) {
25+
// we need to "correct" the tag name for the child to match the "from"
26+
// so that we always get a "diff", not a remove/add
27+
const newTag = cloneElementWithNewTagName(childComponentToElement, childComponent.element.tagName);
28+
rootToElement.replaceChild(newTag, childComponentToElement);
29+
}
30+
});
31+
1532
morphdom(rootFromElement, rootToElement, {
1633
getNodeKey: (node: Node) => {
17-
if (!(node instanceof HTMLElement)) {
18-
return;
19-
}
34+
if (!(node instanceof HTMLElement)) {
35+
return;
36+
}
37+
38+
// TODO: abstract out to make this function agnostic of markup
39+
if (node.dataset.liveId) {
40+
return node.dataset.liveId;
41+
}
2042

21-
return node.dataset.liveId;
43+
// TODO: do we really need data-live-id and data-live-value-id?
44+
return node.dataset.liveIdValue
2245
},
2346
onBeforeElUpdated: (fromEl, toEl) => {
47+
if (fromEl === rootFromElement) {
48+
return true;
49+
}
50+
2451
if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) {
2552
return false;
2653
}
2754

55+
const childComponent = childComponentMap.get(fromEl) || false
56+
if (childComponent) {
57+
return childComponent.updateFromNewElement(toEl);
58+
}
59+
2860
// if this field's value has been modified since this HTML was
2961
// requested, set the toEl's value to match the fromEl
3062
if (modifiedElements.includes(fromEl)) {
@@ -47,16 +79,6 @@ export function executeMorphdom(
4779
}
4880
}
4981

50-
// avoid updating child components: they will handle themselves
51-
const controllerName = fromEl.hasAttribute('data-controller') ? fromEl.getAttribute('data-controller') : null;
52-
if (controllerName
53-
&& controllerName.split(' ').indexOf('live') !== -1
54-
&& fromEl !== rootFromElement
55-
) {
56-
// TODO: add new child logic here
57-
return false;
58-
}
59-
6082
// look for data-live-ignore, and don't update
6183
return !fromEl.hasAttribute('data-live-ignore');
6284
},

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@
99

1010
'use strict';
1111

12-
import { createTest, initComponent, shutdownTest } from '../tools';
12+
import { createTest, initComponent, shutdownTests } from '../tools';
1313
import { getByText, waitFor } from '@testing-library/dom';
1414
import userEvent from '@testing-library/user-event';
1515

1616
describe('LiveController Action Tests', () => {
1717
afterEach(() => {
18-
shutdownTest();
18+
shutdownTests();
1919
})
2020

2121
it('sends an action and renders the result', async () => {

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@
99

1010
'use strict';
1111

12-
import {createTest, initComponent, shutdownTest, startStimulus} from '../tools';
12+
import {createTest, initComponent, shutdownTests, startStimulus} from '../tools';
1313
import { htmlToElement } from '../../src/dom_utils';
14-
import Component from "../../src/Component";
14+
import Component from '../../src/Component';
1515

1616
describe('LiveController Basic Tests', () => {
1717
afterEach(() => {
18-
shutdownTest()
18+
shutdownTests()
1919
});
2020

2121
it('dispatches connect event', async () => {
@@ -37,9 +37,9 @@ describe('LiveController Basic Tests', () => {
3737
<div ${initComponent(data, {}, { debounce: 115, id: 'the-id', fingerprint: 'the-fingerprint' })}></div>
3838
`);
3939

40-
expect(test.controller.component).toBeInstanceOf(Component);
41-
expect(test.controller.component.defaultDebounce).toEqual(115);
42-
expect(test.controller.component.id).toEqual('the-id');
43-
expect(test.controller.component.fingerprint).toEqual('the-fingerprint');
40+
expect(test.component).toBeInstanceOf(Component);
41+
expect(test.component.defaultDebounce).toEqual(115);
42+
expect(test.component.id).toEqual('the-id');
43+
expect(test.component.fingerprint).toEqual('the-fingerprint');
4444
});
4545
});

0 commit comments

Comments
 (0)