Skip to content

[Live] Various bug fixes for new 2.5 parent-child fingerprint calculation #596

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/LiveComponent/assets/dist/live_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -2112,6 +2112,9 @@ class SetValueOntoModelFieldsPlugin {
if (element instanceof HTMLFormElement) {
return;
}
if (!elementBelongsToThisComponent(element, component)) {
return;
}
const modelDirective = getModelDirectiveFromElement(element);
if (!modelDirective) {
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Component from '../index';
import {
elementBelongsToThisComponent,
getModelDirectiveFromElement,
getValueFromElement,
setValueOnElement
Expand Down Expand Up @@ -38,6 +39,10 @@ export default class implements PluginInterface {
return;
}

if (!elementBelongsToThisComponent(element, component)) {
return;
}

const modelDirective = getModelDirectiveFromElement(element);
if (!modelDirective) {
return;
Expand Down
39 changes: 39 additions & 0 deletions src/LiveComponent/assets/test/controller/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,45 @@ describe('LiveController data-model Tests', () => {
expect(commentField.value).toEqual('MMMM SO GOOD');
});

it('does not try to set the value of inputs inside a child component', async () => {
const test = await createTest({ comment: 'cookie', childComment: 'mmmm' }, (data: any) => `
<div ${initComponent(data)}>
<textarea data-model="comment" id="parent-comment"></textarea>

<div ${initComponent({ comment: data.childComment }, {}, {id: 'the-child-id'})}>
<textarea data-model="comment" id="child-comment"></textarea>
</div>
</div>
`);

const commentField = test.element.querySelector('#parent-comment');
if (!(commentField instanceof HTMLTextAreaElement)) {
throw new Error('wrong type');
}
expect(commentField.value).toEqual('cookie');

const childCommentField = test.element.querySelector('#child-comment');
if (!(childCommentField instanceof HTMLTextAreaElement)) {
throw new Error('wrong type');
}
expect(childCommentField.value).toEqual('mmmm');

// NOW we will re-render
test.expectsAjaxCall('get')
.expectSentData(test.initialData)
// change the data to be extra tricky
.serverWillChangeData((data) => {
data.comment = 'i like apples';
})
.init();

await test.component.render();

expect(commentField.value).toEqual('i like apples');
// child component should not have been re-rendered
expect(childCommentField.value).toEqual('mmmm');
});

it('keeps the unsynced value of an input on re-render, but accepts other changes to the field', async () => {
const test = await createTest({
comment: 'Live components',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,13 @@ private function getLiveAttributes(MountedComponent $mounted, ComponentMetadata
}

if ($this->container->get(ComponentStack::class)->hasParentComponent()) {
$id = $this->container->get(DeterministicTwigIdCalculator::class)->calculateDeterministicId();
$attributes['data-live-id'] = $helper->escapeAttribute($id);
if (!isset($mounted->getAttributes()->all()['data-live-id'])) {
$id = $this->container->get(DeterministicTwigIdCalculator::class)->calculateDeterministicId();
$attributes['data-live-id'] = $helper->escapeAttribute($id);
}

$fingerprint = $this->container->get(FingerprintCalculator::class)->calculateFingerprint($mounted->getInputProps());
$attributes['data-live-value-fingerprint'] = $helper->escapeAttribute($fingerprint);
$attributes['data-live-fingerprint-value'] = $helper->escapeAttribute($fingerprint);
}

return new ComponentAttributes($attributes);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public function preComponentCreated(PreCreateForRenderEvent $event): void
$childFingerprints = $parentComponent->getExtraMetadata(self::CHILDREN_FINGERPRINTS_METADATA_KEY);

// get the deterministic id for this child, but without incrementing the counter yet
$deterministicId = $this->deterministicTwigIdCalculator->calculateDeterministicId(increment: false);
$deterministicId = $event->getProps()['data-live-id'] ?? $this->deterministicTwigIdCalculator->calculateDeterministicId(increment: false);
if (!isset($childFingerprints[$deterministicId])) {
// child fingerprint wasn't set, it is likely a new child, allow it to render fully
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,8 @@ final class TodoListComponent
#[LiveProp]
public array $items = [];

#[LiveProp(writable: true)]
public $includeDataLiveId = false;

use DefaultActionTrait;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@

<ul>
{% for item in items %}
{{ component('todo_item', {
text: item.text
}) }}
{% set componentProps = { text: item.text } %}
{% if includeDataLiveId %}
{% set componentProps = componentProps|merge({'data-live-id': ('todo-item-' ~ loop.index) }) %}
{% endif %}
{{ component('todo_item', componentProps) }}
{% endfor %}
</ul>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{{ component('todo_list', {
items: [
{ text: 'milk'},
{ text: 'cheese'},
{ text: 'milk'},
],
includeDataLiveId: true
}) }}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
final class AddLiveAttributesSubscriberTest extends KernelTestCase
{
use HasBrowser;
/**
* The deterministic id of the "todo_item" components in todo_list.html.twig.
* If that template changes, this will need to be updated.
*/
public const TODO_ITEM_DETERMINISTIC_PREFIX = 'live-289310975-';

public function testInitLiveComponent(): void
{
Expand Down Expand Up @@ -85,14 +90,29 @@ public function testItAddsIdAndFingerprintToChildComponent(): void

$lis = $ul->children('li');
// deterministic id: should not change, and counter should increase
$this->assertSame('live-3649730296-0', $lis->first()->attr('data-live-id'));
$this->assertSame('live-3649730296-2', $lis->last()->attr('data-live-id'));
$this->assertSame(self::TODO_ITEM_DETERMINISTIC_PREFIX.'0', $lis->first()->attr('data-live-id'));
$this->assertSame(self::TODO_ITEM_DETERMINISTIC_PREFIX.'2', $lis->last()->attr('data-live-id'));

// fingerprints
// first and last both have the same input - thus fingerprint
$this->assertSame('sH/Rwn0x37n3KyMWQLa6OBPgglriBZqlwPLnm/EQTlE=', $lis->first()->attr('data-live-value-fingerprint'));
$this->assertSame('sH/Rwn0x37n3KyMWQLa6OBPgglriBZqlwPLnm/EQTlE=', $lis->last()->attr('data-live-value-fingerprint'));
$this->assertSame('sH/Rwn0x37n3KyMWQLa6OBPgglriBZqlwPLnm/EQTlE=', $lis->first()->attr('data-live-fingerprint-value'));
$this->assertSame('sH/Rwn0x37n3KyMWQLa6OBPgglriBZqlwPLnm/EQTlE=', $lis->last()->attr('data-live-fingerprint-value'));
// middle has a different fingerprint
$this->assertSame('cuOKkrHC9lOmBa6dyVZ3S0REdw4CKCwJgLDdrVoTb2g=', $lis->eq(1)->attr('data-live-value-fingerprint'));
$this->assertSame('cuOKkrHC9lOmBa6dyVZ3S0REdw4CKCwJgLDdrVoTb2g=', $lis->eq(1)->attr('data-live-fingerprint-value'));
}

public function testItDoesNotOverrideDataLiveIdIfSpecified(): void
{
$ul = $this->browser()
->visit('/render-template/render_todo_list_with_live_id')
->assertSuccessful()
->crawler()
->filter('ul')
;

$lis = $ul->children('li');
// deterministic id: is not used: data-live-id was passed in manually
$this->assertSame('todo-item-1', $lis->first()->attr('data-live-id'));
$this->assertSame('todo-item-3', $lis->last()->attr('data-live-id'));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ final class InterceptChildComponentRenderSubscriberTest extends KernelTestCase
// if you pass in 3 "items" with data that matches what's used by default
// in buildUrlForTodoListComponent
private static array $actualTodoItemFingerprints = [
'live-3649730296-0' => 'LwqODySoRx3q+v64EzalGouzpSHWKIm0jENTUGtQloE=',
'live-3649730296-1' => 'gn9PcPUqL0tkeLSw0ZuhOj96dwIpiBmJPoO5NPync2o=',
'live-3649730296-2' => 'ndV00y/qOSH11bjOKGDJVRsxANtbudYB6K8D46viUI8=',
AddLiveAttributesSubscriberTest::TODO_ITEM_DETERMINISTIC_PREFIX.'0' => 'LwqODySoRx3q+v64EzalGouzpSHWKIm0jENTUGtQloE=',
AddLiveAttributesSubscriberTest::TODO_ITEM_DETERMINISTIC_PREFIX.'1' => 'gn9PcPUqL0tkeLSw0ZuhOj96dwIpiBmJPoO5NPync2o=',
AddLiveAttributesSubscriberTest::TODO_ITEM_DETERMINISTIC_PREFIX.'2' => 'ndV00y/qOSH11bjOKGDJVRsxANtbudYB6K8D46viUI8=',
];

public function testItAllowsFullChildRenderOnMissingFingerprints(): void
Expand Down Expand Up @@ -56,10 +56,30 @@ public function testItRendersEmptyElementOnMatchingFingerprint(): void
;
}

public function testItRendersEmptyElementOnMatchingFingerprintWithCustomDataLiveId(): void
{
$fingerPrintsWithCustomLiveId = [];
foreach (array_values(self::$actualTodoItemFingerprints) as $key => $fingerprintValue) {
// creating fingerprints to match todo_list.html.twig
$fingerPrintsWithCustomLiveId['todo-item-'.$key + 1] = $fingerprintValue;
}

$this->browser()
->visit($this->buildUrlForTodoListComponent($fingerPrintsWithCustomLiveId, true))
->assertSuccessful()
->assertHtml()
// no lis (because we render a div always)
->assertElementCount('ul li', 0)
// because we actually slip in a div element
->assertElementCount('ul div', 3)
->assertNotContains('todo item')
;
}

public function testItRendersNewPropWhenFingerprintDoesNotMatch(): void
{
$fingerprints = self::$actualTodoItemFingerprints;
$fingerprints['live-3649730296-1'] = 'wrong fingerprint';
$fingerprints[AddLiveAttributesSubscriberTest::TODO_ITEM_DETERMINISTIC_PREFIX.'1'] = 'wrong fingerprint';

$this->browser()
->visit($this->buildUrlForTodoListComponent($fingerprints))
Expand All @@ -74,26 +94,30 @@ public function testItRendersNewPropWhenFingerprintDoesNotMatch(): void

// 1st and 3rd render empty
// fingerprint changed in 2nd, so it renders new fingerprint + props
$this->assertStringContainsString(<<<EOF
<ul>
<div data-live-id="live-3649730296-0"></div>
<div data-live-id="live-3649730296-1" data-live-fingerprint-value="gn9PcPUqL0tkeLSw0ZuhOj96dwIpiBmJPoO5NPync2o&#x3D;" data-live-props-value="&#x7B;&quot;_checksum&quot;&#x3A;&quot;YrPg4mHsB82fR&#x5C;&#x2F;VmTL3gJIX32kS8HvWy&#x5C;&#x2F;9uKSs&#x5C;&#x2F;aPfk&#x3D;&quot;&#x7D;"></div>
<div data-live-id="live-3649730296-2"></div>
</ul>
EOF,
$content);
})
;
$this->assertStringContainsString(sprintf(
'<div data-live-id="%s0"></div>',
AddLiveAttributesSubscriberTest::TODO_ITEM_DETERMINISTIC_PREFIX
), $content);
$this->assertStringContainsString(sprintf(
'<div data-live-id="%s1" data-live-fingerprint-value="gn9PcPUqL0tkeLSw0ZuhOj96dwIpiBmJPoO5NPync2o&#x3D;" data-live-props-value="&#x7B;&quot;_checksum&quot;&#x3A;&quot;YrPg4mHsB82fR&#x5C;&#x2F;VmTL3gJIX32kS8HvWy&#x5C;&#x2F;9uKSs&#x5C;&#x2F;aPfk&#x3D;&quot;&#x7D;"></div>',
AddLiveAttributesSubscriberTest::TODO_ITEM_DETERMINISTIC_PREFIX
), $content);
$this->assertStringContainsString(sprintf(
'<div data-live-id="%s2"></div>',
AddLiveAttributesSubscriberTest::TODO_ITEM_DETERMINISTIC_PREFIX
), $content);
});
}

private function buildUrlForTodoListComponent(array $childrenFingerprints): string
private function buildUrlForTodoListComponent(array $childrenFingerprints, bool $includeLiveId = false): string
{
$component = $this->mountComponent('todo_list', [
'items' => [
['text' => 'wake up'],
['text' => 'high five a friend'],
['text' => 'take a nap'],
],
'includeDataLiveId' => $includeLiveId,
]);

$dehydrated = $this->dehydrateComponent($component);
Expand Down
1 change: 1 addition & 0 deletions ux.symfony.com/assets/bootstrap.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { startStimulusApp } from '@symfony/stimulus-bridge';
import Clipboard from 'stimulus-clipboard'
import Live from '../live_components';

// Registers Stimulus controllers from controllers.json and in the controllers/ directory
export const app = startStimulusApp(require.context(
Expand Down