Skip to content

Commit 4b3a535

Browse files
committed
partially fix #76 by not ignoring all ystate updates
Partially fixes #76 where the "unsaved changes" icon appears when a notebook is opened. The bug no longer appears after a notebook is opened for the *first* time, but still appears after closing & re-opening the notebook across the server session.
1 parent 8c0966d commit 4b3a535

File tree

1 file changed

+56
-12
lines changed

1 file changed

+56
-12
lines changed

jupyter_server_documents/rooms/yroom.py

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -405,18 +405,23 @@ def _on_ydoc_update(self, event: TransactionEvent) -> None:
405405
self._broadcast_message(message, message_type="SyncUpdate")
406406

407407

408-
def _on_jupyter_ydoc_update(self, updated_key: str, *_) -> None:
408+
def _on_jupyter_ydoc_update(self, updated_key: str, event: pycrdt) -> None:
409409
"""
410410
This method is an observer on `self._jupyter_ydoc` which saves the file
411-
whenever the YDoc changes, unless `updated_key == "state"`.
411+
whenever the YDoc changes.
412412
413-
The `state` key is used by `jupyter_ydoc` to store temporary data like
414-
whether a file is 'dirty' (has unsaved changes). This data is not
415-
persisted, so changes to the `state` key should be ignored. Otherwise,
416-
an infinite loop of saves will result, as saving sets `dirty = False`.
413+
- This observer ignores updates to the 'state' dictionary if they have
414+
no effect. See `should_ignore_state_update()` documentation for info.
417415
418-
This observer is separate because `pycrdt.Doc.observe()` does not pass
419-
`updated_key` to `self._on_ydoc_update()`.
416+
- This observer is separate from the `pycrdt` observer because we must
417+
check if the update should be ignored. This requires the `updated_key`
418+
and `event` arguments exclusive to `jupyter_ydoc` observers, not
419+
available to `pycrdt` observers.
420+
421+
- The type of the `event` argument depends on the shared type that
422+
`updated_key` references. If it references a `pycrdt.Map`, then event
423+
will always be of type `pycrdt.MapEvent`. Same applies for other shared
424+
types, like `pycrdt.Array` or `pycrdt.Text`.
420425
"""
421426
# Do nothing if there is no file API for this room (e.g. global awareness)
422427
if self.file_api is None:
@@ -429,10 +434,16 @@ def _on_jupyter_ydoc_update(self, updated_key: str, *_) -> None:
429434
if content_loading:
430435
return
431436

432-
# Save only when the content of the YDoc is updated.
433-
# See this method's docstring for more context.
434-
if updated_key != "state":
435-
self.file_api.schedule_save()
437+
# Do nothing if the event updates the 'state' dictionary with no effect
438+
if updated_key == "state":
439+
# The 'state' key always refers to a `pycrdt.Map` shared type, so
440+
# event always has type `pycrdt.MapEvent`.
441+
map_event = cast(pycrdt.MapEvent, event)
442+
if should_ignore_state_update(map_event):
443+
return
444+
445+
# Otherwise, save the file
446+
self.file_api.schedule_save()
436447

437448

438449
def handle_awareness_update(self, client_id: str, message: bytes) -> None:
@@ -614,3 +625,36 @@ async def stop(self) -> None:
614625
# JupyterYDoc in the process.
615626
if self.file_api:
616627
await self.file_api.stop_then_save()
628+
629+
def should_ignore_state_update(event: pycrdt.MapEvent) -> bool:
630+
"""
631+
Returns whether an update to the `state` dictionary should be ignored by
632+
`_on_jupyter_ydoc_update()`. Every Jupyter YDoc includes this dictionary in
633+
its YDoc.
634+
635+
This function returns `False` if the update has no effect, i.e. the event
636+
consists of updating each key to the same value it had originally.
637+
638+
Motivation: `pycrdt` emits update events on the 'state' key even when they have no
639+
effect. Without ignoring those updates, saving the file triggers an
640+
infinite loop of saves, as setting `jupyter_ydoc.dirty = False` always
641+
emits an update, even if that attribute was already `False`. See PR #50 for
642+
more info.
643+
"""
644+
# Iterate through the keys added/updated/deleted by this event. Return
645+
# `False` if:
646+
# - any key was not updated (i.e. a key was added/deleted), or
647+
# - the key was updated to a value different from the previous value
648+
for key in event.keys.keys():
649+
key_update = event.keys[key]
650+
action = key_update.get('action', None)
651+
if action != 'update':
652+
return False
653+
654+
old_value = key_update.get('oldValue', None)
655+
new_value = key_update.get('newValue', None)
656+
if old_value != new_value:
657+
return False
658+
659+
return True
660+

0 commit comments

Comments
 (0)