From 82959d1973ca193a60a283c5f09aeb23c43a424e Mon Sep 17 00:00:00 2001 From: MicroDev <70126934+microdev1@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:03:04 +0530 Subject: [PATCH 1/6] fix incorrect squiggly underline length --- src/eslint/modules/processor.ts | 59 ++++++++++---- src/eslint/modules/transform.ts | 74 +++++++++++++++-- tests/eslint/processor.test.ts | 135 ++++++++++++++++++++++++++++---- tests/eslint/transform.test.ts | 43 +++++++++- 4 files changed, 273 insertions(+), 38 deletions(-) diff --git a/src/eslint/modules/processor.ts b/src/eslint/modules/processor.ts index f12686a..9097f4e 100644 --- a/src/eslint/modules/processor.ts +++ b/src/eslint/modules/processor.ts @@ -1,3 +1,4 @@ +import { Linter } from 'eslint' import { transformForLinting, TransformResult } from './transform' // Store transform metadata per file for postprocess to use @@ -25,7 +26,7 @@ export const processor = { return [result.code] }, - postprocess(messages: { ruleId: string | null; message: string }[][], filename: string) { + postprocess(messages: Linter.LintMessage[][], filename: string) { const result = transformCache.get(filename) if (!result) { return messages[0] @@ -33,27 +34,55 @@ export const processor = { transformCache.delete(filename) - // Build a regex to replace 'props.X' references in messages with the original local name - const { propMappings } = result - if (propMappings.size === 0) { + const { propAccess, restMapping } = result + if (propAccess.size === 0 && !restMapping) { return messages[0] } - // Create reverse mapping: propKey → localName - const keyToLocal = new Map() - for (const [localName, propKey] of propMappings) { - keyToLocal.set(propKey, localName) - } + // Track cumulative column shift per line from prior expansions + const lineShifts = new Map() return messages[0].map((msg) => { let { message } = msg - // Replace '_props.X' with 'X' (the original destructured name) in error messages - for (const [localName, propKey] of propMappings) { - // Handle both '_props.X' (top-level) and '_props.a.b' (nested) patterns - const propsAccess = `_props.${propKey}` - message = message.replaceAll(`'${propsAccess}'`, `'${localName}'`) + let lengthDelta = 0 + + for (const [localName, access] of propAccess) { + if (message.includes(`'${access}'`)) { + message = message.replaceAll(`'${access}'`, `'${localName}'`) + // Track length difference for endColumn adjustment (best-effort) + lengthDelta = access.length - localName.length + } + } + + // Replace generated rest identifier with original (e.g., _props → props) + if (restMapping) { + const restPattern = new RegExp(`'${restMapping.generated}(?:\\.|')`) + if (restPattern.test(message)) { + message = message.replaceAll(restMapping.generated, restMapping.original) + lengthDelta = restMapping.generated.length - restMapping.original.length + } } - return { ...msg, message } + + const adjusted = { ...msg, message } as typeof msg & { endColumn?: number } + + if (lengthDelta > 0 && typeof msg.column === 'number') { + const priorShift = lineShifts.get(msg.line) ?? 0 + + // Shift column back by cumulative prior expansions on this line + if (priorShift > 0) { + adjusted.column = msg.column - priorShift + } + + // Shrink the squiggly underline to match the original variable length + if (typeof msg.endColumn === 'number') { + adjusted.endColumn = msg.endColumn - priorShift - lengthDelta + } + + // Accumulate shift for subsequent messages on this line + lineShifts.set(msg.line, priorShift + lengthDelta) + } + + return adjusted }) }, diff --git a/src/eslint/modules/transform.ts b/src/eslint/modules/transform.ts index 7025894..c1966f6 100644 --- a/src/eslint/modules/transform.ts +++ b/src/eslint/modules/transform.ts @@ -15,6 +15,49 @@ export type TransformResult = { code: string /** Maps original local name → prop key (e.g. "localName" → "propKey") */ propMappings: Map + /** Maps original local name → full transformed access (e.g. "size" → "_props.size") */ + propAccess: Map + /** Maps generated rest identifier name → original rest name (e.g. "_props" → "props") */ + restMapping: { generated: string; original: string } | null +} + +/** + * Renames rest parameter references to the generated props identifier. + * When there's a rest element (e.g. `...props`), the full transform + * produces `const [, props] = splitProps(...)` keeping `props` defined. + * For linting we skip splitProps, so rename rest references to the + * generated identifier so that e.g. `props.class` → `_props.class`. + * Only renames actual references to the rest parameter, not shadowed variables. + */ +function renameRestReferences( + bodyPath: NodePath, + restIdentifier: t.Identifier, + propsIdName: string, + parentPath: NodePath +): void { + const restName = restIdentifier.name + const restBinding = parentPath.scope.getBinding(restName) + const restVisitor = { + Identifier(identPath: NodePath) { + const node = identPath.node + const parent = identPath.parent + if (node.name !== restName) return + + // Only rename if this identifier references the same binding as the rest parameter + const currentBinding = identPath.scope.getBinding(restName) + if (currentBinding !== restBinding) return + + if (identPath.isBindingIdentifier()) return + if (t.isMemberExpression(parent) && parent.property === node && !parent.computed) { + return + } + if (t.isObjectProperty(parent) && parent.key === node && !parent.computed) { + return + } + node.name = propsIdName + } + } + bodyPath.traverse(restVisitor) } /** @@ -28,7 +71,12 @@ export function transformForLinting(code: string): TransformResult | null { return null } - const propMappings = new Map() + const result: TransformResult = { + code: '', + propMappings: new Map(), + propAccess: new Map(), + restMapping: null + } let transformed = false @@ -52,16 +100,21 @@ export function transformForLinting(code: string): TransformResult | null { // Extract info using shared utility const info = extractPropsInfo(firstParam) - // Build propMappings from extracted info + // Generate a unique identifier to avoid conflicts with user-defined _props + const propsIdentifier = path.scope.generateUidIdentifier('props') + const propsIdName = propsIdentifier.name + + // Build propMappings and propAccess from extracted info for (const [localName, key] of info.localToKey) { - propMappings.set(localName, key) + result.propMappings.set(localName, key) + result.propAccess.set(localName, `${propsIdName}.${key}`) } for (const [localName, propPath] of info.nestedPropPaths) { - propMappings.set(localName, propPath.join('.')) + result.propMappings.set(localName, propPath.join('.')) + result.propAccess.set(localName, `${propsIdName}.${propPath.join('.')}`) } - // Replace parameter with _props identifier - const propsIdentifier = t.identifier('_props') + // Preserve TypeAnnotation from the destructured param if (firstParam.typeAnnotation) { propsIdentifier.typeAnnotation = firstParam.typeAnnotation } @@ -70,7 +123,12 @@ export function transformForLinting(code: string): TransformResult | null { // Replace references using shared utility const bodyPath = path.get('body') if (!Array.isArray(bodyPath)) { - replacePropsReferences(bodyPath, '_props', info) + replacePropsReferences(bodyPath, propsIdName, info) + + if (info.restIdentifier) { + result.restMapping = { generated: propsIdName, original: info.restIdentifier.name } + renameRestReferences(bodyPath, info.restIdentifier, propsIdName, path) + } } transformed = true @@ -87,7 +145,7 @@ export function transformForLinting(code: string): TransformResult | null { compact: false }) - return { code: output.code, propMappings } + return { ...result, code: output.code } } catch (error) { console.warn('Failed to transform:', error) return null diff --git a/tests/eslint/processor.test.ts b/tests/eslint/processor.test.ts index ae2324e..b95ba4c 100644 --- a/tests/eslint/processor.test.ts +++ b/tests/eslint/processor.test.ts @@ -1,5 +1,6 @@ import { processor } from '@src/eslint/modules/processor' import { describe, expect, test } from 'bun:test' +import { Linter } from 'eslint' describe('processor.preprocess', () => { test('transforms TSX files with destructured props', () => { @@ -9,7 +10,7 @@ describe('processor.preprocess', () => { } ` const [result] = processor.preprocess(code, 'Component.tsx') - expect(result).toContain('_props.size') + expect(result).toContain('props.size') }) test('returns original code for non-component files', () => { @@ -51,7 +52,8 @@ describe('processor.postprocess', () => { ruleId: 'solid/reactivity', message: "The reactive variable '_props.size' should be used within JSX.", line: 3, - column: 5 + column: 5, + endColumn: 16 } ] ] @@ -61,6 +63,11 @@ describe('processor.postprocess', () => { 'test-post.tsx' ) expect(result[0].message).toBe("The reactive variable 'size' should be used within JSX.") + const r = result[0] + // No prior expansions on this line, column stays the same + expect(r.column).toBe(5) + // endColumn shrinks: 16 - 7 = 9 + expect(r.endColumn).toBe(9) }) test('handles renamed props in messages', () => { @@ -71,40 +78,140 @@ describe('processor.postprocess', () => { ` processor.preprocess(code, 'test-renamed.tsx') - const messages = [ + const messages: Linter.LintMessage[][] = [ [ { ruleId: 'solid/reactivity', message: "The reactive variable '_props.size' should be used within JSX.", line: 3, - column: 5 + column: 5, + endColumn: 16, + severity: 2 } ] ] - const result = processor.postprocess( - messages as Parameters[0], - 'test-renamed.tsx' - ) + const result = processor.postprocess(messages, 'test-renamed.tsx') expect(result[0].message).toBe("The reactive variable 'mySize' should be used within JSX.") + const r = result[0] + expect(r.column).toBe(5) + // endColumn shrinks: 16 - 5 = 11 + expect(r.endColumn).toBe(11) + }) + + test('replaces rest mapping identifier in messages', () => { + const code = ` + function Component({ title, ...props }) { + return
{title}
+ } + ` + processor.preprocess(code, 'test-rest.tsx') + + const messages: Linter.LintMessage[][] = [ + [ + { + ruleId: 'no-undef', + message: "'_props' is not defined.", + line: 3, + column: 26, + endColumn: 32, + severity: 2 + } + ] + ] + + const result = processor.postprocess(messages, 'test-rest.tsx') + expect(result[0].message).toBe("'props' is not defined.") + // endColumn should shrink: 32 - ('_props'.length - 'props'.length) = 32 - 1 = 31 + expect(result[0].endColumn).toBe(31) + }) + + test('propAccess replacement takes priority over rest mapping', () => { + const code = ` + function Component({ title, ...props }) { + return
{title}
+ } + ` + processor.preprocess(code, 'test-rest-priority.tsx') + + const messages: Linter.LintMessage[][] = [ + [ + { + ruleId: 'solid/reactivity', + message: "The reactive variable '_props.title' should be used within JSX.", + line: 3, + column: 5, + endColumn: 18, + severity: 2 + } + ] + ] + + const result = processor.postprocess(messages, 'test-rest-priority.tsx') + // Should show 'title', not 'props.title' (propAccess should win over rest mapping) + expect(result[0].message).toBe("The reactive variable 'title' should be used within JSX.") + // endColumn should shrink: 18 - ('_props.title'.length - 'title'.length) = 18 - 7 = 11 + expect(result[0].endColumn).toBe(11) + }) + + test('adjusts columns for second prop occurrence on same line', () => { + const code = `export function Component({ + size = 'default', + ...props +}: { size?: 'default' | 'sm' } & Solid.ComponentProps<'pre'>) { + const dimension = size === 'default' ? ('md') : size + return
{dimension}
+}` + processor.preprocess(code, 'test-second-occurrence.tsx') + + const messages: Linter.LintMessage[][] = [ + [ + { + ruleId: 'solid/reactivity', + message: "The reactive variable '_props.size' should be used within JSX.", + line: 5, + column: 21, + endColumn: 32, + severity: 2 + }, + { + ruleId: 'solid/reactivity', + message: "The reactive variable '_props.size' should be used within JSX.", + line: 5, + column: 58, + endColumn: 69, + severity: 2 + } + ] + ] + + const result = processor.postprocess(messages, 'test-second-occurrence.tsx') + + // First occurrence: no prior expansions, column stays at 21 + const first = result[0] + expect(first.column).toBe(21) + expect(first.endColumn).toBe(25) // 21 + 'size'.length + + // Second occurrence: one prior expansion shifted columns by 7 + const second = result[1] + expect(second.column).toBe(51) // 58 - 7 + expect(second.endColumn).toBe(55) // 69 - 7 - 7 = 55 }) test('passes through messages unchanged for non-transformed files', () => { - const messages = [ + const messages: Linter.LintMessage[][] = [ [ { ruleId: 'some/rule', message: 'some error', line: 1, - column: 1 + column: 1, + severity: 2 } ] ] - const result = processor.postprocess( - messages as Parameters[0], - 'non-transformed.tsx' - ) + const result = processor.postprocess(messages, 'non-transformed.tsx') expect(result).toEqual(messages[0]) }) }) diff --git a/tests/eslint/transform.test.ts b/tests/eslint/transform.test.ts index 0c7eae4..3ada6de 100644 --- a/tests/eslint/transform.test.ts +++ b/tests/eslint/transform.test.ts @@ -107,6 +107,19 @@ describe('transformForLinting', () => { expect(code).not.toContain('_props.rest') }) + test('renames rest identifier references to generated props id', () => { + const code = transformCode(` + function Component({ title, ...props }) { + return
{title}
+ } + `) + expect(code).toContain('_props.title') + // props.class should become _props.class since the original `props` + // from the rest element is no longer defined after the transform + expect(code).toContain('_props.class') + expect(code).not.toMatch(/[^_]props\.class/) + }) + test('handles nested destructuring', () => { const code = transformCode(` function Component({ nested: { a, b } }) { @@ -147,7 +160,8 @@ describe('transformForLinting', () => { } `) expect(code).toContain('_props.a') - expect(code).toContain('_props.b') + // Second component may get _props2 due to scope-level uniqueness + expect(code).toMatch(/_props\d*\.b/) }) test('handles exported components', () => { @@ -158,4 +172,31 @@ describe('transformForLinting', () => { `) expect(code).toContain('_props.size') }) + + test('avoids conflict with user-defined _props variable', () => { + const code = transformCode(` + function Component({ size }) { + const _props = { extra: true } + return
{size}
+ } + `) + // Should NOT use _props since it's already taken; should use _props2 or similar + expect(code).not.toMatch(/function Component\(_props\)/) + expect(code).not.toContain('{ size }') + // The user-defined _props should be preserved + expect(code).toContain('_props.extra') + }) + + test('populates propAccess map with full access patterns', () => { + const result = transformForLinting(` + function Component({ size: mySize, color }) { + return
{mySize} {color}
+ } + `) + expect(result).not.toBeNull() + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(result!.propAccess.get('mySize')).toBe('_props.size') + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(result!.propAccess.get('color')).toBe('_props.color') + }) }) From 4bc6bbf74a885ca91fb2cf61ea25cb4e12318fee Mon Sep 17 00:00:00 2001 From: MicroDev <70126934+microdev1@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:20:43 +0530 Subject: [PATCH 2/6] mod minor fixes and updates --- README.md | 2 +- bun.lock | 25 ++++++++----------------- package.json | 2 +- src/eslint/index.ts | 24 ++++++++++++++---------- src/eslint/modules/processor.ts | 2 +- 5 files changed, 25 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 55b0a46..1bbd1fd 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ import solid from 'eslint-plugin-solid' import solidUndestructure from 'vite-plugin-solid-undestructure/eslint' export default [ - solidUndestructure.configs.recommended, + solidUndestructure.configs['flat/recommended'], solid.configs['flat/typescript'], { rules: { diff --git a/bun.lock b/bun.lock index aeb1449..bacd469 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,6 @@ "@babel/parser": "^7.29.0", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", - "eslint-plugin-solid": "^0.14.5", }, "devDependencies": { "@types/babel__core": "^7.20.5", @@ -24,8 +23,14 @@ "typescript-eslint": "^8.56.1", }, "peerDependencies": { + "eslint": "^9.0.0", + "eslint-plugin-solid": "^0.14.5", "vite": "^7.3.1", }, + "optionalPeers": [ + "eslint", + "eslint-plugin-solid", + ], }, }, "packages": { @@ -191,7 +196,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -233,7 +238,7 @@ "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], @@ -259,8 +264,6 @@ "eslint": ["eslint@9.39.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg=="], - "eslint-plugin-solid": ["eslint-plugin-solid@0.14.5", "", { "dependencies": { "@typescript-eslint/utils": "^7.13.1 || ^8.0.0", "estraverse": "^5.3.0", "is-html": "^2.0.0", "kebab-case": "^1.0.2", "known-css-properties": "^0.30.0", "style-to-object": "^1.0.6" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", "typescript": ">=4.8.4" } }, "sha512-nfuYK09ah5aJG/oEN6P1qziy1zLgW4PDWe75VNPi4CEFYk1x2AEqwFeQfEPR7gNn0F2jOeqKhx2E+5oNCOBYWQ=="], - "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], @@ -299,22 +302,16 @@ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "html-tags": ["html-tags@3.3.1", "", {}, "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ=="], - "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-html": ["is-html@2.0.0", "", { "dependencies": { "html-tags": "^3.0.0" } }, "sha512-S+OpgB5i7wzIue/YSE5hg0e5ZYfG3hhpNh9KGl6ayJ38p7ED6wxQLd1TV91xHpcTvw90KMJ9EwN3F/iNflHBVg=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -329,12 +326,8 @@ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], - "kebab-case": ["kebab-case@1.0.2", "", {}, "sha512-7n6wXq4gNgBELfDCpzKc+mRrZFs7D+wgfF5WRFLNAr4DA/qtr9Js8uOAVAfHhuLMfAcQ0pRKqbpjx+TcJVdE1Q=="], - "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - "known-css-properties": ["known-css-properties@0.30.0", "", {}, "sha512-VSWXYUnsPu9+WYKkfmJyLKtIvaRJi1kXUqVmBACORXZQxT5oZDsoZ2vQP+bQFDnWtpI/4eq3MLoRMjI2fnLzTQ=="], - "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -389,8 +382,6 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], diff --git a/package.json b/package.json index ed02b8b..dd28a5a 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@types/babel__core": "^7.20.5", "@types/babel__generator": "^7.27.0", "@types/babel__traverse": "^7.28.0", - "@types/bun": "^1.3.9", + "@types/bun": "^1.3.10", "config": "link:config", "eslint": "^9.39.3", "prettier": "^3.8.1", diff --git a/src/eslint/index.ts b/src/eslint/index.ts index bec9646..e3dbbb4 100644 --- a/src/eslint/index.ts +++ b/src/eslint/index.ts @@ -1,21 +1,25 @@ +import { ESLint } from 'eslint' import { processor } from './modules/processor' -const plugin = { +const configs: ESLint.Plugin['configs'] = {} + +const plugin: ESLint.Plugin = { meta: { - name: 'eslint-plugin-solid-undestructure', - version: '0.1.1' + name: 'eslint-plugin-solid-undestructure' }, processors: { 'solid-undestructure': processor }, - configs: {} as Record; processor: string }> + configs } -plugin.configs['recommended'] = { - plugins: { - 'solid-undestructure': plugin - }, - processor: 'solid-undestructure/solid-undestructure' -} +Object.assign(configs, { + 'flat/recommended': { + plugins: { + 'solid-undestructure': plugin + }, + processor: 'solid-undestructure/solid-undestructure' + } +}) export default plugin diff --git a/src/eslint/modules/processor.ts b/src/eslint/modules/processor.ts index 9097f4e..175ae48 100644 --- a/src/eslint/modules/processor.ts +++ b/src/eslint/modules/processor.ts @@ -4,7 +4,7 @@ import { transformForLinting, TransformResult } from './transform' // Store transform metadata per file for postprocess to use const transformCache = new Map() -export const processor = { +export const processor: Linter.Processor = { meta: { name: 'solid-undestructure', version: '0.1.1' From 73c78bbc6808527fa86b07efbb915788958828fc Mon Sep 17 00:00:00 2001 From: MicroDev <70126934+microdev1@users.noreply.github.com> Date: Tue, 3 Mar 2026 18:30:02 +0530 Subject: [PATCH 3/6] mod optimize rest only spread --- package.json | 2 +- src/eslint/modules/processor.ts | 3 +-- src/eslint/modules/transform.ts | 11 +++++++++++ src/modules/props-transformer.ts | 13 ++++++++++--- tests/eslint/transform.test.ts | 11 +++++++++++ tests/props-transformer.test.ts | 12 ++++++++++++ 6 files changed, 46 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index dd28a5a..b0f2fc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vite-plugin-solid-undestructure", - "version": "0.2.1", + "version": "0.2.2", "description": "Automatically transforms props destructuring in SolidJS components", "type": "module", "main": "./dist/index.js", diff --git a/src/eslint/modules/processor.ts b/src/eslint/modules/processor.ts index 175ae48..7d4b81b 100644 --- a/src/eslint/modules/processor.ts +++ b/src/eslint/modules/processor.ts @@ -6,8 +6,7 @@ const transformCache = new Map() export const processor: Linter.Processor = { meta: { - name: 'solid-undestructure', - version: '0.1.1' + name: 'solid-undestructure' }, preprocess(text: string, filename: string) { diff --git a/src/eslint/modules/transform.ts b/src/eslint/modules/transform.ts index c1966f6..073e0ea 100644 --- a/src/eslint/modules/transform.ts +++ b/src/eslint/modules/transform.ts @@ -100,6 +100,17 @@ export function transformForLinting(code: string): TransformResult | null { // Extract info using shared utility const info = extractPropsInfo(firstParam) + // If only a rest element with no other props, just rename the parameter + const restOnly = info.hasRestElement && info.propsToSplit.length === 0 && info.localNames.length === 0 + if (restOnly && info.restIdentifier) { + if (firstParam.typeAnnotation) { + info.restIdentifier.typeAnnotation = firstParam.typeAnnotation + } + path.node.params[0] = info.restIdentifier + transformed = true + return + } + // Generate a unique identifier to avoid conflicts with user-defined _props const propsIdentifier = path.scope.generateUidIdentifier('props') const propsIdName = propsIdentifier.name diff --git a/src/modules/props-transformer.ts b/src/modules/props-transformer.ts index aa4184e..27b2964 100644 --- a/src/modules/props-transformer.ts +++ b/src/modules/props-transformer.ts @@ -184,12 +184,19 @@ export function transformPropsDestructuring( const info = extractPropsInfo(objectPattern) const { propsToSplit, defaultValues, hasRestElement, restIdentifier } = info - // Replace parameter with single props identifier - path.node.params[0] = propsIdentifier - // Create statements to add at the beginning of function body const newStatements: t.Statement[] = [] + // If only a rest element with no other props or defaults, just rename the parameter + const restOnly = hasRestElement && propsToSplit.length === 0 && Object.keys(defaultValues).length === 0 + if (restOnly && restIdentifier) { + path.node.params[0] = restIdentifier + return + } + + // Replace parameter with single props identifier + path.node.params[0] = propsIdentifier + // Add import for mergeProps and splitProps if needed const needsMergeProps = Object.keys(defaultValues).length > 0 const needsSplitProps = propsToSplit.length > 0 || hasRestElement diff --git a/tests/eslint/transform.test.ts b/tests/eslint/transform.test.ts index 3ada6de..f89ea59 100644 --- a/tests/eslint/transform.test.ts +++ b/tests/eslint/transform.test.ts @@ -187,6 +187,17 @@ describe('transformForLinting', () => { expect(code).toContain('_props.extra') }) + test('rest-only spread skips splitProps and uses rest name directly', () => { + const code = transformCode(` + function Component({ ...props }) { + return
+ } + `) + expect(code).toContain('function Component(props)') + expect(code).not.toContain('splitProps') + expect(code).not.toContain('_props') + }) + test('populates propAccess map with full access patterns', () => { const result = transformForLinting(` function Component({ size: mySize, color }) { diff --git a/tests/props-transformer.test.ts b/tests/props-transformer.test.ts index 7f02b5c..8f05dc4 100644 --- a/tests/props-transformer.test.ts +++ b/tests/props-transformer.test.ts @@ -67,6 +67,18 @@ function Card({ title, ...props }) { expectContains(out, '"title"') expectContains(out, 'props') }) + + test('rest-only spread skips splitProps', () => { + const code = ` +function Component({ ...props }) { + return
+} +` + const out = transformOrThrow(code) + expectNotContains(out, 'splitProps') + expectContains(out, 'props') + expectNotMatches(out, /function Component\(\s*\{/) + }) }) // ─── Nested Destructuring ──────────────────────────────────────────────────── From d7f53d59d704fe4da8e39f1bfc501f9c92ef828a Mon Sep 17 00:00:00 2001 From: MicroDev <70126934+microdev1@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:25:16 +0530 Subject: [PATCH 4/6] mod refactor code into package specific dir --- package.json | 5 +- src/eslint/modules/transform.ts | 121 ++++++---------- src/index.ts | 80 ----------- src/modules/babel.ts | 68 +++++++++ src/modules/component-detector-legacy.ts | 17 --- .../{props-transformer.ts => transform.ts} | 128 ----------------- src/vite/index.ts | 31 ++++ src/vite/modules/transform.ts | 132 ++++++++++++++++++ tests/helpers.ts | 2 +- .../{ => modules}/component-detector.test.ts | 2 +- tests/{ => modules}/import-manager.test.ts | 2 +- tests/{ => modules}/skipping.test.ts | 2 +- .../transform.test.ts} | 2 +- 13 files changed, 281 insertions(+), 311 deletions(-) delete mode 100644 src/index.ts create mode 100644 src/modules/babel.ts delete mode 100644 src/modules/component-detector-legacy.ts rename src/modules/{props-transformer.ts => transform.ts} (56%) create mode 100644 src/vite/index.ts create mode 100644 src/vite/modules/transform.ts rename tests/{ => modules}/component-detector.test.ts (98%) rename tests/{ => modules}/import-manager.test.ts (99%) rename tests/{ => modules}/skipping.test.ts (95%) rename tests/{props-transformer.test.ts => modules/transform.test.ts} (99%) diff --git a/package.json b/package.json index b0f2fc0..67aa4cd 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,10 @@ ], "license": "MIT", "scripts": { - "build": "bun build src/index.ts --minify --packages=external --outfile=dist/index.js && bun build src/eslint/index.ts --minify --packages=external --outfile=dist/eslint/index.js && tsc --declaration --emitDeclarationOnly --outDir dist", + "build": "bun build:type && bun build:vite && bun build:eslint", + "build:type": "tsc --declaration --emitDeclarationOnly --outDir dist", + "build:vite": "bun build src/vite/index.ts --minify --packages=external --outfile=dist/index.js", + "build:eslint": "bun build src/eslint/index.ts --minify --packages=external --outfile=dist/eslint/index.js", "type": "tsc --noEmit", "lint": "eslint --cache src", "format": "prettier --write *", diff --git a/src/eslint/modules/transform.ts b/src/eslint/modules/transform.ts index 073e0ea..4980ecd 100644 --- a/src/eslint/modules/transform.ts +++ b/src/eslint/modules/transform.ts @@ -1,15 +1,7 @@ -import generateImport from '@babel/generator' -import { parse } from '@babel/parser' -import traverseImport, { NodePath } from '@babel/traverse' +import { NodePath } from '@babel/traverse' import * as t from '@babel/types' -import { checkIfComponent } from '../../modules/component-detector' -import { extractPropsInfo, replacePropsReferences } from '../../modules/props-transformer' - -// Handle ESM/CJS interop -const traverse = - (traverseImport as unknown as { default?: typeof traverseImport }).default ?? traverseImport -const generate = - (generateImport as unknown as { default?: typeof generateImport }).default ?? generateImport +import { traverseComponentProps } from '../../modules/babel' +import { extractPropsInfo, replacePropsReferences } from '../../modules/transform' export type TransformResult = { code: string @@ -66,11 +58,6 @@ function renameRestReferences( * are added, only the patterns the eslint-plugin-solid reactivity rule needs to see. */ export function transformForLinting(code: string): TransformResult | null { - // Quick check for destructuring pattern - if (!/\(\s*\{/.test(code)) { - return null - } - const result: TransformResult = { code: '', propMappings: new Map(), @@ -78,84 +65,58 @@ export function transformForLinting(code: string): TransformResult | null { restMapping: null } - let transformed = false - try { - const ast = parse(code, { - sourceType: 'module', - plugins: ['typescript', 'jsx'] - }) - - const astNode = ast as unknown as t.Node - traverse(astNode, { - Function(path: NodePath) { - const params = path.node.params - if (params.length !== 1) return - - const firstParam = params[0] - if (!t.isObjectPattern(firstParam)) return - - if (!checkIfComponent(path)) return - - // Extract info using shared utility - const info = extractPropsInfo(firstParam) - - // If only a rest element with no other props, just rename the parameter - const restOnly = info.hasRestElement && info.propsToSplit.length === 0 && info.localNames.length === 0 - if (restOnly && info.restIdentifier) { - if (firstParam.typeAnnotation) { - info.restIdentifier.typeAnnotation = firstParam.typeAnnotation - } - path.node.params[0] = info.restIdentifier - transformed = true - return + const output = traverseComponentProps(code, (path, firstParam) => { + // Extract info using shared utility + const info = extractPropsInfo(firstParam) + + // If only a rest element with no other props, just rename the parameter + const restOnly = + info.hasRestElement && info.propsToSplit.length === 0 && info.localNames.length === 0 + if (restOnly && info.restIdentifier) { + if (firstParam.typeAnnotation) { + info.restIdentifier.typeAnnotation = firstParam.typeAnnotation } + path.node.params[0] = info.restIdentifier + return + } - // Generate a unique identifier to avoid conflicts with user-defined _props - const propsIdentifier = path.scope.generateUidIdentifier('props') - const propsIdName = propsIdentifier.name + // Generate a unique identifier to avoid conflicts with user-defined _props + const propsIdentifier = path.scope.generateUidIdentifier('props') + const propsIdName = propsIdentifier.name - // Build propMappings and propAccess from extracted info - for (const [localName, key] of info.localToKey) { - result.propMappings.set(localName, key) - result.propAccess.set(localName, `${propsIdName}.${key}`) - } - for (const [localName, propPath] of info.nestedPropPaths) { - result.propMappings.set(localName, propPath.join('.')) - result.propAccess.set(localName, `${propsIdName}.${propPath.join('.')}`) - } + // Build propMappings and propAccess from extracted info + for (const [localName, key] of info.localToKey) { + result.propMappings.set(localName, key) + result.propAccess.set(localName, `${propsIdName}.${key}`) + } + for (const [localName, propPath] of info.nestedPropPaths) { + result.propMappings.set(localName, propPath.join('.')) + result.propAccess.set(localName, `${propsIdName}.${propPath.join('.')}`) + } - // Preserve TypeAnnotation from the destructured param - if (firstParam.typeAnnotation) { - propsIdentifier.typeAnnotation = firstParam.typeAnnotation - } - path.node.params[0] = propsIdentifier + // Preserve TypeAnnotation from the destructured param + if (firstParam.typeAnnotation) { + propsIdentifier.typeAnnotation = firstParam.typeAnnotation + } + path.node.params[0] = propsIdentifier - // Replace references using shared utility - const bodyPath = path.get('body') - if (!Array.isArray(bodyPath)) { - replacePropsReferences(bodyPath, propsIdName, info) + // Replace references using shared utility + const bodyPath = path.get('body') + if (!Array.isArray(bodyPath)) { + replacePropsReferences(bodyPath, propsIdName, info) - if (info.restIdentifier) { - result.restMapping = { generated: propsIdName, original: info.restIdentifier.name } - renameRestReferences(bodyPath, info.restIdentifier, propsIdName, path) - } + if (info.restIdentifier) { + result.restMapping = { generated: propsIdName, original: info.restIdentifier.name } + renameRestReferences(bodyPath, info.restIdentifier, propsIdName, path) } - - transformed = true } }) - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!transformed) { + if (!output) { return null } - const output = generate(astNode, { - retainLines: true, - compact: false - }) - return { ...result, code: output.code } } catch (error) { console.warn('Failed to transform:', error) diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 8a90716..0000000 --- a/src/index.ts +++ /dev/null @@ -1,80 +0,0 @@ -import generateImport from '@babel/generator' -import { parse } from '@babel/parser' -import traverseImport, { NodePath } from '@babel/traverse' -import * as t from '@babel/types' -import { Plugin } from 'vite' -import { checkIfComponent } from './modules/component-detector' -import { transformPropsDestructuring } from './modules/props-transformer' - -// Handle ESM/CJS interop -const traverse = - (traverseImport as unknown as { default?: typeof traverseImport }).default ?? traverseImport -const generate = - (generateImport as unknown as { default?: typeof generateImport }).default ?? generateImport - -/** - * Vite plugin that transforms Solid.js component prop destructuring into reactive prop access. - * This ensures that props remain reactive by using mergeProps and splitProps instead of - * direct destructuring, which would break Solid's reactivity system. - */ -export default (): Plugin => ({ - name: 'solid-undestructure', - enforce: 'pre', - transform(code: string, id: string) { - // Only process TypeScript/JavaScript files in components - if (!/\.(tsx?|jsx?)$/.test(id)) { - return null - } - - // Skip node_modules - if (id.includes('node_modules')) { - return null - } - - // Check if the file contains props destructuring - if (!/\(\s*\{/.test(code)) { - return null - } - - let transformed = false - - try { - const ast = parse(code, { - sourceType: 'module', - plugins: ['typescript', 'jsx'] - }) - - const astNode = ast as unknown as t.Node - traverse(astNode, { - // Handle function declarations and arrow functions - Function(path: NodePath) { - const params = path.node.params - if (params.length !== 1) return - - const firstParam = params[0] - if (!t.isObjectPattern(firstParam)) return - - if (!checkIfComponent(path)) return - - transformPropsDestructuring(path, firstParam) - transformed = true - } - }) - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!transformed) { - return null - } - - const output = generate(astNode, { - retainLines: true, - compact: false - }) - - return { code: output.code, map: output.map } - } catch (error) { - console.warn(`Failed to transform ${id}:`, error) - return null - } - } -}) diff --git a/src/modules/babel.ts b/src/modules/babel.ts new file mode 100644 index 0000000..a8d0ca1 --- /dev/null +++ b/src/modules/babel.ts @@ -0,0 +1,68 @@ +import generateImport from '@babel/generator' +import { parse } from '@babel/parser' +import traverseImport, { NodePath } from '@babel/traverse' +import * as t from '@babel/types' +import { checkIfComponent } from './component-detector' + +// Handle ESM/CJS interop +const traverse = + (traverseImport as unknown as { default?: typeof traverseImport }).default ?? traverseImport +const generate = + (generateImport as unknown as { default?: typeof generateImport }).default ?? generateImport + +export { generate, traverse } + +export type ComponentCallback = (path: NodePath, objectPattern: t.ObjectPattern) => void + +/** + * Parses code, finds components with destructured props, calls the callback + * for each one, then generates output. Returns `{ code, map }` or `null` if + * no components were transformed. + */ +export function traverseComponentProps( + code: string, + onComponent: ComponentCallback +): { + code: string + map: ReturnType extends { map: infer M } ? M : unknown +} | null { + // Quick check for destructuring pattern + if (!/\(\s*\{/.test(code)) { + return null + } + + let transformed = false + + const ast = parse(code, { + sourceType: 'module', + plugins: ['typescript', 'jsx'] + }) + + const astNode = ast as unknown as t.Node + traverse(astNode, { + Function(path: NodePath) { + const params = path.node.params + if (params.length !== 1) return + + const firstParam = params[0] + if (!t.isObjectPattern(firstParam)) return + + if (!checkIfComponent(path)) return + + onComponent(path, firstParam) + transformed = true + } + }) + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!transformed) { + return null + } + + const output = generate(astNode, { + retainLines: true, + compact: false + }) + + return { code: output.code, map: output.map } +} diff --git a/src/modules/component-detector-legacy.ts b/src/modules/component-detector-legacy.ts deleted file mode 100644 index 3c5d8ab..0000000 --- a/src/modules/component-detector-legacy.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NodePath } from '@babel/traverse' -import * as t from '@babel/types' - -export function checkIfComponentLegacy(path: NodePath): boolean { - let hasJSX = false - - path.traverse({ - JSXElement() { - hasJSX = true - }, - JSXFragment() { - hasJSX = true - } - }) - - return hasJSX -} diff --git a/src/modules/props-transformer.ts b/src/modules/transform.ts similarity index 56% rename from src/modules/props-transformer.ts rename to src/modules/transform.ts index 27b2964..58a8f72 100644 --- a/src/modules/props-transformer.ts +++ b/src/modules/transform.ts @@ -1,6 +1,5 @@ import { NodePath, Visitor } from '@babel/traverse' import * as t from '@babel/types' -import { ensureImports } from './import-manager' // ─── Shared types ──────────────────────────────────────────────────────────── @@ -164,130 +163,3 @@ export function replacePropsReferences( } // ─── Full transform (Vite plugin) ─────────────────────────────────────────── - -/** - * Transforms destructured props parameters into proper Solid.js reactive props access. - * Converts: - * function Component({ prop1, prop2 }) { ... } - * Into: - * function Component(_props) { - * const _merged = mergeProps(defaults, _props) - * const [, rest] = splitProps(_merged, ['prop1', 'prop2']) - * // References to prop1, prop2 become _merged.prop1, _merged.prop2 - * } - */ -export function transformPropsDestructuring( - path: NodePath, - objectPattern: t.ObjectPattern -) { - const propsIdentifier = path.scope.generateUidIdentifier('props') - const info = extractPropsInfo(objectPattern) - const { propsToSplit, defaultValues, hasRestElement, restIdentifier } = info - - // Create statements to add at the beginning of function body - const newStatements: t.Statement[] = [] - - // If only a rest element with no other props or defaults, just rename the parameter - const restOnly = hasRestElement && propsToSplit.length === 0 && Object.keys(defaultValues).length === 0 - if (restOnly && restIdentifier) { - path.node.params[0] = restIdentifier - return - } - - // Replace parameter with single props identifier - path.node.params[0] = propsIdentifier - - // Add import for mergeProps and splitProps if needed - const needsMergeProps = Object.keys(defaultValues).length > 0 - const needsSplitProps = propsToSplit.length > 0 || hasRestElement - - // Determine which identifier will hold the merged props (for accessing properties later) - let mergedIdentifier: t.Identifier - - // Create mergeProps call if there are default values - if (needsMergeProps) { - const defaultsObject = t.objectExpression( - Object.entries(defaultValues).map(([key, value]) => - t.objectProperty(t.identifier(key), value) - ) - ) - - mergedIdentifier = path.scope.generateUidIdentifier('merged') - newStatements.push( - t.variableDeclaration('const', [ - t.variableDeclarator( - mergedIdentifier, - t.callExpression(t.identifier('mergeProps'), [defaultsObject, propsIdentifier]) - ) - ]) - ) - - // Create splitProps call if needed (only to extract rest) - if (needsSplitProps && restIdentifier) { - const splitArray = t.arrayPattern([ - null, // Skip the first element (the specific props) - restIdentifier - ]) - - newStatements.push( - t.variableDeclaration('const', [ - t.variableDeclarator( - splitArray, - t.callExpression(t.identifier('splitProps'), [ - mergedIdentifier, - t.arrayExpression(propsToSplit.map((prop) => t.stringLiteral(prop))) - ]) - ) - ]) - ) - } - } else if (needsSplitProps) { - // Only splitProps needed (no defaults) - mergedIdentifier = propsIdentifier - - if (restIdentifier) { - const splitArray = t.arrayPattern([ - null, // Skip the first element (the specific props) - restIdentifier - ]) - - newStatements.push( - t.variableDeclaration('const', [ - t.variableDeclarator( - splitArray, - t.callExpression(t.identifier('splitProps'), [ - propsIdentifier, - t.arrayExpression(propsToSplit.map((prop) => t.stringLiteral(prop))) - ]) - ) - ]) - ) - } - } else { - // No transformations needed - mergedIdentifier = propsIdentifier - } - - // Replace all references to destructured variables with property accesses - const bodyPath = path.get('body') - if (Array.isArray(bodyPath)) { - return - } - - replacePropsReferences(bodyPath, mergedIdentifier.name, info) - - // Insert statements at the beginning of function body - if (t.isBlockStatement(path.node.body)) { - path.node.body.body.unshift(...newStatements) - } else if (t.isExpression(path.node.body)) { - // Arrow function with expression body - need to convert to block statement - const returnStatement = t.returnStatement(path.node.body) - path.node.body = t.blockStatement([...newStatements, returnStatement]) - } - - // Ensure imports are added to the file - const program = path.findParent((p) => p.isProgram()) - if (program) { - ensureImports(program as NodePath, needsMergeProps, needsSplitProps) - } -} diff --git a/src/vite/index.ts b/src/vite/index.ts new file mode 100644 index 0000000..52505d1 --- /dev/null +++ b/src/vite/index.ts @@ -0,0 +1,31 @@ +import { Plugin } from 'vite' +import { traverseComponentProps } from '../modules/babel' +import { transformPropsDestructuring } from './modules/transform' + +/** + * Vite plugin that transforms Solid.js component prop destructuring into reactive prop access. + * This ensures that props remain reactive by using mergeProps and splitProps instead of + * direct destructuring, which would break Solid's reactivity system. + */ +export default (): Plugin => ({ + name: 'solid-undestructure', + enforce: 'pre', + transform(code: string, id: string) { + // Only process TypeScript/JavaScript files in components + if (!/\.(tsx?|jsx?)$/.test(id)) { + return null + } + + // Skip node_modules + if (id.includes('node_modules')) { + return null + } + + try { + return traverseComponentProps(code, transformPropsDestructuring) + } catch (error) { + console.warn(`Failed to transform ${id}:`, error) + return null + } + } +}) diff --git a/src/vite/modules/transform.ts b/src/vite/modules/transform.ts new file mode 100644 index 0000000..e513320 --- /dev/null +++ b/src/vite/modules/transform.ts @@ -0,0 +1,132 @@ +import { NodePath } from '@babel/traverse' +import * as t from '@babel/types' +import { ensureImports } from '../../modules/import-manager' +import { extractPropsInfo, replacePropsReferences } from '../../modules/transform' + +/** + * Transforms destructured props parameters into proper Solid.js reactive props access. + * Converts: + * function Component({ prop1, prop2 }) { ... } + * Into: + * function Component(_props) { + * const _merged = mergeProps(defaults, _props) + * const [, rest] = splitProps(_merged, ['prop1', 'prop2']) + * // References to prop1, prop2 become _merged.prop1, _merged.prop2 + * } + */ +export function transformPropsDestructuring( + path: NodePath, + objectPattern: t.ObjectPattern +) { + const propsIdentifier = path.scope.generateUidIdentifier('props') + const info = extractPropsInfo(objectPattern) + const { propsToSplit, defaultValues, hasRestElement, restIdentifier } = info + + // Create statements to add at the beginning of function body + const newStatements: t.Statement[] = [] + + // If only a rest element with no other props or defaults, just rename the parameter + const restOnly = + hasRestElement && propsToSplit.length === 0 && Object.keys(defaultValues).length === 0 + if (restOnly && restIdentifier) { + path.node.params[0] = restIdentifier + return + } + + // Replace parameter with single props identifier + path.node.params[0] = propsIdentifier + + // Add import for mergeProps and splitProps if needed + const needsMergeProps = Object.keys(defaultValues).length > 0 + const needsSplitProps = propsToSplit.length > 0 || hasRestElement + + // Determine which identifier will hold the merged props (for accessing properties later) + let mergedIdentifier: t.Identifier + + // Create mergeProps call if there are default values + if (needsMergeProps) { + const defaultsObject = t.objectExpression( + Object.entries(defaultValues).map(([key, value]) => + t.objectProperty(t.identifier(key), value) + ) + ) + + mergedIdentifier = path.scope.generateUidIdentifier('merged') + newStatements.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + mergedIdentifier, + t.callExpression(t.identifier('mergeProps'), [defaultsObject, propsIdentifier]) + ) + ]) + ) + + // Create splitProps call if needed (only to extract rest) + if (needsSplitProps && restIdentifier) { + const splitArray = t.arrayPattern([ + null, // Skip the first element (the specific props) + restIdentifier + ]) + + newStatements.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + splitArray, + t.callExpression(t.identifier('splitProps'), [ + mergedIdentifier, + t.arrayExpression(propsToSplit.map((prop) => t.stringLiteral(prop))) + ]) + ) + ]) + ) + } + } else if (needsSplitProps) { + // Only splitProps needed (no defaults) + mergedIdentifier = propsIdentifier + + if (restIdentifier) { + const splitArray = t.arrayPattern([ + null, // Skip the first element (the specific props) + restIdentifier + ]) + + newStatements.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + splitArray, + t.callExpression(t.identifier('splitProps'), [ + propsIdentifier, + t.arrayExpression(propsToSplit.map((prop) => t.stringLiteral(prop))) + ]) + ) + ]) + ) + } + } else { + // No transformations needed + mergedIdentifier = propsIdentifier + } + + // Replace all references to destructured variables with property accesses + const bodyPath = path.get('body') + if (Array.isArray(bodyPath)) { + return + } + + replacePropsReferences(bodyPath, mergedIdentifier.name, info) + + // Insert statements at the beginning of function body + if (t.isBlockStatement(path.node.body)) { + path.node.body.body.unshift(...newStatements) + } else if (t.isExpression(path.node.body)) { + // Arrow function with expression body - need to convert to block statement + const returnStatement = t.returnStatement(path.node.body) + path.node.body = t.blockStatement([...newStatements, returnStatement]) + } + + // Ensure imports are added to the file + const program = path.findParent((p) => p.isProgram()) + if (program) { + ensureImports(program as NodePath, needsMergeProps, needsSplitProps) + } +} diff --git a/tests/helpers.ts b/tests/helpers.ts index 769dd30..4194b05 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,6 +1,6 @@ +import solidPropsTransform from '@src/vite' import { expect } from 'bun:test' import { Plugin } from 'vite' -import solidPropsTransform from '../src' /** Run the plugin's transform and return the output code (or null) */ export function transform(code: string, id = 'Component.tsx'): string | null { diff --git a/tests/component-detector.test.ts b/tests/modules/component-detector.test.ts similarity index 98% rename from tests/component-detector.test.ts rename to tests/modules/component-detector.test.ts index d70d311..0091f0f 100644 --- a/tests/component-detector.test.ts +++ b/tests/modules/component-detector.test.ts @@ -1,5 +1,5 @@ import { describe, test } from 'bun:test' -import { expectContains, transformOrThrow } from './helpers' +import { expectContains, transformOrThrow } from '../helpers' describe('component definition styles', () => { test('default export function declaration', () => { diff --git a/tests/import-manager.test.ts b/tests/modules/import-manager.test.ts similarity index 99% rename from tests/import-manager.test.ts rename to tests/modules/import-manager.test.ts index f24d6d0..218b1d5 100644 --- a/tests/import-manager.test.ts +++ b/tests/modules/import-manager.test.ts @@ -1,5 +1,5 @@ import { describe, test } from 'bun:test' -import { expectContains, expectMatches, transformOrThrow } from './helpers' +import { expectContains, expectMatches, transformOrThrow } from '../helpers' describe('import-manager: mergeProps import', () => { test('adds mergeProps import when no solid-js import exists', () => { diff --git a/tests/skipping.test.ts b/tests/modules/skipping.test.ts similarity index 95% rename from tests/skipping.test.ts rename to tests/modules/skipping.test.ts index 374582a..6a7dbd4 100644 --- a/tests/skipping.test.ts +++ b/tests/modules/skipping.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { transform } from './helpers' +import { transform } from '../helpers' describe('skipping', () => { test('returns null for non-tsx/jsx files', () => { diff --git a/tests/props-transformer.test.ts b/tests/modules/transform.test.ts similarity index 99% rename from tests/props-transformer.test.ts rename to tests/modules/transform.test.ts index 8f05dc4..e311f83 100644 --- a/tests/props-transformer.test.ts +++ b/tests/modules/transform.test.ts @@ -5,7 +5,7 @@ import { expectNotMatches, transform, transformOrThrow -} from './helpers' +} from '../helpers' // ─── Basic Destructuring ───────────────────────────────────────────────────── From 75599b926dc709ea660b7f55f5c9c40ee28802d3 Mon Sep 17 00:00:00 2001 From: MicroDev <70126934+microdev1@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:39:52 +0530 Subject: [PATCH 5/6] mod repo name and bump version --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 67aa4cd..042e4c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vite-plugin-solid-undestructure", - "version": "0.2.2", + "version": "0.2.3", "description": "Automatically transforms props destructuring in SolidJS components", "type": "module", "main": "./dist/index.js", @@ -21,7 +21,7 @@ ], "repository": { "type": "git", - "url": "https://github.com/microdev1/vite-plugin-solid-undestructure.git" + "url": "https://github.com/microdev1/solid-undestructure.git" }, "keywords": [ "vite", From 38fe87aa0eec97e36b986d8b64d6262225be6c7c Mon Sep 17 00:00:00 2001 From: MicroDev <70126934+microdev1@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:51:50 +0530 Subject: [PATCH 6/6] fix package configuration --- package.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 042e4c5..3442d08 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,15 @@ { "name": "vite-plugin-solid-undestructure", - "version": "0.2.3", + "version": "0.2.4", "description": "Automatically transforms props destructuring in SolidJS components", "type": "module", - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", + "main": "./dist/vite/index.js", + "module": "./dist/vite/index.js", + "types": "./dist/vite/index.d.ts", "exports": { ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" + "import": "./dist/vite/index.js", + "types": "./dist/vite/index.d.ts" }, "./eslint": { "import": "./dist/eslint/index.js", @@ -33,7 +33,7 @@ "scripts": { "build": "bun build:type && bun build:vite && bun build:eslint", "build:type": "tsc --declaration --emitDeclarationOnly --outDir dist", - "build:vite": "bun build src/vite/index.ts --minify --packages=external --outfile=dist/index.js", + "build:vite": "bun build src/vite/index.ts --minify --packages=external --outfile=dist/vite/index.js", "build:eslint": "bun build src/eslint/index.ts --minify --packages=external --outfile=dist/eslint/index.js", "type": "tsc --noEmit", "lint": "eslint --cache src",