Skip to content

Commit af195b6

Browse files
committed
[LiveComponent] Rebuild Component on reconnect when props changed in between
When a parent action removes and re-adds a child live component that lives on the same DOM element, Stimulus reuses the controller instance — it only calls disconnect() then connect() again, not initialize(). createComponent() is therefore never re-run, so the Component keeps a stale ValueStore that no longer matches the freshly rendered HTML, and Component.set() throws "Invalid model name" the next time the user types into a field. Detect the divergence in connect() by comparing the current propsValue with the props the Component was originally built with, and rebuild it when they differ. Behavior is unchanged on the first connect() (props match by construction) and on plain reconnects without any prop change. Fixes #3424
1 parent 68129d2 commit af195b6

2 files changed

Lines changed: 39 additions & 1 deletion

File tree

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,16 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
9393
}
9494

9595
connect() {
96+
// Stimulus only calls initialize() once per controller instance. When
97+
// the same DOM element is disconnected then reconnected (e.g. a parent
98+
// morphs it back in with new props), connect() is called again on the
99+
// existing controller. If the props now differ from what the Component
100+
// was built with, the ValueStore is stale and would reject any model
101+
// matching the new server-rendered HTML — so rebuild the Component.
102+
if (JSON.stringify(this.propsValue) !== JSON.stringify(this.component.valueStore.getOriginalProps())) {
103+
this.createComponent();
104+
}
105+
96106
this.connectComponent();
97107

98108
this.mutationObserver.observe(this.element, {

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import Component from '../../../src/Component';
1212
import { findComponents } from '../../../src/ComponentRegistry';
1313
import { htmlToElement } from '../../../src/dom_utils';
1414
import { getComponent } from '../../../src/live_controller';
15-
import { createTest, initComponent, shutdownTests, startStimulus } from '../../tools';
15+
import { createTest, getStimulusApplication, initComponent, shutdownTests, startStimulus } from '../../tools';
1616

1717
describe('LiveController Basic Tests', () => {
1818
afterEach(() => {
@@ -52,4 +52,32 @@ describe('LiveController Basic Tests', () => {
5252
await expect(getComponent(test.element)).rejects.toThrow('Component not found for element');
5353
expect(findComponents(test.component, false, null)).toEqual([]);
5454
});
55+
56+
it('rebuilds the Component on reconnect when props changed in between', async () => {
57+
const test = await createTest({ greeting: 'aloha' }, (data: any) => `<div ${initComponent(data)}></div>`);
58+
59+
const controller = getStimulusApplication().getControllerForElementAndIdentifier(test.element, 'live') as any;
60+
const originalComponent = controller.component;
61+
expect(originalComponent.valueStore.getOriginalProps()).toEqual({ greeting: 'aloha' });
62+
63+
// simulate a parent morph: same controller instance, fresh props from the server
64+
controller.disconnect();
65+
controller.propsValue = { greeting: 'hello' };
66+
controller.connect();
67+
68+
expect(controller.component).not.toBe(originalComponent);
69+
expect(controller.component.valueStore.getOriginalProps()).toEqual({ greeting: 'hello' });
70+
});
71+
72+
it('keeps the existing Component on reconnect when props are unchanged', async () => {
73+
const test = await createTest({ greeting: 'aloha' }, (data: any) => `<div ${initComponent(data)}></div>`);
74+
75+
const controller = getStimulusApplication().getControllerForElementAndIdentifier(test.element, 'live') as any;
76+
const originalComponent = controller.component;
77+
78+
controller.disconnect();
79+
controller.connect();
80+
81+
expect(controller.component).toBe(originalComponent);
82+
});
5583
});

0 commit comments

Comments
 (0)