Skip to content

Commit 3c1f809

Browse files
nickshiroryansolidClaude via Cursorcursoragent
authored
fix(compiler): preserve coverage pragmas in JSX (#3301)
* fix(compiler): preserve coverage pragmas in JSX * fix(compiler): implement SSR source access * fix(compiler): align coverage pragma carry-through between Babel and Oxc Both compilers now agree on which authored pragmas lead a component's `children` getter: - Block comments only: line comments are never carried (Oxc dropped `{// c8 ignore next}` and misread `// c8 ignore` inside a block comment). - Pragmas from consecutive empty containers are all carried, in order (Oxc kept only the last container). - A pragma followed by text still leads the getter (Babel dropped it when the next child was text or a same-line space). - Only pragmas print, not neighboring notes in the same container. Oxc re-anchors each run's pragma comments to the first pragma's start and spans the getter there. The shared fixture covers every case with expectations both test suites assert across dom/ssr/universal. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Ryan Carniato <ryansolid@gmail.com> Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b0c8489 commit 3c1f809

15 files changed

Lines changed: 413 additions & 23 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@solidjs/babel-plugin": patch
3+
"@solidjs/compiler": patch
4+
---
5+
6+
Preserve `/* istanbul ignore … */` and `/* c8 ignore … */` block comments written as JSX children on the generated component `children` getter. Line comments are not carried.

‎packages/babel-plugin/src/shared/component.ts‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
isDynamic,
66
registerImportMethod,
77
filterChildren,
8+
getCoverageIgnoreComments,
89
trimWhitespace,
910
transformCondition,
1011
convertJSXIdentifier
@@ -22,7 +23,7 @@ type ComponentTransformResult = TransformResult & {
2223
exprs: Array<t.Expression | t.Statement>;
2324
};
2425

25-
type ComponentChildrenResult = [t.Expression, boolean] | undefined;
26+
type ComponentChildrenResult = [t.Expression, boolean, t.Comment[]?] | undefined;
2627

2728
function isSimpleOptionalMemberExpression(
2829
expression: t.Expression | t.JSXEmptyExpression
@@ -341,14 +342,14 @@ export default function transformComponent(
341342
: t.isFunction(childResult[0])
342343
? childResult[0].body
343344
: childResult[0];
344-
runningObject.push(
345-
t.objectMethod(
346-
"get",
347-
t.identifier("children"),
348-
[],
349-
t.isExpression(body) ? t.blockStatement([t.returnStatement(body)]) : body
350-
)
345+
const getter = t.objectMethod(
346+
"get",
347+
t.identifier("children"),
348+
[],
349+
t.isExpression(body) ? t.blockStatement([t.returnStatement(body)]) : body
351350
);
351+
if (childResult[2]?.length) getter.leadingComments = childResult[2];
352+
runningObject.push(getter);
352353
} else runningObject.push(t.objectProperty(t.identifier("children"), childResult[0]));
353354
}
354355
if (runningObject.length || !props.length) props.push(propsLiteral(runningObject));
@@ -409,9 +410,11 @@ function transformComponentChildren(
409410
if (!filteredChildren.length) return;
410411
let dynamic = false;
411412
let pathNodes: t.Node[] = [];
413+
let coverageIgnoreComments: t.Comment[] | undefined;
412414

413415
let transformedChildren: t.Expression | t.Expression[] = filteredChildren.reduce(
414416
(memo: t.Expression[], path: BabelPath<JSXNode>) => {
417+
coverageIgnoreComments ||= getCoverageIgnoreComments(path);
415418
if (t.isJSXText(path.node)) {
416419
const v = decode(trimWhitespace((path.node.extra?.raw as string | undefined) ?? ""));
417420
if (v.length) {
@@ -468,5 +471,5 @@ function transformComponentChildren(
468471
transformedChildren = t.arrowFunctionExpression([], t.arrayExpression(transformedChildren));
469472
dynamic = true;
470473
}
471-
return [transformedChildren as t.Expression, dynamic];
474+
return [transformedChildren as t.Expression, dynamic, coverageIgnoreComments];
472475
}

‎packages/babel-plugin/src/shared/utils.ts‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -243,13 +243,38 @@ export function getStaticExpression(
243243
);
244244
}
245245

246+
const coverageIgnoreCommentData = "solid.coverageIgnoreComments";
247+
248+
export function getCoverageIgnoreComments(path: NodePath): t.Comment[] | undefined {
249+
return path.getData(coverageIgnoreCommentData) as t.Comment[] | undefined;
250+
}
251+
246252
// remove unnecessary JSX Text nodes
247253
export function filterChildren<TPath extends NodePath>(children: TPath[]): TPath[] {
248-
return children.filter(
249-
({ node: child }) =>
250-
!(t.isJSXExpressionContainer(child) && t.isJSXEmptyExpression(child.expression)) &&
251-
(!t.isJSXText(child) || !/^[\r\n]\s*$/.test((child.extra?.raw as string | undefined) ?? ""))
252-
);
254+
const filtered: TPath[] = [];
255+
let pendingCoverageIgnoreComments: t.Comment[] = [];
256+
257+
for (const path of children) {
258+
const child = path.node;
259+
if (t.isJSXExpressionContainer(child) && t.isJSXEmptyExpression(child.expression)) {
260+
pendingCoverageIgnoreComments.push(
261+
...(child.expression.innerComments?.filter(
262+
comment =>
263+
comment.type === "CommentBlock" && /^\s*(istanbul|c8)\s+ignore\b/.test(comment.value)
264+
) ?? [])
265+
);
266+
continue;
267+
}
268+
if (t.isJSXText(child) && /^[\r\n]\s*$/.test((child.extra?.raw as string | undefined) ?? "")) {
269+
continue;
270+
}
271+
if (pendingCoverageIgnoreComments.length) {
272+
path.setData(coverageIgnoreCommentData, pendingCoverageIgnoreComments);
273+
pendingCoverageIgnoreComments = [];
274+
}
275+
filtered.push(path);
276+
}
277+
return filtered;
253278
}
254279

255280
export function checkLength(children: NodePath[]): boolean {
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
export const istanbulPragma = (
2+
<Show when={condition()}>
3+
{/* istanbul ignore next */}
4+
<div>Hello</div>
5+
</Show>
6+
);
7+
8+
export const c8Pragma = (
9+
<Show when={condition()}>
10+
{/* c8 ignore next */}
11+
<div>Hello</div>
12+
</Show>
13+
);
14+
15+
export const expressionChild = (
16+
<Show when={condition()}>
17+
{/* c8 ignore next */}
18+
{state.value}
19+
</Show>
20+
);
21+
22+
export const multipleChildren = (
23+
<Comp>
24+
{/* istanbul ignore next */}
25+
<div>A</div>
26+
<span>B</span>
27+
</Comp>
28+
);
29+
30+
export const nestedComponent = (
31+
<Outer>
32+
<Inner>
33+
{/* c8 ignore next */}
34+
<div>A</div>
35+
</Inner>
36+
</Outer>
37+
);
38+
39+
export const twoContainers = (
40+
<Comp>
41+
{/* istanbul ignore next */}
42+
{/* c8 ignore next */}
43+
<div>A</div>
44+
</Comp>
45+
);
46+
47+
export const noteBesidePragma = (
48+
<Comp>
49+
{/* note */
50+
/* c8 ignore next */}
51+
<div>A</div>
52+
</Comp>
53+
);
54+
55+
export const textAfterPragma = (
56+
<Comp>
57+
{/* c8 ignore next */}text<div>A</div>
58+
</Comp>
59+
);
60+
61+
export const spaceAfterPragma = (
62+
<Comp>
63+
{/* c8 ignore next */} <div>A</div>
64+
</Comp>
65+
);
66+
67+
export const lineComment = (
68+
<Comp>
69+
{
70+
// c8 ignore next
71+
}
72+
<div>A</div>
73+
</Comp>
74+
);
75+
76+
export const lineCommentMentioningBlock = (
77+
<Comp>
78+
{
79+
// see /* c8 ignore next */
80+
}
81+
<div>A</div>
82+
</Comp>
83+
);
84+
85+
export const blockCommentMentioningLine = (
86+
<Comp>
87+
{/* see // c8 ignore next */}
88+
<div>A</div>
89+
</Comp>
90+
);
91+
92+
export const trailingPragma = (
93+
<Comp>
94+
<div>A</div>
95+
{/* c8 ignore next */}
96+
</Comp>
97+
);
98+
99+
export const notePragmaOnly = (
100+
<Comp>
101+
{/* just a note */}
102+
<div>A</div>
103+
</Comp>
104+
);
105+
106+
export const lookalike = (
107+
<Comp>
108+
{/* c8 ignored */}
109+
<div>A</div>
110+
</Comp>
111+
);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Shared by the Babel plugin and native compiler tests: the comments each
2+
// export of `code.js` must print ahead of its `children` getters, in order.
3+
const expected = {
4+
istanbulPragma: ["/* istanbul ignore next */"],
5+
c8Pragma: ["/* c8 ignore next */"],
6+
expressionChild: ["/* c8 ignore next */"],
7+
multipleChildren: ["/* istanbul ignore next */"],
8+
nestedComponent: ["/* c8 ignore next */"],
9+
twoContainers: ["/* istanbul ignore next */", "/* c8 ignore next */"],
10+
noteBesidePragma: ["/* c8 ignore next */"],
11+
textAfterPragma: ["/* c8 ignore next */"],
12+
spaceAfterPragma: ["/* c8 ignore next */"],
13+
lineComment: [],
14+
lineCommentMentioningBlock: [],
15+
blockCommentMentioningLine: [],
16+
trailingPragma: [],
17+
notePragmaOnly: [],
18+
lookalike: []
19+
};
20+
21+
exports.expectedFor = function expectedFor(generate) {
22+
// SSR prop hoisting moves a nested component's static props into a shared
23+
// descriptor, whose `get()` does not carry the getter's comments.
24+
return generate === "ssr" ? { ...expected, nestedComponent: [] } : expected;
25+
};
26+
27+
const comment = String.raw`\/\*(?:(?!\*\/)[\s\S])*\*\/|\/\/[^\n]*`;
28+
const getterComments = new RegExp(String.raw`((?:(?:${comment})\s*)+)get children\(\)`, "g");
29+
30+
exports.pragmasByExport = function pragmasByExport(code) {
31+
const result = {};
32+
const sections = code.split(/^export const (\w+) = /m);
33+
for (let i = 1; i < sections.length; i += 2) {
34+
result[sections[i]] = [...sections[i + 1].matchAll(getterComments)].flatMap(([, group]) =>
35+
group.match(new RegExp(comment, "g")).map(text => text.trim())
36+
);
37+
}
38+
return result;
39+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
const babel = require("@babel/core");
2+
const fs = require("fs");
3+
const path = require("path");
4+
const plugin = require("../index");
5+
const { expectedFor, pragmasByExport } = require("./__shared_fixtures__/coveragePragmas/expected");
6+
7+
const fixture = fs.readFileSync(
8+
path.join(__dirname, "__shared_fixtures__", "coveragePragmas", "code.js"),
9+
"utf8"
10+
);
11+
12+
describe("coverage pragmas", () => {
13+
test.each(["dom", "ssr", "universal"])(
14+
"carries authored pragmas onto %s component children getters",
15+
generate => {
16+
const { code } = babel.transformSync(fixture, {
17+
plugins: [[plugin, { generate, moduleName: "r-dom" }]],
18+
configFile: false,
19+
babelrc: false,
20+
filename: "coveragePragmas.jsx"
21+
});
22+
expect(pragmasByExport(code)).toEqual(expectedFor(generate));
23+
}
24+
);
25+
});

‎packages/compiler/__tests__/transform.test.js‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ const fs = require("fs");
33
const path = require("path");
44

55
const babelDomFixtures = path.resolve(__dirname, "../../babel-plugin/test/__dom_fixtures__");
6+
const coveragePragmasDir = path.resolve(
7+
__dirname,
8+
"../../babel-plugin/test/__shared_fixtures__/coveragePragmas"
9+
);
10+
const coveragePragmas = require(path.join(coveragePragmasDir, "expected"));
611

712
function readFixture(name) {
813
return fs.readFileSync(path.join(babelDomFixtures, name, "code.js"), "utf8");
@@ -89,6 +94,21 @@ describe("@solidjs/compiler transform", () => {
8994
expect(result.code).toContain('_$createComponent(Child, { name: "Jake" });');
9095
});
9196

97+
it.each(["dom", "ssr", "universal"])(
98+
"carries authored coverage pragmas onto %s component children getters",
99+
generate => {
100+
const result = transform(fs.readFileSync(path.join(coveragePragmasDir, "code.js"), "utf8"), {
101+
filename: "coveragePragmas.jsx",
102+
moduleName: "r-dom",
103+
generate
104+
});
105+
106+
expect(coveragePragmas.pragmasByExport(result.code)).toEqual(
107+
coveragePragmas.expectedFor(generate)
108+
);
109+
}
110+
);
111+
92112
it("memoizes dynamic conditional component props by default", () => {
93113
const result = transform("const view = <Comp render={state.dynamic ? good() : bad} />;", {
94114
filename: "input.jsx",

‎packages/compiler/src/compiler.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,8 @@ fn compile_inner(source: &str, options: &CompileOptions) -> Result<CompileOutput
283283
crate::tsrx::clear_generated_spans(&mut program, options.source_map);
284284
}
285285

286+
crate::shared::component_children::anchor_coverage_pragmas(&mut program, source);
287+
286288
match options.generate {
287289
Generate::Dom => {
288290
let mut transform = AstDomTransform::new(

‎packages/compiler/src/dom/condition.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ impl<'a> crate::shared::component_children::ComponentChildLower<'a> for AstDomTr
4141
}
4242

4343
impl<'a> ModeLower<'a> for AstDomTransform<'a, '_> {
44+
fn source(&self) -> &str {
45+
self.source
46+
}
47+
4448
fn wrap_conditionals_enabled(&self) -> bool {
4549
self.wrap_conditionals
4650
}

‎packages/compiler/src/shared/component.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ pub(crate) fn lower_component_with_setup<'a, C: ComponentLower<'a>>(
164164
if children.needs_getter {
165165
running_props.push(crate::shared::ast::object_getter_property_with_setup(
166166
allocator,
167-
element.span,
167+
children.coverage_pragma_span.unwrap_or(element.span),
168168
"children",
169169
children.setup,
170170
children.value,

0 commit comments

Comments
 (0)