Skip to content

Whitespace collapsing trims spaces too early in nested inline phrasing nodes #33

@leineveber

Description

@leineveber

Hi! 👋

Firstly, thanks for your work on this project! 🙂

Today I used patch-package to patch @native-html/transient-render-engine@12.0.0 for the project I'm working on.

I ran into an issue with whitespace collapsing around nested inline phrasing nodes.

Problem

Spaces can disappear when they are stored at the boundary of a named inline wrapper such as span, instead of being attached directly to a text node.
For example, this renders without the expected space between foo and bar:

<span><span><strong>foo</strong> </span><span><strong>bar</strong></span></span>

In my real case, the HTML comes from a backend rich-text editor and contains lots of nested inline tags (span, strong, colored text, etc.).
So visually separate words like:

<span style="color:#ffdb80"><strong>Foo</strong> </span>
<span style="color:#000000"><strong>Bar</strong> </span>
<span style="color:#5cce89"><strong>Baz</strong></span>

were rendered as if the spaces between words did not exist.

Expected behavior

Whitespace collapsing should preserve a single visible space between adjacent inline phrasing siblings, even if that space is wrapped in nested inline tags.

So this:

<span><span><strong>foo</strong> </span><span><strong>bar</strong></span></span>

should render like:
foo bar

Actual behavior

The space is trimmed too early during phrasing collapse, so the final output becomes:
foobar

Why this seems to happen

From debugging the transient tree, it looks like TPhrasingCtor.collapseChildren() always calls:

this.trimLeft();
this.trimRight();

after collapsing its children.

That works for anonymous phrasing containers, but for named inline wrappers like span it removes boundary spaces before the parent phrasing node gets a chance to collapse sibling boundaries correctly.

So a structure like:

<span>
  <strong>foo</strong>
  " "
</span>
<span>
  <strong>bar</strong>
</span>

loses the trailing space inside the first span before the outer phrasing container compares the two sibling spans.

Why the patch seems correct

The patch only prevents this eager trimming for named phrasing nodes.
Anonymous phrasing nodes still trim as before.

This preserves boundary spaces for nested inline wrappers while keeping existing whitespace collapsing behavior for normal anonymous phrasing containers.

Validation

After applying this patch, the nested-inline case above works correctly.

I also ran the library's published Jest suites for both:

  • @native-html/transient-render-engine
  • @native-html/render
    and the existing tests still passed with this change.

Here is the diff that solved my problem:

diff --git a/node_modules/@native-html/render/node_modules/@native-html/transient-render-engine/lib/commonjs/tree/TPhrasingCtor.js b/node_modules/@native-html/render/node_modules/@native-html/transient-render-engine/lib/commonjs/tree/TPhrasingCtor.js
index e735f56..579a4b3 100644
--- a/node_modules/@native-html/render/node_modules/@native-html/transient-render-engine/lib/commonjs/tree/TPhrasingCtor.js
+++ b/node_modules/@native-html/render/node_modules/@native-html/transient-render-engine/lib/commonjs/tree/TPhrasingCtor.js
@@ -35,8 +35,12 @@ TPhrasingCtor.prototype.collapseChildren = function collapseChildren() {
     }
     previous = childK;
   });
-  this.trimLeft();
-  this.trimRight();
+  // Preserve boundary spaces for named inline wrappers (e.g. styled spans) so
+  // their parent phrasing container can collapse sibling boundaries correctly.
+  if (this.tagName === null) {
+    this.trimLeft();
+    this.trimRight();
+  }
   return null;
 };
 var _default = exports.default = TPhrasingCtor;
diff --git a/node_modules/@native-html/render/node_modules/@native-html/transient-render-engine/src/flow/__tests__/collapse.test.ts b/node_modules/@native-html/render/node_modules/@native-html/transient-render-engine/src/flow/__tests__/collapse.test.ts
index 21bf432..944cf9f 100644
--- a/node_modules/@native-html/render/node_modules/@native-html/transient-render-engine/src/flow/__tests__/collapse.test.ts
+++ b/node_modules/@native-html/render/node_modules/@native-html/transient-render-engine/src/flow/__tests__/collapse.test.ts
@@ -37,6 +37,19 @@ describe('collapse function', () => {
     const ttree = makeTTree('<span><span>foo </span><span> bar</span></span>');
     expect(ttree).toMatchSnapshot();
   });
+  it('should preserve boundary spaces wrapped in nested inline phrasing tags', () => {
+    const ttree = makeTTree(
+      '<span><span><strong>foo</strong> </span><span><strong>bar</strong></span></span>'
+    );
+    const [firstSpan, secondSpan] = ttree.children;
+    expect(firstSpan.tagName).toBe('span');
+    expect(firstSpan.children).toHaveLength(2);
+    expect((firstSpan.children[0] as TTextImpl).data).toBe('foo');
+    expect((firstSpan.children[1] as TTextImpl).data).toBe(' ');
+    expect(secondSpan.tagName).toBe('span');
+    expect(secondSpan.children).toHaveLength(1);
+    expect((secondSpan.children[0] as TTextImpl).data).toBe('bar');
+  });
   it('should handle nested anchors', () => {
     const ttree = makeTTree(nestedHyperlinksSource);
     expect(ttree).toMatchSnapshot();
diff --git a/node_modules/@native-html/render/node_modules/@native-html/transient-render-engine/src/tree/TPhrasingCtor.ts b/node_modules/@native-html/render/node_modules/@native-html/transient-render-engine/src/tree/TPhrasingCtor.ts
index bcf6131..5ad6bac 100644
--- a/node_modules/@native-html/render/node_modules/@native-html/transient-render-engine/src/tree/TPhrasingCtor.ts
+++ b/node_modules/@native-html/render/node_modules/@native-html/transient-render-engine/src/tree/TPhrasingCtor.ts
@@ -48,8 +48,12 @@ TPhrasingCtor.prototype.collapseChildren = function collapseChildren() {
     }
     previous = childK;
   });
-  this.trimLeft();
-  this.trimRight();
+  // Preserve boundary spaces for named inline wrappers (e.g. styled spans) so
+  // their parent phrasing container can collapse sibling boundaries correctly.
+  if (this.tagName === null) {
+    this.trimLeft();
+    this.trimRight();
+  }
   return null;
 };
 

This issue body was partially generated by patch-package.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions