@wsxjs/wsx-core 0.0.28 → 0.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,8 +10,8 @@
10
10
  import { h, type JSXChildren } from "./jsx-factory";
11
11
  import { BaseComponent, type BaseComponentConfig } from "./base-component";
12
12
  import { RenderContext } from "./render-context";
13
- import { shouldPreserveElement } from "./utils/element-marking";
14
13
  import { createLogger } from "@wsxjs/wsx-logger";
14
+ import { reconcileElement } from "./utils/element-update";
15
15
 
16
16
  const logger = createLogger("LightComponent");
17
17
 
@@ -156,6 +156,10 @@ export abstract class LightComponent extends BaseComponent {
156
156
  return HTMLElement.prototype.querySelectorAll.call(this, selector) as NodeListOf<T>;
157
157
  }
158
158
 
159
+ /**
160
+ * 递归协调子元素
161
+ * 更新现有子元素的属性和内容,而不是替换整个子树
162
+ */
159
163
  /**
160
164
  * 内部重渲染实现
161
165
  * 包含从 rerender() 方法迁移的实际渲染逻辑
@@ -180,11 +184,11 @@ export abstract class LightComponent extends BaseComponent {
180
184
 
181
185
  try {
182
186
  // 3. 重新渲染JSX内容
183
- const content = RenderContext.runInContext(this, () => this.render());
187
+ const newContent = RenderContext.runInContext(this, () => this.render());
184
188
 
185
189
  // 4. 在添加到 DOM 之前恢复值,避免浏览器渲染状态值
186
190
  if (focusState && focusState.key && focusState.value !== undefined) {
187
- const target = content.querySelector(
191
+ const target = newContent.querySelector(
188
192
  `[data-wsx-key="${focusState.key}"]`
189
193
  ) as HTMLElement;
190
194
 
@@ -220,17 +224,9 @@ export abstract class LightComponent extends BaseComponent {
220
224
 
221
225
  // 6. 使用 requestAnimationFrame 批量执行 DOM 操作
222
226
  requestAnimationFrame(() => {
223
- // 先添加新内容
224
- this.appendChild(content);
225
-
226
- // 移除旧内容(保留 JSX children、样式元素和未标记元素)
227
- // 关键修复:使用 shouldPreserveElement() 来保护手动创建的元素(如第三方库注入的元素)
227
+ // 获取当前的 children(排除样式元素和 JSX children)
228
228
  const oldChildren = Array.from(this.children).filter((child) => {
229
- // 保留新添加的内容
230
- if (child === content) {
231
- return false;
232
- }
233
- // 保留样式元素
229
+ // 排除样式元素
234
230
  if (
235
231
  stylesToApply &&
236
232
  child instanceof HTMLStyleElement &&
@@ -238,38 +234,72 @@ export abstract class LightComponent extends BaseComponent {
238
234
  ) {
239
235
  return false;
240
236
  }
241
- // 保留 JSX children(通过 JSX factory 直接添加的 children)
237
+ // 排除 JSX children
242
238
  if (child instanceof HTMLElement && jsxChildren.includes(child)) {
243
239
  return false;
244
240
  }
245
- // 保留未标记的元素(手动创建的元素、第三方库注入的元素)
246
- // 这是 RFC 0037 Phase 5 的核心:保护未标记元素
247
- if (shouldPreserveElement(child)) {
248
- return false;
249
- }
250
241
  return true;
251
242
  });
252
- oldChildren.forEach((child) => child.remove());
243
+
244
+ // 🔥 关键修复:实现真正的 DOM reconciliation
245
+ // 而不是简单的删除+添加,我们需要:
246
+ // 1. 如果新旧内容是相同类型的元素,更新其属性
247
+ // 2. 如果类型不同,才替换元素
248
+
249
+ if (oldChildren.length === 1 && newContent instanceof HTMLElement) {
250
+ const oldElement = oldChildren[0];
251
+
252
+ // 如果旧元素和新元素是相同类型的标签,更新属性而不是替换
253
+ if (
254
+ oldElement instanceof HTMLElement &&
255
+ oldElement.tagName === newContent.tagName
256
+ ) {
257
+ // 更新属性
258
+ // 1. 移除旧属性
259
+ Array.from(oldElement.attributes).forEach((attr) => {
260
+ if (!newContent.hasAttribute(attr.name)) {
261
+ oldElement.removeAttribute(attr.name);
262
+ }
263
+ });
264
+
265
+ // 2. 设置/更新新属性
266
+ Array.from(newContent.attributes).forEach((attr) => {
267
+ if (oldElement.getAttribute(attr.name) !== attr.value) {
268
+ oldElement.setAttribute(attr.name, attr.value);
269
+ }
270
+ });
271
+
272
+ // 3. 递归更新子元素
273
+ reconcileElement(oldElement, newContent);
274
+ } else {
275
+ // 类型不同,直接替换
276
+ oldElement.remove();
277
+ this.appendChild(newContent);
278
+ }
279
+ } else {
280
+ // 数量不匹配或者不是单个元素,使用简单替换
281
+ oldChildren.forEach((child) => child.remove());
282
+ this.appendChild(newContent);
283
+ }
253
284
 
254
285
  // 确保样式元素存在并在第一个位置
255
- // 关键修复:在元素复用场景中,如果 _autoStyles 存在但样式元素被意外移除,需要重新创建
256
286
  if (stylesToApply) {
257
- let styleElement = this.querySelector(
287
+ let styleEl = this.querySelector(
258
288
  `style[data-wsx-light-component="${styleName}"]`
259
289
  ) as HTMLStyleElement | null;
260
290
 
261
- if (!styleElement) {
291
+ if (!styleEl) {
262
292
  // 样式元素被意外移除,重新创建
263
- styleElement = document.createElement("style");
264
- styleElement.setAttribute("data-wsx-light-component", styleName);
265
- styleElement.textContent = stylesToApply;
266
- this.insertBefore(styleElement, this.firstChild);
267
- } else if (styleElement.textContent !== stylesToApply) {
293
+ styleEl = document.createElement("style");
294
+ styleEl.setAttribute("data-wsx-light-component", styleName);
295
+ styleEl.textContent = stylesToApply;
296
+ this.insertBefore(styleEl, this.firstChild);
297
+ } else if (styleEl.textContent !== stylesToApply) {
268
298
  // 样式内容已变化,更新
269
- styleElement.textContent = stylesToApply;
270
- } else if (styleElement !== this.firstChild) {
299
+ styleEl.textContent = stylesToApply;
300
+ } else if (styleEl !== this.firstChild) {
271
301
  // 样式元素存在但不在第一个位置,移动到第一个位置
272
- this.insertBefore(styleElement, this.firstChild);
302
+ this.insertBefore(styleEl, this.firstChild);
273
303
  }
274
304
  }
275
305
 
@@ -51,9 +51,15 @@ export function parseHTMLToNodes(html: string): (HTMLElement | SVGElement | stri
51
51
  // Text nodes are converted to strings, elements are kept as-is
52
52
  return Array.from(temp.childNodes).map((node) => {
53
53
  if (node instanceof HTMLElement || node instanceof SVGElement) {
54
+ // 关键修复:标记解析出的元素为框架管理
55
+ // 这确保了 shouldPreserveElement 不会错误地保留这些元素
56
+ // 从而防止了在频繁重渲染(如 Markdown 输入)时的元素堆积
57
+ (node as any).__wsxManaged = true;
54
58
  return node;
55
59
  } else {
56
60
  // Convert text nodes and other node types to strings
61
+ // Note: When these strings are processed by appendChildrenToElement,
62
+ // they will be wraped in managed text nodes.
57
63
  return node.textContent || "";
58
64
  }
59
65
  });
@@ -79,7 +85,13 @@ export function isHTMLString(str: string): boolean {
79
85
  const looksLikeMath = /^[^<]*<[^>]*>[^>]*$/.test(trimmed) && !htmlTagPattern.test(trimmed);
80
86
  if (looksLikeMath) return false;
81
87
 
82
- return htmlTagPattern.test(trimmed);
88
+ const result = htmlTagPattern.test(trimmed);
89
+ if (result) {
90
+ console.log(`[WSX Debug] isHTMLString("${trimmed.substring(0, 50)}..."): ${result}`, {
91
+ looksLikeMath,
92
+ });
93
+ }
94
+ return result;
83
95
  }
84
96
 
85
97
  /**
@@ -68,15 +68,17 @@ export function shouldPreserveElement(element: Node): boolean {
68
68
  }
69
69
 
70
70
  // 规则 2: 没有标记的元素保留(自定义元素、第三方库注入)
71
- if (!isCreatedByH(element)) {
71
+ // 关键修正:除了检查是否由 h() 创建,还要检查是否被框架显式标记为托管 (__wsxManaged)
72
+ // 这主要用于处理从 HTML 字符串解析出的元素 (parseHTMLToNodes)
73
+ if (!isCreatedByH(element) && (element as any).__wsxManaged !== true) {
72
74
  return true;
73
75
  }
74
76
 
75
77
  // 规则 3: 显式标记保留
76
- if (element.hasAttribute("data-wsx-preserve")) {
78
+ if (element instanceof HTMLElement && element.hasAttribute("data-wsx-preserve")) {
77
79
  return true;
78
80
  }
79
81
 
80
- // 规则 4: 由 h() 创建的元素 → 不保留(由框架管理)
82
+ // 规则 4: 由 h() 创建或被标记为托管的元素 → 不保留(由框架管理)
81
83
  return false;
82
84
  }
@@ -23,6 +23,7 @@ import {
23
23
  removeNodes,
24
24
  reinsertPreservedElements,
25
25
  flattenChildrenSafe,
26
+ replaceOrInsertElementAtPosition,
26
27
  } from "./update-children-helpers";
27
28
 
28
29
  /**
@@ -435,21 +436,16 @@ export function updateChildren(
435
436
  ? element.childNodes[insertionIndex.value]
436
437
  : null;
437
438
 
438
- // 即使 newChild === oldNode,如果位置不对也需要移动
439
- // 使用 insertBeforeNode 确保它在正确的位置
440
- if (newChild === oldNode) {
441
- if (newChild.nextSibling !== insertBeforeNode) {
442
- element.insertBefore(newChild, insertBeforeNode);
443
- }
444
- } else {
445
- // RFC 0053 关键修复:直接使用 insertBefore 确保插入到正确位置
446
- // replaceOrInsertElement 会尝试基于 oldNode (此处为 insertBeforeNode) 的 nextSibling 插入,
447
- // 这对于旨在基于位置插入的场景会导致 Off-By-One 错误(插入到了后面)
448
- element.insertBefore(newChild, insertBeforeNode);
449
- }
439
+ // 甚至 newChild === oldNode,如果位置不对也需要移动
440
+ // 使用 helper 处理元素替换和插入,支持 HTML 解析内容的自动等价性匹配
441
+ replaceOrInsertElementAtPosition(
442
+ element,
443
+ newChild as HTMLElement | SVGElement,
444
+ oldNode,
445
+ insertBeforeNode,
446
+ processedNodes
447
+ );
450
448
 
451
- // 标记新元素为已处理
452
- processedNodes.add(newChild);
453
449
  insertionIndex.value++;
454
450
  } else {
455
451
  // 类型变化:元素 -> 文本/Fragment
@@ -538,3 +534,85 @@ export function updateElement(
538
534
  // 更新 children
539
535
  updateChildren(element, oldChildren, newChildren, cacheManager);
540
536
  }
537
+
538
+ /**
539
+ * Recursively reconciles an old element to match a new element's structure.
540
+ * This is a pure function that updates the oldParent in-place to match newParent.
541
+ *
542
+ * Used by LightComponent to update DOM without full replacement, preserving:
543
+ * - Element references (important for event listeners, focus state)
544
+ * - User-added JSX children
545
+ * - Third-party library injected elements
546
+ *
547
+ * @param oldParent - The existing DOM element to update
548
+ * @param newParent - The new element structure to match
549
+ */
550
+ export function reconcileElement(oldParent: HTMLElement, newParent: HTMLElement): void {
551
+ const oldChildren = Array.from(oldParent.childNodes);
552
+ const newChildren = Array.from(newParent.childNodes);
553
+
554
+ const maxLength = Math.max(oldChildren.length, newChildren.length);
555
+
556
+ for (let i = 0; i < maxLength; i++) {
557
+ const oldChild = oldChildren[i];
558
+ const newChild = newChildren[i];
559
+
560
+ if (!newChild) {
561
+ // 新的子节点不存在,删除旧的
562
+ oldChild?.remove();
563
+ } else if (!oldChild) {
564
+ // 旧的子节点不存在,添加新的
565
+ oldParent.appendChild(newChild.cloneNode(true));
566
+ } else if (oldChild.nodeType !== newChild.nodeType) {
567
+ // 节点类型不同,替换
568
+ oldParent.replaceChild(newChild.cloneNode(true), oldChild);
569
+ } else if (oldChild.nodeType === Node.TEXT_NODE) {
570
+ // 文本节点,更新内容
571
+ if (oldChild.textContent !== newChild.textContent) {
572
+ oldChild.textContent = newChild.textContent;
573
+ }
574
+ } else if (oldChild.nodeType === Node.ELEMENT_NODE) {
575
+ const oldEl = oldChild as HTMLElement;
576
+ const newEl = newChild as HTMLElement;
577
+
578
+ // 元素节点
579
+ if (oldEl.tagName !== newEl.tagName) {
580
+ // 标签不同,替换
581
+ oldParent.replaceChild(newEl.cloneNode(true), oldEl);
582
+ } else {
583
+ // 标签相同,更新属性
584
+ // 1. 移除旧属性
585
+ Array.from(oldEl.attributes).forEach((attr) => {
586
+ if (!newEl.hasAttribute(attr.name)) {
587
+ oldEl.removeAttribute(attr.name);
588
+ }
589
+ });
590
+
591
+ // 2. 设置/更新新属性
592
+ Array.from(newEl.attributes).forEach((attr) => {
593
+ if (oldEl.getAttribute(attr.name) !== attr.value) {
594
+ oldEl.setAttribute(attr.name, attr.value);
595
+ }
596
+ });
597
+
598
+ // 3. 特殊处理:className 是 property,不是 attribute
599
+ if (oldEl.className !== newEl.className) {
600
+ oldEl.className = newEl.className;
601
+ }
602
+
603
+ // 4. 特殊处理:对于 input 元素,更新 checked 和 value 属性
604
+ if (oldEl instanceof HTMLInputElement && newEl instanceof HTMLInputElement) {
605
+ if (oldEl.checked !== newEl.checked) {
606
+ oldEl.checked = newEl.checked;
607
+ }
608
+ if (oldEl.value !== newEl.value) {
609
+ oldEl.value = newEl.value;
610
+ }
611
+ }
612
+
613
+ // 5. 递归更新子元素
614
+ reconcileElement(oldEl, newEl);
615
+ }
616
+ }
617
+ }
618
+ }
@@ -207,7 +207,8 @@ export function replaceOrInsertElementAtPosition(
207
207
  parent: HTMLElement | SVGElement,
208
208
  newChild: HTMLElement | SVGElement,
209
209
  oldNode: Node | null,
210
- targetNextSibling: Node | null
210
+ targetNextSibling: Node | null,
211
+ processedNodes?: Set<Node>
211
212
  ): void {
212
213
  // 如果新元素已经在其他父元素中,先移除
213
214
  if (newChild.parentNode && newChild.parentNode !== parent) {
@@ -215,11 +216,15 @@ export function replaceOrInsertElementAtPosition(
215
216
  }
216
217
 
217
218
  // 检查元素是否已经在正确位置
219
+ // 1. 如果 newChild.nextSibling === targetNextSibling,说明在正确位置的前面
220
+ // 2. 关键修复:如果 targetNextSibling === newChild,说明 newChild 就在目标位置(它本身就是下一个节点)
218
221
  const isInCorrectPosition =
219
- newChild.parentNode === parent && newChild.nextSibling === targetNextSibling;
222
+ newChild.parentNode === parent &&
223
+ (newChild.nextSibling === targetNextSibling || targetNextSibling === newChild);
220
224
 
221
225
  if (isInCorrectPosition) {
222
226
  // 已经在正确位置,不需要移动
227
+ if (processedNodes) processedNodes.add(newChild);
223
228
  return;
224
229
  }
225
230
 
@@ -227,25 +232,40 @@ export function replaceOrInsertElementAtPosition(
227
232
  if (newChild.parentNode === parent) {
228
233
  // insertBefore 会自动处理:如果元素已经在 DOM 中,会先移除再插入到新位置
229
234
  parent.insertBefore(newChild, targetNextSibling);
235
+ if (processedNodes) processedNodes.add(newChild);
230
236
  return;
231
237
  }
232
238
 
233
- // 元素不在 parent 中,需要插入或替换
239
+ // 元素不在 parent 中,或者需要替换
234
240
  if (oldNode && oldNode.parentNode === parent && !shouldPreserveElement(oldNode)) {
235
241
  if (oldNode !== newChild) {
242
+ // RFC 0048 & RFC 0053 关键修正:特殊处理从 HTML 字符串解析出的元素
243
+ // 如果旧节点和新节点都没有 cache key (说明是 HTML 解析出的) 且内容相同
244
+ // 则保留旧节点,不进行替换。这解决了 Markdown 渲染中的 DOM 抖动。
245
+ const oldCacheKey = getElementCacheKey(oldNode as HTMLElement | SVGElement);
246
+ const newCacheKey = getElementCacheKey(newChild);
247
+
248
+ if (!oldCacheKey && !newCacheKey && (oldNode as any).__wsxManaged === true) {
249
+ const oldTag = (oldNode as HTMLElement | SVGElement).tagName.toLowerCase();
250
+ const newTag = newChild.tagName.toLowerCase();
251
+ if (oldTag === newTag && oldNode.textContent === newChild.textContent) {
252
+ // 内容相同,逻辑上旧节点已经“处理”过了
253
+ if (processedNodes) processedNodes.add(oldNode);
254
+ return;
255
+ }
256
+ }
257
+
236
258
  // 关键修复:在替换元素之前,标记旧元素内的所有文本节点为已处理
237
- // 这样可以防止在移除阶段被误删
238
- // 注意:这里只标记直接子文本节点,不递归处理嵌套元素内的文本节点
239
- // 因为 replaceChild 会一起处理元素及其所有子节点
240
259
  parent.replaceChild(newChild, oldNode);
260
+ if (processedNodes) processedNodes.add(newChild);
261
+ } else {
262
+ // 已经是同一个节点
263
+ if (processedNodes) processedNodes.add(newChild);
241
264
  }
242
265
  } else {
243
- // RFC 0048 关键修复:在插入元素之前,检查是否已经存在相同内容的元素
244
- // 如果 newChild 已经在 parent 中,不应该再次插入
245
- // 如果 parent 中已经存在相同标签名和内容的元素,也不应该插入
246
- // 这样可以防止重复插入相同的元素(特别是从 HTML 字符串解析而来的元素)
266
+ // ... 继续原有的插入逻辑 ...
247
267
  if (newChild.parentNode === parent) {
248
- // 元素已经在 parent 中,不需要再次插入
268
+ // 已经在正确位置,不需要再次插入
249
269
  return;
250
270
  }
251
271
  // RFC 0048 关键修复:检查是否已经存在相同内容的元素
@@ -272,6 +292,13 @@ export function replaceOrInsertElementAtPosition(
272
292
  ) {
273
293
  // 找到相同内容的元素(且都没有 cache key),不需要插入 newChild
274
294
  // 这是从 HTML 字符串解析而来的重复元素
295
+ // 关键修复:必须将其标记为已处理,否则会被 shouldRemoveNode 移除
296
+ console.log(
297
+ "[WSX Debug] Found duplicate content, keeping existing:",
298
+ existingNode.tagName,
299
+ existingNode.textContent
300
+ );
301
+ if (processedNodes) processedNodes.add(existingNode);
275
302
  return;
276
303
  }
277
304
  }
@@ -405,11 +432,11 @@ export function shouldRemoveNode(
405
432
 
406
433
  const isProcessed = processedNodes && processedNodes.has(node);
407
434
 
408
- if (shouldPreserveElement(node)) {
435
+ if (isProcessed) {
409
436
  return false;
410
437
  }
411
438
 
412
- if (node.nodeType === Node.TEXT_NODE && isProcessed) {
439
+ if (shouldPreserveElement(node)) {
413
440
  return false;
414
441
  }
415
442