Skip to content

Commit 74cc4c2

Browse files
committed
Fix parent->child fingerprint problem
Also fixes using the correct element for children always
1 parent 6530065 commit 74cc4c2

31 files changed

+509
-223
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
public User $user;
1111
```
1212

13+
- [BC BREAK]: Child components are no longer automatically re-rendered when
14+
a parent component re-renders and the value of one of the props passed to
15+
the child has changed. Pass `acceptUpdatesFromParent: true` to any `LiveProp`
16+
on the child component to re-enable this behavior.
17+
1318
- Non-persisted entity objects can now be used with `LiveProp`: it will be
1419
serialized using the serializer.
1520

src/LiveComponent/assets/src/Backend/Backend.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import BackendRequest from './BackendRequest';
22
import RequestBuilder from './RequestBuilder';
33

4+
export interface ChildrenFingerprints {
5+
// key is the id of the child component
6+
[key: string]: {fingerprint: string, tag: string}
7+
}
8+
49
export interface BackendInterface {
510
makeRequest(
611
props: any,
712
actions: BackendAction[],
813
updated: {[key: string]: any},
9-
childrenFingerprints: any
14+
children: ChildrenFingerprints,
15+
updatedPropsFromParent: {[key: string]: any},
1016
): BackendRequest;
1117
}
1218

@@ -26,13 +32,15 @@ export default class implements BackendInterface {
2632
props: any,
2733
actions: BackendAction[],
2834
updated: {[key: string]: any},
29-
childrenFingerprints: any
35+
children: ChildrenFingerprints,
36+
updatedPropsFromParent: {[key: string]: any},
3037
): BackendRequest {
3138
const { url, fetchOptions } = this.requestBuilder.buildRequest(
3239
props,
3340
actions,
3441
updated,
35-
childrenFingerprints
42+
children,
43+
updatedPropsFromParent
3644
);
3745

3846
return new BackendRequest(

src/LiveComponent/assets/src/Backend/RequestBuilder.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BackendAction } from './Backend';
1+
import { BackendAction, ChildrenFingerprints } from './Backend';
22

33
export default class {
44
private url: string;
@@ -13,7 +13,8 @@ export default class {
1313
props: any,
1414
actions: BackendAction[],
1515
updated: {[key: string]: any},
16-
childrenFingerprints: any
16+
children: ChildrenFingerprints,
17+
updatedPropsFromParent: {[key: string]: any},
1718
): { url: string; fetchOptions: RequestInit } {
1819
const splitUrl = this.url.split('?');
1920
let [url] = splitUrl;
@@ -25,23 +26,29 @@ export default class {
2526
Accept: 'application/vnd.live-component+html',
2627
};
2728

28-
const hasFingerprints = Object.keys(childrenFingerprints).length > 0;
29+
const hasFingerprints = Object.keys(children).length > 0;
2930
if (
3031
actions.length === 0 &&
31-
this.willDataFitInUrl(JSON.stringify(props), JSON.stringify(updated), params, JSON.stringify(childrenFingerprints))
32+
this.willDataFitInUrl(JSON.stringify(props), JSON.stringify(updated), params, JSON.stringify(children), JSON.stringify(updatedPropsFromParent))
3233
) {
3334
params.set('props', JSON.stringify(props));
3435
params.set('updated', JSON.stringify(updated));
36+
if (Object.keys(updatedPropsFromParent).length > 0) {
37+
params.set('propsFromParent', JSON.stringify(updatedPropsFromParent));
38+
}
3539
if (hasFingerprints) {
36-
params.set('childrenFingerprints', JSON.stringify(childrenFingerprints));
40+
params.set('children', JSON.stringify(children));
3741
}
3842
fetchOptions.method = 'GET';
3943
} else {
4044
fetchOptions.method = 'POST';
4145
fetchOptions.headers['Content-Type'] = 'application/json';
4246
const requestData: any = { props, updated };
47+
if (Object.keys(updatedPropsFromParent).length > 0) {
48+
requestData.propsFromParent = updatedPropsFromParent;
49+
}
4350
if (hasFingerprints) {
44-
requestData.childrenFingerprints = childrenFingerprints;
51+
requestData.children = children;
4552
}
4653

4754
if (actions.length > 0) {
@@ -72,8 +79,8 @@ export default class {
7279
}
7380
}
7481

75-
private willDataFitInUrl(propsJson: string, updatedJson: string, params: URLSearchParams, childrenFingerprintsJson: string) {
76-
const urlEncodedJsonData = new URLSearchParams(propsJson + updatedJson + childrenFingerprintsJson).toString();
82+
private willDataFitInUrl(propsJson: string, updatedJson: string, params: URLSearchParams, childrenJson: string, propsFromParentJson: string) {
83+
const urlEncodedJsonData = new URLSearchParams(propsJson + updatedJson + childrenJson + propsFromParentJson).toString();
7784

7885
// if the URL gets remotely close to 2000 chars, it may not fit
7986
return (urlEncodedJsonData + params.toString()).length < 1500;

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

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ export default class {
2020
*/
2121
private pendingProps: {[key: string]: any} = {};
2222

23+
/**
24+
* A list of props that the parent wants us to update.
25+
*
26+
* These will be sent on the next request to the server.
27+
*/
28+
private updatedPropsFromParent: {[key: string]: any} = {};
29+
2330
constructor(props: any) {
2431
this.props = props;
2532
}
@@ -82,6 +89,10 @@ export default class {
8289
return { ...this.dirtyProps };
8390
}
8491

92+
getUpdatedPropsFromParent(): any {
93+
return { ...this.updatedPropsFromParent };
94+
}
95+
8596
/**
8697
* Called when an update request begins.
8798
*/
@@ -95,6 +106,7 @@ export default class {
95106
*/
96107
reinitializeAllProps(props: any): void {
97108
this.props = props;
109+
this.updatedPropsFromParent = {};
98110
this.pendingProps = {};
99111
}
100112

@@ -108,18 +120,19 @@ export default class {
108120
}
109121

110122
/**
111-
* Reinitialize *only* the provided props, but leave other untouched.
112-
*
113123
* This is used when a parent component is rendering, and it includes
114-
* a fresh set of the "readonly" props for a child. This allows any writable
115-
* props to remain (as these may have already been changed by the user).
124+
* a fresh set of props that should be updated on the child component.
125+
*
126+
* The server manages returning only the props that should be updated onto
127+
* the child, so we don't need to worry about that.
116128
*
117-
* The server manages returning only the readonly props, so we don't need to
118-
* worry about that.
129+
* The props are stored in a different place, because the existing props
130+
* have their own checksum and these new props have *their* own checksum.
131+
* So, on the next render, both need to be sent independently.
119132
*
120-
* Returns true if any of the props changed.
133+
* Returns true if any of the props are different.
121134
*/
122-
reinitializeProvidedProps(props: any): boolean {
135+
storeNewPropsFromParent(props: any): boolean {
123136
let changed = false;
124137

125138
for (const [key, value] of Object.entries(props)) {
@@ -129,10 +142,13 @@ export default class {
129142
// prop entirely
130143
if (currentValue !== value) {
131144
changed = true;
132-
this.props[key] = value;
133145
}
134146
}
135147

148+
if (changed) {
149+
this.updatedPropsFromParent = props;
150+
}
151+
136152
return changed;
137153
}
138154
}

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

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BackendAction, BackendInterface } from '../Backend/Backend';
1+
import { BackendAction, BackendInterface, ChildrenFingerprints } from '../Backend/Backend';
22
import ValueStore from './ValueStore';
33
import { normalizeModelName } from '../string_utils';
44
import BackendRequest from '../Backend/BackendRequest';
@@ -279,17 +279,17 @@ export default class Component {
279279
*
280280
* @param toEl
281281
*/
282-
updateFromNewElement(toEl: HTMLElement): boolean {
282+
updateFromNewElementFromParentRender(toEl: HTMLElement): void {
283283
const props = this.elementDriver.getComponentProps(toEl);
284284

285285
// if no props are on the element, use the existing element completely
286286
// this means the parent is signaling that the child does not need to be re-rendered
287287
if (props === null) {
288-
return false;
288+
return;
289289
}
290290

291291
// push props directly down onto the value store
292-
const isChanged = this.valueStore.reinitializeProvidedProps(props);
292+
const isChanged = this.valueStore.storeNewPropsFromParent(props);
293293

294294
const fingerprint = toEl.dataset.liveFingerprintValue;
295295
if (fingerprint !== undefined) {
@@ -299,8 +299,6 @@ export default class Component {
299299
if (isChanged) {
300300
this.render();
301301
}
302-
303-
return false;
304302
}
305303

306304
/**
@@ -358,7 +356,8 @@ export default class Component {
358356
this.valueStore.getOriginalProps(),
359357
this.pendingActions,
360358
this.valueStore.getDirtyProps(),
361-
this.getChildrenFingerprints()
359+
this.getChildrenFingerprints(),
360+
this.valueStore.getUpdatedPropsFromParent(),
362361
);
363362
this.hooks.triggerHook('loading.state:started', this.element, this.backendRequest);
364363

@@ -572,16 +571,19 @@ export default class Component {
572571
modal.focus();
573572
}
574573

575-
private getChildrenFingerprints(): any {
576-
const fingerprints: any = {};
574+
private getChildrenFingerprints(): ChildrenFingerprints {
575+
const fingerprints: ChildrenFingerprints = {};
577576

578577
this.children.forEach((childComponent) => {
579578
const child = childComponent.component;
580579
if (!child.id) {
581580
throw new Error('missing id');
582581
}
583582

584-
fingerprints[child.id] = child.fingerprint;
583+
fingerprints[child.id] = {
584+
fingerprint: child.fingerprint as string,
585+
tag: child.element.tagName.toLowerCase(),
586+
};
585587
});
586588

587589
return fingerprints;

src/LiveComponent/assets/src/dom_utils.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -250,19 +250,6 @@ export function htmlToElement(html: string): HTMLElement {
250250
return child;
251251
}
252252

253-
// Inspired by https://stackoverflow.com/questions/13389751/change-tag-using-javascript
254-
export function cloneElementWithNewTagName(element: Element, newTag: string): HTMLElement {
255-
const originalTag = element.tagName;
256-
const startRX = new RegExp('^<' + originalTag, 'i');
257-
const endRX = new RegExp(originalTag + '>$', 'i');
258-
const startSubst = '<' + newTag;
259-
const endSubst = newTag + '>';
260-
261-
const newHTML = element.outerHTML.replace(startRX, startSubst).replace(endRX, endSubst);
262-
263-
return htmlToElement(newHTML);
264-
}
265-
266253
/**
267254
* Returns just the outer element's HTML as a string - useful for error messages.
268255
*

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
4343
listeners: { type: Array, default: [] },
4444
debounce: { type: Number, default: 150 },
4545
id: String,
46-
fingerprint: String,
46+
fingerprint: { type: String, default: '' },
4747
};
4848

4949
declare readonly nameValue: string;

src/LiveComponent/assets/src/morphdom.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { cloneElementWithNewTagName, cloneHTMLElement, setValueOnElement } from './dom_utils';
1+
import { cloneHTMLElement, setValueOnElement } from './dom_utils';
22
import morphdom from 'morphdom';
33
import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison';
44
import Component from './Component';
@@ -17,16 +17,6 @@ export function executeMorphdom(
1717
const childComponentMap: Map<HTMLElement, Component> = new Map();
1818
childComponents.forEach((childComponent) => {
1919
childComponentMap.set(childComponent.element, childComponent);
20-
if (!childComponent.id) {
21-
throw new Error('Child is missing id.');
22-
}
23-
const childComponentToElement = findChildComponent(childComponent.id, rootToElement);
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-
childComponentToElement.replaceWith(newTag);
29-
}
3020
});
3121

3222
morphdom(rootFromElement, rootToElement, {
@@ -55,7 +45,9 @@ export function executeMorphdom(
5545
if (childComponentMap.has(fromEl)) {
5646
const childComponent = childComponentMap.get(fromEl) as Component;
5747

58-
return childComponent.updateFromNewElement(toEl);
48+
childComponent.updateFromNewElementFromParentRender(toEl);
49+
50+
return false;
5951
}
6052

6153
// if this field's value has been modified since this HTML was

0 commit comments

Comments
 (0)