Skip to content

fix: async props callback - #245

Merged
cofin merged 9 commits into
mainfrom
fix/inertia-async-props-callback
May 2, 2026
Merged

cofin merged 9 commits into
mainfrom
fix/inertia-async-props-callback

Conversation

@Kumzy

@Kumzy Kumzy commented Apr 27, 2026

Copy link
Copy Markdown
Member

Fix #244

@Kumzy Kumzy changed the title fix: fix async props callback fix: async props callback Apr 27, 2026
@Kumzy
Kumzy marked this pull request as ready for review April 28, 2026 14:01
Kumzy and others added 9 commits May 2, 2026 15:00
Async prop callbacks and SSR HTTP fetches now resolve on the request
event loop in a single async pre-pass via _AsyncInertiaASGIResponse.
The to_asgi_response() sync path becomes a fast path for cases with no
async work pending; everything async runs on the same task as the route
handler, eliminating cross-loop errors with request-scoped resources
(asyncpg/aiosqlite/sqlspec sessions).

Breaking (Inertia internals):
- Removed inertia_plugin.portal and the BlockingPortal lifespan.
- Removed portal= from Static/Deferred/Once/Optional/AlwaysProp.render().
- Removed litestar_vite.inertia._async_mixin (AsyncRenderMixin).
- Calling render() directly on an unresolved async prop now raises
  RuntimeError with a clear message.

Folded the previous _aresolve.py module into helpers.py, renamed to
match existing helpers.py conventions: resolve_async_props (walker)
and has_unresolved_async_props (predicate). Each prop class now exposes
a resolve_async() method so private state stays in-class.

Adds 5 regression tests for nested/multiple/error/once-evaluation/SSR
+ async cases.

Fixes #244.
The Angular E2E examples were failing on `npm install` in CI: pinning
@angular/core to 21.2.6 transitively pulled @angular/platform-browser@21.2.5
which has a peerOptional on @angular/animations@21.2.5, conflicting with
the root-pinned @angular/animations@21.2.6.

Bumping all @angular/* packages to 21.2.10 (where peer-deps are internally
consistent — every package at 21.2.x.10 only requires the same 21.2.10),
except @angular/build/cli/devkit which max out at 21.2.8 (their caret
ranges accept the 21.2.10 runtime packages).

Same-batch hygiene bumps for the rest of the example/template ecosystem
(vite 8.0.10, react 19.2.5, svelte 5.55.5, vue 3.5.33, hey-api 0.96.1,
tailwind 4.2.4, etc.) keeping TypeScript on 5.9.3 and astro on 5.18.1
to avoid major-version risk in this PR.

Drive-by fix: replace deprecated `Iterator`/`AsyncIterator` return-type
annotations on @contextmanager/@asynccontextmanager-decorated lifespan
methods in plugin/__init__.py with `Generator`/`AsyncGenerator` so
pyright stops flagging reportDeprecated.
Adds a regression test that closes over a yield-based DI dependency in an
optional()/defer() async prop callback. The dependency releases its fake
connection on __aexit__ — the test asserts the callback runs *before* that
release, i.e. inside Litestar's _call_handler_function AsyncExitStack frame.

Currently xfail(strict=True): _AsyncInertiaASGIResponse.__call__ resolves
async props during ASGI dispatch, which Litestar runs only after
_call_handler_function returns and the DI stack pops. The strict marker
forces removal once the handler-fn wrap (litestar-vite-conn-fix.3) lands
and the tests start passing.

Refs litestar-vite-conn-fix.1, #244
Lift the async-resolution + SSR-prefetch logic out of
_AsyncInertiaASGIResponse into a public coroutine on InertiaResponse so
the same code path can be driven from inside the route-handler frame
(coming in litestar-vite-conn-fix.3). _AsyncInertiaASGIResponse.__call__
now delegates and stays as a fallback until the handler-fn wrap lands.

Pure refactor: no behavior change. All 224 inertia tests still pass; the
two xfail reproductions still fail with the original traceback.

Bundles the existing uv.lock churn from prior dep bumps (commit d9c8337).

Refs litestar-vite-conn-fix.2, #244
@cofin
cofin force-pushed the fix/inertia-async-props-callback branch from 976d00e to e5276c7 Compare May 2, 2026 20:00
@cofin
cofin merged commit 5e39fb7 into main May 2, 2026
31 checks passed
@cofin
cofin deleted the fix/inertia-async-props-callback branch May 2, 2026 20:04
cofin added a commit that referenced this pull request May 3, 2026
Six fixes that came out of triaging follow-ups from #245 and a user repro on a remote-host setup. They all touch the response/proxy/bridge layer #245 just rewrote, so they're bundled rather than spread across six PRs.

Closes #247.
Closes #243.

### Bridge `appUrl` fallback for the dev proxy (#247)

Python writes the canonical backend URL into `.litestar.json` (`appUrl: string | null`), derived from `APP_URL` → `LITESTAR_HOST`+`LITESTAR_PORT`/`PORT`. The Vite plugin uses it as the fallback target for the default `/api` and `/schema` proxies when the Vite process doesn't have `APP_URL` exported — common when `npm run dev` runs in a separate shell. User-supplied `server.proxy` and `env.APP_URL` still win.

### SSR prop rendering inside the DI scope

Hybrid SSR full-page loads from dict-returning handlers were 500-ing whenever a sync `once()` / `optional()` / `defer()` / shared-props closure captured a yield-based DI dep (asyncpg, SQLAlchemy session, aiosqlite). `_AsyncInertiaSSRResponse.__call__` runs after Litestar's `_call_handler_function` `AsyncExitStack` closes, so the DI resources were already released by the time prop callbacks fired.

`InertiaPlugin._wrap_handler_fn` now pre-wraps dict returns into an `InertiaResponse` and resolves async props inside the handler frame — the same path explicit `InertiaResponse` returns already used. SSR prefetch + prop rendering happen while DI is still alive.

### Dev proxy graceful fallback when Vite isn't running

`ViteProxyMiddleware` was returning HTTP 503 "Vite server not running" for every asset request when `dev_mode=True` and the Vite hot file was absent — even when a built asset existed on disk. The static files router (registered alongside the proxy) never got a turn.

When `_get_target_base_url()` returns `None`, the middleware now passes the request through to the next ASGI app instead of emitting 503. Built assets get served from `bundle_dir`; truly-missing assets return a natural 404. The proxy still wins when the hot file is present.

### Default Vite `server.origin` to bridge `appUrl` for `proxyMode="vite"`

`proxyMode="vite"` is supposed to keep all traffic on the Litestar origin, but rendered Jinja templates were emitting absolute Vite URLs (`http://127.0.0.1:5173/...`) that browsers on remote hosts couldn't reach (cloud workstations, devcontainers, dev tunnels, port-mapped Docker). Page rendered, every asset 404'd.

When `proxyMode === "vite"`, the user hasn't set `server.origin` explicitly, and the bridge has `appUrl` populated, the JS plugin now defaults `server.origin` to `appUrl`. Vite stamps that origin into the hotfile, the Python loader emits matching absolute URLs, and the proxy gets the requests it was registered to handle. HMR WebSocket terminates at the Litestar origin via the existing route — no user-side WS config needed.

The `server.origin: process.env.APP_URL` workaround in `vite.config.ts` is no longer required.

### Mode aliases

`ViteConfig.mode` accepted nine values, only five of which were canonical. Three aliases were already normalized at construction (`inertia → hybrid`, `ssr → framework`, `ssg → framework`); `htmx` flowed through unnormalized as a parallel-but-identical synonym for `template`.

Added `htmx → template` to `_normalize_mode`. Collapsed `mode in {"template", "htmx"}` membership checks throughout. The mode docstring now enumerates the five canonical modes (`spa`, `template`, `hybrid`, `framework`, `external`) and the four aliases.

Also dropped the bogus `template + Inertia` validation rejection — Inertia + Jinja templates is exactly the setup the #243 reporter was trying to use. Only `external` mode (non-Vite dev server like Angular CLI) remains incompatible with Inertia.

### Inertia SSR for `mode="template"` (#243)

SSR was silently skipped for `mode="template"` even when `InertiaConfig(ssr=...)` was configured — the SSR endpoint never received a POST and the page rendered entirely client-side.

Dropped the `mode == "hybrid"` gate from `_will_render_ssr`; SSR now fires for `mode in {"hybrid", "template"}` whenever `ssr_config` is present. Added `InertiaSSRConfig.target_selector` (default `#app`) — the CSS selector for the element whose outer HTML is replaced by the SSR-rendered body. Hybrid mode continues to use `SPAConfig.app_selector`.

`_render_template` now reads `_cached_ssr_payload` and injects the SSR body via `replace_element_outer_html` and any returned head HTML via `inject_head_html`. Same shape as `_render_spa`.

The SSR HTTP fetch still happens inside `resolve_async_props` (handler frame), so the DI scope fix above carries through to template mode.
cofin pushed a commit that referenced this pull request May 3, 2026
#249)

## Summary                                                                                                                                                                                           
                                                            
  Fix a 0.22.2 regression where `InertiaPlugin.on_app_init` is silently never                                                                                                                          
  called when `VitePlugin` auto-registers it via `ViteConfig.inertia`,
  breaking the Inertia response envelope, the template `data-page` injection,                                                                                                                          
  and the React adapter mount in apps that combine `VitePlugin` with another                                                                                                                           
  plugin that rebinds `app_config.plugins` (e.g. older `SQLSpecPlugin`).                                                                                                                               
                                                                                                                                                                                                       
  ## Symptoms                                                                                                                                                                                          
                                                                                                                                                                                                       
  After upgrading from 0.22.1 to 0.22.2, apps using `VitePlugin` in Inertia                                                                                                                            
  mode silently break:
                                                                                                                                                                                                       
  - HTTP responses no longer carry the Inertia JSON envelope
    (`component` / `props` / `url` / `version`).                                                                                                                                                       
  - The Inertia page payload lands in an orphan `<script id="app_page">`                                                                                                                               
    instead of as `data-page="..."` on the root element, so the
    `@inertiajs/react` adapter cannot find it and React never mounts.                                                                                                                                  
  - Integration tests asserting the Inertia envelope start failing.                                                                                                                                    
                                                                                                                                                                                                       
  ## Root cause                                                                                                                                                                                        
                                                                                                                                                                                                       
  `VitePlugin._configure_inertia` constructed an `InertiaPlugin` and appended
  it to `app_config.plugins`, then relied on Litestar's plugin iterator to
  call `on_app_init` later. That iterator (in `litestar/app.py`) is built                                                                                                                              
  once over a captured reference to `app_config.plugins`, so when a prior                                                                                                                              
  plugin does `app_config.plugins = [...]` (rebind), the appended                                                                                                                                      
  `InertiaPlugin` lands in a list the iterator no longer sees and its                                                                                                                                  
  `on_app_init` is never called.                                                                                                                                                                       
                                                                                                                                                                                                       
  The same `_configure_inertia` shipped in 0.22.1, but the symptom became                                                                                                                              
  visible in 0.22.2 because the response-class swap, middleware install and                                                                                                                            
  the new `on_startup` handler-wrap (introduced for async-prop resolution                                                                                                                              
  in #245) all run inside `InertiaPlugin.on_app_init`. In 0.22.1 some of                                                                                                                               
  that wiring still ran via the `BlockingPortal` lifespan path that 0.22.2                                                                                                                             
  removed, so the broken hand-off was masked.                                                                                                                                                          
                                                                                                                                                                                                       
  ## Fix                                                                                                                                                                                               
                                                                                                                                                                                                       
  1. `VitePlugin._configure_inertia` now calls                                                                                                                                                         
     `inertia_plugin.on_app_init(app_config)` **eagerly**, instead of waiting
     for Litestar's iterator. The plugin is still added to                                                                                                                                             
     `app_config.plugins` so `app.plugins.get(InertiaPlugin)` keeps working                                                                                                                            
     (5 callsites in the source depend on it).                                                                                                                                                         
  2. `InertiaPlugin.on_app_init` is now idempotent via a natural sentinel:                                                                                                                             
     `app_config.response_class is InertiaResponse`. `response_class`                                                                                                                                  
     first call, so the eager invocation plus a subsequent iterator-driven                                                                                                                             
     call in healthy plugin orderings is safe (no double middleware /                                                                                                                                  
     lifespan / on_startup registration).
cofin added a commit that referenced this pull request May 3, 2026
## Summary

Cleans up the Vite plugin's dev proxy and config surface, adds a few more example apps, and bumps the Astro example.

## Changes

**Config**

- Drops the *"template mode requires Jinja2"* hard validation — HTMX without Jinja and other non-Jinja template engines now work out of the box.
- Collapses the mode set to four canonical values (`spa`, `hybrid`, `template`, `framework`) plus 6 capability predicates on `ViteConfig`. `mode='external'` still works but emits a `DeprecationWarning` and forwards to `framework` + `external_dev_server`.
- `proxy_mode` is now derived from `mode` instead of being set directly. `proxy_mode='direct'` is removed (raises `ValueError`); `VITE_PROXY_MODE='direct'` is coerced to `vite` with a deprecation warning. Mismatched combinations (e.g. `proxy_mode='proxy'` with `mode='template'`) raise a clear error.

**Dev proxy**

- The SSR HTTP catch-all moves from a route handler to middleware, eliminating the `Handler already registered for path '/'` collision class. `create_ssr_proxy_controller` is kept as a deprecated alias; WebSocket support moves to a `create_ssr_websocket_handler` factory.
- Framework HMR (Astro / Nuxt / SvelteKit) now routes through the Litestar port instead of connecting directly to the framework dev server. The bridge file gains an optional `litestarPort` field; older readers ignore it.

**Examples**

- Adds `react-router`, `svelte-inertia`, `svelte-inertia-jinja`, `htmx-no-jinja`, `vue-inertia-ssr`, and `vue-inertia-jinja-ssr`. The two SSR examples ship a real Node `/render` runner.
- Bumps `examples/astro` from 5.18.1 to 6.2.1. Astro 6 ships rolldown-vite, which doesn't accept `@tailwindcss/vite@4.x`, so the example and the scaffolding template both switch to `@tailwindcss/postcss` (other framework templates are unaffected).

**Codegen**

- `_extract_schema_ref_name` and `_resolve_component_schema_name` now resolve module-prefixed enum refs to literal unions. Closes #198.

## References

- Supersedes #198
- Builds on #245, #248, #249
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.

Inertia async prop callbacks fail when using request-scoped async I/O

2 participants