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
6 changes: 3 additions & 3 deletions dev/docs/yjs.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,13 +243,13 @@ responsible for converting between the native file format and the Y.Doc's shared

```ts
interface YjsAdapter {
hydrate(ydoc: Y.Doc, content: string): void | Promise<void>
hydrate(ydoc: Y.Doc, content: string): void

serialize(ydoc: Y.Doc): string | Promise<string>
serialize(ydoc: Y.Doc): string

hasContent(ydoc: Y.Doc): boolean

reset?(ydoc: Y.Doc): void
reset(ydoc: Y.Doc): void
}
```

Expand Down
21 changes: 10 additions & 11 deletions packages/web-pkg/src/components/AppTemplates/AppWrapper.vue
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,8 @@ const isLoading = computed(() => {
* The Yjs session reports doc changes debounced, so `isDirty` and `currentContent` lag behind
* the last keystrokes. Let it catch up before deciding about unsaved work.
*/
async function flushPendingContent() {
await yjsSession?.flushContent()
function flushPendingContent() {
yjsSession?.flushContent()
}

// `beforeunload` can't wait for a flush, so a pending change counts as unsaved there.
Expand Down Expand Up @@ -639,7 +639,7 @@ async function runSaveCallback(content: unknown): Promise<void> {
}

const save = async () => {
await flushPendingContent()
flushPendingContent()
const saved = await saveFileTask.perform()
if (!saved) {
return false
Expand Down Expand Up @@ -681,7 +681,7 @@ onMounted(() => {
if (editorOptions.autosaveEnabled && !disableAutoSave) {
autosaveIntervalId = setInterval(
async () => {
await flushPendingContent()
flushPendingContent()
if (!unref(isDirty) || unref(isConflicted)) {
return
}
Expand Down Expand Up @@ -713,8 +713,8 @@ onBeforeUnmount(() => {
})

const { bindKeyAction } = useKeyboardActions({ skipDisabledKeyBindingsCheck: true })
bindKeyAction({ modifier: Modifier.Ctrl, primary: Key.S }, async () => {
await flushPendingContent()
bindKeyAction({ modifier: Modifier.Ctrl, primary: Key.S }, () => {
flushPendingContent()
if (!unref(isDirty)) {
return
}
Expand All @@ -734,7 +734,7 @@ const downloadFileActionInterceptor = async (
args: FileActionOptions,
originalAction: Action<FileActionOptions>['handler']
) => {
await flushPendingContent()
flushPendingContent()
if (unref(isDirty)) {
if (unref(isConflicted)) {
showMessage({
Expand Down Expand Up @@ -834,10 +834,9 @@ const dropDownMenuSections = computed(() => {
return sections
})

// Returns instead of calling `next`: the guard has to await the flush first, and an async guard
// that also takes `next` is rejected by the router.
onBeforeRouteLeave(async () => {
await flushPendingContent()
// Returns instead of calling `next`: the unsaved changes modal resolves the navigation later.
onBeforeRouteLeave(() => {
flushPendingContent()
if (!unref(isDirty)) {
unregisterExtensions([topBarExtensionId])
return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ export function useAppWrapperYjs(options: AppWrapperYjsOptions) {
// Retry with the merged state, not `newContent`: that snapshot predates
// the peer save that caused this conflict, so writing it again would
// drop exactly the edits the peer just committed.
const mergedContent = yield* call(session.serializeMerged())
const mergedContent = session.serializeMerged()
const retryContent = mergedContent ?? newContent
const retry = yield* call(
putFileContents(currentFileContext, {
Expand Down Expand Up @@ -246,8 +246,7 @@ export function useAppWrapperYjs(options: AppWrapperYjsOptions) {

// The room's own state is what landed: a peer saved and left before its
// stamp reached us. Land it like the conflict retry does.
const merged = await session.serializeMerged()
if (!unref(canHandleExternalEvents)) return
const merged = session.serializeMerged()
if (merged !== null && body === merged) {
serverContent.value = currentContent.value = body
applySavedResource(freshEtag)
Expand Down
10 changes: 5 additions & 5 deletions packages/web-pkg/src/composables/yjs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ export interface YjsAdapter {
* Render the current Y.Doc state to the native file format for WebDAV PUT
* and the local `isDirty` check in the app wrapper. Runs continuously and
* on every peer, triggered by Y.Doc/meta changes.
*
* **Must be synchronous.** The session pairs the result with the doc's
* state vector, which must not move while serializing.
*/
serialize(ydoc: Y.Doc): string | Promise<string>
serialize(ydoc: Y.Doc): string

/**
* Returns true if the adapter has populated the Y.Doc with app data.
Expand All @@ -40,9 +43,6 @@ export interface YjsAdapter {
* Called when the persisted Y.Doc turns out to be stale (e.g. an external
* file write happened between sessions); the elected client then
* re-hydrates from the fresh native content.
*
* Optional; adapters that omit this won't recover from a stale-state
* signal in-place; the session falls back to forcing a full reload.
*/
reset?(ydoc: Y.Doc): void
reset(ydoc: Y.Doc): void
}
124 changes: 46 additions & 78 deletions packages/web-pkg/src/composables/yjs/useYjsSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export interface YjsSession {
* is about to write. Used to retry a conflicted save without dropping the
* peer edits that caused the conflict.
*/
serializeMerged: () => Promise<string | null>
serializeMerged: () => string | null
/**
* Whether a write announced with this etag came from inside the room, i.e.
* the room's etag stamp already names it. Always false in local mode.
Expand All @@ -155,7 +155,7 @@ export interface YjsSession {
* Report a pending doc change right away. Call it before deciding about
* unsaved work, e.g. before saving or leaving, so the last keystrokes count.
*/
flushContent: () => Promise<void>
flushContent: () => void
}

const META_KEY = '_oc_meta'
Expand Down Expand Up @@ -508,12 +508,10 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {
stopProvider(prov)
}

async function serializeDoc(doc: Y.Doc): Promise<string | null> {
function serializeDoc(doc: Y.Doc): string | null {
const current = toValue(adapter)
if (doc.isDestroyed || !current.hasContent(doc)) return null
const value = await Promise.resolve(current.serialize(doc))
if (doc.isDestroyed) return null
return value
return current.serialize(doc)
}

/** See {@link YjsSession.beginSave}. */
Expand All @@ -524,14 +522,12 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {
}

/** See {@link YjsSession.serializeMerged}. */
async function serializeMerged(): Promise<string | null> {
function serializeMerged(): string | null {
const doc = unref(ydoc)
if (!doc || doc.isDestroyed) return null
// Taken before serializing, for the same reason as in the debounced emit.
const vector = Y.encodeStateVector(doc)
const value = await serializeDoc(doc)
const value = serializeDoc(doc)
if (value === null) return null
pendingSaveStateVector = vector
pendingSaveStateVector = Y.encodeStateVector(doc)
return value
}

Expand Down Expand Up @@ -784,15 +780,6 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {
): Promise<boolean> {
const current = toValue(adapter)
const meta = sessionMeta(doc)
if (typeof current.reset !== 'function') {
lockForReload(
prov,
$gettext(
'This file was changed externally and your editor cannot recover in-place. Please reload.'
)
)
return false
}

const grant = recoveryGrant(etag)
let isServerGrant = false
Expand All @@ -807,7 +794,7 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {
return false
}
// The rewrite would wipe what was typed while waiting for the grant.
if (unref(isReady)) await activeReporter?.flush()
if (unref(isReady)) activeReporter?.flush()
if (doc.isDestroyed || unref(ydoc) !== doc) return giveUp()
if (unref(effectiveReadOnly) || unref(isConflicted)) return giveUp()
// The rewrite for this etag already landed.
Expand All @@ -831,7 +818,7 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {
isRewritingDoc = true
try {
doc.transact(() => {
current.reset?.(doc)
current.reset(doc)
current.hydrate(doc, content)
})

Expand Down Expand Up @@ -895,33 +882,20 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {
*/
function createContentReporter(doc: Y.Doc) {
let timer: number | undefined
let inFlight: Promise<void> | null = null
function serialize(): Promise<void> {
if (doc.isDestroyed) return Promise.resolve()
// Vector taken before serializing: adapters may serialize
// asynchronously, and a peer update landing in between must not be
// counted as part of what we reported.
const vectorAtSerialize = Y.encodeStateVector(doc)
return serializeDoc(doc).then((value) => {
if (value === null) return
lastReportedStateVector = vectorAtSerialize
lastReportedContent = value
onContentChange(value)
})
}
function report(): Promise<void> {
function report() {
// Re-checked: the debounce window can outlive the change that opened it.
if (!canReportContent()) return Promise.resolve()
// Chained onto a running report: two concurrent serializations could
// settle out of order, and the older one would then win.
const previous = inFlight
const run: Promise<void> = (previous ? previous.then(serialize) : serialize())
.catch((e) => console.error('[yjs] serialize for content update failed:', e))
.finally(() => {
if (inFlight === run) inFlight = null
})
inFlight = run
return run
if (!canReportContent()) return
let value: string | null
try {
value = serializeDoc(doc)
} catch (e) {
console.error('[yjs] serialize for content update failed:', e)
return
}
if (value === null) return
lastReportedStateVector = Y.encodeStateVector(doc)
lastReportedContent = value
onContentChange(value)
}
function onDocUpdate() {
if (!canReportContent()) return
Expand All @@ -930,20 +904,20 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {
timer = window.setTimeout(() => {
timer = undefined
hasPendingContent.value = false
void report()
report()
}, SERIALIZE_DEBOUNCE_MS)
}
/**
* Run a pending report now. The caller's dirty state lags the doc by the
* debounce, and a decision about unsaved work must not miss a keystroke
* from inside that window. A doc with no pending report stays untouched.
*/
function flush(): Promise<void> {
if (timer === undefined) return inFlight ?? Promise.resolve()
function flush() {
if (timer === undefined) return
window.clearTimeout(timer)
timer = undefined
hasPendingContent.value = false
return report()
report()
}
function cancel() {
if (timer !== undefined) window.clearTimeout(timer)
Expand Down Expand Up @@ -1070,7 +1044,7 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {
isRewritingDoc = true
reporter.cancel()

if (content === null || typeof current.reset !== 'function') {
if (content === null) {
// No conflict toast, there are no changes to be copied - they're gone.
markConflicted(false)
console.error('[yjs] the room discarded unsaved work that cannot be restored')
Expand All @@ -1092,7 +1066,7 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {
if (doc.isDestroyed) return
try {
doc.transact(() => {
current.reset?.(doc)
current.reset(doc)
current.hydrate(doc, content)
})
isRewritingDoc = false
Expand Down Expand Up @@ -1133,9 +1107,7 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {
// Peer-save fan-out. The fresh etag keeps our next If-Match correct.
// The content only follows when the peer's snapshot covers everything
// we hold; otherwise our dirty flag would drop over edits that never
// reached the peer's PUT and they could leave with the tab. Re-checked
// after serializing because a keystroke can land while that runs.
// Not on a recovery that costs us our work: the rewriting peer's etag
// reached the peer's PUT and they could leave with the tab. Not on a recovery that costs us our work: the rewriting peer's etag
// would make our next If-Match match, so a manual save would overwrite
// the body they just put on disk.
if (isRemoteMetaWrite && !resyncWouldDropOurWork) {
Expand All @@ -1155,14 +1127,13 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {
}
if (event.keysChanged.has('lastSavedAt')) {
const theirState = meta.get('savedStateVector')
if (theirState instanceof Uint8Array) {
serializeDoc(doc)
.then((value) => {
if (value === null || doc.isDestroyed) return
if (!peerSaveCoversUs(doc, theirState)) return
onServerContentChange(value)
})
.catch((e) => console.error('[yjs] serialize for peer-save sync failed:', e))
if (theirState instanceof Uint8Array && peerSaveCoversUs(doc, theirState)) {
try {
const value = serializeDoc(doc)
if (value !== null) onServerContentChange(value)
} catch (e) {
console.error('[yjs] serialize for peer-save sync failed:', e)
}
}
}
}
Expand Down Expand Up @@ -1193,7 +1164,7 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {
}

if (isRemoteMetaWrite && event.keysChanged.has('isStale') && meta.get('isStale') === true) {
void onRemoteStaleFlag(doc, meta, reporter)
onRemoteStaleFlag(reporter)
}
}
}
Expand All @@ -1204,10 +1175,8 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {
* peers did at flag time, or we are clean and follow the rewrite
* (`onExternalUpdate` fires when it lands).
*/
async function onRemoteStaleFlag(doc: Y.Doc, meta: SessionMetaMap, reporter: ContentReporter) {
await reporter.flush()
if (doc.isDestroyed || unref(ydoc) !== doc) return
if (meta.get('isStale') !== true) return
function onRemoteStaleFlag(reporter: ContentReporter) {
reporter.flush()
if (unref(isConflicted) || unref(isLockedForReload)) return
if (toValue(hasUnsavedChanges)) markConflicted()
}
Expand All @@ -1220,10 +1189,10 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {
}

/** See {@link YjsSession.flushContent}. */
async function flushContent() {
function flushContent() {
// Before that, the doc only holds what hydration put there, nothing typed.
if (!unref(isReady)) return
await activeReporter?.flush()
activeReporter?.flush()
}

/** See {@link YjsSession.adoptEtag}. */
Expand All @@ -1247,9 +1216,9 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {
* content and etag all moved. Reports the serialized form: the next
* debounced report produces the same string, so the file stays clean.
*/
async function reportRecovered(doc: Y.Doc, etag: string) {
const value = await serializeDoc(doc)
if (value === null || doc.isDestroyed || unref(ydoc) !== doc) return
function reportRecovered(doc: Y.Doc, etag: string) {
const value = serializeDoc(doc)
if (value === null) return
lastReportedStateVector = Y.encodeStateVector(doc)
lastReportedContent = value
onContentChange(value)
Expand All @@ -1274,8 +1243,7 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {
const meta = sessionMeta(doc)
if (meta.get('etag') === etag) return 'skipped'

await activeReporter?.flush()
if (doc.isDestroyed || unref(ydoc) !== doc) return 'skipped'
activeReporter?.flush()

if (toValue(hasUnsavedChanges)) {
if (!prov) {
Expand All @@ -1298,7 +1266,7 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {
// of them through.
const recovered = await recoverFromStaleState(doc, prov, { content, etag })
if (!recovered) return unref(isConflicted) ? 'conflicted' : 'skipped'
await reportRecovered(doc, etag)
reportRecovered(doc, etag)
onExternalUpdate?.()
return 'recovered'
}
Expand Down
Loading
Loading