Skip to content

svelte-check --tsgo writing .svelte-kit/.svelte-check/tsconfig.json triggers a Vite full reload that 500s any SSR render in flight #17148

Description

@jose-manuel-silva

Describe the bug

Running svelte-check --tsgo (or --incremental) while vite dev is running makes Vite treat the run as a TypeScript config change:

[vite] changed tsconfig file detected: /path/to/app/.svelte-kit/.svelte-check/tsconfig.json - Clearing cache and forcing full-reload to ensure TypeScript is compiled with updated config values.

Vite then calls moduleGraph.invalidateAll() on every environment and sends a full-reload to every open tab. Any SSR render that is in flight at that moment fails with a 500 and Svelte error: lifecycle_outside_component (full trace under Logs). Requests that start after the invalidation are fine, so it looks like a random 500 rather than a broken server.

Why it happens. Three pieces, none wrong on its own:

  1. svelte-check (packages/svelte-check/src/incremental.ts, since feat: svelte-check --incremental / --tsgo language-tools#2932) writes its overlay to .svelte-kit/.svelte-check/tsconfig.json and rewrites it unconditionally on every run. Maintainers there consider the location intended (--incremental saves .svelte-check within .svelte-kit language-tools#2941).

  2. Vite (packages/vite/src/node/plugins/esbuild.ts, reloadOnTsconfigChange) reacts to any watched file whose path ends in /tsconfig.json, anywhere under the root, with invalidateAll() + full-reload. It runs from the watcher's change/add/unlink handlers, so server.watch.ignored is the effective gate.

  3. SvelteKit's ignore glob does not cover nested files in outDir. packages/kit/src/exports/vite/index.js:

    watch: {
        ignored: [
            // Ignore all siblings of config.kit.outDir/generated
            `${out_dir}/!(generated)`
        ]
    }

    !(generated) is a single-segment extglob. It matches .svelte-kit/tsconfig.json and the directory .svelte-kit/.svelte-check, but not .svelte-kit/.svelte-check/tsconfig.json (nor .svelte-kit/types/**). Checked with picomatch and chokidar's { dot: true }:

    OUT/tsconfig.json               => true   (ignored)
    OUT/.svelte-check               => true   (ignored)
    OUT/.svelte-check/tsconfig.json => false  (watched!)
    OUT/types/a/$types.d.ts         => false  (watched!)
    OUT/generated/root.js           => false  (watched, as intended)
    

    On macOS chokidar uses one recursive fsevents watch and filters each event by its own path (FsEventsHandler.checkIgnored); a directory-level match only extends to descendants once an event for the directory itself is seen with directory stats. .svelte-check/ already exists when vite dev starts, so the file event passes straight through to Vite. (Read from the bundled chokidar source. On Linux the nodefs handler never descends into an ignored directory, so it may not reproduce there; that is a difference in chokidar handlers, not in the glob.)

Why the 500. The ?v=72a77cde stamp in the trace is the SSR dependency optimizer's hash (vite-plugin-svelte prebundles the svelte packages for the ssr environment too). A request has already evaluated svelte/internal/server and started rendering; invalidateAll() clears the module graph underneath it; the next module the render imports (here svelte-sonner's Toaster) is evaluated fresh against a new instance of svelte/internal/server. component() pushed the current component in the old instance, setContext reads it in the new one and finds nothing.

The race is easy to hit in a normal svelte-kit sync && svelte-check run: sync rewrites .svelte-kit/generated/*, which triggers a page reload, the open tab re-requests the page, and about a second later svelte-check writes its tsconfig and Vite invalidates everything under that render. In a real-sized app, where a page render takes a few seconds, the tab's reload request is nearly always still in flight.

Proposed fix (kit). Kit owns both outDir and the ignore list, and the comment already states the intent ("ignore all siblings of generated"). A second entry makes it cover nested paths on every platform:

watch: {
    ignored: [
        // Ignore all siblings of config.kit.outDir/generated, at any depth
        `${out_dir}/!(generated)`,
        `${out_dir}/!(generated)/**`
    ]
}

picomatch ({ dot: true }) with both entries:

OUT/tsconfig.json               => true
OUT/.svelte-check/tsconfig.json => true
OUT/types/a/$types.d.ts         => true
OUT/generated                   => false
OUT/generated/root.js           => false
OUT/generated/client/app.js     => false

Verified in the reproduction: with that entry in server.watch.ignored, svelte-check --tsgo against the running dev server no longer logs the tsconfig change, reloads, or fails the in-flight request; the control run without it does.

Alternatives, if you would rather see it fixed elsewhere: svelte-check could name the overlay anything other than tsconfig.json (Vite keys on endsWith('/tsconfig.json')) or skip the write when the content is unchanged, but kit's glob would still be leaky for any other tooling writing into .svelte-kit. Vite could narrow the reload to tsconfigs that tsconfck actually resolved for a module (vitejs/vite#22588 goes partway but keeps the full reload).

Happy to open a PR for the kit change if you agree with the direction.

Reproduction

https://github.com/jose-manuel-silva/sveltekit-svelte-check-tsgo-reload

Minimal SvelteKit project (sv create --template minimal --types ts) plus svelte-sonner's <Toaster /> in +layout.svelte and a +page.server.ts whose load sleeps 4s (stands in for a real page's render time and opens the window for the race).

bun install
npx svelte-check --tsconfig ./tsconfig.json --tsgo   # creates .svelte-kit/.svelte-check/
npx vite dev --port 5173

Second terminal:

curl -s -o /dev/null -w '%{http_code}\n' http://localhost:5173/ &
sleep 1
npx svelte-check --tsconfig ./tsconfig.json --tsgo
wait

Expected 200, actual 500 plus the log below. Every time, on macOS, under both Node 25 and Bun 1.4. Uncomment the server.watch.ignored line in the repro's vite.config.ts and the same steps print 200 with no reload. Without the artificial delay the render finishes before the invalidation and only the spurious full reload remains, which is why a trivially small app does not show the 500.

Logs

4:56:05 PM [vite] changed tsconfig file detected: /path/to/repro/.svelte-kit/.svelte-check/tsconfig.json - Clearing cache and forcing full-reload to ensure TypeScript is compiled with updated config values.
Svelte error: lifecycle_outside_component
`setContext(...)` can only be used during component initialisation
https://svelte.dev/e/lifecycle_outside_component
    at lifecycle_outside_component (node_modules/svelte/src/internal/shared/errors.js:78:17)


Same thing in the real app where we first saw it. Note the request that fails is the one whose render straddles the tsconfig message (5s render, started at :54, returned at :59); the request 5s later is fine:


3:54:53 PM [vite] (client) page reload .svelte-kit/generated/client/nodes/0.js
3:54:53 PM [vite] (ssr) page reload .svelte-kit/generated/root.svelte
3:54:54 PM [vite] changed tsconfig file detected: /path/to/app/.svelte-kit/.svelte-check/tsconfig.json - Clearing cache and forcing full-reload to ensure TypeScript is compiled with updated config values.
Svelte error: lifecycle_outside_component
`setContext(...)` can only be used during component initialisation
https://svelte.dev/e/lifecycle_outside_component
    at lifecycle_outside_component (node_modules/svelte/src/internal/shared/errors.js:78:21)
    at get_or_init_context_map (node_modules/svelte/src/internal/shared/context.js:48:3)
    at setContext (node_modules/svelte/src/internal/server/context.js:43:2)
    at <anonymous> (node_modules/svelte-sonner/dist/Toaster.svelte:383:19)
    at child (node_modules/svelte/src/internal/server/renderer.js:214:18)
    at component (node_modules/svelte/src/internal/server/renderer.js:319:22)
    at Toaster (node_modules/svelte-sonner/dist/Toaster.svelte?v=72a77cde:75:22)
    at <anonymous> (src/lib/components/ui/sonner/sonner.svelte:70:38)
    at child (node_modules/svelte/src/internal/server/renderer.js:214:18)
    at component (node_modules/svelte/src/internal/server/renderer.js:319:22)
[15:54:59.201] INFO: request GET /platform-requests/<id> status: 500 durationMs: 5091
[15:55:04.524] INFO: request GET /login status: 200 durationMs: 2813

System Info

System:
    OS: macOS 15.7.5
    CPU: (10) arm64 Apple M4
    Memory: 304.20 MB / 24.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 25.6.1 - /opt/homebrew/bin/node
    npm: 11.9.0 - /opt/homebrew/bin/npm
    pnpm: 10.32.1 - /opt/homebrew/bin/pnpm
    bun: 1.4.2 - /opt/homebrew/bin/bun
    Deno: 2.7.4 - /opt/homebrew/bin/deno
  npmPackages:
    @sveltejs/adapter-auto: ^7.0.1 => 7.0.1
    @sveltejs/kit: ^2.70.3 => 2.70.3
    @sveltejs/vite-plugin-svelte: ^7.1.2 => 7.3.0
    svelte: 5.56.10 => 5.56.10
    svelte-check: ^4.7.6 => 4.7.6
    svelte-sonner: ^1.2.1 => 1.2.1
    vite: 8.2.2 => 8.2.2


(kit `main` / 3.0.0-next carries the same `${out_dir}/!(generated)` glob, so it is affected too. Also reproduced with vite 8.3.0 and svelte 5.57.0.)

Severity

serious, but I can work around it

Additional Information

Workaround, in vite.config.ts:

server: {
    watch: {
        ignored: ['**/.svelte-kit/.svelte-check/**']
    }
}

Related:

Disclosure: the investigation, root-cause analysis, reproduction project and this write-up were done by Claude (Fable 5.1, Anthropic) working in Claude Code. I reproduced the failure in my own app with the open tab and confirmed the 500 / 200 results of the repro steps and the workaround myself.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions