Skip to content

Keep a long chat edit whole, and never send an empty chunk #264

Description

@HMarzban

Problem

A chat message longer than one database row is split into chunks before it is saved. The chunking has four defects. Together they blank messages on edit and post empty messages on send.

Measured in Chrome 153 on 14ab7f9c1. The run used the real prepareOutboundContent, sanitizeChunk, and dispatchOutboundChunk, with a copy of the loop in useComposerSubmit. The runs used a StarterKit editor with default options, not the real composer. The network calls were stubbed to succeed, so the results for chunks over 3,000 characters follow the column check, not a real database.

Case Mode Chunk HTML lengths Result
One paragraph, 2,389 characters edit or send none Saved whole
One paragraph, 2,989 characters edit 3,005 and 17 Row saved with empty text, and no error
One paragraph, 3,389 characters edit 20, 3,398, and 11 Row saved with empty text, then an error
Three paragraphs, 3,421 characters edit 2,312 and 1,159 Row keeps only the last paragraph
One paragraph, 2,989 characters send 3,005 and 17 The full message, then an empty message
One paragraph, 3,389 characters send 20, 3,398, and 11 An empty message is saved. The database rejects the next chunk, and the send stops.

The four defects:

  1. The chunker counts its own wrapper. chunkHtmlContent walks doc.body, so <body> and </body> count toward the limit (apps/webapp/src/utils/chunkHtmlContent.ts:59). Stored HTML of 2,988 to 3,000 characters still splits.
  2. It emits chunks with tags but no text. The limit check runs before each piece is added (chunkHtmlContent.ts:34). Closing tags then start a new chunk that holds no text (:53, :63). The same check also splits off a chunk that holds only opening tags, which is the empty first chunk in the 3,389 case. A chunk can also end over the limit, as the 3,005-character chunk in the 2,989 case shows.
  3. It never splits one text node. The node lands whole in one chunk, and that chunk runs over the limit. The 3,398-character chunk in the 3,389 case shows this. sanitizeChunk then cuts its text to 3,000 characters (apps/webapp/src/utils/sanitizeContent.ts:30-31, :45-48). Its HTML stays longer than 3,000, and messages.html rejects that (packages/supabase/scripts/05-0-message.sql:17-18).
  4. Edit writes every chunk to one row. In edit mode each chunk calls updateMsg with the same message id (apps/webapp/src/components/chatroom/utils/outboundMessagePipeline.ts:257-258). The submit loop runs once per chunk (apps/webapp/src/components/chatroom/components/MessageComposer/hooks/useComposerSubmit.ts:166). The last write that succeeds stays.

Nothing stops an empty chunk. useSendMessage.send and persistChatMessage accept an empty content and an empty html, and both column checks allow an empty string.

Who sees it: anyone who edits a message near 3,000 characters, or sends one long paragraph. The edit case loses data. When every chunk fits the column limit, it shows no error. When a later chunk is over the limit, the database rejects it and an error shows. The row keeps the empty text that an earlier chunk wrote.

Steps to reproduce

  1. Open any channel as a signed-in user.
  2. Paste one paragraph of exactly 2,989 characters. Use only letters, digits, and spaces, with no line breaks. Send it.
  3. Check the messages table. See a second row with an empty content and an empty html.
  4. Edit the first message. Delete one character and save.
  5. Check that row. Its content is now empty, and no error showed.

Acceptance criteria

  • Saving an edit writes at most one row update, and the row keeps all of the edited text.
  • An edit that does not fit one row is refused before any write. The composer keeps the text and shows an error that names the limit.
  • A send never posts a message with an empty content, an empty html, and no media.
  • No chunk sent to the database has more than 3,000 characters of content or of html.
  • A paragraph longer than the limit splits at the last space inside the limit. With no space inside the limit, it splits at the limit. No text is lost across the chunks.
  • Content whose stored HTML is 3,000 characters or less saves as one row on edit, and posts as one message on send.
  • Every case in the table above gives the expected result. The 2,389 and 2,989 edits save whole. The 3,389 and 3,421 edits are refused before any write. Every send posts only non-empty messages and loses no text.

Agent Brief

Category: bug
Summary: Make long-message chunking lossless on send, and make edit refuse content that does not fit one row.

Current behavior:
The chunker counts a wrapper element toward the limit, and emits chunks that hold tags but no text. It never divides a text node, so the chunk that holds a long plain paragraph runs past the limit. Its text is cut and its HTML breaks the column limit. In edit mode every chunk updates the same row, so the saved message is the last chunk that succeeded, which is often empty.

Desired behavior:
Send splits content into chunks that each fit the messages column limits, each carry text, and together keep every character. Edit never splits. If edited content does not fit one row, the submit stops before any write and tells the user the limit. An empty chunk is never dispatched.

Key interfaces:

  • chunkHtmlContent(html, maxLength) — returns { htmlChunks, textChunks }; an empty array still means "fits in one send".
  • prepareOutboundContent() — already refuses attachments when the content needs more than one chunk; the edit guard belongs beside that check.
  • dispatchOutboundChunk() — its edit branch calls updateMsg once per chunk.
  • sanitizeChunk() — cuts chunk text to 3,000 characters.
  • messages.content and messages.html — each checked <= 3000.

Out of scope

  • Raising the 3,000-character column limit.
  • A character counter in the composer.
  • How the feed groups consecutive messages.

Notes

chunkHtmlContent is a pure utility with dense branching, so a unit test fits AGENTS.md §Test Policy. Pin every case in the table.

Comments use the same splitting through sendComment. Give that path the same fix.

The chunked content also joins text nodes with no block separator (apps/webapp/src/utils/chunkHtmlContent.ts:43, :55). A mention that opens a paragraph can then lose the boundary that the mention trigger needs (packages/supabase/scripts/10-func-notifications.sql:52). Keep a separator between blocks when the chunker builds content.

The thresholds move with content. & and < become entities in the HTML, so the same text length can land on a different side of a threshold.

Every case ran in Chrome, because the HTML lengths depend on the real sanitizer. happy-dom gives different HTML lengths. jsdom, the webapp Jest environment, gives the same lengths as the table.

Activity

  1. added
    bugSomething isn't working
    ChatRelated to chat features
    on Sep 14, 2026
  2. added a commit that references this issue on Sep 22, 2026
    f65b154
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ChatRelated to chat featuresbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions