Skip to content

Commit 6901cac

Browse files
committed
fix(react): scan jsx component initializers
1 parent 2856c9c commit 6901cac

5 files changed

Lines changed: 161 additions & 12 deletions

File tree

packages/dtsx/src/extractor/scanner.ts

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1979,6 +1979,7 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
19791979
// in value expressions (comparisons like <=, >=, and plain < >)
19801980
let depth = 0
19811981
let angleDepth = 0
1982+
let jsxDepth = 0
19821983
while (pos < len) {
19831984
if (skipNonCode())
19841985
continue
@@ -1988,20 +1989,18 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
19881989
else if (ic === CH_RPAREN || ic === CH_RBRACE || ic === CH_RBRACKET)
19891990
depth--
19901991
else if (ic === CH_LANGLE && depth === 0) {
1991-
// Only track angle brackets at depth 0 (top-level generics like Map<K,V>).
1992-
// Inside braces (function bodies), < and > are comparison operators.
1993-
// Don't count <= as opening angle bracket
1994-
if (pos + 1 >= len || source.charCodeAt(pos + 1) !== CH_EQUAL)
1995-
angleDepth++
1992+
const jsxDelta = jsxTagDeltaAt(pos)
1993+
if (jsxDelta !== null) jsxDepth = Math.max(0, jsxDepth + jsxDelta)
1994+
else if (pos + 1 >= len || source.charCodeAt(pos + 1) !== CH_EQUAL) angleDepth++
19961995
}
1997-
else if (ic === CH_RANGLE && depth === 0 && !isArrowGT()) {
1996+
else if (ic === CH_RANGLE && depth === 0 && jsxDepth === 0 && !isArrowGT()) {
19981997
// Don't count >= as closing angle bracket, and prevent going negative
19991998
if (angleDepth > 0 && (pos + 1 >= len || source.charCodeAt(pos + 1) !== CH_EQUAL))
20001999
angleDepth--
20012000
}
2002-
else if (depth === 0 && angleDepth === 0 && (ic === CH_SEMI || ic === CH_COMMA))
2001+
else if (depth === 0 && angleDepth === 0 && jsxDepth === 0 && (ic === CH_SEMI || ic === CH_COMMA))
20032002
break
2004-
if (depth === 0 && angleDepth === 0 && checkASITopLevel())
2003+
if (depth === 0 && angleDepth === 0 && jsxDepth === 0 && checkASITopLevel())
20052004
break
20062005
pos++
20072006
}
@@ -2051,6 +2050,47 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
20512050
return results
20522051
}
20532052

2053+
/** Return the JSX nesting delta for a tag beginning at index, or null. */
2054+
function jsxTagDeltaAt(index: number): number | null {
2055+
if (source.charCodeAt(index) !== CH_LANGLE || index + 1 >= len) return null
2056+
const next = source.charCodeAt(index + 1)
2057+
if (next === CH_RANGLE) return 1 // fragment open: <>
2058+
if (next === CH_SLASH) return -1 // element or fragment close
2059+
if (!isIdentStart(next)) return null
2060+
2061+
let nameEnd = index + 2
2062+
while (nameEnd < len) {
2063+
const code = source.charCodeAt(nameEnd)
2064+
if (!isIdentChar(code) && code !== CH_DOT && code !== 58 /* : */ && code !== 45 /* - */) break
2065+
nameEnd++
2066+
}
2067+
const tagName = source.slice(index + 1, nameEnd)
2068+
const delimiter = source.charCodeAt(nameEnd)
2069+
if (delimiter > 32 && delimiter !== CH_RANGLE && delimiter !== CH_SLASH) return null
2070+
2071+
let braceDepth = 0
2072+
let quote = 0
2073+
let tagEnd = nameEnd
2074+
for (; tagEnd < len; tagEnd++) {
2075+
const code = source.charCodeAt(tagEnd)
2076+
if (quote !== 0) {
2077+
if (code === CH_BACKSLASH) tagEnd++
2078+
else if (code === quote) quote = 0
2079+
continue
2080+
}
2081+
if (code === CH_SQUOTE || code === CH_DQUOTE || code === CH_BACKTICK) quote = code
2082+
else if (code === CH_LBRACE) braceDepth++
2083+
else if (code === CH_RBRACE && braceDepth > 0) braceDepth--
2084+
else if (code === CH_RANGLE && braceDepth === 0) break
2085+
}
2086+
if (tagEnd >= len) return null
2087+
2088+
let beforeEnd = tagEnd - 1
2089+
while (beforeEnd > index && source.charCodeAt(beforeEnd) <= 32) beforeEnd--
2090+
if (source.charCodeAt(beforeEnd) === CH_SLASH) return 0
2091+
return source.indexOf(`</${tagName}`, tagEnd + 1) === -1 ? null : 1
2092+
}
2093+
20542094
/**
20552095
* Extract interface declaration.
20562096
* pos should be at 'interface' keyword.

packages/dtsx/test/react-components.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,25 @@ describe('React and JSX component declarations', () => {
9898
expect(output).toContain("import type { FC } from 'react';")
9999
expect(output).toContain('Greeting: FC<GreetingProps>;')
100100
})
101+
102+
test('emits local arrow components referenced by default exports', () => {
103+
const output = processSource(`
104+
export interface PanelProps { title: string }
105+
const Panel = ({ title }: PanelProps) => <section>{title}, ready</section>
106+
export default Panel
107+
`)
108+
109+
expect(output).toContain('declare const Panel: ({ title }: PanelProps) => JSX.Element;')
110+
expect(output).toContain('export default Panel;')
111+
})
112+
113+
test('stops JSX initializers before following exports', () => {
114+
const output = processSource(`
115+
export const Header = () => <header>Hello, world</header>
116+
export const footerLabel = 'Footer'
117+
`)
118+
119+
expect(output).toContain('Header: () => JSX.Element;')
120+
expect(output).toContain("footerLabel: 'Footer';")
121+
})
101122
})

packages/zig-dtsx/src/extractors.zig

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,6 +1082,64 @@ pub fn extractFunction(s: *Scanner, decl_start: usize, is_exported: bool, is_asy
10821082
// Variable extraction
10831083
// ========================================================================
10841084

1085+
const JsxTagInfo = struct { delta: isize, end: usize };
1086+
1087+
fn jsxTagAt(source: []const u8, index: usize) ?JsxTagInfo {
1088+
if (index + 1 >= source.len or source[index] != ch.CH_LANGLE) return null;
1089+
const next = source[index + 1];
1090+
if (next == ch.CH_RANGLE) return .{ .delta = 1, .end = index + 1 };
1091+
if (next == ch.CH_SLASH) {
1092+
const close_end = ch.indexOfChar(source, ch.CH_RANGLE, index + 2) orelse return null;
1093+
return .{ .delta = -1, .end = close_end };
1094+
}
1095+
if (!ch.isIdentStart(next)) return null;
1096+
1097+
var name_end = index + 2;
1098+
while (name_end < source.len) : (name_end += 1) {
1099+
const code = source[name_end];
1100+
if (!ch.isIdentChar(code) and code != ch.CH_DOT and code != ':' and code != '-') break;
1101+
}
1102+
const tag_name = source[index + 1 .. name_end];
1103+
if (name_end >= source.len) return null;
1104+
const delimiter = source[name_end];
1105+
if (!ch.isWhitespace(delimiter) and delimiter != ch.CH_RANGLE and delimiter != ch.CH_SLASH) return null;
1106+
1107+
var brace_depth: usize = 0;
1108+
var quote: u8 = 0;
1109+
var tag_end = name_end;
1110+
while (tag_end < source.len) : (tag_end += 1) {
1111+
const code = source[tag_end];
1112+
if (quote != 0) {
1113+
if (code == ch.CH_BACKSLASH) tag_end += 1 else if (code == quote) quote = 0;
1114+
continue;
1115+
}
1116+
if (code == ch.CH_SQUOTE or code == ch.CH_DQUOTE or code == ch.CH_BACKTICK) {
1117+
quote = code;
1118+
} else if (code == ch.CH_LBRACE) {
1119+
brace_depth += 1;
1120+
} else if (code == ch.CH_RBRACE and brace_depth > 0) {
1121+
brace_depth -= 1;
1122+
} else if (code == ch.CH_RANGLE and brace_depth == 0) {
1123+
break;
1124+
}
1125+
}
1126+
if (tag_end >= source.len) return null;
1127+
1128+
var before_end = tag_end - 1;
1129+
while (before_end > index and ch.isWhitespace(source[before_end])) before_end -= 1;
1130+
if (source[before_end] == ch.CH_SLASH) return .{ .delta = 0, .end = tag_end };
1131+
1132+
var search_from = tag_end + 1;
1133+
while (ch.indexOf(source, "</", search_from)) |close_start| {
1134+
const close_name_start = close_start + 2;
1135+
const close_name_end = close_name_start + tag_name.len;
1136+
if (close_name_end <= source.len and std.mem.eql(u8, source[close_name_start..close_name_end], tag_name) and
1137+
close_name_end < source.len and (source[close_name_end] == ch.CH_RANGLE or ch.isWhitespace(source[close_name_end]))) return .{ .delta = 1, .end = tag_end };
1138+
search_from = close_start + 2;
1139+
}
1140+
return null;
1141+
}
1142+
10851143
/// Extract variable declaration(s)
10861144
pub fn extractVariable(s: *Scanner, decl_start: usize, kind: []const u8, is_exported: bool) []const Declaration {
10871145
s.pos += kind.len; // skip const/let/var
@@ -1141,6 +1199,8 @@ pub fn extractVariable(s: *Scanner, decl_start: usize, kind: []const u8, is_expo
11411199
s.skipWhitespaceAndComments();
11421200
const init_start = s.pos;
11431201
var depth: isize = 0;
1202+
var jsx_depth: isize = 0;
1203+
var jsx_tag_end: ?usize = null;
11441204
while (s.pos < s.len) {
11451205
// SIMD fast-skip: bulk-skip bytes that can't be structural
11461206
if (depth > 0) {
@@ -1193,14 +1253,27 @@ pub fn extractVariable(s: *Scanner, decl_start: usize, kind: []const u8, is_expo
11931253
if (s.pos >= s.len) break;
11941254
if (s.skipNonCode()) continue;
11951255
const ic = s.source[s.pos];
1196-
if (ic == ch.CH_LPAREN or ic == ch.CH_LBRACE or ic == ch.CH_LBRACKET or ic == ch.CH_LANGLE) {
1256+
if (ic == ch.CH_LANGLE) {
1257+
if (jsxTagAt(s.source, s.pos)) |tag| {
1258+
jsx_depth = @max(0, jsx_depth + tag.delta);
1259+
jsx_tag_end = tag.end;
1260+
} else {
1261+
depth += 1;
1262+
}
1263+
} else if (ic == ch.CH_LPAREN or ic == ch.CH_LBRACE or ic == ch.CH_LBRACKET) {
11971264
depth += 1;
1198-
} else if (ic == ch.CH_RPAREN or ic == ch.CH_RBRACE or ic == ch.CH_RBRACKET or (ic == ch.CH_RANGLE and !s.isArrowGT())) {
1265+
} else if (ic == ch.CH_RPAREN or ic == ch.CH_RBRACE or ic == ch.CH_RBRACKET) {
11991266
depth -= 1;
1200-
} else if (depth == 0 and (ic == ch.CH_SEMI or ic == ch.CH_COMMA)) {
1267+
} else if (ic == ch.CH_RANGLE) {
1268+
if (jsx_tag_end != null and jsx_tag_end.? == s.pos) {
1269+
jsx_tag_end = null;
1270+
} else if (jsx_depth == 0 and !s.isArrowGT() and depth > 0) {
1271+
depth -= 1;
1272+
}
1273+
} else if (depth == 0 and jsx_depth == 0 and (ic == ch.CH_SEMI or ic == ch.CH_COMMA)) {
12011274
break;
12021275
}
1203-
if (depth == 0 and s.checkASITopLevel()) break;
1276+
if (depth == 0 and jsx_depth == 0 and s.checkASITopLevel()) break;
12041277
s.pos += 1;
12051278
}
12061279
initializer_text = if (skip_isolated_initializer) "" else s.sliceTrimmed(init_start, s.pos);

packages/zig-dtsx/src/type_inference.zig

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1152,6 +1152,10 @@ pub fn inferNarrowType(alloc: std.mem.Allocator, value: []const u8, is_const: bo
11521152

11531153
if (isJsxExpression(trimmed)) return "JSX.Element";
11541154

1155+
// Recognize an outer arrow before comma-operator handling because JSX text
1156+
// and children may contain commas that are not JavaScript operators.
1157+
if (findMainArrowIndex(trimmed) != null) return inferFunctionType(alloc, trimmed, in_union, depth, is_const);
1158+
11551159
// The comma operator evaluates to its final operand. Reuse the balanced
11561160
// element splitter so commas inside calls, arrays, and objects are ignored.
11571161
if (findTopLevelComma(trimmed)) |comma| return inferNarrowType(alloc, trim(trimmed[comma + 1 ..]), is_const, in_union, depth + 1);

packages/zig-dtsx/test/react-components.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,15 @@ describeIf('Zig React component declarations', () => {
4343
expect(output).toContain('Card: ReturnType<typeof memo>;')
4444
expect(output).toContain('LazyPanel: ReturnType<typeof lazy>;')
4545
})
46+
47+
test('emits default-exported local arrow components', () => {
48+
const output = dts(`
49+
export interface PanelProps { title: string }
50+
const Panel = ({ title }: PanelProps) => <section>{title}, ready</section>
51+
export default Panel
52+
`)
53+
54+
expect(output).toContain('declare const Panel: ({ title }: PanelProps) => JSX.Element;')
55+
expect(output).toContain('export default Panel;')
56+
})
4657
})

0 commit comments

Comments
 (0)