Identifying Inconsistent Page Reruns with Buttons

This is way too long for a reasonable question. For a quick summary: two functions exist, very extremely similar to one another. One causes entire page to reload, the other doesn’t. Details on the code causing the behavioural difference in a reply below.


I’m adding a debug menu to my app to manipulate database tables for testing, and am experiencing some inconsistent behaviour when interacting with buttons that call functions that write to the database.

For context, I’m using pymongo, and have 2 tables, A and B.
Table A is pretty simple, 4 rows with 2 columns, “name” and “score”.
Table B is a bit more complex, and stores records of changes to those scores, so each row has “name”, “timestamp”, and “score_delta”.

My debug menu is laid out like so:

  • container:
    • container:
      • col > button: purge_scores (deletes all scores
      • col > button: populate_scores (inserts 4 known names with scores of 0)
      • col > button: randomize_scores (randomizes all score values 1-999)
      • table (session_state(“scores”))
    • container:
      • col > button: purge_records (deletes all records)
      • col > button: random_record (insert 1 randomly generated record)
      • table (session_state(“records”))

Session states values are loaded at run (if X not in session_state), and all of the functions called by the above functions do their thing to the table, the clear the cache on get_scores and get_records functions, then update session_state = get_scores/get_records.

The problem is this:
When interacting with the scores section, behaviour is mostly as expected, if I click “purge”, the table refreshes, now empty, but the page doesn’t reload. I can then click “populate” and the table contents will update and be shown, with the page not reloading. Same with random. If I switch between buttons one after the other the same behaviour occurs, with no unexpected reloading.

If I then click purge_records, the entire page reloads and the now empty table shows. If I then click generate_random, the entire page reloads again and the new record is shown. This reload occurs whenever I switch between the two record buttons, but not if I press the same button multiple times.

After interacting with the records area, going back and clicking any of the scores buttons refreshes the page entirely.

With that long winded explanation out of the way, my question is this: how do I avoid this entire page refresh occurring?
I’m using cache_data with a ttl for the functions that get scores, and rendering the tables using data in session state, making sure to enforce a cache clear when data changes, however the mismatch in behaviour has me stumped.

After some extensive experimenting, I’ve discovered something very strange that I think is the cause of the difference.

In the functions that exhibit the behaviour I would like (click button > do something > no refresh), I’m running my get_scores function at the start, doing something to that data in the db, then calling get_scores.clear(), then session_state[“scores”] = get_scores().

The functions that do cause a full reload don’t call their respective “get” function first. For example, this code runs nicely, with the page not reloading in its entirety:

@classmethod
def purge_scores(cls):
    collection = cls.get_collection("scores")
    _scores = cls.get_scores()

    collection.delete_many({"team": {"$in": list(Teams.__members__)}})

    cls.get_scores.clear()
    st.session_state["scores"] = cls.get_scores()

However, this code will cause the page to reload entirely:

@classmethod
def purge_scores(cls):
    collection = cls.get_collection("scores")
    ### _scores = cls.get_scores()

    collection.delete_many({"team": {"$in": list(Teams.__members__)}})

    cls.get_scores.clear()
    st.session_state["scores"] = cls.get_scores()

I’m not entirely sure why this is happening, however, given both versions are clearing their relevant cache and then running it again.

Can you share more of your code or a link to your repo? What is the full class definition and can you share a minimal, executable code snippet to show the behavior you’re describing?

Absolutely, although if I share the repo the code won’t run unless you have a mongodb instance and secrets configured the same way.

Link to repo at the relevant file: claan_chaaos/utils/debug.py at migration · whompratt/claan_chaaos · GitHub
(ignore the name please, I inherited this project and it’s a semi-work thing)

The buttons in that file (debug.py) point to functions in the quests.py and scores.py files (same dir).

Very quick overview of things that might be relevant:

  • main checks for a debug flag in secrets, and if true, loads debug menu
  • debug menu uses same functions as what will be used when users submit quests and activities (obvs without the ability for a user to randomize all the scores…)

If you look in scores.py or quest.py, you’ll see some of the functions have a line like _scores = cls.get_scores() which is then unused in the rest of the function. This is there so the page doesn’t rerun entirely. Removing this line causes the entire page to reload when that function is called.

I’m a bit confused how not reloading is the expected behavior. The buttons aren’t in fragments, so if you click them, the whole page should reload. When you say “the table refreshes…but the page doesn’t reload,” do you mean the database updates but nothing visible on the page changes? And is this all on your main page (Claan-Portal.py)?

In general, if you want to avoid having your entire page reload when you click a button, you should place the button inside a fragment.

I don’t really know how to explain it, but with the code as it is on that branch (with the _scores= line, then clicking “purge”, “populate”, “randomize”, etc. doesn’t cause the page to reload. At the bottom of each container where there’s an st.table() call, the data being shown there updates to reflect the change, but the page itself doesn’t rerun.

Removing the early call to get_scores() causes the page to rerun in full, instead.

There is a noticeable difference between the two behaviours as well. When the page reruns, all the content disappears first, then loads in piecemeal and shuffles around until settled.
With the _scores line, this doesn’t happen. Nothing moves or changes except the contents of the st.table.

(I know these are janky, sorry, I know nothing about video)
Example of ideal behaviour with no re-running, notice the “debug mode” at the top never disappears.
Claans Debug Smooth

Other behaviour:
Claans Debug Janky

note that randomize requires a get_scores call to function, so I couldn’t demo that behaviour on that func

I haven’t had time to chase this down in complete detail, but I can identify the kind of thing that causes that visual artifact:

In hand-wavy terms: The way Streamlit works with each rerun is to redraw everything on the page. As long as Streamlit encounters the same elements in the same order during a redraw, it won’t actively destroy the later elements. Instead, it just verifies as it goes along that each element still belongs there. However, if you have a command that transiently shows a spinner or status message (etc) on one rerun, but not the next, you’ll get this flash of destruction as the page redraws. As soon as Streamlit encounters something different, it drops what follows. You usually get around this with containers to make sure the transient element is written into a container. With a persistent container around the transient element,
Streamlit thinks it “looks the same” from outside the container. Only the contents within the container (that holds the transient element directly) would be subject to total destruction and re-rendering. I can try to put together some toy examples to illustrate, if you need me to, but does that give you a hint as to what might be buried somewhere within cls.get_scores() that Streamit is expecting, to see all the elements in the rerun as “the same?”

1 Like