Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/compiler-coverage-pragmas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@solidjs/babel-plugin": patch
"@solidjs/compiler": patch
---

Preserve `istanbul ignore` and `c8 ignore` JSX comments on generated component children getters.
21 changes: 12 additions & 9 deletions packages/babel-plugin/src/shared/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isDynamic,
registerImportMethod,
filterChildren,
getCoverageIgnoreComments,
trimWhitespace,
transformCondition,
convertJSXIdentifier
Expand All @@ -20,7 +21,7 @@ type ComponentTransformResult = TransformResult & {
exprs: Array<t.Expression | t.Statement>;
};

type ComponentChildrenResult = [t.Expression, boolean] | undefined;
type ComponentChildrenResult = [t.Expression, boolean, t.Comment[]?] | undefined;

function isSimpleOptionalMemberExpression(
expression: t.Expression | t.JSXEmptyExpression
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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<JSXNode>) => {
Expand All @@ -378,6 +380,7 @@ function transformComponentChildren(
memo.push(t.stringLiteral(v));
}
} else {
coverageIgnoreComments ||= getCoverageIgnoreComments(path);
const child = transformNode(path, {
topLevel: true,
componentChild: true,
Expand Down Expand Up @@ -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];
}
34 changes: 29 additions & 5 deletions packages/babel-plugin/src/shared/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TPath extends NodePath>(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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const istanbulPragma = (
<Show when={condition()}>
{/* istanbul ignore next */}
<div>Hello</div>
</Show>
);

export const c8Pragma = (
<Show when={condition()}>
{/* c8 ignore next */}
<div>Hello</div>
</Show>
);
15 changes: 15 additions & 0 deletions packages/babel-plugin/test/ref-spread.spec.js
Original file line number Diff line number Diff line change
@@ -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 }]],
Expand All @@ -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 = <div {...attrs()} />;"],
[
Expand Down
21 changes: 21 additions & 0 deletions packages/compiler/__tests__/transform.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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 = <Comp render={state.dynamic ? good() : bad} />;", {
filename: "input.jsx",
Expand Down
4 changes: 4 additions & 0 deletions packages/compiler/src/dom/condition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion packages/compiler/src/shared/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 33 additions & 1 deletion packages/compiler/src/shared/component_children.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<Statement<'a>>,
/// 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<oxc_span::Span>,
}

enum ChildKind {
Expand Down Expand Up @@ -58,6 +62,7 @@ pub(crate) fn component_children<'a, C: ComponentChildLower<'a>>(
) -> Result<Option<ComponentChildren<'a>>> {
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 {
Expand Down Expand Up @@ -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,
})
}
_ => {
Expand Down Expand Up @@ -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<oxc_span::Span> {
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
}
3 changes: 3 additions & 0 deletions packages/compiler/src/shared/mode_lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
39 changes: 39 additions & 0 deletions packages/compiler/src/shared/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading