Skip to content

Commit 71fc4f8

Browse files
authored
perf: tighten vite integration hot paths (#315)
## Summary - reduce repeated work in the typegen/export, route metadata, proxy, deploy, and handler paths - keep SPA worker lifespan initialization on the active async event loop - preserve custom Inertia component option keys during startup wrapping
1 parent 95f7652 commit 71fc4f8

24 files changed

Lines changed: 574 additions & 114 deletions

docs/changelog.rst

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,33 @@ Notable changes to this project are documented in this file.
77
Litestar Vite Changelog
88
^^^^^^^^^^^^^^^^^^^^^^^
99

10-
0.26.0 (unreleased)
10+
0.26.0 - 2026-07-05
1111
-------------------
1212

13-
- 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.
14-
- 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.
15-
- Fixed Inertia infinite-scroll ``scrollProps`` to emit a record keyed by data prop name, matching the official client protocol.
16-
- 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.
17-
- Fixed Precognition validation success handling for handlers without an explicit ``request`` parameter by using the middleware request context.
18-
- Fixed Inertia SSR serialization so app, handler, and response type encoders are honored for custom page prop values.
19-
- Fixed Inertia partial reloads so ``deferredProps`` metadata is omitted on partial responses while initial responses still advertise deferred props.
13+
- 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)
14+
- 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)
15+
- Fixed Inertia infinite-scroll ``scrollProps`` to emit a record keyed by data prop name, matching the official client protocol. (#306)
16+
- 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)
17+
- Fixed Precognition validation success handling for handlers without an explicit ``request`` parameter by using the middleware request context. (#306)
18+
- Fixed Inertia SSR serialization so app, handler, and response type encoders are honored for custom page prop values. (#306)
19+
- Fixed Inertia partial reloads so ``deferredProps`` metadata is omitted on partial responses while initial responses still advertise deferred props. (#306)
2020
- Fixed ``litestar assets init`` so generated ``package.json`` files no longer emit duplicate dependency keys. (#302, #303)
2121
- 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)
2222
- 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)
2323
- 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)
2424
- 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)
2525
- Updated npm publishing to use trusted publishing with provenance and no npm token. (#314)
26-
- Fixed Vite dev-server lifecycle handling so unexpected exits are restarted with capped backoff and intentional shutdowns do not trigger restarts.
27-
- 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.
26+
- Fixed Vite dev-server lifecycle handling so unexpected exits are restarted with capped backoff and intentional shutdowns do not trigger restarts. (#317)
27+
- 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)
2828
- 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)
29-
- Added ``RuntimeConfig.extra_route_prefixes`` for deliberately reserving custom backend prefixes from SPA/proxy fallbacks.
29+
- Added ``RuntimeConfig.extra_route_prefixes`` for deliberately reserving custom backend prefixes from SPA/proxy fallbacks. (#317)
3030
- Fixed ``getCsrfToken()`` so SPA and HTMX helpers can fall back to the ``csrftoken`` or ``XSRF-TOKEN`` cookie when injected page-state sources are absent. (#299, #310)
31+
- Hardened HTMX helper handling so dynamic ``href``, ``src``, and ``action`` attributes reject dangerous protocols and expression checks catch denied globals reconstructed through string concatenation. (#316)
32+
- Simplified Vite integration internals by consolidating type-config resolution, hotfile handling, proxy helpers, Inertia prop wrappers, and typing-only helper paths without removing public exports. (#316)
33+
- Reduced duplicate work in type generation, route metadata extraction, proxy header filtering, deploy diffing, bridge reads, placeholder probing, and Inertia response wrapping while preserving generated output paths and public behavior. (#315)
34+
- Fixed SPA startup so async lifespan initialization uses the async handler path and custom Inertia component option keys are preserved when startup wrappers are installed. (#315)
35+
- Fixed production SPA manifest resolution for Vite's default ``.vite/manifest.json`` path and recursive deploy change detection for nested bundle assets. (#315)
36+
- Updated Python dependencies: ``typing-extensions`` 4.16.0, ``prek`` 0.4.8, ``slotscheck`` 0.20.1, and ``coverage`` 7.15.0. (#309)
3137

3238
0.25.0 - 2026-06-25
3339
-------------------

src/js/src/index.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1504,29 +1504,47 @@ function dirname(): string {
15041504
return path.resolve(process.cwd(), "../dist/js")
15051505
}
15061506

1507+
let cachedDevServerPlaceholderCandidates: string[] | undefined
1508+
let cachedDevServerPlaceholderPath: string | undefined
1509+
15071510
function getDevServerPlaceholderCandidates(): string[] {
1511+
if (cachedDevServerPlaceholderCandidates) {
1512+
return cachedDevServerPlaceholderCandidates
1513+
}
1514+
15081515
const distJsRoot = "dist/js"
15091516
const placeholderName = "dev-server-index.html"
15101517
const moduleDir = dirname()
15111518

15121519
const candidates = [
1513-
path.join(dirname(), placeholderName),
1520+
path.join(moduleDir, placeholderName),
15141521
path.join(process.cwd(), distJsRoot, placeholderName),
15151522
path.join(process.cwd(), "src", "js", distJsRoot, placeholderName),
15161523
path.resolve(process.cwd(), "..", distJsRoot, placeholderName),
15171524
path.join(moduleDir, distJsRoot, placeholderName),
15181525
]
15191526

1520-
return [...new Set(candidates)]
1527+
cachedDevServerPlaceholderCandidates = [...new Set(candidates)]
1528+
return cachedDevServerPlaceholderCandidates
15211529
}
15221530

15231531
async function loadDevServerPlaceholder(): Promise<string> {
1532+
if (cachedDevServerPlaceholderPath) {
1533+
try {
1534+
return await fs.promises.readFile(cachedDevServerPlaceholderPath, "utf-8")
1535+
} catch {
1536+
cachedDevServerPlaceholderPath = undefined
1537+
}
1538+
}
1539+
15241540
const candidates = getDevServerPlaceholderCandidates()
15251541
let lastError: unknown = new Error("Failed to load dev server placeholder index.html")
15261542

15271543
for (const candidatePath of candidates) {
15281544
try {
1529-
return await fs.promises.readFile(candidatePath, "utf-8")
1545+
const content = await fs.promises.readFile(candidatePath, "utf-8")
1546+
cachedDevServerPlaceholderPath = candidatePath
1547+
return content
15301548
} catch (error) {
15311549
lastError = error
15321550
}

src/js/src/shared/bridge-schema.ts

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -335,28 +335,53 @@ export function parseBridgeSchema(value: unknown): BridgeSchema {
335335
export function readBridgeConfig(explicitPath?: string): BridgeSchema | null {
336336
const envPath = explicitPath ?? process.env.LITESTAR_VITE_CONFIG_PATH
337337
if (envPath) {
338-
if (!fs.existsSync(envPath)) {
339-
return null
340-
}
341-
try {
342-
return readBridgeConfigFile(envPath)
343-
} catch (error) {
344-
warn(error instanceof Error ? error.message : String(error))
345-
return null
346-
}
338+
return readBridgeConfigFileCached(envPath)
347339
}
348340

349341
const defaultPath = path.join(process.cwd(), ".litestar.json")
350-
if (fs.existsSync(defaultPath)) {
351-
try {
352-
return readBridgeConfigFile(defaultPath)
353-
} catch (error) {
354-
warn(error instanceof Error ? error.message : String(error))
355-
return null
342+
return readBridgeConfigFileCached(defaultPath)
343+
}
344+
345+
interface BridgeConfigCacheEntry {
346+
mtimeMs: number
347+
config: BridgeSchema | null
348+
}
349+
350+
const bridgeConfigCache = new Map<string, BridgeConfigCacheEntry>()
351+
352+
function readBridgeConfigFileCached(filePath: string): BridgeSchema | null {
353+
const resolvedPath = path.resolve(filePath)
354+
if (!fs.existsSync(resolvedPath)) {
355+
return null
356+
}
357+
358+
let mtimeMs: number | null = null
359+
try {
360+
mtimeMs = fs.statSync(resolvedPath).mtimeMs
361+
} catch {
362+
mtimeMs = null
363+
}
364+
365+
if (mtimeMs !== null) {
366+
const cached = bridgeConfigCache.get(resolvedPath)
367+
if (cached?.mtimeMs === mtimeMs) {
368+
return cached.config
356369
}
357370
}
358371

359-
return null
372+
try {
373+
const config = readBridgeConfigFile(resolvedPath)
374+
if (mtimeMs !== null) {
375+
bridgeConfigCache.set(resolvedPath, { mtimeMs, config })
376+
}
377+
return config
378+
} catch (error) {
379+
warn(error instanceof Error ? error.message : String(error))
380+
if (mtimeMs !== null) {
381+
bridgeConfigCache.set(resolvedPath, { mtimeMs, config: null })
382+
}
383+
return null
384+
}
360385
}
361386

362387
function readBridgeConfigFile(filePath: string): BridgeSchema {

src/js/src/shared/typegen-plugin.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,10 @@ export function createLitestarTypeGenPlugin(typesConfig: RequiredTypeGenConfig,
341341
},
342342

343343
async buildStart() {
344+
if (resolvedConfig?.command === "build" && process.env.LITESTAR_VITE_SKIP_BUILD_TYPEGEN === "1") {
345+
return
346+
}
347+
344348
if (typesConfig.enabled && hasPythonConfig === false) {
345349
const projectRoot = resolvedConfig?.root ?? process.cwd()
346350
const openapiPath = path.resolve(projectRoot, typesConfig.openapiPath)

src/js/tests/index.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1539,6 +1539,26 @@ describe("litestar-vite-plugin", () => {
15391539
expect(mockNext).not.toHaveBeenCalled()
15401540
})
15411541

1542+
it("caches placeholder path probing across placeholder responses", async () => {
1543+
const appUrl = "http://test.app"
1544+
process.env.APP_URL = appUrl
1545+
await setupServer()
1546+
mockFs(null)
1547+
1548+
const existsSpy = vi.spyOn(fs, "existsSync")
1549+
1550+
await mockMiddleware({ url: "/index.html", originalUrl: "/index.html" }, mockRes, mockNext)
1551+
const firstProbeCount = existsSpy.mock.calls.filter(([candidate]) => String(candidate).endsWith("dev-server-index.html")).length
1552+
1553+
mockRes.end.mockClear()
1554+
await mockMiddleware({ url: "/", originalUrl: "/" }, mockRes, mockNext)
1555+
const totalProbeCount = existsSpy.mock.calls.filter(([candidate]) => String(candidate).endsWith("dev-server-index.html")).length
1556+
1557+
expect(totalProbeCount).toBe(firstProbeCount)
1558+
expect(mockRes.end).toHaveBeenCalledWith(actualPlaceholderContent.replace(/{{ APP_URL }}/g, appUrl))
1559+
expect(mockNext).not.toHaveBeenCalled()
1560+
})
1561+
15421562
it("serves placeholder when index.html is not detected and url is /", async () => {
15431563
// When no index.html exists (hybrid/inertia mode), serve the placeholder at root
15441564
// This helps users who accidentally navigate to the Vite dev server port

src/js/tests/shared/bridge-schema.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from "node:fs"
22
import os from "node:os"
33
import path from "node:path"
4-
import { describe, expect, it } from "vitest"
4+
import { describe, expect, it, vi } from "vitest"
55
import { parseBridgeSchema, readBridgeConfig } from "../../src/shared/bridge-schema"
66

77
const baseBridgeConfig = {
@@ -98,6 +98,29 @@ describe("bridge schema additive fields", () => {
9898
fs.rmSync(tmpDir, { recursive: true, force: true })
9999
}
100100
})
101+
102+
it("memoizes bridge file reads until path mtime changes", () => {
103+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "litestar-bridge-"))
104+
const configPath = path.join(tmpDir, ".litestar.json")
105+
fs.writeFileSync(configPath, JSON.stringify(baseBridgeConfig))
106+
const readSpy = vi.spyOn(fs, "readFileSync")
107+
108+
try {
109+
expect(readBridgeConfig(configPath)?.port).toBe(5173)
110+
expect(readBridgeConfig(configPath)?.port).toBe(5173)
111+
expect(readSpy).toHaveBeenCalledTimes(1)
112+
113+
fs.writeFileSync(configPath, JSON.stringify({ ...baseBridgeConfig, port: 5174 }))
114+
const mtime = new Date(Date.now() + 1000)
115+
fs.utimesSync(configPath, mtime, mtime)
116+
117+
expect(readBridgeConfig(configPath)?.port).toBe(5174)
118+
expect(readSpy).toHaveBeenCalledTimes(2)
119+
} finally {
120+
readSpy.mockRestore()
121+
fs.rmSync(tmpDir, { recursive: true, force: true })
122+
}
123+
})
101124
})
102125

103126
describe("bridge schema typegen failOnError", () => {

src/js/tests/shared/typegen-plugin.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,23 @@ describe("createLitestarTypeGenPlugin", () => {
180180
expect(context.error).not.toHaveBeenCalled()
181181
})
182182

183+
it("skips buildStart generation when Python assets build already ran typegen", async () => {
184+
process.env.LITESTAR_VITE_SKIP_BUILD_TYPEGEN = "1"
185+
const plugin = createPlugin()
186+
plugin.configResolved?.(createResolvedConfig("build") as never)
187+
const context = { error: vi.fn(), warn: vi.fn() }
188+
189+
try {
190+
await plugin.buildStart?.call(context as never)
191+
} finally {
192+
delete process.env.LITESTAR_VITE_SKIP_BUILD_TYPEGEN
193+
}
194+
195+
expect(mocks.runTypeGeneration).not.toHaveBeenCalled()
196+
expect(context.error).not.toHaveBeenCalled()
197+
expect(context.warn).not.toHaveBeenCalled()
198+
})
199+
183200
it("reruns once when a change arrives during active generation", async () => {
184201
let resolveFirst: (value: unknown) => void = () => undefined
185202
const firstRun = new Promise((resolve) => {

src/py/litestar_vite/cli.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@
4848
]
4949

5050

51+
@contextlib.contextmanager
52+
def _temporary_env_var(name: str, value: str) -> "Any":
53+
"""Temporarily set an environment variable."""
54+
previous = os.environ.get(name)
55+
os.environ[name] = value
56+
try:
57+
yield
58+
finally:
59+
if previous is None:
60+
os.environ.pop(name, None)
61+
else:
62+
os.environ[name] = previous
63+
64+
5165
def _format_command(command: "list[str] | None") -> str:
5266
"""Join a command list for display.
5367
@@ -218,13 +232,19 @@ def _prepare_and_build(config: ViteConfig, root_dir: Path, console: Any, app: "L
218232
set_environment(config=config)
219233
os.environ.setdefault("VITE_BASE_URL", config.base_url or "/")
220234

221-
ext = config.runtime.external_dev_server
222-
if isinstance(ext, ExternalDevServer) and ext.enabled:
223-
build_cmd = ext.build_command or config.executor.build_command
224-
console.print(f"[dim]Running external build: {' '.join(build_cmd)}[/]")
225-
config.executor.execute(build_cmd, cwd=root_dir)
226-
else:
227-
config.executor.execute(config.build_command, cwd=root_dir)
235+
env_manager = (
236+
_temporary_env_var("LITESTAR_VITE_SKIP_BUILD_TYPEGEN", "1")
237+
if generated_assets and isinstance(config.types, TypeGenConfig)
238+
else contextlib.nullcontext()
239+
)
240+
with env_manager:
241+
ext = config.runtime.external_dev_server
242+
if isinstance(ext, ExternalDevServer) and ext.enabled:
243+
build_cmd = ext.build_command or config.executor.build_command
244+
console.print(f"[dim]Running external build: {' '.join(build_cmd)}[/]")
245+
config.executor.execute(build_cmd, cwd=root_dir)
246+
else:
247+
config.executor.execute(config.build_command, cwd=root_dir)
228248

229249

230250
def _run_vite_build(

src/py/litestar_vite/codegen/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
are kept in private submodules to keep the public API clean.
1111
"""
1212

13-
from litestar_vite.codegen._export import ExportResult, export_integration_assets
13+
from litestar_vite.codegen._export import ExportResult, export_integration_assets, typegen_outputs_requested
1414
from litestar_vite.codegen._inertia import (
1515
InertiaPageMetadata,
1616
extract_inertia_pages,
@@ -44,5 +44,6 @@
4444
"generate_routes_json",
4545
"generate_routes_ts",
4646
"strip_timestamp_for_comparison",
47+
"typegen_outputs_requested",
4748
"write_if_changed",
4849
)

0 commit comments

Comments
 (0)