How to Do Partial Rerun for This Dynamic Form Using Fragments?

I’m designing a form which allows users to dynamically add inputs.
The goal is to prevent full script reruns when users add new inputs and enter input values by using the new Streamlit fragments decorator.
However, under some conditions, the full script reruns when the user enters an input value.

This script demonstrates a simplified version of the form.

Each button click adds an expander.
Within the expander, the user enters an input value.
There is a popover and checkbox to allow the user to remove the expander.

To cause the undesired full script rerun:

  1. Add two or more food items
  2. Enter an input value in any input following the first food input
    –or–
  3. Use the remove popover on any input following the first food input

The issue appears to only occur when operating on a new input after creating multiple inputs.

I suspect it is related to the limitation that fragments cannot call other fragments.

I wonder if there is a way to structure the code to avoid this issue?

Python v3.10
Streamlit v1.33
GitHub repo: https://github.com/TerraX3000/streamlit_fragment_test
Runs locally

import streamlit as st
import random
import string
from typing import List


def add_food_key_to_session(key):
    print("Running add_food_key_to_session()", key)
    food_input_keys: List = st.session_state["food_input_keys"]
    food_input_keys.append(key)
    st.session_state["food_input_keys"] = food_input_keys


def remove_food_input_key(key):
    print("Running remove_food_input_key()", key)
    food_input_keys: List = st.session_state["food_input_keys"]
    food_input_keys.remove(key)
    st.session_state["food_input_keys"] = food_input_keys


def food_input(key):
    print("Running food_input()", key)
    food = st.text_input("Favorite Food", key=key)
    if food:
        st.write(food)


@st.experimental_fragment()
def add_edit_remove_food_container(key):
    print("Running add_edit_remove_food_container()", key)
    container = st.empty()
    with container.expander("Food Details", expanded=True):
        food_input(key)
        remove_food_popover = st.popover("Remove this food?")
        is_remove_food = remove_food_popover.checkbox(
            "Confirm", key=f"remove-{key}")

        if is_remove_food:
            remove_food_input_key(key)
            container.empty()


def render_food_containers():
    print("Running render_food_containers()")
    food_input_keys: List = st.session_state["food_input_keys"]
    for key in food_input_keys:
        add_edit_remove_food_container(key)


@st.experimental_fragment()
def manage_food_button_and_containers():
    print("Running manage_food_button_and_containers()")
    if st.button("Add Favorite Food"):
        key = get_new_key()
        add_food_key_to_session("food-" + key)
    render_food_containers()
    print("Session State:", st.session_state, "\n")


def get_new_key(k=4):
    key = "".join(random.choices(
        string.ascii_lowercase + string.digits, k=k))
    return key


def run_full_script():
    print("Running run_full_script")
    if "script_run_counter" not in st.session_state:
        st.session_state["script_run_counter"] = 1
    else:
        st.session_state["script_run_counter"] += 1
    if "food_input_keys" not in st.session_state:
        st.session_state["food_input_keys"] = []

    stats_container = st.container()
    st.divider()

    if st.button("Reset Counter"):
        st.session_state["script_run_counter"] = 1
    if st.button("Reset form"):
        for key in st.session_state.keys():
            del st.session_state[key]
        st.session_state["script_run_counter"] = 1
        st.session_state["food_input_keys"] = []

    manage_food_button_and_containers()
    with stats_container:
        if st.session_state["script_run_counter"] > 1:
            st.title(":red[Full Script Run -- You Lose!]")
            print("\n", "Full Script Run -- You Lose!", "\n")
        st.write(
            f'#### Full Script Run Counter: {st.session_state["script_run_counter"]}')
        st.write(
            "Goal: allow users to dynamically add/edit/delete food inputs without a full script rerun.")
    st.divider()
    with st.expander("Session State", expanded=False):
        st.json(st.session_state)


run_full_script()

2 Likes

The main function run_full_script is huge, it calls a fragment-decorated function and there are other functions that are not fragment-decorated. So whenever there are changes in the states, expects a full rerun to trigger.

TL;DR: Streamlit version 1.37 supports nested fragments and that update appears to resolve the issue I was having.

Updated code below replaces st.experimental_fragment with st.fragment.

I am unable to cause a full script rerun doing any of the actions I listed in my original post.

This is really cool!!!

import streamlit as st
import random
import string
from typing import List


def add_food_key_to_session(key):
    print("Running add_food_key_to_session()", key)
    food_input_keys: List = st.session_state["food_input_keys"]
    food_input_keys.append(key)
    st.session_state["food_input_keys"] = food_input_keys


def remove_food_input_key(key):
    print("Running remove_food_input_key()", key)
    food_input_keys: List = st.session_state["food_input_keys"]
    food_input_keys.remove(key)
    st.session_state["food_input_keys"] = food_input_keys


def food_input(key):
    print("Running food_input()", key)
    food = st.text_input("Favorite Food", key=key)
    if food:
        st.write(food)


@st.fragment()
def add_edit_remove_food_container(key):
    print("Running add_edit_remove_food_container()", key)
    container = st.empty()
    with container.expander("Food Details", expanded=True):
        food_input(key)
        remove_food_popover = st.popover("Remove this food?")
        is_remove_food = remove_food_popover.checkbox(
            "Confirm", key=f"remove-{key}")

        if is_remove_food:
            remove_food_input_key(key)
            container.empty()


def render_food_containers():
    print("Running render_food_containers()")
    food_input_keys: List = st.session_state["food_input_keys"]
    for key in food_input_keys:
        add_edit_remove_food_container(key)


@st.fragment()
def manage_food_button_and_containers():
    print("Running manage_food_button_and_containers()")
    if st.button("Add Favorite Food"):
        key = get_new_key()
        add_food_key_to_session("food-" + key)
    render_food_containers()
    print("Session State:", st.session_state, "\n")


def get_new_key(k=4):
    key = "".join(random.choices(
        string.ascii_lowercase + string.digits, k=k))
    return key


def run_full_script():
    print("Running run_full_script")
    if "script_run_counter" not in st.session_state:
        st.session_state["script_run_counter"] = 1
    else:
        st.session_state["script_run_counter"] += 1
    if "food_input_keys" not in st.session_state:
        st.session_state["food_input_keys"] = []

    stats_container = st.container()
    st.divider()

    if st.button("Reset Counter"):
        st.session_state["script_run_counter"] = 1
    if st.button("Reset form"):
        for key in st.session_state.keys():
            del st.session_state[key]
        st.session_state["script_run_counter"] = 1
        st.session_state["food_input_keys"] = []

    manage_food_button_and_containers()
    with stats_container:
        if st.session_state["script_run_counter"] > 1:
            st.title(":red[Full Script Run -- You Lose!]")
            print("\n", "Full Script Run -- You Lose!", "\n")
        st.write(
            f'#### Full Script Run Counter: {st.session_state["script_run_counter"]}')
        st.write(
            "Goal: allow users to dynamically add/edit/delete food inputs without a full script rerun.")
    st.divider()
    with st.expander("Session State", expanded=False):
        st.json(st.session_state)


run_full_script()

Update: Although Streamlit v.137 allows nested fragments, I discovered a bug where Streamlit will incorrectly rerender an element previously created and removed with st.empty.

Steps to Reproduce:

  1. Click Add Favorite Food
  2. Type pizza
  3. Click Remove this food and confirm
  4. Click Add Favorite Food
  5. Type ice cream

During step 5, ice cream will be replaced with pizza and an error will be displayed.

To aid troubleshooting, I’ve added the input key value to the form. Note that the key value reverts to the initial key value when the error occurs. This suggests that Streamlit is maintaining the previous container in memory and redrawing it.

Error When Removing Container

Updated code:

import streamlit as st
import random
import string
from typing import List


def add_food_key_to_session(key):
    print("Running add_food_key_to_session()", key)
    food_input_keys: List = st.session_state["food_input_keys"]
    food_input_keys.append(key)
    st.session_state["food_input_keys"] = food_input_keys


def remove_food_input_key(key):
    print("Running remove_food_input_key()", key)
    food_input_keys: List = st.session_state["food_input_keys"]
    print("Before Remove: Session State:", st.session_state, "\n")
    food_input_keys.remove(key)
    st.session_state["food_input_keys"] = food_input_keys
    print("After Remove: Session State:", st.session_state, "\n\n\n\n")


def food_input(key):
    print("Running food_input()", key)
    food = st.text_input(f"Favorite Food ({key})", key=key)
    if food:
        st.write(food)


@st.fragment()
def add_edit_remove_food_container(key):
    print("Running add_edit_remove_food_container()", key)
    container = st.empty()
    with container.expander("Food Details", expanded=True):
        food_input(key)
        remove_food_popover = st.popover(
            f"Remove this food? ({key})")
        is_remove_food = remove_food_popover.checkbox(
            "Confirm", key=f"remove-{key}")

        if is_remove_food:
            remove_food_input_key(key)
            container.empty()


def render_food_containers():
    print("Running render_food_containers()")
    food_input_keys: List = st.session_state["food_input_keys"]
    for key in food_input_keys:
        add_edit_remove_food_container(key)


@st.fragment()
def manage_food_button_and_containers():
    print("Running manage_food_button_and_containers()")
    if st.button("Add Favorite Food"):
        key = get_new_key()
        add_food_key_to_session("food-" + key)
    render_food_containers()
    print("Session State:", st.session_state, "\n\n\n\n")


def get_new_key(k=4):
    key = "".join(random.choices(
        string.ascii_lowercase + string.digits, k=k))
    return key


def run_full_script():
    print("Running run_full_script")
    if "script_run_counter" not in st.session_state:
        st.session_state["script_run_counter"] = 1
    else:
        st.session_state["script_run_counter"] += 1
    if "food_input_keys" not in st.session_state:
        st.session_state["food_input_keys"] = []

    stats_container = st.container()
    st.divider()

    if st.button("Reset Counter"):
        st.session_state["script_run_counter"] = 1
    if st.button("Reset form"):
        for key in st.session_state.keys():
            del st.session_state[key]
        st.session_state["script_run_counter"] = 1
        st.session_state["food_input_keys"] = []

    manage_food_button_and_containers()
    with stats_container:
        if st.session_state["script_run_counter"] > 1:
            st.title(":red[Full Script Run -- You Lose!]")
            print("\n", "Full Script Run -- You Lose!", "\n")
        st.write(
            f'#### Full Script Run Counter: {st.session_state["script_run_counter"]}')
        st.write(
            "Goal: allow users to dynamically add/edit/delete food inputs without a full script rerun.")
    st.divider()
    with st.expander("Session State", expanded=False):
        st.json(st.session_state)


run_full_script()

Discovered GitHub issue opened for this issue here:

It appears the bug was introduced in v1.37.

1 Like

The bug was resolved and fixed in Streamlit v.1.38. The script now runs without error and we call this issue Solved! Thanks, Streamlit Team!

This topic was automatically closed 2 days after the last reply. New replies are no longer allowed.