Skip to content
Merged
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/silly-wings-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@platejs/yjs": patch
---

revert @platejs/yjs to 52.0.5
212 changes: 88 additions & 124 deletions packages/yjs/src/lib/BaseYjsPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ export const BaseYjsPlugin = createTSlatePlugin<YjsConfig>({
if (!ydoc) {
ydoc = new Y.Doc();
}

if (!awareness) {
awareness = new Awareness(ydoc);
}
Expand Down Expand Up @@ -126,7 +125,7 @@ export const BaseYjsPlugin = createTSlatePlugin<YjsConfig>({

try {
YjsEditor.disconnect(editor as any);
} catch (_error) {}
} catch {}
},
/**
* Disconnect from all providers or specific provider types. For WebRTC
Expand All @@ -138,7 +137,7 @@ export const BaseYjsPlugin = createTSlatePlugin<YjsConfig>({
* specified, disconnects from all providers.
*/
disconnect: (type?: YjsProviderType | YjsProviderType[]) => {
const { getOptions } = ctx;
const { editor: _editor, getOptions } = ctx;
const { _providers } = getOptions();

const typesToDisconnect = type
Expand Down Expand Up @@ -191,7 +190,6 @@ export const BaseYjsPlugin = createTSlatePlugin<YjsConfig>({

const options = getOptions();
const {
_providers,
awareness,
providers: providerConfigsOrInstances = [],
sharedType: customSharedType,
Expand All @@ -205,73 +203,42 @@ export const BaseYjsPlugin = createTSlatePlugin<YjsConfig>({
);
}

// Store initial value for potential later use (after sync)
let pendingInitialValue: Value | null = null;

// CRITICAL: Check for both null AND undefined to avoid creating default value
if (value != null) {
// != checks for both null and undefined
let initialNodes = value as Value;
if (typeof value === 'string') {
initialNodes = editor.api.html.deserialize({
element: value,
}) as Value;
} else if (typeof value === 'function') {
initialNodes = await value(editor);
} else if (value) {
initialNodes = value;
}
if (!initialNodes || initialNodes?.length === 0) {
initialNodes = editor.api.create.value();
}

// Store for later - will apply after sync if Y.doc is empty
pendingInitialValue = initialNodes;
}

// Final providers array that will contain both configured and custom providers
const finalProviders: UnifiedProvider[] = [];

// Connect the YjsEditor first to set up slate-yjs bindings.
YjsEditor.connect(editor as any);

// CRITICAL: Don't call editor.tf.init yet - wait for first sync to complete
// This prevents editor.tf.init from adding an empty paragraph before server content arrives
let hasInitialized = false;
// Track sync state for waiting
let syncResolve: (() => void) | null = null;
const syncPromise = new Promise<void>((resolve) => {
syncResolve = resolve;
});

// Then process and create providers
// Create providers FIRST (before connecting YjsEditor)
for (const item of providerConfigsOrInstances) {
if (isProviderConfig(item)) {
// It's a configuration object, create the provider
const { options, type } = item;
const { options: providerOptions, type } = item;

if (!options) {
console.warn(
`[yjs] No options provided for provider type: ${type}`
);
if (!providerOptions) {
continue;
}

try {
// Create provider with shared handlers, Y.Doc, and Awareness
const provider = createProvider({
awareness,
doc: ydoc,
options,
options: providerOptions,
type,
onConnect: () => {
getOptions().onConnect?.({ type });
// At least one provider is connected
setOption('_isConnected', true);
},
onDisconnect: () => {
getOptions().onDisconnect?.({ type });

// Check for any connected providers
const { _providers } = getOptions();
const hasConnectedProvider = _providers.some(
(provider) => provider.isConnected
(p) => p.isConnected
);

if (!hasConnectedProvider) {
setOption('_isConnected', false);
}
Expand All @@ -283,95 +250,27 @@ export const BaseYjsPlugin = createTSlatePlugin<YjsConfig>({
getOptions().onSyncChange?.({ isSynced, type });
setOption('_isSynced', isSynced);

// CRITICAL: Initialize editor AFTER first sync to avoid empty paragraph before server content
if (isSynced && !hasInitialized) {
hasInitialized = true;

// After first sync completes, apply initial value ONLY if Y.doc is still empty
if (pendingInitialValue) {
const sharedTypeToCheck =
customSharedType || ydoc.get('content', Y.XmlText);
const ydocHasContent =
sharedTypeToCheck && sharedTypeToCheck.length > 0;

if (ydocHasContent) {
pendingInitialValue = null;
} else {
// Use custom sharedType if provided, otherwise use default 'content'
if (customSharedType) {
const delta =
slateNodesToInsertDelta(pendingInitialValue);
ydoc.transact(() => {
customSharedType.applyDelta(delta);
});
} else {
// For default 'content' key, use deterministic state
slateToDeterministicYjsState(
id ?? editor.id,
pendingInitialValue
)
.then((initialDelta) => {
ydoc.transact(() => {
Y.applyUpdate(ydoc, initialDelta);
});
})
.catch(() => {
// Ignore errors applying pending value
});
}

pendingInitialValue = null;
}
}

// Now call editor.tf.init after Y.doc sync is complete
editor.tf.init({
autoSelect,
selection,
shouldNormalizeEditor: false,
value: null,
onReady,
});
// Resolve sync promise on first sync
if (isSynced && syncResolve) {
syncResolve();
syncResolve = null;
}
},
});
finalProviders.push(provider);
} catch (error) {
console.warn(
`[yjs] Error creating provider of type ${type}:`,
error
);
} catch {
// Provider creation failed
}
} else {
// It's a pre-instantiated UnifiedProvider instance
const customProvider = item;

// Check if the provider's document matches our shared document
if (customProvider.document !== ydoc) {
console.warn(
`[yjs] Custom provider instance (${customProvider.type}) has a different Y.Doc. ` +
'This may cause synchronization issues. Ensure custom providers use the shared Y.Doc.'
);
}
// Check if the provider's awareness matches our shared awareness
if (customProvider.awareness !== awareness) {
console.warn(
`[yjs] Custom provider instance (${customProvider.type}) has a different Awareness instance. ` +
'Ensure custom providers use the shared Awareness instance for cursor consistency.'
);
}

// Add the custom provider to our providers array
finalProviders.push(customProvider);
finalProviders.push(item);
}
}

// Update provider counts after creation
setOption('_providers', finalProviders);

// Finally, connect providers if autoConnect is true
if (autoConnect) {
_providers.forEach((provider) => {
// Connect providers to start sync
if (autoConnect && finalProviders.length > 0) {
finalProviders.forEach((provider) => {
try {
provider.connect();
} catch (error) {
Expand All @@ -381,6 +280,71 @@ export const BaseYjsPlugin = createTSlatePlugin<YjsConfig>({
});
}
});

// Wait for first sync to complete (with timeout)
const SYNC_TIMEOUT = 5000;
await Promise.race([
syncPromise,
new Promise<void>((resolve) => {
setTimeout(() => {
syncResolve = null; // Clear to prevent late resolution
resolve();
}, SYNC_TIMEOUT);
}),
]);
}

// After sync, check if ydoc has content from server
// Use custom sharedType if provided, otherwise use default 'content' key
const sharedRoot =
customSharedType ?? (ydoc.get('content', Y.XmlText) as Y.XmlText);

// Only apply initial value if ydoc is empty (no content from server)
if (sharedRoot.length === 0 && value !== null) {
let initialNodes = value as Value;
Comment on lines +300 to +304

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid seeding doc before initial sync completes

After racing the provider sync against a 5s timeout, this block always seeds the shared document whenever it is empty and value is not null, which includes the default undefined case. If a provider simply takes longer than 5s to deliver the server state, we will insert create.value() locally before the first sync and that default paragraph will then be merged with the late-arriving server content, reintroducing the phantom paragraph the previous implementation avoided by waiting for the first onSyncChange before applying any initial value.

Useful? React with 👍 / 👎.


if (typeof value === 'string') {
initialNodes = editor.api.html.deserialize({
element: value,
}) as Value;
} else if (typeof value === 'function') {
initialNodes = await value(editor);
} else if (value) {
initialNodes = value;
}
if (!initialNodes || initialNodes?.length === 0) {
initialNodes = editor.api.create.value();
}
if (customSharedType) {
const delta = slateNodesToInsertDelta(initialNodes);
ydoc.transact(() => {
customSharedType.applyDelta(delta);
});
} else {
const initialDelta = await slateToDeterministicYjsState(
id ?? editor.id,
initialNodes
);
ydoc.transact(() => {
Y.applyUpdate(ydoc, initialDelta);
});
}
}

// NOW connect YjsEditor after sync is complete
YjsEditor.connect(editor as any);

editor.tf.init({
autoSelect,
selection,
shouldNormalizeEditor: false,
value: null,
});

// Force React to re-render by triggering onChange
editor.api.onChange();

// Call onReady callback
onReady?.({ editor, isAsync: true, value: editor.children });
},
}));