Repository: streamlit/streamlit

Issue:
setIn Error with `st.empty`
### Summary

Streamlit produces a `setIn` error when using `st.empty`

### Steps to reproduce

From: https://discuss.streamlit.io/t/bad-message-format-setin-cannot-be-called-on-an-elementnode/15618

Code snippet:

```python
import streamlit as st

placeholder = st.empty()
column, = placeholder.columns(1)

if column.button("Click me"):
    placeholder.empty()
```

Or even more succinctly:

```python
import streamlit as st

placeholder = st.empty()
placeholder.columns(1)
placeholder.empty()
```

1. Run script
2. Press the button

**Expected behavior:**

The button should disappear, and there shouldn't be an error.

**Actual behavior:**

The button disappears, but there's an error.

### Is this a regression?

No

### Debug info

- Streamlit version: 0.85.0
- Python version: 3.8.6
- Using Conda? PipEnv? PyEnv? Pex? None
- OS version: Mac OS X
- Browser version: Brave Browser



Discussion:
I think we are hitting a race condition where we attempt to update an empty element (once holding a column) with a column again that we are calling `setIn` on the empty element and throwing an error. I made a code change that makes it not error (essentially ignoring the exception if it's an empty component, but I think it’s a poor solution and we need to think of a better solution.
Might be related to https://github.com/streamlit/streamlit/issues/3243 and https://github.com/streamlit/streamlit/issues/3223

Resolving patch:
diff --git a/lib/streamlit/report_queue.py b/lib/streamlit/report_queue.py
--- a/lib/streamlit/report_queue.py
+++ b/lib/streamlit/report_queue.py
@@ -19,7 +19,9 @@
 
 import copy
 import threading
+from typing import Optional, List, Dict, Any, Tuple, Iterator
 
+from streamlit.proto.Delta_pb2 import Delta
 from streamlit.proto.ForwardMsg_pb2 import ForwardMsg
 
 from streamlit.logger import get_logger
@@ -28,24 +30,24 @@
 LOGGER = get_logger(__name__)
 
 
-class ReportQueue(object):
+class ReportQueue:
     """Thread-safe queue that smartly accumulates the report's messages."""
 
     def __init__(self):
-        """Constructor."""
         self._lock = threading.Lock()
+        self._queue: List[ForwardMsg] = []
 
-        with self._lock:
-            self._queue = []
-
-            # Map: (delta_path, msg.metadata.delta_id) -> _queue.indexof(msg),
-            # where delta_path = (container, parent block path as a string)
-            self._delta_index_map = dict()
+        # A mapping of (delta_path -> _queue.indexof(msg)) for each
+        # Delta message in the queue. We use this for coalescing
+        # redundant outgoing Deltas (where a newer Delta supercedes
+        # an older Delta, with the same delta_path, that's still in the
+        # queue).
+        self._delta_index_map: Dict[Tuple[int, ...], int] = dict()
 
     def __repr__(self) -> str:
         return util.repr_(self)
 
-    def get_debug(self):
+    def get_debug(self) -> Dict[str, Any]:
         from google.protobuf.json_format import MessageToDict
 
         return {
@@ -53,94 +55,96 @@ def get_debug(self):
             "ids": list(self._delta_index_map.keys()),
         }
 
-    def __iter__(self):
+    def __iter__(self) -> Iterator[ForwardMsg]:
         return iter(self._queue)
 
-    def is_empty(self):
+    def is_empty(self) -> bool:
         return len(self._queue) == 0
 
-    def get_initial_msg(self):
+    def get_initial_msg(self) -> Optional[ForwardMsg]:
         if len(self._queue) > 0:
             return self._queue[0]
         return None
 
-    def enqueue(self, msg):
-        """Add message into queue, possibly composing it with another message.
-
-        Parameters
-        ----------
-        msg : ForwardMsg
-        """
+    def enqueue(self, msg: ForwardMsg) -> None:
+        """Add message into queue, possibly composing it with another message."""
         with self._lock:
-            # Optimize only if it's a delta message
             if not msg.HasField("delta"):
                 self._queue.append(msg)
-            else:
-                # Deltas are uniquely identified by their delta_path.
-                delta_key = tuple(msg.metadata.delta_path)
-
-                if (
-                    delta_key in self._delta_index_map
-                    # This delta combination logic is "legacy" only,
-                    # and will be removed when that option is gone.
-                    and not msg.delta.HasField("arrow_add_rows")
-                ):
-                    # Combine the previous message into the new message.
-                    index = self._delta_index_map[delta_key]
-                    old_msg = self._queue[index]
-                    composed_delta = compose_deltas(old_msg.delta, msg.delta)
+                return
+
+            # If there's a Delta message with the same delta_path already in
+            # the queue - meaning that it refers to the same location in
+            # the report - we attempt to combine this new Delta into the old
+            # one. This is an optimization that prevents redundant Deltas
+            # from being sent to the frontend.
+            delta_key = tuple(msg.metadata.delta_path)
+            if delta_key in self._delta_index_map:
+                index = self._delta_index_map[delta_key]
+                old_msg = self._queue[index]
+                composed_delta = _maybe_compose_deltas(old_msg.delta, msg.delta)
+                if composed_delta is not None:
                     new_msg = ForwardMsg()
                     new_msg.delta.CopyFrom(composed_delta)
                     new_msg.metadata.CopyFrom(msg.metadata)
                     self._queue[index] = new_msg
-                else:
-                    # Append this message to the queue, and store its index
-                    # for future combining.
-                    self._delta_index_map[delta_key] = len(self._queue)
-                    self._queue.append(msg)
+                    return
 
-    def clone(self):
-        """Return the elements of this ReportQueue as a collections.deque."""
-        r = ReportQueue()
-
-        with self._lock:
-            r._queue = list(self._queue)
-            r._delta_index_map = dict(self._delta_index_map)
+            # No composition occured. Append this message to the queue, and
+            # store its index for potential future composition.
+            self._delta_index_map[delta_key] = len(self._queue)
+            self._queue.append(msg)
 
-        return r
-
-    def _clear(self):
+    def _clear(self) -> None:
         self._queue = []
         self._delta_index_map = dict()
 
-    def clear(self):
+    def clear(self) -> None:
         """Clear this queue."""
         with self._lock:
             self._clear()
 
-    def flush(self):
+    def flush(self) -> List[ForwardMsg]:
         with self._lock:
             queue = self._queue
             self._clear()
         return queue
 
 
-def compose_deltas(old_delta, new_delta):
+def _maybe_compose_deltas(old_delta: Delta, new_delta: Delta) -> Optional[Delta]:
     """Combines new_delta onto old_delta if possible.
 
-    If combination takes place, returns old_delta, since it has the combined
-    data. If not, returns new_delta.
+    If the combination takes place, the function returns a new Delta that
+    should replace old_delta in the queue.
 
+    If the new_delta is incompatible with old_delta, the function returns None.
+    In this case, the new_delta should just be appended to the queue as normal.
     """
-    new_delta_type = new_delta.WhichOneof("type")
+    old_delta_type = old_delta.WhichOneof("type")
+    if old_delta_type == "add_block":
+        # We never replace add_block deltas, because blocks can have
+        # other dependent deltas later in the queue. For example:
+        #
+        #   placeholder = st.empty()
+        #   placeholder.columns(1)
+        #   placeholder.empty()
+        #
+        # The call to "placeholder.columns(1)" creates two blocks, a parent
+        # container with delta_path (0, 0), and a column child with
+        # delta_path (0, 0, 0). If the final "placeholder.empty()" Delta
+        # is composed with the parent container Delta, the frontend will
+        # throw an error when it tries to add that column child to what is
+        # now just an element, and not a block.
+        return None
 
+    new_delta_type = new_delta.WhichOneof("type")
     if new_delta_type == "new_element":
         return new_delta
 
-    elif new_delta_type == "add_block":
+    if new_delta_type == "add_block":
         return new_delta
 
-    elif new_delta_type == "add_rows":
+    if new_delta_type == "add_rows":
         import streamlit.elements.legacy_data_frame as data_frame
 
         # We should make data_frame.add_rows *not* mutate any of the
@@ -150,6 +154,7 @@ def compose_deltas(old_delta, new_delta):
         data_frame.add_rows(composed_delta, new_delta, name=new_delta.add_rows.name)
         return composed_delta
 
-    LOGGER.error("Old delta: %s;\nNew delta: %s;", old_delta, new_delta)
+    # We deliberately don't handle the "arrow_add_rows" delta type. With Arrow,
+    # `add_rows` is a frontend-only operation.
 
-    raise NotImplementedError("Need to implement the compose code.")
+    return None


Test patch:
diff --git a/lib/tests/streamlit/report_queue_test.py b/lib/tests/streamlit/report_queue_test.py
--- a/lib/tests/streamlit/report_queue_test.py
+++ b/lib/tests/streamlit/report_queue_test.py
@@ -18,6 +18,8 @@
 import unittest
 from typing import Tuple
 
+from parameterized import parameterized
+
 from streamlit import RootContainer
 from streamlit.cursor import make_delta_path
 from streamlit.report_queue import ReportQueue
@@ -39,6 +41,10 @@
 TEXT_DELTA_MSG2.delta.new_element.text.body = "text2"
 TEXT_DELTA_MSG2.metadata.delta_path[:] = make_delta_path(RootContainer.MAIN, (), 0)
 
+ADD_BLOCK_MSG = ForwardMsg()
+ADD_BLOCK_MSG.delta.add_block.horizontal.total_weight = 1
+ADD_BLOCK_MSG.metadata.delta_path[:] = make_delta_path(RootContainer.MAIN, (), 0)
+
 DF_DELTA_MSG = ForwardMsg()
 legacy_data_frame.marshall_data_frame(
     {"col1": [0, 1, 2], "col2": [10, 11, 12]}, DF_DELTA_MSG.delta.new_element.data_frame
@@ -137,6 +143,28 @@ def test_replace_element(self):
         )
         self.assertEqual(queue[1].delta.new_element.text.body, "text2")
 
+    @parameterized.expand([(TEXT_DELTA_MSG1,), (ADD_BLOCK_MSG,)])
+    def test_dont_replace_block(self, other_msg: ForwardMsg):
+        """add_block deltas should never be replaced/composed because they can
+        have dependent deltas later in the queue."""
+        rq = ReportQueue()
+        self.assertTrue(rq.is_empty())
+
+        ADD_BLOCK_MSG.metadata.delta_path[:] = make_delta_path(
+            RootContainer.MAIN, (), 0
+        )
+
+        other_msg.metadata.delta_path[:] = make_delta_path(RootContainer.MAIN, (), 0)
+
+        # Delta messages should not replace `add_block` deltas with the
+        # same delta_path.
+        rq.enqueue(ADD_BLOCK_MSG)
+        rq.enqueue(other_msg)
+        queue = rq.flush()
+        self.assertEqual(len(queue), 2)
+        self.assertEqual(queue[0], ADD_BLOCK_MSG)
+        self.assertEqual(queue[1], other_msg)
+
     def test_simple_add_rows(self):
         rq = ReportQueue()
         self.assertTrue(rq.is_empty())