Skip to content

Instantly share code, notes, and snippets.

@alexlazarian
Last active May 5, 2026 05:30
Show Gist options
  • Select an option

  • Save alexlazarian/b63186d97b585f17d7c768e0a490e34d to your computer and use it in GitHub Desktop.

Select an option

Save alexlazarian/b63186d97b585f17d7c768e0a490e34d to your computer and use it in GitHub Desktop.
JUS-1204 Tiptap migration: PRE vs POST verification + DB round-trip findings

JUS-1204 — TinyMCE → Tiptap migration verification report

Captured: 2026-05-04 PR: #630 — Modernization of bid /setup/ pages PRE worktree: .worktrees/jus-1204-pre @ origin/main (15d626697d) POST branch: alexlazarian/JUS-1204-followup-select (HEAD)


TL;DR

13 surfaces enumerated across Launchpad, Bid, and Messaging. Every surface that mounts a rich-text editor was verified PRE (TinyMCE) vs POST (Tiptap) with byte-level DB evidence and side-by-side UI screenshots.

Count
Surfaces verified 13 / 13
Findings 7
Open / blocking 0
Resolved (real fix) 1 (HIGH — Invitation::SPACES_REGEXP, commit 375ecea)
Resolved (cosmetic accept) 6 (5 MEDIUM + 1 NOTE — global Tiptap TextAlign attribute)

Single global fix point would close the 5 MEDIUM cosmetic findings: configure the shared <RichTextEditor> in @justicebid/jb-ui to either disable the TextAlign extension or strip default-aligned attributes on serialization. Decision: defer (cosmetic only, no user-visible impact at current data scale).


Surface index

Surface Severity Status URL
FAQ content ✅ parity /manage_faq
AI Prompt ✅ parity /ai/prompts/new
Org description ✅ parity /organizations/6w4f6w4f4f
Attorney overview / pro_bono ✅ parity attorney 1890 edit
Notable records (perf) ✅ parity (160→90 MB) attorney 1890 edit
Bid: Question title + body MEDIUM ✓ resolved (cosmetic) /setup/31233/information_request
Bid: Scorecard description NOTE ✓ resolved (POST is better) /setup/35182/evaluation_guide
Bid: Negotiation Summary MEDIUM ✓ resolved (cosmetic) /setup/35335/general_settings
Bid: Invitation HTML HIGH fixed (commit 375ecea) /setup/35335/participation/invitation
Bid: Award email body MEDIUM ✓ resolved (cosmetic) /setup/34597/award
Bid: Unsuccessful email body MEDIUM ✓ resolved (cosmetic) /setup/34597/award
Bid: Bidder text answer MEDIUM ✓ resolved (cosmetic) /bidder_steps/questions/30625
Messaging: new + reply MEDIUM ✓ resolved (cosmetic) messaging /

Real regression — Invitation::SPACES_REGEXP (HIGH, FIXED)

File: domains/select/rails/bid/app/models/invitation.rb Symptom (PRE-fix): Invitation HTML row grew +1135 bytes per save (1808 → 2943) on every UX action because the trailing-empty-paragraph trim regex only matched TinyMCE's literal <p>&nbsp;</p> form. Tiptap emits <p style="text-align: left;"></p> — the regex didn't match → empties accumulated forever.

Fix (commit 375ecea):

SPACES_REGEXP = %r{
  (
    <p\b[^>]*>
    (?:\s|&nbsp;| |<br\b[^>]*/?>)*
    </p>
    \s*
  )+\Z
}x

Broadened to match every empty-paragraph form an editor could emit (bare <p></p>, <p><br></p>, <p>&nbsp;</p>, <p style="text-align: left;"></p>, raw U+00A0 variants).

Re-verified: PUT /setup/35335/prepare_invitation with body containing 3 trailing styled empties → DB row stored 122 bytes with 0 trailing empties. Specs added for all four Tiptap variants + mixed PRE/POST trail. The original TinyMCE <p>&nbsp;</p> case still passes.

PRE — TinyMCE POST — Tiptap
PRE invitation POST invitation
POST byte-trace (post-fix re-verification)

See trace-post-bid-invitation.txt (none — surface has no per-side trace; details inline in finding).


Cosmetic regression — global Tiptap TextAlign extension (5 MEDIUM, accepted)

Pattern: Tiptap's TextAlign extension serializes default-left as an explicit inline style="text-align: left;" attribute on every <p>. PRE/TinyMCE emitted bare <p> wrappers (or stripped them entirely on the bidder-text surface). POST/Tiptap stores all paragraphs with the inline attribute, plus 3 trailing styled empties when Enter is pressed.

Per-paragraph cost: ~28 bytes for the style="text-align: left;" attribute + ~14 bytes for each empty paragraph.

Why accepted: Visually identical (left is the browser default); no user-facing impact. Storage scale is small at current data volumes. Single fix point exists if/when ever needed: configure the shared RichTextEditor to skip serializing alignment when it equals the default, or disable TextAlign entirely.

Per-surface details follow.


Bid: Question title + body (MEDIUM, resolved)

URL: /setup/31233/information_request Mutation: click body card to lazy-mount editor → caret-end + Enter ×3 → click outside. Persists to: question_options.text (TextOption row 449457 holds question 76478 body).

Finding: Both editors persist trailing empty paragraphs (no server-side trim).

bytes tail content
PRE / TinyMCE 16 → 49 (+33) 3 trailing <p>\xC2\xA0</p> (raw U+00A0)
POST / Tiptap 16 → 137 (+121) 3 trailing <p style="text-align: left;"></p>
Delta-of-deltas POST stores +88 bytes more per save than PRE inline TextAlign attribute on every paragraph

Resolution: Cosmetic only — visually identical, no user impact, storage scale is small. Won't fix; revisit if Tiptap config touched for other reasons.

Note: a sibling mount QuestionForm.tsx (under setup/common/components/Wizard/Steps/QuestionsStep) wraps the same RhfRichTextField → same RichTextEditor → same emitted HTML → same column. Verifying this surface covers both.

PRE POST
pre post
Byte traces (PRE / POST)

trace-pre-bid-question.txt · trace-post-bid-question.txt


Bid: Scorecard description (NOTE, resolved — POST is better)

URL: /setup/35182/evaluation_guide Mutation: click .formatted-text card → caret-end + Enter ×3 → on PRE also press x + Backspace (RHF dirty-mark) → click outside. Persists to: scorecard_items.description (varchar(500), id=2468 / neg 35182).

Finding (the rare positive one): POST stores less than PRE on this surface.

bytes tail content
PRE / TinyMCE 127 → 157 (+30) 3 trailing <p>\xC2\xA0</p> joined by \n (one per Enter)
POST / Tiptap 127 → 134 (+7) 1 trailing bare <p></p>

POST emits bare <p></p> (no style="text-align: left;"), suggesting the TextAlign extension is NOT registered on RhfRichTextField for scorecard. Tiptap also coalesces consecutive empties on this surface. No regression; opposite direction from the other surfaces.

Resolution: Cosmetic only — accept.

PRE POST
pre post
Byte traces (PRE / POST)

trace-pre-bid-scorecard.txt · trace-post-bid-scorecard.txt


Bid: Negotiation Summary (MEDIUM, resolved)

URL: /setup/35335/general_settings — open via the Summary tab. Mutation: click summary body editor (mounts directly — PRE TinyMCE iframe, POST Tiptap) → caret-end + Enter ×3 → on PRE also x + Backspace → click outside iframe to autosave. Persists to: summaries.body (text, id=638 / neg 35335).

Finding: Tiptap rewrites every <p> tag in the body with inline style="text-align: left;" on first save, not just trailing empties.

bytes tail content
PRE / TinyMCE 1759 → 1788 (+29) 3 trailing <p>&nbsp;</p> (HTML entity), zero inline styles
POST / Tiptap 1753 → 2109 (+356, +20%) 3 trailing styled empties PLUS all 13 in-body paragraphs rewritten from bare <p> to <p style="text-align: left;"> (28 bytes × 13 = +364)

Wrapping <div> is also dropped on POST serialization.

Resolution: Cosmetic only — accept.

PRE POST
pre post
Byte traces (PRE / POST)

trace-pre-bid-summary.txt · trace-post-bid-summary.txt


Bid: Award email body (MEDIUM, resolved)

URL: /setup/34597/awardAward Email tab. Mutation: click "Award Email" tab → editor mounts (PRE: TinyMCE iframe, POST: Tiptap) → caret-end + Enter ×3 → on PRE also x + Backspace → click outside. Persists to: award_emails.body (text, id=93 / neg 34597).

bytes tail content
PRE / TinyMCE 378 → 432 (+54) 3 trailing <p>&nbsp;</p>, zero inline styles
POST / Tiptap 374 → 711 (+337, +90%) 3 trailing styled empties + all 12 in-body paragraphs rewritten with inline TextAlign

Body is delivered to bidders via ActionMailer .html_safe — visually identical, but stored HTML doubles on first save. POST is 6× more wasteful per save than PRE on this surface.

Resolution: Cosmetic only — accept.

PRE POST
pre post
Byte traces (PRE / POST)

trace-pre-bid-award-awarded.txt · trace-post-bid-award-awarded.txt


Bid: Unsuccessful email body (MEDIUM, resolved)

URL: /setup/34597/awardUnsuccessful Email tab. Mutation: same as Award email body, on the sibling tab. Persists to: unsuccessful_emails.body (text, id=8 / neg 34597).

bytes tail content
PRE / TinyMCE 296 → 354 (+58) 3 trailing <p>&nbsp;</p>, zero inline styles
POST / Tiptap 313 → 612 (+299, +96%) 3 trailing styled empties + all 11 in-body paragraphs rewritten

4th surface confirming the same global Tiptap TextAlign pattern.

Resolution: Cosmetic only — accept.

PRE POST
pre post
Byte traces (PRE / POST)

trace-pre-bid-award-unsuccessful.txt · trace-post-bid-award-unsuccessful.txt


Bid: Bidder text answer (MEDIUM, resolved — biggest delta)

URL: /bidder_steps/questions/30625 Mutation: log in as bidder marketmaker4test+b01@gmail.com (Password1!) via legacy launchpad form → seat current_actor by visiting an org URL of a wizard-complete org → expand category accordion (#header-16224) → click div.formatted-text#answer_text_75450 to mount editor → caret-end + Enter ×3 → click question's Save button. Persists to: answer_options.value (longtext with serialize :value YAML, id=33472).

Finding: Two compound deltas, biggest delta of any surface.

bytes stored content
PRE / TinyMCE 13 → 9 (-4) YAML-encoded plain text: --- test\n
POST / Tiptap 9 → 139 (+130, +1444%) YAML-encoded full HTML with inline style="text-align: left;" × 4
  1. Format-contract change: TinyMCE's serializer strips wrapper <p> when no formatting applied (column held plain text). Tiptap preserves the wrapper. Anything downstream that assumed plain text now gets HTML.
  2. TextAlign injection: same global Tiptap config issue.

Setup notes (durable in the verifyMutation field for the next person):

  • Dev OAuth fails for bidders unless their current_actor's org has wizard_completed_at — set via Organization.find(<id>).update!(wizard_completed_at: Time.current).
  • PRE worktree is missing public/vendor/jquery.min.jscp from main.

Resolution: Cosmetic only — accept.

PRE POST
pre post
Byte traces (PRE / POST)

trace-pre-bid-bidder-text.txt · trace-post-bid-bidder-text.txt


Messaging: new + reply (MEDIUM, resolved — editor-output capture)

URL: messaging / Mutation: click New Message or open a thread for reply → editor mounts inside the modal/footer → caret-end + Enter ×3 → click Send. PRE uses TinyMCE 7 inline mode (<div class="mce-content-body">), not the iframe variant. POST uses Tiptap. Persists to: messages.body (text column on messaging_development.messages).

Live row-byte trace not captured because the test bidder has zero projects to send a message about. Captured the editor's emitted HTML instead (server path is identical between PRE/POST, so emitted HTML is the diagnostic):

editor output for test\n\n\n
PRE / TinyMCE 7 inline <p>test</p><p><br></p><p><br></p><p><br></p> (after data-mce-bogus strip)
POST / Tiptap <p style="text-align: left;">test</p><p style="text-align: left;"></p><p style="text-align: left;"></p><p style="text-align: left;"></p>

Both new-message and reply forms wrap the same RichTextEditor via MessagingRichTextField.tsx → single fix point covers both.

Resolution: Cosmetic only — accept.

PRE POST
pre post
Byte traces (PRE / POST — editor-output capture)

trace-pre-messaging.txt · trace-post-messaging.txt


Launchpad surfaces (parity — no findings)

All five Launchpad surfaces verified visually + behaviorally. PRE/POST screenshots side-by-side; no byte deltas to flag because the global TextAlign issue manifests on round-trip save and these surfaces had stable round-trip parity (toolbar, fonts, lists, links, tables all preserved through Tiptap save).

FAQ content

PRE POST
pre post

AI Prompt

PRE POST
pre post

Org description

PRE POST
pre post

Attorney overview / pro_bono

PRE — 2 TinyMCE iframes POST — 2 Tiptap instances
pre post

Notable records (FieldArray — 26 instances, perf-sensitive)

PRE — 26 TinyMCE iframes POST — 26 Tiptap instances
pre post

Memory at 26 instances: 160 MB → 90 MB (-44%)


Architecture summary

Surface PRE editor POST editor
FAQ content 1 TinyMCE iframe 1 ProseMirror
AI Prompt 1 TinyMCE iframe 1 ProseMirror
Org description 1 TinyMCE iframe 1 ProseMirror
Attorney overview / pro_bono 2 TinyMCE iframes 2 ProseMirror
Notable records (FieldArray) 26 TinyMCE iframes 26 ProseMirror
Bid: Question title + body TinyMCE 7 iframe Tiptap (RhfRichTextField)
Bid: Scorecard description TinyMCE 7 iframe Tiptap (no TextAlign extension)
Bid: Negotiation Summary TinyMCE 7 iframe Tiptap (RhfRichTextField)
Bid: Invitation HTML TinyMCE 7 iframe Tiptap (FullHtml)
Bid: Award email body TinyMCE 7 iframe Tiptap (FullHtml)
Bid: Unsuccessful email body TinyMCE 7 iframe Tiptap (FullHtml)
Bid: Bidder text answer TinyMCE 7 iframe (CoffeeScript bridge) Tiptap (text_tiptap_bridge.tsx)
Messaging: new + reply TinyMCE 7 inline mode Tiptap (MessagingRichTextField)

Toolbar diff (Launchpad surfaces)

Capability PRE (TinyMCE) POST (Tiptap)
Undo / Redo ✅ added
Text style menu (H1–H6) ✅ added
Font family ✅ Helvetica (default) ✅ added (post-fix)
Font size ✅ 12px (default) ✅ added (post-fix)
Bold / Italic / Underline
Strikethrough
Subscript / Superscript ❌ dropped
Text color
Background color (highlight) ✅ (renamed Highlight)
Quote ✅ added
Bullet / Numbered list
Insert link
Remove link (separate button) ❌ via Insert link
Table (insert + cell/table props) ✅ table only (no cell/table props menu)
Text alignment ✅ 4 buttons (L/C/R/J) ✅ dropdown (L/C/R)
Clear formatting

Round-trip findings (DB save without edit)

Attorney 1890 overview, after Tiptap save:

Aspect Verdict
<table> with inline styles (border, color, width) ✅ preserved
<s> strikethrough ✅ preserved
Font family / size styles ✅ preserved (CSS spaces stripped)
Text color, background color ✅ preserved
Lists <ol> / <li> ✅ preserved
Links ✅ preserved (gain tw: Tailwind classes)
<u> underline tags rewritten to <span style="text-decoration: underline;"> (intentional, see SpanUnderline)
&nbsp; HTML entity rewritten to UTF-8 NBSP \xC2\xA0 ⚠️ originally broke invitation.rb regex — fixed in 375ecea
MsoNormal paragraph class ❌ dropped

How this report was produced

A custom dashboard (migration-mission-control) drives a 2-worktree verification mission:

  • PRE worktree runs origin/main with TinyMCE; POST worktree runs the migration branch with Tiptap.
  • Only one worktree is active at a time, both bind the same dev ports + the same MySQL/Redis containers (read-mostly).
  • For each surface: navigate, mutate (caret-end + Enter ×3), capture byte-level DB state via docker exec mysql HEX(SUBSTRING(...)), screenshot, swap worktrees, repeat.
  • Every finding is a structured row with severity, byte trace, screenshots, and a triage state.

The full mission JSON, per-surface byte traces, and all screenshots are attached to this gist as files (search for trace-*.txt and pre-*.png / post-*.png).

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment