You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Keep a long chat edit whole, and never send an empty chunk #264
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:
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.
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.
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).
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
Open any channel as a signed-in user.
Paste one paragraph of exactly 2,989 characters. Use only letters, digits, and spaces, with no line breaks. Send it.
Check the messages table. See a second row with an empty content and an empty html.
Edit the first message. Delete one character and save.
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.
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 realprepareOutboundContent,sanitizeChunk, anddispatchOutboundChunk, with a copy of the loop inuseComposerSubmit. 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.The four defects:
chunkHtmlContentwalksdoc.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.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.sanitizeChunkthen cuts its text to 3,000 characters (apps/webapp/src/utils/sanitizeContent.ts:30-31,:45-48). Its HTML stays longer than 3,000, andmessages.htmlrejects that (packages/supabase/scripts/05-0-message.sql:17-18).updateMsgwith 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.sendandpersistChatMessageaccept an emptycontentand an emptyhtml, 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
messagestable. See a second row with an emptycontentand an emptyhtml.contentis now empty, and no error showed.Acceptance criteria
content, an emptyhtml, and no media.contentor ofhtml.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
messagescolumn 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 callsupdateMsgonce per chunk.sanitizeChunk()— cuts chunk text to 3,000 characters.messages.contentandmessages.html— each checked<= 3000.Out of scope
Notes
chunkHtmlContentis 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
contentalso 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 buildscontent.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-domgives different HTML lengths.jsdom, the webapp Jest environment, gives the same lengths as the table.