Skip to content

Commit d47aaa6

Browse files
committed
fix(react): infer jsx component returns
1 parent a76b183 commit d47aaa6

3 files changed

Lines changed: 148 additions & 0 deletions

File tree

packages/dtsx/src/processor/type-inference.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,45 @@ function countOccurrences(str: string, sub: string): number {
110110
return count
111111
}
112112

113+
function isJsxTagNameChar(code: number): boolean {
114+
return (code >= 65 && code <= 90)
115+
|| (code >= 97 && code <= 122)
116+
|| (code >= 48 && code <= 57)
117+
|| code === 36 || code === 95 || code === 46 || code === 58 || code === 45
118+
}
119+
120+
/**
121+
* Detect a complete JSX element or fragment without assuming React as the JSX
122+
* runtime. A matching outer close prevents generic arrows and type assertions
123+
* from being classified as JSX.
124+
*/
125+
function isJsxExpression(value: string): boolean {
126+
const expression = value.trim()
127+
if (expression.length < 3 || expression.charCodeAt(0) !== 60) return false
128+
if (expression.startsWith('<>')) return expression.endsWith('</>')
129+
130+
const firstTagCode = expression.charCodeAt(1)
131+
const isIdentifierStart = (firstTagCode >= 65 && firstTagCode <= 90)
132+
|| (firstTagCode >= 97 && firstTagCode <= 122)
133+
|| firstTagCode === 36 || firstTagCode === 95
134+
if (!isIdentifierStart) return false
135+
136+
let tagEnd = 2
137+
while (tagEnd < expression.length && isJsxTagNameChar(expression.charCodeAt(tagEnd))) tagEnd++
138+
const tagName = expression.slice(1, tagEnd)
139+
const delimiter = expression.charCodeAt(tagEnd)
140+
if (delimiter > 32 && delimiter !== 62 && delimiter !== 47) return false
141+
if (expression.endsWith('/>')) return true
142+
143+
const closeStart = expression.lastIndexOf('</')
144+
if (closeStart === -1) return false
145+
let closeEnd = closeStart + 2
146+
while (closeEnd < expression.length && isJsxTagNameChar(expression.charCodeAt(closeEnd))) closeEnd++
147+
return expression.slice(closeStart + 2, closeEnd) === tagName
148+
&& expression.charCodeAt(closeEnd) === 62
149+
&& closeEnd === expression.length - 1
150+
}
151+
113152
/** Collapse runs of whitespace to single spaces (no regex) */
114153
function collapseWhitespace(s: string): string {
115154
const len = s.length
@@ -169,6 +208,8 @@ export function inferNarrowType(value: unknown, isConst: boolean = false, inUnio
169208

170209
const trimmed = value.trim()
171210

211+
if (isJsxExpression(trimmed)) return 'JSX.Element'
212+
172213
// BigInt expressions (check early)
173214
if (trimmed.startsWith('BigInt(')) {
174215
return 'bigint'
@@ -536,6 +577,11 @@ export function inferFunctionBodyReturnType(body: string, isAsync: boolean = fal
536577
braceDepth--
537578
}
538579
else if (expressionChar === 59 && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) break
580+
else if ((expressionChar === 10 || expressionChar === 13) && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) {
581+
let next = i + 1
582+
while (next < body.length && body.charCodeAt(next) <= 32) next++
583+
if (body.startsWith('return', next) && !isWordChar(body.charCodeAt(next + 6))) break
584+
}
539585
i++
540586
}
541587

@@ -586,6 +632,7 @@ function collectParameterTypes(parameters: string): Map<string, string> {
586632
function inferBodyExpressionType(expression: string, parameterTypes: ReadonlyMap<string, string>): string {
587633
let value = expression.trim()
588634
while (hasBalancedOuterParentheses(value)) value = value.slice(1, -1).trim()
635+
if (isJsxExpression(value)) return 'JSX.Element'
589636
if (value.startsWith('await ')) {
590637
const awaited = inferBodyExpressionType(value.slice(6), parameterTypes)
591638
return awaited.startsWith('Promise<') && awaited.endsWith('>') ? awaited.slice(8, -1) : awaited
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { processSource } from '../src/generator'
3+
4+
describe('React and JSX component declarations', () => {
5+
test('infers JSX returns for function components', () => {
6+
const output = processSource(`
7+
export interface ButtonProps { label: string }
8+
export function Button({ label }: ButtonProps) {
9+
return <button aria-label={label}>{label}</button>
10+
}
11+
`)
12+
13+
expect(output).toContain('export declare function Button({ label }: ButtonProps): JSX.Element;')
14+
})
15+
16+
test('infers JSX returns for arrow components', () => {
17+
const output = processSource(`
18+
export interface BadgeProps { text: string }
19+
export const Badge = ({ text }: BadgeProps) => <span>{text}</span>
20+
`)
21+
22+
expect(output).toContain('export declare const Badge: ({ text }: BadgeProps) => JSX.Element;')
23+
})
24+
25+
test('supports fragments and member component tags', () => {
26+
const output = processSource(`
27+
export const Fields = () => <><Form.Field name="email" /><Form.Field name="name" /></>
28+
`)
29+
30+
expect(output).toContain('export declare const Fields: () => JSX.Element;')
31+
})
32+
33+
test('unions nullable component returns', () => {
34+
const output = processSource(`
35+
export function OptionalPanel(hidden: boolean) {
36+
if (hidden) return null
37+
return <section>Visible</section>
38+
}
39+
`)
40+
41+
expect(output).toContain('export declare function OptionalPanel(hidden: boolean): null | JSX.Element;')
42+
})
43+
44+
test('preserves explicit JSX return annotations', () => {
45+
const output = processSource('export function Portal(): JSX.Element | null { return null }')
46+
expect(output).toContain('export declare function Portal(): JSX.Element | null;')
47+
})
48+
49+
test('does not mistake angle-bracket assertions for JSX', () => {
50+
const output = processSource('export const value = <string>input')
51+
expect(output).not.toContain('value: JSX.Element')
52+
})
53+
})

packages/zig-dtsx/src/type_inference.zig

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,34 @@ fn isRegexLiteral(value: []const u8) bool {
217217
return false;
218218
}
219219

220+
fn isJsxTagNameChar(c: u8) bool {
221+
return ch.isIdentChar(c) or c == '.' or c == ':' or c == '-';
222+
}
223+
224+
/// Detect a complete JSX element or fragment without assuming a particular JSX
225+
/// runtime. This deliberately requires a matching outer close or a self-close,
226+
/// keeping angle-bracket assertions and generic arrow functions distinct.
227+
fn isJsxExpression(value: []const u8) bool {
228+
const expression = trim(value);
229+
if (expression.len < 3 or expression[0] != '<') return false;
230+
if (ch.startsWith(expression, "<>")) return ch.endsWith(expression, "</>");
231+
if (!ch.isIdentStart(expression[1])) return false;
232+
233+
var tag_end: usize = 2;
234+
while (tag_end < expression.len and isJsxTagNameChar(expression[tag_end])) tag_end += 1;
235+
const tag_name = expression[1..tag_end];
236+
if (tag_name.len == 0 or tag_end >= expression.len) return false;
237+
const delimiter = expression[tag_end];
238+
if (!ch.isWhitespace(delimiter) and delimiter != '>' and delimiter != '/') return false;
239+
if (ch.endsWith(expression, "/>")) return true;
240+
241+
const close_start = std.mem.lastIndexOf(u8, expression, "</") orelse return false;
242+
var close_end = close_start + 2;
243+
while (close_end < expression.len and isJsxTagNameChar(expression[close_end])) close_end += 1;
244+
return std.mem.eql(u8, expression[close_start + 2 .. close_end], tag_name) and
245+
close_end < expression.len and expression[close_end] == '>' and close_end == expression.len - 1;
246+
}
247+
220248
fn inferAccessType(alloc: std.mem.Allocator, value: []const u8) InferError!?[]const u8 {
221249
if (value.len > 3 and value[value.len - 1] == ']') {
222250
const bracket = std.mem.lastIndexOfScalar(u8, value, '[') orelse return null;
@@ -990,6 +1018,11 @@ pub fn inferFunctionBodyReturnType(alloc: std.mem.Allocator, body: []const u8, p
9901018
if (expression_depth == 0) break;
9911019
expression_depth -= 1;
9921020
} else if (c == ';' and expression_depth == 0) break;
1021+
if ((c == '\n' or c == '\r') and expression_depth == 0) {
1022+
var next = i + 1;
1023+
while (next < content.len and ch.isWhitespace(content[next])) next += 1;
1024+
if (ch.startsWith(content[next..], "return") and (next + 6 >= content.len or !ch.isIdentChar(content[next + 6]))) break;
1025+
}
9931026
}
9941027
const expression = trim(content[expression_start..i]);
9951028
const inferred = try inferBodyExpressionType(alloc, expression, parameters, depth + 1);
@@ -1012,6 +1045,7 @@ fn inferBodyExpressionType(alloc: std.mem.Allocator, expression: []const u8, par
10121045
while (value.len >= 2 and value[0] == '(' and value[value.len - 1] == ')' and findMatchingBracket(value, 0, '(', ')') == value.len - 1) {
10131046
value = trim(value[1 .. value.len - 1]);
10141047
}
1048+
if (isJsxExpression(value)) return "JSX.Element";
10151049
if (findParameterType(parameters, value)) |parameter_type| return parameter_type;
10161050
if (try inferBodyCallType(alloc, value, parameters)) |call_type| return call_type;
10171051

@@ -1114,6 +1148,8 @@ pub fn inferNarrowType(alloc: std.mem.Allocator, value: []const u8, is_const: bo
11141148
return inferNarrowType(alloc, trim(trimmed[1 .. trimmed.len - 1]), is_const, in_union, depth + 1);
11151149
}
11161150

1151+
if (isJsxExpression(trimmed)) return "JSX.Element";
1152+
11171153
// The comma operator evaluates to its final operand. Reuse the balanced
11181154
// element splitter so commas inside calls, arrays, and objects are ignored.
11191155
if (findTopLevelComma(trimmed)) |comma| return inferNarrowType(alloc, trim(trimmed[comma + 1 ..]), is_const, in_union, depth + 1);
@@ -2562,6 +2598,18 @@ test "arithmetic and update expressions emit valid result types" {
25622598
try std.testing.expectEqualStrings("typeof state.count", try inferNarrowType(alloc, "--state.count", false, false, 0));
25632599
}
25642600

2601+
test "JSX expressions infer portable element types" {
2602+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
2603+
defer arena.deinit();
2604+
const alloc = arena.allocator();
2605+
try std.testing.expectEqualStrings("JSX.Element", try inferNarrowType(alloc, "<button>Save</button>", false, false, 0));
2606+
try std.testing.expectEqualStrings("JSX.Element", try inferNarrowType(alloc, "<Form.Field name='email' />", false, false, 0));
2607+
try std.testing.expectEqualStrings("JSX.Element", try inferNarrowType(alloc, "<><span>One</span><span>Two</span></>", false, false, 0));
2608+
try std.testing.expectEqualStrings("JSX.Element", try inferFunctionBodyReturnType(alloc, "{ return <Card title={title} /> }", "(title: string)", 0));
2609+
try std.testing.expectEqualStrings("null | JSX.Element", try inferFunctionBodyReturnType(alloc, "{ if (hidden) return null; return <Panel /> }", "(hidden: boolean)", 0));
2610+
try std.testing.expectEqualStrings("boolean", try inferNarrowType(alloc, "<Value>input", false, false, 0));
2611+
}
2612+
25652613
test "extractSatisfiesType" {
25662614
try std.testing.expectEqualStrings("Config", extractSatisfiesType("{ port: 3000 } satisfies Config").?);
25672615
try std.testing.expect(extractSatisfiesType("just a value without it") == null);

0 commit comments

Comments
 (0)