Skip to content

fix(server): propagate render context to sync handlers in executor#38

Merged
Az107 merged 1 commit into
Az107:developfrom
LoboGuardian:fix/sync-handler-render-context
Jul 12, 2026
Merged

fix(server): propagate render context to sync handlers in executor#38
Az107 merged 1 commit into
Az107:developfrom
LoboGuardian:fix/sync-handler-render-context

Conversation

@LoboGuardian

Copy link
Copy Markdown
Collaborator

Problem

use_state() and @js are silently broken inside sync route handlers.

@app.route("/")
def index():                      # sync
    count = use_state(0)
    @js
    def increment():
        count.set(count.get() + 1)
    return html(head(), body(h1("Count: ", count), button("+").attr(onclick=increment())))

The response ships an empty <head /> — no LocalState initializer, no transpiled function — so the page shows a raw {{localstate_…}} placeholder and onclick calls an undefined function. The same handler declared async def works.

Cause

handle_request stores the render context in a ContextVar (init_render_ctx) and then runs sync handlers via loop.run_in_executor(...). run_in_executor does not propagate contextvars, so inside the worker thread get_render_ctx() returns None and every registration is dropped without error.

Fix

Run the handler through contextvars.copy_context():

context = contextvars.copy_context()
response = await asyncio.get_event_loop().run_in_executor(
    None, functools.partial(context.run, handler, **params)
)

The copied context sees the same RenderContext object, so registrations made in the executor thread are visible when the main thread renders the response. Async handlers are unaffected.

Verification

Reproduced on develop (60e7e1f) with uvicorn + curl: before the patch the sync handler renders <head />; after it, both <script> blocks (state initializer + transpiled increment) are injected and identical to the async handler's output. ruff check passes.

run_in_executor does not carry contextvars into the worker thread, so
the render context set by init_render_ctx was invisible to sync route
handlers: use_state() and @js registered nothing and pages shipped
without their <script> blocks or state initializers, silently. Running
the handler through contextvars.copy_context() makes the executor
thread see the same RenderContext object, whose mutations the main
thread then renders. Async handlers are unaffected.
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