From a8ed074bf088a17bc25fc3ccc5ed5f2df52c0481 Mon Sep 17 00:00:00 2001 From: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:35:53 +0530 Subject: [PATCH] fix: Preserve double newlines in text-based messaging channels (#13055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the issue where double newlines (paragraph breaks) were collapsing to single newlines in text-based messaging channels (Telegram, WhatsApp, Instagram, Facebook, LINE, SMS). ### Root Cause The `preserve_multiple_newlines` method only preserved 3+ consecutive newlines using the regex `/\n{3,}/`. When users pressed Enter twice (creating a paragraph break with 2 newlines), CommonMarker would parse this as separate paragraphs, which then collapsed to a single newline in the output. This caused: - ❌ Normal Enter: Double newlines collapsed to single newline - ✅ Shift+Enter: Worked (created hard breaks) ### Fix Changed the regex from `/\n{3,}/` to `/\n{2,}/` to preserve 2+ consecutive newlines. This prevents CommonMarker from collapsing paragraph breaks. Now: - ✅ Single newline (`\n`) → Single newline (handled by softbreak) - ✅ Double newline (`\n\n`) → Double newline (preserved with placeholders) - ✅ Triple+ newlines → Preserved as before ### Test Coverage Added comprehensive tests for: - Single newlines preservation - Double newlines (paragraph breaks) preservation - Multiple consecutive newlines - Newlines with varying amounts of whitespace between them (1 space, 3 spaces, 5 spaces, tabs) All 66 tests passing. ### Impact This fix affects all text-based messaging channels that use the markdown renderer: - Telegram - WhatsApp - Instagram - Facebook - LINE - SMS - Twilio SMS (when configured for WhatsApp) Fixes https://linear.app/chatwoot/issue/CW-6135/double-newline-is-breaking --------- Co-authored-by: Muhsin Keloth --- .../messages/markdown_renderer_service.rb | 6 ++-- .../markdown_renderer_service_spec.rb | 29 ++++++++++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/app/services/messages/markdown_renderer_service.rb b/app/services/messages/markdown_renderer_service.rb index a90cfca06..00a731a6d 100644 --- a/app/services/messages/markdown_renderer_service.rb +++ b/app/services/messages/markdown_renderer_service.rb @@ -96,10 +96,10 @@ class Messages::MarkdownRendererService restore_multiple_newlines(result) end - # Preserve multiple consecutive newlines (3+) by replacing them with placeholders - # Standard markdown treats 2 newlines as paragraph break, we preserve 3+ + # Preserve multiple consecutive newlines (2+) by replacing them with placeholders + # Standard markdown treats 2 newlines as paragraph break which collapses to 1 newline, we preserve 2+ def preserve_multiple_newlines(content) - content.gsub(/\n{3,}/) do |match| + content.gsub(/\n{2,}/) do |match| "{{PRESERVE_#{match.length}_NEWLINES}}" end end diff --git a/spec/services/messages/markdown_renderer_service_spec.rb b/spec/services/messages/markdown_renderer_service_spec.rb index 89210dc0e..5b8e02e2f 100644 --- a/spec/services/messages/markdown_renderer_service_spec.rb +++ b/spec/services/messages/markdown_renderer_service_spec.rb @@ -241,10 +241,37 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do expect(result).to include('link text') end - it 'preserves newlines' do + it 'preserves single newlines' do content = "line 1\nline 2" result = described_class.new(content, channel_type).render expect(result).to include("\n") + expect(result).to include("line 1\nline 2") + end + + it 'preserves double newlines (paragraph breaks)' do + content = "para 1\n\npara 2" + result = described_class.new(content, channel_type).render + expect(result.scan("\n").count).to eq(2) + expect(result).to include("para 1\n\npara 2") + end + + it 'preserves multiple consecutive newlines' do + content = "para 1\n\n\n\npara 2" + result = described_class.new(content, channel_type).render + expect(result.scan("\n").count).to eq(4) + expect(result).to include("para 1\n\n\n\npara 2") + end + + it 'preserves newlines with varying amounts of whitespace between them' do + # Test with 1 space, 3 spaces, 5 spaces, and tabs to ensure it handles any amount of whitespace + content = "hello\n \n \n \n\t\nworld" + result = described_class.new(content, channel_type).render + # Whitespace-only lines are normalized, so we should have at least 5 newlines preserved + expect(result.scan("\n").count).to be >= 5 + expect(result).to include('hello') + expect(result).to include('world') + # Should not collapse to just 1-2 newlines + expect(result.scan("\n").count).to be > 3 end it 'converts strikethrough to HTML' do