-
Notifications
You must be signed in to change notification settings - Fork 3.8k
fix(files): stop a cached collab snapshot resurrecting blank lines #6293
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| /** | ||
| * @vitest-environment node | ||
| */ | ||
| import { describe, expect, it } from 'vitest' | ||
| import * as Y from 'yjs' | ||
| import { COLLAB_DOC_FIELD, stripEmptyTopLevelParagraphs } from './normalize' | ||
|
|
||
| /** Build a top-level element with the given tag and optional text content. */ | ||
| function element(tag: string, text?: string): Y.XmlElement { | ||
| const el = new Y.XmlElement(tag) | ||
| if (text !== undefined) el.insert(0, [new Y.XmlText(text)]) | ||
| return el | ||
| } | ||
|
|
||
| /** Recursively concatenate the visible text of a Yjs XML node. */ | ||
| function textOf(node: Y.XmlElement | Y.XmlText | Y.XmlHook): string { | ||
| if (node instanceof Y.XmlText) return node.toString() | ||
| if (node instanceof Y.XmlElement) { | ||
| let text = '' | ||
| for (let i = 0; i < node.length; i++) text += textOf(node.get(i)) | ||
| return text | ||
| } | ||
| return '' | ||
| } | ||
|
|
||
| /** The ordered list of top-level `[tag, text]` pairs currently in a doc's body fragment. */ | ||
| function structure(doc: Y.Doc): Array<[string, string]> { | ||
| const fragment = doc.getXmlFragment(COLLAB_DOC_FIELD) | ||
| const out: Array<[string, string]> = [] | ||
| for (let i = 0; i < fragment.length; i++) { | ||
| const node = fragment.get(i) | ||
| out.push([node instanceof Y.XmlElement ? node.nodeName! : 'text', textOf(node)]) | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| describe('stripEmptyTopLevelParagraphs', () => { | ||
| it('removes interior empty paragraphs while preserving content and order (production repro)', () => { | ||
| // Mirrors the persisted snapshot for random_data.md: a description paragraph, TWO consecutive empty | ||
| // paragraphs (the reported "two spaces"), then a bullet list, then another interior empty paragraph. | ||
| const doc = new Y.Doc() | ||
| const fragment = doc.getXmlFragment(COLLAB_DOC_FIELD) | ||
| fragment.insert(0, [ | ||
| element('paragraph', 'A small collection of sample data.'), | ||
| element('paragraph'), | ||
| element('paragraph'), | ||
| element('bulletList', 'list'), | ||
| element('paragraph'), | ||
| element('paragraph', 'trailing content'), | ||
| ]) | ||
|
|
||
| expect(stripEmptyTopLevelParagraphs(doc)).toBe(true) | ||
| expect(structure(doc)).toEqual([ | ||
| ['paragraph', 'A small collection of sample data.'], | ||
| ['bulletList', 'list'], | ||
| ['paragraph', 'trailing content'], | ||
| ]) | ||
| doc.destroy() | ||
| }) | ||
|
|
||
| it('is idempotent — a second pass finds nothing to remove', () => { | ||
| const doc = new Y.Doc() | ||
| doc | ||
| .getXmlFragment(COLLAB_DOC_FIELD) | ||
| .insert(0, [element('paragraph'), element('paragraph', 'body')]) | ||
|
|
||
| expect(stripEmptyTopLevelParagraphs(doc)).toBe(true) | ||
| expect(stripEmptyTopLevelParagraphs(doc)).toBe(false) | ||
| expect(structure(doc)).toEqual([['paragraph', 'body']]) | ||
| doc.destroy() | ||
| }) | ||
|
|
||
| it('returns false and mutates nothing when there are no top-level empty paragraphs', () => { | ||
| const doc = new Y.Doc() | ||
| doc | ||
| .getXmlFragment(COLLAB_DOC_FIELD) | ||
| .insert(0, [element('heading', 'Title'), element('paragraph', 'body')]) | ||
|
|
||
| expect(stripEmptyTopLevelParagraphs(doc)).toBe(false) | ||
| expect(structure(doc)).toEqual([ | ||
| ['heading', 'Title'], | ||
| ['paragraph', 'body'], | ||
| ]) | ||
| doc.destroy() | ||
| }) | ||
|
|
||
| it('leaves an empty paragraph nested inside another block untouched (only top-level is stripped)', () => { | ||
| const doc = new Y.Doc() | ||
| const listItem = new Y.XmlElement('listItem') | ||
| listItem.insert(0, [new Y.XmlElement('paragraph')]) // an empty paragraph BELOW the fragment root | ||
| const list = new Y.XmlElement('bulletList') | ||
| list.insert(0, [listItem]) | ||
| doc.getXmlFragment(COLLAB_DOC_FIELD).insert(0, [list]) | ||
|
|
||
| expect(stripEmptyTopLevelParagraphs(doc)).toBe(false) | ||
| const nestedList = doc.getXmlFragment(COLLAB_DOC_FIELD).get(0) as Y.XmlElement | ||
| const nestedItem = nestedList.get(0) as Y.XmlElement | ||
| expect(nestedItem.get(0)).toBeInstanceOf(Y.XmlElement) | ||
| expect((nestedItem.get(0) as Y.XmlElement).nodeName).toBe('paragraph') | ||
| doc.destroy() | ||
| }) | ||
|
|
||
| it('survives an encode/decode round-trip preserving CRDT ids and the config map (seed-repair path)', () => { | ||
| const original = new Y.Doc() | ||
| original | ||
| .getXmlFragment(COLLAB_DOC_FIELD) | ||
| .insert(0, [element('paragraph', 'kept'), element('paragraph')]) | ||
| original.getMap('config').set('initialContentLoaded', true) | ||
| original.getMap('config').set('frontmatter', 'title: x') | ||
| const before = Y.encodeStateAsUpdate(original) | ||
| original.destroy() | ||
|
|
||
| // Repair exactly as normalizeSeedUpdate does: apply → strip → re-encode. | ||
| const repair = new Y.Doc() | ||
| Y.applyUpdate(repair, before) | ||
| expect(stripEmptyTopLevelParagraphs(repair)).toBe(true) | ||
| const after = Y.encodeStateAsUpdate(repair) | ||
| repair.destroy() | ||
|
|
||
| const seeded = new Y.Doc() | ||
| Y.applyUpdate(seeded, after) | ||
| expect(structure(seeded)).toEqual([['paragraph', 'kept']]) | ||
| expect(seeded.getMap('config').get('initialContentLoaded')).toBe(true) | ||
| expect(seeded.getMap('config').get('frontmatter')).toBe('title: x') | ||
| seeded.destroy() | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import * as Y from 'yjs' | ||
|
|
||
| /** | ||
| * The Yjs `XmlFragment` name TipTap's Collaboration extension binds to (its default `field`). The | ||
| * client configures `Collaboration.configure({ document })` with no explicit `field`, so it uses | ||
| * TipTap's default, `'default'`. Server-side conversion, seeding, and persistence MUST target the same | ||
| * fragment or the client would sync an empty document — so this is the single canonical source consumed | ||
| * by both bundles (it imports only `yjs`, making it safe from client and server alike). | ||
| */ | ||
| export const COLLAB_DOC_FIELD = 'default' | ||
|
|
||
| /** | ||
| * Remove every top-level empty paragraph (a `paragraph` element with no children) from a collaborative | ||
| * document's body fragment, returning whether it deleted any. | ||
| * | ||
| * The markdown parse pipeline strips these from EVERY parse target (see `stripEmptyParagraphs` in | ||
| * `markdown-parse.ts`): in markdown a run of blank lines between blocks is insignificant, so the static | ||
| * placeholder, the download, and every standard renderer show no interior blank. A cached Yjs snapshot, | ||
| * however, is a raw CRDT binary that bypasses that parse — so it can preserve an empty-paragraph node the | ||
| * re-parse would have dropped. When a warm room seeds from such a snapshot, the empty paragraph surfaces | ||
| * as a stray blank line appearing once the doc settles, diverging from the placeholder that was shown | ||
| * first. Enforcing the same no-top-level-empty-paragraph invariant on the Yjs side keeps the live | ||
| * collaborative doc rendering identically to the markdown re-parse. | ||
| * | ||
| * Idempotent, and only TOP-LEVEL paragraphs are touched — blank lines that carry meaning inside a | ||
| * construct (e.g. a loose list) live below the fragment root and are left alone. Runs its own Yjs | ||
| * transaction so the deletions commit atomically, iterating the fragment back-to-front so a deletion | ||
| * never shifts a not-yet-checked index. | ||
| */ | ||
| export function stripEmptyTopLevelParagraphs(doc: Y.Doc): boolean { | ||
| const fragment = doc.getXmlFragment(COLLAB_DOC_FIELD) | ||
| let removed = false | ||
| doc.transact(() => { | ||
| for (let i = fragment.length - 1; i >= 0; i--) { | ||
| const node = fragment.get(i) | ||
| if (node instanceof Y.XmlElement && node.nodeName === 'paragraph' && node.length === 0) { | ||
| fragment.delete(i, 1) | ||
| removed = true | ||
| } | ||
| } | ||
| }) | ||
| return removed | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.