benjamn / recast

JavaScript syntax tree transformer, nondestructive pretty-printer, and automatic source map generator
MIT License
4.95k stars 346 forks source link

JSXText node leading space is not preserved #886

Open skovy opened 3 years ago

skovy commented 3 years ago

I'm working with jscodeshift, and noticed an issue when re-printing an existing node. I'm creating a codemod to convert a JSXElement into another type of JSXElement, however, I'd like to re-use the entire children array. Doing so results in a functional change to the code (leading space from JSXText node is removed).

Here's a basic script to convey what I'm attempting with the latest version (recast@0.20.4):

const { parse, print, types } = require("recast");

const b = types.builders; // alias

// Parsed.
const source = `
<original>
  <span role="img">📝</span> Text
</original>`;
const parsedJsxElement = parse(source).program.body[0].expression;

// Builders.
const builtJsxElement = b.jsxElement(
  b.jsxOpeningElement(b.jsxIdentifier("New")),
  b.jsxClosingElement(b.jsxIdentifier("New")),
  [
    b.jsxText("\n  "),
    b.jsxElement(
      b.jsxOpeningElement(b.jsxIdentifier("span")),
      b.jsxClosingElement(b.jsxIdentifier("span")),
      [b.jsxText("📝")]
    ),
    b.jsxText(" Text\n"),
  ]
);

// Built, but reusing children from the parsed source.
const builtJsxElementWithReusedChildren = b.jsxElement(
  b.jsxOpeningElement(b.jsxIdentifier("New")),
  b.jsxClosingElement(b.jsxIdentifier("New")),
  parsedJsxElement.children
);

console.log("=== Parsed JSX Element ===");
console.log(print(parsedJsxElement).code);
// <original>
//   <span role="img">📝</span> Text
// </original>

console.log("=== Built JSX Element ===");
console.log(print(builtJsxElement).code);
// <New>
//     <span>📝</span>Text</New>

console.log("=== Built JSX Element w/ Reused Children ===");
console.log(print(builtJsxElementWithReusedChildren).code);
// <New>
//     <span role="img">📝</span>Text</New>

// Mutation.
parsedJsxElement.openingElement = b.jsxOpeningElement(b.jsxIdentifier("New"));
parsedJsxElement.closingElement = b.jsxClosingElement(b.jsxIdentifier("New"));

console.log("=== Mutated Parsed JSX Element ===");
console.log(print(parsedJsxElement).code);
// <New>
//   <span role="img">📝</span> Text
// </New>

Output

=== Parsed JSX Element ===
<original>
  <span role="img">📝</span> Text
</original>
=== Built JSX Element ===
<New>
    <span>📝</span>Text</New>
=== Built JSX Element w/ Reused Children ===
<New>
    <span role="img">📝</span>Text</New>
=== Mutated Parsed JSX Element ===
<New>
  <span role="img">📝</span> Text
</New>

The only approach I've found to work is directly mutating the original node. Is it possible to construct an entirely new JSXElement, or reuse children from another node while retaining the spacing between the closing span and Text (</span>[SPACE]Text)? Reusing children directly would be ideal in this case since the exact child nodes can vary.

Tismas commented 2 years ago

I've run into similar issue, when moving children to a different place or mutating children array. Spaces around jsxExpression are not preserved:

Input:

<div> Testing {variable} stuff :) </div>

Output:

<div>Testing{variable}stuff :)
</div>

Example code for jscodeshift with @babel/parser:

export default function transformer(file, api) {
  const j = api.jscodeshift;

  return j(file.source)
    .findJSXElements("div")
    .forEach(nodePath => {
        nodePath.node.children.push(j.jsxText("\n"));
    })
    .toSource();
}
ijain3 commented 2 years ago

Hello @skovy

I am running into same issue. Can you please share some example or code snippet on how can you mutate the original node? Thank you.

jeresig commented 7 months ago

For future folks, I encountered this, as well and came up with a solution. Looking at the code all leading and trailing whitespace is removed if there is any non-whitespace text in the JSX text node: https://github.com/benjamn/recast/blob/7f441d2c74d2cd61287fc6b498a9060f5597a27c/lib/printer.ts#L1317

We can work around this by splitting out the whitespace into dedicated JSX text nodes. Here's the little TypeScript function that I wrote to help with this problem:

const getJsxTextNodes = (j: JSCodeshift, text: string) => {
    const nodes: JSXText[] = [];

    if (/^(\s+)/.test(text)) {
        nodes.push(j.jsxText(RegExp.$1));
        text = text.trimStart();
    }

    if (/(\s+)$/.test(text)) {
        nodes.push(j.jsxText(text.trimEnd()), j.jsxText(RegExp.$1));
    } else if (text) {
        nodes.push(j.jsxText(text));
    }

    return nodes;
};

Hope this helps!