Skip to content

Commit cc9bbd4

Browse files
authored
fix(inertia): align response protocol handling (#306)
## Summary - Align Inertia response behavior with protocol expectations for asset-version mismatches, partial reloads, deferred metadata, scroll props, SSR serialization, and error envelopes. - Preserve Litestar-resolved status codes for auto-wrapped responses while keeping explicit `InertiaResponse` statuses authoritative. - Update Inertia helper docs for keyed `scrollProps` and flattened pagination metadata, including the multi-paginator collision limitation. Closes #304 Refs #305
1 parent 429383f commit cc9bbd4

18 files changed

Lines changed: 1595 additions & 180 deletions

docs/changelog.rst

Lines changed: 537 additions & 13 deletions
Large diffs are not rendered by default.

docs/frameworks/inertia/infinite-scroll.rst

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,18 +69,18 @@ Trigger partial reloads when the bottom sentinel becomes visible:
6969
export default function Posts() {
7070
const page = usePage();
7171
const { posts } = page.props;
72-
const { scrollProps } = page;
72+
const postScroll = page.props.scrollProps?.posts;
7373
7474
return (
7575
<div>
7676
{posts.map((post) => (
7777
<PostCard key={post.id} post={post} />
7878
))}
7979
80-
{scrollProps?.nextPage && (
80+
{postScroll?.nextPage && (
8181
<WhenVisible
8282
always
83-
params={{ only: ["posts"], data: { page: scrollProps.nextPage } }}
83+
params={{ only: ["posts"], data: { page: postScroll.nextPage } }}
8484
>
8585
<Spinner />
8686
</WhenVisible>
@@ -105,9 +105,9 @@ Trigger partial reloads when the bottom sentinel becomes visible:
105105
<PostCard v-for="post in posts" :key="post.id" :post="post" />
106106
107107
<WhenVisible
108-
v-if="page.scrollProps?.nextPage"
108+
v-if="page.props.scrollProps?.posts?.nextPage"
109109
always
110-
:params="{ only: ['posts'], data: { page: page.scrollProps.nextPage } }"
110+
:params="{ only: ['posts'], data: { page: page.props.scrollProps.posts.nextPage } }"
111111
>
112112
<Spinner />
113113
</WhenVisible>

docs/frameworks/inertia/merging-props.rst

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,12 @@ Merge props are indicated in the response:
219219
"props": {"posts": ["Post 1", "Post 2"]},
220220
"mergeProps": ["posts"],
221221
"scrollProps": {
222-
"pageName": "page",
223-
"currentPage": 1,
224-
"nextPage": 2,
225-
"previousPage": null
222+
"posts": {
223+
"pageName": "page",
224+
"currentPage": 1,
225+
"nextPage": 2,
226+
"previousPage": null
227+
}
226228
}
227229
}
228230

src/js/src/inertia-helpers/index.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,16 @@ export async function resolvePageComponent(path: string | string[], pages: Recor
140140
* Offset-based pagination props.
141141
*
142142
* Returned when a route returns Litestar's `OffsetPagination` type.
143-
* Contains items plus metadata for offset/limit pagination.
143+
* Litestar-Vite flattens pagination metadata as top-level sibling props:
144+
* the route prop contains the item array, and `total`, `limit`, and `offset`
145+
* are emitted beside it.
144146
*
145147
* @example
146148
* ```ts
147149
* interface User { id: string; name: string }
148-
* const { items, total, limit, offset } = props.users as OffsetPaginationProps<User>
150+
* type UsersPageProps = { users: User[] } & Omit<OffsetPaginationProps<User>, 'items'>
151+
*
152+
* const { users, total, limit, offset } = props as UsersPageProps
149153
* ```
150154
*/
151155
export interface OffsetPaginationProps<T> {
@@ -163,12 +167,16 @@ export interface OffsetPaginationProps<T> {
163167
* Classic page-based pagination props.
164168
*
165169
* Returned when a route returns Litestar's `ClassicPagination` type.
166-
* Contains items plus metadata for page number pagination.
170+
* Litestar-Vite flattens pagination metadata as top-level sibling props:
171+
* the route prop contains the item array, and `currentPage`, `totalPages`,
172+
* and `pageSize` are emitted beside it.
167173
*
168174
* @example
169175
* ```ts
170176
* interface Post { id: string; title: string }
171-
* const { items, currentPage, totalPages, pageSize } = props.posts as ClassicPaginationProps<Post>
177+
* type PostsPageProps = { posts: Post[] } & Omit<ClassicPaginationProps<Post>, 'items'>
178+
*
179+
* const { posts, currentPage, totalPages, pageSize } = props as PostsPageProps
172180
* ```
173181
*/
174182
export interface ClassicPaginationProps<T> {
@@ -186,12 +194,16 @@ export interface ClassicPaginationProps<T> {
186194
* Cursor-based pagination props.
187195
*
188196
* Used for cursor/keyset pagination, commonly with infinite scroll.
189-
* Contains items plus cursor tokens for navigation.
197+
* Litestar-Vite flattens pagination metadata as top-level sibling props:
198+
* the route prop contains the item array, and cursor metadata is emitted
199+
* beside it.
190200
*
191201
* @example
192202
* ```ts
193203
* interface Message { id: string; content: string }
194-
* const { items, hasMore, nextCursor } = props.messages as CursorPaginationProps<Message>
204+
* type MessagesPageProps = { messages: Message[] } & Omit<CursorPaginationProps<Message>, 'items'>
205+
*
206+
* const { messages, hasMore, nextCursor } = props as MessagesPageProps
195207
* if (hasMore && nextCursor) {
196208
* // Fetch more with cursor
197209
* }
@@ -218,6 +230,11 @@ export interface CursorPaginationProps<T> {
218230
* Union type for any pagination props.
219231
*
220232
* Use when you need to handle multiple pagination styles.
233+
* Because pagination metadata is flattened as sibling keys, two paginators in
234+
* one response can collide on keys such as `total`, `limit`, `offset`,
235+
* `currentPage`, or `pageSize`. Return one flattened paginator per response,
236+
* or wrap additional paginators in explicit prop containers with distinct
237+
* metadata.
221238
*
222239
* @example
223240
* ```ts
@@ -250,11 +267,12 @@ export type PaginationProps<T> = OffsetPaginationProps<T> | ClassicPaginationPro
250267
* import { usePage } from '@inertiajs/vue3'
251268
*
252269
* const page = usePage()
253-
* const scrollProps = page.props.scrollProps as ScrollProps
270+
* const scrollProps = page.props.scrollProps as Record<string, ScrollProps> | undefined
271+
* const postsScroll = scrollProps?.posts
254272
*
255273
* function loadMore() {
256-
* if (scrollProps.nextPage) {
257-
* router.get(url, { [scrollProps.pageName]: scrollProps.nextPage })
274+
* if (postsScroll?.nextPage) {
275+
* router.get(url, { [postsScroll.pageName]: postsScroll.nextPage })
258276
* }
259277
* }
260278
* ```

src/js/tests/vite-compatibility.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type * as FsModule from "fs"
2+
import nodePath from "node:path"
23
import { beforeEach, describe, expect, it, vi } from "vitest"
34
import litestar from "../src"
45
import { isVite8Plus } from "../src/shared/vite-compat"
@@ -27,6 +28,7 @@ const originalEnv = process.env
2728
beforeEach(() => {
2829
vi.resetModules()
2930
process.env = { ...originalEnv }
31+
process.env.LITESTAR_VITE_CONFIG_PATH = nodePath.join(process.cwd(), ".vitest-missing-litestar.json")
3032
vi.clearAllMocks()
3133
})
3234

src/py/litestar_vite/inertia/exception_handler.py

Lines changed: 111 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,91 @@ def exception_to_http_response(request: "Request[UserT, AuthT, StateT]", exc: "E
6969
return cast("Response[Any]", create_exception_response(request, exc))
7070
if request.app.debug:
7171
return cast("Response[Any]", create_debug_response(request, exc))
72-
detail = str(exc.__cause__) if exc.__cause__ is not None else str(exc)
73-
return cast("Response[Any]", create_exception_response(request, InternalServerException(detail=detail)))
72+
# Production (non-debug, non-HTTPException): never embed raw exception text.
73+
# Debug rendering is already returned above by create_debug_response.
74+
return cast("Response[Any]", create_exception_response(request, InternalServerException()))
7475
return create_inertia_exception_response(request, exc)
7576

7677

78+
def _exception_detail_for_response(request: "Request[Any, Any, Any]", exc: Exception) -> Any:
79+
if isinstance(exc, HTTPException):
80+
return exc.detail
81+
if request.app.debug:
82+
return str(exc)
83+
return "Internal Server Error"
84+
85+
86+
def _exception_extra(exc: Exception) -> Any:
87+
if not isinstance(exc, HTTPException):
88+
return None
89+
try:
90+
return exc.extra # pyright: ignore[reportUnknownMemberType]
91+
except AttributeError:
92+
return None
93+
94+
95+
def _get_inertia_plugin(request: "Request[Any, Any, Any]") -> "InertiaPlugin | None":
96+
try:
97+
return request.app.plugins.get("InertiaPlugin")
98+
except KeyError:
99+
return None
100+
101+
102+
def _store_field_errors(request: "Request[Any, Any, Any]", extras: Any, detail: Any) -> None:
103+
if not extras or not isinstance(extras, (list, tuple)) or len(extras) < 1: # pyright: ignore[reportUnknownArgumentType]
104+
return
105+
first_extra = extras[0] # pyright: ignore[reportUnknownVariableType]
106+
if not isinstance(first_extra, dict):
107+
return
108+
message: dict[str, str] = cast("dict[str, str]", first_extra)
109+
key_value = message.get("key")
110+
default_field = f"root.{key_value}" if key_value is not None else "root"
111+
error_detail = str(message.get("message", detail) or detail)
112+
match = FIELD_ERR_RE.search(error_detail)
113+
field = match.group(1) if match else default_field
114+
error(request, field, error_detail or str(detail))
115+
116+
117+
def _create_exception_page_response(
118+
*,
119+
content: dict[str, Any],
120+
preferred_type: MediaType,
121+
route_component: str | None,
122+
is_inertia: bool,
123+
status_code: int,
124+
) -> "Response[Any]":
125+
if is_inertia and route_component is None:
126+
return Response[Any](content=content, media_type=MediaType.JSON, status_code=status_code)
127+
return InertiaResponse[Any](media_type=preferred_type, content=content, status_code=status_code)
128+
129+
130+
def _append_error_query(redirect_to: str, detail: Any) -> str:
131+
parsed = urlparse(redirect_to)
132+
error_param = f"error={quote(str(detail), safe='')}"
133+
query = f"{parsed.query}&{error_param}" if parsed.query else error_param
134+
return urlunparse(parsed._replace(query=query))
135+
136+
137+
def _create_unauthorized_response(
138+
request: "Request[Any, Any, Any]",
139+
*,
140+
detail: Any,
141+
flash_succeeded: bool,
142+
inertia_plugin: "InertiaPlugin",
143+
status_code: int,
144+
exc: Exception,
145+
) -> "Response[Any] | None":
146+
is_unauthorized = status_code == HTTP_401_UNAUTHORIZED or isinstance(exc, NotAuthorizedException)
147+
redirect_to_login = inertia_plugin.config.redirect_unauthorized_to
148+
if not is_unauthorized or redirect_to_login is None:
149+
return None
150+
if request.url.path != redirect_to_login:
151+
if not flash_succeeded and detail:
152+
redirect_to_login = _append_error_query(redirect_to_login, detail)
153+
return InertiaRedirect(request, redirect_to=redirect_to_login)
154+
return InertiaBack(request)
155+
156+
77157
def create_inertia_exception_response(request: "Request[UserT, AuthT, StateT]", exc: "Exception") -> "Response[Any]":
78158
"""Create the inertia exception response.
79159
@@ -95,23 +175,14 @@ def create_inertia_exception_response(request: "Request[UserT, AuthT, StateT]",
95175
"""
96176
is_inertia_header = request.headers.get("x-inertia", "").lower() == "true"
97177
is_inertia = request.is_inertia if isinstance(request, InertiaRequest) else is_inertia_header
178+
route_component = request.inertia.route_component if isinstance(request, InertiaRequest) else None
98179

99180
status_code = exc.status_code if isinstance(exc, HTTPException) else HTTP_500_INTERNAL_SERVER_ERROR
100181
preferred_type = MediaType.HTML if not is_inertia else MediaType.JSON
101-
detail = exc.detail if isinstance(exc, HTTPException) else str(exc)
102-
extras: Any = None
103-
if isinstance(exc, HTTPException):
104-
try:
105-
extras = exc.extra # pyright: ignore[reportUnknownMemberType]
106-
except AttributeError:
107-
extras = None
182+
detail = _exception_detail_for_response(request, exc)
183+
extras = _exception_extra(exc)
108184
content: dict[str, Any] = {"status_code": status_code, "message": detail}
109-
110-
inertia_plugin: "InertiaPlugin | None"
111-
try:
112-
inertia_plugin = request.app.plugins.get("InertiaPlugin")
113-
except KeyError:
114-
inertia_plugin = None
185+
inertia_plugin = _get_inertia_plugin(request)
115186

116187
if extras:
117188
content.update({"extra": extras})
@@ -120,46 +191,45 @@ def create_inertia_exception_response(request: "Request[UserT, AuthT, StateT]",
120191
if detail:
121192
flash_succeeded = flash(request, detail, category="error")
122193

123-
if extras and isinstance(extras, (list, tuple)) and len(extras) >= 1: # pyright: ignore[reportUnknownArgumentType]
124-
first_extra = extras[0] # pyright: ignore[reportUnknownVariableType]
125-
if isinstance(first_extra, dict):
126-
message: dict[str, str] = cast("dict[str, str]", first_extra)
127-
key_value = message.get("key")
128-
default_field = f"root.{key_value}" if key_value is not None else "root"
129-
error_detail = str(message.get("message", detail) or detail)
130-
match = FIELD_ERR_RE.search(error_detail)
131-
field = match.group(1) if match else default_field
132-
error(request, field, error_detail or detail)
194+
_store_field_errors(request, extras, detail)
133195

134196
if status_code in {HTTP_422_UNPROCESSABLE_ENTITY, HTTP_400_BAD_REQUEST} or isinstance(
135197
exc, PermissionDeniedException
136198
):
137199
return InertiaBack(request)
138200

139201
if inertia_plugin is None:
140-
return InertiaResponse[Any](media_type=preferred_type, content=content, status_code=status_code)
202+
return _create_exception_page_response(
203+
content=content,
204+
preferred_type=preferred_type,
205+
route_component=route_component,
206+
is_inertia=is_inertia,
207+
status_code=status_code,
208+
)
141209

142-
is_unauthorized = status_code == HTTP_401_UNAUTHORIZED or isinstance(exc, NotAuthorizedException)
143-
redirect_to_login = inertia_plugin.config.redirect_unauthorized_to
144-
if is_unauthorized and redirect_to_login is not None:
145-
if request.url.path != redirect_to_login:
146-
# If flash failed (no session), pass error message via query param
147-
if not flash_succeeded and detail:
148-
parsed = urlparse(redirect_to_login)
149-
error_param = f"error={quote(detail, safe='')}"
150-
query = f"{parsed.query}&{error_param}" if parsed.query else error_param
151-
redirect_to_login = urlunparse(parsed._replace(query=query))
152-
return InertiaRedirect(request, redirect_to=redirect_to_login)
153-
# Already on login page - redirect back so Inertia processes flash messages
154-
# (Inertia.js shows 4xx responses in a modal instead of updating page state)
155-
return InertiaBack(request)
210+
unauthorized_response = _create_unauthorized_response(
211+
request,
212+
detail=detail,
213+
flash_succeeded=flash_succeeded,
214+
inertia_plugin=inertia_plugin,
215+
status_code=status_code,
216+
exc=exc,
217+
)
218+
if unauthorized_response is not None:
219+
return unauthorized_response
156220

157221
if status_code in {HTTP_404_NOT_FOUND, HTTP_405_METHOD_NOT_ALLOWED} and (
158222
inertia_plugin.config.redirect_404 is not None and request.url.path != inertia_plugin.config.redirect_404
159223
):
160224
return InertiaRedirect(request, redirect_to=inertia_plugin.config.redirect_404)
161225

162-
return InertiaResponse[Any](media_type=preferred_type, content=content, status_code=status_code)
226+
return _create_exception_page_response(
227+
content=content,
228+
preferred_type=preferred_type,
229+
route_component=route_component,
230+
is_inertia=is_inertia,
231+
status_code=status_code,
232+
)
163233

164234

165235
def _register_exception_handlers( # pyright: ignore[reportUnusedFunction]

0 commit comments

Comments
 (0)