Repository: streamlit/streamlit

Issue:
file_uploader with beta_expander
### Summary

In a `beta_expander` a `file_uploader` widget's uploaded file information is disappearing from front-end after closed and re-expanded the expander


### Steps to reproduce

Code snippet:

```
import streamlit as st

with st.beta_expander('Config file', expanded=True):
  if st.file_uploader(''):
    print('file is uploaded')
```

1. Upload a file
2. Close the `beta_expander`
3. Open the `beta_expander`

**Expected behavior:**

The uploaded file information (file name, file size, and ability to remove attaching) is there

![image](https://user-images.githubusercontent.com/6457941/125744323-b1f517d9-6360-4d8f-a9b1-c9848985b127.png)

**Actual behavior:**

The uploaded file information is missing

![image](https://user-images.githubusercontent.com/6457941/125744358-a0235e69-7295-4322-b684-600774352432.png)

### Is this a regression?

probably no, an older version (0.76.0) streamlit neither works 

### Debug info

- Streamlit version: Streamlit, version 0.84.1
- Python version: Python 3.6.9
- No venv.
- OS version: Linux Mint 19.2 Tina
- Browser version: Mozilla Firefox 89.0.2 (64-bit)



Discussion:
Thanks for reporting this @schaumb! It turns out we're actually discussing how this should work from a product standpoint as a related bug (https://github.com/streamlit/streamlit/issues/3512) brought the behavior of widgets that are removed and redisplayed to our attention.

Exactly how we handle this is a bit tricky as there are cases where we want the opposite behavior, but we're hoping to have a solution that can address both use-cases released soon.
While the linked issue is about a python run widget reappear event, here only a front-end modification happened (expander open and close ; beta expander "value" is not synchronized back to back-end, so no rerun happened).

So I am not really understand how related the issue.
Ah, thanks for the clarification. This was my mistake -- I thought that the two issues looked similar so incorrectly assumed that they had the same root cause. You're right that these aren't related, and I'll investigate this bug this week or next if I have the bandwidth. 
Quick update: I took a bit of time to look into this, and it may be somewhat tricky to fix as when a file_uploader is hidden by the expander, we currently lose some of the file data that we'll need to properly re-render the file_uploader when it comes back into view.

Of course, the answer is to just store this data somewhere, but we'll want to do a bit of thinking to figure out where that best place is. I'll revisit this early next week when I'm able to ask some questions to people that are more familiar with this code 

Resolving patch:
diff --git a/lib/streamlit/elements/file_uploader.py b/lib/streamlit/elements/file_uploader.py
--- a/lib/streamlit/elements/file_uploader.py
+++ b/lib/streamlit/elements/file_uploader.py
@@ -27,7 +27,10 @@
     WidgetKwargs,
 )
 from .form import current_form_id
-from ..proto.Common_pb2 import SInt64Array
+from ..proto.Common_pb2 import (
+    FileUploaderState as FileUploaderStateProto,
+    UploadedFileInfo as UploadedFileInfoProto,
+)
 from ..uploaded_file_manager import UploadedFile, UploadedFileRec
 from .utils import check_callback_rules, check_session_state_rules
 
@@ -154,7 +157,7 @@ def file_uploader(
             file_uploader_proto.help = dedent(help)
 
         def deserialize_file_uploader(
-            ui_value: List[int], widget_id: str
+            ui_value: Optional[FileUploaderStateProto], widget_id: str
         ) -> SomeUploadedFiles:
             file_recs = self._get_file_recs(widget_id, ui_value)
             if len(file_recs) == 0:
@@ -166,23 +169,30 @@ def deserialize_file_uploader(
                 return_value = files if accept_multiple_files else files[0]
             return return_value
 
-        def serialize_file_uploader(files: SomeUploadedFiles) -> List[int]:
-            if not files:
-                return []
-            if isinstance(files, list):
-                ids = [f.id for f in files]
-            else:
-                ids = [files.id]
+        def serialize_file_uploader(files: SomeUploadedFiles) -> FileUploaderStateProto:
+            state_proto = FileUploaderStateProto()
 
             ctx = get_report_ctx()
             if ctx is None:
-                return []
+                return state_proto
 
             # ctx.uploaded_file_mgr._file_id_counter stores the id to use for
-            # the next uploaded file, so the current highest file id is the
+            # the *next* uploaded file, so the current highest file id is the
             # counter minus 1.
-            max_id = ctx.uploaded_file_mgr._file_id_counter - 1
-            return [max_id] + ids
+            state_proto.max_file_id = ctx.uploaded_file_mgr._file_id_counter - 1
+
+            if not files:
+                return state_proto
+            elif not isinstance(files, list):
+                files = [files]
+
+            for f in files:
+                file_info: UploadedFileInfoProto = state_proto.uploaded_file_info.add()
+                file_info.id = f.id
+                file_info.name = f.name
+                file_info.size = f.size
+
+            return state_proto
 
         # FileUploader's widget value is a list of file IDs
         # representing the current set of files that this uploader should
@@ -199,12 +209,11 @@ def serialize_file_uploader(files: SomeUploadedFiles) -> List[int]:
         )
 
         ctx = get_report_ctx()
-        serialized = serialize_file_uploader(widget_value)
-        if ctx is not None and len(serialized) != 0:
-            # The first number in the serialized widget_value list is the id
-            # of the most recently uploaded file.
-            newest_file_id = serialized[0]
-            active_file_ids = list(serialized[1:])
+        file_uploader_state = serialize_file_uploader(widget_value)
+        uploaded_file_info = file_uploader_state.uploaded_file_info
+        if ctx is not None and len(uploaded_file_info) != 0:
+            newest_file_id = file_uploader_state.max_file_id
+            active_file_ids = [f.id for f in uploaded_file_info]
 
             ctx.uploaded_file_mgr.remove_orphaned_files(
                 session_id=ctx.session_id,
@@ -218,7 +227,7 @@ def serialize_file_uploader(files: SomeUploadedFiles) -> List[int]:
 
     @staticmethod
     def _get_file_recs(
-        widget_id: str, widget_value: Optional[List[int]]
+        widget_id: str, widget_value: Optional[FileUploaderStateProto]
     ) -> List[UploadedFileRec]:
         if widget_value is None:
             return []
@@ -227,10 +236,11 @@ def _get_file_recs(
         if ctx is None:
             return []
 
-        if len(widget_value) == 0:
+        uploaded_file_info = widget_value.uploaded_file_info
+        if len(uploaded_file_info) == 0:
             return []
 
-        active_file_ids = list(widget_value[1:])
+        active_file_ids = [f.id for f in uploaded_file_info]
 
         # Grab the files that correspond to our active file IDs.
         return ctx.uploaded_file_mgr.get_files(
diff --git a/lib/streamlit/state/session_state.py b/lib/streamlit/state/session_state.py
--- a/lib/streamlit/state/session_state.py
+++ b/lib/streamlit/state/session_state.py
@@ -186,6 +186,8 @@ def get_serialized(
                         arr.data.extend(serialized)
                     elif field == "json_value":
                         setattr(widget, field, json.dumps(serialized))
+                    elif field == "file_uploader_state_value":
+                        widget.file_uploader_state_value.CopyFrom(serialized)
                     else:
                         setattr(widget, field, serialized)
                     return widget
diff --git a/lib/streamlit/state/widgets.py b/lib/streamlit/state/widgets.py
--- a/lib/streamlit/state/widgets.py
+++ b/lib/streamlit/state/widgets.py
@@ -181,7 +181,7 @@ def register_widget(
     "checkbox": "bool_value",
     "color_picker": "string_value",
     "date_input": "string_array_value",
-    "file_uploader": "int_array_value",
+    "file_uploader": "file_uploader_state_value",
     "multiselect": "int_array_value",
     "number_input": "double_value",
     "radio": "int_value",


Test patch:
diff --git a/frontend/src/components/widgets/FileUploader/FileUploader.test.tsx b/frontend/src/components/widgets/FileUploader/FileUploader.test.tsx
--- a/frontend/src/components/widgets/FileUploader/FileUploader.test.tsx
+++ b/frontend/src/components/widgets/FileUploader/FileUploader.test.tsx
@@ -20,7 +20,11 @@ import React from "react"
 import { FileError } from "react-dropzone"
 import { mount, shallow } from "src/lib/test_util"
 
-import { FileUploader as FileUploaderProto } from "src/autogen/proto"
+import {
+  FileUploader as FileUploaderProto,
+  FileUploaderState as FileUploaderStateProto,
+  UploadedFileInfo as UploadedFileInfoProto,
+} from "src/autogen/proto"
 import { WidgetStateManager } from "src/lib/WidgetStateManager"
 import { notUndefined } from "src/lib/utils"
 import FileDropzone from "./FileDropzone"
@@ -34,6 +38,22 @@ const createFile = (): File => {
   })
 }
 
+const buildFileUploaderStateProto = (
+  maxFileId: number,
+  uploadedFileIds: number[]
+): FileUploaderStateProto =>
+  new FileUploaderStateProto({
+    maxFileId,
+    uploadedFileInfo: uploadedFileIds.map(
+      fileId =>
+        new UploadedFileInfoProto({
+          id: fileId,
+          name: "filename.txt",
+          size: 15,
+        })
+    ),
+  })
+
 const INVALID_TYPE_ERROR: FileError = {
   message: "error message",
   code: "file-invalid-type",
@@ -109,6 +129,32 @@ describe("FileUploader widget", () => {
     expect(instance.status).toBe("ready")
   })
 
+  it("sets initial value properly if non-empty", () => {
+    const props = getProps()
+    const { element, widgetMgr } = props
+
+    widgetMgr.setFileUploaderStateValue(
+      element,
+      buildFileUploaderStateProto(INITIAL_SERVER_FILE_ID, [
+        INITIAL_SERVER_FILE_ID,
+      ]),
+      { fromUi: false }
+    )
+
+    const wrapper = shallow(<FileUploader {...props} />)
+    expect(wrapper.state()).toEqual({
+      files: [
+        {
+          name: "filename.txt",
+          size: 15,
+          status: { type: "uploaded", serverFileId: 1 },
+          id: 1,
+        },
+      ],
+      newestServerFileId: 1,
+    })
+  })
+
   it("shows a label", () => {
     const props = getProps({ label: "Test label" })
     const wrapper = shallow(<FileUploader {...props} />)
@@ -118,7 +164,7 @@ describe("FileUploader widget", () => {
 
   it("uploads a single selected file", async () => {
     const props = getProps()
-    jest.spyOn(props.widgetMgr, "setIntArrayValue")
+    jest.spyOn(props.widgetMgr, "setFileUploaderStateValue")
     const wrapper = shallow(<FileUploader {...props} />)
     const instance = wrapper.instance() as FileUploader
     const fileDropzone = wrapper.find(FileDropzone)
@@ -131,13 +177,13 @@ describe("FileUploader widget", () => {
     expect(instance.status).toBe("updating")
 
     // WidgetStateManager should not have been called yet
-    expect(props.widgetMgr.setIntArrayValue).not.toHaveBeenCalled()
+    expect(props.widgetMgr.setFileUploaderStateValue).not.toHaveBeenCalled()
 
     await process.nextTick
 
     // The server will have assigned us a new ID. This will also be
     // our "newestServerFileId".
-    const serverFileId = getServerFileId(getFiles(wrapper)[0])
+    const serverFileId = getServerFileId(getFiles(wrapper)[0]) as number
     const newestServerFileId = serverFileId
 
     // After upload completes, our file should be "uploaded" and our status
@@ -149,9 +195,9 @@ describe("FileUploader widget", () => {
     expect(instance.status).toBe("ready")
 
     // And WidgetStateManager should have been called with the file's ID
-    expect(props.widgetMgr.setIntArrayValue).toHaveBeenCalledWith(
+    expect(props.widgetMgr.setFileUploaderStateValue).toHaveBeenCalledWith(
       props.element,
-      [newestServerFileId, serverFileId],
+      buildFileUploaderStateProto(newestServerFileId, [serverFileId]),
       {
         fromUi: true,
       }
@@ -160,7 +206,7 @@ describe("FileUploader widget", () => {
 
   it("uploads a single file even if too many files are selected", async () => {
     const props = getProps({ multipleFiles: false })
-    jest.spyOn(props.widgetMgr, "setIntArrayValue")
+    jest.spyOn(props.widgetMgr, "setFileUploaderStateValue")
     const wrapper = shallow(<FileUploader {...props} />)
     const instance = wrapper.instance() as FileUploader
     const fileDropzone = wrapper.find(FileDropzone)
@@ -194,13 +240,13 @@ describe("FileUploader widget", () => {
     expect(getFiles(wrapper, "error").length).toBe(2)
     expect(instance.status).toBe("ready")
 
-    const fileId = getServerFileId(getFiles(wrapper)[0])
+    const fileId = getServerFileId(getFiles(wrapper)[0]) as number
     const newestServerFileId = fileId
 
     // WidgetStateManager should have been called with the file's ID
-    expect(props.widgetMgr.setIntArrayValue).toHaveBeenCalledWith(
+    expect(props.widgetMgr.setFileUploaderStateValue).toHaveBeenCalledWith(
       props.element,
-      [newestServerFileId, fileId],
+      buildFileUploaderStateProto(newestServerFileId, [fileId]),
       {
         fromUi: true,
       }
@@ -245,7 +291,7 @@ describe("FileUploader widget", () => {
 
   it("uploads multiple files, even if some have errors", async () => {
     const props = getProps({ multipleFiles: true })
-    jest.spyOn(props.widgetMgr, "setIntArrayValue")
+    jest.spyOn(props.widgetMgr, "setFileUploaderStateValue")
     const wrapper = shallow(<FileUploader {...props} />)
     const instance = wrapper.instance() as FileUploader
     const fileDropzone = wrapper.find(FileDropzone)
@@ -281,10 +327,9 @@ describe("FileUploader widget", () => {
     const uploadedFiles = getFiles(wrapper, "uploaded")
     const uploadedFileIds = getServerFileIds(uploadedFiles)
     const newestServerId = Math.max(...uploadedFileIds)
-    const expectedWidgetValue = [newestServerId, ...uploadedFileIds]
-    expect(props.widgetMgr.setIntArrayValue).toHaveBeenCalledWith(
+    expect(props.widgetMgr.setFileUploaderStateValue).toHaveBeenCalledWith(
       props.element,
-      expectedWidgetValue,
+      buildFileUploaderStateProto(newestServerId, uploadedFileIds),
       {
         fromUi: true,
       }
@@ -293,7 +338,7 @@ describe("FileUploader widget", () => {
 
   it("can delete completed upload", async () => {
     const props = getProps({ multipleFiles: true })
-    jest.spyOn(props.widgetMgr, "setIntArrayValue")
+    jest.spyOn(props.widgetMgr, "setFileUploaderStateValue")
     const wrapper = mount(<FileUploader {...props} />)
     const instance = wrapper.instance() as FileUploader
 
@@ -309,13 +354,13 @@ describe("FileUploader widget", () => {
     expect(instance.status).toBe("ready")
 
     // WidgetStateManager should have been called with our two file IDs
-    expect(props.widgetMgr.setIntArrayValue).toHaveBeenCalledTimes(1)
+    expect(props.widgetMgr.setFileUploaderStateValue).toHaveBeenCalledTimes(1)
 
     const initialFileIds = getServerFileIds(initialFiles)
-    const initialWidgetValue = [Math.max(...initialFileIds), ...initialFileIds]
-    expect(props.widgetMgr.setIntArrayValue).toHaveBeenLastCalledWith(
+    const newestServerId = Math.max(...initialFileIds)
+    expect(props.widgetMgr.setFileUploaderStateValue).toHaveBeenLastCalledWith(
       props.element,
-      initialWidgetValue,
+      buildFileUploaderStateProto(newestServerId, initialFileIds),
       {
         fromUi: true,
       }
@@ -336,11 +381,11 @@ describe("FileUploader widget", () => {
     // WidgetStateManager should have been called with the file ID
     // of the remaining file. This should be the second time WidgetStateManager
     // has been updated.
-    expect(props.widgetMgr.setIntArrayValue).toHaveBeenCalledTimes(2)
-    const newWidgetValue = [Math.max(...initialFileIds), initialFileIds[1]]
-    expect(props.widgetMgr.setIntArrayValue).toHaveBeenLastCalledWith(
+    expect(props.widgetMgr.setFileUploaderStateValue).toHaveBeenCalledTimes(2)
+    const newWidgetValue = [initialFileIds[1]]
+    expect(props.widgetMgr.setFileUploaderStateValue).toHaveBeenLastCalledWith(
       props.element,
-      newWidgetValue,
+      buildFileUploaderStateProto(newestServerId, newWidgetValue),
       {
         fromUi: true,
       }
@@ -349,7 +394,7 @@ describe("FileUploader widget", () => {
 
   it("can delete in-progress upload", async () => {
     const props = getProps({ multipleFiles: true })
-    jest.spyOn(props.widgetMgr, "setIntArrayValue")
+    jest.spyOn(props.widgetMgr, "setFileUploaderStateValue")
     const wrapper = mount(<FileUploader {...props} />)
     const instance = wrapper.instance() as FileUploader
 
@@ -371,11 +416,10 @@ describe("FileUploader widget", () => {
 
     // WidgetStateManager will still have been called once, with a single
     // value - the id that was last returned from the server.
-    const expectedWidgetValue = [INITIAL_SERVER_FILE_ID]
-    expect(props.widgetMgr.setIntArrayValue).toHaveBeenCalledTimes(1)
-    expect(props.widgetMgr.setIntArrayValue).toHaveBeenLastCalledWith(
+    expect(props.widgetMgr.setFileUploaderStateValue).toHaveBeenCalledTimes(1)
+    expect(props.widgetMgr.setFileUploaderStateValue).toHaveBeenCalledWith(
       props.element,
-      expectedWidgetValue,
+      buildFileUploaderStateProto(INITIAL_SERVER_FILE_ID, []),
       {
         fromUi: true,
       }
@@ -384,7 +428,7 @@ describe("FileUploader widget", () => {
 
   it("can delete file with ErrorStatus", () => {
     const props = getProps()
-    jest.spyOn(props.widgetMgr, "setIntArrayValue")
+    jest.spyOn(props.widgetMgr, "setFileUploaderStateValue")
     const wrapper = shallow(<FileUploader {...props} />)
     const instance = wrapper.instance() as FileUploader
     const fileDropzone = wrapper.find(FileDropzone)
@@ -403,7 +447,7 @@ describe("FileUploader widget", () => {
     expect(getFiles(wrapper).length).toBe(0)
 
     // WidgetStateManager should not have been called - no uploads happened.
-    expect(props.widgetMgr.setIntArrayValue).not.toHaveBeenCalled()
+    expect(props.widgetMgr.setFileUploaderStateValue).not.toHaveBeenCalled()
   })
 
   it("handles upload error", async () => {
@@ -497,7 +541,7 @@ describe("FileUploader widget", () => {
   it("resets its value when form is cleared", async () => {
     // Create a widget in a clearOnSubmit form
     const props = getProps({ formId: "form" })
-    jest.spyOn(props.widgetMgr, "setIntArrayValue")
+    jest.spyOn(props.widgetMgr, "setFileUploaderStateValue")
     props.widgetMgr.setFormClearOnSubmit("form", true)
 
     jest.spyOn(props.widgetMgr, "setIntValue")
@@ -509,11 +553,11 @@ describe("FileUploader widget", () => {
     fileDropzone.props().onDrop([createFile()], [])
     await process.nextTick
 
-    const serverFileId = getServerFileId(getFiles(wrapper)[0])
+    const serverFileId = getServerFileId(getFiles(wrapper)[0]) as number
     expect(wrapper.state("files")).toHaveLength(1)
-    expect(props.widgetMgr.setIntArrayValue).toHaveBeenCalledWith(
+    expect(props.widgetMgr.setFileUploaderStateValue).toHaveBeenCalledWith(
       props.element,
-      [serverFileId, serverFileId],
+      buildFileUploaderStateProto(serverFileId, [serverFileId]),
       {
         fromUi: true,
       }
@@ -525,9 +569,9 @@ describe("FileUploader widget", () => {
 
     // Our widget should be reset, and the widgetMgr should be updated
     expect(wrapper.state("files")).toHaveLength(0)
-    expect(props.widgetMgr.setIntArrayValue).toHaveBeenCalledWith(
+    expect(props.widgetMgr.setFileUploaderStateValue).toHaveBeenCalledWith(
       props.element,
-      [serverFileId],
+      buildFileUploaderStateProto(serverFileId, []),
       {
         fromUi: true,
       }
diff --git a/frontend/src/components/widgets/FileUploader/UploadedFile.test.tsx b/frontend/src/components/widgets/FileUploader/UploadedFile.test.tsx
--- a/frontend/src/components/widgets/FileUploader/UploadedFile.test.tsx
+++ b/frontend/src/components/widgets/FileUploader/UploadedFile.test.tsx
@@ -26,19 +26,14 @@ import Button from "src/components/shared/Button"
 import UploadedFile, { Props, UploadedFileStatus } from "./UploadedFile"
 import { FileStatus, UploadFileInfo } from "./UploadFileInfo"
 
-const MOCK_FILE = new File(["Text in a file!"], "filename.txt", {
-  type: "text/plain",
-  lastModified: 0,
-})
-
 const getProps = (fileStatus: FileStatus): Props => ({
-  fileInfo: new UploadFileInfo(MOCK_FILE, 0, fileStatus),
+  fileInfo: new UploadFileInfo("filename.txt", 15, 1, fileStatus),
   onDelete: jest.fn(),
 })
 
 describe("FileStatus widget", () => {
   it("renders without crashing", () => {
-    const props = getProps({ type: "uploaded" })
+    const props = getProps({ type: "uploaded", serverFileId: 1 })
     const wrapper = shallow(<UploadedFileStatus {...props} />)
 
     expect(wrapper).toBeDefined()
@@ -67,7 +62,7 @@ describe("FileStatus widget", () => {
   })
 
   it("show file size when uploaded", () => {
-    const props = getProps({ type: "uploaded" })
+    const props = getProps({ type: "uploaded", serverFileId: 1 })
 
     const wrapper = shallow(<UploadedFileStatus {...props} />)
     const statusWrapper = wrapper.find(Small)
@@ -77,14 +72,15 @@ describe("FileStatus widget", () => {
 
 describe("UploadedFile widget", () => {
   it("renders without crashing", () => {
-    const props = getProps({ type: "uploaded" })
+    const props = getProps({ type: "uploaded", serverFileId: 1 })
     const wrapper = shallow(<UploadedFile {...props} />)
 
     expect(wrapper).toBeDefined()
+    expect(wrapper.text()).toContain("filename.txt")
   })
 
   it("calls delete callback", () => {
-    const props = getProps({ type: "uploaded" })
+    const props = getProps({ type: "uploaded", serverFileId: 1 })
     const wrapper = mount(<UploadedFile {...props} />)
     const deleteBtn = wrapper.find(Button)
     deleteBtn.simulate("click")
diff --git a/frontend/src/lib/WidgetStateManager.test.ts b/frontend/src/lib/WidgetStateManager.test.ts
--- a/frontend/src/lib/WidgetStateManager.test.ts
+++ b/frontend/src/lib/WidgetStateManager.test.ts
@@ -16,7 +16,11 @@
  */
 
 import { enableAllPlugins } from "immer"
-import { ArrowTable } from "src/autogen/proto"
+import {
+  ArrowTable as ArrowTableProto,
+  FileUploaderState as FileUploaderStateProto,
+  UploadedFileInfo as UploadedFileInfoProto,
+} from "src/autogen/proto"
 import {
   createFormsData,
   FormsData,
@@ -25,7 +29,7 @@ import {
   WidgetStateManager,
 } from "src/lib/WidgetStateManager"
 
-const MOCK_ARROW_TABLE = new ArrowTable({
+const MOCK_ARROW_TABLE = new ArrowTableProto({
   data: new Uint8Array(),
   index: new Uint8Array(),
   columns: new Uint8Array(),
@@ -45,6 +49,23 @@ const MOCK_FORM_WIDGET = {
   formId: "mockFormId",
 }
 
+const MOCK_FILE_UPLOADER_STATE = new FileUploaderStateProto({
+  maxFileId: 42,
+  uploadedFileInfo: [
+    new UploadedFileInfoProto({
+      id: 4,
+      name: "bob",
+      size: 5,
+    }),
+
+    new UploadedFileInfoProto({
+      id: 42,
+      name: "linus",
+      size: 9001,
+    }),
+  ],
+})
+
 // Required by ImmerJS
 enableAllPlugins()
 
@@ -202,6 +223,20 @@ describe("Widget State Manager", () => {
     }
   )
 
+  it.each([false, true])(
+    "sets FileUploaderState value correctly (insideForm=%p)",
+    insideForm => {
+      const widget = getWidget({ insideForm })
+      widgetMgr.setFileUploaderStateValue(widget, MOCK_FILE_UPLOADER_STATE, {
+        fromUi: true,
+      })
+      expect(widgetMgr.getFileUploaderStateValue(widget)).toEqual(
+        MOCK_FILE_UPLOADER_STATE
+      )
+      assertCallbacks({ insideForm })
+    }
+  )
+
   describe("Primitive types as JSON values", () => {
     it("sets string value as JSON correctly", () => {
       widgetMgr.setJsonValue(MOCK_WIDGET, "mockStringValue", { fromUi: true })
diff --git a/lib/tests/streamlit/state/session_state_test.py b/lib/tests/streamlit/state/session_state_test.py
--- a/lib/tests/streamlit/state/session_state_test.py
+++ b/lib/tests/streamlit/state/session_state_test.py
@@ -36,6 +36,7 @@
     WidgetMetadata,
     WStates,
 )
+from streamlit.uploaded_file_manager import UploadedFileRec
 from tests import testutil
 
 
@@ -279,6 +280,31 @@ def test_date_input_serde(self):
         )
         check_roundtrip("date_interval", date_interval)
 
+    @patch("streamlit.elements.file_uploader.FileUploaderMixin._get_file_recs")
+    def test_file_uploader_serde(self, get_file_recs_patch):
+        file_recs = [
+            UploadedFileRec(1, "file1", "type", b"123"),
+        ]
+        get_file_recs_patch.return_value = file_recs
+
+        uploaded_file = st.file_uploader("file_uploader", key="file_uploader")
+
+        # We can't use check_roundtrip here as the return_value of a
+        # file_uploader widget isn't a primitive value, so comparing them
+        # using == checks for reference equality.
+        session_state = get_session_state()
+        metadata = session_state._new_widget_state.widget_metadata["file_uploader"]
+        serializer = metadata.serializer
+        deserializer = metadata.deserializer
+
+        file_after_serde = deserializer(serializer(uploaded_file), "")
+
+        assert uploaded_file.id == file_after_serde.id
+        assert uploaded_file.name == file_after_serde.name
+        assert uploaded_file.type == file_after_serde.type
+        assert uploaded_file.size == file_after_serde.size
+        assert uploaded_file.read() == file_after_serde.read()
+
     def test_multiselect_serde(self):
         multiselect = st.multiselect(
             "multiselect", options=["a", "b", "c"], key="multiselect"