From 446e93260d251af5923332ebfc3989c535246d23 Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 9 Sep 2026 13:03:25 +0300 Subject: [PATCH 1/3] fix(compiler): preserve coverage pragmas in JSX --- .changeset/compiler-coverage-pragmas.md | 6 +++ packages/babel-plugin/src/shared/component.ts | 21 +++++----- packages/babel-plugin/src/shared/utils.ts | 34 +++++++++++++--- .../coveragePragmas/code.js | 13 +++++++ packages/babel-plugin/test/ref-spread.spec.js | 15 +++++++ packages/compiler/__tests__/transform.test.js | 21 ++++++++++ packages/compiler/src/dom/condition.rs | 4 ++ packages/compiler/src/shared/component.rs | 2 +- .../compiler/src/shared/component_children.rs | 34 +++++++++++++++- packages/compiler/src/shared/mode_lower.rs | 3 ++ packages/compiler/src/shared/utils.rs | 39 +++++++++++++++++++ packages/compiler/src/ssr/transform.rs | 19 ++++++--- packages/compiler/src/universal/transform.rs | 4 ++ 13 files changed, 194 insertions(+), 21 deletions(-) create mode 100644 .changeset/compiler-coverage-pragmas.md create mode 100644 packages/babel-plugin/test/__shared_fixtures__/coveragePragmas/code.js diff --git a/.changeset/compiler-coverage-pragmas.md b/.changeset/compiler-coverage-pragmas.md new file mode 100644 index 000000000..4f6cbd218 --- /dev/null +++ b/.changeset/compiler-coverage-pragmas.md @@ -0,0 +1,6 @@ +--- +"@solidjs/babel-plugin": patch +"@solidjs/compiler": patch +--- + +Preserve `istanbul ignore` and `c8 ignore` JSX comments on generated component children getters. diff --git a/packages/babel-plugin/src/shared/component.ts b/packages/babel-plugin/src/shared/component.ts index dbb3b37cb..008f7bcd4 100644 --- a/packages/babel-plugin/src/shared/component.ts +++ b/packages/babel-plugin/src/shared/component.ts @@ -5,6 +5,7 @@ import { isDynamic, registerImportMethod, filterChildren, + getCoverageIgnoreComments, trimWhitespace, transformCondition, convertJSXIdentifier @@ -22,7 +23,7 @@ type ComponentTransformResult = TransformResult & { exprs: Array; }; -type ComponentChildrenResult = [t.Expression, boolean] | undefined; +type ComponentChildrenResult = [t.Expression, boolean, t.Comment[]?] | undefined; function isSimpleOptionalMemberExpression( expression: t.Expression | t.JSXEmptyExpression @@ -341,14 +342,14 @@ export default function transformComponent( : t.isFunction(childResult[0]) ? childResult[0].body : childResult[0]; - runningObject.push( - t.objectMethod( - "get", - t.identifier("children"), - [], - t.isExpression(body) ? t.blockStatement([t.returnStatement(body)]) : body - ) + const getter = t.objectMethod( + "get", + t.identifier("children"), + [], + t.isExpression(body) ? t.blockStatement([t.returnStatement(body)]) : body ); + if (childResult[2]?.length) getter.leadingComments = childResult[2]; + runningObject.push(getter); } else runningObject.push(t.objectProperty(t.identifier("children"), childResult[0])); } if (runningObject.length || !props.length) props.push(propsLiteral(runningObject)); @@ -409,6 +410,7 @@ function transformComponentChildren( if (!filteredChildren.length) return; let dynamic = false; let pathNodes: t.Node[] = []; + let coverageIgnoreComments: t.Comment[] | undefined; let transformedChildren: t.Expression | t.Expression[] = filteredChildren.reduce( (memo: t.Expression[], path: BabelPath) => { @@ -419,6 +421,7 @@ function transformComponentChildren( memo.push(t.stringLiteral(v)); } } else { + coverageIgnoreComments ||= getCoverageIgnoreComments(path); const child = transformNode(path, { topLevel: true, componentChild: true, @@ -468,5 +471,5 @@ function transformComponentChildren( transformedChildren = t.arrowFunctionExpression([], t.arrayExpression(transformedChildren)); dynamic = true; } - return [transformedChildren as t.Expression, dynamic]; + return [transformedChildren as t.Expression, dynamic, coverageIgnoreComments]; } diff --git a/packages/babel-plugin/src/shared/utils.ts b/packages/babel-plugin/src/shared/utils.ts index f6db35bfe..9ae2f7976 100644 --- a/packages/babel-plugin/src/shared/utils.ts +++ b/packages/babel-plugin/src/shared/utils.ts @@ -244,12 +244,36 @@ export function getStaticExpression( } // remove unnecessary JSX Text nodes +const coverageIgnoreCommentData = "solid.coverageIgnoreComments"; + +export function getCoverageIgnoreComments(path: NodePath): t.Comment[] | undefined { + return path.getData(coverageIgnoreCommentData) as t.Comment[] | undefined; +} + export function filterChildren(children: TPath[]): TPath[] { - return children.filter( - ({ node: child }) => - !(t.isJSXExpressionContainer(child) && t.isJSXEmptyExpression(child.expression)) && - (!t.isJSXText(child) || !/^[\r\n]\s*$/.test((child.extra?.raw as string | undefined) ?? "")) - ); + const filtered: TPath[] = []; + let pendingCoverageIgnoreComments: t.Comment[] = []; + + for (const path of children) { + const child = path.node; + if (t.isJSXExpressionContainer(child) && t.isJSXEmptyExpression(child.expression)) { + pendingCoverageIgnoreComments.push( + ...(child.expression.innerComments?.filter(comment => + /^\s*(istanbul|c8)\s+ignore\b/.test(comment.value) + ) ?? []) + ); + continue; + } + if (t.isJSXText(child) && /^[\r\n]\s*$/.test((child.extra?.raw as string | undefined) ?? "")) { + continue; + } + if (pendingCoverageIgnoreComments.length) { + path.setData(coverageIgnoreCommentData, pendingCoverageIgnoreComments); + pendingCoverageIgnoreComments = []; + } + filtered.push(path); + } + return filtered; } export function checkLength(children: NodePath[]): boolean { diff --git a/packages/babel-plugin/test/__shared_fixtures__/coveragePragmas/code.js b/packages/babel-plugin/test/__shared_fixtures__/coveragePragmas/code.js new file mode 100644 index 000000000..a6b090e6c --- /dev/null +++ b/packages/babel-plugin/test/__shared_fixtures__/coveragePragmas/code.js @@ -0,0 +1,13 @@ +export const istanbulPragma = ( + + {/* istanbul ignore next */} +
Hello
+
+); + +export const c8Pragma = ( + + {/* c8 ignore next */} +
Hello
+
+); diff --git a/packages/babel-plugin/test/ref-spread.spec.js b/packages/babel-plugin/test/ref-spread.spec.js index 262a65af4..fe387b458 100644 --- a/packages/babel-plugin/test/ref-spread.spec.js +++ b/packages/babel-plugin/test/ref-spread.spec.js @@ -1,6 +1,13 @@ const babel = require("@babel/core"); +const fs = require("fs"); +const path = require("path"); const plugin = require("../index"); +const coveragePragmasFixture = fs.readFileSync( + path.join(__dirname, "__shared_fixtures__", "coveragePragmas", "code.js"), + "utf8" +); + function compile(code, generate = "ssr", hydratable = true) { return babel.transformSync(code, { plugins: [[plugin, { generate, hydratable }]], @@ -11,6 +18,14 @@ function compile(code, generate = "ssr", hydratable = true) { } describe("intrinsic ref and spread sources", () => { + test.each(["istanbul", "c8"])( + "preserves a %s ignore comment for a generated children getter", + tool => { + const output = compile(coveragePragmasFixture, "dom"); + expect(output).toMatch(new RegExp(`/\\* ${tool} ignore next \\*/\\s*get children\\(\\)`)); + } + ); + test.each([ ["a spread", "const view =
;"], [ diff --git a/packages/compiler/__tests__/transform.test.js b/packages/compiler/__tests__/transform.test.js index 86077f00e..8b8bb2482 100644 --- a/packages/compiler/__tests__/transform.test.js +++ b/packages/compiler/__tests__/transform.test.js @@ -3,6 +3,10 @@ const fs = require("fs"); const path = require("path"); const babelDomFixtures = path.resolve(__dirname, "../../babel-plugin/test/__dom_fixtures__"); +const coveragePragmasFixture = path.resolve( + __dirname, + "../../babel-plugin/test/__shared_fixtures__/coveragePragmas/code.js" +); function readFixture(name) { return fs.readFileSync(path.join(babelDomFixtures, name, "code.js"), "utf8"); @@ -89,6 +93,23 @@ describe("@solidjs/compiler transform", () => { expect(result.code).toContain('_$createComponent(Child, { name: "Jake" });'); }); + it.each([ + ["dom", "istanbul"], + ["universal", "istanbul"], + ["ssr", "istanbul"], + ["dom", "c8"], + ["universal", "c8"], + ["ssr", "c8"] + ])("preserves a %s ignore comment in %s component children getters", (generate, tool) => { + const result = transform(fs.readFileSync(coveragePragmasFixture, "utf8"), { + filename: "coveragePragmas.jsx", + moduleName: "r-dom", + generate + }); + + expect(result.code).toMatch(new RegExp(`/\\* ${tool} ignore next \\*/\\s*get children\\(\\)`)); + }); + it("memoizes dynamic conditional component props by default", () => { const result = transform("const view = ;", { filename: "input.jsx", diff --git a/packages/compiler/src/dom/condition.rs b/packages/compiler/src/dom/condition.rs index b30016d16..de3535030 100644 --- a/packages/compiler/src/dom/condition.rs +++ b/packages/compiler/src/dom/condition.rs @@ -41,6 +41,10 @@ impl<'a> crate::shared::component_children::ComponentChildLower<'a> for AstDomTr } impl<'a> ModeLower<'a> for AstDomTransform<'a, '_> { + fn source(&self) -> &str { + self.source + } + fn wrap_conditionals_enabled(&self) -> bool { self.wrap_conditionals } diff --git a/packages/compiler/src/shared/component.rs b/packages/compiler/src/shared/component.rs index 719185117..823bc8473 100644 --- a/packages/compiler/src/shared/component.rs +++ b/packages/compiler/src/shared/component.rs @@ -164,7 +164,7 @@ pub(crate) fn lower_component_with_setup<'a, C: ComponentLower<'a>>( if children.needs_getter { running_props.push(crate::shared::ast::object_getter_property_with_setup( allocator, - element.span, + children.coverage_pragma_span.unwrap_or(element.span), "children", children.setup, children.value, diff --git a/packages/compiler/src/shared/component_children.rs b/packages/compiler/src/shared/component_children.rs index 6f8aa64ce..2ff2f47a6 100644 --- a/packages/compiler/src/shared/component_children.rs +++ b/packages/compiler/src/shared/component_children.rs @@ -13,7 +13,7 @@ use crate::shared::ast::arrow_return_expression; use crate::shared::condition::{is_condition_shape, transform_condition_inline}; use crate::shared::fragment::lower_fragment; use crate::shared::mode_lower::{ModeLower, mode_ast}; -use crate::shared::utils::{decode_html_entities, trim_jsx_text}; +use crate::shared::utils::{decode_html_entities, is_coverage_ignore_pragma, trim_jsx_text}; /// The extra seam component children need beyond [`ModeLower`]: element /// children keep their setup statements (template declarations + operations) @@ -29,6 +29,10 @@ pub(crate) struct ComponentChildren<'a> { pub(crate) value: Expression<'a>, pub(crate) needs_getter: bool, pub(crate) setup: std::vec::Vec>, + /// The source anchor of an authored coverage pragma. Oxc attaches a JSX + /// empty-expression comment to its closing `}`, so the synthetic getter + /// uses that span to retain the pragma in generated output. + pub(crate) coverage_pragma_span: Option, } enum ChildKind { @@ -58,6 +62,7 @@ pub(crate) fn component_children<'a, C: ComponentChildLower<'a>>( ) -> Result>> { let allocator = ctx.condition_allocator(); let ast = mode_ast(ctx); + let coverage_pragma_span = component_children_coverage_pragma_span(children, ctx.source()); let mut values = std::vec::Vec::new(); for child in children { match child { @@ -153,6 +158,7 @@ pub(crate) fn component_children<'a, C: ComponentChildLower<'a>>( value: child.value, needs_getter: !matches!(child.kind, ChildKind::Static), setup: child.setup, + coverage_pragma_span, }) } _ => { @@ -186,7 +192,33 @@ pub(crate) fn component_children<'a, C: ComponentChildLower<'a>>( value: ast.expression_array(span, ast.vec_from_iter(elements)), needs_getter: true, setup: std::vec::Vec::new(), + coverage_pragma_span, }) } }) } + +pub(crate) fn component_children_coverage_pragma_span( + children: &[JSXChild<'_>], + source: &str, +) -> Option { + let mut pending = None; + for child in children { + match child { + JSXChild::ExpressionContainer(container) + if matches!(container.expression, JSXExpression::EmptyExpression(_)) => + { + if is_coverage_ignore_pragma(source, container.span) { + pending = Some(oxc_span::Span::new( + container.span.end - 1, + container.span.end, + )); + } + } + JSXChild::Text(text) if trim_jsx_text(&text.value).is_empty() => {} + _ if pending.is_some() => return pending, + _ => {} + } + } + None +} diff --git a/packages/compiler/src/shared/mode_lower.rs b/packages/compiler/src/shared/mode_lower.rs index eebbf81dd..5e93b1774 100644 --- a/packages/compiler/src/shared/mode_lower.rs +++ b/packages/compiler/src/shared/mode_lower.rs @@ -18,6 +18,9 @@ use crate::shared::condition::{ }; pub(crate) trait ModeLower<'a>: ConditionBuilder<'a> { + /// Original source, used to identify comments on JSX empty expressions. + fn source(&self) -> &str; + /// Whether `wrapConditionals` is enabled for this generate. fn wrap_conditionals_enabled(&self) -> bool; diff --git a/packages/compiler/src/shared/utils.rs b/packages/compiler/src/shared/utils.rs index 9ec982a2d..ee3ef517c 100644 --- a/packages/compiler/src/shared/utils.rs +++ b/packages/compiler/src/shared/utils.rs @@ -234,6 +234,45 @@ pub(crate) fn source_from_span(span: Span, source: &str) -> &str { &source[span.start as usize..span.end as usize] } +/// Whether a JSX empty expression carries a coverage pragma that should be +/// retained when its following child is lowered into a component getter. +pub(crate) fn is_coverage_ignore_pragma(source: &str, span: Span) -> bool { + let source = source_from_span(span, source); + let mut rest = source; + while let Some(comment_start) = rest.find("/*") { + let after_start = &rest[comment_start + 2..]; + let Some(comment_end) = after_start.find("*/") else { + break; + }; + if is_coverage_ignore_comment(&after_start[..comment_end]) { + return true; + } + rest = &after_start[comment_end + 2..]; + } + source + .lines() + .filter_map(|line| line.split_once("//").map(|(_, comment)| comment)) + .any(is_coverage_ignore_comment) +} + +fn is_coverage_ignore_comment(comment: &str) -> bool { + let comment = comment.trim_start(); + ["istanbul", "c8"].iter().any(|tool| { + let Some(rest) = comment.strip_prefix(tool) else { + return false; + }; + if !rest.chars().next().is_some_and(char::is_whitespace) { + return false; + } + let Some(rest) = rest.trim_start().strip_prefix("ignore") else { + return false; + }; + !rest.chars().next().is_some_and(|character| { + character.is_alphanumeric() || character == '_' || character == '$' + }) + }) +} + /// Exact port of Babel's `trimWhitespace`: strip `\r`; for multiline text, /// drop each continuation line's indentation and all-whitespace lines, then /// join with spaces (the first line keeps its leading, and the last line its diff --git a/packages/compiler/src/ssr/transform.rs b/packages/compiler/src/ssr/transform.rs index 9e8662bbd..ccf43fdb7 100644 --- a/packages/compiler/src/ssr/transform.rs +++ b/packages/compiler/src/ssr/transform.rs @@ -19,6 +19,7 @@ use crate::shared::attr_plan::{AttrPlan, AttrPlanner, PlanValue}; use crate::shared::bindings::BindingTable; use crate::shared::classify::{Classify, jsx_text_is_filtered, significant_children}; use crate::shared::component_callee::{ComponentCalleeContext, component_callee_expression}; +use crate::shared::component_children::component_children_coverage_pragma_span; use crate::shared::component_props::{ ComponentPropContext, component_property, component_props_expression, component_spread_expression, flush_component_props, @@ -1156,7 +1157,9 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { } } - if let Some((children, dynamic)) = self.component_children_expression(&element.children)? { + if let Some((children, dynamic, coverage_pragma_span)) = + self.component_children_expression(&element.children)? + { if dynamic { // Babel's getter-body inlining for dynamic children: unwrap a // `memo(fn)` call to `fn.body`, a plain function to its body, @@ -1165,7 +1168,7 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { component_children_getter_statements(self.allocator, element.span, children); running_props.push(crate::shared::ast::object_getter_property_with_statements( self.allocator, - element.span, + coverage_pragma_span.unwrap_or(element.span), "children", statements, )); @@ -1218,7 +1221,8 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { fn component_children_expression( &mut self, children: &[JSXChild<'a>], - ) -> Result, bool)>> { + ) -> Result, bool, Option)>> { + let coverage_pragma_span = component_children_coverage_pragma_span(children, self.source); // `filterChildren`: drop empty expression containers and JSXText whose // raw starts with a newline and contains only whitespace. let filtered: std::vec::Vec<&JSXChild<'a>> = children @@ -1368,7 +1372,7 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { 1 => { let child = values.pop().expect("component child exists"); if child.expression_source { - Some((child.value, child.dynamic)) + Some((child.value, child.dynamic, coverage_pragma_span)) } else { // Elements/fragments force a thunk (Babel's single-child // branch in `transformComponentChildren`): a zero-arg @@ -1382,7 +1386,11 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { let mut statements = self.ast().vec(); statements.extend(setup); statements.push(self.ast().statement_return(span, Some(value))); - Some((arrow_iife(self.allocator, span, statements), true)) + Some(( + arrow_iife(self.allocator, span, statements), + true, + coverage_pragma_span, + )) } } _ => { @@ -1396,6 +1404,7 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { self.ast() .expression_array(span, self.ast().vec_from_iter(elements)), true, + coverage_pragma_span, )) } }) diff --git a/packages/compiler/src/universal/transform.rs b/packages/compiler/src/universal/transform.rs index f4300100f..129857e3f 100644 --- a/packages/compiler/src/universal/transform.rs +++ b/packages/compiler/src/universal/transform.rs @@ -1834,6 +1834,10 @@ impl<'a> crate::shared::component_children::ComponentChildLower<'a> } impl<'a> crate::shared::mode_lower::ModeLower<'a> for AstUniversalTransform<'a, '_> { + fn source(&self) -> &str { + self.source + } + fn wrap_conditionals_enabled(&self) -> bool { self.wrap_conditionals } From 03073b2fa5bb3ddccfc02dfead236b13fa46b6cb Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 25 Sep 2026 21:37:21 +0300 Subject: [PATCH 2/3] fix(compiler): implement SSR source access --- packages/compiler/src/ssr/transform.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/compiler/src/ssr/transform.rs b/packages/compiler/src/ssr/transform.rs index ccf43fdb7..3408bc4e9 100644 --- a/packages/compiler/src/ssr/transform.rs +++ b/packages/compiler/src/ssr/transform.rs @@ -3461,6 +3461,10 @@ impl<'a> ConditionBuilder<'a> for AstSsrTransform<'a, '_> { } impl<'a> crate::shared::mode_lower::ModeLower<'a> for AstSsrTransform<'a, '_> { + fn source(&self) -> &str { + self.source + } + fn wrap_conditionals_enabled(&self) -> bool { self.wrap_conditionals } From 3d20da1f43722547f7b9a81320eed1962219c5f4 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 30 Sep 2026 10:32:09 -0700 Subject: [PATCH 3/3] 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 Co-authored-by: Cursor --- .changeset/compiler-coverage-pragmas.md | 2 +- packages/babel-plugin/src/shared/component.ts | 2 +- packages/babel-plugin/src/shared/utils.ts | 7 +- .../coveragePragmas/code.js | 98 +++++++++++++++++++ .../coveragePragmas/expected.js | 39 ++++++++ .../test/coverage-pragmas.spec.js | 25 +++++ packages/babel-plugin/test/ref-spread.spec.js | 15 --- packages/compiler/__tests__/transform.test.js | 33 +++---- packages/compiler/src/compiler.rs | 2 + .../compiler/src/shared/component_children.rs | 90 +++++++++++++---- packages/compiler/src/shared/utils.rs | 52 ++++++---- 11 files changed, 289 insertions(+), 76 deletions(-) create mode 100644 packages/babel-plugin/test/__shared_fixtures__/coveragePragmas/expected.js create mode 100644 packages/babel-plugin/test/coverage-pragmas.spec.js diff --git a/.changeset/compiler-coverage-pragmas.md b/.changeset/compiler-coverage-pragmas.md index 4f6cbd218..e94dadd6c 100644 --- a/.changeset/compiler-coverage-pragmas.md +++ b/.changeset/compiler-coverage-pragmas.md @@ -3,4 +3,4 @@ "@solidjs/compiler": patch --- -Preserve `istanbul ignore` and `c8 ignore` JSX comments on generated component children getters. +Preserve `/* istanbul ignore … */` and `/* c8 ignore … */` block comments written as JSX children on the generated component `children` getter. Line comments are not carried. diff --git a/packages/babel-plugin/src/shared/component.ts b/packages/babel-plugin/src/shared/component.ts index 008f7bcd4..c43b861ee 100644 --- a/packages/babel-plugin/src/shared/component.ts +++ b/packages/babel-plugin/src/shared/component.ts @@ -414,6 +414,7 @@ function transformComponentChildren( let transformedChildren: t.Expression | t.Expression[] = filteredChildren.reduce( (memo: t.Expression[], path: BabelPath) => { + coverageIgnoreComments ||= getCoverageIgnoreComments(path); if (t.isJSXText(path.node)) { const v = decode(trimWhitespace((path.node.extra?.raw as string | undefined) ?? "")); if (v.length) { @@ -421,7 +422,6 @@ function transformComponentChildren( memo.push(t.stringLiteral(v)); } } else { - coverageIgnoreComments ||= getCoverageIgnoreComments(path); const child = transformNode(path, { topLevel: true, componentChild: true, diff --git a/packages/babel-plugin/src/shared/utils.ts b/packages/babel-plugin/src/shared/utils.ts index f7184d334..b66a23250 100644 --- a/packages/babel-plugin/src/shared/utils.ts +++ b/packages/babel-plugin/src/shared/utils.ts @@ -243,13 +243,13 @@ export function getStaticExpression( ); } -// remove unnecessary JSX Text nodes const coverageIgnoreCommentData = "solid.coverageIgnoreComments"; export function getCoverageIgnoreComments(path: NodePath): t.Comment[] | undefined { return path.getData(coverageIgnoreCommentData) as t.Comment[] | undefined; } +// remove unnecessary JSX Text nodes export function filterChildren(children: TPath[]): TPath[] { const filtered: TPath[] = []; let pendingCoverageIgnoreComments: t.Comment[] = []; @@ -258,8 +258,9 @@ export function filterChildren(children: TPath[]): TPath const child = path.node; if (t.isJSXExpressionContainer(child) && t.isJSXEmptyExpression(child.expression)) { pendingCoverageIgnoreComments.push( - ...(child.expression.innerComments?.filter(comment => - /^\s*(istanbul|c8)\s+ignore\b/.test(comment.value) + ...(child.expression.innerComments?.filter( + comment => + comment.type === "CommentBlock" && /^\s*(istanbul|c8)\s+ignore\b/.test(comment.value) ) ?? []) ); continue; diff --git a/packages/babel-plugin/test/__shared_fixtures__/coveragePragmas/code.js b/packages/babel-plugin/test/__shared_fixtures__/coveragePragmas/code.js index a6b090e6c..45702a337 100644 --- a/packages/babel-plugin/test/__shared_fixtures__/coveragePragmas/code.js +++ b/packages/babel-plugin/test/__shared_fixtures__/coveragePragmas/code.js @@ -11,3 +11,101 @@ export const c8Pragma = (
Hello
); + +export const expressionChild = ( + + {/* c8 ignore next */} + {state.value} + +); + +export const multipleChildren = ( + + {/* istanbul ignore next */} +
A
+ B +
+); + +export const nestedComponent = ( + + + {/* c8 ignore next */} +
A
+
+
+); + +export const twoContainers = ( + + {/* istanbul ignore next */} + {/* c8 ignore next */} +
A
+
+); + +export const noteBesidePragma = ( + + {/* note */ + /* c8 ignore next */} +
A
+
+); + +export const textAfterPragma = ( + + {/* c8 ignore next */}text
A
+
+); + +export const spaceAfterPragma = ( + + {/* c8 ignore next */}
A
+
+); + +export const lineComment = ( + + { + // c8 ignore next + } +
A
+
+); + +export const lineCommentMentioningBlock = ( + + { + // see /* c8 ignore next */ + } +
A
+
+); + +export const blockCommentMentioningLine = ( + + {/* see // c8 ignore next */} +
A
+
+); + +export const trailingPragma = ( + +
A
+ {/* c8 ignore next */} +
+); + +export const notePragmaOnly = ( + + {/* just a note */} +
A
+
+); + +export const lookalike = ( + + {/* c8 ignored */} +
A
+
+); diff --git a/packages/babel-plugin/test/__shared_fixtures__/coveragePragmas/expected.js b/packages/babel-plugin/test/__shared_fixtures__/coveragePragmas/expected.js new file mode 100644 index 000000000..1b00d6961 --- /dev/null +++ b/packages/babel-plugin/test/__shared_fixtures__/coveragePragmas/expected.js @@ -0,0 +1,39 @@ +// Shared by the Babel plugin and native compiler tests: the comments each +// export of `code.js` must print ahead of its `children` getters, in order. +const expected = { + istanbulPragma: ["/* istanbul ignore next */"], + c8Pragma: ["/* c8 ignore next */"], + expressionChild: ["/* c8 ignore next */"], + multipleChildren: ["/* istanbul ignore next */"], + nestedComponent: ["/* c8 ignore next */"], + twoContainers: ["/* istanbul ignore next */", "/* c8 ignore next */"], + noteBesidePragma: ["/* c8 ignore next */"], + textAfterPragma: ["/* c8 ignore next */"], + spaceAfterPragma: ["/* c8 ignore next */"], + lineComment: [], + lineCommentMentioningBlock: [], + blockCommentMentioningLine: [], + trailingPragma: [], + notePragmaOnly: [], + lookalike: [] +}; + +exports.expectedFor = function expectedFor(generate) { + // SSR prop hoisting moves a nested component's static props into a shared + // descriptor, whose `get()` does not carry the getter's comments. + return generate === "ssr" ? { ...expected, nestedComponent: [] } : expected; +}; + +const comment = String.raw`\/\*(?:(?!\*\/)[\s\S])*\*\/|\/\/[^\n]*`; +const getterComments = new RegExp(String.raw`((?:(?:${comment})\s*)+)get children\(\)`, "g"); + +exports.pragmasByExport = function pragmasByExport(code) { + const result = {}; + const sections = code.split(/^export const (\w+) = /m); + for (let i = 1; i < sections.length; i += 2) { + result[sections[i]] = [...sections[i + 1].matchAll(getterComments)].flatMap(([, group]) => + group.match(new RegExp(comment, "g")).map(text => text.trim()) + ); + } + return result; +}; diff --git a/packages/babel-plugin/test/coverage-pragmas.spec.js b/packages/babel-plugin/test/coverage-pragmas.spec.js new file mode 100644 index 000000000..f25ffb75e --- /dev/null +++ b/packages/babel-plugin/test/coverage-pragmas.spec.js @@ -0,0 +1,25 @@ +const babel = require("@babel/core"); +const fs = require("fs"); +const path = require("path"); +const plugin = require("../index"); +const { expectedFor, pragmasByExport } = require("./__shared_fixtures__/coveragePragmas/expected"); + +const fixture = fs.readFileSync( + path.join(__dirname, "__shared_fixtures__", "coveragePragmas", "code.js"), + "utf8" +); + +describe("coverage pragmas", () => { + test.each(["dom", "ssr", "universal"])( + "carries authored pragmas onto %s component children getters", + generate => { + const { code } = babel.transformSync(fixture, { + plugins: [[plugin, { generate, moduleName: "r-dom" }]], + configFile: false, + babelrc: false, + filename: "coveragePragmas.jsx" + }); + expect(pragmasByExport(code)).toEqual(expectedFor(generate)); + } + ); +}); diff --git a/packages/babel-plugin/test/ref-spread.spec.js b/packages/babel-plugin/test/ref-spread.spec.js index fe387b458..262a65af4 100644 --- a/packages/babel-plugin/test/ref-spread.spec.js +++ b/packages/babel-plugin/test/ref-spread.spec.js @@ -1,13 +1,6 @@ const babel = require("@babel/core"); -const fs = require("fs"); -const path = require("path"); const plugin = require("../index"); -const coveragePragmasFixture = fs.readFileSync( - path.join(__dirname, "__shared_fixtures__", "coveragePragmas", "code.js"), - "utf8" -); - function compile(code, generate = "ssr", hydratable = true) { return babel.transformSync(code, { plugins: [[plugin, { generate, hydratable }]], @@ -18,14 +11,6 @@ function compile(code, generate = "ssr", hydratable = true) { } describe("intrinsic ref and spread sources", () => { - test.each(["istanbul", "c8"])( - "preserves a %s ignore comment for a generated children getter", - tool => { - const output = compile(coveragePragmasFixture, "dom"); - expect(output).toMatch(new RegExp(`/\\* ${tool} ignore next \\*/\\s*get children\\(\\)`)); - } - ); - test.each([ ["a spread", "const view =
;"], [ diff --git a/packages/compiler/__tests__/transform.test.js b/packages/compiler/__tests__/transform.test.js index 8b8bb2482..1212487cb 100644 --- a/packages/compiler/__tests__/transform.test.js +++ b/packages/compiler/__tests__/transform.test.js @@ -3,10 +3,11 @@ const fs = require("fs"); const path = require("path"); const babelDomFixtures = path.resolve(__dirname, "../../babel-plugin/test/__dom_fixtures__"); -const coveragePragmasFixture = path.resolve( +const coveragePragmasDir = path.resolve( __dirname, - "../../babel-plugin/test/__shared_fixtures__/coveragePragmas/code.js" + "../../babel-plugin/test/__shared_fixtures__/coveragePragmas" ); +const coveragePragmas = require(path.join(coveragePragmasDir, "expected")); function readFixture(name) { return fs.readFileSync(path.join(babelDomFixtures, name, "code.js"), "utf8"); @@ -93,22 +94,20 @@ describe("@solidjs/compiler transform", () => { expect(result.code).toContain('_$createComponent(Child, { name: "Jake" });'); }); - it.each([ - ["dom", "istanbul"], - ["universal", "istanbul"], - ["ssr", "istanbul"], - ["dom", "c8"], - ["universal", "c8"], - ["ssr", "c8"] - ])("preserves a %s ignore comment in %s component children getters", (generate, tool) => { - const result = transform(fs.readFileSync(coveragePragmasFixture, "utf8"), { - filename: "coveragePragmas.jsx", - moduleName: "r-dom", - generate - }); + it.each(["dom", "ssr", "universal"])( + "carries authored coverage pragmas onto %s component children getters", + generate => { + const result = transform(fs.readFileSync(path.join(coveragePragmasDir, "code.js"), "utf8"), { + filename: "coveragePragmas.jsx", + moduleName: "r-dom", + generate + }); - expect(result.code).toMatch(new RegExp(`/\\* ${tool} ignore next \\*/\\s*get children\\(\\)`)); - }); + expect(coveragePragmas.pragmasByExport(result.code)).toEqual( + coveragePragmas.expectedFor(generate) + ); + } + ); it("memoizes dynamic conditional component props by default", () => { const result = transform("const view = ;", { diff --git a/packages/compiler/src/compiler.rs b/packages/compiler/src/compiler.rs index 619f28ccb..23fdfffa5 100644 --- a/packages/compiler/src/compiler.rs +++ b/packages/compiler/src/compiler.rs @@ -283,6 +283,8 @@ fn compile_inner(source: &str, options: &CompileOptions) -> Result { let mut transform = AstDomTransform::new( diff --git a/packages/compiler/src/shared/component_children.rs b/packages/compiler/src/shared/component_children.rs index 2ff2f47a6..bc45b06aa 100644 --- a/packages/compiler/src/shared/component_children.rs +++ b/packages/compiler/src/shared/component_children.rs @@ -5,15 +5,20 @@ use crate::error::Result; use oxc_allocator::CloneIn; -use oxc_ast::ast::{Expression, JSXChild, JSXElement, JSXExpression, Statement}; -use oxc_span::GetSpan; +use oxc_ast::ast::{ + CommentPosition, Expression, JSXChild, JSXElement, JSXExpression, Program, Statement, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_span::{GetSpan, Span}; +use std::collections::HashMap; use crate::shared::array::expression_to_array_element; use crate::shared::ast::arrow_return_expression; +use crate::shared::classify::jsx_text_is_filtered; use crate::shared::condition::{is_condition_shape, transform_condition_inline}; use crate::shared::fragment::lower_fragment; use crate::shared::mode_lower::{ModeLower, mode_ast}; -use crate::shared::utils::{decode_html_entities, is_coverage_ignore_pragma, trim_jsx_text}; +use crate::shared::utils::{coverage_ignore_block_comments, decode_html_entities, trim_jsx_text}; /// The extra seam component children need beyond [`ModeLower`]: element /// children keep their setup statements (template declarations + operations) @@ -29,10 +34,9 @@ pub(crate) struct ComponentChildren<'a> { pub(crate) value: Expression<'a>, pub(crate) needs_getter: bool, pub(crate) setup: std::vec::Vec>, - /// The source anchor of an authored coverage pragma. Oxc attaches a JSX - /// empty-expression comment to its closing `}`, so the synthetic getter - /// uses that span to retain the pragma in generated output. - pub(crate) coverage_pragma_span: Option, + /// Where the authored coverage pragmas are anchored; see + /// [`anchor_coverage_pragmas`]. + pub(crate) coverage_pragma_span: Option, } enum ChildKind { @@ -198,27 +202,75 @@ pub(crate) fn component_children<'a, C: ComponentChildLower<'a>>( }) } -pub(crate) fn component_children_coverage_pragma_span( +/// Babel's `filterChildren` hand-off: coverage pragmas in empty expression +/// containers accumulate until the next child that survives the filter, and +/// the first such child's pragmas lead the `children` getter — whatever kind +/// of child it is. +fn component_children_coverage_pragmas( children: &[JSXChild<'_>], source: &str, -) -> Option { - let mut pending = None; +) -> std::vec::Vec { + let mut pending = std::vec::Vec::new(); for child in children { match child { JSXChild::ExpressionContainer(container) if matches!(container.expression, JSXExpression::EmptyExpression(_)) => { - if is_coverage_ignore_pragma(source, container.span) { - pending = Some(oxc_span::Span::new( - container.span.end - 1, - container.span.end, - )); - } + pending.extend(coverage_ignore_block_comments(source, container.span)); } - JSXChild::Text(text) if trim_jsx_text(&text.value).is_empty() => {} - _ if pending.is_some() => return pending, + JSXChild::Text(text) if jsx_text_is_filtered(&text.value) => {} + _ if !pending.is_empty() => return pending, _ => {} } } - None + std::vec::Vec::new() +} + +/// The span the `children` getter takes so codegen prints the authored +/// pragmas ahead of it: the first pragma's own start, which +/// [`anchor_coverage_pragmas`] attaches every pragma of the run to. +pub(crate) fn component_children_coverage_pragma_span( + children: &[JSXChild<'_>], + source: &str, +) -> Option { + component_children_coverage_pragmas(children, source) + .first() + .copied() +} + +/// Oxc attaches a comment to the token after it, so each `{/* … */}` pragma +/// hangs off its container's `}` and disappears with the JSX. Re-attach every +/// pragma of a run to the first pragma's start — a position no other node +/// starts at — so only the pragmas print, ahead of the getter spanned there. +pub(crate) fn anchor_coverage_pragmas(program: &mut Program<'_>, source: &str) { + struct Collector<'s> { + source: &'s str, + anchors: HashMap, + } + impl<'a> Visit<'a> for Collector<'_> { + fn visit_jsx_element(&mut self, element: &JSXElement<'a>) { + let pragmas = component_children_coverage_pragmas(&element.children, self.source); + if let Some(anchor) = pragmas.first() { + for pragma in &pragmas { + self.anchors.insert(pragma.start, anchor.start); + } + } + walk::walk_jsx_element(self, element); + } + } + + let mut collector = Collector { + source, + anchors: HashMap::new(), + }; + collector.visit_program(program); + if collector.anchors.is_empty() { + return; + } + for comment in program.comments.iter_mut() { + if let Some(&anchor) = collector.anchors.get(&comment.span.start) { + comment.attached_to = anchor; + comment.position = CommentPosition::Leading; + } + } } diff --git a/packages/compiler/src/shared/utils.rs b/packages/compiler/src/shared/utils.rs index ee3ef517c..4f07bd283 100644 --- a/packages/compiler/src/shared/utils.rs +++ b/packages/compiler/src/shared/utils.rs @@ -234,27 +234,38 @@ pub(crate) fn source_from_span(span: Span, source: &str) -> &str { &source[span.start as usize..span.end as usize] } -/// Whether a JSX empty expression carries a coverage pragma that should be -/// retained when its following child is lowered into a component getter. -pub(crate) fn is_coverage_ignore_pragma(source: &str, span: Span) -> bool { - let source = source_from_span(span, source); - let mut rest = source; - while let Some(comment_start) = rest.find("/*") { - let after_start = &rest[comment_start + 2..]; - let Some(comment_end) = after_start.find("*/") else { - break; - }; - if is_coverage_ignore_comment(&after_start[..comment_end]) { - return true; +/// Spans of the coverage-pragma block comments inside a JSX empty expression +/// container (`{/* istanbul ignore next */}`). Line comments never count, +/// matching Babel's `CommentBlock`-only filter. +pub(crate) fn coverage_ignore_block_comments(source: &str, span: Span) -> std::vec::Vec { + let mut comments = std::vec::Vec::new(); + let Some(text) = source.get(span.start as usize..span.end as usize) else { + return comments; + }; + let mut index = 0; + while index < text.len() { + let rest = &text[index..]; + if rest.starts_with("//") { + index += rest + .find(['\n', '\r', '\u{2028}', '\u{2029}']) + .unwrap_or(rest.len()); + } else if let Some(body) = rest.strip_prefix("/*") { + let Some(end) = body.find("*/") else { + break; + }; + if is_coverage_ignore_comment(&body[..end]) { + let start = span.start + index as u32; + comments.push(Span::new(start, start + end as u32 + 4)); + } + index += end + 4; + } else { + index += rest.chars().next().map_or(1, char::len_utf8); } - rest = &after_start[comment_end + 2..]; } - source - .lines() - .filter_map(|line| line.split_once("//").map(|(_, comment)| comment)) - .any(is_coverage_ignore_comment) + comments } +/// `/^\s*(istanbul|c8)\s+ignore\b/` — `\b` is ASCII-word based, as in JS. fn is_coverage_ignore_comment(comment: &str) -> bool { let comment = comment.trim_start(); ["istanbul", "c8"].iter().any(|tool| { @@ -267,9 +278,10 @@ fn is_coverage_ignore_comment(comment: &str) -> bool { let Some(rest) = rest.trim_start().strip_prefix("ignore") else { return false; }; - !rest.chars().next().is_some_and(|character| { - character.is_alphanumeric() || character == '_' || character == '$' - }) + !rest + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphanumeric() || character == '_') }) }