Skip to content

fix(slides): stop false content-loss confirms in both editors, and the data loss behind them - #2395

Open
samark231 wants to merge 12 commits into
mainfrom
fix/lexical-callout-content-loss
Open

fix(slides): stop false content-loss confirms in both editors, and the data loss behind them#2395
samark231 wants to merge 12 commits into
mainfrom
fix/lexical-callout-content-loss

Conversation

@samark231

@samark231 samark231 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Summary of Changes

Authors were getting "To prevent accidental data loss… This will remove 1 callout / 1 table from the slide." confirms on slides where they had deleted nothing. The backend guard was arithmetically right about the payload it received — the editor really was dropping those blocks on the way through, while the words stayed visible on screen, so there was nothing for the author to act on. Two of the causes were silent data loss, not just a noisy dialog.

Root causes fixed:

  1. A callout stored plaintext. Its payload was read/written via textContent, so a table or image nested in a callout was flattened to its words — the <table>/<img> vanished from the saved HTML while every cell's text stayed on screen.
  2. A callout whose only element child was media got deleted outright. unwrapMediaWrappers replaceWith-es any div wrapping a lone image/iframe/video, including a custom block's own marker div — block, theme and text went with it. formatHTMLString's empty-image stripper had the same shape and could delete a callout whose upload had failed.
  3. The callout marker was only matched on <div>. Hand- and AI-authored lesson HTML puts it on <aside>/<blockquote>, which imported as a plain paragraph and never emitted the marker again.
  4. A src-less <img> counted as content. An abandoned upload placeholder is dropped by the importer and stripped by formatHTMLString on every save, so it read as "1 image removed" on saves that changed nothing.
  5. A lossy load could be laundered into a clean baseline. A draft stashed from a slide whose import dropped a block outranks the server copy on the next open; the load check then compares the thinned draft against itself, finds nothing wrong, and lets the save through — so the backend guard was the first thing to notice.

Backend:

  • SlideService.structuralMarkerCounts counts only images with a usable src (new countRenderableImages); empty/null/undefined placeholders are ignored. Real image loss is still reported.
  • IMG_TAG matches quoted attribute values properly, so an alt="a > b" can't cut a tag short before its src.
  • 3 new cases in SlideStructuralLossTest (placeholder ignored, real loss alongside a placeholder still caught, quote-style/spacing variants).

Frontend:

  • CalloutBlock stores rich HTML instead of plaintext; editing UI moves from <textarea> to the shared RichTextField. Legacy plaintext callouts round-trip byte-identically; legacy multi-line ones get a one-time \n<br> promotion (they also collapsed on the learner side, which skips newline conversion inside [data-yoopta-type] subtrees).
  • CalloutBlock.importTags widened to div/aside/blockquote/section. importMatch returns null without the marker, so plain elements of those tags are untouched.
  • unwrapMediaWrappers skips wrappers carrying a block marker or their own text; genuine Yoopta flex wrappers still unwrap.
  • formatHTMLString's empty-image stripper skips divs carrying data-yoopta-type.
  • normalizeYooptaHtml carries markup (not just words) out of a legacy <dl> callout, so bold/links/nested tables survive conversion.
  • structuralCounts ignores src-less images, kept in step with the backend rule.
  • stashDocDraftLocally refuses to write a local draft while that slide's load is flagged lossy.

Legacy (Yoopta) editor — the mid-session "will remove 1 table" confirm

Reported symptom: mid-session, on a slide where the author added or removed no table,
Save draft says "This will remove 1 table from the slide."

The editor's existing save-side checks only fire when more than half the blocks vanish, or
when a serializer throws. A serialize that quietly drops one table out of twenty passes both,
so the payload reaches the server, and the backend's structural guard is the first thing to
notice — where the only thing it can say is "you are removing a table", which to the author is
simply false.

New detectSerializeLoss(editorValue, serializedHtml) closes that gap: it compares the editor
value against its own serialization per structural type (table / image / video-embed / mermaid
/ each custom block). A mismatch means the serializer lost a block the editor still holds, so the
save is flagged degraded and refused with the honest "this slide could not be read correctly"
message instead of a delete-confirm the author cannot act on. The lossy HTML is also no longer
cached as the last-known-good baseline.

It cannot fire on a real deletion: deleting a block removes it from the editor value too, so both
sides drop together. It compares one snapshot against itself — no debounce, no older baseline, no
timing window.

Measured on real production content (local DB restored from prod dumps):

  • 132 Yoopta slides containing tables, run through the real pipeline (appReloadPreprocess
    html.deserialize → the app's serialize → formatHTMLString) and scored with the backend's
    exact counting: 0 would trip the confirm from a plain open→save, 126 clean, 6 caught by the
    existing load-time guard. So the drop is transient/session-dependent, which is exactly what the
    new check is shaped to catch.
  • 0 false positives for detectSerializeLoss across the 500 richest real Yoopta slides
    (498 clean; the 2 that threw are the already-handled "serializer throws" path).

Also found and not fixed here (separate issues, no code change in this PR):

  • 6 slides lose content during html.deserialize itself — 4 of them tables, all AI-generated or
    pasted markup. Those slides are effectively unsavable today: the loss happens during load, so
    the author is told to reload, which reproduces it. Needs its own bisection.
  • Ctrl+B toggles the sidebar (shadcn's SIDEBAR_KEYBOARD_SHORTCUT, sidebar.tsx:18) on a
    window-level listener, so it steals the bold shortcut inside the editor.

Related Issue

Follow-up to the slide content-loss work (docs/SLIDE_CONTENT_LOSS_INVESTIGATION.md).

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

How Has This Been Tested?

  • 15 new regression tests (lexical-editor/roundtrip-integrity/test.ts), one per reproduced false alarm. Counts are asserted for equality, so a duplicated table fails as loudly as a dropped one. Includes blast-radius pins: an image inside a text-carrying wrapper still imports, a plain <blockquote> stays a quote, plain <aside>/<section> are untouched, and the <br> promotion is idempotent.
  • 93 tests pass across the slides area; 10 in SlideStructuralLossTest; tsc --noEmit clean; design-lint 0 errors.
  • Real stored content: a 94 KB / 20-custom-block production slide and the only real callout-bearing slide were round-tripped through the actual import→export — every counted type preserved and byte-stable on the second pass (matters because the unsaved-changes baseline is an exact string compare).
  • Real backend, real HTTP (local admin_core on the Docker DB): unchanged content → 200; callout genuinely deleted → 409; table inside a callout flattened, i.e. the old editor's exact output → 409 "This will remove 1 table from the slide." (reproduces the reported popup); placeholder image dropped → 200; real image dropped → 409.
  • Legacy-editor guard: 12 unit tests for detectSerializeLoss (including "never fires on a real deletion" and the placeholder-media cases); 105 tests pass across the slides area; false-positive sweep over 500 real slides reported zero firings.
  • Browser click-through (new editor): done. Callout insert, multi-line typing, bold, colour switch and save verified in the app against a local backend — text intact, no confirm dialog.
  • Not yet done: a human click-through of the callout in a browser (insert, type, paste an image, theme switch, backspace behaviour). The editing UI changed from a <textarea> to a contentEditable, and while it now uses the same RichTextField-inside-BlockChrome pattern already shipped for quiz options, nobody has clicked it. Worth doing before merge.

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing tests pass locally with my changes
  • I have updated the documentation accordingly

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying frontend-admin-dashboard with  Cloudflare Pages  Cloudflare Pages

Latest commit: 256498c
Status: ✅  Deploy successful!
Preview URL: https://5b259f8f.frontend-admin-dashboard.pages.dev
Branch Preview URL: https://fix-lexical-callout-content.frontend-admin-dashboard.pages.dev

View logs

@codacy-production

codacy-production Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Not up to standards ⛔

🔴 Issues 1 critical · 2 minor

Alerts:
⚠ 3 issues (≤ 0 issues of at least minor severity)

Results:
3 new issues

Category Results
Security 1 critical
CodeStyle 2 minor

View in Codacy

🟢 Metrics 60 complexity

Metric Results
Complexity 60

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@samark231 samark231 changed the title fix(slides): stop false content-loss confirms and the callout data loss behind them fix(slides): stop false content-loss confirms in both editors, and the data loss behind them Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant