@wsxjs/eslint-plugin-wsx 0.0.19 → 0.0.21

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.
package/dist/index.js CHANGED
@@ -550,6 +550,125 @@ var i18nAfterAutoRegister = {
550
550
  }
551
551
  };
552
552
 
553
+ // src/rules/no-duplicate-keys.ts
554
+ var noDuplicateKeys = {
555
+ meta: {
556
+ type: "problem",
557
+ docs: {
558
+ description: "disallow using the same key in different parent containers",
559
+ category: "Possible Errors",
560
+ recommended: true
561
+ },
562
+ messages: {
563
+ duplicateKey: 'Duplicate key "{{key}}" found in different parent containers ({{parent1}} and {{parent2}}). This will cause DOM cache conflicts. Use unique key prefixes like key="{{parent1}}-{{key}}" and key="{{parent2}}-{{key}}".'
564
+ },
565
+ schema: []
566
+ // 无配置选项
567
+ },
568
+ create(context) {
569
+ const functionKeyMap = /* @__PURE__ */ new Map();
570
+ const functionStack = [];
571
+ function getParentJSXName(node) {
572
+ let parent = node.parent;
573
+ while (parent) {
574
+ if (parent.type === "JSXElement") {
575
+ const openingElement = parent.openingElement;
576
+ if (openingElement) {
577
+ const name = openingElement.name;
578
+ if (name) {
579
+ const nameValue = name.name || name;
580
+ if (typeof nameValue === "string") {
581
+ return nameValue;
582
+ }
583
+ if (nameValue.name) {
584
+ return nameValue.name;
585
+ }
586
+ }
587
+ }
588
+ }
589
+ parent = parent.parent;
590
+ }
591
+ return "unknown";
592
+ }
593
+ function getKeyValue(attr) {
594
+ const value = attr.value;
595
+ if (!value) {
596
+ return null;
597
+ }
598
+ if (value.type === "Literal") {
599
+ return String(value.value);
600
+ }
601
+ if (value.type === "JSXExpressionContainer") {
602
+ const expression = value.expression;
603
+ if (expression.type === "Identifier") {
604
+ return expression.name;
605
+ }
606
+ if (expression.type === "TemplateLiteral") {
607
+ const quasis = expression.quasis || [];
608
+ if (quasis.length > 0 && quasis[0].value) {
609
+ return quasis[0].value.raw || quasis[0].value.cooked;
610
+ }
611
+ }
612
+ }
613
+ return null;
614
+ }
615
+ return {
616
+ // Track function/method entry
617
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
618
+ ":function"(node) {
619
+ functionStack.push(node);
620
+ functionKeyMap.set(node, /* @__PURE__ */ new Map());
621
+ },
622
+ // Track function/method exit
623
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
624
+ ":function:exit"(_node) {
625
+ functionStack.pop();
626
+ },
627
+ // Check JSX elements for key attributes
628
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
629
+ JSXOpeningElement(node) {
630
+ if (functionStack.length === 0) {
631
+ return;
632
+ }
633
+ const currentFunction = functionStack[functionStack.length - 1];
634
+ const keyMap = functionKeyMap.get(currentFunction);
635
+ if (!keyMap) {
636
+ return;
637
+ }
638
+ const keyAttr = (node.attributes || []).find((attr) => {
639
+ return attr.type === "JSXAttribute" && attr.name && attr.name.name === "key";
640
+ });
641
+ if (!keyAttr) {
642
+ return;
643
+ }
644
+ const keyValue = getKeyValue(keyAttr);
645
+ if (!keyValue) {
646
+ return;
647
+ }
648
+ const parentName = getParentJSXName(node);
649
+ const existing = keyMap.get(keyValue);
650
+ if (existing && existing.parentName !== parentName) {
651
+ context.report({
652
+ node: keyAttr,
653
+ messageId: "duplicateKey",
654
+ data: {
655
+ key: keyValue,
656
+ parent1: existing.parentName,
657
+ parent2: parentName
658
+ }
659
+ });
660
+ } else if (!existing) {
661
+ keyMap.set(keyValue, {
662
+ key: keyValue,
663
+ parentName,
664
+ node: keyAttr
665
+ });
666
+ }
667
+ }
668
+ };
669
+ }
670
+ };
671
+
553
672
  // src/configs/recommended.ts
554
673
  var recommendedConfig = {
555
674
  parser: "@typescript-eslint/parser",
@@ -575,6 +694,7 @@ var recommendedConfig = {
575
694
  "wsx/no-null-render": "error",
576
695
  "wsx/no-inner-html": "error",
577
696
  "wsx/i18n-after-autoregister": "error",
697
+ "wsx/no-duplicate-keys": "error",
578
698
  // TypeScript 规则(推荐)
579
699
  "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
580
700
  "@typescript-eslint/no-explicit-any": "warn",
@@ -744,7 +864,8 @@ var plugin = {
744
864
  "require-jsx-import-source": requireJsxImportSource,
745
865
  "no-null-render": noNullRender,
746
866
  "no-inner-html": noInnerHTML,
747
- "i18n-after-autoregister": i18nAfterAutoRegister
867
+ "i18n-after-autoregister": i18nAfterAutoRegister,
868
+ "no-duplicate-keys": noDuplicateKeys
748
869
  },
749
870
  // 配置预设
750
871
  configs: {
package/dist/index.mjs CHANGED
@@ -521,6 +521,125 @@ var i18nAfterAutoRegister = {
521
521
  }
522
522
  };
523
523
 
524
+ // src/rules/no-duplicate-keys.ts
525
+ var noDuplicateKeys = {
526
+ meta: {
527
+ type: "problem",
528
+ docs: {
529
+ description: "disallow using the same key in different parent containers",
530
+ category: "Possible Errors",
531
+ recommended: true
532
+ },
533
+ messages: {
534
+ duplicateKey: 'Duplicate key "{{key}}" found in different parent containers ({{parent1}} and {{parent2}}). This will cause DOM cache conflicts. Use unique key prefixes like key="{{parent1}}-{{key}}" and key="{{parent2}}-{{key}}".'
535
+ },
536
+ schema: []
537
+ // 无配置选项
538
+ },
539
+ create(context) {
540
+ const functionKeyMap = /* @__PURE__ */ new Map();
541
+ const functionStack = [];
542
+ function getParentJSXName(node) {
543
+ let parent = node.parent;
544
+ while (parent) {
545
+ if (parent.type === "JSXElement") {
546
+ const openingElement = parent.openingElement;
547
+ if (openingElement) {
548
+ const name = openingElement.name;
549
+ if (name) {
550
+ const nameValue = name.name || name;
551
+ if (typeof nameValue === "string") {
552
+ return nameValue;
553
+ }
554
+ if (nameValue.name) {
555
+ return nameValue.name;
556
+ }
557
+ }
558
+ }
559
+ }
560
+ parent = parent.parent;
561
+ }
562
+ return "unknown";
563
+ }
564
+ function getKeyValue(attr) {
565
+ const value = attr.value;
566
+ if (!value) {
567
+ return null;
568
+ }
569
+ if (value.type === "Literal") {
570
+ return String(value.value);
571
+ }
572
+ if (value.type === "JSXExpressionContainer") {
573
+ const expression = value.expression;
574
+ if (expression.type === "Identifier") {
575
+ return expression.name;
576
+ }
577
+ if (expression.type === "TemplateLiteral") {
578
+ const quasis = expression.quasis || [];
579
+ if (quasis.length > 0 && quasis[0].value) {
580
+ return quasis[0].value.raw || quasis[0].value.cooked;
581
+ }
582
+ }
583
+ }
584
+ return null;
585
+ }
586
+ return {
587
+ // Track function/method entry
588
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
589
+ ":function"(node) {
590
+ functionStack.push(node);
591
+ functionKeyMap.set(node, /* @__PURE__ */ new Map());
592
+ },
593
+ // Track function/method exit
594
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
595
+ ":function:exit"(_node) {
596
+ functionStack.pop();
597
+ },
598
+ // Check JSX elements for key attributes
599
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
600
+ JSXOpeningElement(node) {
601
+ if (functionStack.length === 0) {
602
+ return;
603
+ }
604
+ const currentFunction = functionStack[functionStack.length - 1];
605
+ const keyMap = functionKeyMap.get(currentFunction);
606
+ if (!keyMap) {
607
+ return;
608
+ }
609
+ const keyAttr = (node.attributes || []).find((attr) => {
610
+ return attr.type === "JSXAttribute" && attr.name && attr.name.name === "key";
611
+ });
612
+ if (!keyAttr) {
613
+ return;
614
+ }
615
+ const keyValue = getKeyValue(keyAttr);
616
+ if (!keyValue) {
617
+ return;
618
+ }
619
+ const parentName = getParentJSXName(node);
620
+ const existing = keyMap.get(keyValue);
621
+ if (existing && existing.parentName !== parentName) {
622
+ context.report({
623
+ node: keyAttr,
624
+ messageId: "duplicateKey",
625
+ data: {
626
+ key: keyValue,
627
+ parent1: existing.parentName,
628
+ parent2: parentName
629
+ }
630
+ });
631
+ } else if (!existing) {
632
+ keyMap.set(keyValue, {
633
+ key: keyValue,
634
+ parentName,
635
+ node: keyAttr
636
+ });
637
+ }
638
+ }
639
+ };
640
+ }
641
+ };
642
+
524
643
  // src/configs/recommended.ts
525
644
  var recommendedConfig = {
526
645
  parser: "@typescript-eslint/parser",
@@ -546,6 +665,7 @@ var recommendedConfig = {
546
665
  "wsx/no-null-render": "error",
547
666
  "wsx/no-inner-html": "error",
548
667
  "wsx/i18n-after-autoregister": "error",
668
+ "wsx/no-duplicate-keys": "error",
549
669
  // TypeScript 规则(推荐)
550
670
  "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
551
671
  "@typescript-eslint/no-explicit-any": "warn",
@@ -715,7 +835,8 @@ var plugin = {
715
835
  "require-jsx-import-source": requireJsxImportSource,
716
836
  "no-null-render": noNullRender,
717
837
  "no-inner-html": noInnerHTML,
718
- "i18n-after-autoregister": i18nAfterAutoRegister
838
+ "i18n-after-autoregister": i18nAfterAutoRegister,
839
+ "no-duplicate-keys": noDuplicateKeys
719
840
  },
720
841
  // 配置预设
721
842
  configs: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wsxjs/eslint-plugin-wsx",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
4
4
  "description": "ESLint plugin for WSXJS",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -25,7 +25,7 @@
25
25
  "web-components"
26
26
  ],
27
27
  "dependencies": {
28
- "@wsxjs/wsx-core": "0.0.19"
28
+ "@wsxjs/wsx-core": "0.0.21"
29
29
  },
30
30
  "devDependencies": {
31
31
  "tsup": "^8.0.0",
@@ -29,6 +29,7 @@ export const recommendedConfig: WSXConfig = {
29
29
  "wsx/no-null-render": "error",
30
30
  "wsx/no-inner-html": "error",
31
31
  "wsx/i18n-after-autoregister": "error",
32
+ "wsx/no-duplicate-keys": "error",
32
33
 
33
34
  // TypeScript 规则(推荐)
34
35
  "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ import { requireJsxImportSource } from "./rules/require-jsx-import-source";
13
13
  import { noNullRender } from "./rules/no-null-render";
14
14
  import { noInnerHTML } from "./rules/no-inner-html";
15
15
  import { i18nAfterAutoRegister } from "./rules/i18n-after-autoregister";
16
+ import { noDuplicateKeys } from "./rules/no-duplicate-keys";
16
17
  import { recommendedConfig } from "./configs/recommended";
17
18
  import { createFlatConfig } from "./configs/flat";
18
19
  import { WSXPlugin } from "./types";
@@ -34,6 +35,7 @@ const plugin: WSXPlugin = {
34
35
  "no-null-render": noNullRender,
35
36
  "no-inner-html": noInnerHTML,
36
37
  "i18n-after-autoregister": i18nAfterAutoRegister,
38
+ "no-duplicate-keys": noDuplicateKeys,
37
39
  },
38
40
 
39
41
  // 配置预设
@@ -0,0 +1,192 @@
1
+ /**
2
+ * ESLint 规则:no-duplicate-keys
3
+ *
4
+ * 检测同一个 key 在不同父容器中使用的情况
5
+ * 这会导致 DOM 缓存冲突,元素被错误地移动到错误的容器中
6
+ */
7
+
8
+ import { Rule } from "eslint";
9
+ import { WSXRuleModule } from "../types";
10
+
11
+ interface KeyUsage {
12
+ key: string;
13
+ parentName: string;
14
+ node: unknown;
15
+ }
16
+
17
+ export const noDuplicateKeys: WSXRuleModule = {
18
+ meta: {
19
+ type: "problem",
20
+ docs: {
21
+ description: "disallow using the same key in different parent containers",
22
+ category: "Possible Errors",
23
+ recommended: true,
24
+ },
25
+ messages: {
26
+ duplicateKey:
27
+ 'Duplicate key "{{key}}" found in different parent containers ({{parent1}} and {{parent2}}). ' +
28
+ "This will cause DOM cache conflicts. " +
29
+ 'Use unique key prefixes like key="{{parent1}}-{{key}}" and key="{{parent2}}-{{key}}".',
30
+ },
31
+ schema: [], // 无配置选项
32
+ },
33
+ create(context: Rule.RuleContext) {
34
+ // Map<FunctionNode, Map<KeyValue, KeyUsage>>
35
+ const functionKeyMap = new Map<unknown, Map<string, KeyUsage>>();
36
+ const functionStack: unknown[] = [];
37
+
38
+ /**
39
+ * Gets the parent JSX element name
40
+ */
41
+ function getParentJSXName(node: unknown): string {
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
+ let parent = (node as any).parent;
44
+
45
+ // Traverse up to find the parent JSX element
46
+ while (parent) {
47
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
48
+ if ((parent as any).type === "JSXElement") {
49
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
50
+ const openingElement = (parent as any).openingElement;
51
+ if (openingElement) {
52
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
+ const name = (openingElement as any).name;
54
+ if (name) {
55
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
+ const nameValue = (name as any).name || name;
57
+ if (typeof nameValue === "string") {
58
+ return nameValue;
59
+ }
60
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
+ if ((nameValue as any).name) {
62
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
63
+ return (nameValue as any).name;
64
+ }
65
+ }
66
+ }
67
+ }
68
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
69
+ parent = (parent as any).parent;
70
+ }
71
+
72
+ return "unknown";
73
+ }
74
+
75
+ /**
76
+ * Extracts the key value from JSX attribute
77
+ */
78
+ function getKeyValue(attr: unknown): string | null {
79
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
80
+ const value = (attr as any).value;
81
+
82
+ if (!value) {
83
+ return null;
84
+ }
85
+
86
+ // String literal: key="value"
87
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
88
+ if ((value as any).type === "Literal") {
89
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
90
+ return String((value as any).value);
91
+ }
92
+
93
+ // Template literal: key={`prefix-${id}`}
94
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
95
+ if ((value as any).type === "JSXExpressionContainer") {
96
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
97
+ const expression = (value as any).expression;
98
+
99
+ // Simple identifier: key={itemId}
100
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
101
+ if ((expression as any).type === "Identifier") {
102
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
103
+ return (expression as any).name;
104
+ }
105
+
106
+ // Template literal: key={`prefix-${id}`}
107
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
108
+ if ((expression as any).type === "TemplateLiteral") {
109
+ // Extract the static parts
110
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
+ const quasis = (expression as any).quasis || [];
112
+ if (quasis.length > 0 && quasis[0].value) {
113
+ return quasis[0].value.raw || quasis[0].value.cooked;
114
+ }
115
+ }
116
+ }
117
+
118
+ return null;
119
+ }
120
+
121
+ return {
122
+ // Track function/method entry
123
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
124
+ ":function"(node: any) {
125
+ functionStack.push(node);
126
+ functionKeyMap.set(node, new Map());
127
+ },
128
+
129
+ // Track function/method exit
130
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
131
+ ":function:exit"(_node: any) {
132
+ functionStack.pop();
133
+ },
134
+
135
+ // Check JSX elements for key attributes
136
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
137
+ JSXOpeningElement(node: any) {
138
+ if (functionStack.length === 0) {
139
+ return;
140
+ }
141
+
142
+ const currentFunction = functionStack[functionStack.length - 1];
143
+ const keyMap = functionKeyMap.get(currentFunction);
144
+
145
+ if (!keyMap) {
146
+ return;
147
+ }
148
+
149
+ // Find the key attribute
150
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
151
+ const keyAttr = (node.attributes || []).find((attr: any) => {
152
+ return attr.type === "JSXAttribute" && attr.name && attr.name.name === "key";
153
+ });
154
+
155
+ if (!keyAttr) {
156
+ return;
157
+ }
158
+
159
+ const keyValue = getKeyValue(keyAttr);
160
+
161
+ if (!keyValue) {
162
+ return;
163
+ }
164
+
165
+ const parentName = getParentJSXName(node);
166
+
167
+ // Check if this key was already used in a different parent
168
+ const existing = keyMap.get(keyValue);
169
+
170
+ if (existing && existing.parentName !== parentName) {
171
+ // Found duplicate key in different parent!
172
+ context.report({
173
+ node: keyAttr,
174
+ messageId: "duplicateKey",
175
+ data: {
176
+ key: keyValue,
177
+ parent1: existing.parentName,
178
+ parent2: parentName,
179
+ },
180
+ });
181
+ } else if (!existing) {
182
+ // First time seeing this key, record it
183
+ keyMap.set(keyValue, {
184
+ key: keyValue,
185
+ parentName,
186
+ node: keyAttr,
187
+ });
188
+ }
189
+ },
190
+ };
191
+ },
192
+ };