Skip to content

Commit d1076f8

Browse files
committed
Updating docs for working via JS & making change event always update a model
updating CHANGELOG
1 parent e38381e commit d1076f8

File tree

8 files changed

+173
-87
lines changed

8 files changed

+173
-87
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@
2626
<input data-model="firstName">
2727
```
2828
29+
- [BEHAVIOR CHANGE] The way that child components re-render when a parent re-renders
30+
has changed, but shouldn't be drastically different. Child components will now
31+
avoid re-rendering if no "input" to the component changed *and* will maintain
32+
any writable `LiveProp` values after the re-render. Also, the re-render happens
33+
in a separate Ajax call after the parent has finished re-rendering.
34+
35+
- [BEHAVIOR CHANGE] If a model is updated, but the new value is equal to the old
36+
one, a re-render will now be avoided.
37+
38+
- [BC BREAK] The `live:update-model` and `live:render` events are not longer
39+
dispatched. You can now use the hook system directly on the `Component` object.
40+
2941
- Added the ability to add `data-loading` behavior, which is only activated
3042
when a specific **action** is triggered - e.g. `<span data-loading="action(save)|show">Loading</span>`.
3143

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,20 @@ export default class {
3838
* Sets data back onto the value store.
3939
*
4040
* The name can be in the non-normalized format.
41+
*
42+
* Returns true if the new value is different than the existing value.
4143
*/
42-
set(name: string, value: any): void {
44+
set(name: string, value: any): boolean {
4345
const normalizedName = normalizeModelName(name);
44-
if (!this.updatedModels.includes(normalizedName)) {
46+
const currentValue = this.get(name);
47+
48+
if (currentValue !== value && !this.updatedModels.includes(normalizedName)) {
4549
this.updatedModels.push(normalizedName);
4650
}
4751

4852
this.data = setDeepData(this.data, normalizedName, value);
53+
54+
return currentValue !== value;
4955
}
5056

5157
all(): any {

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,15 @@ export default class Component {
104104
set(model: string, value: any, reRender = false, debounce: number|boolean = false): Promise<BackendResponse> {
105105
const promise = this.nextRequestPromise;
106106
const modelName = normalizeModelName(model);
107-
this.valueStore.set(modelName, value);
107+
const isChanged = this.valueStore.set(modelName, value);
108108

109109
this.hooks.triggerHook('model:set', model, value);
110110

111111
// the model's data is no longer unsynced
112112
this.unsyncedInputsTracker.markModelAsSynced(modelName);
113113

114-
if (reRender) {
114+
// don't bother re-rendering if the value didn't change
115+
if (reRender && isChanged) {
115116
this.debouncedStartRequest(debounce);
116117
}
117118

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,13 @@ export default class extends Controller<HTMLElement> implements LiveController {
292292
shouldRender = false;
293293
}
294294

295+
// just in case, if a "change" event is happening, and this field
296+
// targets "input", set the model to be safe. This helps when people
297+
// manually trigger field updates by dispatching a "change" event
298+
if (eventName === 'change' && targetEventName === 'input') {
299+
targetEventName = 'change';
300+
}
301+
295302
// e.g. we are targeting "change" and this is the "input" event
296303
// so do *not* update the model yet
297304
if (eventName && targetEventName !== eventName) {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
import BackendRequest from '../../src/BackendRequest';
77
import { Response } from 'node-fetch';
88
import {waitFor} from '@testing-library/dom';
9-
import BackendResponse from "../../src/BackendResponse";
9+
import BackendResponse from '../../src/BackendResponse';
1010

1111
interface MockBackend extends BackendInterface {
1212
actions: BackendAction[],
@@ -58,7 +58,7 @@ describe('Component class', () => {
5858
expect(backendResponse).toBeNull();
5959

6060
// set model WITH re-render
61-
component.set('firstName', 'Ryan', true);
61+
component.set('firstName', 'Kevin', true);
6262
// it's still not *instantly* resolve - it'll
6363
expect(backendResponse).toBeNull();
6464
await waitFor(() => expect(backendResponse).not.toBeNull());

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import {createTest, initComponent, shutdownTests} from '../tools';
1313
import {getByTestId, getByText, waitFor} from '@testing-library/dom';
14-
import userEvent from "@testing-library/user-event";
14+
import userEvent from '@testing-library/user-event';
1515

1616
describe('LiveController data-loading Tests', () => {
1717
afterEach(() => {

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,4 +686,33 @@ describe('LiveController data-model Tests', () => {
686686
expect(unmappedTextarea.value).toEqual('no data-model here!');
687687
expect(unmappedTextarea.getAttribute('class')).toEqual('changed-class');
688688
});
689+
690+
it('allows model fields to be manually set as long as change event is dispatched', async () => {
691+
const test = await createTest({ food: '' }, (data: any) => `
692+
<div ${initComponent(data)}>
693+
<!-- specifically using on(input) then we will trigger a "change" event -->
694+
<select data-model="on(input)|food" data-testid="food-select">
695+
<option value="">choose a food</option>
696+
<option value="carrot">🥕</option>
697+
<option value="brocolli">🥦</option>
698+
</select>
699+
700+
Food: ${data.food}
701+
</div>
702+
`);
703+
704+
test.expectsAjaxCall('get')
705+
.expectSentData({ food: 'carrot' })
706+
.init();
707+
708+
const foodSelect = getByTestId(test.element, 'food-select');
709+
if (!(foodSelect instanceof HTMLSelectElement)) {
710+
throw new Error('wrong type');
711+
}
712+
713+
foodSelect.value = 'carrot';
714+
foodSelect.dispatchEvent(new Event('change', { bubbles: true }));
715+
716+
await waitFor(() => expect(test.element).toHaveTextContent('Food: carrot'));
717+
});
689718
});

src/LiveComponent/src/Resources/doc/index.rst

Lines changed: 111 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -384,31 +384,61 @@ live property on your component to the value ``edit``. The
384384
``data-action="live#update"`` is Stimulus code that triggers
385385
the update.
386386

387-
Updating a Field via Custom JavaScript
388-
--------------------------------------
387+
Working with the Component in JavaScript
388+
----------------------------------------
389389

390-
Sometimes you might want to change the value of a field via your own
391-
custom JavaScript. Suppose you have the following field inside your component:
390+
Want to change the value of a model or even trigger an action from your
391+
own custom JavaScript? No problem, thanks to a JavaScript ``Component``
392+
object, which is attached to each root component element.
392393

393-
.. code-block:: twig
394+
For example, to write your custom JavaScript, you create a Stimulus
395+
controller and put it around (or attached to) your root component element:
394396

395-
<input
396-
id="favorite-food"
397-
data-model="favoriteFood"
398-
>
397+
.. code-block:: javascript
398+
399+
// assets/controllers/some-custom-controller.js
400+
// ...
401+
402+
export default class extends Controller {
403+
connect() {
404+
// when the live component inside of this controller is initialized,
405+
// this method will be called and you can access the Component object
406+
this.element.addEventListener('live:connect', (event) => {
407+
this.component = event.detail.component;
408+
});
409+
}
410+
411+
// some Stimulus action triggered, for example, on user click
412+
toggleMode() {
413+
// e.g. set some live property called "mode" on your component
414+
this.component.set('mode', 'editing');
415+
// you can also say
416+
this.component.mode = 'editing';
417+
418+
// or call an action
419+
this.action('save', { arg1: 'value1' });
420+
// you can also say:
421+
this.save({ arg1: 'value1'});
422+
}
423+
}
399424
400-
To set the value of this field via custom JavaScript (e.g. a Stimulus controller),
425+
You can also access the ``Component`` object via a special property
426+
on the root component element:
427+
428+
.. code-block:: javascript
429+
430+
const component = document.getElementById('id-on-your-element').__component;
431+
component.mode = 'editing';
432+
433+
Finally, you can also set the value of a model field directly. However,
401434
be sure to *also* trigger a ``change`` event so that live components is notified
402435
of the change:
403436

404437
.. code-block:: javascript
405438
406-
const input = document.getElementById('favorite-food');
439+
const rootElement = document.getElementById('favorite-food');
407440
input.value = 'sushi';
408441
409-
element.dispatchEvent(new Event('input', { bubbles: true }));
410-
411-
// if you have data-model="on(change)|favoriteFood", use the "change" event
412442
element.dispatchEvent(new Event('change', { bubbles: true }));
413443
414444
Loading States
@@ -1636,13 +1666,6 @@ To validate only on "change", use the ``on(change)`` modifier:
16361666
class="{{ this.getError('post.content') ? 'has-error' : '' }}"
16371667
>
16381668
1639-
Interacting with JavaScript
1640-
---------------------------
1641-
1642-
TODO:
1643-
- events - like live:connect
1644-
- the Component object
1645-
16461669
Polling
16471670
-------
16481671

@@ -1687,49 +1710,82 @@ Nested Components
16871710

16881711
Need to nest one live component inside another one? No problem! As a
16891712
rule of thumb, **each component exists in its own, isolated universe**.
1690-
This means that nesting one component inside another could be really
1691-
simple or a bit more complex, depending on how inter-connected you want
1692-
your components to be.
1713+
This means that if a parent component re-renders, it won't automatically
1714+
cause the child to re-render (but it *may* - keep reading). Or, if
1715+
a model in a child updates, it won't also update that model in its parent
1716+
(but it *can* - keep reading).
16931717

1694-
Here are a few helpful things to know:
1718+
The parent-child system is *smart*. And with a few tricks, you can make
1719+
it behave exactly like you need.
16951720

16961721
Each component re-renders independent of one another
16971722
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
16981723

1699-
If a parent component re-renders, the child component will *not* (most
1700-
of the time) be updated, even though it lives inside the parent. Each
1701-
component is its own, isolated universe.
1702-
1703-
But this is not always what you want. For example, suppose you have a
1704-
parent component that renders a form and a child component that renders
1705-
one field in that form. When you click a "Save" button on the parent
1706-
component, that validates the form and re-renders with errors -
1707-
including a new ``error`` value that it passes into the child:
1724+
If a parent component re-renders, this may or may not cause the child
1725+
component to send its own Ajax request to re-render. What determines
1726+
that? Let's look at an example of a todo list component with a child
1727+
that renders the total number of todo items:
17081728

17091729
.. code-block:: twig
17101730
1711-
{# templates/components/post_form.html.twig #}
1731+
{# templates/components/todo_list.html.twig #}
1732+
<div {{ attributes }}>
1733+
<input data-model="listName">
17121734
1713-
{{ component('textarea_field', {
1714-
value: this.content,
1715-
error: this.getError('content')
1716-
}) }}
1735+
{% for todo in todos %}
1736+
...
1737+
{% endfor %}
17171738
1718-
In this situation, when the parent component re-renders after clicking
1719-
"Save", you *do* want the updated child component (with the validation
1720-
error) to be rendered. And this *will* happen automatically. Why?
1721-
because the live component system detects that the **parent component
1722-
has changed how it's rendering the child**.
1739+
{{ component('todo_footer', {
1740+
count: todos|length
1741+
}) }}
1742+
</div>
1743+
1744+
Suppose the user updates the ``listName`` model and the parent component
1745+
re-renders. In this case, the child component will *not* re-render. Why?
1746+
Because the live components system will detect that none of the values passed
1747+
*into* ``todo_footer`` (just ``count`` in this case). Have change. If no inputs
1748+
to the child changed, there's no need to re-render it.
1749+
1750+
But if the user added a *new* todo item and the number of todos changed from
1751+
5 to 6, this *would* change the ``count`` value that's passed into the ``todo_footer``.
1752+
In this case, immediately after the parent component re-renders, the child
1753+
request will make a second Ajax request to render itself. Smart!
1754+
1755+
Child components keep their modifiable LiveProp values
1756+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1757+
1758+
But suppose that the ``todo_footer`` in the previous example also has
1759+
an ``isVisible`` ``LiveProp(writable: true)`` property which starts as
1760+
``true`` but can be changed (via a link click) to ``false``. Will
1761+
re-rendering the child cause this to be reset back to its original
1762+
value? Nope! When the child component re-renders, it will keep the
1763+
current value for any of its writable props.
1764+
1765+
What if you *do* want your entire child component to re-render (including
1766+
resetting writable live props) when some value in the parent changes? This
1767+
can be done by manually giving your component a ``data-live-id`` attribute
1768+
that will change if the component should be totally re-rendered:
1769+
1770+
.. code-block:: twig
1771+
1772+
{# templates/components/todo_list.html.twig #}
1773+
<div {{ attributes }}>
1774+
<!-- ... -->
17231775
1724-
This may not always be perfect, and if your child component has its own
1725-
``LiveProp`` that has changed since it was first rendered, that value
1726-
will be lost when the parent component causes the child to re-render. If
1727-
you have this situation, use ``data-model-map`` to map that child
1728-
``LiveProp`` to a ``LiveProp`` in the parent component, and pass it into
1729-
the child when rendering.
1776+
{{ component('todo_footer', {
1777+
count: todos|length,
1778+
'data-live-id': 'todo-footer-'~todos|length
1779+
}) }}
1780+
</div>
1781+
1782+
In this case, if the number of todos change, then the ``data-live-id``
1783+
attribute of the component will also change. This signals that the
1784+
component should re-render itself completely, discarding any writable
1785+
LiveProp values.
17301786

1731-
Actions, methods and model updates in a child do not affect the parent
1732-
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1787+
Actions in a child do not affect the parent
1788+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
17331789

17341790
Again, each component is its own, isolated universe! For example,
17351791
suppose your child component has:
@@ -1750,42 +1806,17 @@ Suppose a child component has a:
17501806

17511807
.. code-block:: html
17521808

1753-
<textarea data-model="markdown_value" data-action="live#update">
1809+
<textarea data-model="value" data-action="live#update">
17541810

17551811
When the user changes this field, this will *only* update the
1756-
``markdown_value`` field in the *child* component… because (yup, we're
1812+
``value`` field in the *child* component… because (yup, we're
17571813
saying it again): each component is its own, isolated universe.
17581814

17591815
However, sometimes this isn't what you want! Sometimes, in addition to
17601816
updating the child component's model, you *also* want to update a model
17611817
on the *parent* component.
17621818

1763-
To help with this, whenever a model updates, a ``live:update-model``
1764-
event is dispatched. All components automatically listen to this event.
1765-
This means that, when the ``markdown_value`` model is updated in the
1766-
child component, *if* the parent component *also* has a model called
1767-
``markdown_value`` it will *also* be updated. This is done as a
1768-
"deferred" update
1769-
(i.e. :ref:`updateDefer() <deferring-a-re-render-until-later>`).
1770-
1771-
If the model name in your child component (e.g. ``markdown_value``) is
1772-
*different* than the model name in your parent component
1773-
(e.g. ``post.content``), you have two options. First, you can make sure
1774-
both are set by leveraging both the ``data-model`` and ``name``
1775-
attributes:
1776-
1777-
.. code-block:: twig
1778-
1779-
<textarea
1780-
data-model="markdown_value"
1781-
name="post[content]"
1782-
>
1783-
1784-
In this situation, the ``markdown_value`` model will be updated on the
1785-
child component (because ``data-model`` takes precedence over ``name``).
1786-
But if any parent components have a ``markdown_value`` model *or* a
1787-
``post.content`` model (normalized from ``post[content``]`), their model
1788-
will also be updated.
1819+
TODO: still need to update this:
17891820

17901821
A second option is to wrap your child element in a special
17911822
``data-model-map`` element:

0 commit comments

Comments
 (0)