Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/defensive-path-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@slate-yjs/core": patch
---

Fix crash when applying Yjs update with invalid Slate path. When Yjs and Slate get out of sync (e.g. after paste/duplication), the editor now recovers by re-syncing from Yjs instead of throwing "Cannot find a descendant at path".
12 changes: 6 additions & 6 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v2
uses: actions/setup-node@v4
with:
cache: yarn
node-version-file: '.nvmrc'
Expand All @@ -40,22 +40,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4

- name: Login to Docker Hub
uses: docker/login-action@v1
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v5
with:
images: bitphinix/slate-yjs-example-backend

- name: Build and push Docker image
uses: docker/build-push-action@v2
uses: docker/build-push-action@v5
with:
context: .
file: ./examples/backend/Dockerfile
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Node
uses: actions/setup-node@v2
uses: actions/setup-node@v4
with:
cache: yarn
node-version-file: '.nvmrc'
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Node
uses: actions/setup-node@v2
uses: actions/setup-node@v4
with:
cache: yarn
node-version-file: '.nvmrc'
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Node
uses: actions/setup-node@v2
uses: actions/setup-node@v4
with:
cache: yarn
node-version-file: '.nvmrc'
Expand Down
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
lts/gallium
lts/iron
86 changes: 80 additions & 6 deletions packages/core/src/applyToSlate/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { Editor, Operation } from 'slate';
import { Editor, Operation, Transforms } from 'slate';
import * as Y from 'yjs';
import { RelativeRange } from '../model/types';
import { yTextToSlateElement } from '../utils/convert';
import { isSyncSkewError } from '../utils/errors';
import {
relativeRangeToSlateRange,
slateRangeToRelativeRange,
} from '../utils/position';
import { translateYTextEvent } from './textEvent';

let lastResyncTime = 0;
const RESYNC_THROTTLE_MS = 1000;

/**
* Translate a yjs event into slate operations. The editor state has to match the
* yText state before the event occurred.
Expand All @@ -21,10 +31,52 @@
throw new Error('Unexpected Y event type');
}

/**
* Performs a full re-sync of the Slate editor from the Yjs shared root.
* Preserves the user's selection if possible by converting to/from relative positions.
*/
function resyncFromYjs(sharedRoot: Y.XmlText, editor: Editor): void {
let savedSelection: RelativeRange | null = null;

try {
if (editor.selection) {
savedSelection = slateRangeToRelativeRange(
sharedRoot,
editor,
editor.selection
);
}
} catch {
// Selection conversion failed; we'll lose the selection but continue with re-sync
}

const content = yTextToSlateElement(sharedRoot);
editor.children = content.children;
Editor.normalize(editor, { force: true });

if (savedSelection) {
try {
const newSelection = relativeRangeToSlateRange(
sharedRoot,
editor,
savedSelection
);
if (newSelection) {
Transforms.select(editor, newSelection);
}
} catch {
// Selection restoration failed; cursor position is lost but document is correct
}
}
}

/**
* Translates yjs events into slate operations and applies them to the editor. The
* editor state has to match the yText state before the events occurred.
*
* If a sync-skew error occurs (e.g. invalid path due to Yjs/Slate mismatch),
* the editor is re-synced from the Yjs shared root to recover gracefully.
*
* @param sharedRoot
* @param editor
* @param events
Expand All @@ -35,10 +87,32 @@
events: Y.YEvent<Y.XmlText>[]
) {
Editor.withoutNormalizing(editor, () => {
events.forEach((event) => {
translateYjsEvent(sharedRoot, editor, event).forEach((op) => {
editor.apply(op);
});
});
for (const event of events) {
try {
const ops = translateYjsEvent(sharedRoot, editor, event);
for (const op of ops) {
editor.apply(op);
}
} catch (error) {
if (isSyncSkewError(error)) {
const now = Date.now();
if (now - lastResyncTime >= RESYNC_THROTTLE_MS) {
lastResyncTime = now;
console.warn(

Check warning on line 101 in packages/core/src/applyToSlate/index.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
'[slate-yjs] Sync skew detected, re-syncing from Yjs:',
error instanceof Error ? error.message : error
);
resyncFromYjs(sharedRoot, editor);
} else {
console.warn(

Check warning on line 107 in packages/core/src/applyToSlate/index.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
'[slate-yjs] Sync skew detected (throttled, skipping re-sync):',
error instanceof Error ? error.message : error
);
}
return;
}
throw error;
}
}
});
}
20 changes: 20 additions & 0 deletions packages/core/src/utils/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const SYNC_SKEW_ERROR_PATTERNS = [
'Cannot find a descendant at path',
'yOffset out of bounds',
'Cannot descent into slate text',
"yText isn't a descendant of root element",
"Path doesn't match yText",
'Unexpected y parent type',
'Path has to a have a length >= 1',
'yTarget spans multiple nodes',
];

export function isSyncSkewError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}

return SYNC_SKEW_ERROR_PATTERNS.some((pattern) =>
error.message.includes(pattern)
);
}
36 changes: 21 additions & 15 deletions packages/core/src/utils/position.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BasePoint, BaseRange, Node, Text } from 'slate';
import * as Y from 'yjs';
import { InsertDelta, RelativeRange, TextRange } from '../model/types';
import { getInsertDeltaLength, yTextToInsertDelta } from './delta';
import { isSyncSkewError } from './errors';
import { getSlatePath, getYTarget, yOffsetToSlateOffsets } from './location';
import { assertDocumentAttachment } from './yjs';

Expand Down Expand Up @@ -41,25 +42,30 @@ export function absolutePositionToSlatePoint(
throw new Error('Absolute position points to a non-XMLText');
}

const parentPath = getSlatePath(sharedRoot, slateRoot, type);
const parent = Node.get(slateRoot, parentPath);
try {
const parentPath = getSlatePath(sharedRoot, slateRoot, type);
const parent = Node.get(slateRoot, parentPath);

if (Text.isText(parent)) {
throw new Error(
"Absolute position doesn't match slateRoot, cannot descent into text"
);
}
if (Text.isText(parent)) {
return null;
}

const [pathOffset, textOffset] = yOffsetToSlateOffsets(parent, index, {
assoc,
});
const [pathOffset, textOffset] = yOffsetToSlateOffsets(parent, index, {
assoc,
});

const target = parent.children[pathOffset];
if (!Text.isText(target)) {
return null;
}
const target = parent.children[pathOffset];
if (!Text.isText(target)) {
return null;
}

return { path: [...parentPath, pathOffset], offset: textOffset };
return { path: [...parentPath, pathOffset], offset: textOffset };
} catch (error) {
if (isSyncSkewError(error)) {
return null;
}
throw error;
}
}

export function relativePositionToSlatePoint(
Expand Down
Loading