Skip to content

Commit 1c1e573

Browse files
committed
add some diagrams for state as snapshot
1 parent f8b0b99 commit 1c1e573

File tree

15 files changed

+179
-72
lines changed

15 files changed

+179
-72
lines changed

docs/examples.py

Lines changed: 41 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ def all_example_names() -> set[str]:
3535

3636

3737
def load_one_example(file_or_name: Path | str) -> Callable[[], ComponentType]:
38+
return lambda: (
39+
# we do this to ensure each instance is fresh
40+
_load_one_example(file_or_name)
41+
)
42+
43+
44+
def _load_one_example(file_or_name: Path | str) -> ComponentType:
3845
if isinstance(file_or_name, str):
3946
file = get_main_example_file_by_name(file_or_name)
4047
else:
@@ -43,8 +50,14 @@ def load_one_example(file_or_name: Path | str) -> Callable[[], ComponentType]:
4350
if not file.exists():
4451
raise FileNotFoundError(str(file))
4552

53+
print_buffer = _PrintBuffer()
54+
55+
def capture_print(*args, **kwargs):
56+
buffer = StringIO()
57+
print(*args, file=buffer, **kwargs)
58+
print_buffer.write(buffer.getvalue())
59+
4660
captured_component_constructor = None
47-
capture_print, ShowPrint = _printout_viewer()
4861

4962
def capture_component(component_constructor):
5063
nonlocal captured_component_constructor
@@ -68,13 +81,18 @@ def capture_component(component_constructor):
6881

6982
if captured_component_constructor is None:
7083
return _make_example_did_not_run(str(file))
71-
else:
7284

73-
@idom.component
74-
def Wrapper():
75-
return idom.html.div(captured_component_constructor(), ShowPrint())
85+
@idom.component
86+
def Wrapper():
87+
return idom.html.div(captured_component_constructor(), PrintView())
7688

77-
return Wrapper
89+
@idom.component
90+
def PrintView():
91+
text, set_text = idom.hooks.use_state(print_buffer.getvalue())
92+
print_buffer.set_callback(set_text)
93+
return idom.html.pre({"class": "printout"}, text) if text else idom.html.div()
94+
95+
return Wrapper()
7896

7997

8098
def get_main_example_file_by_name(name: str) -> Path:
@@ -98,44 +116,26 @@ def _get_root_example_path_by_name(name: str) -> Path:
98116
return EXAMPLES_DIR.joinpath(*name.split("/"))
99117

100118

101-
def _printout_viewer():
102-
print_callbacks: set[Callable[[str], None]] = set()
119+
class _PrintBuffer:
120+
def __init__(self, max_lines: int = 10):
121+
self._callback = None
122+
self._lines = ()
123+
self._max_lines = max_lines
103124

104-
@idom.component
105-
def ShowPrint():
106-
lines, set_lines = idom.hooks.use_state(())
107-
108-
def set_buffer(text: str):
109-
if len(lines) > 10:
110-
# limit printout size - protects against malicious actors
111-
# plus it gives you some nice scrolling printout
112-
set_lines(lines[1:] + (text,))
113-
else:
114-
set_lines(lines + (text,))
115-
116-
@idom.hooks.use_effect(args=[set_buffer])
117-
def add_set_buffer_callback():
118-
print_callbacks.add(set_buffer)
119-
return lambda: print_callbacks.remove(set_buffer)
120-
121-
if not lines:
122-
return idom.html.div()
123-
else:
124-
return idom.html.pre({"class": "printout"}, "".join(lines))
125+
def set_callback(self, function: Callable[[str], None]) -> None:
126+
self._callback = function
127+
return None
125128

126-
def capture_print(*args, **kwargs):
127-
buffer = StringIO()
128-
print(*args, file=buffer, **kwargs)
129-
value = buffer.getvalue()
130-
for cb in print_callbacks:
131-
cb(value)
132-
133-
return capture_print, ShowPrint
129+
def getvalue(self) -> str:
130+
return "".join(self._lines)
134131

135-
136-
def _use_force_update():
137-
toggle, set_toggle = idom.hooks.use_state(False)
138-
return lambda: set_toggle(not toggle)
132+
def write(self, text: str) -> None:
133+
if len(self._lines) == self._max_lines:
134+
self._lines = self._lines[1:] + (text,)
135+
else:
136+
self._lines += (text,)
137+
if self._callback is not None:
138+
self._callback(self.getvalue())
139139

140140

141141
def _make_example_did_not_run(example_name):
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import asyncio
2+
3+
from idom import component, event, html, run, use_state
4+
5+
6+
@component
7+
def App():
8+
recipient, set_recipient = use_state("Alice")
9+
message, set_message = use_state("")
10+
11+
@event(prevent_default=True)
12+
async def handle_submit(event):
13+
set_message("")
14+
print("About to send message...")
15+
await asyncio.sleep(5)
16+
print(f"Sent '{message}' to {recipient}")
17+
18+
return html.form(
19+
{"onSubmit": handle_submit, "style": {"display": "inline-grid"}},
20+
html.label(
21+
"To: ",
22+
html.select(
23+
{
24+
"value": recipient,
25+
"onChange": lambda event: set_recipient(event["value"]),
26+
},
27+
html.option({"value": "Alice"}, "Alice"),
28+
html.option({"value": "Bob"}, "Bob"),
29+
),
30+
),
31+
html.input(
32+
{
33+
"type": "text",
34+
"placeholder": "Your message...",
35+
"value": message,
36+
"onChange": lambda event: set_message(event["value"]),
37+
}
38+
),
39+
html.button({"type": "submit"}, "Send"),
40+
)
41+
42+
43+
run(App)

docs/source/_exts/widget_example.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class WidgetExample(SphinxDirective):
2525
}
2626

2727
def run(self):
28+
print(self.get_source_info())
2829
example_name = self.arguments[0]
2930
show_linenos = "linenos" in self.options
3031
live_example_is_default_tab = "result-is-default-tab" in self.options

docs/source/_static/custom.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1483,7 +1483,17 @@ const targetTransformCategories = {
14831483
};
14841484

14851485
const targetTagCategories = {
1486-
hasValue: ["BUTTON", "INPUT", "OPTION", "LI", "METER", "PROGRESS", "PARAM"],
1486+
hasValue: [
1487+
"BUTTON",
1488+
"INPUT",
1489+
"OPTION",
1490+
"LI",
1491+
"METER",
1492+
"PROGRESS",
1493+
"PARAM",
1494+
"SELECT",
1495+
"TEXTAREA",
1496+
],
14871497
hasCurrentTime: ["AUDIO", "VIDEO"],
14881498
hasFiles: ["INPUT"],
14891499
};
@@ -1941,7 +1951,9 @@ function mountLayoutWithReconnectingWebSocket(
19411951
mountState
19421952
);
19431953

1944-
console.info(`IDOM WebSocket connection lost. Reconnecting in ${reconnectTimeout} seconds...`);
1954+
console.info(
1955+
`IDOM WebSocket connection lost. Reconnecting in ${reconnectTimeout} seconds...`
1956+
);
19451957

19461958
setTimeout(function () {
19471959
mountState.reconnectAttempts++;
Loading
Loading

docs/source/adding-interactivity/components-with-state.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ below highlights a line of code where something of interest occurs:
224224

225225
.. raw:: html
226226

227-
<h2>Event handler triggers</h2>
227+
<h2>New state is set</h2>
228228

229229
.. literalinclude:: /_examples/adding_interactivity/adding_state_variable/app.py
230230
:lines: 12-33

docs/source/adding-interactivity/index.rst

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ Adding Interactivity
3636
:link: state-as-a-snapshot
3737
:link-type: doc
3838

39-
Under construction 🚧
39+
Learn why IDOM does not change component state the moment it is set, but
40+
instead schedules a re-render.
4041

4142
.. grid-item-card:: :octicon:`issue-opened` Dangers of Mutability
4243
:link: dangers-of-mutability
@@ -112,14 +113,32 @@ Section 3: State as a Snapshot
112113

113114
As we :ref:`learned earlier <Components with State>`, state setters behave a little
114115
differently than you might exepct at first glance. Instead of updating your current
115-
handle on the corresponding state variable it schedules a re-render of the component
116-
which owns the state:
116+
handle on the setter's corresponding variable, it schedules a re-render of the component
117+
which owns the state.
117118

118119
.. code-block::
119120
120-
print(count)
121-
set_count(count + 1)
122-
print(count)
121+
count, set_count = use_state(0)
122+
print(count) # prints: 0
123+
set_count(count + 1) # schedule a re-render where count is 1
124+
print(count) # still prints: 0
125+
126+
This behavior of IDOM means that each render of a component is like taking a snapshot of
127+
the UI based on the component's state at that time. Treating state in this way can help
128+
reduce subtle bugs. For example, in the code below there's a simple chat app with a
129+
message input and recipient selector. The catch is that the message actually gets sent 5
130+
seconds after the "Send" button is clicked. So what would happen if we changed the
131+
recipient between the time the "Send" button was clicked and the moment the message is
132+
actually sent?
133+
134+
.. example:: adding_interactivity/print_chat_message
135+
:activate-result:
136+
137+
As it turns out, changing the message recipient after pressing send does not change
138+
where the message ulitmately goes. However, one could imagine a bug where the recipient
139+
of a message is determined at the time the message is sent rather than at the time the
140+
"Send" button it clicked. In many cases, IDOM avoids this class of bug entirely because
141+
it treats state as a snapshot.
123142

124143
.. card::
125144
:link: state-as-a-snapshot
Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
State as a Snapshot
22
===================
33

4-
While you can read state variables like you might normally in Python, as we
5-
:ref:`learned earlier <Components with State>`, assigning to them is somewhat different.
6-
When you use a state setter to update a component, instead of modifying your handle to
7-
its corresponding state variable, a re-render is triggered. Only after that next render
8-
begins will things change.
4+
When you watch the user interfaces you build change as you interact with them, it's easy
5+
to imagining that they do so because there's some bit of code that modifies the relevant
6+
parts of the view directly. For example, you may think that when a user clicks a "Send"
7+
button, there's code which reaches into the view and adds some text saying "Message
8+
sent!":
9+
10+
.. image:: _static/direct-state-change.png
11+
12+
13+
14+
.. image:: _static/idom-state-change.png

docs/source/creating-interfaces/your-first-components.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ At their core, components are just normal Python functions that return HTML. To
1515
component you just need to add a ``@component`` `decorator
1616
<https://realpython.com/primer-on-python-decorators/>`__ to a function. Functions
1717
decorator in this way are known as **render function** and, by convention, we name them
18-
like classes - with ``CamelCase``. So for example, if we wanted to write, and then
19-
:ref:`display <Running IDOM>` a ``Photo`` component, we might write:
18+
like classes - with ``CamelCase``. So consider what we would do if we wanted to write,
19+
and then :ref:`display <Running IDOM>` a ``Photo`` component:
2020

2121
.. example:: creating_interfaces/simple_photo
2222
:activate-result:

docs/source/index.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ IDOM is also ecosystem independent. It can be added to existing applications bui
3939
variety of sync and async web servers, as well as integrated with other frameworks like
4040
Django, Jupyter, and Plotly Dash. Not only does this mean you're free to choose what
4141
technology stack to run on, but on top of that, you can run the exact same components
42-
wherever you need them. For example, you can take a component originally developed in a
43-
Jupyter Notebook and embed it in your production application without changing anything
44-
about the component itself.
42+
wherever you need them. Consider the case where, you can take a component originally
43+
developed in a Jupyter Notebook and embed it in your production application without
44+
changing anything about the component itself.
4545

4646

4747
At a Glance

noxfile.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,12 @@ def update_version(session: Session) -> None:
253253
session.install("-e", ".")
254254

255255

256+
@nox.session
257+
def build_js(session: Session) -> None:
258+
session.chdir(ROOT / "src" / "client")
259+
session.run("npm", "run", "build", external=True)
260+
261+
256262
@nox.session(reuse_venv=True)
257263
def latest_pull_requests(session: Session) -> None:
258264
"""A basic script for outputing changelog info"""

src/client/packages/idom-client-react/src/event-to-object.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,17 @@ const targetTransformCategories = {
3939
};
4040

4141
const targetTagCategories = {
42-
hasValue: ["BUTTON", "INPUT", "OPTION", "LI", "METER", "PROGRESS", "PARAM"],
42+
hasValue: [
43+
"BUTTON",
44+
"INPUT",
45+
"OPTION",
46+
"LI",
47+
"METER",
48+
"PROGRESS",
49+
"PARAM",
50+
"SELECT",
51+
"TEXTAREA",
52+
],
4353
hasCurrentTime: ["AUDIO", "VIDEO"],
4454
hasFiles: ["INPUT"],
4555
};

src/client/packages/idom-client-react/src/mount.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ function mountLayoutWithReconnectingWebSocket(
6666
mountState
6767
);
6868

69-
console.info(`IDOM WebSocket connection lost. Reconnecting in ${reconnectTimeout} seconds...`);
69+
console.info(
70+
`IDOM WebSocket connection lost. Reconnecting in ${reconnectTimeout} seconds...`
71+
);
7072

7173
setTimeout(function () {
7274
mountState.reconnectAttempts++;

src/idom/html.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,18 @@
100100
Forms
101101
-----
102102
103-
- :func:`meter`
104-
- :func:`output`
105-
- :func:`progress`
106-
- :func:`input`
107103
- :func:`button`
108-
- :func:`label`
109104
- :func:`fieldset`
105+
- :func:`form`
106+
- :func:`input`
107+
- :func:`label`
110108
- :func:`legend`
109+
- :func:`meter`
110+
- :func:`option`
111+
- :func:`output`
112+
- :func:`progress`
113+
- :func:`select`
114+
- :func:`textarea`
111115
112116
113117
Interactive Elements
@@ -201,14 +205,18 @@
201205
tr = make_vdom_constructor("tr")
202206

203207
# Forms
204-
meter = make_vdom_constructor("meter")
205-
output = make_vdom_constructor("output")
206-
progress = make_vdom_constructor("progress")
207-
input = make_vdom_constructor("input", allow_children=False)
208208
button = make_vdom_constructor("button")
209-
label = make_vdom_constructor("label")
210209
fieldset = make_vdom_constructor("fieldset")
210+
form = make_vdom_constructor("form")
211+
input = make_vdom_constructor("input", allow_children=False)
212+
label = make_vdom_constructor("label")
211213
legend = make_vdom_constructor("legend")
214+
meter = make_vdom_constructor("meter")
215+
option = make_vdom_constructor("option")
216+
output = make_vdom_constructor("output")
217+
progress = make_vdom_constructor("progress")
218+
select = make_vdom_constructor("select")
219+
textarea = make_vdom_constructor("textarea")
212220

213221
# Interactive elements
214222
details = make_vdom_constructor("details")

0 commit comments

Comments
 (0)