Repository: bokeh/bokeh

Issue:
[FEATURE] Add a DocumentReady event
In many cases you want your application to display something as quickly as possible. In the current design of bokeh server everything has to be initialized in the initial request. One solution to alleviate this issue would be to defer any expensive computation on initialization until after we have actually rendered something to screen. However currently there is no way to tell when the frontend is done initializing the rendered Document. 

Therefore I propose adding a BokehEvent which signals when the Document is ready (or in BokehJS terminology, when it is idle).


Discussion:

Resolving patch:
diff --git a/bokeh/document/document.py b/bokeh/document/document.py
--- a/bokeh/document/document.py
+++ b/bokeh/document/document.py
@@ -48,7 +48,7 @@
 from ..core.query import find
 from ..core.templates import FILE
 from ..core.validation import check_integrity
-from ..events import Event
+from ..events import _CONCRETE_EVENT_CLASSES, DocumentEvent, Event
 from ..model import Model
 from ..themes import Theme, built_in_themes
 from ..themes import default as default_theme
@@ -113,6 +113,7 @@ def __init__(self, **kwargs):
         self._all_models_by_name = MultiValuedDict()
         self._all_former_model_ids = set()
         self._callbacks = {}
+        self._event_callbacks = {}
         self._message_callbacks = {}
         self._session_destroyed_callbacks = set()
         self._session_callbacks = set()
@@ -353,6 +354,9 @@ def apply_json_event(self, json):
             for model in subscribed:
                 model._trigger_event(event)
 
+        for cb in self._event_callbacks.get(event.event_name, []):
+            cb(event)
+
     def apply_json_patch(self, patch, setter=None):
         ''' Apply a JSON patch object and process any resulting events.
 
@@ -686,6 +690,26 @@ def _trigger_on_message(self, msg_type: str, msg_data: Any) -> None:
             for cb in message_callbacks:
                 cb(msg_data)
 
+    def on_event(self, event, *callbacks):
+        ''' Provide callbacks to invoke if a bokeh event is received.
+
+        '''
+        if not isinstance(event, str) and issubclass(event, Event):
+            event = event.event_name
+
+        if not issubclass(_CONCRETE_EVENT_CLASSES[event], DocumentEvent):
+            raise ValueError("Document.on_event may only be used to subscribe "
+                             "to events of type DocumentEvent. To subscribe "
+                             "to a ModelEvent use the Model.on_event method.")
+
+        for callback in callbacks:
+            _check_callback(callback, ('event',), what='Event callback')
+
+        if event not in self._event_callbacks:
+            self._event_callbacks[event] = [cb for cb in callbacks]
+        else:
+            self._event_callbacks[event].extend(callbacks)
+
     def on_change(self, *callbacks):
         ''' Provide callbacks to invoke if the document or any Model reachable
         from its roots changes.
diff --git a/bokeh/events.py b/bokeh/events.py
--- a/bokeh/events.py
+++ b/bokeh/events.py
@@ -121,18 +121,6 @@ def __init_subclass__(cls):
         if hasattr(cls, "event_name"):
             _CONCRETE_EVENT_CLASSES[cls.event_name] = cls
 
-    def __init__(self, model):
-        ''' Create a new base event.
-
-        Args:
-
-            model (Model) : a Bokeh model to register event callbacks on
-
-        '''
-        self._model_id = None
-        if model is not None:
-            self._model_id = model.id
-
     @classmethod
     def decode_json(cls, dct):
         ''' Custom JSON decoder for Events.
@@ -168,11 +156,51 @@ def decode_json(cls, dct):
 
         event_values = dct['event_values']
         model_id = event_values.pop('model', {"id": None})["id"]
-        event = _CONCRETE_EVENT_CLASSES[event_name](model=None, **event_values)
-        event._model_id = model_id
+        event_cls = _CONCRETE_EVENT_CLASSES[event_name]
+        if issubclass(event_cls, ModelEvent):
+            event = event_cls(model=None, **event_values)
+            event._model_id = model_id
+        else:
+            event = event_cls(**event_values)
         return event
 
-class ButtonClick(Event):
+
+class DocumentEvent(Event):
+    ''' Base class for all Bokeh Document events.
+
+    This base class is not typically useful to instantiate on its own.
+
+    '''
+
+
+class DocumentReady(DocumentEvent):
+    ''' Announce when a Document is fully idle.
+
+    '''
+    event_name = 'document_ready'
+
+
+class ModelEvent(Event):
+    ''' Base class for all Bokeh Model events.
+
+    This base class is not typically useful to instantiate on its own.
+
+    '''
+
+    def __init__(self, model):
+        ''' Create a new base event.
+
+        Args:
+
+            model (Model) : a Bokeh model to register event callbacks on
+
+        '''
+        self._model_id = None
+        if model is not None:
+            self._model_id = model.id
+
+
+class ButtonClick(ModelEvent):
     ''' Announce a button click event on a Bokeh button widget.
 
     '''
@@ -185,7 +213,7 @@ def __init__(self, model):
             raise ValueError(msg.format(clsname=self.__class__.__name__))
         super().__init__(model=model)
 
-class MenuItemClick(Event):
+class MenuItemClick(ModelEvent):
     ''' Announce a button click event on a Bokeh menu item.
 
     '''
@@ -195,7 +223,7 @@ def __init__(self, model, item=None):
         self.item = item
         super().__init__(model=model)
 
-class PlotEvent(Event):
+class PlotEvent(ModelEvent):
     ''' The base class for all events applicable to Plot models.
 
     '''


Test patch:
diff --git a/bokehjs/test/unit/document/document.ts b/bokehjs/test/unit/document/document.ts
--- a/bokehjs/test/unit/document/document.ts
+++ b/bokehjs/test/unit/document/document.ts
@@ -432,6 +432,28 @@ describe("Document", () => {
     expect(d._all_models.size).to.be.equal(3)
   })
 
+  it("can notify on ready", () => {
+    const doc = new Document()
+
+    let signals = 0
+    doc.idle.connect(() => signals += 1)
+
+    const events: ev.DocumentEvent[] = []
+    doc.on_change((event) => events.push(event))
+
+    const root = new SomeModelWithChildren()
+    doc.add_root(root)
+    doc.notify_idle(root)
+
+    expect(signals).to.be.equal(1)
+    expect(events.length).to.be.equal(2) // [RootAdded, MessageSent]
+
+    const [, event] = events
+    assert(event instanceof ev.MessageSentEvent)
+    expect(event.msg_type).to.be.equal("bokeh_event")
+    expect(event.msg_data).to.be.equal({event_name: "document_ready", event_values: {}})
+  })
+
   it("can notify on changes", () => {
     const d = new Document()
     expect(d.roots().length).to.be.equal(0)
diff --git a/tests/unit/bokeh/test_events.py b/tests/unit/bokeh/test_events.py
--- a/tests/unit/bokeh/test_events.py
+++ b/tests/unit/bokeh/test_events.py
@@ -51,9 +51,13 @@ def test_event_metaclass() -> None:
 def test_common_decode_json() -> None:
     for event_name, event_cls in events._CONCRETE_EVENT_CLASSES.items():
         if event_name is None: continue # Skip abstract base class
-        event = events.Event.decode_json({'event_name':event_cls.event_name,
-                                          'event_values':{'model': {'id': 'test-model-id'}}})
-        assert event._model_id == 'test-model-id'
+        event = events.Event.decode_json({
+            'event_name': event_cls.event_name,
+            'event_values': {'model': {'id': 'test-model-id'}},
+        })
+        assert isinstance(event, events.Event)
+        if isinstance(event, events.ModelEvent):
+            assert event._model_id == 'test-model-id'
 
 def test_pointevent_subclass_decode_json() -> None:
     event_values = dict(model_id='test-model-id', sx=3, sy=-2, x=10, y=100)
@@ -104,17 +108,17 @@ def test_pinchevent_decode_json() -> None:
 
 def test_event_constructor_button() -> None:
     model = Button()
-    event = events.Event(model)
+    event = events.ModelEvent(model)
     assert event._model_id == model.id
 
 def test_event_constructor_div() -> None:
     model = Div()
-    event = events.Event(model)
+    event = events.ModelEvent(model)
     assert event._model_id == model.id
 
 def test_event_constructor_plot() -> None:
     model = Plot()
-    event = events.Event(model)
+    event = events.ModelEvent(model)
     assert event._model_id == model.id
 
 def test_buttonclick_constructor_button() -> None: