Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions solara/autorouting.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,15 +115,24 @@ def set_path_with_redirect(path):
solara.routing.router_context.provide(solara.routing.Router(path, routes=routes, set_path=set_path_with_redirect))

def get_nav_widget():
# not sure why get_widget(nav) does not work
nav_widget.current = solara.get_widget(main).children[0] # type: ignore
# get_widget(nav) did not work when this was written (2023, "not sure why"): reacton's
# get_widget only searched the current component context, and nav is reconciled in the
# VBox component's child context. reacton 1.7.2 (dfdca66) made it search child contexts,
# and solara requires reacton>=1.9, so the detour via get_widget(main).children[0] is gone.
nav_widget.current = solara.get_widget(nav) # type: ignore

import solara.widgets as w

nav_widget = solara.use_ref(cast(Optional[w.Navigator], None))
if nav_widget.current:
nav_widget.current.location = path
solara.use_effect(get_nav_widget)
# run once: the root element of this component is always the same VBox at the same slot, so
# reacton updates those widgets in place instead of replacing them, and the Navigator widget
# stays the same object for the lifetime of the app. Running the effect on every render would
# queue a widget lookup for an element of a render pass that is never reconciled, get_widget
# then raises from inside the effect and the traceback replaces the whole app
# (see widgetti/reacton#54, fixed in reacton 1.10.3).
solara.use_effect(get_nav_widget, [])

if solara.checks.should_perform_solara_check():
children = [solara.checks.SolaraCheck(), *children]
Expand Down
5 changes: 4 additions & 1 deletion solara/server/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ def showtraceback(self):
pdb.post_mortem()
etype, value, tb = sys.exc_info()
traceback_string = "".join(traceback.format_exception(etype, value, tb))
logger.error("Uncaught exception: %s", traceback_string)
# keep the message short and pass the exception via exc_info: error trackers (e.g. Sentry)
# group on the log message, so formatting the traceback into it puts every uncaught
# exception of every app into a single issue.
logger.error("Uncaught exception", exc_info=value)
msg = {
"type": "exception",
"traceback": traceback_string,
Expand Down
28 changes: 28 additions & 0 deletions tests/unit/patch_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,31 @@ def test_pool_worker_thread_does_not_pin_kernel_context(no_kernel_context):
assert context_ref() is None
finally:
executor.shutdown(wait=False)


def test_showtraceback_logs_exception_with_exc_info(no_kernel_context, caplog):
import logging
import traceback

from solara.server import patch

kernel1 = kernel.Kernel()
context = kernel_context.VirtualKernelContext(id="showtraceback-1", kernel=kernel1, session_id="session-1")
fake_ipython = patch.FakeIPython(context)

with caplog.at_level(logging.ERROR, logger="solara.server.patch"):
try:
raise ValueError("some app error")
except ValueError:
fake_ipython.showtraceback()

records = [record for record in caplog.records if record.name == "solara.server.patch"]
assert len(records) == 1
record = records[0]
# the message stays short and the exception travels via exc_info, so error trackers group
# per exception type instead of putting every uncaught exception in a single issue
assert record.getMessage() == "Uncaught exception"
assert record.exc_info is not None
assert record.exc_info[0] is ValueError
assert "some app error" in "".join(traceback.format_exception(*record.exc_info))
context.close()
40 changes: 39 additions & 1 deletion tests/unit/router_test.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Callable, Optional
from typing import Callable, List, Optional

import ipyvue as vue
import ipyvuetify as v
Expand Down Expand Up @@ -167,3 +167,41 @@ def Provider():
assert rc._find(v.Btn).widget.children[0] == "kiwi"
set_path("/fruit/wumpa")
assert rc._find(v.Btn).widget.children[0] == "Error!"


def test_routing_provider_navigator_widget_stable():
"""The Navigator widget of RoutingProvider is created once and reused for the whole app lifetime.

The effect that looks the widget up runs only once (empty dependencies). Re-running it on every
render would queue a lookup of an element of a render pass that may never be reconciled, which
raises from inside the effect and replaces the app by a traceback (widgetti/reacton#54).
"""
import solara.widgets

push: Callable[[str], None] = lambda x: None # noqa: E731
seen_paths: List[str] = []

@solara.component
def Child():
nonlocal push
router = solara.use_router()
push = router.push
seen_paths.append(router.path)
return solara.Text(router.path)

box, rc = solara.render(solara.RoutingProvider(children=[Child()], routes=routes, pathname="/"), handle_error=False)
navigator = rc.find(solara.widgets.Navigator).widget
assert navigator.location == "/"
assert seen_paths == ["/"]

for path in ["/fruit", "/contact", "/fruit"]:
push(path)
# the same widget, updated in place, not a new one
assert rc.find(solara.widgets.Navigator).widget is navigator
assert navigator.location == path
assert seen_paths[-1] == path

# a navigation started by the browser (the widget sets its own location) also keeps the widget
navigator.location = "/contact"
assert rc.find(solara.widgets.Navigator).widget is navigator
assert seen_paths[-1] == "/contact"
Loading