@@ -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