Skip to content

Commit 98552e9

Browse files
committed
fix: architectural resolution for VDOM ID collisions (#8465)
1 parent ff1f015 commit 98552e9

5 files changed

Lines changed: 227 additions & 27 deletions

File tree

apps/portal/view/news/blog/Container.mjs

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,19 +45,6 @@ class Container extends BaseContainer {
4545
*/
4646
layout: {ntype: 'vbox', align: 'stretch'}
4747
}
48-
49-
/**
50-
*
51-
*/
52-
onConstructed() {
53-
let me = this;
54-
55-
if (me.items[1]) {
56-
me.items[1].id = `${me.id}__list`
57-
}
58-
59-
super.onConstructed()
60-
}
6148
}
6249

6350
export default Neo.setupClass(Container);

apps/portal/view/shared/content/Container.mjs

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -82,19 +82,6 @@ class MainContainer extends Container {
8282

8383
super.construct(config)
8484
}
85-
86-
/**
87-
*
88-
*/
89-
onConstructed() {
90-
let me = this;
91-
92-
if (me.items[1]) {
93-
me.items[1].id = `${me.id}__splitter`
94-
}
95-
96-
super.onConstructed()
97-
}
9885
}
9986

10087
export default Neo.setupClass(MainContainer);

src/component/Base.mjs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,21 @@ class Component extends Abstract {
463463
*/
464464
afterSetId(value, oldValue) {
465465
super.afterSetId(value, oldValue);
466-
this.changeVdomRootKey('id', value)
466+
467+
const
468+
me = this,
469+
vdom = me.vdom,
470+
vdomRoot = me.getVdomRoot();
471+
472+
if (vdomRoot) {
473+
vdomRoot.id = value;
474+
475+
if (vdom !== vdomRoot) {
476+
vdom.id = value + '__wrapper'
477+
}
478+
479+
me.update()
480+
}
467481
}
468482

469483
/**

src/mixin/VdomLifecycle.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,16 @@ class VdomLifecycle extends Base {
339339
if (me.vdom) {
340340
me.isVdomUpdating = true;
341341

342+
const vdomRoot = me.getVdomRoot();
343+
344+
if (vdomRoot) {
345+
vdomRoot.id = me.id;
346+
347+
if (me.vdom !== vdomRoot) {
348+
me.vdom.id = me.id + '__wrapper'
349+
}
350+
}
351+
342352
// Ensure child components do not trigger updates while the vnode generation is in progress
343353
VDomUpdate.registerInFlightUpdate(me.id, -1);
344354

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import {setup} from '../../setup.mjs';
2+
3+
const appName = 'AutoIdTest';
4+
5+
setup({
6+
neoConfig: {
7+
allowVdomUpdatesInTests: true,
8+
useDomApiRenderer : true
9+
},
10+
appConfig: {
11+
name: appName
12+
}
13+
});
14+
15+
import {test, expect} from '@playwright/test';
16+
import Neo from '../../../../src/Neo.mjs';
17+
import * as core from '../../../../src/core/_export.mjs';
18+
import Component from '../../../../src/component/Base.mjs';
19+
import Container from '../../../../src/container/Base.mjs';
20+
import DomApiVnodeCreator from '../../../../src/vdom/util/DomApiVnodeCreator.mjs';
21+
import VdomHelper from '../../../../src/vdom/Helper.mjs';
22+
23+
class MyComponent extends Component {
24+
static config = {
25+
className: 'Test.MyComponent',
26+
_vdom: {
27+
tag: 'div',
28+
cls: ['my-component'],
29+
cn : [{
30+
tag: 'span',
31+
cls: ['inner-span']
32+
}]
33+
}
34+
}
35+
}
36+
37+
class MyOtherComponent extends Component {
38+
static config = {
39+
className: 'Test.MyOtherComponent',
40+
_vdom: {
41+
tag: 'section',
42+
cls: ['other-component'],
43+
cn : [{
44+
tag: 'div',
45+
cls: ['inner-div']
46+
}]
47+
}
48+
}
49+
}
50+
51+
Neo.setupClass(MyComponent);
52+
Neo.setupClass(MyOtherComponent);
53+
54+
test.describe('VDOM Auto-ID Generation', () => {
55+
56+
test('Instances of same class should have unique VDOM IDs', async () => {
57+
const c1 = Neo.create(MyComponent, {appName});
58+
await c1.initVnode();
59+
60+
const c2 = Neo.create(MyComponent, {appName});
61+
await c2.initVnode();
62+
63+
const id1 = c1.vnode.id;
64+
const id2 = c2.vnode.id;
65+
66+
expect(id1).toBeDefined();
67+
expect(id2).toBeDefined();
68+
expect(id1).not.toBe(id2);
69+
70+
// Check children IDs
71+
const childId1 = c1.vnode.childNodes[0].id;
72+
const childId2 = c2.vnode.childNodes[0].id;
73+
74+
expect(childId1).toBeDefined();
75+
expect(childId2).toBeDefined();
76+
expect(childId1).not.toBe(childId2);
77+
78+
c1.destroy();
79+
c2.destroy();
80+
});
81+
82+
test('Instances of different classes should have unique VDOM IDs', async () => {
83+
const c1 = Neo.create(MyComponent, {appName});
84+
await c1.initVnode();
85+
86+
const c2 = Neo.create(MyOtherComponent, {appName});
87+
await c2.initVnode();
88+
89+
const id1 = c1.vnode.id;
90+
const id2 = c2.vnode.id;
91+
92+
expect(id1).not.toBe(id2);
93+
94+
c1.destroy();
95+
c2.destroy();
96+
});
97+
98+
test('Should reproduce ID collision if VDOM is shared (Anti-Pattern Check)', async () => {
99+
// This test manually simulates the "Shared Prototype" bug to prove it causes ID collision
100+
class BadComponent extends Component {
101+
static config = {
102+
className: 'Test.BadComponent'
103+
// vdom defined as non-reactive property, possibly shared?
104+
// But Component.mergeConfig clones it.
105+
// We need to bypass the clone logic to simulate the bug.
106+
}
107+
}
108+
109+
// Manually inject a shared vdom object into prototype
110+
const sharedVdom = { tag: 'div', cls: ['shared'] };
111+
BadComponent.prototype._vdom = sharedVdom;
112+
113+
Neo.setupClass(BadComponent);
114+
115+
// Force instances to use the shared object (bypass mergeConfig cloning)
116+
// We can't easily bypass construct() logic.
117+
// But we can verify that IF they share the object, IDs collide.
118+
119+
// Simulating logic:
120+
const vdom1 = sharedVdom;
121+
const vnode1 = VdomHelper.createVnode(vdom1); // This assigns ID to vdom1 if optimization used?
122+
// VdomHelper.createVnode returns NEW VNode. It doesn't mutate input vdom ID unless it's a component placeholder?
123+
// Wait, VNode constructor assigns ID.
124+
125+
// If vdom1 (the plain object) doesn't have an ID, VNode constructor generates one.
126+
// It does NOT write it back to vdom1.
127+
128+
const vnode2 = VdomHelper.createVnode(vdom1);
129+
130+
// Since vdom1 is just input config, vnode1 and vnode2 are new instances.
131+
// They will generate NEW IDs.
132+
133+
expect(vnode1.id).not.toBe(vnode2.id);
134+
135+
// So even if they share the prototype object, Helper generates unique IDs!
136+
// This disproves the "Shared Prototype VDOM" causing ID collision theory,
137+
// UNLESS the ID is written back to the shared object BEFORE Helper sees it.
138+
});
139+
140+
test('ComponentManager.wrapperNodes Collision Prevention (Fix Verification)', async () => {
141+
// This test verifies that the fix (stable __wrapper IDs) prevents collisions
142+
// in ComponentManager.wrapperNodes even if IdGenerator state is reset.
143+
144+
class WrapperComponent extends Component {
145+
static config = {
146+
className: 'Test.WrapperComponent',
147+
_vdom: {
148+
// This node should now get me.id + '__wrapper'
149+
cn: [{ tag: 'div', cls: ['wrapped-content'] }]
150+
}
151+
}
152+
153+
getVdomRoot() { return this.vdom.cn[0]; }
154+
getVnodeRoot() { return this.vnode.childNodes[0]; }
155+
}
156+
Neo.setupClass(WrapperComponent);
157+
158+
// 1. Create first component
159+
core.IdGenerator.idCounter['vnode'] = 0;
160+
const c1 = Neo.create(WrapperComponent, {appName});
161+
await c1.initVnode();
162+
const vnodeId1 = c1.vnode.id;
163+
164+
expect(vnodeId1).toBe(c1.id + '__wrapper');
165+
166+
// 2. Reset IdGenerator for 'vnode'
167+
core.IdGenerator.idCounter['vnode'] = 0;
168+
169+
// 3. Create second component
170+
const c2 = Neo.create(WrapperComponent, {appName});
171+
await c2.initVnode();
172+
const vnodeId2 = c2.vnode.id;
173+
174+
expect(vnodeId2).toBe(c2.id + '__wrapper');
175+
176+
// Verify NO collision occurred
177+
expect(vnodeId1).not.toBe(vnodeId2);
178+
expect(c1.id).not.toBe(c2.id);
179+
180+
// Check ComponentManager state - both should be registered correctly
181+
const manager = Neo.manager.Component;
182+
expect(manager.wrapperNodes.get(vnodeId1)).toBe(c1);
183+
expect(manager.wrapperNodes.get(vnodeId2)).toBe(c2);
184+
185+
// 4. Verify addVnodeComponentReferences picks up correct IDs
186+
const parentVnode = {
187+
id: 'parent',
188+
childNodes: [c1.vnode]
189+
};
190+
191+
const resultVnode = manager.addVnodeComponentReferences(parentVnode, 'parent');
192+
193+
// RESULT: The node representing c1's root is correctly replaced by a reference to c1
194+
const reference = resultVnode.childNodes[0];
195+
196+
expect(reference.componentId).toBe(c1.id); // FIXED!
197+
expect(reference.id).toBe(vnodeId1);
198+
199+
c1.destroy();
200+
c2.destroy();
201+
});
202+
});

0 commit comments

Comments
 (0)