Skip to content

Add live reload to JupyUvi#875

Open
curtis-allan wants to merge 2 commits into
mainfrom
jupy_live_reload
Open

Add live reload to JupyUvi#875
curtis-allan wants to merge 2 commits into
mainfrom
jupy_live_reload

Conversation

@curtis-allan
Copy link
Copy Markdown
Contributor

This PR add live browser reload functionality to JupyUvi through a live keyword arg.

Since JupyUvi runs uvicorn in a separate thread, the code is automatically updated when changed within the kernel namespace. The fast_app life refresh websocket logic listens for a disconnection to trigger the browser reload, but with JupyUvi the server process doesn't need to restart to update the app logic.

By using a simple sse endpoint and binding the reload flag callback to:

get_ipython().events.register('post_run_cell', ...

We can initiate a browser refresh after a cell has been run in the kernel, which makes for a smooth dev experience in notebook-like contexts.

@curtis-allan curtis-allan requested a review from jph00 May 5, 2026 22:08
@jph00
Copy link
Copy Markdown
Contributor

jph00 commented May 10, 2026

Can you add a bit of an explanation/example to this @curtis-allan ?

Comment thread fasthtml/jupyter.py
class JupyUvi:
"Start and stop a Jupyter compatible uvicorn server with ASGI `app` on `port` with `log_level`"
def __init__(self, app, log_level="error", host='0.0.0.0', port=8000, start=True, daemon=False, **kwargs):
def __init__(self, app, log_level="error", host='0.0.0.0', port=8000, start=True, live=False, live_rt='/_lr', daemon=False, **kwargs):
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The live_rt param is the path of the SSE route that get's registered in the app if live is set to True, to send reload events to connected clients

Comment thread fasthtml/jupyter.py
store_attr(but='start,live')
self.server = None
self._live_ver = 0
if live: self._setup_live(app)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since multiple clients can be connected to our app at a given time, using a version over a boolean flag ensures all client's reload (since each get's their own version number once connected to the generator endpoint)

Comment thread fasthtml/jupyter.py
def _setup_live(self, app):
rt = self.live_rt or '/_lr'
if not rt.startswith('/'): rt = f'/{rt}'
app.hdrs.append(Script(f"new EventSource({rt!r}).onmessage=e=>{{if(e.data==='reload')navigation.reload()}}"))
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registers a simple EventSource in the client browsers by adding it to the global app headers. Logic is as follows:

  1. Clients connect to an SSE endpoint at self.live_rt (which falls back to /_lr if overridden)
  2. When the browser receives a data: reload event from the server, it triggers a browser-side reload to fetch the latest version of whatever page is loaded

Comment thread fasthtml/jupyter.py
app.hdrs.append(Script(f"new EventSource({rt!r}).onmessage=e=>{{if(e.data==='reload')navigation.reload()}}"))
@app.get(rt)
async def _sse(): return EventStream(self._live_sse())
get_ipython().events.register('post_run_cell', lambda _: setattr(self, '_live_ver', self._live_ver+1))
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This registers a global callback in the running IPykernel. Each time a cell has been run/ executed in the kernel, it triggers the callback & increments the self._live_ver variable

Comment thread fasthtml/jupyter.py
await asyncio.sleep(0.1)
if ver != self._live_ver:
ver = self._live_ver
yield 'data: reload\n\n'
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generator which sends the reload events to connected clients.

Each client that connects receives it's own new generator on the server-side. Each generator stores an initial reference to the JupyUvi _live_ver variable.

If the client's version doesn't match the server version state, it sends a data: reload event to that client, triggering a browser refresh.

The self.server.should_exit loop conditional ensures generators are stopped & cleaned up before the uvicorn thread/ process exits so it doesn't cause connection hangs. It seems to work well in notebook environments since stop() sets the server attribute first

@curtis-allan curtis-allan self-assigned this May 10, 2026
@curtis-allan
Copy link
Copy Markdown
Contributor Author

@jph00 I've recorded a demo of the behaviour this PR aims to add, and I've annotated the code changes to indicate my reasoning behind them

@jph00
Copy link
Copy Markdown
Contributor

jph00 commented May 10, 2026

Thanks @curtis-allan this is great! :) Those comments might be better as comments in the code, docstring, or ipynb, for future readers.

Should we replace the fastapp live reload with this impl? Would be nice to share code if possible...

@curtis-allan
Copy link
Copy Markdown
Contributor Author

Thanks @curtis-allan this is great! :) Those comments might be better as comments in the code, docstring, or ipynb, for future readers.

Should we replace the fastapp live reload with this impl? Would be nice to share code if possible...

That makes sense @jph00 , I'll document the core parts in the notebook and re-push.

I haven't looked at the fastapp live-reload logic in a while but that would be nice. It wouldn't work OOTB as a drop-in replacement though, since running a main.py with serve/ uvicorn directly from the CLI requires server process restart to propagate changes. This was a work-around since the uvicorn process never reloads when using JupyUvi, so the current fastapp live logic doesn't work with JupyUvi (server-side changes are dynamic since the app is bound as a factory IIRC?)

Will have a look and see

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants