From 961bd14ab2ded0089264ea055929a16f72e90153 Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 9 Sep 2026 13:03:25 +0300 Subject: [PATCH] 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 | 8 +++- 13 files changed, 196 insertions(+), 23 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 f180d25ee..a4f39cf9a 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 @@ -20,7 +21,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 @@ -316,14 +317,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(t.objectExpression(runningObject)); @@ -368,6 +369,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) => { @@ -378,6 +380,7 @@ function transformComponentChildren( memo.push(t.stringLiteral(v)); } } else { + coverageIgnoreComments ||= getCoverageIgnoreComments(path); const child = transformNode(path, { topLevel: true, componentChild: true, @@ -427,5 +430,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 663edde97..7451c97e0 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 85e35d7ee..5159bdaf5 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 117c08f53..0efa40285 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 09c0f34b9..587226a70 100644 --- a/packages/compiler/src/shared/component.rs +++ b/packages/compiler/src/shared/component.rs @@ -148,7 +148,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 8a390e455..eaf9d488f 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 7984ab6cf..3c9a52a3a 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, @@ -1012,7 +1013,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, @@ -1021,7 +1024,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, )); @@ -1054,7 +1057,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 @@ -1204,7 +1208,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 @@ -1218,7 +1222,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, + )) } } _ => { @@ -1232,6 +1240,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 7d0dd6d96..cb3412f0d 100644 --- a/packages/compiler/src/universal/transform.rs +++ b/packages/compiler/src/universal/transform.rs @@ -19,8 +19,8 @@ use crate::shared::condition::{ }; use crate::shared::refs::{assignment_fallback, callable_test}; use crate::shared::utils::{ - decode_html_entities, element_name, get_numbered_id, is_component_name, - static_jsx_expression, trim_jsx_text, + decode_html_entities, element_name, get_numbered_id, is_component_name, static_jsx_expression, + trim_jsx_text, }; pub(crate) struct AstUniversalTransform<'a, 'source> { @@ -1827,6 +1827,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 }