Skip to content

Commit 1754b31

Browse files
authored
fix: address 0.26.0 release review regressions (#318)
Summary - Fix Inertia partial reload filtering for plain dict route props, explicit scrollProps keying, and sessionless scope shared props. - Fix Deno package/bin fallback argv for typegen and JS install-hint execution. - Fix Vite dev-server auto-restart retry accounting so stable restarts reset the retry budget while crash loops still cap out. - Add 0.26.0 changelog migration notes and release-review fix entries.
1 parent 71fc4f8 commit 1754b31

11 files changed

Lines changed: 224 additions & 24 deletions

File tree

docs/changelog.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,32 @@ Litestar Vite Changelog
1010
0.26.0 - 2026-07-05
1111
-------------------
1212

13+
Migration notes
14+
~~~~~~~~~~~~~~~
15+
16+
- Inertia asset-version mismatches now follow the protocol more strictly: stale ``GET`` visits receive the ``409`` refresh response, while stale non-``GET`` submissions continue to their handlers. Review handlers that relied on non-``GET`` mismatch short-circuiting.
17+
- Inertia infinite-scroll metadata is now emitted as ``scrollProps`` keyed by data prop name. Frontend code that read a single flat scroll config should read ``scrollProps.<propName>`` instead.
18+
- Type generation now fails production builds by default when generation fails. Set ``TypeGenConfig(fail_on_error=False)`` or ``types.failOnError = false`` to keep warn-only build behavior.
19+
1320
- Fixed Inertia asset-version mismatch handling so only ``GET`` requests can return the protocol ``409`` refresh response; non-GET submissions now continue to their handlers instead of being downgraded and losing the request body. (#306)
1421
- Fixed ``share()`` with Inertia redirects so top-level sync special props are materialized before session storage and async special props are skipped instead of crashing session serialization. (#306)
22+
- Fixed request-scope Inertia shared props so they are still rendered on routes without session middleware.
1523
- Fixed Inertia infinite-scroll ``scrollProps`` to emit a record keyed by data prop name, matching the official client protocol. (#306)
24+
- Fixed explicit Inertia ``scroll_props=`` responses so metadata is keyed by the returned data prop when there is a single route prop.
1625
- Fixed auto-wrapped Inertia responses so non-Inertia JSON handlers preserve Litestar's resolved ``201``/``204`` status codes while user-created ``InertiaResponse`` statuses still win. (#306)
1726
- Fixed Precognition validation success handling for handlers without an explicit ``request`` parameter by using the middleware request context. (#306)
1827
- Fixed Inertia SSR serialization so app, handler, and response type encoders are honored for custom page prop values. (#306)
1928
- Fixed Inertia partial reloads so ``deferredProps`` metadata is omitted on partial responses while initial responses still advertise deferred props. (#306)
29+
- Fixed Inertia partial reloads so plain dict route props honor ``X-Inertia-Partial-Data`` and ``X-Inertia-Partial-Except`` independently.
2030
- Fixed ``litestar assets init`` so generated ``package.json`` files no longer emit duplicate dependency keys. (#302, #303)
2131
- Fixed type generation so the JS plugin resolves local ``@hey-api/openapi-ts`` installs first, uses a pinned package-manager fallback when needed, fails production builds loudly by default, and lets ``TypeGenConfig(fail_on_error=False)`` or ``types.failOnError = false`` opt out. (#311, #314)
32+
- Fixed Deno type-generation fallback execution when the providing npm package exposes a binary with a different name.
2233
- Fixed type generation reliability for generated outputs by preserving queued dev regenerations, checking that expected output files exist before reusing caches, watching ``routes.json``, emitting ``static-props.ts`` from the shared CLI path, and handling semantic aliases and nested hey-api operation types. (#311, #314)
2334
- Fixed Vite dev-server resilience by revalidating hotfile targets by mtime, recovering after missing or replaced hotfiles, tolerating additive/corrupt bridge config files, aligning TLS certificate env vars, and preserving static-files defaults when user overrides leave fields unset. (#312, #314)
2435
- Fixed ``litestar assets init`` scaffold correctness for framework variants without dedicated directories, Tailwind CSS/PostCSS dependencies and entry inputs, current hey-api/TanStack/Vite template APIs, transactional writes, and non-interactive collision handling. (#313, #314)
2536
- Updated npm publishing to use trusted publishing with provenance and no npm token. (#314)
2637
- Fixed Vite dev-server lifecycle handling so unexpected exits are restarted with capped backoff and intentional shutdowns do not trigger restarts. (#317)
38+
- Fixed Vite dev-server auto-restart so each recovered crash gets a fresh retry budget instead of exhausting the lifetime budget.
2739
- Changed SPA/proxy route-prefix fallbacks so ``/docs`` is no longer reserved unless Litestar actually registers docs there or ``RuntimeConfig.extra_route_prefixes`` includes it. (#317)
2840
- Added ``ViteConfig(enabled=...)`` and ``VITE_ENABLED`` so Vite routes and lifespans can be disabled in CLI, worker, and test contexts while keeping asset CLI commands available. (#301, #310)
2941
- Added ``RuntimeConfig.extra_route_prefixes`` for deliberately reserving custom backend prefixes from SPA/proxy fallbacks. (#317)

src/js/src/install-hint.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,31 @@ export interface PackageExecutorArgvOptions {
106106
binName?: string
107107
}
108108

109+
function getPackageNameFromSpec(packageSpec: string): string {
110+
if (packageSpec.startsWith("@")) {
111+
const versionIndex = packageSpec.indexOf("@", packageSpec.indexOf("/") + 1)
112+
return versionIndex === -1 ? packageSpec : packageSpec.slice(0, versionIndex)
113+
}
114+
const versionIndex = packageSpec.indexOf("@")
115+
return versionIndex === -1 ? packageSpec : packageSpec.slice(0, versionIndex)
116+
}
117+
118+
function resolveDenoPackageSpec(packageSpec: string, binName?: string): string {
119+
if (!binName) return packageSpec
120+
121+
const packageName = getPackageNameFromSpec(packageSpec)
122+
const defaultBinName = packageName.split("/").pop()
123+
return defaultBinName === binName ? packageSpec : `${packageSpec}/${binName}`
124+
}
125+
109126
export function resolvePackageExecutorArgv(args: string[], executor?: string, options: PackageExecutorArgvOptions = {}): string[] {
110127
const runtime = executor || detectExecutor()
111128
const { packageSpec, binName } = options
112129
switch (runtime) {
113130
case "bun":
114131
return ["bunx", ...(packageSpec ? [packageSpec, ...args] : args)]
115132
case "deno":
116-
return ["deno", "run", "-A", ...(packageSpec ? [`npm:${packageSpec}`, ...args] : args)]
133+
return ["deno", "run", "-A", ...(packageSpec ? [`npm:${resolveDenoPackageSpec(packageSpec, binName)}`, ...args] : args)]
117134
case "pnpm":
118135
return ["pnpm", "dlx", ...(packageSpec ? [packageSpec, ...args] : args)]
119136
case "yarn":

src/js/tests/install-hint.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,5 +387,14 @@ describe("install-hint", () => {
387387
"openapi-ts.config.ts",
388388
])
389389
})
390+
391+
it("uses the package binary path for deno when package name and bin differ", () => {
392+
expect(
393+
resolvePackageExecutorArgv(["--verbose"], "deno", {
394+
packageSpec: "litestar-vite-plugin",
395+
binName: "litestar-vite-typegen",
396+
}),
397+
).toEqual(["deno", "run", "-A", "npm:litestar-vite-plugin/litestar-vite-typegen", "--verbose"])
398+
})
390399
})
391400
})

src/py/litestar_vite/cli.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1013,7 +1013,8 @@ def _get_package_executor_cmd(executor: "str | None", binary: str, *, package_na
10131013
case "bun":
10141014
return ["bunx", "--package", package, binary] if package_name else ["bunx", binary]
10151015
case "deno":
1016-
return ["deno", "run", "-A", f"npm:{package}"]
1016+
deno_package = f"{package}/{binary}" if package != binary else package
1017+
return ["deno", "run", "-A", f"npm:{deno_package}"]
10171018
case "yarn":
10181019
return ["yarn", "dlx", "--package", package, binary] if package_name else ["yarn", "dlx", binary]
10191020
case "pnpm":

src/py/litestar_vite/inertia/helpers.py

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1375,15 +1375,28 @@ def get_shared_props(
13751375
once_props_entries: "list[_OncePropEntry]" = []
13761376
error_bag = request.headers.get("X-Inertia-Error-Bag", None)
13771377

1378+
session_available = True
1379+
session_shared_props: Mapping[str, Any] = {}
1380+
scope_shared_props = cast("dict[str, Any]", request.scope).pop(_RAW_SHARED_SCOPE_KEY, {})
13781381
try:
13791382
errors = request.session.pop("_errors", {})
1380-
session_shared_props = request.session.pop("_shared", {})
1381-
scope_shared_props = cast("dict[str, Any]", request.scope).pop(_RAW_SHARED_SCOPE_KEY, {})
1382-
shared_props = (
1383-
{**cast("Mapping[str, Any]", session_shared_props), **cast("Mapping[str, Any]", scope_shared_props)}
1384-
if isinstance(session_shared_props, Mapping) and isinstance(scope_shared_props, Mapping)
1385-
else cast("dict[str,Any]", session_shared_props)
1383+
raw_session_shared_props = request.session.pop("_shared", {})
1384+
except (AttributeError, ImproperlyConfiguredException):
1385+
session_available = False
1386+
msg = "Unable to generate all shared props. A valid session was not found for this request."
1387+
request.logger.warning(msg)
1388+
else:
1389+
session_shared_props = (
1390+
cast("Mapping[str, Any]", raw_session_shared_props) if isinstance(raw_session_shared_props, Mapping) else {}
13861391
)
1392+
1393+
shared_props = (
1394+
{**session_shared_props, **cast("Mapping[str, Any]", scope_shared_props)}
1395+
if isinstance(scope_shared_props, Mapping)
1396+
else dict(session_shared_props)
1397+
)
1398+
1399+
try:
13871400
inertia_plugin = cast("InertiaPlugin", request.app.plugins.get("InertiaPlugin"))
13881401

13891402
once_props_entries = _extract_once_prop_entries(shared_props, partial_data, partial_except)
@@ -1407,20 +1420,22 @@ def get_shared_props(
14071420
else:
14081421
props[key] = value
14091422

1410-
for message in cast("list[dict[str,Any]]", request.session.pop("_messages", [])):
1411-
flash[message["category"]].append(message["message"])
1423+
if session_available:
1424+
for message in cast("list[dict[str,Any]]", request.session.pop("_messages", [])):
1425+
flash[message["category"]].append(message["message"])
14121426

14131427
for key, value in inertia_plugin.config.extra_static_page_props.items():
14141428
if should_render(value, partial_data, partial_except, key=key):
14151429
props[key] = value
14161430

1417-
for session_prop in inertia_plugin.config.extra_session_page_props:
1418-
if (
1419-
session_prop not in props
1420-
and session_prop in request.session
1421-
and should_render(None, partial_data, partial_except, key=session_prop)
1422-
):
1423-
props[session_prop] = request.session.get(session_prop)
1431+
if session_available:
1432+
for session_prop in inertia_plugin.config.extra_session_page_props:
1433+
if (
1434+
session_prop not in props
1435+
and session_prop in request.session
1436+
and should_render(None, partial_data, partial_except, key=session_prop)
1437+
):
1438+
props[session_prop] = request.session.get(session_prop)
14241439

14251440
except (AttributeError, ImproperlyConfiguredException):
14261441
msg = "Unable to generate all shared props. A valid session was not found for this request."

src/py/litestar_vite/inertia/response.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ def _build_page_props(
208208
content: Any = self.content
209209
route_content: Any | None = None
210210
route_once_props: "list[tuple[str, str]]" = []
211+
route_prop_keys: list[str] = []
211212

212213
# v2.2+ protocol: Extract deferred props metadata before filtering.
213214
# Route props override shared props with the same key, so discard any
@@ -225,7 +226,7 @@ def _build_page_props(
225226
filtered_content: Any = lazy_render(cast("Any", content), partial_data, partial_except, except_once_props)
226227
if filtered_content is not None:
227228
route_content = filtered_content
228-
elif isinstance(content, Mapping) and partial_data and partial_except:
229+
elif isinstance(content, Mapping) and (partial_data or partial_except):
229230
route_content = lazy_render(
230231
cast("Mapping[str, Any]", content), partial_data, partial_except, except_once_props
231232
)
@@ -239,11 +240,14 @@ def _build_page_props(
239240
if self.prop_filter is not None and not self.prop_filter.should_include(str(key)):
240241
continue
241242
shared_props[key] = value
243+
route_prop_keys.append(str(key))
242244
elif is_pagination_container(route_content):
243245
prop_key = _get_route_prop_key(route_handler)
244246
shared_props[prop_key] = route_content
247+
route_prop_keys.append(prop_key)
245248
else:
246249
shared_props["content"] = route_content
250+
route_prop_keys.append("content")
247251

248252
# Drop keys this partial reload just resolved, or the client loops on loadDeferredProps.
249253
deferred_props = _resolve_deferred_props(deferred_props_map, partial_data, is_partial_render)
@@ -259,7 +263,10 @@ def _build_page_props(
259263
shared_props = unwrap_merge_props(shared_props)
260264

261265
scroll_props = _apply_pagination_props(
262-
shared_props, route_handler=route_handler, explicit_scroll_props=self.scroll_props
266+
shared_props,
267+
route_handler=route_handler,
268+
explicit_scroll_props=self.scroll_props,
269+
explicit_scroll_props_key=_get_explicit_scroll_props_key(route_prop_keys, route_handler),
263270
)
264271

265272
encrypt_history = _resolve_encrypt_history(self.encrypt_history, inertia_plugin)
@@ -1057,12 +1064,22 @@ def _get_route_prop_key(route_handler: Any) -> str:
10571064
return (route_handler.opt.get("key", "items") if route_handler else "items") or "items"
10581065

10591066

1067+
def _get_explicit_scroll_props_key(route_prop_keys: list[str], route_handler: Any) -> str:
1068+
if len(route_prop_keys) == 1:
1069+
return route_prop_keys[0]
1070+
return _get_route_prop_key(route_handler)
1071+
1072+
10601073
def _apply_pagination_props(
1061-
shared_props: "dict[str, Any]", *, route_handler: Any, explicit_scroll_props: "ScrollPropsConfig | None"
1074+
shared_props: "dict[str, Any]",
1075+
*,
1076+
route_handler: Any,
1077+
explicit_scroll_props: "ScrollPropsConfig | None",
1078+
explicit_scroll_props_key: str,
10621079
) -> "dict[str, ScrollPropsConfig] | None":
10631080
scroll_props_map: "dict[str, ScrollPropsConfig]" = {}
10641081
if explicit_scroll_props is not None:
1065-
scroll_props_map[_get_route_prop_key(route_handler)] = explicit_scroll_props
1082+
scroll_props_map[explicit_scroll_props_key] = explicit_scroll_props
10661083

10671084
infinite_scroll_enabled = bool(route_handler and route_handler.opt.get("infinite_scroll", False))
10681085
for key in tuple(shared_props):

src/py/litestar_vite/plugin/_process.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class ViteProcess:
2929
_signals_registered: bool = False
3030
_original_handlers: "dict[int, Any]" = {}
3131
_RESTART_BACKOFFS: ClassVar[tuple[float, ...]] = (1.0, 2.0, 4.0)
32+
_RESTART_STABILITY_SECONDS: ClassVar[float] = 5.0
3233

3334
def __init__(self, executor: "JSExecutor") -> None:
3435
"""Initialize the Vite process manager.
@@ -200,7 +201,9 @@ def _watch_process(self, generation: int) -> None:
200201
command = self._restart_command
201202
cwd = self._restart_cwd
202203

204+
wait_started = time.monotonic()
203205
exit_code = process.wait()
206+
process_runtime = time.monotonic() - wait_started
204207
self._terminate_exited_process_group(process, timeout=0.5)
205208

206209
with self._lock:
@@ -210,6 +213,10 @@ def _watch_process(self, generation: int) -> None:
210213
if command is None or cwd is None:
211214
return
212215

216+
if process_runtime >= self._RESTART_STABILITY_SECONDS:
217+
attempts = 0
218+
last_error = None
219+
213220
restarted = False
214221
while attempts < len(self._RESTART_BACKOFFS):
215222
backoff = self._RESTART_BACKOFFS[attempts]

src/py/tests/unit/inertia/test_helpers.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from unittest.mock import MagicMock
55

66
from litestar import Request, get
7+
from litestar.exceptions import ImproperlyConfiguredException
78
from litestar.middleware.session.server_side import ServerSideSessionConfig
89
from litestar.stores.memory import MemoryStore
910
from litestar.template.config import TemplateConfig
@@ -42,6 +43,27 @@ def test_get_shared_props_includes_csrf_token_from_scope() -> None:
4243
assert shared_props["csrf_token"] == "scope-csrf-token"
4344

4445

46+
def test_get_shared_props_includes_scope_props_when_session_is_unavailable() -> None:
47+
"""Request-scope shared props should survive requests excluded from session middleware."""
48+
from litestar_vite.inertia.helpers import _RAW_SHARED_SCOPE_KEY, get_shared_props
49+
50+
request = MagicMock()
51+
request.headers.get.return_value = None
52+
request.session.pop.side_effect = ImproperlyConfiguredException("No session")
53+
request.scope = {_RAW_SHARED_SCOPE_KEY: {"auth": {"user": "Ada"}}}
54+
request.logger = MagicMock()
55+
56+
inertia_plugin = MagicMock()
57+
inertia_plugin.config.extra_static_page_props = {}
58+
inertia_plugin.config.extra_session_page_props = []
59+
request.app.plugins.get.return_value = inertia_plugin
60+
61+
shared_props = get_shared_props(request)
62+
63+
assert shared_props["auth"] == {"user": "Ada"}
64+
assert _RAW_SHARED_SCOPE_KEY not in request.scope
65+
66+
4567
def test_scroll_props_helper_creates_config() -> None:
4668
"""Test scroll_props() helper creates correct ScrollPropsConfig."""
4769
config = scroll_props(page_name="page", current_page=2, previous_page=1, next_page=3)
@@ -255,7 +277,7 @@ async def handler(request: Request[Any, Any, Any]) -> dict[str, Any]:
255277
share(request, "user", {"name": "Alice"})
256278
share(request, "settings", {"theme": "dark"})
257279
share(request, "notifications", ["msg1", "msg2"])
258-
# Route handler props are always included
280+
# Route handler props are filtered by the same partial-data keys
259281
return {"posts": [1, 2, 3], "comments": [4, 5, 6]}
260282

261283
with create_test_client(
@@ -286,9 +308,9 @@ async def handler(request: Request[Any, Any, Any]) -> dict[str, Any]:
286308
partial_props = partial_response.json()["props"]
287309
# user is requested and present in shared props
288310
assert "user" in partial_props
289-
# Route handler props are always included (posts, comments)
311+
# Route handler props follow partial-data filtering
290312
assert "posts" in partial_props
291-
assert "comments" in partial_props
313+
assert "comments" not in partial_props
292314
# Shared props not requested are filtered out
293315
assert "settings" not in partial_props
294316
assert "notifications" not in partial_props

0 commit comments

Comments
 (0)