From e4ef161ad3cb0f78dd0bcc6ca7c20191b7e756eb Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 7 Aug 2026 17:21:26 -0400 Subject: [PATCH 01/10] feat: load the SvelteKit config lazily via`@sveltejs/load-config` --- .changeset/lazy-svelte-config.md | 42 ++++++ CLAUDE.md | 4 +- package-lock.json | 18 ++- package.json | 1 + src/docs/gen.md | 2 +- src/docs/task.md | 2 +- src/lib/changeset.task.ts | 5 +- src/lib/constants.ts | 6 + src/lib/esbuild_plugin_svelte.ts | 4 +- src/lib/filer.ts | 10 +- src/lib/gen.ts | 6 +- src/lib/gen_helpers.ts | 6 +- src/lib/gro.config.default.ts | 26 ++-- src/lib/gro_plugin_server.ts | 19 +-- src/lib/loader.ts | 19 ++- src/lib/module.ts | 7 +- src/lib/paths.ts | 17 ++- src/lib/run_gen.ts | 6 +- src/lib/run_task.ts | 6 +- src/lib/svelte_config.ts | 170 +++++++++++++++++++------ src/lib/sveltekit_helpers.ts | 16 ++- src/lib/task.ts | 6 +- src/test/esbuild_plugin_svelte.test.ts | 6 +- src/test/svelte_config.test.ts | 103 +++++++++++++++ src/test/test_helpers.ts | 2 +- 25 files changed, 406 insertions(+), 103 deletions(-) create mode 100644 .changeset/lazy-svelte-config.md create mode 100644 src/test/svelte_config.test.ts diff --git a/.changeset/lazy-svelte-config.md b/.changeset/lazy-svelte-config.md new file mode 100644 index 0000000000..3113e329c9 --- /dev/null +++ b/.changeset/lazy-svelte-config.md @@ -0,0 +1,42 @@ +--- +'@fuzdev/gro': minor +--- + +feat: load the SvelteKit config lazily via +[`@sveltejs/load-config`](https://github.com/sveltejs/language-tools/tree/master/packages/load-config) + +Gro read the SvelteKit config eagerly at module scope, so every invocation paid for +it twice - once on the main thread and once on the Node loader's worker thread - even +for tasks that never touch it. It's now loaded on demand and memoized, and reading it +goes through `@sveltejs/load-config`, which adds `svelte.config.{ts,mts,cjs,mjs}` +support, resolves through `vite.config` when one is present, and applies SvelteKit's +own defaults instead of Gro's hand-rolled fallbacks. A config that fails to load now +throws instead of being silently ignored. + +The loader keeps reading the config at module scope and opts out of Vite resolution: +it runs on a worker thread, where Vite's resolution can't run because it calls +`process.chdir`. A custom `svelte_config_filename` also opts out, because Vite's +resolution finds `svelte.config.*` on its own. + +Also fixes `parse_svelte_config` mutating the `compilerOptions` of the config passed +to it, and normalizes `kit.env.dir` to a project-relative path - it's serialized into +the generated `$env/dynamic/*` modules, so an absolute one would bake the build +machine's directory into server bundles. + +Breaking changes: + +- `TaskContext.svelte_config` and `GenContext.svelte_config` are now + `Promise` - `await` them +- `default_svelte_config` is replaced by `load_default_svelte_config()` +- `parse_svelte_config` takes `{dir, svelte_config}` instead of `{dir_or_config}` +- `ROUTES_DIRNAME` is removed +- `paths.lib`, `LIB_DIRNAME`, `LIB_PATH`, and `LIB_DIR` are the conventional `src/lib` + rather than derived from `kit.files.lib`, so that `paths` stays free of the config. + Read `lib_path` off `ParsedSvelteConfig` to honor a customized `files.lib`, and point + `task_root_dirs` at it in `gro.config.ts` if tasks live there +- `MODULE_PATH_LIB_PREFIX` is always `$lib/`, matching SvelteKit, which names the alias + `$lib` no matter where `files.lib` points - it was previously derived from `files.lib` +- `has_sveltekit_library` takes an optional `ParsedSvelteConfig` and checks the + `@sveltejs/package` dependency before the lib directory +- the default config detects plugins inside `plugins()` instead of when the config + loads, so tasks other than `dev` and `build` no longer trigger detection diff --git a/CLAUDE.md b/CLAUDE.md index 49ec1e15c8..6447c5db00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,7 @@ Task context: interface TaskContext { args: TArgs; config: GroConfig; - svelte_config: ParsedSvelteConfig; + svelte_config: Promise; filer: Filer; log: Logger; timings: Timings; @@ -193,7 +193,7 @@ Gen context: ```typescript interface GenContext { config: GroConfig; - svelte_config: ParsedSvelteConfig; + svelte_config: Promise; filer: Filer; log: Logger; timings: Timings; diff --git a/package-lock.json b/package-lock.json index 30b999ef8e..83a11601ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@fuzdev/tsv_wasm": "^0.2.0", + "@sveltejs/load-config": "0.2.2", "chokidar": "^5.0.0", "dotenv": "^17.2.3", "esm-env": "^1.2.2", @@ -1829,10 +1830,9 @@ } }, "node_modules/@sveltejs/load-config": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.1.1.tgz", - "integrity": "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA==", - "dev": true, + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.2.tgz", + "integrity": "sha512-K7dsJDQxBOF+f+epuhMactcjK2VP4MRkLKtwSykNtEI+cKVEyzrmmhQ1pmoxI800m4JKcyJ05L2M4yPwAGiBNw==", "license": "MIT", "engines": { "node": ">= 18.0.0" @@ -3992,6 +3992,16 @@ "typescript": ">=5.0.0" } }, + "node_modules/svelte-check/node_modules/@sveltejs/load-config": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.1.1.tgz", + "integrity": "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, "node_modules/svelte-check/node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", diff --git a/package.json b/package.json index cf798c2c1d..2bfc5ac45d 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ ], "dependencies": { "@fuzdev/tsv_wasm": "^0.2.0", + "@sveltejs/load-config": "0.2.2", "chokidar": "^5.0.0", "dotenv": "^17.2.3", "esm-env": "^1.2.2", diff --git a/src/docs/gen.md b/src/docs/gen.md index 24927cfba4..b174f78317 100644 --- a/src/docs/gen.md +++ b/src/docs/gen.md @@ -116,7 +116,7 @@ The generate function receives a `GenContext` object: ```ts export interface GenContext { config: GroConfig; - svelte_config: ParsedSvelteConfig; + svelte_config: Promise; filer: Filer; log: Logger; timings: Timings; diff --git a/src/docs/task.md b/src/docs/task.md index 499683ec6e..3530acc811 100644 --- a/src/docs/task.md +++ b/src/docs/task.md @@ -163,7 +163,7 @@ import type {TaskContext} from '@fuzdev/gro'; export interface TaskContext { args: TArgs; config: GroConfig; - svelte_config: ParsedSvelteConfig; + svelte_config: Promise; log: Logger; timings: Timings; invoke_task: InvokeTask; diff --git a/src/lib/changeset.task.ts b/src/lib/changeset.task.ts index 99801d05cd..0e9f7c6766 100644 --- a/src/lib/changeset.task.ts +++ b/src/lib/changeset.task.ts @@ -102,7 +102,10 @@ export const task: Task = { const package_json = await package_json_load(); - const has_sveltekit_library_result = await has_sveltekit_library(package_json, svelte_config); + const has_sveltekit_library_result = await has_sveltekit_library( + package_json, + await svelte_config + ); if (!has_sveltekit_library_result.ok) { throw new TaskError( 'Failed to find SvelteKit library: ' + has_sveltekit_library_result.message diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 7221e208e2..678df1919a 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -20,6 +20,12 @@ export const GRO_DIR = GRO_DIRNAME + '/'; export const GRO_DEV_DIR = GRO_DEV_DIRNAME + '/'; export const GRO_CONFIG_FILENAME = 'gro.config.ts'; export const SVELTE_CONFIG_FILENAME = 'svelte.config.js'; +/** + * SvelteKit's alias for the library directory. + * Always `$lib` no matter where `files.lib` points. + * @see https://svelte.dev/docs/kit/configuration#files + */ +export const SVELTEKIT_LIB_ALIAS = '$lib'; export const VITE_CONFIG_FILENAME = 'vite.config.ts'; export const NODE_MODULES_DIRNAME = 'node_modules'; export const PACKAGE_JSON_FILENAME = 'package.json'; diff --git a/src/lib/esbuild_plugin_svelte.ts b/src/lib/esbuild_plugin_svelte.ts index 1ce1be7613..1dda3bc497 100644 --- a/src/lib/esbuild_plugin_svelte.ts +++ b/src/lib/esbuild_plugin_svelte.ts @@ -12,7 +12,7 @@ import { relative } from 'node:path'; import { to_define_import_meta_env, default_ts_transform_options } from './esbuild_helpers.ts'; import { - default_svelte_config, + SVELTE_COMPILE_OPTIONS_DEFAULT, to_default_compile_module_options, type ParsedSvelteConfig } from './svelte_config.ts'; @@ -34,7 +34,7 @@ export const esbuild_plugin_svelte = (options: EsbuildPluginSvelteOptions): esbu dev, base_url, dir = process.cwd(), - svelte_compile_options = default_svelte_config.svelte_compile_options, + svelte_compile_options = SVELTE_COMPILE_OPTIONS_DEFAULT, svelte_compile_module_options = to_default_compile_module_options(svelte_compile_options), svelte_preprocessors, ts_transform_options = default_ts_transform_options, diff --git a/src/lib/filer.ts b/src/lib/filer.ts index e65e979743..549d2f6026 100644 --- a/src/lib/filer.ts +++ b/src/lib/filer.ts @@ -19,12 +19,16 @@ import { import { paths } from './paths.ts'; import { parse_imports } from './parse_imports.ts'; import { resolve_specifier } from './resolve_specifier.ts'; -import { default_svelte_config } from './svelte_config.ts'; +import { load_default_svelte_config } from './svelte_config.ts'; import { map_sveltekit_aliases } from './sveltekit_helpers.ts'; import { SVELTEKIT_GLOBAL_SPECIFIER } from './constants.ts'; import type { Disknode } from './disknode.ts'; -const aliases = Object.entries(default_svelte_config.alias); +let aliases: Array<[string, string]> | undefined; + +/** Loaded on demand so constructing a `Filer` doesn't read the SvelteKit config. */ +const load_aliases = async (): Promise> => + (aliases ??= Object.entries((await load_default_svelte_config()).alias)); export type OnFilerChange = (change: WatcherChange, disknode: Disknode) => void; @@ -272,7 +276,7 @@ export class Filer { } for (const specifier of imported) { if (SVELTEKIT_GLOBAL_SPECIFIER.test(specifier)) continue; - const path = map_sveltekit_aliases(specifier, aliases); + const path = map_sveltekit_aliases(specifier, await load_aliases()); let path_id; // TODO replace `resolve_specifier` with `import.meta.resolve` for local specifiers too diff --git a/src/lib/gen.ts b/src/lib/gen.ts index fc3415e069..221bf286b8 100644 --- a/src/lib/gen.ts +++ b/src/lib/gen.ts @@ -63,7 +63,11 @@ export interface GenConfig { export interface GenContext { config: GroConfig; - svelte_config: ParsedSvelteConfig; + /** + * Resolved on first access, so genfiles that don't touch it + * never pay to read the SvelteKit config. + */ + svelte_config: Promise; filer: Filer; log: Logger; timings: Timings; diff --git a/src/lib/gen_helpers.ts b/src/lib/gen_helpers.ts index 6c9b1d3ca2..264620f2a3 100644 --- a/src/lib/gen_helpers.ts +++ b/src/lib/gen_helpers.ts @@ -13,7 +13,7 @@ import { type GenDependencies, type GenDependenciesConfig } from './gen.ts'; -import { default_svelte_config } from './svelte_config.ts'; +import { load_default_svelte_config } from './svelte_config.ts'; import { to_root_path } from './paths.ts'; import { load_module } from './modules.ts'; @@ -100,7 +100,9 @@ const resolve_gen_dependencies = async ( if (typeof dependencies === 'function') { const gen_ctx: GenContext = { config, - svelte_config: default_svelte_config, + get svelte_config() { + return load_default_svelte_config(); + }, filer, log, timings, diff --git a/src/lib/gro.config.default.ts b/src/lib/gro.config.default.ts index 934a5e7db4..d2ebd1658b 100644 --- a/src/lib/gro.config.default.ts +++ b/src/lib/gro.config.default.ts @@ -17,24 +17,28 @@ import { package_json_load } from './package_json.ts'; * - if `src/lib` + `@sveltejs/package`, assumes a Node library - respects `KitConfig.kit.files.lib` * - if `src/lib/server/server.ts`, assumes a Node server - needs config */ -const config: CreateGroConfig = async (cfg, svelte_config) => { - const package_json = await package_json_load(); // TODO gets wastefully loaded by some plugins, maybe put in plugin/task context? how does that interact with `map_package_json`? +const config: CreateGroConfig = (cfg, svelte_config) => { + // Detection is deferred into `plugins` because every Gro invocation loads the config, + // but only `dev` and `build` create plugins - this keeps `package.json` + // and the SvelteKit config off the path of every other task. + cfg.plugins = async () => { + const package_json = await package_json_load(); // TODO gets wastefully loaded by some plugins, maybe put in plugin/task context? how does that interact with `map_package_json`? - const [has_server_result, has_sveltekit_library_result, has_sveltekit_app_result] = - await Promise.all([ - has_server(), - has_sveltekit_library(package_json, svelte_config), - has_sveltekit_app() - ]); + const [has_server_result, has_sveltekit_library_result, has_sveltekit_app_result] = + await Promise.all([ + has_server(), + has_sveltekit_library(package_json, svelte_config), + has_sveltekit_app() + ]); - // put things that generate files before SvelteKit so it can see them - cfg.plugins = () => - [ + // put things that generate files before SvelteKit so it can see them + return [ gro_plugin_gen(), has_server_result.ok ? gro_plugin_server() : null, has_sveltekit_library_result.ok ? gro_plugin_sveltekit_library() : null, has_sveltekit_app_result.ok ? gro_plugin_sveltekit_app() : null ].filter((v) => v !== null); + }; return cfg; }; diff --git a/src/lib/gro_plugin_server.ts b/src/lib/gro_plugin_server.ts index 8c8f969908..1d4987386c 100644 --- a/src/lib/gro_plugin_server.ts +++ b/src/lib/gro_plugin_server.ts @@ -3,7 +3,7 @@ import * as esbuild from 'esbuild'; import type { Config as SvelteConfig } from '@sveltejs/kit'; import { join, resolve } from 'node:path'; import { identity } from '@fuzdev/fuz_util/function.ts'; -import { strip_before, strip_end } from '@fuzdev/fuz_util/string.ts'; +import { strip_before } from '@fuzdev/fuz_util/string.ts'; import type { Result } from '@fuzdev/fuz_util/result.ts'; import { fs_exists } from '@fuzdev/fuz_util/fs.ts'; import { throttle } from '@fuzdev/fuz_util/throttle.ts'; @@ -12,7 +12,7 @@ import type { PathId } from '@fuzdev/fuz_util/path.ts'; import type { Plugin } from './plugin.ts'; import { base_path_to_path_id, LIB_DIRNAME, paths } from './paths.ts'; import { GRO_DEV_DIRNAME, SERVER_DIST_PATH } from './constants.ts'; -import { parse_svelte_config, default_svelte_config } from './svelte_config.ts'; +import { parse_svelte_config, load_default_svelte_config } from './svelte_config.ts'; import { esbuild_plugin_sveltekit_shim_app } from './esbuild_plugin_sveltekit_shim_app.ts'; import { esbuild_plugin_sveltekit_shim_env } from './esbuild_plugin_sveltekit_shim_env.ts'; import { print_build_result, to_define_import_meta_env } from './esbuild_helpers.ts'; @@ -133,13 +133,14 @@ export const gro_plugin_server = ({ return { name: 'gro_plugin_server', setup: async ({ dev, watch, timings, log, config, filer }) => { - const parsed_svelte_config = - !svelte_config && strip_end(dir, '/') === process.cwd() - ? default_svelte_config - : await parse_svelte_config({ - dir_or_config: svelte_config ?? dir, - config_filename: config.svelte_config_filename - }); + // `load_default_svelte_config` memoizes per directory, + // so this shares the parse with the rest of the process when `dir` is the cwd. + const parsed_svelte_config = svelte_config + ? await parse_svelte_config({ svelte_config, dir }) + : await load_default_svelte_config({ + dir, + config_filename: config.svelte_config_filename + }); const { alias, base_url, diff --git a/src/lib/loader.ts b/src/lib/loader.ts index 5f272b3a11..82e2077cf2 100644 --- a/src/lib/loader.ts +++ b/src/lib/loader.ts @@ -13,7 +13,7 @@ import { SVELTEKIT_SHIM_APP_PATHS_MATCHER, sveltekit_shim_app_specifiers } from './sveltekit_shim_app.ts'; -import { default_svelte_config } from './svelte_config.ts'; +import { load_default_svelte_config } from './svelte_config.ts'; import { paths } from './paths.ts'; import { TS_MATCHER, SVELTE_MATCHER, SVELTE_RUNES_MATCHER } from './constants.ts'; import { resolve_specifier } from './resolve_specifier.ts'; @@ -53,6 +53,21 @@ const dev = true; const dir = paths.root; +/* + +Unlike the rest of Gro, the loader reads the config eagerly, at module scope. + +It runs on a worker thread, where resolving through Vite is unavailable because Vite's +resolution calls `process.chdir`, so it opts out and reads the authored config - which +carries everything the loader needs. + +Loading it here rather than on demand is deliberate. Importing the config runs the +`resolve` hook for each of the config's own imports, and a hook that awaited the load +it is part of would deadlock. Doing it during module evaluation, before the hooks go +live, keeps that graph out of them. Nothing is lost by being eager: the alias step +runs for every bare specifier, so the first resolve would load the config anyway. + +*/ const { alias, base_url, @@ -63,7 +78,7 @@ const { svelte_compile_options, svelte_compile_module_options, svelte_preprocessors -} = default_svelte_config; +} = await load_default_svelte_config({ dir, resolve_with_vite: false }); const aliases = Object.entries(alias); diff --git a/src/lib/module.ts b/src/lib/module.ts index 7be3636770..ee977befdd 100644 --- a/src/lib/module.ts +++ b/src/lib/module.ts @@ -1,11 +1,10 @@ -import { LIB_DIRNAME } from './paths.ts'; -import { SOURCE_DIR, SOURCE_DIRNAME } from './constants.ts'; +import { SOURCE_DIR, SOURCE_DIRNAME, SVELTEKIT_LIB_ALIAS } from './constants.ts'; export const MODULE_PATH_SRC_PREFIX = SOURCE_DIR; -export const MODULE_PATH_LIB_PREFIX = `$${LIB_DIRNAME}/`; +export const MODULE_PATH_LIB_PREFIX = SVELTEKIT_LIB_ALIAS + '/'; const INTERNAL_MODULE_MATCHER = new RegExp( - `^(\\.?\\.?|${SOURCE_DIRNAME}|\\$${LIB_DIRNAME})\\/`, + `^(\\.?\\.?|${SOURCE_DIRNAME}|\\${SVELTEKIT_LIB_ALIAS})\\/`, 'u' ); diff --git a/src/lib/paths.ts b/src/lib/paths.ts index e2692ed8db..376706176c 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -1,4 +1,4 @@ -import { join, extname, relative, basename } from 'node:path'; +import { join, extname, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ensure_end, strip_end } from '@fuzdev/fuz_util/string.ts'; import { styleText as st } from 'node:util'; @@ -11,7 +11,6 @@ import { SOURCE_DIR, SVELTEKIT_DIST_DIRNAME } from './constants.ts'; -import { default_svelte_config } from './svelte_config.ts'; /* @@ -20,11 +19,21 @@ It's the same name that Rollup uses. */ -export const LIB_DIRNAME = basename(default_svelte_config.lib_path); +/* + +These are the conventional locations, not the SvelteKit `files` config values, +so that `paths` stays cheap - reading the SvelteKit config imports the config module +and its preprocessors, which is too expensive to do on every Gro invocation. +Code that needs to honor a customized `kit.files.lib` reads `lib_path` +off `ParsedSvelteConfig` instead, and projects that move it +can point `task_root_dirs` at the new location in `gro.config.ts`. + +*/ + +export const LIB_DIRNAME = 'lib'; export const LIB_PATH = SOURCE_DIR + LIB_DIRNAME; /** @trailing_slash */ export const LIB_DIR = LIB_PATH + '/'; -export const ROUTES_DIRNAME = basename(default_svelte_config.routes_path); export interface Paths { /** @trailing_slash */ diff --git a/src/lib/run_gen.ts b/src/lib/run_gen.ts index 8715ad52ba..3a2af1c8dd 100644 --- a/src/lib/run_gen.ts +++ b/src/lib/run_gen.ts @@ -16,7 +16,7 @@ import { import { print_path, to_root_path } from './paths.ts'; import type { format_file as base_format_file } from './format_file.ts'; import type { GroConfig } from './gro_config.ts'; -import { default_svelte_config } from './svelte_config.ts'; +import { load_default_svelte_config } from './svelte_config.ts'; import type { Filer } from './filer.ts'; import type { InvokeTask } from './task.ts'; @@ -45,7 +45,9 @@ export const run_gen = async ( const gen_config = normalize_gen_config(module_meta.mod.gen); const gen_ctx: GenContext = { config, - svelte_config: default_svelte_config, + get svelte_config() { + return load_default_svelte_config(); + }, filer, log, timings, diff --git a/src/lib/run_task.ts b/src/lib/run_task.ts index 23171bdc97..ab613b8652 100644 --- a/src/lib/run_task.ts +++ b/src/lib/run_task.ts @@ -7,7 +7,7 @@ import { z } from 'zod'; import type { Filer } from './filer.ts'; import type { GroConfig } from './gro_config.ts'; import type { invoke_task as base_invoke_task } from './invoke_task.ts'; -import { default_svelte_config } from './svelte_config.ts'; +import { load_default_svelte_config } from './svelte_config.ts'; import { TaskError, type TaskModuleMeta } from './task.ts'; import { log_task_help } from './task_logging.ts'; @@ -58,7 +58,9 @@ export const run_task = async ( output = await task.run({ args, config, - svelte_config: default_svelte_config, + get svelte_config() { + return load_default_svelte_config(); + }, filer, log, timings, diff --git a/src/lib/svelte_config.ts b/src/lib/svelte_config.ts index 67a13f4fe9..b39dcc0ff9 100644 --- a/src/lib/svelte_config.ts +++ b/src/lib/svelte_config.ts @@ -1,9 +1,10 @@ import type { Config as SvelteConfig } from '@sveltejs/kit'; import type { CompileOptions, ModuleCompileOptions, PreprocessorGroup } from 'svelte/compiler'; -import { join } from 'node:path'; +import { isAbsolute, join, relative } from 'node:path'; +import { loadConfig } from '@sveltejs/load-config'; import { EMPTY_OBJECT } from '@fuzdev/fuz_util/object.ts'; -import { SVELTE_CONFIG_FILENAME } from './constants.ts'; +import { SVELTE_CONFIG_FILENAME, SVELTEKIT_LIB_ALIAS } from './constants.ts'; /* eslint-disable @typescript-eslint/no-deprecated */ // see https://github.com/sveltejs/kit/discussions/14240 @@ -11,34 +12,68 @@ import { SVELTE_CONFIG_FILENAME } from './constants.ts'; /* This module is intended to have minimal dependencies to avoid over-imports in the CLI. +Loading is lazy and memoized - see `load_default_svelte_config`. */ +export interface LoadSvelteConfigOptions { + /** + * @default cwd + */ + dir?: string; + /** + * @default `SVELTE_CONFIG_FILENAME` + */ + config_filename?: string; + /** + * Resolve the config through `vite.config` when one is present, + * which applies SvelteKit's own defaults and supports projects + * that configure Svelte from Vite instead of `svelte.config.js`. + * + * Costs a full Vite config resolution, and is unavailable on worker threads + * because it calls `process.chdir`, so the Node loader opts out. + * @default true + */ + resolve_with_vite?: boolean; +} + /** * Loads a SvelteKit config at `dir`. * @returns `null` if no config is found + * @throws if a config is found but fails to load */ export const load_svelte_config = async ({ dir = process.cwd(), - config_filename = SVELTE_CONFIG_FILENAME -}: { dir?: string; config_filename?: string } = EMPTY_OBJECT): Promise => { - try { - return (await import(join(dir, config_filename))).default; - } catch (_err) { - return null; + config_filename = SVELTE_CONFIG_FILENAME, + resolve_with_vite = true +}: LoadSvelteConfigOptions = EMPTY_OBJECT): Promise => { + // Vite's resolution finds `svelte.config.*` on its own, so a custom filename + // can only be honored by importing that file directly. + const use_vite = resolve_with_vite && config_filename === SVELTE_CONFIG_FILENAME; + // Passing the config file instead of its directory tells `loadConfig` to import it directly, + // skipping both the `vite.config` lookup and the `process.chdir` it performs. + const loaded = await loadConfig(use_vite ? dir : join(dir, config_filename), { + traverse: false + }); + if (!loaded) return null; + if ('error' in loaded) { + throw new Error(`Failed to load SvelteKit config at ${loaded.configFilePath}`, { + cause: loaded.error + }); } + // `loadConfig` types the config loosely (`kit` is `unknown`) because it also handles + // plain Svelte projects, but everything Gro reads off it is optional and guarded. + return loaded.config as SvelteConfig; }; /** - * A subset of SvelteKit's config in a form that Gro uses - * because SvelteKit doesn't expose its config resolver. + * A subset of SvelteKit's config in a form that Gro uses. * Flattens things out to keep them simple and easy to pass around, * and doesn't deal with most properties, but includes the full `svelte_config`. * The `base` and `assets` in particular are renamed for clarity with Gro's internal systems, * so these properties become first-class vocabulary inside Gro. */ export interface ParsedSvelteConfig { - // TODO probably fill these out with defaults svelte_config: SvelteConfig | null; alias: Record; base_url: '' | `/${string}` | undefined; @@ -46,15 +81,15 @@ export interface ParsedSvelteConfig { // TODO others, but maybe replace with a Zod schema? https://svelte.dev/docs/kit/configuration /** - * Same as the SvelteKit `files.assets`. + * Same as the SvelteKit `files.assets`, relative to the project directory. */ assets_path: string; /** - * Same as the SvelteKit `files.lib`. + * Same as the SvelteKit `files.lib`, relative to the project directory. */ lib_path: string; /** - * Same as the SvelteKit `files.routes`. + * Same as the SvelteKit `files.routes`, relative to the project directory. */ routes_path: string; @@ -66,51 +101,74 @@ export interface ParsedSvelteConfig { svelte_preprocessors: PreprocessorGroup | Array | undefined; } -// TODO currently incomplete and hack - maybe rethink +/** + * Resolving through Vite yields absolute `files` paths, + * but Gro's vocabulary is relative to the project directory. + */ +const to_project_relative_path = ( + path: string | undefined, + dir: string, + fallback: string +): string => { + if (path === undefined) return fallback; + if (!isAbsolute(path)) return path; + return relative(dir, path) || '.'; +}; + +/** + * Gro compiles for the server by default, + * because SvelteKit handles the client in the normal cases. + */ +export const SVELTE_COMPILE_OPTIONS_DEFAULT: CompileOptions = { generate: 'server' }; + +export interface ParseSvelteConfigOptions extends LoadSvelteConfigOptions { + /** + * An already-loaded config to parse instead of reading one from `dir`. + */ + svelte_config?: SvelteConfig; +} + /** * Returns Gro-relevant properties of a SvelteKit config * as a convenience wrapper around `load_svelte_config`. - * Needed because SvelteKit doesn't expose its config resolver. */ -export const parse_svelte_config = async ({ - dir_or_config = process.cwd(), // TODO maybe not the best API, maybe a type union? `({svelte_config} | {dir}) & {config_filename}` - config_filename = SVELTE_CONFIG_FILENAME -}: { - dir_or_config?: string | SvelteConfig; - config_filename?: string; -} = EMPTY_OBJECT): Promise => { - const svelte_config = - typeof dir_or_config === 'string' - ? await load_svelte_config({ dir: dir_or_config, config_filename }) - : dir_or_config; +export const parse_svelte_config = async ( + options: ParseSvelteConfigOptions = EMPTY_OBJECT +): Promise => { + const { dir = process.cwd() } = options; + + const svelte_config = options.svelte_config ?? (await load_svelte_config(options)); const kit = svelte_config?.kit; - const alias = { $lib: 'src/lib', ...kit?.alias }; + const assets_path = to_project_relative_path(kit?.files?.assets, dir, 'static'); + const lib_path = to_project_relative_path(kit?.files?.lib, dir, 'src/lib'); + const routes_path = to_project_relative_path(kit?.files?.routes, dir, 'src/routes'); + + // SvelteKit always names this alias `$lib` and points it at `files.lib`. + // @see https://svelte.dev/docs/kit/configuration#alias + const alias = { [SVELTEKIT_LIB_ALIAS]: lib_path, ...kit?.alias }; const base_url = kit?.paths?.base; const assets_url = kit?.paths?.assets; - // TODO probably a Zod schema instead - const assets_path = kit?.files?.assets ?? 'static'; - const lib_path = kit?.files?.lib ?? 'src/lib'; - const routes_path = kit?.files?.routes ?? 'src/routes'; - - const env_dir = kit?.env?.dir; + // Relative like the paths above, and for a sharper reason: `env_dir` is serialized into + // the generated `$env/dynamic/*` modules, so an absolute path from Vite resolution would + // bake the build machine's directory into server bundles. + const env_dir = + kit?.env?.dir === undefined ? undefined : to_project_relative_path(kit.env.dir, dir, '.'); const private_prefix = kit?.env?.privatePrefix; const public_prefix = kit?.env?.publicPrefix; - const svelte_compile_options: CompileOptions = svelte_config?.compilerOptions ?? {}; - // Change the default to `generate: 'server'`, - // because SvelteKit handles the client in the normal cases. + const svelte_compile_options: CompileOptions = { ...svelte_config?.compilerOptions }; if (svelte_compile_options.generate === undefined) { - svelte_compile_options.generate = 'server'; + svelte_compile_options.generate = SVELTE_COMPILE_OPTIONS_DEFAULT.generate; } const svelte_compile_module_options = to_default_compile_module_options(svelte_compile_options); // TODO will kit have these separately? const svelte_preprocessors = svelte_config?.preprocess; return { - svelte_config, + svelte_config: svelte_config ?? null, alias, base_url, assets_url, @@ -134,7 +192,37 @@ export const to_default_compile_module_options = ({ warningFilter }: CompileOptions): ModuleCompileOptions => ({ dev, generate, filename, rootDir, warningFilter }); +const default_svelte_configs: Map> = new Map(); + /** - * The parsed SvelteKit config for the cwd, cached globally at the module level. + * The parsed SvelteKit config, memoized per directory and resolution mode. + * + * Loading a SvelteKit config is expensive - it imports the config module and its + * preprocessors, and resolving through Vite costs a full Vite config resolution - + * so callers pull it in on demand instead of paying for it on every Gro invocation. */ -export const default_svelte_config = await parse_svelte_config(); // always load it to keep things simple ahead +export const load_default_svelte_config = ( + options: LoadSvelteConfigOptions = EMPTY_OBJECT +): Promise => { + const { + dir = process.cwd(), + config_filename = SVELTE_CONFIG_FILENAME, + resolve_with_vite = true + } = options; + const key = `${resolve_with_vite ? 'vite' : 'svelte'}:${join(dir, config_filename)}`; + let loading = default_svelte_configs.get(key); + if (loading === undefined) { + loading = parse_svelte_config({ dir, config_filename, resolve_with_vite }); + default_svelte_configs.set(key, loading); + // Evict failures so a long-lived process like `gro dev` picks up a fixed config. + // Attaching the handler here also keeps the cached promise from being reported + // as an unhandled rejection when nothing has awaited it yet. + const failed = loading; + void loading.catch(() => { + if (default_svelte_configs.get(key) === failed) { + default_svelte_configs.delete(key); + } + }); + } + return loading; +}; diff --git a/src/lib/sveltekit_helpers.ts b/src/lib/sveltekit_helpers.ts index 70b094922f..570286b453 100644 --- a/src/lib/sveltekit_helpers.ts +++ b/src/lib/sveltekit_helpers.ts @@ -15,7 +15,7 @@ import { SVELTEKIT_DEV_DIRNAME } from './constants.ts'; import { package_json_has_dependency } from './package_json.ts'; -import { default_svelte_config, type ParsedSvelteConfig } from './svelte_config.ts'; +import { load_default_svelte_config, type ParsedSvelteConfig } from './svelte_config.ts'; import { TaskError } from './task.ts'; export const has_sveltekit_app = async ( @@ -30,7 +30,7 @@ export const has_sveltekit_app = async ( export const has_sveltekit_library = async ( package_json: PackageJson, - svelte_config: ParsedSvelteConfig = default_svelte_config, + svelte_config?: ParsedSvelteConfig, dep_name = SVELTE_PACKAGE_DEP_NAME ): Promise> => { const has_sveltekit_app_result = await has_sveltekit_app(); @@ -38,10 +38,9 @@ export const has_sveltekit_library = async ( return has_sveltekit_app_result; } - if (!(await fs_exists(svelte_config.lib_path))) { - return { ok: false, message: `no SvelteKit lib directory found at ${svelte_config.lib_path}` }; - } - + // Checked before the lib directory because it's the cheaper of the two + // and it's what distinguishes a library from an app, + // so apps bail out without reading the SvelteKit config. if (!package_json_has_dependency(dep_name, package_json)) { return { ok: false, @@ -49,6 +48,11 @@ export const has_sveltekit_library = async ( }; } + const { lib_path } = svelte_config ?? (await load_default_svelte_config()); + if (!(await fs_exists(lib_path))) { + return { ok: false, message: `no SvelteKit lib directory found at ${lib_path}` }; + } + return { ok: true }; }; diff --git a/src/lib/task.ts b/src/lib/task.ts index 0ea0b660eb..c0a8160fbf 100644 --- a/src/lib/task.ts +++ b/src/lib/task.ts @@ -34,7 +34,11 @@ export interface Task< export interface TaskContext { args: TArgs; config: GroConfig; - svelte_config: ParsedSvelteConfig; + /** + * Resolved on first access, so tasks that don't touch it + * never pay to read the SvelteKit config. + */ + svelte_config: Promise; filer: Filer; log: Logger; timings: Timings; diff --git a/src/test/esbuild_plugin_svelte.test.ts b/src/test/esbuild_plugin_svelte.test.ts index eff3e8f983..416145860f 100644 --- a/src/test/esbuild_plugin_svelte.test.ts +++ b/src/test/esbuild_plugin_svelte.test.ts @@ -3,7 +3,7 @@ import * as esbuild from 'esbuild'; import { readFile, rm } from 'node:fs/promises'; import { esbuild_plugin_svelte } from '$lib/esbuild_plugin_svelte.ts'; -import { default_svelte_config } from '$lib/svelte_config.ts'; +import { load_default_svelte_config } from '$lib/svelte_config.ts'; // TODO improve these tests to have automatic caching @@ -14,7 +14,7 @@ test('build for the client', async () => { plugins: [ esbuild_plugin_svelte({ dev: true, - base_url: default_svelte_config.base_url, + base_url: (await load_default_svelte_config()).base_url, svelte_compile_options: { generate: 'client' } }) ], @@ -81,7 +81,7 @@ test('build for the server', async () => { plugins: [ esbuild_plugin_svelte({ dev: true, - base_url: default_svelte_config.base_url + base_url: (await load_default_svelte_config()).base_url }) ], outfile, diff --git a/src/test/svelte_config.test.ts b/src/test/svelte_config.test.ts new file mode 100644 index 0000000000..caf60964ba --- /dev/null +++ b/src/test/svelte_config.test.ts @@ -0,0 +1,103 @@ +import { describe, test, expect } from 'vitest'; +import type { Config as SvelteConfig } from '@sveltejs/kit'; + +import { load_default_svelte_config, parse_svelte_config } from '$lib/svelte_config.ts'; + +const DIR = '/fake/project'; + +const parse = (svelte_config: SvelteConfig) => parse_svelte_config({ svelte_config, dir: DIR }); + +describe('parse_svelte_config', () => { + test('falls back to the conventional paths when nothing is configured', async () => { + const parsed = await parse({}); + expect(parsed.lib_path).toBe('src/lib'); + expect(parsed.routes_path).toBe('src/routes'); + expect(parsed.assets_path).toBe('static'); + expect(parsed.env_dir).toBe(undefined); + }); + + test('keeps already-relative paths as authored', async () => { + const parsed = await parse({ kit: { files: { lib: 'src/library', routes: 'src/pages' } } }); + expect(parsed.lib_path).toBe('src/library'); + expect(parsed.routes_path).toBe('src/pages'); + }); + + // Resolving through Vite yields absolute paths, but Gro's vocabulary is project-relative. + test('makes absolute paths relative to the project directory', async () => { + const parsed = await parse({ + kit: { + files: { + lib: DIR + '/src/lib', + routes: DIR + '/src/routes', + assets: DIR + '/static' + } + } + }); + expect(parsed.lib_path).toBe('src/lib'); + expect(parsed.routes_path).toBe('src/routes'); + expect(parsed.assets_path).toBe('static'); + }); + + // `env_dir` is serialized into the generated `$env/dynamic/*` modules, + // so an absolute path would bake the build machine's directory into server bundles. + test('makes an absolute env dir relative so it stays portable', async () => { + expect((await parse({ kit: { env: { dir: DIR } } })).env_dir).toBe('.'); + expect((await parse({ kit: { env: { dir: DIR + '/config' } } })).env_dir).toBe('config'); + expect((await parse({ kit: { env: { dir: 'config' } } })).env_dir).toBe('config'); + }); + + describe('alias', () => { + test('points `$lib` at the lib path, like SvelteKit', async () => { + expect((await parse({})).alias.$lib).toBe('src/lib'); + expect((await parse({ kit: { files: { lib: 'src/library' } } })).alias.$lib).toBe( + 'src/library' + ); + }); + + test('includes configured aliases and lets them override `$lib`', async () => { + const parsed = await parse({ kit: { alias: { $routes: 'src/routes', $lib: 'elsewhere' } } }); + expect(parsed.alias.$routes).toBe('src/routes'); + expect(parsed.alias.$lib).toBe('elsewhere'); + }); + }); + + describe('svelte_compile_options', () => { + test('defaults to generating for the server', async () => { + expect((await parse({})).svelte_compile_options.generate).toBe('server'); + expect((await parse({})).svelte_compile_module_options.generate).toBe('server'); + }); + + // `generate` is cast in because SvelteKit omits it from its own `compilerOptions` type - + // it reaches Gro from plain Svelte projects, which configure the compiler through Vite. + test('preserves configured compiler options', async () => { + const parsed = await parse({ + compilerOptions: { runes: true, generate: 'client' } as SvelteConfig['compilerOptions'] + }); + expect(parsed.svelte_compile_options.generate).toBe('client'); + expect(parsed.svelte_compile_options.runes).toBe(true); + }); + + test('does not mutate the source config', async () => { + const compilerOptions = { runes: true }; + await parse({ compilerOptions }); + expect(compilerOptions).toEqual({ runes: true }); + }); + }); + + test('passes the config through unparsed properties', async () => { + const svelte_config: SvelteConfig = { kit: { paths: { base: '/base' } } }; + const parsed = await parse(svelte_config); + expect(parsed.svelte_config).toBe(svelte_config); + expect(parsed.base_url).toBe('/base'); + }); +}); + +describe('load_default_svelte_config', () => { + test('memoizes per directory and resolution mode', async () => { + const authored = load_default_svelte_config({ resolve_with_vite: false }); + expect(load_default_svelte_config({ resolve_with_vite: false })).toBe(authored); + // A different resolution mode is a different cache entry. + expect(load_default_svelte_config({ resolve_with_vite: true })).not.toBe(authored); + await expect(authored).resolves.toMatchObject({ lib_path: 'src/lib' }); + }); +}); diff --git a/src/test/test_helpers.ts b/src/test/test_helpers.ts index 990ce0b88f..4608a4f263 100644 --- a/src/test/test_helpers.ts +++ b/src/test/test_helpers.ts @@ -321,7 +321,7 @@ export const create_mock_task_context = ( build_cache_config_hash: 'test_hash', ...config_overrides } as GroConfig, - svelte_config: create_mock_svelte_config(), + svelte_config: Promise.resolve(create_mock_svelte_config()), filer: create_mock_filer(), log: create_mock_logger(), timings: create_mock_timings(), From 9493d85c1b935a60ae137874e524567f9bc981f2 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 7 Aug 2026 17:37:24 -0400 Subject: [PATCH 02/10] cleanup --- .changeset/lazy-svelte-config.md | 10 +++++++++- src/lib/gen_helpers.ts | 2 +- src/lib/gro.config.default.ts | 8 ++------ src/lib/gro_config.ts | 6 +----- src/lib/run_gen.ts | 2 +- src/lib/run_task.ts | 2 +- src/lib/svelte_config.ts | 16 ++++++++++++---- src/test/esbuild_plugin_svelte.test.ts | 8 ++++++-- src/test/svelte_config.test.ts | 21 +++++++++++++++++---- 9 files changed, 50 insertions(+), 25 deletions(-) diff --git a/.changeset/lazy-svelte-config.md b/.changeset/lazy-svelte-config.md index 3113e329c9..38542df52c 100644 --- a/.changeset/lazy-svelte-config.md +++ b/.changeset/lazy-svelte-config.md @@ -21,7 +21,9 @@ resolution finds `svelte.config.*` on its own. Also fixes `parse_svelte_config` mutating the `compilerOptions` of the config passed to it, and normalizes `kit.env.dir` to a project-relative path - it's serialized into the generated `$env/dynamic/*` modules, so an absolute one would bake the build -machine's directory into server bundles. +machine's directory into server bundles. The `svelte_config` on the task and gen +contexts now honors `svelte_config_filename` from `gro.config.ts`, which previously +only `gro_plugin_server` respected. Breaking changes: @@ -40,3 +42,9 @@ Breaking changes: `@sveltejs/package` dependency before the lib directory - the default config detects plugins inside `plugins()` instead of when the config loads, so tasks other than `dev` and `build` no longer trigger detection +- `CreateGroConfig` takes only `base_config` - its second `ParsedSvelteConfig` parameter + was never passed, and passing one would defeat the lazy load +- `esbuild_plugin_svelte`'s default `svelte_compile_options` is + `SVELTE_COMPILE_OPTIONS_DEFAULT` rather than the project's `compilerOptions`, because + the default can't read the config synchronously - pass `svelte_compile_options` from a + `ParsedSvelteConfig` to honor them diff --git a/src/lib/gen_helpers.ts b/src/lib/gen_helpers.ts index 264620f2a3..6c8f68079e 100644 --- a/src/lib/gen_helpers.ts +++ b/src/lib/gen_helpers.ts @@ -101,7 +101,7 @@ const resolve_gen_dependencies = async ( const gen_ctx: GenContext = { config, get svelte_config() { - return load_default_svelte_config(); + return load_default_svelte_config({ config_filename: config.svelte_config_filename }); }, filer, log, diff --git a/src/lib/gro.config.default.ts b/src/lib/gro.config.default.ts index d2ebd1658b..76596d2ede 100644 --- a/src/lib/gro.config.default.ts +++ b/src/lib/gro.config.default.ts @@ -17,7 +17,7 @@ import { package_json_load } from './package_json.ts'; * - if `src/lib` + `@sveltejs/package`, assumes a Node library - respects `KitConfig.kit.files.lib` * - if `src/lib/server/server.ts`, assumes a Node server - needs config */ -const config: CreateGroConfig = (cfg, svelte_config) => { +const config: CreateGroConfig = (cfg) => { // Detection is deferred into `plugins` because every Gro invocation loads the config, // but only `dev` and `build` create plugins - this keeps `package.json` // and the SvelteKit config off the path of every other task. @@ -25,11 +25,7 @@ const config: CreateGroConfig = (cfg, svelte_config) => { const package_json = await package_json_load(); // TODO gets wastefully loaded by some plugins, maybe put in plugin/task context? how does that interact with `map_package_json`? const [has_server_result, has_sveltekit_library_result, has_sveltekit_app_result] = - await Promise.all([ - has_server(), - has_sveltekit_library(package_json, svelte_config), - has_sveltekit_app() - ]); + await Promise.all([has_server(), has_sveltekit_library(package_json), has_sveltekit_app()]); // put things that generate files before SvelteKit so it can see them return [ diff --git a/src/lib/gro_config.ts b/src/lib/gro_config.ts index 9cafbd6f25..739f34859f 100644 --- a/src/lib/gro_config.ts +++ b/src/lib/gro_config.ts @@ -18,7 +18,6 @@ import { import create_default_config from './gro.config.default.ts'; import type { PluginsCreateConfig } from './plugin.ts'; import type { PackageJsonMapper } from './package_json.ts'; -import type { ParsedSvelteConfig } from './svelte_config.ts'; import type { FilerOptions } from './filer.ts'; /** @@ -114,10 +113,7 @@ export interface RawGroConfig { filer_options?: Partial | null; } -export type CreateGroConfig = ( - base_config: GroConfig, - svelte_config?: ParsedSvelteConfig -) => RawGroConfig | Promise; +export type CreateGroConfig = (base_config: GroConfig) => RawGroConfig | Promise; export const create_empty_gro_config = (): GroConfig => ({ plugins: () => [], diff --git a/src/lib/run_gen.ts b/src/lib/run_gen.ts index 3a2af1c8dd..3bef0b48de 100644 --- a/src/lib/run_gen.ts +++ b/src/lib/run_gen.ts @@ -46,7 +46,7 @@ export const run_gen = async ( const gen_ctx: GenContext = { config, get svelte_config() { - return load_default_svelte_config(); + return load_default_svelte_config({ config_filename: config.svelte_config_filename }); }, filer, log, diff --git a/src/lib/run_task.ts b/src/lib/run_task.ts index ab613b8652..85fb96185d 100644 --- a/src/lib/run_task.ts +++ b/src/lib/run_task.ts @@ -59,7 +59,7 @@ export const run_task = async ( args, config, get svelte_config() { - return load_default_svelte_config(); + return load_default_svelte_config({ config_filename: config.svelte_config_filename }); }, filer, log, diff --git a/src/lib/svelte_config.ts b/src/lib/svelte_config.ts index b39dcc0ff9..07cc996d7d 100644 --- a/src/lib/svelte_config.ts +++ b/src/lib/svelte_config.ts @@ -37,6 +37,14 @@ export interface LoadSvelteConfigOptions { resolve_with_vite?: boolean; } +/** + * Whether a load actually goes through Vite. + * Vite's resolution finds `svelte.config.*` on its own, so a custom filename + * can only be honored by importing that file directly. + */ +const uses_vite = (resolve_with_vite: boolean, config_filename: string): boolean => + resolve_with_vite && config_filename === SVELTE_CONFIG_FILENAME; + /** * Loads a SvelteKit config at `dir`. * @returns `null` if no config is found @@ -47,9 +55,7 @@ export const load_svelte_config = async ({ config_filename = SVELTE_CONFIG_FILENAME, resolve_with_vite = true }: LoadSvelteConfigOptions = EMPTY_OBJECT): Promise => { - // Vite's resolution finds `svelte.config.*` on its own, so a custom filename - // can only be honored by importing that file directly. - const use_vite = resolve_with_vite && config_filename === SVELTE_CONFIG_FILENAME; + const use_vite = uses_vite(resolve_with_vite, config_filename); // Passing the config file instead of its directory tells `loadConfig` to import it directly, // skipping both the `vite.config` lookup and the `process.chdir` it performs. const loaded = await loadConfig(use_vite ? dir : join(dir, config_filename), { @@ -209,7 +215,9 @@ export const load_default_svelte_config = ( config_filename = SVELTE_CONFIG_FILENAME, resolve_with_vite = true } = options; - const key = `${resolve_with_vite ? 'vite' : 'svelte'}:${join(dir, config_filename)}`; + // Keyed on whether the load *actually* goes through Vite, not on what was asked for, + // so a custom filename doesn't get one cache entry per requested mode for the same load. + const key = `${uses_vite(resolve_with_vite, config_filename) ? 'vite' : 'svelte'}:${join(dir, config_filename)}`; let loading = default_svelte_configs.get(key); if (loading === undefined) { loading = parse_svelte_config({ dir, config_filename, resolve_with_vite }); diff --git a/src/test/esbuild_plugin_svelte.test.ts b/src/test/esbuild_plugin_svelte.test.ts index 416145860f..ad203603ae 100644 --- a/src/test/esbuild_plugin_svelte.test.ts +++ b/src/test/esbuild_plugin_svelte.test.ts @@ -5,6 +5,10 @@ import { readFile, rm } from 'node:fs/promises'; import { esbuild_plugin_svelte } from '$lib/esbuild_plugin_svelte.ts'; import { load_default_svelte_config } from '$lib/svelte_config.ts'; +// Resolving through Vite would cost a full Vite config resolution and call `process.chdir`, +// and the authored config carries everything these tests need. +const svelte_config = load_default_svelte_config({ resolve_with_vite: false }); + // TODO improve these tests to have automatic caching test('build for the client', async () => { @@ -14,7 +18,7 @@ test('build for the client', async () => { plugins: [ esbuild_plugin_svelte({ dev: true, - base_url: (await load_default_svelte_config()).base_url, + base_url: (await svelte_config).base_url, svelte_compile_options: { generate: 'client' } }) ], @@ -81,7 +85,7 @@ test('build for the server', async () => { plugins: [ esbuild_plugin_svelte({ dev: true, - base_url: (await load_default_svelte_config()).base_url + base_url: (await svelte_config).base_url }) ], outfile, diff --git a/src/test/svelte_config.test.ts b/src/test/svelte_config.test.ts index caf60964ba..58af66aa0a 100644 --- a/src/test/svelte_config.test.ts +++ b/src/test/svelte_config.test.ts @@ -93,11 +93,24 @@ describe('parse_svelte_config', () => { }); describe('load_default_svelte_config', () => { + // Uses `DIR`, which holds no config, so the Vite-resolving entry stays cheap - + // resolving a real project through Vite costs a full Vite config resolution + // and calls `process.chdir`, which is not something to do from a unit test. test('memoizes per directory and resolution mode', async () => { - const authored = load_default_svelte_config({ resolve_with_vite: false }); - expect(load_default_svelte_config({ resolve_with_vite: false })).toBe(authored); + const authored = load_default_svelte_config({ dir: DIR, resolve_with_vite: false }); + expect(load_default_svelte_config({ dir: DIR, resolve_with_vite: false })).toBe(authored); // A different resolution mode is a different cache entry. - expect(load_default_svelte_config({ resolve_with_vite: true })).not.toBe(authored); - await expect(authored).resolves.toMatchObject({ lib_path: 'src/lib' }); + const resolved_with_vite = load_default_svelte_config({ dir: DIR, resolve_with_vite: true }); + expect(resolved_with_vite).not.toBe(authored); + // As is a different directory. + const nested = load_default_svelte_config({ dir: DIR + '/nested', resolve_with_vite: false }); + expect(nested).not.toBe(authored); + await Promise.all([authored, resolved_with_vite, nested]); + }); + + test('parses the config of the project it runs in', async () => { + await expect(load_default_svelte_config({ resolve_with_vite: false })).resolves.toMatchObject({ + lib_path: 'src/lib' + }); }); }); From dd95ce8069cf870d4900ac04bdda3e793f64ad11 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 7 Aug 2026 18:53:13 -0400 Subject: [PATCH 03/10] vite-only config --- .changeset/lazy-svelte-config.md | 61 ++++---- CLAUDE.md | 10 +- package-lock.json | 14 +- package.json | 5 +- src/docs/gro_plugin_sveltekit_library.md | 4 +- src/lib/constants.ts | 14 +- src/lib/esbuild_plugin_svelte.ts | 6 + src/lib/filer.ts | 2 +- src/lib/gen_helpers.ts | 2 +- src/lib/gro.config.default.ts | 13 +- src/lib/gro_config.ts | 2 - src/lib/gro_plugin_server.ts | 12 +- src/lib/loader.ts | 17 ++- src/lib/release.task.ts | 2 +- src/lib/run_gen.ts | 2 +- src/lib/run_task.ts | 2 +- src/lib/svelte_config.ts | 168 ++++++++++++----------- src/lib/sveltekit_helpers.ts | 22 +-- src/test/esbuild_plugin_svelte.test.ts | 11 +- src/test/svelte_config.test.ts | 38 +++-- 20 files changed, 223 insertions(+), 184 deletions(-) diff --git a/.changeset/lazy-svelte-config.md b/.changeset/lazy-svelte-config.md index 38542df52c..21bbb451bd 100644 --- a/.changeset/lazy-svelte-config.md +++ b/.changeset/lazy-svelte-config.md @@ -2,35 +2,48 @@ '@fuzdev/gro': minor --- -feat: load the SvelteKit config lazily via -[`@sveltejs/load-config`](https://github.com/sveltejs/language-tools/tree/master/packages/load-config) - -Gro read the SvelteKit config eagerly at module scope, so every invocation paid for -it twice - once on the main thread and once on the Node loader's worker thread - even -for tasks that never touch it. It's now loaded on demand and memoized, and reading it -goes through `@sveltejs/load-config`, which adds `svelte.config.{ts,mts,cjs,mjs}` -support, resolves through `vite.config` when one is present, and applies SvelteKit's -own defaults instead of Gro's hand-rolled fallbacks. A config that fails to load now -throws instead of being silently ignored. - -The loader keeps reading the config at module scope and opts out of Vite resolution: -it runs on a worker thread, where Vite's resolution can't run because it calls -`process.chdir`. A custom `svelte_config_filename` also opts out, because Vite's -resolution finds `svelte.config.*` on its own. - -Also fixes `parse_svelte_config` mutating the `compilerOptions` of the config passed -to it, and normalizes `kit.env.dir` to a project-relative path - it's serialized into -the generated `$env/dynamic/*` modules, so an absolute one would bake the build -machine's directory into server bundles. The `svelte_config` on the task and gen -contexts now honors `svelte_config_filename` from `gro.config.ts`, which previously -only `gro_plugin_server` respected. +feat: read the Svelte config through Vite, lazily + +Gro read `svelte.config.js` eagerly at module scope, so every invocation paid for it +twice - once on the main thread and once on the Node loader's worker thread - even for +tasks that never touch it. It's now loaded on demand and memoized, and it's resolved +from the project's Vite config rather than read from `svelte.config.js` directly. + +SvelteKit resolves its own config the same way, so Gro now sees exactly what `vite dev` +and `vite build` see: the inline options when a project passes them to `sveltekit()`, +and otherwise whatever SvelteKit loaded from `svelte.config.js` on its own. That means +projects keeping a `svelte.config.js` are unaffected - Vite reads it for them - while +projects configuring Svelte inline in `vite.config.ts` now work, along with SvelteKit's +own defaults instead of Gro's hand-rolled fallbacks. A Vite config that fails to +resolve now throws instead of being silently ignored. + +The Node loader resolves the config the same way as the main thread. It runs on a +worker thread, where `process.chdir` is unavailable, but Vite's config resolution +doesn't need it - only `@sveltejs/load-config` does, which is why Gro calls +`vite.resolveConfig` directly and drops that dependency. + +Vite is now an optional peer dependency. A project with no Vite config, or with no Vite +installed, gets the conventional defaults rather than an error, so non-Vite projects +keep working. + +Also fixes `parse_svelte_config` mutating the `compilerOptions` of the config passed to +it, and normalizes `kit.env.dir` to a project-relative path - it's serialized into the +generated `$env/dynamic/*` modules, so the absolute one Vite resolution produces would +bake the build machine's directory into server bundles. Breaking changes: - `TaskContext.svelte_config` and `GenContext.svelte_config` are now `Promise` - `await` them - `default_svelte_config` is replaced by `load_default_svelte_config()` -- `parse_svelte_config` takes `{dir, svelte_config}` instead of `{dir_or_config}` +- `parse_svelte_config` takes `{dir, svelte_config}` instead of `{dir_or_config}`, as a + union that rejects contradictory options +- `load_svelte_config` resolves the Vite config at `dir` and takes only `{dir}`; + `GroConfig.svelte_config_filename` is removed, since Vite picks its own config file +- `has_sveltekit_app` is synchronous and takes a `PackageJson`, detecting + `@sveltejs/kit` as a dependency rather than the presence of `svelte.config.js` +- `has_sveltekit_library` takes an optional `ParsedSvelteConfig` and checks the + `@sveltejs/package` dependency before the lib directory - `ROUTES_DIRNAME` is removed - `paths.lib`, `LIB_DIRNAME`, `LIB_PATH`, and `LIB_DIR` are the conventional `src/lib` rather than derived from `kit.files.lib`, so that `paths` stays free of the config. @@ -38,8 +51,6 @@ Breaking changes: `task_root_dirs` at it in `gro.config.ts` if tasks live there - `MODULE_PATH_LIB_PREFIX` is always `$lib/`, matching SvelteKit, which names the alias `$lib` no matter where `files.lib` points - it was previously derived from `files.lib` -- `has_sveltekit_library` takes an optional `ParsedSvelteConfig` and checks the - `@sveltejs/package` dependency before the lib directory - the default config detects plugins inside `plugins()` instead of when the config loads, so tasks other than `dev` and `build` no longer trigger detection - `CreateGroConfig` takes only `base_config` - its second `ParsedSvelteConfig` parameter diff --git a/CLAUDE.md b/CLAUDE.md index 6447c5db00..cf1290afda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,14 +141,14 @@ Capabilities: SvelteKit module shims: Best-effort shims for tasks/tests/servers, not identical to actual SvelteKit modules: -- `$lib/*` → resolved via svelte.config.js alias to `src/lib/` +- `$lib/*` → resolved via the SvelteKit `$lib` alias to `src/lib/` - `$env/static/public` → reads `PUBLIC_*` vars from `.env` - `$env/static/private` → reads all vars from `.env` - `$env/dynamic/public` → `process.env` with `PUBLIC_*` filtering - `$env/dynamic/private` → full `process.env` - `$app/environment` → `{dev: true, browser: false, building: false, version: ''}` -- `$app/paths` → `{base: '', assets: ''}` from svelte.config.js +- `$app/paths` → `{base: '', assets: ''}` from the resolved Svelte config ### Code generation @@ -289,8 +289,8 @@ config object. If absent, uses default config from Default config behavior: Auto-detects project type by checking filesystem: -- `svelte.config.js` → enables `gro_plugin_sveltekit_app` -- `svelte.config.js` + `@sveltejs/package` in package.json + `src/lib/` → enables `gro_plugin_sveltekit_library` +- `@sveltejs/kit` in package.json → enables `gro_plugin_sveltekit_app` +- `@sveltejs/package` in package.json + `src/lib/` → enables `gro_plugin_sveltekit_library` - `src/lib/server/server.ts` → enables `gro_plugin_server` - Always enables `gro_plugin_gen` @@ -342,7 +342,7 @@ export default config; - Gen files: `*.gen.*` anywhere in `src/` (pattern: `.gen.` substring) - Test files: `*.test.ts` anywhere (run by Vitest) - Config: `gro.config.ts` at project root -- SvelteKit config: `svelte.config.js` at project root +- Vite config: `vite.config.ts` at project root - the Svelte config is read through it Exclusions (configurable via `search_filters`): diff --git a/package-lock.json b/package-lock.json index 83a11601ee..ac8f5caeb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "MIT", "dependencies": { "@fuzdev/tsv_wasm": "^0.2.0", - "@sveltejs/load-config": "0.2.2", "chokidar": "^5.0.0", "dotenv": "^17.2.3", "esm-env": "^1.2.2", @@ -71,6 +70,7 @@ "svelte": "^5", "svelte-docinfo": ">=0.4.1", "typescript": "^5", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vitest": "^3 || ^4", "zod": "^4" }, @@ -81,6 +81,9 @@ "svelte-docinfo": { "optional": true }, + "vite": { + "optional": true + }, "vitest": { "optional": true } @@ -1829,15 +1832,6 @@ } } }, - "node_modules/@sveltejs/load-config": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.2.tgz", - "integrity": "sha512-K7dsJDQxBOF+f+epuhMactcjK2VP4MRkLKtwSykNtEI+cKVEyzrmmhQ1pmoxI800m4JKcyJ05L2M4yPwAGiBNw==", - "license": "MIT", - "engines": { - "node": ">= 18.0.0" - } - }, "node_modules/@sveltejs/package": { "version": "2.5.8", "resolved": "https://registry.npmjs.org/@sveltejs/package/-/package-2.5.8.tgz", diff --git a/package.json b/package.json index 2bfc5ac45d..d3c4e3d890 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ ], "dependencies": { "@fuzdev/tsv_wasm": "^0.2.0", - "@sveltejs/load-config": "0.2.2", "chokidar": "^5.0.0", "dotenv": "^17.2.3", "esm-env": "^1.2.2", @@ -65,6 +64,7 @@ "svelte": "^5", "svelte-docinfo": ">=0.4.1", "typescript": "^5", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vitest": "^3 || ^4", "zod": "^4" }, @@ -75,6 +75,9 @@ "svelte-docinfo": { "optional": true }, + "vite": { + "optional": true + }, "vitest": { "optional": true } diff --git a/src/docs/gro_plugin_sveltekit_library.md b/src/docs/gro_plugin_sveltekit_library.md index 1157f40eb9..19a08ca994 100644 --- a/src/docs/gro_plugin_sveltekit_library.md +++ b/src/docs/gro_plugin_sveltekit_library.md @@ -9,8 +9,8 @@ to build libraries from `src/lib/` for publishing to npm. The [default config](/src/lib/gro.config.default.ts) enables this plugin when all three conditions are met: -1. `svelte.config.js` exists at the project root -2. `src/lib/` directory exists (or the path configured in `svelte.config.js`) +1. `@sveltejs/kit` is a dependency in `package.json` +2. `src/lib/` directory exists (or the path configured by `kit.files.lib`) 3. `@sveltejs/package` is listed in `package.json` dependencies Install to enable: diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 678df1919a..386a1a0f69 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -19,6 +19,11 @@ export const GRO_DIR = GRO_DIRNAME + '/'; /** @trailing_slash */ export const GRO_DEV_DIR = GRO_DEV_DIRNAME + '/'; export const GRO_CONFIG_FILENAME = 'gro.config.ts'; +/** + * Gro reads the Svelte config through Vite, never from this file directly, + * but SvelteKit still loads it when `sveltekit()` gets no inline options, + * so it's a project file that Gro formats. + */ export const SVELTE_CONFIG_FILENAME = 'svelte.config.js'; /** * SvelteKit's alias for the library directory. @@ -26,7 +31,13 @@ export const SVELTE_CONFIG_FILENAME = 'svelte.config.js'; * @see https://svelte.dev/docs/kit/configuration#files */ export const SVELTEKIT_LIB_ALIAS = '$lib'; -export const VITE_CONFIG_FILENAME = 'vite.config.ts'; +export const VITE_CONFIG_BASENAME = 'vite.config'; +/** + * The extensions Vite itself accepts for its config, in Vite's own precedence order. + * @see https://vite.dev/config/ + */ +export const VITE_CONFIG_EXTENSIONS = ['js', 'mjs', 'ts', 'cjs', 'mts', 'cts']; +export const VITE_CONFIG_FILENAME = VITE_CONFIG_BASENAME + '.ts'; export const NODE_MODULES_DIRNAME = 'node_modules'; export const PACKAGE_JSON_FILENAME = 'package.json'; export const LOCKFILE_FILENAME = 'package-lock.json'; @@ -55,5 +66,6 @@ export const SVELTEKIT_CLI = 'svelte-kit'; export const SVELTE_CHECK_CLI = 'svelte-check'; export const SVELTE_PACKAGE_CLI = 'svelte-package'; export const SVELTE_PACKAGE_DEP_NAME = '@sveltejs/package'; +export const SVELTEKIT_DEP_NAME = '@sveltejs/kit'; export const VITE_CLI = 'vite'; export const VITEST_CLI = 'vitest'; diff --git a/src/lib/esbuild_plugin_svelte.ts b/src/lib/esbuild_plugin_svelte.ts index 1dda3bc497..15e1fddba6 100644 --- a/src/lib/esbuild_plugin_svelte.ts +++ b/src/lib/esbuild_plugin_svelte.ts @@ -22,6 +22,12 @@ export interface EsbuildPluginSvelteOptions { dev: boolean; base_url: ParsedSvelteConfig['base_url']; dir?: string; + /** + * Defaults to Gro's baseline, not the project's `compilerOptions` - + * reading those is async, so callers pass `svelte_compile_options` + * off a `ParsedSvelteConfig` to honor them. + * @default `SVELTE_COMPILE_OPTIONS_DEFAULT` + */ svelte_compile_options?: CompileOptions; svelte_compile_module_options?: ModuleCompileOptions; svelte_preprocessors?: PreprocessorGroup | Array; diff --git a/src/lib/filer.ts b/src/lib/filer.ts index 549d2f6026..47fe918284 100644 --- a/src/lib/filer.ts +++ b/src/lib/filer.ts @@ -65,7 +65,7 @@ export class Filer { // TODO for package.json maybe another array of files/dirs to watch to invalidate everything? // or instead of that, think of taking an array of config objects that can specify invalidation rules, // so package.json would be configured differently than ./src, and we could add a default with - // package.json/gro.config.ts/tsconfig.json/svelte.config.js/vite.config.ts to invalidate everything + // package.json/gro.config.ts/tsconfig.json/vite.config.ts to invalidate everything this.#log = options.log; } get inited(): boolean { diff --git a/src/lib/gen_helpers.ts b/src/lib/gen_helpers.ts index 6c8f68079e..264620f2a3 100644 --- a/src/lib/gen_helpers.ts +++ b/src/lib/gen_helpers.ts @@ -101,7 +101,7 @@ const resolve_gen_dependencies = async ( const gen_ctx: GenContext = { config, get svelte_config() { - return load_default_svelte_config({ config_filename: config.svelte_config_filename }); + return load_default_svelte_config(); }, filer, log, diff --git a/src/lib/gro.config.default.ts b/src/lib/gro.config.default.ts index 76596d2ede..2f90fc97ec 100644 --- a/src/lib/gro.config.default.ts +++ b/src/lib/gro.config.default.ts @@ -11,10 +11,10 @@ import { package_json_load } from './package_json.ts'; /** * This is the default config that's passed to `gro.config.ts` * if it exists in the current project, and if not, this is the final config. - * It looks at the SvelteKit config and filesystem and tries to do the right thing: + * It looks at `package.json` and the filesystem and tries to do the right thing: * - * - if `svelte.config.js`, assumes a SvelteKit frontend - respects `KitConfig.kit.files.routes` - * - if `src/lib` + `@sveltejs/package`, assumes a Node library - respects `KitConfig.kit.files.lib` + * - if `@sveltejs/kit`, assumes a SvelteKit frontend + * - if `@sveltejs/package` + the lib directory, assumes a Node library - respects `KitConfig.kit.files.lib` * - if `src/lib/server/server.ts`, assumes a Node server - needs config */ const config: CreateGroConfig = (cfg) => { @@ -24,8 +24,11 @@ const config: CreateGroConfig = (cfg) => { cfg.plugins = async () => { const package_json = await package_json_load(); // TODO gets wastefully loaded by some plugins, maybe put in plugin/task context? how does that interact with `map_package_json`? - const [has_server_result, has_sveltekit_library_result, has_sveltekit_app_result] = - await Promise.all([has_server(), has_sveltekit_library(package_json), has_sveltekit_app()]); + const has_sveltekit_app_result = has_sveltekit_app(package_json); + const [has_server_result, has_sveltekit_library_result] = await Promise.all([ + has_server(), + has_sveltekit_library(package_json) + ]); // put things that generate files before SvelteKit so it can see them return [ diff --git a/src/lib/gro_config.ts b/src/lib/gro_config.ts index 739f34859f..1286e6484e 100644 --- a/src/lib/gro_config.ts +++ b/src/lib/gro_config.ts @@ -61,8 +61,6 @@ export interface GroConfig extends RawGroConfig { * The CLI to use that's compatible with `npm install` and `npm link`. Defaults to `'npm'`. */ pm_cli: string; - /** @default `SVELTE_CONFIG_FILENAME` */ - svelte_config_filename?: string; /** * SHA-256 hash of the user's `build_cache_config` from `gro.config.ts`. * This is computed during config normalization and the raw value is immediately deleted. diff --git a/src/lib/gro_plugin_server.ts b/src/lib/gro_plugin_server.ts index 1d4987386c..8e791659cc 100644 --- a/src/lib/gro_plugin_server.ts +++ b/src/lib/gro_plugin_server.ts @@ -60,7 +60,8 @@ export interface GroPluginServerOptions { */ ambient_env?: Record; /** - * @default ```loaded from `${cwd}/${SVELTE_CONFIG_FILENAME}```` + * An already-loaded Svelte config, to skip resolving `dir`'s Vite config. + * @default ```resolved from `dir`'s Vite config```` */ svelte_config?: SvelteConfig; /** @@ -133,14 +134,11 @@ export const gro_plugin_server = ({ return { name: 'gro_plugin_server', setup: async ({ dev, watch, timings, log, config, filer }) => { - // `load_default_svelte_config` memoizes per directory, - // so this shares the parse with the rest of the process when `dir` is the cwd. + // `load_default_svelte_config` memoizes per directory, so this shares the resolution + // with the rest of the process when `dir` is the cwd. const parsed_svelte_config = svelte_config ? await parse_svelte_config({ svelte_config, dir }) - : await load_default_svelte_config({ - dir, - config_filename: config.svelte_config_filename - }); + : await load_default_svelte_config({ dir }); const { alias, base_url, diff --git a/src/lib/loader.ts b/src/lib/loader.ts index 82e2077cf2..9707628f9a 100644 --- a/src/lib/loader.ts +++ b/src/lib/loader.ts @@ -57,15 +57,14 @@ const dir = paths.root; Unlike the rest of Gro, the loader reads the config eagerly, at module scope. -It runs on a worker thread, where resolving through Vite is unavailable because Vite's -resolution calls `process.chdir`, so it opts out and reads the authored config - which -carries everything the loader needs. +Loading it here rather than on demand is deliberate. Resolving the config runs the +`resolve` hook for each of its own imports, and a hook that awaited the load it is part +of would deadlock. Doing it during module evaluation, before the hooks go live, keeps +that graph out of them. Nothing is lost by being eager: the alias step runs for every +bare specifier, so the first resolve would load the config anyway. -Loading it here rather than on demand is deliberate. Importing the config runs the -`resolve` hook for each of the config's own imports, and a hook that awaited the load -it is part of would deadlock. Doing it during module evaluation, before the hooks go -live, keeps that graph out of them. Nothing is lost by being eager: the alias step -runs for every bare specifier, so the first resolve would load the config anyway. +The loader runs on a worker thread, where `process.chdir` is unavailable - Vite's own +config resolution doesn't need it, so this is the same load the main thread does. */ const { @@ -78,7 +77,7 @@ const { svelte_compile_options, svelte_compile_module_options, svelte_preprocessors -} = await load_default_svelte_config({ dir, resolve_with_vite: false }); +} = await load_default_svelte_config({ dir }); const aliases = Object.entries(alias); diff --git a/src/lib/release.task.ts b/src/lib/release.task.ts index c545256be0..0b3144a31f 100644 --- a/src/lib/release.task.ts +++ b/src/lib/release.task.ts @@ -45,7 +45,7 @@ export const task: Task = { if (publish) { await invoke_task('publish', { optional: true, dry, check, build, pull, sync, install }); } - if ((await has_sveltekit_app()).ok) { + if (has_sveltekit_app(package_json).ok) { await invoke_task('deploy', { build: build && !publish, dry, diff --git a/src/lib/run_gen.ts b/src/lib/run_gen.ts index 3bef0b48de..3a2af1c8dd 100644 --- a/src/lib/run_gen.ts +++ b/src/lib/run_gen.ts @@ -46,7 +46,7 @@ export const run_gen = async ( const gen_ctx: GenContext = { config, get svelte_config() { - return load_default_svelte_config({ config_filename: config.svelte_config_filename }); + return load_default_svelte_config(); }, filer, log, diff --git a/src/lib/run_task.ts b/src/lib/run_task.ts index 85fb96185d..ab613b8652 100644 --- a/src/lib/run_task.ts +++ b/src/lib/run_task.ts @@ -59,7 +59,7 @@ export const run_task = async ( args, config, get svelte_config() { - return load_default_svelte_config({ config_filename: config.svelte_config_filename }); + return load_default_svelte_config(); }, filer, log, diff --git a/src/lib/svelte_config.ts b/src/lib/svelte_config.ts index 07cc996d7d..9831c19047 100644 --- a/src/lib/svelte_config.ts +++ b/src/lib/svelte_config.ts @@ -1,10 +1,10 @@ import type { Config as SvelteConfig } from '@sveltejs/kit'; import type { CompileOptions, ModuleCompileOptions, PreprocessorGroup } from 'svelte/compiler'; -import { isAbsolute, join, relative } from 'node:path'; -import { loadConfig } from '@sveltejs/load-config'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import { existsSync } from 'node:fs'; import { EMPTY_OBJECT } from '@fuzdev/fuz_util/object.ts'; -import { SVELTE_CONFIG_FILENAME, SVELTEKIT_LIB_ALIAS } from './constants.ts'; +import { SVELTEKIT_LIB_ALIAS, VITE_CONFIG_BASENAME, VITE_CONFIG_EXTENSIONS } from './constants.ts'; /* eslint-disable @typescript-eslint/no-deprecated */ // see https://github.com/sveltejs/kit/discussions/14240 @@ -14,62 +14,72 @@ import { SVELTE_CONFIG_FILENAME, SVELTEKIT_LIB_ALIAS } from './constants.ts'; This module is intended to have minimal dependencies to avoid over-imports in the CLI. Loading is lazy and memoized - see `load_default_svelte_config`. +The Svelte config is read through Vite, never from `svelte.config.js` directly. +SvelteKit resolves its own config the same way, so this sees exactly what +`vite dev` and `vite build` see - inline `sveltekit()` options when a project passes +them, and otherwise whatever SvelteKit loaded from `svelte.config.js` on its own. + */ +/** + * The names of the Vite plugins that carry the resolved Svelte config, + * in the order SvelteKit itself prefers them. + */ +const CONFIG_PROVIDER_PLUGIN_NAMES = ['vite-plugin-sveltekit-setup', 'vite-plugin-svelte:config']; + +/** + * Whether `dir` has a Vite config at all, used only to skip the work of loading Vite. + * Which of several Vite configs wins is Vite's call, not Gro's. + */ +const has_vite_config = (dir: string): boolean => + VITE_CONFIG_EXTENSIONS.some((ext) => existsSync(join(dir, VITE_CONFIG_BASENAME + '.' + ext))); + export interface LoadSvelteConfigOptions { /** * @default cwd */ dir?: string; - /** - * @default `SVELTE_CONFIG_FILENAME` - */ - config_filename?: string; - /** - * Resolve the config through `vite.config` when one is present, - * which applies SvelteKit's own defaults and supports projects - * that configure Svelte from Vite instead of `svelte.config.js`. - * - * Costs a full Vite config resolution, and is unavailable on worker threads - * because it calls `process.chdir`, so the Node loader opts out. - * @default true - */ - resolve_with_vite?: boolean; } /** - * Whether a load actually goes through Vite. - * Vite's resolution finds `svelte.config.*` on its own, so a custom filename - * can only be honored by importing that file directly. + * Loads the Svelte config at `dir` by resolving its Vite config. + * @returns `null` if `dir` has no Vite config, or one that configures no Svelte plugin + * @throws if the Vite config is found but fails to resolve */ -const uses_vite = (resolve_with_vite: boolean, config_filename: string): boolean => - resolve_with_vite && config_filename === SVELTE_CONFIG_FILENAME; +export const load_svelte_config = async ( + options: LoadSvelteConfigOptions = EMPTY_OBJECT +): Promise => { + // Normalized because SvelteKit compares the `root` it's given against the one it enforces + // and prints a red warning when they differ - a trailing slash is enough to trip it. + const dir = resolve(options.dir ?? process.cwd()); + if (!has_vite_config(dir)) return null; + + let vite; + try { + vite = await import('vite'); + } catch (_err) { + // Vite isn't installed, so the project can't be built with it either. + // Degrading beats throwing here - this runs in the Node loader on every invocation, + // and a project in this state still needs to be able to run tasks like `gro sync`. + return null; + } -/** - * Loads a SvelteKit config at `dir`. - * @returns `null` if no config is found - * @throws if a config is found but fails to load - */ -export const load_svelte_config = async ({ - dir = process.cwd(), - config_filename = SVELTE_CONFIG_FILENAME, - resolve_with_vite = true -}: LoadSvelteConfigOptions = EMPTY_OBJECT): Promise => { - const use_vite = uses_vite(resolve_with_vite, config_filename); - // Passing the config file instead of its directory tells `loadConfig` to import it directly, - // skipping both the `vite.config` lookup and the `process.chdir` it performs. - const loaded = await loadConfig(use_vite ? dir : join(dir, config_filename), { - traverse: false - }); - if (!loaded) return null; - if ('error' in loaded) { - throw new Error(`Failed to load SvelteKit config at ${loaded.configFilePath}`, { - cause: loaded.error - }); + let resolved; + try { + // No `configFile`, so Vite picks its own config the way it does everywhere else. + // Unlike `@sveltejs/load-config` this doesn't `process.chdir` - Gro always resolves + // the project it's running in, and chdir is unavailable on the loader's worker thread. + resolved = await vite.resolveConfig({ root: dir, logLevel: 'error' }, 'serve'); + } catch (err) { + throw new Error(`Failed to resolve the Vite config at ${dir}`, { cause: err }); + } + + for (const name of CONFIG_PROVIDER_PLUGIN_NAMES) { + // `api.options` is the split config shape, with SvelteKit's options under `kit`. + const options = resolved.plugins.find((p) => p.name === name)?.api?.options; + if (options) return options as SvelteConfig; } - // `loadConfig` types the config loosely (`kit` is `unknown`) because it also handles - // plain Svelte projects, but everything Gro reads off it is optional and guarded. - return loaded.config as SvelteConfig; + return null; }; /** @@ -111,28 +121,28 @@ export interface ParsedSvelteConfig { * Resolving through Vite yields absolute `files` paths, * but Gro's vocabulary is relative to the project directory. */ -const to_project_relative_path = ( - path: string | undefined, - dir: string, - fallback: string -): string => { - if (path === undefined) return fallback; - if (!isAbsolute(path)) return path; - return relative(dir, path) || '.'; -}; +const to_project_relative_path = (path: string | undefined, dir: string): string | undefined => + path === undefined || !isAbsolute(path) ? path : relative(dir, path) || '.'; /** * Gro compiles for the server by default, * because SvelteKit handles the client in the normal cases. + * Frozen because it's handed out as a default value. */ -export const SVELTE_COMPILE_OPTIONS_DEFAULT: CompileOptions = { generate: 'server' }; - -export interface ParseSvelteConfigOptions extends LoadSvelteConfigOptions { - /** - * An already-loaded config to parse instead of reading one from `dir`. - */ - svelte_config?: SvelteConfig; -} +export const SVELTE_COMPILE_OPTIONS_DEFAULT: CompileOptions = Object.freeze({ generate: 'server' }); + +export type ParseSvelteConfigOptions = + | LoadSvelteConfigOptions + | { + /** + * An already-loaded config to parse instead of reading one from `dir`. + */ + svelte_config: SvelteConfig; + /** + * @default cwd + */ + dir?: string; + }; /** * Returns Gro-relevant properties of a SvelteKit config @@ -143,13 +153,14 @@ export const parse_svelte_config = async ( ): Promise => { const { dir = process.cwd() } = options; - const svelte_config = options.svelte_config ?? (await load_svelte_config(options)); + const svelte_config = + 'svelte_config' in options ? options.svelte_config : await load_svelte_config(options); const kit = svelte_config?.kit; - const assets_path = to_project_relative_path(kit?.files?.assets, dir, 'static'); - const lib_path = to_project_relative_path(kit?.files?.lib, dir, 'src/lib'); - const routes_path = to_project_relative_path(kit?.files?.routes, dir, 'src/routes'); + const assets_path = to_project_relative_path(kit?.files?.assets, dir) ?? 'static'; + const lib_path = to_project_relative_path(kit?.files?.lib, dir) ?? 'src/lib'; + const routes_path = to_project_relative_path(kit?.files?.routes, dir) ?? 'src/routes'; // SvelteKit always names this alias `$lib` and points it at `files.lib`. // @see https://svelte.dev/docs/kit/configuration#alias @@ -161,8 +172,7 @@ export const parse_svelte_config = async ( // Relative like the paths above, and for a sharper reason: `env_dir` is serialized into // the generated `$env/dynamic/*` modules, so an absolute path from Vite resolution would // bake the build machine's directory into server bundles. - const env_dir = - kit?.env?.dir === undefined ? undefined : to_project_relative_path(kit.env.dir, dir, '.'); + const env_dir = to_project_relative_path(kit?.env?.dir, dir); const private_prefix = kit?.env?.privatePrefix; const public_prefix = kit?.env?.publicPrefix; @@ -201,26 +211,20 @@ export const to_default_compile_module_options = ({ const default_svelte_configs: Map> = new Map(); /** - * The parsed SvelteKit config, memoized per directory and resolution mode. + * The parsed Svelte config, memoized per directory. * - * Loading a SvelteKit config is expensive - it imports the config module and its - * preprocessors, and resolving through Vite costs a full Vite config resolution - - * so callers pull it in on demand instead of paying for it on every Gro invocation. + * Reading it costs a full Vite config resolution, which runs every Vite plugin's + * config hooks, so callers pull it in on demand instead of paying for it + * on every Gro invocation. */ export const load_default_svelte_config = ( options: LoadSvelteConfigOptions = EMPTY_OBJECT ): Promise => { - const { - dir = process.cwd(), - config_filename = SVELTE_CONFIG_FILENAME, - resolve_with_vite = true - } = options; - // Keyed on whether the load *actually* goes through Vite, not on what was asked for, - // so a custom filename doesn't get one cache entry per requested mode for the same load. - const key = `${uses_vite(resolve_with_vite, config_filename) ? 'vite' : 'svelte'}:${join(dir, config_filename)}`; + const { dir = process.cwd() } = options; + const key = resolve(dir); let loading = default_svelte_configs.get(key); if (loading === undefined) { - loading = parse_svelte_config({ dir, config_filename, resolve_with_vite }); + loading = parse_svelte_config({ dir }); default_svelte_configs.set(key, loading); // Evict failures so a long-lived process like `gro dev` picks up a fixed config. // Attaching the handler here also keeps the cached promise from being reported diff --git a/src/lib/sveltekit_helpers.ts b/src/lib/sveltekit_helpers.ts index 570286b453..bce95fdc46 100644 --- a/src/lib/sveltekit_helpers.ts +++ b/src/lib/sveltekit_helpers.ts @@ -9,22 +9,26 @@ import { to_forwarded_args } from './args.ts'; import { find_cli, spawn_cli, to_cli_name, type Cli } from './cli.ts'; import { PM_CLI_DEFAULT, - SVELTE_CONFIG_FILENAME, SVELTE_PACKAGE_DEP_NAME, SVELTEKIT_CLI, + SVELTEKIT_DEP_NAME, SVELTEKIT_DEV_DIRNAME } from './constants.ts'; import { package_json_has_dependency } from './package_json.ts'; import { load_default_svelte_config, type ParsedSvelteConfig } from './svelte_config.ts'; import { TaskError } from './task.ts'; -export const has_sveltekit_app = async ( - svelte_config_path: string = SVELTE_CONFIG_FILENAME -): Promise> => { - if (!(await fs_exists(svelte_config_path))) { - return { ok: false, message: `no SvelteKit config found at ${SVELTE_CONFIG_FILENAME}` }; +/** + * Detected from `package.json` rather than the Svelte config, + * because reading the config costs a full Vite config resolution. + */ +export const has_sveltekit_app = ( + package_json: PackageJson, + dep_name = SVELTEKIT_DEP_NAME +): Result => { + if (!package_json_has_dependency(dep_name, package_json)) { + return { ok: false, message: `no dependency found in package.json for ${dep_name}` }; } - // TODO check for routes? return { ok: true }; }; @@ -33,14 +37,14 @@ export const has_sveltekit_library = async ( svelte_config?: ParsedSvelteConfig, dep_name = SVELTE_PACKAGE_DEP_NAME ): Promise> => { - const has_sveltekit_app_result = await has_sveltekit_app(); + const has_sveltekit_app_result = has_sveltekit_app(package_json); if (!has_sveltekit_app_result.ok) { return has_sveltekit_app_result; } // Checked before the lib directory because it's the cheaper of the two // and it's what distinguishes a library from an app, - // so apps bail out without reading the SvelteKit config. + // so apps bail out without reading the Svelte config. if (!package_json_has_dependency(dep_name, package_json)) { return { ok: false, diff --git a/src/test/esbuild_plugin_svelte.test.ts b/src/test/esbuild_plugin_svelte.test.ts index ad203603ae..b495878185 100644 --- a/src/test/esbuild_plugin_svelte.test.ts +++ b/src/test/esbuild_plugin_svelte.test.ts @@ -3,11 +3,10 @@ import * as esbuild from 'esbuild'; import { readFile, rm } from 'node:fs/promises'; import { esbuild_plugin_svelte } from '$lib/esbuild_plugin_svelte.ts'; -import { load_default_svelte_config } from '$lib/svelte_config.ts'; -// Resolving through Vite would cost a full Vite config resolution and call `process.chdir`, -// and the authored config carries everything these tests need. -const svelte_config = load_default_svelte_config({ resolve_with_vite: false }); +// Passed literally rather than read off the project's config, +// which would cost a full Vite config resolution for a value these tests already know. +const base_url = ''; // TODO improve these tests to have automatic caching @@ -18,7 +17,7 @@ test('build for the client', async () => { plugins: [ esbuild_plugin_svelte({ dev: true, - base_url: (await svelte_config).base_url, + base_url, svelte_compile_options: { generate: 'client' } }) ], @@ -85,7 +84,7 @@ test('build for the server', async () => { plugins: [ esbuild_plugin_svelte({ dev: true, - base_url: (await svelte_config).base_url + base_url }) ], outfile, diff --git a/src/test/svelte_config.test.ts b/src/test/svelte_config.test.ts index 58af66aa0a..2a12c708cb 100644 --- a/src/test/svelte_config.test.ts +++ b/src/test/svelte_config.test.ts @@ -93,24 +93,32 @@ describe('parse_svelte_config', () => { }); describe('load_default_svelte_config', () => { - // Uses `DIR`, which holds no config, so the Vite-resolving entry stays cheap - - // resolving a real project through Vite costs a full Vite config resolution - // and calls `process.chdir`, which is not something to do from a unit test. - test('memoizes per directory and resolution mode', async () => { - const authored = load_default_svelte_config({ dir: DIR, resolve_with_vite: false }); - expect(load_default_svelte_config({ dir: DIR, resolve_with_vite: false })).toBe(authored); - // A different resolution mode is a different cache entry. - const resolved_with_vite = load_default_svelte_config({ dir: DIR, resolve_with_vite: true }); - expect(resolved_with_vite).not.toBe(authored); - // As is a different directory. - const nested = load_default_svelte_config({ dir: DIR + '/nested', resolve_with_vite: false }); - expect(nested).not.toBe(authored); - await Promise.all([authored, resolved_with_vite, nested]); + // `DIR` holds no Vite config, so these stay cheap - Vite is never loaded. + test('memoizes per directory', async () => { + const loading = load_default_svelte_config({ dir: DIR }); + expect(load_default_svelte_config({ dir: DIR })).toBe(loading); + // Keyed on the resolved directory, so these are the same entry. + expect(load_default_svelte_config({ dir: DIR + '/' })).toBe(loading); + expect(load_default_svelte_config({ dir: DIR + '/nested/..' })).toBe(loading); + // A different directory is not. + const nested = load_default_svelte_config({ dir: DIR + '/nested' }); + expect(nested).not.toBe(loading); + await Promise.all([loading, nested]); }); - test('parses the config of the project it runs in', async () => { - await expect(load_default_svelte_config({ resolve_with_vite: false })).resolves.toMatchObject({ + test('falls back to the conventional paths when the directory has no Vite config', async () => { + await expect(load_default_svelte_config({ dir: DIR })).resolves.toMatchObject({ + svelte_config: null, lib_path: 'src/lib' }); }); + + // Resolves this project's own `vite.config.ts` through Vite, the way every Gro + // invocation does, so it covers reading the config off the SvelteKit plugin. + test('resolves the config of the project it runs in', async () => { + await expect(load_default_svelte_config()).resolves.toMatchObject({ + lib_path: 'src/lib', + routes_path: 'src/routes' + }); + }); }); From a878186ff6d29e261e073b9244aa70f653b40f09 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 7 Aug 2026 20:06:49 -0400 Subject: [PATCH 04/10] more vite config refactoring --- .changeset/lazy-svelte-config.md | 52 +++++-- CLAUDE.md | 5 +- src/docs/gro_plugin_sveltekit_app.md | 2 +- src/docs/gro_plugin_sveltekit_library.md | 7 +- src/lib/constants.ts | 25 ++-- .../esbuild_plugin_sveltekit_shim_alias.ts | 7 +- src/lib/filer.ts | 9 +- src/lib/format_directory.ts | 15 +- src/lib/gro_plugin_server.ts | 13 +- src/lib/loader.ts | 3 +- src/lib/module.ts | 4 +- src/lib/paths.ts | 4 +- src/lib/svelte_config.ts | 132 +++++++++--------- src/test/svelte_config.test.ts | 82 ++++++++--- 14 files changed, 226 insertions(+), 134 deletions(-) diff --git a/.changeset/lazy-svelte-config.md b/.changeset/lazy-svelte-config.md index 21bbb451bd..e8334a951d 100644 --- a/.changeset/lazy-svelte-config.md +++ b/.changeset/lazy-svelte-config.md @@ -6,25 +6,43 @@ feat: read the Svelte config through Vite, lazily Gro read `svelte.config.js` eagerly at module scope, so every invocation paid for it twice - once on the main thread and once on the Node loader's worker thread - even for -tasks that never touch it. It's now loaded on demand and memoized, and it's resolved -from the project's Vite config rather than read from `svelte.config.js` directly. +tasks that never touch it. The main thread now loads it on demand and memoizes it, and +it's resolved from the project's Vite config rather than read from `svelte.config.js` +directly. -SvelteKit resolves its own config the same way, so Gro now sees exactly what `vite dev` -and `vite build` see: the inline options when a project passes them to `sveltekit()`, -and otherwise whatever SvelteKit loaded from `svelte.config.js` on its own. That means -projects keeping a `svelte.config.js` are unaffected - Vite reads it for them - while -projects configuring Svelte inline in `vite.config.ts` now work, along with SvelteKit's -own defaults instead of Gro's hand-rolled fallbacks. A Vite config that fails to -resolve now throws instead of being silently ignored. +It's the same `vite.resolveConfig` call SvelteKit's own `load_config` makes, so Gro sees +exactly what SvelteKit sees: the inline options when a project passes them to +`sveltekit()`, and otherwise whatever SvelteKit loaded from `svelte.config.js` on its +own. That means projects keeping a `svelte.config.js` are unaffected - Vite reads it for +them - while projects configuring Svelte inline in `vite.config.ts` now work, along with +SvelteKit's own defaults instead of Gro's hand-rolled fallbacks. A Vite config that +fails to resolve now throws instead of being silently ignored. + +Gro reads the project in the cwd and only that project. SvelteKit resolves its `files` +and `env.dir` against its own cwd rather than the Vite `root` it's handed, so a +directory parameter could only ever be half-honored, and there isn't one. The Node loader resolves the config the same way as the main thread. It runs on a worker thread, where `process.chdir` is unavailable, but Vite's config resolution doesn't need it - only `@sveltejs/load-config` does, which is why Gro calls `vite.resolveConfig` directly and drops that dependency. +The loader stays eager, though: resolving the config runs the `resolve` hook for each of +its own imports, so a hook that awaited the load it is part of would deadlock. That side +is now more expensive than reading `svelte.config.js` was, and it's on the critical path +of every invocation, so the win here is on the main thread rather than overall. + +Resolving a Vite config is more than a read - it runs every plugin's `config` and +`configResolved` hooks, so it inherits their side effects. SvelteKit's rewrite of +`.svelte-kit/env.d.ts` is one, and Vite writing `process.env.NODE_ENV` when it's unset +is another. Gro restores `NODE_ENV` around the call, so the `development` Vite would +leave behind no longer reaches the `vite build` that `gro build` spawns. + Vite is now an optional peer dependency. A project with no Vite config, or with no Vite installed, gets the conventional defaults rather than an error, so non-Vite projects -keep working. +keep working. Note that a `svelte.config.js` is only read through Vite, so a project +with one but no Vite config gets those defaults too, not its own preprocessors, +aliases, or compiler options. Also fixes `parse_svelte_config` mutating the `compilerOptions` of the config passed to it, and normalizes `kit.env.dir` to a project-relative path - it's serialized into the @@ -35,16 +53,22 @@ Breaking changes: - `TaskContext.svelte_config` and `GenContext.svelte_config` are now `Promise` - `await` them -- `default_svelte_config` is replaced by `load_default_svelte_config()` -- `parse_svelte_config` takes `{dir, svelte_config}` instead of `{dir_or_config}`, as a - union that rejects contradictory options -- `load_svelte_config` resolves the Vite config at `dir` and takes only `{dir}`; +- `default_svelte_config` is replaced by `load_default_svelte_config()`, which takes no + arguments +- `parse_svelte_config` takes `{svelte_config}` instead of `{dir_or_config}`, to parse an + already-loaded config; omit it to resolve the project's +- `load_svelte_config` resolves the project's Vite config and takes no arguments; `GroConfig.svelte_config_filename` is removed, since Vite picks its own config file - `has_sveltekit_app` is synchronous and takes a `PackageJson`, detecting `@sveltejs/kit` as a dependency rather than the presence of `svelte.config.js` - `has_sveltekit_library` takes an optional `ParsedSvelteConfig` and checks the `@sveltejs/package` dependency before the lib directory - `ROUTES_DIRNAME` is removed +- `SVELTE_CONFIG_FILENAME` and `VITE_CONFIG_FILENAME` are replaced by + `SVELTE_CONFIG_FILENAMES` and `VITE_CONFIG_FILENAMES`, every filename SvelteKit and + Vite accept for their configs. `gro format` now formats whichever of them a project + has, not only `svelte.config.js` and `vite.config.ts`. `VITE_CONFIG_BASENAME` and + `VITE_CONFIG_EXTENSIONS` are removed, subsumed by the filename list - `paths.lib`, `LIB_DIRNAME`, `LIB_PATH`, and `LIB_DIR` are the conventional `src/lib` rather than derived from `kit.files.lib`, so that `paths` stays free of the config. Read `lib_path` off `ParsedSvelteConfig` to honor a customized `files.lib`, and point diff --git a/CLAUDE.md b/CLAUDE.md index cf1290afda..e45a0bf118 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -287,7 +287,8 @@ Optional `gro.config.ts` at project root exports `CreateGroConfig` function or config object. If absent, uses default config from `src/lib/gro.config.default.ts`. -Default config behavior: Auto-detects project type by checking filesystem: +Default config behavior: Auto-detects project type from `package.json` and the +filesystem, deferred until plugins are created: - `@sveltejs/kit` in package.json → enables `gro_plugin_sveltekit_app` - `@sveltejs/package` in package.json + `src/lib/` → enables `gro_plugin_sveltekit_library` @@ -342,7 +343,7 @@ export default config; - Gen files: `*.gen.*` anywhere in `src/` (pattern: `.gen.` substring) - Test files: `*.test.ts` anywhere (run by Vitest) - Config: `gro.config.ts` at project root -- Vite config: `vite.config.ts` at project root - the Svelte config is read through it +- Vite config: `vite.config.*` at project root - the Svelte config is read through it Exclusions (configurable via `search_filters`): diff --git a/src/docs/gro_plugin_sveltekit_app.md b/src/docs/gro_plugin_sveltekit_app.md index 9b354a49b7..3ba134f54e 100644 --- a/src/docs/gro_plugin_sveltekit_app.md +++ b/src/docs/gro_plugin_sveltekit_app.md @@ -10,7 +10,7 @@ import {gro_plugin_sveltekit_app} from '@fuzdev/gro/gro_plugin_sveltekit_app.ts' const config: CreateGroConfig = async (cfg) => { cfg.plugins = async () => [ - // included in the default config for SvelteKit projects with src/routes/ + // included in the default config when `@sveltejs/kit` is a dependency gro_plugin_sveltekit_app(), ]; return cfg; diff --git a/src/docs/gro_plugin_sveltekit_library.md b/src/docs/gro_plugin_sveltekit_library.md index 19a08ca994..b9fdc7ae3f 100644 --- a/src/docs/gro_plugin_sveltekit_library.md +++ b/src/docs/gro_plugin_sveltekit_library.md @@ -10,8 +10,11 @@ The [default config](/src/lib/gro.config.default.ts) enables this plugin when all three conditions are met: 1. `@sveltejs/kit` is a dependency in `package.json` -2. `src/lib/` directory exists (or the path configured by `kit.files.lib`) -3. `@sveltejs/package` is listed in `package.json` dependencies +2. `@sveltejs/package` is listed in `package.json` dependencies +3. `src/lib/` directory exists (or the path configured by `kit.files.lib`) + +They're checked in that order, so a project that isn't a library never reads the +Svelte config, which is the only one of the three that costs anything. Install to enable: diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 386a1a0f69..7611936d10 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -20,24 +20,33 @@ export const GRO_DIR = GRO_DIRNAME + '/'; export const GRO_DEV_DIR = GRO_DEV_DIRNAME + '/'; export const GRO_CONFIG_FILENAME = 'gro.config.ts'; /** - * Gro reads the Svelte config through Vite, never from this file directly, - * but SvelteKit still loads it when `sveltekit()` gets no inline options, - * so it's a project file that Gro formats. + * Every filename SvelteKit loads its config from, in SvelteKit's own precedence order. + * Gro reads the Svelte config through Vite, never from these directly, + * but SvelteKit still loads one when `sveltekit()` gets no inline options, + * so they're project files that Gro formats. + * @see https://svelte.dev/docs/kit/configuration */ -export const SVELTE_CONFIG_FILENAME = 'svelte.config.js'; +export const SVELTE_CONFIG_FILENAMES = ['svelte.config.js', 'svelte.config.ts']; /** * SvelteKit's alias for the library directory. * Always `$lib` no matter where `files.lib` points. * @see https://svelte.dev/docs/kit/configuration#files */ export const SVELTEKIT_LIB_ALIAS = '$lib'; -export const VITE_CONFIG_BASENAME = 'vite.config'; /** - * The extensions Vite itself accepts for its config, in Vite's own precedence order. + * Every filename Vite picks up as its config, in Vite's own precedence order. + * Which one wins is Vite's call, so Gro treats them as a set rather than privileging + * one extension - it detects a Vite config with all of them, and formats all of them. * @see https://vite.dev/config/ */ -export const VITE_CONFIG_EXTENSIONS = ['js', 'mjs', 'ts', 'cjs', 'mts', 'cts']; -export const VITE_CONFIG_FILENAME = VITE_CONFIG_BASENAME + '.ts'; +export const VITE_CONFIG_FILENAMES = [ + 'vite.config.js', + 'vite.config.mjs', + 'vite.config.ts', + 'vite.config.cjs', + 'vite.config.mts', + 'vite.config.cts' +]; export const NODE_MODULES_DIRNAME = 'node_modules'; export const PACKAGE_JSON_FILENAME = 'package.json'; export const LOCKFILE_FILENAME = 'package-lock.json'; diff --git a/src/lib/esbuild_plugin_sveltekit_shim_alias.ts b/src/lib/esbuild_plugin_sveltekit_shim_alias.ts index 928c1b1ce5..e5094fb6e5 100644 --- a/src/lib/esbuild_plugin_sveltekit_shim_alias.ts +++ b/src/lib/esbuild_plugin_sveltekit_shim_alias.ts @@ -2,6 +2,9 @@ import type * as esbuild from 'esbuild'; import { escape_regexp } from '@fuzdev/fuz_util/regexp.ts'; import { join } from 'node:path'; +import { SVELTEKIT_LIB_ALIAS } from './constants.ts'; +import { LIB_PATH } from './paths.ts'; + export interface EsbuildPluginSveltekitShimAliasOptions { dir?: string; alias?: Record; @@ -13,7 +16,9 @@ export const esbuild_plugin_sveltekit_shim_alias = ({ }: EsbuildPluginSveltekitShimAliasOptions): esbuild.Plugin => ({ name: 'sveltekit_shim_alias', setup: (build) => { - const aliases: Record = { $lib: 'src/lib', ...alias }; + // The `$lib` fallback is for callers that pass no `alias` at all - + // a `ParsedSvelteConfig` always carries one, pointed at its `files.lib`. + const aliases: Record = { [SVELTEKIT_LIB_ALIAS]: LIB_PATH, ...alias }; // Create a Go-compatible regexp const filter = new RegExp(`^(?:${Object.keys(aliases).map(escape_regexp).join('|')})`); build.onResolve({ filter }, async (args) => { diff --git a/src/lib/filer.ts b/src/lib/filer.ts index 47fe918284..12f32cd392 100644 --- a/src/lib/filer.ts +++ b/src/lib/filer.ts @@ -24,11 +24,12 @@ import { map_sveltekit_aliases } from './sveltekit_helpers.ts'; import { SVELTEKIT_GLOBAL_SPECIFIER } from './constants.ts'; import type { Disknode } from './disknode.ts'; -let aliases: Array<[string, string]> | undefined; - -/** Loaded on demand so constructing a `Filer` doesn't read the SvelteKit config. */ +/** + * Loaded on demand so constructing a `Filer` doesn't read the SvelteKit config. + * `load_default_svelte_config` memoizes, so this is cheap after the first call. + */ const load_aliases = async (): Promise> => - (aliases ??= Object.entries((await load_default_svelte_config()).alias)); + Object.entries((await load_default_svelte_config()).alias); export type OnFilerChange = (change: WatcherChange, disknode: Disknode) => void; diff --git a/src/lib/format_directory.ts b/src/lib/format_directory.ts index 223d40ffa6..c69bd51ede 100644 --- a/src/lib/format_directory.ts +++ b/src/lib/format_directory.ts @@ -6,7 +6,11 @@ import { globSync, readFileSync } from 'node:fs'; import { readFile, writeFile } from 'node:fs/promises'; import { isAbsolute, join, resolve } from 'node:path'; -import { GRO_CONFIG_FILENAME, SVELTE_CONFIG_FILENAME, VITE_CONFIG_FILENAME } from './constants.ts'; +import { + GRO_CONFIG_FILENAME, + SVELTE_CONFIG_FILENAMES, + VITE_CONFIG_FILENAMES +} from './constants.ts'; import { format_file } from './format_file.ts'; import { paths } from './paths.ts'; @@ -20,11 +24,18 @@ const FORMATTABLE_MATCHER = /\.(ts|mts|cts|js|mjs|cjs|svelte|css)$/; /** * Root-level files formatted alongside `paths.source`. + * Every Svelte and Vite config filename is listed rather than just the `.js`/`.ts` one, + * since which extension a project uses is SvelteKit's and Vite's call; the ones a project + * doesn't have are skipped when read, the same as any other default root file it lacks. * `package.json` is intentionally omitted — `gro sync` owns its serialization * via `package_json_serialize` (2-space, matching the npm convention). * `tsconfig.json` is omitted because the sweep no longer formats json. */ -const ROOT_FILES_DEFAULT = [GRO_CONFIG_FILENAME, SVELTE_CONFIG_FILENAME, VITE_CONFIG_FILENAME]; +const ROOT_FILES_DEFAULT = [ + GRO_CONFIG_FILENAME, + ...SVELTE_CONFIG_FILENAMES, + ...VITE_CONFIG_FILENAMES +]; /** * Root-level ignore files for the format sweep (gitignore-style patterns). diff --git a/src/lib/gro_plugin_server.ts b/src/lib/gro_plugin_server.ts index 8e791659cc..ff5a300b39 100644 --- a/src/lib/gro_plugin_server.ts +++ b/src/lib/gro_plugin_server.ts @@ -60,8 +60,8 @@ export interface GroPluginServerOptions { */ ambient_env?: Record; /** - * An already-loaded Svelte config, to skip resolving `dir`'s Vite config. - * @default ```resolved from `dir`'s Vite config```` + * An already-loaded Svelte config, to skip resolving the project's Vite config. + * @default ```resolved from the project's Vite config```` */ svelte_config?: SvelteConfig; /** @@ -134,11 +134,12 @@ export const gro_plugin_server = ({ return { name: 'gro_plugin_server', setup: async ({ dev, watch, timings, log, config, filer }) => { - // `load_default_svelte_config` memoizes per directory, so this shares the resolution - // with the rest of the process when `dir` is the cwd. + // `load_default_svelte_config` memoizes, so this shares the resolution + // with the rest of the process. Note that it reads the cwd's config, + // not `dir`'s - `dir` positions esbuild's output and alias resolution. const parsed_svelte_config = svelte_config - ? await parse_svelte_config({ svelte_config, dir }) - : await load_default_svelte_config({ dir }); + ? await parse_svelte_config({ svelte_config }) + : await load_default_svelte_config(); const { alias, base_url, diff --git a/src/lib/loader.ts b/src/lib/loader.ts index 9707628f9a..822df23d05 100644 --- a/src/lib/loader.ts +++ b/src/lib/loader.ts @@ -65,6 +65,7 @@ bare specifier, so the first resolve would load the config anyway. The loader runs on a worker thread, where `process.chdir` is unavailable - Vite's own config resolution doesn't need it, so this is the same load the main thread does. +Both read the project in the cwd, which is the only project either one resolves. */ const { @@ -77,7 +78,7 @@ const { svelte_compile_options, svelte_compile_module_options, svelte_preprocessors -} = await load_default_svelte_config({ dir }); +} = await load_default_svelte_config(); const aliases = Object.entries(alias); diff --git a/src/lib/module.ts b/src/lib/module.ts index ee977befdd..e41fece9f5 100644 --- a/src/lib/module.ts +++ b/src/lib/module.ts @@ -1,10 +1,12 @@ +import { escape_regexp } from '@fuzdev/fuz_util/regexp.ts'; + import { SOURCE_DIR, SOURCE_DIRNAME, SVELTEKIT_LIB_ALIAS } from './constants.ts'; export const MODULE_PATH_SRC_PREFIX = SOURCE_DIR; export const MODULE_PATH_LIB_PREFIX = SVELTEKIT_LIB_ALIAS + '/'; const INTERNAL_MODULE_MATCHER = new RegExp( - `^(\\.?\\.?|${SOURCE_DIRNAME}|\\${SVELTEKIT_LIB_ALIAS})\\/`, + `^(\\.?\\.?|${SOURCE_DIRNAME}|${escape_regexp(SVELTEKIT_LIB_ALIAS)})\\/`, 'u' ); diff --git a/src/lib/paths.ts b/src/lib/paths.ts index 376706176c..894bbcea22 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -22,8 +22,8 @@ It's the same name that Rollup uses. /* These are the conventional locations, not the SvelteKit `files` config values, -so that `paths` stays cheap - reading the SvelteKit config imports the config module -and its preprocessors, which is too expensive to do on every Gro invocation. +so that `paths` stays cheap - reading the SvelteKit config costs a full Vite config +resolution, which is too expensive to do on every Gro invocation. Code that needs to honor a customized `kit.files.lib` reads `lib_path` off `ParsedSvelteConfig` instead, and projects that move it can point `task_root_dirs` at the new location in `gro.config.ts`. diff --git a/src/lib/svelte_config.ts b/src/lib/svelte_config.ts index 9831c19047..675b7261a2 100644 --- a/src/lib/svelte_config.ts +++ b/src/lib/svelte_config.ts @@ -1,10 +1,10 @@ import type { Config as SvelteConfig } from '@sveltejs/kit'; import type { CompileOptions, ModuleCompileOptions, PreprocessorGroup } from 'svelte/compiler'; -import { isAbsolute, join, relative, resolve } from 'node:path'; +import { isAbsolute, join, relative } from 'node:path'; import { existsSync } from 'node:fs'; import { EMPTY_OBJECT } from '@fuzdev/fuz_util/object.ts'; -import { SVELTEKIT_LIB_ALIAS, VITE_CONFIG_BASENAME, VITE_CONFIG_EXTENSIONS } from './constants.ts'; +import { SVELTEKIT_LIB_ALIAS, VITE_CONFIG_FILENAMES } from './constants.ts'; /* eslint-disable @typescript-eslint/no-deprecated */ // see https://github.com/sveltejs/kit/discussions/14240 @@ -14,16 +14,23 @@ import { SVELTEKIT_LIB_ALIAS, VITE_CONFIG_BASENAME, VITE_CONFIG_EXTENSIONS } fro This module is intended to have minimal dependencies to avoid over-imports in the CLI. Loading is lazy and memoized - see `load_default_svelte_config`. -The Svelte config is read through Vite, never from `svelte.config.js` directly. -SvelteKit resolves its own config the same way, so this sees exactly what -`vite dev` and `vite build` see - inline `sveltekit()` options when a project passes -them, and otherwise whatever SvelteKit loaded from `svelte.config.js` on its own. +The Svelte config is read through Vite, never from `svelte.config.js` directly, +with the same `resolveConfig` call SvelteKit's own `load_config` makes. +So this sees exactly what SvelteKit sees - inline `sveltekit()` options when a project +passes them, and otherwise whatever SvelteKit loaded from `svelte.config.js` on its own. + +Always the project in the cwd, never an arbitrary directory. SvelteKit resolves its +`files` and `env.dir` against its own cwd rather than the Vite `root` it's handed, +so a `dir` parameter here could only ever be half-honored. */ /** - * The names of the Vite plugins that carry the resolved Svelte config, - * in the order SvelteKit itself prefers them. + * The names of the Vite plugins that carry the resolved Svelte config, most specific first. + * SvelteKit's `api.options` is the split config shape, with its own options under `kit` - + * it's the only one SvelteKit itself reads. `vite-plugin-svelte`'s is that plugin's resolved + * options, which carry no `kit` but overlap in `compilerOptions` and `preprocess`, + * so a plain Svelte project still gets its compiler options and preprocessors. */ const CONFIG_PROVIDER_PLUGIN_NAMES = ['vite-plugin-sveltekit-setup', 'vite-plugin-svelte:config']; @@ -32,26 +39,15 @@ const CONFIG_PROVIDER_PLUGIN_NAMES = ['vite-plugin-sveltekit-setup', 'vite-plugi * Which of several Vite configs wins is Vite's call, not Gro's. */ const has_vite_config = (dir: string): boolean => - VITE_CONFIG_EXTENSIONS.some((ext) => existsSync(join(dir, VITE_CONFIG_BASENAME + '.' + ext))); - -export interface LoadSvelteConfigOptions { - /** - * @default cwd - */ - dir?: string; -} + VITE_CONFIG_FILENAMES.some((filename) => existsSync(join(dir, filename))); /** - * Loads the Svelte config at `dir` by resolving its Vite config. - * @returns `null` if `dir` has no Vite config, or one that configures no Svelte plugin + * Loads the Svelte config of the project in the cwd by resolving its Vite config. + * @returns `null` if the project has no Vite config, or one that configures no Svelte plugin * @throws if the Vite config is found but fails to resolve */ -export const load_svelte_config = async ( - options: LoadSvelteConfigOptions = EMPTY_OBJECT -): Promise => { - // Normalized because SvelteKit compares the `root` it's given against the one it enforces - // and prints a red warning when they differ - a trailing slash is enough to trip it. - const dir = resolve(options.dir ?? process.cwd()); +export const load_svelte_config = async (): Promise => { + const dir = process.cwd(); if (!has_vite_config(dir)) return null; let vite; @@ -65,17 +61,32 @@ export const load_svelte_config = async ( } let resolved; + // `resolveConfig` writes `process.env.NODE_ENV` when it's unset, and reading the config is + // not a decision about the process. Left alone, the `development` it writes is inherited by + // the `vite build` that `gro build` spawns, which builds for production as if for dev. + // The restore can't cover the await itself, but it bounds the window to this call. + const node_env = process.env.NODE_ENV; try { - // No `configFile`, so Vite picks its own config the way it does everywhere else. - // Unlike `@sveltejs/load-config` this doesn't `process.chdir` - Gro always resolves - // the project it's running in, and chdir is unavailable on the loader's worker thread. - resolved = await vite.resolveConfig({ root: dir, logLevel: 'error' }, 'serve'); + // The same call SvelteKit's `load_config` makes, minus the `process.chdir` it needs + // only to point at another directory - so Gro reads the config SvelteKit would read. + // No `root` or `configFile`, so Vite picks its own config the way it does everywhere + // else; `logLevel` is Gro's, to keep config reads off the CLI's output. + resolved = await vite.resolveConfig( + { logLevel: 'error' }, + 'build', + process.env.MODE ?? 'production' + ); } catch (err) { throw new Error(`Failed to resolve the Vite config at ${dir}`, { cause: err }); + } finally { + if (node_env === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = node_env; + } } for (const name of CONFIG_PROVIDER_PLUGIN_NAMES) { - // `api.options` is the split config shape, with SvelteKit's options under `kit`. const options = resolved.plugins.find((p) => p.name === name)?.api?.options; if (options) return options as SvelteConfig; } @@ -118,11 +129,12 @@ export interface ParsedSvelteConfig { } /** - * Resolving through Vite yields absolute `files` paths, - * but Gro's vocabulary is relative to the project directory. + * Resolving through Vite yields absolute `files` paths, but Gro's vocabulary is relative + * to the project directory. The cwd is the base because that's what SvelteKit resolved + * them against, and what every consumer of these paths resolves them against in turn. */ -const to_project_relative_path = (path: string | undefined, dir: string): string | undefined => - path === undefined || !isAbsolute(path) ? path : relative(dir, path) || '.'; +const to_project_relative_path = (path: string | undefined): string | undefined => + path === undefined || !isAbsolute(path) ? path : relative(process.cwd(), path) || '.'; /** * Gro compiles for the server by default, @@ -131,18 +143,12 @@ const to_project_relative_path = (path: string | undefined, dir: string): string */ export const SVELTE_COMPILE_OPTIONS_DEFAULT: CompileOptions = Object.freeze({ generate: 'server' }); -export type ParseSvelteConfigOptions = - | LoadSvelteConfigOptions - | { - /** - * An already-loaded config to parse instead of reading one from `dir`. - */ - svelte_config: SvelteConfig; - /** - * @default cwd - */ - dir?: string; - }; +export interface ParseSvelteConfigOptions { + /** + * An already-loaded config to parse instead of resolving the project's Vite config. + */ + svelte_config?: SvelteConfig; +} /** * Returns Gro-relevant properties of a SvelteKit config @@ -151,16 +157,13 @@ export type ParseSvelteConfigOptions = export const parse_svelte_config = async ( options: ParseSvelteConfigOptions = EMPTY_OBJECT ): Promise => { - const { dir = process.cwd() } = options; - - const svelte_config = - 'svelte_config' in options ? options.svelte_config : await load_svelte_config(options); + const svelte_config = options.svelte_config ?? (await load_svelte_config()); const kit = svelte_config?.kit; - const assets_path = to_project_relative_path(kit?.files?.assets, dir) ?? 'static'; - const lib_path = to_project_relative_path(kit?.files?.lib, dir) ?? 'src/lib'; - const routes_path = to_project_relative_path(kit?.files?.routes, dir) ?? 'src/routes'; + const assets_path = to_project_relative_path(kit?.files?.assets) ?? 'static'; + const lib_path = to_project_relative_path(kit?.files?.lib) ?? 'src/lib'; + const routes_path = to_project_relative_path(kit?.files?.routes) ?? 'src/routes'; // SvelteKit always names this alias `$lib` and points it at `files.lib`. // @see https://svelte.dev/docs/kit/configuration#alias @@ -172,7 +175,7 @@ export const parse_svelte_config = async ( // Relative like the paths above, and for a sharper reason: `env_dir` is serialized into // the generated `$env/dynamic/*` modules, so an absolute path from Vite resolution would // bake the build machine's directory into server bundles. - const env_dir = to_project_relative_path(kit?.env?.dir, dir); + const env_dir = to_project_relative_path(kit?.env?.dir); const private_prefix = kit?.env?.privatePrefix; const public_prefix = kit?.env?.publicPrefix; @@ -208,33 +211,26 @@ export const to_default_compile_module_options = ({ warningFilter }: CompileOptions): ModuleCompileOptions => ({ dev, generate, filename, rootDir, warningFilter }); -const default_svelte_configs: Map> = new Map(); +let default_svelte_config: Promise | undefined; /** - * The parsed Svelte config, memoized per directory. + * The parsed Svelte config for the project in the cwd, memoized. * * Reading it costs a full Vite config resolution, which runs every Vite plugin's * config hooks, so callers pull it in on demand instead of paying for it * on every Gro invocation. */ -export const load_default_svelte_config = ( - options: LoadSvelteConfigOptions = EMPTY_OBJECT -): Promise => { - const { dir = process.cwd() } = options; - const key = resolve(dir); - let loading = default_svelte_configs.get(key); - if (loading === undefined) { - loading = parse_svelte_config({ dir }); - default_svelte_configs.set(key, loading); +export const load_default_svelte_config = (): Promise => { + if (default_svelte_config === undefined) { + const loading = (default_svelte_config = parse_svelte_config()); // Evict failures so a long-lived process like `gro dev` picks up a fixed config. // Attaching the handler here also keeps the cached promise from being reported // as an unhandled rejection when nothing has awaited it yet. - const failed = loading; void loading.catch(() => { - if (default_svelte_configs.get(key) === failed) { - default_svelte_configs.delete(key); + if (default_svelte_config === loading) { + default_svelte_config = undefined; } }); } - return loading; + return default_svelte_config; }; diff --git a/src/test/svelte_config.test.ts b/src/test/svelte_config.test.ts index 2a12c708cb..23b917d13a 100644 --- a/src/test/svelte_config.test.ts +++ b/src/test/svelte_config.test.ts @@ -1,11 +1,19 @@ import { describe, test, expect } from 'vitest'; import type { Config as SvelteConfig } from '@sveltejs/kit'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; -import { load_default_svelte_config, parse_svelte_config } from '$lib/svelte_config.ts'; +import { + load_default_svelte_config, + load_svelte_config, + parse_svelte_config +} from '$lib/svelte_config.ts'; -const DIR = '/fake/project'; +// The project directory is always the cwd - see `svelte_config.ts` for why it can't be anything else. +const DIR = process.cwd(); -const parse = (svelte_config: SvelteConfig) => parse_svelte_config({ svelte_config, dir: DIR }); +const parse = (svelte_config: SvelteConfig) => parse_svelte_config({ svelte_config }); describe('parse_svelte_config', () => { test('falls back to the conventional paths when nothing is configured', async () => { @@ -93,32 +101,62 @@ describe('parse_svelte_config', () => { }); describe('load_default_svelte_config', () => { - // `DIR` holds no Vite config, so these stay cheap - Vite is never loaded. - test('memoizes per directory', async () => { - const loading = load_default_svelte_config({ dir: DIR }); - expect(load_default_svelte_config({ dir: DIR })).toBe(loading); - // Keyed on the resolved directory, so these are the same entry. - expect(load_default_svelte_config({ dir: DIR + '/' })).toBe(loading); - expect(load_default_svelte_config({ dir: DIR + '/nested/..' })).toBe(loading); - // A different directory is not. - const nested = load_default_svelte_config({ dir: DIR + '/nested' }); - expect(nested).not.toBe(loading); - await Promise.all([loading, nested]); - }); - - test('falls back to the conventional paths when the directory has no Vite config', async () => { - await expect(load_default_svelte_config({ dir: DIR })).resolves.toMatchObject({ - svelte_config: null, - lib_path: 'src/lib' - }); + test('memoizes', () => { + expect(load_default_svelte_config()).toBe(load_default_svelte_config()); }); // Resolves this project's own `vite.config.ts` through Vite, the way every Gro // invocation does, so it covers reading the config off the SvelteKit plugin. + // `env_dir` is `'.'` rather than undefined because SvelteKit defaults `kit.env.dir` + // to its own cwd, so the real path always yields an absolute one to rebase. test('resolves the config of the project it runs in', async () => { await expect(load_default_svelte_config()).resolves.toMatchObject({ lib_path: 'src/lib', - routes_path: 'src/routes' + routes_path: 'src/routes', + env_dir: '.' }); }); }); + +/** + * Runs `fn` in an empty directory. The config is always read from the cwd, + * so moving the cwd is the only way to point `load_svelte_config` somewhere else. + */ +const in_empty_dir = async (fn: () => Promise): Promise => { + const cwd = process.cwd(); + const dir = mkdtempSync(join(tmpdir(), 'gro_svelte_config_')); + process.chdir(dir); + try { + return await fn(); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } +}; + +describe('load_svelte_config', () => { + // The path that keeps non-Vite projects working. Cheap too - + // it short-circuits before Vite is imported at all. + test('returns null when the project has no Vite config', async () => { + await expect(in_empty_dir(load_svelte_config)).resolves.toBe(null); + }); + + // Vite's `resolveConfig` writes `NODE_ENV` when it's unset, and the `development` it would + // leave behind is inherited by the `vite build` that `gro build` spawns, which then builds + // for production as if for dev. Uses `load_svelte_config` rather than the memoized wrapper + // so the resolution actually runs. + test('leaves NODE_ENV as it found it', async () => { + const node_env = process.env.NODE_ENV; + delete process.env.NODE_ENV; + try { + await load_svelte_config(); + expect('NODE_ENV' in process.env).toBe(false); + } finally { + if (node_env === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = node_env; + } + } + }); +}); From 6c1f1f42d63ebed20aba061afe2465e11d33a385 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 7 Aug 2026 20:17:28 -0400 Subject: [PATCH 05/10] cleanup --- .changeset/lazy-svelte-config.md | 5 +++-- src/lib/changeset.task.ts | 6 +----- src/lib/filer.ts | 8 +++++--- src/lib/sveltekit_helpers.ts | 5 ++--- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/.changeset/lazy-svelte-config.md b/.changeset/lazy-svelte-config.md index e8334a951d..f87bbe347b 100644 --- a/.changeset/lazy-svelte-config.md +++ b/.changeset/lazy-svelte-config.md @@ -61,8 +61,9 @@ Breaking changes: `GroConfig.svelte_config_filename` is removed, since Vite picks its own config file - `has_sveltekit_app` is synchronous and takes a `PackageJson`, detecting `@sveltejs/kit` as a dependency rather than the presence of `svelte.config.js` -- `has_sveltekit_library` takes an optional `ParsedSvelteConfig` and checks the - `@sveltejs/package` dependency before the lib directory +- `has_sveltekit_library` no longer takes a `ParsedSvelteConfig` - it reads the memoized + one, and only after checking the `@sveltejs/package` dependency, so a project that + isn't a library never reads the Svelte config - `ROUTES_DIRNAME` is removed - `SVELTE_CONFIG_FILENAME` and `VITE_CONFIG_FILENAME` are replaced by `SVELTE_CONFIG_FILENAMES` and `VITE_CONFIG_FILENAMES`, every filename SvelteKit and diff --git a/src/lib/changeset.task.ts b/src/lib/changeset.task.ts index 0e9f7c6766..db9736802a 100644 --- a/src/lib/changeset.task.ts +++ b/src/lib/changeset.task.ts @@ -84,7 +84,6 @@ export const task: Task = { changeset_cli }, log, - svelte_config, config } = ctx; @@ -102,10 +101,7 @@ export const task: Task = { const package_json = await package_json_load(); - const has_sveltekit_library_result = await has_sveltekit_library( - package_json, - await svelte_config - ); + const has_sveltekit_library_result = await has_sveltekit_library(package_json); if (!has_sveltekit_library_result.ok) { throw new TaskError( 'Failed to find SvelteKit library: ' + has_sveltekit_library_result.message diff --git a/src/lib/filer.ts b/src/lib/filer.ts index 12f32cd392..3cca5fe462 100644 --- a/src/lib/filer.ts +++ b/src/lib/filer.ts @@ -24,12 +24,14 @@ import { map_sveltekit_aliases } from './sveltekit_helpers.ts'; import { SVELTEKIT_GLOBAL_SPECIFIER } from './constants.ts'; import type { Disknode } from './disknode.ts'; +let aliases: Array<[string, string]> | undefined; + /** - * Loaded on demand so constructing a `Filer` doesn't read the SvelteKit config. - * `load_default_svelte_config` memoizes, so this is cheap after the first call. + * Loaded on demand so constructing a `Filer` doesn't read the SvelteKit config, + * and memoized because this is called for every import specifier of every changed file. */ const load_aliases = async (): Promise> => - Object.entries((await load_default_svelte_config()).alias); + (aliases ??= Object.entries((await load_default_svelte_config()).alias)); export type OnFilerChange = (change: WatcherChange, disknode: Disknode) => void; diff --git a/src/lib/sveltekit_helpers.ts b/src/lib/sveltekit_helpers.ts index bce95fdc46..8da27a2854 100644 --- a/src/lib/sveltekit_helpers.ts +++ b/src/lib/sveltekit_helpers.ts @@ -15,7 +15,7 @@ import { SVELTEKIT_DEV_DIRNAME } from './constants.ts'; import { package_json_has_dependency } from './package_json.ts'; -import { load_default_svelte_config, type ParsedSvelteConfig } from './svelte_config.ts'; +import { load_default_svelte_config } from './svelte_config.ts'; import { TaskError } from './task.ts'; /** @@ -34,7 +34,6 @@ export const has_sveltekit_app = ( export const has_sveltekit_library = async ( package_json: PackageJson, - svelte_config?: ParsedSvelteConfig, dep_name = SVELTE_PACKAGE_DEP_NAME ): Promise> => { const has_sveltekit_app_result = has_sveltekit_app(package_json); @@ -52,7 +51,7 @@ export const has_sveltekit_library = async ( }; } - const { lib_path } = svelte_config ?? (await load_default_svelte_config()); + const { lib_path } = await load_default_svelte_config(); if (!(await fs_exists(lib_path))) { return { ok: false, message: `no SvelteKit lib directory found at ${lib_path}` }; } From 966a4237ba3245bc46892d72fc5da1fa0a6f68c7 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 7 Aug 2026 20:25:08 -0400 Subject: [PATCH 06/10] throw when vite config exists but vite not installed --- .changeset/lazy-svelte-config.md | 16 +++++++--- src/lib/svelte_config.ts | 53 ++++++++++++++++++++++++-------- src/test/svelte_config.test.ts | 27 ++++++++++++++-- 3 files changed, 76 insertions(+), 20 deletions(-) diff --git a/.changeset/lazy-svelte-config.md b/.changeset/lazy-svelte-config.md index f87bbe347b..3428a6156a 100644 --- a/.changeset/lazy-svelte-config.md +++ b/.changeset/lazy-svelte-config.md @@ -38,11 +38,17 @@ Resolving a Vite config is more than a read - it runs every plugin's `config` an is another. Gro restores `NODE_ENV` around the call, so the `development` Vite would leave behind no longer reaches the `vite build` that `gro build` spawns. -Vite is now an optional peer dependency. A project with no Vite config, or with no Vite -installed, gets the conventional defaults rather than an error, so non-Vite projects -keep working. Note that a `svelte.config.js` is only read through Vite, so a project -with one but no Vite config gets those defaults too, not its own preprocessors, -aliases, or compiler options. +Vite is now an optional peer dependency. A project with no Vite config gets the +conventional defaults rather than an error, so non-Vite projects keep working. Note that +a `svelte.config.js` is only read through Vite, so a project with one but no Vite config +gets those defaults too, not its own preprocessors, aliases, or compiler options - that +combination now warns, since it looks configured while being silently ignored. + +A project that does have a Vite config but no Vite installed throws rather than falling +back, because falling back would mean compiling against the wrong config in silence. + +The warning goes through the exported `svelte_config_log`, since this runs where there's +no logger to pass in - set `svelte_config_log.level = 'off'` to silence it. Also fixes `parse_svelte_config` mutating the `compilerOptions` of the config passed to it, and normalizes `kit.env.dir` to a project-relative path - it's serialized into the diff --git a/src/lib/svelte_config.ts b/src/lib/svelte_config.ts index 675b7261a2..2e64ec9b48 100644 --- a/src/lib/svelte_config.ts +++ b/src/lib/svelte_config.ts @@ -3,8 +3,13 @@ import type { CompileOptions, ModuleCompileOptions, PreprocessorGroup } from 'sv import { isAbsolute, join, relative } from 'node:path'; import { existsSync } from 'node:fs'; import { EMPTY_OBJECT } from '@fuzdev/fuz_util/object.ts'; +import { Logger } from '@fuzdev/fuz_util/log.ts'; -import { SVELTEKIT_LIB_ALIAS, VITE_CONFIG_FILENAMES } from './constants.ts'; +import { + SVELTE_CONFIG_FILENAMES, + SVELTEKIT_LIB_ALIAS, + VITE_CONFIG_FILENAMES +} from './constants.ts'; /* eslint-disable @typescript-eslint/no-deprecated */ // see https://github.com/sveltejs/kit/discussions/14240 @@ -19,6 +24,11 @@ with the same `resolveConfig` call SvelteKit's own `load_config` makes. So this sees exactly what SvelteKit sees - inline `sveltekit()` options when a project passes them, and otherwise whatever SvelteKit loaded from `svelte.config.js` on its own. +A project with no Vite config is read as having no Svelte config, since there's nothing +to read one through, so projects that don't use Vite keep working on the defaults. +Having a Vite config that can't be resolved is an error rather than a fallback, +because falling back would mean compiling against the wrong config in silence. + Always the project in the cwd, never an arbitrary directory. SvelteKit resolves its `files` and `env.dir` against its own cwd rather than the Vite `root` it's handed, so a `dir` parameter here could only ever be half-honored. @@ -35,29 +45,48 @@ so a `dir` parameter here could only ever be half-honored. const CONFIG_PROVIDER_PLUGIN_NAMES = ['vite-plugin-sveltekit-setup', 'vite-plugin-svelte:config']; /** - * Whether `dir` has a Vite config at all, used only to skip the work of loading Vite. - * Which of several Vite configs wins is Vite's call, not Gro's. + * This module has no logger in scope - it's called from the Node loader and from + * `load_default_svelte_config`, neither of which has one to pass in. + * Exported so it can be silenced or redirected, e.g. `svelte_config_log.level = 'off'`. */ -const has_vite_config = (dir: string): boolean => - VITE_CONFIG_FILENAMES.some((filename) => existsSync(join(dir, filename))); +export const svelte_config_log = new Logger('svelte_config'); + +/** + * The first of `filenames` that exists in `dir`, if any. + * Which of several configs wins is Vite's and SvelteKit's call, not Gro's, + * so their filenames are treated as a set rather than privileging one extension. + */ +const find_config_file = (dir: string, filenames: Array): string | undefined => + filenames.find((filename) => existsSync(join(dir, filename))); /** * Loads the Svelte config of the project in the cwd by resolving its Vite config. * @returns `null` if the project has no Vite config, or one that configures no Svelte plugin - * @throws if the Vite config is found but fails to resolve + * @throws if the project has a Vite config but Vite isn't installed, or if it fails to resolve */ export const load_svelte_config = async (): Promise => { const dir = process.cwd(); - if (!has_vite_config(dir)) return null; + if (!find_config_file(dir, VITE_CONFIG_FILENAMES)) { + // A project with neither config simply isn't a Svelte project, but one with a Svelte config + // and no Vite config looks configured while being silently ignored, so it gets a warning. + const svelte_config_filename = find_config_file(dir, SVELTE_CONFIG_FILENAMES); + if (svelte_config_filename) { + svelte_config_log.warn( + `Found ${svelte_config_filename} but no Vite config in ${dir},` + + ' so its preprocessors, aliases, and compiler options are being ignored.' + + ' Gro reads the Svelte config through Vite, the same as SvelteKit does.' + ); + } + return null; + } let vite; try { vite = await import('vite'); - } catch (_err) { - // Vite isn't installed, so the project can't be built with it either. - // Degrading beats throwing here - this runs in the Node loader on every invocation, - // and a project in this state still needs to be able to run tasks like `gro sync`. - return null; + } catch (err) { + // Only reachable with a Vite config in hand, so the project is meant to build with Vite + // and can't. Degrading would mean compiling against the wrong config in silence. + throw new Error(`Found a Vite config at ${dir} but failed to import Vite`, { cause: err }); } let resolved; diff --git a/src/test/svelte_config.test.ts b/src/test/svelte_config.test.ts index 23b917d13a..70dd577801 100644 --- a/src/test/svelte_config.test.ts +++ b/src/test/svelte_config.test.ts @@ -1,13 +1,14 @@ -import { describe, test, expect } from 'vitest'; +import { describe, test, expect, vi } from 'vitest'; import type { Config as SvelteConfig } from '@sveltejs/kit'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { load_default_svelte_config, load_svelte_config, - parse_svelte_config + parse_svelte_config, + svelte_config_log } from '$lib/svelte_config.ts'; // The project directory is always the cwd - see `svelte_config.ts` for why it can't be anything else. @@ -141,6 +142,26 @@ describe('load_svelte_config', () => { await expect(in_empty_dir(load_svelte_config)).resolves.toBe(null); }); + // A project with neither config isn't a Svelte project, so it gets no warning, + // but one with a Svelte config and no Vite config is silently ignored without this. + test('warns when a Svelte config has no Vite config to be read through', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // `Logger` defaults to `'off'` under Vitest, so the level is opted back in here. + svelte_config_log.level = 'warn'; + try { + const loaded = await in_empty_dir(async () => { + writeFileSync('svelte.config.js', 'export default {};'); + return load_svelte_config(); + }); + expect(loaded).toBe(null); + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls.flat().join(' ')).toContain('svelte.config.js'); + } finally { + svelte_config_log.clear_level_override(); + warn.mockRestore(); + } + }); + // Vite's `resolveConfig` writes `NODE_ENV` when it's unset, and the `development` it would // leave behind is inherited by the `vite build` that `gro build` spawns, which then builds // for production as if for dev. Uses `load_svelte_config` rather than the memoized wrapper From 059085ca15ed5893f1aea11c04be48c69a0ad3ad Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 7 Aug 2026 20:29:29 -0400 Subject: [PATCH 07/10] cleanup --- .changeset/lazy-svelte-config.md | 5 ++++- src/lib/gen.ts | 32 ++++++++++++++++++++++++++++++-- src/lib/gen_helpers.ts | 23 +++++------------------ src/lib/run_gen.ts | 21 ++++----------------- src/lib/sveltekit_helpers.ts | 14 ++++++-------- 5 files changed, 49 insertions(+), 46 deletions(-) diff --git a/.changeset/lazy-svelte-config.md b/.changeset/lazy-svelte-config.md index 3428a6156a..833f702e91 100644 --- a/.changeset/lazy-svelte-config.md +++ b/.changeset/lazy-svelte-config.md @@ -69,7 +69,10 @@ Breaking changes: `@sveltejs/kit` as a dependency rather than the presence of `svelte.config.js` - `has_sveltekit_library` no longer takes a `ParsedSvelteConfig` - it reads the memoized one, and only after checking the `@sveltejs/package` dependency, so a project that - isn't a library never reads the Svelte config + isn't a library never reads the Svelte config. Both it and `has_sveltekit_app` also + drop their unused `dep_name` parameter and use the dependency-name constants directly +- `GenContext` is built by the new `create_gen_context`, shared by generation and + dependency resolution so they can't drift - `ROUTES_DIRNAME` is removed - `SVELTE_CONFIG_FILENAME` and `VITE_CONFIG_FILENAME` are replaced by `SVELTE_CONFIG_FILENAMES` and `VITE_CONFIG_FILENAMES`, every filename SvelteKit and diff --git a/src/lib/gen.ts b/src/lib/gen.ts index 221bf286b8..148a871b68 100644 --- a/src/lib/gen.ts +++ b/src/lib/gen.ts @@ -8,9 +8,9 @@ import type { PathId } from '@fuzdev/fuz_util/path.ts'; import { each_concurrent, map_concurrent } from '@fuzdev/fuz_util/async.ts'; import { fs_search } from '@fuzdev/fuz_util/fs.ts'; -import { print_path } from './paths.ts'; +import { print_path, to_root_path } from './paths.ts'; import type { GroConfig } from './gro_config.ts'; -import type { ParsedSvelteConfig } from './svelte_config.ts'; +import { load_default_svelte_config, type ParsedSvelteConfig } from './svelte_config.ts'; import { load_modules, type LoadModulesFailure, type ModuleMeta } from './modules.ts'; import { InputPath, @@ -88,6 +88,34 @@ export interface GenContext { changed_file_id: PathId | undefined; } +/** + * The `GenContext` values that are the same for every genfile in a run, + * as opposed to the per-genfile ones `create_gen_context` derives. + */ +export type GenContextBase = Pick< + GenContext, + 'config' | 'filer' | 'log' | 'timings' | 'invoke_task' +>; + +/** + * Builds a `GenContext` for one genfile, so generating and resolving dependencies + * see the same context - notably the lazy `svelte_config`, which is a getter + * rather than a value so a genfile that ignores it never resolves it. + */ +export const create_gen_context = ( + base: GenContextBase, + origin_id: PathId, + changed_file_id?: PathId +): GenContext => ({ + ...base, + get svelte_config() { + return load_default_svelte_config(); + }, + origin_id, + origin_path: to_root_path(origin_id), + changed_file_id +}); + // TODO consider other return data - metadata? effects? non-file build artifacts? export type RawGenResult = string | RawGenFile | null | Array; export interface RawGenFile { diff --git a/src/lib/gen_helpers.ts b/src/lib/gen_helpers.ts index 264620f2a3..0ffe30bd5c 100644 --- a/src/lib/gen_helpers.ts +++ b/src/lib/gen_helpers.ts @@ -9,12 +9,10 @@ import type { InvokeTask } from './task.ts'; import { normalize_gen_config, validate_gen_module, - type GenContext, type GenDependencies, - type GenDependenciesConfig + type GenDependenciesConfig, + create_gen_context } from './gen.ts'; -import { load_default_svelte_config } from './svelte_config.ts'; -import { to_root_path } from './paths.ts'; import { load_module } from './modules.ts'; /** @@ -98,20 +96,9 @@ const resolve_gen_dependencies = async ( let dependencies: GenDependencies | null = gen_config.dependencies; if (typeof dependencies === 'function') { - const gen_ctx: GenContext = { - config, - get svelte_config() { - return load_default_svelte_config(); - }, - filer, - log, - timings, - invoke_task, - origin_id: gen_file_id, - origin_path: to_root_path(gen_file_id), - changed_file_id - }; - dependencies = await dependencies(gen_ctx); + dependencies = await dependencies( + create_gen_context({ config, filer, log, timings, invoke_task }, gen_file_id, changed_file_id) + ); } if (dependencies === null || dependencies === 'all') { diff --git a/src/lib/run_gen.ts b/src/lib/run_gen.ts index 3a2af1c8dd..7cd518e4e5 100644 --- a/src/lib/run_gen.ts +++ b/src/lib/run_gen.ts @@ -7,16 +7,15 @@ import { map_concurrent } from '@fuzdev/fuz_util/async.ts'; import { type GenResults, type GenfileModuleResult, - type GenContext, type GenfileModuleMeta, to_gen_result, type RawGenResult, - normalize_gen_config + normalize_gen_config, + create_gen_context } from './gen.ts'; -import { print_path, to_root_path } from './paths.ts'; +import { print_path } from './paths.ts'; import type { format_file as base_format_file } from './format_file.ts'; import type { GroConfig } from './gro_config.ts'; -import { load_default_svelte_config } from './svelte_config.ts'; import type { Filer } from './filer.ts'; import type { InvokeTask } from './task.ts'; @@ -43,19 +42,7 @@ export const run_gen = async ( const timing_for_module = timings.start(id); const gen_config = normalize_gen_config(module_meta.mod.gen); - const gen_ctx: GenContext = { - config, - get svelte_config() { - return load_default_svelte_config(); - }, - filer, - log, - timings, - invoke_task, - origin_id: id, - origin_path: to_root_path(id), - changed_file_id: undefined - }; + const gen_ctx = create_gen_context({ config, filer, log, timings, invoke_task }, id); let raw_gen_result: RawGenResult; try { raw_gen_result = await gen_config.generate(gen_ctx); diff --git a/src/lib/sveltekit_helpers.ts b/src/lib/sveltekit_helpers.ts index 8da27a2854..df1a882411 100644 --- a/src/lib/sveltekit_helpers.ts +++ b/src/lib/sveltekit_helpers.ts @@ -23,18 +23,16 @@ import { TaskError } from './task.ts'; * because reading the config costs a full Vite config resolution. */ export const has_sveltekit_app = ( - package_json: PackageJson, - dep_name = SVELTEKIT_DEP_NAME + package_json: PackageJson ): Result => { - if (!package_json_has_dependency(dep_name, package_json)) { - return { ok: false, message: `no dependency found in package.json for ${dep_name}` }; + if (!package_json_has_dependency(SVELTEKIT_DEP_NAME, package_json)) { + return { ok: false, message: `no dependency found in package.json for ${SVELTEKIT_DEP_NAME}` }; } return { ok: true }; }; export const has_sveltekit_library = async ( - package_json: PackageJson, - dep_name = SVELTE_PACKAGE_DEP_NAME + package_json: PackageJson ): Promise> => { const has_sveltekit_app_result = has_sveltekit_app(package_json); if (!has_sveltekit_app_result.ok) { @@ -44,10 +42,10 @@ export const has_sveltekit_library = async ( // Checked before the lib directory because it's the cheaper of the two // and it's what distinguishes a library from an app, // so apps bail out without reading the Svelte config. - if (!package_json_has_dependency(dep_name, package_json)) { + if (!package_json_has_dependency(SVELTE_PACKAGE_DEP_NAME, package_json)) { return { ok: false, - message: `no dependency found in package.json for ${dep_name}` + message: `no dependency found in package.json for ${SVELTE_PACKAGE_DEP_NAME}` }; } From ee99d50e00354884a617c2a9e98054c6fc90859b Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 7 Aug 2026 21:17:43 -0400 Subject: [PATCH 08/10] refactoring and tests --- .changeset/lazy-svelte-config.md | 61 +++++++++++++--- CLAUDE.md | 8 +- src/docs/gro_plugin_sveltekit_app.md | 3 +- src/docs/gro_plugin_sveltekit_library.md | 3 + src/lib/build.task.ts | 4 +- src/lib/dev.task.ts | 4 +- src/lib/gro_plugin_server.ts | 65 ++++++++++++----- src/lib/package_json.ts | 31 ++++++-- src/lib/plugin.ts | 16 ++++ src/lib/svelte_config.ts | 49 +++++++++---- src/lib/sveltekit_helpers.ts | 7 +- src/test/build_task.args.test.ts | 3 +- src/test/build_task.cache_persistence.test.ts | 3 +- .../build_task.cache_race_conditions.test.ts | 3 +- src/test/build_task.cache_validation.test.ts | 3 +- src/test/build_task.errors.test.ts | 3 +- src/test/build_task.optimization.test.ts | 3 +- src/test/build_task.plugins.test.ts | 3 +- src/test/build_task.workspace.test.ts | 3 +- src/test/package_json.test.ts | 27 ++++++- src/test/plugin.test.ts | 38 +++++++++- src/test/svelte_config.test.ts | 54 ++++++++++++-- src/test/sveltekit_helpers.test.ts | 73 +++++++++++++++++++ 23 files changed, 390 insertions(+), 77 deletions(-) create mode 100644 src/test/sveltekit_helpers.test.ts diff --git a/.changeset/lazy-svelte-config.md b/.changeset/lazy-svelte-config.md index 833f702e91..8d58edff8d 100644 --- a/.changeset/lazy-svelte-config.md +++ b/.changeset/lazy-svelte-config.md @@ -27,10 +27,17 @@ worker thread, where `process.chdir` is unavailable, but Vite's config resolutio doesn't need it - only `@sveltejs/load-config` does, which is why Gro calls `vite.resolveConfig` directly and drops that dependency. -The loader stays eager, though: resolving the config runs the `resolve` hook for each of -its own imports, so a hook that awaited the load it is part of would deadlock. That side -is now more expensive than reading `svelte.config.js` was, and it's on the critical path -of every invocation, so the win here is on the main thread rather than overall. +The loader stays eager, though: the hooks thread's own imports go back through its own +`resolve` hook, so a hook that awaited the load it is part of re-enters itself until the +stack blows. Loading during module evaluation, before the hooks go live, keeps that +graph out of them. + +That trades a main-thread cost for a slower floor on every invocation. In this repo a +full `vite.resolveConfig` measures ~750-1100ms against ~275ms for importing +`svelte.config.js`, and the loader is registered for every `gro` command, so a short task +like `gro format` now pays it with nothing to show for it. Tasks that never touch the +config no longer pay on the main thread, which is the win; the loader side is a +regression, and caching the resolved values under `.gro/` is the way out of it. Resolving a Vite config is more than a read - it runs every plugin's `config` and `configResolved` hooks, so it inherits their side effects. SvelteKit's rewrite of @@ -38,17 +45,30 @@ Resolving a Vite config is more than a read - it runs every plugin's `config` an is another. Gro restores `NODE_ENV` around the call, so the `development` Vite would leave behind no longer reaches the `vite build` that `gro build` spawns. +The `compilerOptions` read back for a plain Svelte project are `vite-plugin-svelte`'s +*resolved* options rather than the user's, so they arrive with that plugin's own `css`, +`dev`, and `hmr` mixed in, resolved for the `build`/`production` pass. It deletes +`generate`, so Gro's server default survives, and the loader always sets `dev` itself. +SvelteKit projects are unaffected - their `api.options` is the Svelte config as authored. + Vite is now an optional peer dependency. A project with no Vite config gets the conventional defaults rather than an error, so non-Vite projects keep working. Note that a `svelte.config.js` is only read through Vite, so a project with one but no Vite config -gets those defaults too, not its own preprocessors, aliases, or compiler options - that -combination now warns, since it looks configured while being silently ignored. +gets those defaults too, not its own preprocessors, aliases, or compiler options. + +Both ways of ending up there now warn, since the Svelte config looks like it's +configuring the project while being ignored: no Vite config to read it through, and a +Vite config that configures no Svelte plugin. The second is the likelier one - a +`vite.config.ts` that only sets up Vitest, alongside a `svelte.config.js` doing the real +configuring, lands exactly there. Unlike SvelteKit, Gro doesn't fall back to importing +the Svelte config itself. A project with no Svelte config stays quiet: it's configuring +nothing to ignore. A project that does have a Vite config but no Vite installed throws rather than falling back, because falling back would mean compiling against the wrong config in silence. -The warning goes through the exported `svelte_config_log`, since this runs where there's -no logger to pass in - set `svelte_config_log.level = 'off'` to silence it. +The warnings go through the exported `svelte_config_log`, since this runs where there's +no logger to pass in - set `svelte_config_log.level = 'off'` to silence them. Also fixes `parse_svelte_config` mutating the `compilerOptions` of the config passed to it, and normalizes `kit.env.dir` to a project-relative path - it's serialized into the @@ -71,18 +91,32 @@ Breaking changes: one, and only after checking the `@sveltejs/package` dependency, so a project that isn't a library never reads the Svelte config. Both it and `has_sveltekit_app` also drop their unused `dep_name` parameter and use the dependency-name constants directly +- both detect through `dependencies` and `devDependencies` only. A peer dep declares what + a package works alongside, not what it is, so counting it would run `vite build` over a + library that peers on SvelteKit and has no app. `package_json_has_dependency` takes a + third `include_peer` param for this, defaulting to `true` as before - `GenContext` is built by the new `create_gen_context`, shared by generation and dependency resolution so they can't drift - `ROUTES_DIRNAME` is removed - `SVELTE_CONFIG_FILENAME` and `VITE_CONFIG_FILENAME` are replaced by `SVELTE_CONFIG_FILENAMES` and `VITE_CONFIG_FILENAMES`, every filename SvelteKit and Vite accept for their configs. `gro format` now formats whichever of them a project - has, not only `svelte.config.js` and `vite.config.ts`. `VITE_CONFIG_BASENAME` and - `VITE_CONFIG_EXTENSIONS` are removed, subsumed by the filename list + has, not only `svelte.config.js` and `vite.config.ts` - `paths.lib`, `LIB_DIRNAME`, `LIB_PATH`, and `LIB_DIR` are the conventional `src/lib` rather than derived from `kit.files.lib`, so that `paths` stays free of the config. - Read `lib_path` off `ParsedSvelteConfig` to honor a customized `files.lib`, and point - `task_root_dirs` at it in `gro.config.ts` if tasks live there + Everything that has to honor a customized `files.lib` reads `lib_path` off + `ParsedSvelteConfig` instead - the `package.json` exports automation, the server + plugin's entry point and `outbase`, and `has_sveltekit_library`. Point `task_root_dirs` + at it in `gro.config.ts` if tasks live there +- `package_json_sync`'s `exports_dir` param no longer defaults to `paths.lib`; when + omitted it's the Svelte config's `lib_path`, which `has_sveltekit_library` has already + resolved by then. Searching the conventional `src/lib` for a project that moved its lib + directory found nothing and replaced the whole `exports` map with a single entry +- `gro_plugin_server`'s `SERVER_SOURCE_ID` is replaced by `SERVER_SOURCE_PATH` (relative + to the lib directory) and `to_server_source_id(lib_path)`. Its `entry_points` and + `outpaths` defaults now resolve in `setup` rather than at plugin creation, since + reading `lib_path` is async; the outpaths default is exported as `to_default_outpaths`. + `has_server()` takes an optional path and defaults to the configured lib directory - `MODULE_PATH_LIB_PREFIX` is always `$lib/`, matching SvelteKit, which names the alias `$lib` no matter where `files.lib` points - it was previously derived from `files.lib` - the default config detects plugins inside `plugins()` instead of when the config @@ -93,3 +127,6 @@ Breaking changes: `SVELTE_COMPILE_OPTIONS_DEFAULT` rather than the project's `compilerOptions`, because the default can't read the config synchronously - pass `svelte_compile_options` from a `ParsedSvelteConfig` to honor them +- `dev` and `build` widen their `TaskContext` with the new `to_plugin_context` instead of + spreading it, because spreading calls the lazy `svelte_config` getter and resolves the + config for plugin sets that never read it diff --git a/CLAUDE.md b/CLAUDE.md index e45a0bf118..52d0b19c84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -290,9 +290,11 @@ config object. If absent, uses default config from Default config behavior: Auto-detects project type from `package.json` and the filesystem, deferred until plugins are created: -- `@sveltejs/kit` in package.json → enables `gro_plugin_sveltekit_app` -- `@sveltejs/package` in package.json + `src/lib/` → enables `gro_plugin_sveltekit_library` -- `src/lib/server/server.ts` → enables `gro_plugin_server` +- `@sveltejs/kit` in package.json deps or dev deps (not peer deps) → enables + `gro_plugin_sveltekit_app` +- `@sveltejs/package` in package.json + the lib directory → enables + `gro_plugin_sveltekit_library` +- `server/server.ts` in the lib directory → enables `gro_plugin_server` - Always enables `gro_plugin_gen` Config interface: diff --git a/src/docs/gro_plugin_sveltekit_app.md b/src/docs/gro_plugin_sveltekit_app.md index 3ba134f54e..23a8c06c5c 100644 --- a/src/docs/gro_plugin_sveltekit_app.md +++ b/src/docs/gro_plugin_sveltekit_app.md @@ -10,7 +10,8 @@ import {gro_plugin_sveltekit_app} from '@fuzdev/gro/gro_plugin_sveltekit_app.ts' const config: CreateGroConfig = async (cfg) => { cfg.plugins = async () => [ - // included in the default config when `@sveltejs/kit` is a dependency + // included in the default config when `@sveltejs/kit` is a dep or dev dep + // (a peer dep doesn't count - it declares what a package works with, not what it is) gro_plugin_sveltekit_app(), ]; return cfg; diff --git a/src/docs/gro_plugin_sveltekit_library.md b/src/docs/gro_plugin_sveltekit_library.md index b9fdc7ae3f..c82c65af73 100644 --- a/src/docs/gro_plugin_sveltekit_library.md +++ b/src/docs/gro_plugin_sveltekit_library.md @@ -16,6 +16,9 @@ when all three conditions are met: They're checked in that order, so a project that isn't a library never reads the Svelte config, which is the only one of the three that costs anything. +Only `dependencies` and `devDependencies` count for the first two - a peer dep +declares what a package works alongside, not what the package itself is. + Install to enable: ```bash diff --git a/src/lib/build.task.ts b/src/lib/build.task.ts index 9b50cfd60f..ba4531bbca 100644 --- a/src/lib/build.task.ts +++ b/src/lib/build.task.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; import { fs_exists } from '@fuzdev/fuz_util/fs.ts'; import { TaskError, type Task } from './task.ts'; -import { Plugins } from './plugin.ts'; +import { Plugins, to_plugin_context } from './plugin.ts'; import { clean_fs } from './clean_fs.ts'; import { is_build_cache_valid, @@ -111,7 +111,7 @@ export const task: Task = { await clean_fs({ build_dist: true }); } - const plugins = await Plugins.create({ ...ctx, dev: false, watch: false }); + const plugins = await Plugins.create(to_plugin_context(ctx, false, false)); await plugins.setup(); await plugins.adapt(); await plugins.teardown(); diff --git a/src/lib/dev.task.ts b/src/lib/dev.task.ts index 88ab2df368..a59109aeae 100644 --- a/src/lib/dev.task.ts +++ b/src/lib/dev.task.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import type { Task } from './task.ts'; -import { Plugins, type PluginContext } from './plugin.ts'; +import { Plugins, to_plugin_context, type PluginContext } from './plugin.ts'; import { clean_fs } from './clean_fs.ts'; /** @nodocs */ @@ -36,7 +36,7 @@ export const task: Task = { await invoke_task('sync', { install, gen: !watch }); } - const plugins = await Plugins.create({ ...ctx, dev: true, watch }); + const plugins = await Plugins.create(to_plugin_context(ctx, true, watch)); await plugins.setup(); if (!watch) { await plugins.teardown(); diff --git a/src/lib/gro_plugin_server.ts b/src/lib/gro_plugin_server.ts index ff5a300b39..dbf677e812 100644 --- a/src/lib/gro_plugin_server.ts +++ b/src/lib/gro_plugin_server.ts @@ -10,7 +10,7 @@ import { throttle } from '@fuzdev/fuz_util/throttle.ts'; import type { PathId } from '@fuzdev/fuz_util/path.ts'; import type { Plugin } from './plugin.ts'; -import { base_path_to_path_id, LIB_DIRNAME, paths } from './paths.ts'; +import { paths } from './paths.ts'; import { GRO_DEV_DIRNAME, SERVER_DIST_PATH } from './constants.ts'; import { parse_svelte_config, load_default_svelte_config } from './svelte_config.ts'; import { esbuild_plugin_sveltekit_shim_app } from './esbuild_plugin_sveltekit_shim_app.ts'; @@ -25,13 +25,27 @@ import { esbuild_plugin_svelte } from './esbuild_plugin_svelte.ts'; // TODO sourcemap as a hoisted option? disable for production by default - or like `outpaths`, passed a `dev` param -export const SERVER_SOURCE_ID = base_path_to_path_id(LIB_DIRNAME + '/server/server.ts'); +/** + * The server entry point, relative to the project's lib directory. + */ +export const SERVER_SOURCE_PATH = 'server/server.ts'; + +/** + * The server entry point of a project whose lib directory is `lib_path`. + * Taken from the Svelte config's `files.lib` rather than the conventional `src/lib`, + * so a project that moves its lib directory still has its server found and built. + */ +export const to_server_source_id = (lib_path: string): PathId => + join(paths.root, lib_path, SERVER_SOURCE_PATH); -export const has_server = async ( - path = SERVER_SOURCE_ID -): Promise> => { - if (!(await fs_exists(path))) { - return { ok: false, message: `no server file found at ${path}` }; +/** + * @param path - the server entry point to look for; + * defaults to `to_server_source_id` of the Svelte config's `lib_path` + */ +export const has_server = async (path?: string): Promise> => { + const final_path = path ?? to_server_source_id((await load_default_svelte_config()).lib_path); + if (!(await fs_exists(final_path))) { + return { ok: false, message: `no server file found at ${final_path}` }; } return { ok: true }; }; @@ -39,6 +53,7 @@ export const has_server = async ( export interface GroPluginServerOptions { /** * same as esbuild's `entryPoints` + * @default ```[`to_server_source_id` of the Svelte config's `lib_path`]```` */ entry_points?: Array; /** @@ -49,6 +64,7 @@ export interface GroPluginServerOptions { * Returns the `Outpaths` given a `dev` param. * Decoupling this from plugin creation allows it to be created generically, * so the build and dev tasks can be the source of truth for `dev`. + * @default `to_default_outpaths` */ outpaths?: CreateOutpaths; /** @@ -98,7 +114,7 @@ export interface Outpaths { */ outdir: string; /** - * @default 'src/lib' + * @default ```the Svelte config's `lib_path`, so `src/lib` unless it's customized```` */ outbase: string; /** @@ -109,14 +125,23 @@ export interface Outpaths { export type CreateOutpaths = (dev: boolean) => Outpaths; -export const gro_plugin_server = ({ - entry_points = [SERVER_SOURCE_ID], - dir = process.cwd(), - outpaths = (dev) => ({ +/** + * The `Outpaths` used when the plugin is given none. + * Takes `lib_dir` as a param rather than reading `paths.lib` so a customized `kit.files.lib` + * is honored - resolving it is async, so it can't be a plugin-creation default. + */ +export const to_default_outpaths = + (dir: string, lib_dir: string): CreateOutpaths => + (dev) => ({ outdir: join(dir, dev ? GRO_DEV_DIRNAME : SERVER_DIST_PATH), - outbase: paths.lib, + outbase: lib_dir, outname: 'server/server.js' - }), + }); + +export const gro_plugin_server = ({ + entry_points, + dir = process.cwd(), + outpaths, env_files, ambient_env, svelte_config, @@ -149,10 +174,16 @@ export const gro_plugin_server = ({ public_prefix, svelte_compile_options, svelte_compile_module_options, - svelte_preprocessors + svelte_preprocessors, + lib_path } = parsed_svelte_config; - const { outbase, outdir, outname } = outpaths(dev); + // The entry point and `outbase` defaults land here rather than in the destructuring above + // because they come from the Svelte config, which can only be read asynchronously. + const lib_dir = join(paths.root, lib_path); + const final_entry_points = entry_points ?? [join(lib_dir, SERVER_SOURCE_PATH)]; + + const { outbase, outdir, outname } = (outpaths ?? to_default_outpaths(dir, lib_dir))(dev); const server_outpath = join(outdir, outname); @@ -170,7 +201,7 @@ export const gro_plugin_server = ({ }); build_ctx = await esbuild.context({ - entryPoints: entry_points.map((path) => resolve(dir, path)), + entryPoints: final_entry_points.map((path) => resolve(dir, path)), plugins: [ esbuild_plugin_sveltekit_shim_app({ dev, base_url, assets_url }), esbuild_plugin_sveltekit_shim_env({ diff --git a/src/lib/package_json.ts b/src/lib/package_json.ts index e175d444a3..e39f1f6543 100644 --- a/src/lib/package_json.ts +++ b/src/lib/package_json.ts @@ -18,6 +18,7 @@ import { CSS_MATCHER } from './constants.ts'; import { has_sveltekit_library } from './sveltekit_helpers.ts'; +import { load_default_svelte_config } from './svelte_config.ts'; import { GITHUB_REPO_MATCHER } from './github.ts'; export type PackageJsonMapper = ( @@ -51,19 +52,28 @@ export const package_json_load = async ( return package_json; }; +/** + * @param exports_dir - the directory whose files become the `exports`; + * defaults to the Svelte config's `lib_path`, which `has_sveltekit_library` + * has already resolved by the time it's read + */ export const package_json_sync = async ( map_package_json: PackageJsonMapper, log: Logger, write = true, dir = paths.root, - exports_dir = paths.lib + exports_dir?: string ): Promise<{ package_json: PackageJson | null; changed: boolean }> => { - const exported_files = await fs_search(exports_dir); - const exported_paths = exported_files.map((f) => f.path); const updated = await package_json_update( async (package_json) => { if ((await has_sveltekit_library(package_json)).ok) { - package_json.exports = package_json_to_exports(exported_paths); + // Reading the lib directory off the Svelte config rather than `paths.lib` is what honors + // a customized `kit.files.lib`. Searching the conventional `src/lib` instead would find + // nothing and quietly replace the library's whole `exports` map with a single entry. + const final_exports_dir = + exports_dir ?? join(paths.root, (await load_default_svelte_config()).lib_path); + const exported_files = await fs_search(final_exports_dir); + package_json.exports = package_json_to_exports(exported_files.map((f) => f.path)); } const mapped = await map_package_json(package_json); return mapped ? parse_package_json(PackageJson, mapped) : mapped; @@ -225,10 +235,19 @@ const parse_or_throw_formatted_error = ( return parsed.data; }; -export const package_json_has_dependency = (dep_name: string, package_json: PackageJson): boolean => +/** + * @param include_peer - whether a `peerDependencies` entry counts. Pass `false` when + * detecting what a project *is*, since a peer dep declares what it works alongside - + * a plugin package peered on `@sveltejs/kit` isn't itself a SvelteKit app. + */ +export const package_json_has_dependency = ( + dep_name: string, + package_json: PackageJson, + include_peer = true +): boolean => !!package_json.devDependencies?.[dep_name] || !!package_json.dependencies?.[dep_name] || - !!package_json.peerDependencies?.[dep_name]; + (include_peer && !!package_json.peerDependencies?.[dep_name]); export interface PackageJsonDep { name: string; diff --git a/src/lib/plugin.ts b/src/lib/plugin.ts index 0f004b1269..6b15f781ad 100644 --- a/src/lib/plugin.ts +++ b/src/lib/plugin.ts @@ -20,6 +20,22 @@ export interface PluginContext extends TaskContext { watch: boolean; } +/** + * Widens a `TaskContext` to a `PluginContext` by adding `dev` and `watch`. + * Copies property descriptors rather than spreading, because `svelte_config` is a lazy getter - + * spreading would call it, resolving the Svelte config even for a plugin set that never reads it, + * and pinning whichever promise it returned instead of the memoized one. + */ +export const to_plugin_context = ( + ctx: TaskContext, + dev: boolean, + watch: boolean +): PluginContext => + Object.defineProperties( + { dev, watch }, + Object.getOwnPropertyDescriptors(ctx) + ) as PluginContext; + /** See `Plugins.create` for a usage example. */ export class Plugins { readonly ctx: TPluginContext; diff --git a/src/lib/svelte_config.ts b/src/lib/svelte_config.ts index 2e64ec9b48..c9e06370f0 100644 --- a/src/lib/svelte_config.ts +++ b/src/lib/svelte_config.ts @@ -24,10 +24,19 @@ with the same `resolveConfig` call SvelteKit's own `load_config` makes. So this sees exactly what SvelteKit sees - inline `sveltekit()` options when a project passes them, and otherwise whatever SvelteKit loaded from `svelte.config.js` on its own. -A project with no Vite config is read as having no Svelte config, since there's nothing -to read one through, so projects that don't use Vite keep working on the defaults. -Having a Vite config that can't be resolved is an error rather than a fallback, -because falling back would mean compiling against the wrong config in silence. +A project with no Vite config, or one whose Vite config configures no Svelte plugin, +is read as having no Svelte config, so projects that don't use Vite keep working on the +defaults. Unlike SvelteKit, which falls back to importing `svelte.config.js` itself, +a project in either of those states that has a Svelte config gets a warning instead - +it looks configured while being ignored. Having a Vite config that can't be resolved +is an error rather than a fallback, because falling back would mean compiling against +the wrong config in silence. + +The `api.options` read back off `vite-plugin-svelte` is that plugin's *resolved* options, +not the user's, so a plain Svelte project's `compilerOptions` arrive with the plugin's own +`css`, `dev`, and `hmr` mixed in, resolved for the `build`/`production` pass below. +It deletes `generate` (along with `format` and `filename`), so Gro's server default survives, +and consumers that care about `dev` set it themselves - the loader always compiles for dev. Always the project in the cwd, never an arbitrary directory. SvelteKit resolves its `files` and `env.dir` against its own cwd rather than the Vite `root` it's handed, @@ -59,6 +68,22 @@ export const svelte_config_log = new Logger('svelte_config'); const find_config_file = (dir: string, filenames: Array): string | undefined => filenames.find((filename) => existsSync(join(dir, filename))); +/** + * Warns when `dir` has a Svelte config that Gro found no way to read, because that config + * looks like it's configuring the project while being ignored. A project with no Svelte + * config isn't configuring Svelte at all, so it stays quiet and takes the defaults. + * @param reason - why the config couldn't be read, as a clause following "but" + */ +const warn_svelte_config_ignored = (dir: string, reason: string): void => { + const svelte_config_filename = find_config_file(dir, SVELTE_CONFIG_FILENAMES); + if (!svelte_config_filename) return; + svelte_config_log.warn( + `Found ${svelte_config_filename} in ${dir} but ${reason},` + + ' so its preprocessors, aliases, and compiler options are being ignored.' + + ' Gro reads the Svelte config through Vite, the same as SvelteKit does.' + ); +}; + /** * Loads the Svelte config of the project in the cwd by resolving its Vite config. * @returns `null` if the project has no Vite config, or one that configures no Svelte plugin @@ -67,16 +92,7 @@ const find_config_file = (dir: string, filenames: Array): string | undef export const load_svelte_config = async (): Promise => { const dir = process.cwd(); if (!find_config_file(dir, VITE_CONFIG_FILENAMES)) { - // A project with neither config simply isn't a Svelte project, but one with a Svelte config - // and no Vite config looks configured while being silently ignored, so it gets a warning. - const svelte_config_filename = find_config_file(dir, SVELTE_CONFIG_FILENAMES); - if (svelte_config_filename) { - svelte_config_log.warn( - `Found ${svelte_config_filename} but no Vite config in ${dir},` + - ' so its preprocessors, aliases, and compiler options are being ignored.' + - ' Gro reads the Svelte config through Vite, the same as SvelteKit does.' - ); - } + warn_svelte_config_ignored(dir, 'no Vite config to read it through'); return null; } @@ -119,6 +135,11 @@ export const load_svelte_config = async (): Promise => { const options = resolved.plugins.find((p) => p.name === name)?.api?.options; if (options) return options as SvelteConfig; } + + // A Vite config that configures no Svelte plugin is the same silent-ignore as having no Vite + // config at all, and likelier to be unintended - a `vite.config.ts` that only sets up Vitest + // alongside a `svelte.config.js` that does the real configuring reaches exactly here. + warn_svelte_config_ignored(dir, 'its Vite config configures no Svelte plugin'); return null; }; diff --git a/src/lib/sveltekit_helpers.ts b/src/lib/sveltekit_helpers.ts index df1a882411..e212c6297e 100644 --- a/src/lib/sveltekit_helpers.ts +++ b/src/lib/sveltekit_helpers.ts @@ -21,11 +21,13 @@ import { TaskError } from './task.ts'; /** * Detected from `package.json` rather than the Svelte config, * because reading the config costs a full Vite config resolution. + * Peer deps don't count - a package peered on SvelteKit is built to work with one, + * not to be one, and counting them would run `vite build` over a library that has no app. */ export const has_sveltekit_app = ( package_json: PackageJson ): Result => { - if (!package_json_has_dependency(SVELTEKIT_DEP_NAME, package_json)) { + if (!package_json_has_dependency(SVELTEKIT_DEP_NAME, package_json, false)) { return { ok: false, message: `no dependency found in package.json for ${SVELTEKIT_DEP_NAME}` }; } return { ok: true }; @@ -42,7 +44,8 @@ export const has_sveltekit_library = async ( // Checked before the lib directory because it's the cheaper of the two // and it's what distinguishes a library from an app, // so apps bail out without reading the Svelte config. - if (!package_json_has_dependency(SVELTE_PACKAGE_DEP_NAME, package_json)) { + // Peer deps don't count here either, for the same reason as `has_sveltekit_app`. + if (!package_json_has_dependency(SVELTE_PACKAGE_DEP_NAME, package_json, false)) { return { ok: false, message: `no dependency found in package.json for ${SVELTE_PACKAGE_DEP_NAME}` diff --git a/src/test/build_task.args.test.ts b/src/test/build_task.args.test.ts index 97aa3a40bd..4df877277d 100644 --- a/src/test/build_task.args.test.ts +++ b/src/test/build_task.args.test.ts @@ -24,7 +24,8 @@ vi.mock('$lib/clean_fs.ts', () => ({ clean_fs: vi.fn() })); -vi.mock('$lib/plugin.ts', () => ({ +vi.mock('$lib/plugin.ts', async (import_original) => ({ + ...(await import_original()), Plugins: { create: vi.fn() } diff --git a/src/test/build_task.cache_persistence.test.ts b/src/test/build_task.cache_persistence.test.ts index 5135e695a7..1548798673 100644 --- a/src/test/build_task.cache_persistence.test.ts +++ b/src/test/build_task.cache_persistence.test.ts @@ -26,7 +26,8 @@ vi.mock('$lib/clean_fs.ts', () => ({ clean_fs: vi.fn() })); -vi.mock('$lib/plugin.ts', () => ({ +vi.mock('$lib/plugin.ts', async (import_original) => ({ + ...(await import_original()), Plugins: { create: vi.fn() } diff --git a/src/test/build_task.cache_race_conditions.test.ts b/src/test/build_task.cache_race_conditions.test.ts index d69c5702cd..04b340e56e 100644 --- a/src/test/build_task.cache_race_conditions.test.ts +++ b/src/test/build_task.cache_race_conditions.test.ts @@ -24,7 +24,8 @@ vi.mock('$lib/clean_fs.ts', () => ({ clean_fs: vi.fn() })); -vi.mock('$lib/plugin.ts', () => ({ +vi.mock('$lib/plugin.ts', async (import_original) => ({ + ...(await import_original()), Plugins: { create: vi.fn() } diff --git a/src/test/build_task.cache_validation.test.ts b/src/test/build_task.cache_validation.test.ts index 03326848b1..29d0e6d2ef 100644 --- a/src/test/build_task.cache_validation.test.ts +++ b/src/test/build_task.cache_validation.test.ts @@ -24,7 +24,8 @@ vi.mock('$lib/clean_fs.ts', () => ({ clean_fs: vi.fn() })); -vi.mock('$lib/plugin.ts', () => ({ +vi.mock('$lib/plugin.ts', async (import_original) => ({ + ...(await import_original()), Plugins: { create: vi.fn() } diff --git a/src/test/build_task.errors.test.ts b/src/test/build_task.errors.test.ts index d192b78598..2c16d14e2b 100644 --- a/src/test/build_task.errors.test.ts +++ b/src/test/build_task.errors.test.ts @@ -26,7 +26,8 @@ vi.mock('$lib/clean_fs.ts', () => ({ clean_fs: vi.fn() })); -vi.mock('$lib/plugin.ts', () => ({ +vi.mock('$lib/plugin.ts', async (import_original) => ({ + ...(await import_original()), Plugins: { create: vi.fn() } diff --git a/src/test/build_task.optimization.test.ts b/src/test/build_task.optimization.test.ts index bcf44f37fa..83469472e2 100644 --- a/src/test/build_task.optimization.test.ts +++ b/src/test/build_task.optimization.test.ts @@ -24,7 +24,8 @@ vi.mock('$lib/clean_fs.ts', () => ({ clean_fs: vi.fn() })); -vi.mock('$lib/plugin.ts', () => ({ +vi.mock('$lib/plugin.ts', async (import_original) => ({ + ...(await import_original()), Plugins: { create: vi.fn() } diff --git a/src/test/build_task.plugins.test.ts b/src/test/build_task.plugins.test.ts index fdb2537123..ed3da7775a 100644 --- a/src/test/build_task.plugins.test.ts +++ b/src/test/build_task.plugins.test.ts @@ -24,7 +24,8 @@ vi.mock('$lib/clean_fs.ts', () => ({ clean_fs: vi.fn() })); -vi.mock('$lib/plugin.ts', () => ({ +vi.mock('$lib/plugin.ts', async (import_original) => ({ + ...(await import_original()), Plugins: { create: vi.fn() } diff --git a/src/test/build_task.workspace.test.ts b/src/test/build_task.workspace.test.ts index d8ded451fc..5f86169d2c 100644 --- a/src/test/build_task.workspace.test.ts +++ b/src/test/build_task.workspace.test.ts @@ -27,7 +27,8 @@ vi.mock('$lib/clean_fs.ts', () => ({ clean_fs: vi.fn() })); -vi.mock('$lib/plugin.ts', () => ({ +vi.mock('$lib/plugin.ts', async (import_original) => ({ + ...(await import_original()), Plugins: { create: vi.fn() } diff --git a/src/test/package_json.test.ts b/src/test/package_json.test.ts index f3c3c153a7..137934e4f5 100644 --- a/src/test/package_json.test.ts +++ b/src/test/package_json.test.ts @@ -1,7 +1,8 @@ -import { test, expect } from 'vitest'; +import { describe, test, expect } from 'vitest'; import { PackageJson, PackageJsonExports } from '@fuzdev/fuz_util/package_json.ts'; import { + package_json_has_dependency, package_json_load, package_json_parse_repo_url, package_json_serialize, @@ -214,3 +215,27 @@ test('rejects invalid exports', () => { expect(parsed.success).toBe(false); } }); + +describe('package_json_has_dependency', () => { + const package_json: PackageJson = { + name: 'a', + version: '0', + dependencies: { dep: '1' }, + devDependencies: { dev_dep: '1' }, + peerDependencies: { peer_dep: '1' } + }; + + test('finds deps and dev deps', () => { + expect(package_json_has_dependency('dep', package_json)).toBe(true); + expect(package_json_has_dependency('dev_dep', package_json)).toBe(true); + expect(package_json_has_dependency('missing', package_json)).toBe(false); + }); + + // Peer deps count by default because most callers ask "is this available at runtime", + // but detection asks "is this what the project is", and a peer answers neither. + test('counts peer deps only when `include_peer`', () => { + expect(package_json_has_dependency('peer_dep', package_json)).toBe(true); + expect(package_json_has_dependency('peer_dep', package_json, false)).toBe(false); + expect(package_json_has_dependency('dev_dep', package_json, false)).toBe(true); + }); +}); diff --git a/src/test/plugin.test.ts b/src/test/plugin.test.ts index 97520ac113..3f3afcc9c0 100644 --- a/src/test/plugin.test.ts +++ b/src/test/plugin.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'vitest'; -import { plugin_replace } from '$lib/plugin.ts'; +import { plugin_replace, to_plugin_context } from '$lib/plugin.ts'; +import { create_mock_task_context } from './test_helpers.ts'; describe('plugin_replace', () => { test('plugin_replace', () => { @@ -54,3 +55,38 @@ describe('plugin_replace', () => { expect(err).toBeTruthy(); }); }); + +describe('to_plugin_context', () => { + test('adds `dev` and `watch` and carries the task context through', () => { + const ctx = create_mock_task_context({ a: 1 }); + const plugin_ctx = to_plugin_context(ctx, true, false); + expect(plugin_ctx.dev).toBe(true); + expect(plugin_ctx.watch).toBe(false); + expect(plugin_ctx.args).toBe(ctx.args); + expect(plugin_ctx.config).toBe(ctx.config); + expect(plugin_ctx.filer).toBe(ctx.filer); + expect(plugin_ctx.log).toBe(ctx.log); + expect(plugin_ctx.timings).toBe(ctx.timings); + expect(plugin_ctx.invoke_task).toBe(ctx.invoke_task); + }); + + // `svelte_config` is a lazy getter on the real task context, and spreading would call it, + // resolving the Svelte config for plugin sets that never read it. + test('does not read a lazy `svelte_config`', async () => { + let reads = 0; + const svelte_config = Promise.resolve('config'); + const ctx = Object.defineProperty(create_mock_task_context(), 'svelte_config', { + enumerable: true, + get: () => { + reads++; + return svelte_config; + } + }); + + const plugin_ctx = to_plugin_context(ctx, false, false); + expect(reads).toBe(0); + + await expect(plugin_ctx.svelte_config).resolves.toBe('config'); + expect(reads).toBe(1); + }); +}); diff --git a/src/test/svelte_config.test.ts b/src/test/svelte_config.test.ts index 70dd577801..503010f4b0 100644 --- a/src/test/svelte_config.test.ts +++ b/src/test/svelte_config.test.ts @@ -122,6 +122,8 @@ describe('load_default_svelte_config', () => { /** * Runs `fn` in an empty directory. The config is always read from the cwd, * so moving the cwd is the only way to point `load_svelte_config` somewhere else. + * Note that `process.chdir` throws on a worker thread, so these tests need Vitest's + * default `forks` pool - switching to `threads` would break them. */ const in_empty_dir = async (fn: () => Promise): Promise => { const cwd = process.cwd(); @@ -142,24 +144,60 @@ describe('load_svelte_config', () => { await expect(in_empty_dir(load_svelte_config)).resolves.toBe(null); }); - // A project with neither config isn't a Svelte project, so it gets no warning, - // but one with a Svelte config and no Vite config is silently ignored without this. - test('warns when a Svelte config has no Vite config to be read through', async () => { + /** + * Loads the config in an empty dir seeded with `files`, capturing anything warned. + * `Logger` defaults to `'off'` under Vitest, so the level is opted back in here. + */ + const load_with_warnings = async ( + files: Record + ): Promise<{ loaded: SvelteConfig | null; warnings: Array }> => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - // `Logger` defaults to `'off'` under Vitest, so the level is opted back in here. svelte_config_log.level = 'warn'; try { const loaded = await in_empty_dir(async () => { - writeFileSync('svelte.config.js', 'export default {};'); + for (const [filename, content] of Object.entries(files)) writeFileSync(filename, content); return load_svelte_config(); }); - expect(loaded).toBe(null); - expect(warn).toHaveBeenCalledOnce(); - expect(warn.mock.calls.flat().join(' ')).toContain('svelte.config.js'); + return { loaded, warnings: warn.mock.calls.map((c) => c.join(' ')) }; } finally { svelte_config_log.clear_level_override(); warn.mockRestore(); } + }; + + // A project with neither config isn't a Svelte project, so it gets no warning, + // but one with a Svelte config and no Vite config is silently ignored without this. + test('warns when a Svelte config has no Vite config to be read through', async () => { + const { loaded, warnings } = await load_with_warnings({ + 'svelte.config.js': 'export default {};' + }); + expect(loaded).toBe(null); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('svelte.config.js'); + expect(warnings[0]).toContain('no Vite config'); + }); + + // The same silent ignore, one step further in: a `vite.config.js` that sets up something other + // than Svelte - Vitest, most plausibly - alongside a `svelte.config.js` doing the real work. + test('warns when the Vite config configures no Svelte plugin', async () => { + const { loaded, warnings } = await load_with_warnings({ + 'vite.config.js': 'export default {};', + 'svelte.config.js': 'export default {};' + }); + expect(loaded).toBe(null); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('svelte.config.js'); + expect(warnings[0]).toContain('no Svelte plugin'); + }); + + // Only the Svelte config makes the silence worth warning about - + // a project with a Vite config and no Svelte config is configuring nothing to ignore. + test('stays quiet when there is no Svelte config to ignore', async () => { + const { loaded, warnings } = await load_with_warnings({ + 'vite.config.js': 'export default {};' + }); + expect(loaded).toBe(null); + expect(warnings).toHaveLength(0); }); // Vite's `resolveConfig` writes `NODE_ENV` when it's unset, and the `development` it would diff --git a/src/test/sveltekit_helpers.test.ts b/src/test/sveltekit_helpers.test.ts new file mode 100644 index 0000000000..14de1f1569 --- /dev/null +++ b/src/test/sveltekit_helpers.test.ts @@ -0,0 +1,73 @@ +import { assert, describe, test, expect } from 'vitest'; +import type { PackageJson } from '@fuzdev/fuz_util/package_json.ts'; + +import { has_sveltekit_app, has_sveltekit_library } from '$lib/sveltekit_helpers.ts'; +import { SVELTE_PACKAGE_DEP_NAME, SVELTEKIT_DEP_NAME } from '$lib/constants.ts'; + +const to_package_json = ( + deps: Partial> +): PackageJson => ({ name: 'a', version: '0', ...deps }); + +describe('has_sveltekit_app', () => { + test('detects SvelteKit as a dep or dev dep', () => { + expect( + has_sveltekit_app(to_package_json({ dependencies: { [SVELTEKIT_DEP_NAME]: '2' } })).ok + ).toBe(true); + expect( + has_sveltekit_app(to_package_json({ devDependencies: { [SVELTEKIT_DEP_NAME]: '2' } })).ok + ).toBe(true); + }); + + test('is not detected with no SvelteKit dependency', () => { + expect(has_sveltekit_app(to_package_json({})).ok).toBe(false); + }); + + // A package peered on SvelteKit is built to work with one, not to be one - + // counting the peer would run `vite build` over a library that has no app. + test('ignores a peer dependency', () => { + expect( + has_sveltekit_app(to_package_json({ peerDependencies: { [SVELTEKIT_DEP_NAME]: '2' } })).ok + ).toBe(false); + }); +}); + +describe('has_sveltekit_library', () => { + test('needs SvelteKit first', async () => { + const result = await has_sveltekit_library( + to_package_json({ devDependencies: { [SVELTE_PACKAGE_DEP_NAME]: '2' } }) + ); + assert(!result.ok); + expect(result.message).toContain(SVELTEKIT_DEP_NAME); + }); + + // The packaging dep is checked before the lib directory so a project that isn't a library + // never reads the Svelte config, which is the only one of the three checks that costs anything. + test('needs the packaging dep, and reports it before reading the config', async () => { + const result = await has_sveltekit_library( + to_package_json({ devDependencies: { [SVELTEKIT_DEP_NAME]: '2' } }) + ); + assert(!result.ok); + expect(result.message).toContain(SVELTE_PACKAGE_DEP_NAME); + }); + + // Resolves this project's own config for the lib directory check, so it covers the whole path. + test('detects this project', async () => { + const result = await has_sveltekit_library( + to_package_json({ + devDependencies: { [SVELTEKIT_DEP_NAME]: '2', [SVELTE_PACKAGE_DEP_NAME]: '2' } + }) + ); + expect(result.ok).toBe(true); + }); + + test('ignores peer dependencies', async () => { + const result = await has_sveltekit_library( + to_package_json({ + devDependencies: { [SVELTEKIT_DEP_NAME]: '2' }, + peerDependencies: { [SVELTE_PACKAGE_DEP_NAME]: '2' } + }) + ); + assert(!result.ok); + expect(result.message).toContain(SVELTE_PACKAGE_DEP_NAME); + }); +}); From e7594fcff2a6b27c3a42fda3f5af6e3510b8615d Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 7 Aug 2026 21:48:55 -0400 Subject: [PATCH 09/10] config caching --- .changeset/lazy-svelte-config.md | 31 ++++-- CLAUDE.md | 9 ++ src/lib/loader.ts | 73 +++++++++---- src/lib/svelte_config.ts | 25 ++++- src/lib/svelte_config_cache.ts | 154 +++++++++++++++++++++++++++ src/test/svelte_config_cache.test.ts | 144 +++++++++++++++++++++++++ 6 files changed, 401 insertions(+), 35 deletions(-) create mode 100644 src/lib/svelte_config_cache.ts create mode 100644 src/test/svelte_config_cache.test.ts diff --git a/.changeset/lazy-svelte-config.md b/.changeset/lazy-svelte-config.md index 8d58edff8d..91185b8684 100644 --- a/.changeset/lazy-svelte-config.md +++ b/.changeset/lazy-svelte-config.md @@ -27,17 +27,26 @@ worker thread, where `process.chdir` is unavailable, but Vite's config resolutio doesn't need it - only `@sveltejs/load-config` does, which is why Gro calls `vite.resolveConfig` directly and drops that dependency. -The loader stays eager, though: the hooks thread's own imports go back through its own -`resolve` hook, so a hook that awaited the load it is part of re-enters itself until the -stack blows. Loading during module evaluation, before the hooks go live, keeps that -graph out of them. - -That trades a main-thread cost for a slower floor on every invocation. In this repo a -full `vite.resolveConfig` measures ~750-1100ms against ~275ms for importing -`svelte.config.js`, and the loader is registered for every `gro` command, so a short task -like `gro format` now pays it with nothing to show for it. Tasks that never touch the -config no longer pay on the main thread, which is the win; the loader side is a -regression, and caching the resolved values under `.gro/` is the way out of it. +The loader can't load it on demand: the hooks thread's own imports go back through its +own `resolve` hook, so a hook that awaited the load it is part of re-enters itself until +the stack blows. But `resolve` is the only hook under that constraint, and the only field +it reads is `alias` - everything else is read inside `load`, which awaits safely. So the +loader caches the alias map at `.gro/svelte_config.json` and defers the rest of the config +to `load`. On a hit it resolves nothing; on a miss it loads at module scope as before, +before the hooks go live, and writes the cache. + +An alias map is plain strings, so nothing is lost to serialization - which is why the new +`svelte_config_cache.ts` caches a slice of the config rather than the config, whose +preprocessors and `compilerOptions.warningFilter` are functions. The key is the mtime and +size of every Vite and Svelte config filename plus `package.json`, absent ones included so +that adding a config invalidates too. The blind spot is a Vite config that imports the +module declaring `kit.alias` or `kit.files.lib`: editing that module doesn't invalidate, +and the stale alias surfaces as an unresolved import rather than a bad compile. `gro clean` +clears it, along with the rest of `.gro`. + +Measured in this repo, `gro --version` goes from ~1.24s to ~0.98s. A resolution costs +~700-900ms on its own, but the hooks thread overlaps with main-thread startup, so an +invocation only sheds the part that didn't overlap. Resolving a Vite config is more than a read - it runs every plugin's `config` and `configResolved` hooks, so it inherits their side effects. SvelteKit's rewrite of diff --git a/CLAUDE.md b/CLAUDE.md index 52d0b19c84..c9e1a889b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,6 +138,15 @@ Capabilities: - JSON imports (any extension with `type: 'json'` import attribute) - Raw text imports (`.css`, `.svg`, or `?raw` suffix) +Svelte config: the `resolve` hook needs the `alias` map before anything can be +imported, and can't await it — the hooks thread's own imports re-enter its own +hooks. So the alias map is cached at `.gro/svelte_config.json` +([`svelte_config_cache.ts`](src/lib/svelte_config_cache.ts)), keyed by the mtime +and size of the Vite and Svelte config filenames plus `package.json`. Everything +else the loader reads from the config is awaited inside `load`, which most +invocations never reach. A cache miss resolves the whole config at module scope +and rewrites. + SvelteKit module shims: Best-effort shims for tasks/tests/servers, not identical to actual SvelteKit modules: diff --git a/src/lib/loader.ts b/src/lib/loader.ts index 822df23d05..a04755a4d6 100644 --- a/src/lib/loader.ts +++ b/src/lib/loader.ts @@ -13,7 +13,17 @@ import { SVELTEKIT_SHIM_APP_PATHS_MATCHER, sveltekit_shim_app_specifiers } from './sveltekit_shim_app.ts'; -import { load_default_svelte_config } from './svelte_config.ts'; +import { + has_vite_config, + load_default_svelte_config, + warn_svelte_config_ignored, + NO_SVELTE_PLUGIN_REASON +} from './svelte_config.ts'; +import { + svelte_config_cache_read, + svelte_config_cache_stamps, + svelte_config_cache_write +} from './svelte_config_cache.ts'; import { paths } from './paths.ts'; import { TS_MATCHER, SVELTE_MATCHER, SVELTE_RUNES_MATCHER } from './constants.ts'; import { resolve_specifier } from './resolve_specifier.ts'; @@ -55,32 +65,51 @@ const dir = paths.root; /* -Unlike the rest of Gro, the loader reads the config eagerly, at module scope. +`resolve` is the one hook that can't await the config. + +Resolving it imports the Vite config, and the hooks thread's own imports go back through +its own hooks - so a `resolve` that awaited the load it is part of re-enters itself until +the stack blows. `load` has no such problem, and it's where all but one of the config's +fields are read. So the alias map is the only thing needed up front, and it's the only +thing cached: see `svelte_config_cache.ts`. -Loading it here rather than on demand is deliberate. Resolving the config runs the -`resolve` hook for each of its own imports, and a hook that awaited the load it is part -of would deadlock. Doing it during module evaluation, before the hooks go live, keeps -that graph out of them. Nothing is lost by being eager: the alias step runs for every -bare specifier, so the first resolve would load the config anyway. +On a hit, nothing is resolved here and the rest of the config is awaited inside `load`, +which most invocations never reach - tasks and genfiles are TypeScript, and `gro test` +hands its files to Vitest rather than to this loader. On a miss the whole config loads +here at module scope, before the hooks go live, which keeps that import graph out of them. + +The one shape this can't survive is a Vite config that imports a module `load` resolves +the config for - a `.svelte`, `.svelte.ts`, `$env`, or `$app/paths` import reached from +the config graph would await a load it is part of. Nothing puts those in a Vite config, +and there's no correct value to hand back if something did. The loader runs on a worker thread, where `process.chdir` is unavailable - Vite's own config resolution doesn't need it, so this is the same load the main thread does. Both read the project in the cwd, which is the only project either one resolves. */ -const { - alias, - base_url, - assets_url, - env_dir, - private_prefix, - public_prefix, - svelte_compile_options, - svelte_compile_module_options, - svelte_preprocessors -} = await load_default_svelte_config(); - -const aliases = Object.entries(alias); +const cache_stamps = svelte_config_cache_stamps(); +const cached_svelte_config = svelte_config_cache_read(cache_stamps); + +let aliases: Array<[string, string]>; +if (cached_svelte_config) { + aliases = Object.entries(cached_svelte_config.alias); + if (!cached_svelte_config.svelte_config_found) { + warn_svelte_config_ignored(process.cwd(), NO_SVELTE_PLUGIN_REASON); + } +} else { + const svelte_config = await load_default_svelte_config(); + aliases = Object.entries(svelte_config.alias); + // Skipped without a Vite config because that path resolves nothing to save, + // and because its warning has to repeat rather than be cached away. + if (has_vite_config()) { + svelte_config_cache_write( + cache_stamps, + svelte_config.alias, + svelte_config.svelte_config !== null + ); + } +} const RAW_MATCHER = /(%3Fraw|\.css|\.svg)$/; // TODO others? configurable? @@ -89,6 +118,7 @@ export const load: LoadHook = async (url, context, nextLoad) => { // console.log(`url`, url); if (SVELTEKIT_SHIM_APP_PATHS_MATCHER.test(url)) { // SvelteKit `$app/paths` shim + const { base_url, assets_url } = await load_default_svelte_config(); return { format: 'module', shortCircuit: true, @@ -109,6 +139,7 @@ export const load: LoadHook = async (url, context, nextLoad) => { if (raw_source == null) throw Error(`Failed to load ${url}`); // TODO should be nice if we could use Node's builtin amaro transform, but I couldn't find a way after digging into the source, AFAICT it's internal and not exposed const source = ts_blank_space(raw_source); // TODO was using oxc-transform and probably should, but this doesn't require sourcemaps, and it's still alpha as of May 2025 + const { svelte_compile_module_options } = await load_default_svelte_config(); const transformed = compileModule(source, { ...svelte_compile_module_options, dev, @@ -123,6 +154,7 @@ export const load: LoadHook = async (url, context, nextLoad) => { const loaded = await nextLoad(url, { ...context, format: 'module' }); const raw_source = loaded.source!.toString(); // eslint-disable-line @typescript-eslint/no-base-to-string const filename = fileURLToPath(url); + const { svelte_compile_options, svelte_preprocessors } = await load_default_svelte_config(); const preprocessed = svelte_preprocessors // TODO @many use sourcemaps (and diagnostics?) ? await preprocess(raw_source, svelte_preprocessors, { filename }) : null; @@ -177,6 +209,7 @@ export const load: LoadHook = async (url, context, nextLoad) => { throw Error(`Unknown $env import: ${context.importAttributes.virtual}`); } } + const { env_dir, private_prefix, public_prefix } = await load_default_svelte_config(); const source = render_env_shim_module( dev, mode, diff --git a/src/lib/svelte_config.ts b/src/lib/svelte_config.ts index c9e06370f0..f111912e8e 100644 --- a/src/lib/svelte_config.ts +++ b/src/lib/svelte_config.ts @@ -68,13 +68,30 @@ export const svelte_config_log = new Logger('svelte_config'); const find_config_file = (dir: string, filenames: Array): string | undefined => filenames.find((filename) => existsSync(join(dir, filename))); +/** + * Whether the project in `dir` has a Vite config, and so anything to read a Svelte config + * through. Exported because it's what makes a config read worth caching - a project without + * one never pays for a resolution to begin with. + */ +export const has_vite_config = (dir = process.cwd()): boolean => + find_config_file(dir, VITE_CONFIG_FILENAMES) !== undefined; + +const NO_VITE_CONFIG_REASON = 'no Vite config to read it through'; + +/** + * Exported because the loader repeats this warning when it reads a cached config - + * a cache is only written after a Vite config resolves, so it's the only reason + * that can still apply on a hit. + */ +export const NO_SVELTE_PLUGIN_REASON = 'its Vite config configures no Svelte plugin'; + /** * Warns when `dir` has a Svelte config that Gro found no way to read, because that config * looks like it's configuring the project while being ignored. A project with no Svelte * config isn't configuring Svelte at all, so it stays quiet and takes the defaults. * @param reason - why the config couldn't be read, as a clause following "but" */ -const warn_svelte_config_ignored = (dir: string, reason: string): void => { +export const warn_svelte_config_ignored = (dir: string, reason: string): void => { const svelte_config_filename = find_config_file(dir, SVELTE_CONFIG_FILENAMES); if (!svelte_config_filename) return; svelte_config_log.warn( @@ -91,8 +108,8 @@ const warn_svelte_config_ignored = (dir: string, reason: string): void => { */ export const load_svelte_config = async (): Promise => { const dir = process.cwd(); - if (!find_config_file(dir, VITE_CONFIG_FILENAMES)) { - warn_svelte_config_ignored(dir, 'no Vite config to read it through'); + if (!has_vite_config(dir)) { + warn_svelte_config_ignored(dir, NO_VITE_CONFIG_REASON); return null; } @@ -139,7 +156,7 @@ export const load_svelte_config = async (): Promise => { // A Vite config that configures no Svelte plugin is the same silent-ignore as having no Vite // config at all, and likelier to be unintended - a `vite.config.ts` that only sets up Vitest // alongside a `svelte.config.js` that does the real configuring reaches exactly here. - warn_svelte_config_ignored(dir, 'its Vite config configures no Svelte plugin'); + warn_svelte_config_ignored(dir, NO_SVELTE_PLUGIN_REASON); return null; }; diff --git a/src/lib/svelte_config_cache.ts b/src/lib/svelte_config_cache.ts new file mode 100644 index 0000000000..5d98908403 --- /dev/null +++ b/src/lib/svelte_config_cache.ts @@ -0,0 +1,154 @@ +import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { + GRO_DIRNAME, + PACKAGE_JSON_FILENAME, + SVELTE_CONFIG_FILENAMES, + VITE_CONFIG_FILENAMES +} from './constants.ts'; + +/* + +Caches the one piece of the Svelte config the Node loader needs before it can resolve +anything, so the common case doesn't pay a full Vite config resolution per invocation. + +The loader's `resolve` hook reads `alias` and nothing else - every other field it uses is +read inside `load`, which can await the real config lazily. So this holds an alias map and +nothing else, and an alias map is always plain strings: `$lib` pointing at `kit.files.lib` +plus whatever `kit.alias` declares. There's nothing here that doesn't survive JSON, which +is why this caches a slice of the config rather than the config, whose preprocessors and +`compilerOptions.warningFilter` are functions. + +Validation is hand-rolled rather than a Zod schema because this module is on the loader's +critical path, where the point is to be cheaper than the thing it replaces. + +*/ + +export const SVELTE_CONFIG_CACHE_FILENAME = 'svelte_config.json'; + +/** + * Bump when `SvelteConfigCache`'s shape changes, or when the meaning of a field does, + * so caches written by an older Gro self-invalidate instead of being read as the new shape. + */ +export const SVELTE_CONFIG_CACHE_VERSION = 1; + +/** + * The mtime and size of each cache input, or `null` for one that doesn't exist. + * Absence is recorded rather than omitted so that *adding* a config file invalidates too. + */ +export type SvelteConfigCacheStamps = Record; + +export interface SvelteConfigCache { + version: number; + stamps: SvelteConfigCacheStamps; + /** + * The `alias` of a `ParsedSvelteConfig`. + */ + alias: Record; + /** + * `false` when the Vite config resolved but configured no Svelte plugin, so a cached read + * can repeat the warning a fresh load would have emitted instead of going quiet about it. + */ + svelte_config_found: boolean; +} + +/** + * The files whose state invalidates the cache, relative to the project directory. + * + * Deliberately the config files themselves and `package.json`, not the modules a Vite config + * imports: `alias` comes from `kit.alias` and `kit.files.lib`, which are authored in one of + * these. A project that factors them out into an imported module keeps a stale alias until + * something else invalidates, which surfaces as an unresolved import rather than a bad + * compile - `gro clean` clears it. + */ +const CACHE_INPUT_FILENAMES = [ + PACKAGE_JSON_FILENAME, + ...VITE_CONFIG_FILENAMES, + ...SVELTE_CONFIG_FILENAMES +]; + +const to_cache_path = (dir: string): string => join(dir, GRO_DIRNAME, SVELTE_CONFIG_CACHE_FILENAME); + +const to_file_stamp = (path: string): string | null => { + try { + const stats = statSync(path); + return `${stats.mtimeMs}:${stats.size}`; + } catch { + return null; // absent, or unreadable, which is the same thing for cache purposes + } +}; + +/** + * Snapshots the state of the cache inputs in `dir`. + * + * Taken by the caller *before* resolving the config, not inside `svelte_config_cache_write` + * afterwards: a config edited mid-resolution should leave a stamp that no longer matches, so + * the next invocation resolves again. Stamping afterwards would pair the old alias with the + * new state and serve it as fresh. + */ +export const svelte_config_cache_stamps = (dir = process.cwd()): SvelteConfigCacheStamps => { + const stamps: SvelteConfigCacheStamps = {}; + for (const filename of CACHE_INPUT_FILENAMES) { + stamps[filename] = to_file_stamp(join(dir, filename)); + } + return stamps; +}; + +/** + * Reads the cache and returns it only if every input still matches `stamps`. + * Every kind of miss - absent, corrupt, wrong version, stale - reads the same to the caller, + * which resolves the config and rewrites. + */ +export const svelte_config_cache_read = ( + stamps: SvelteConfigCacheStamps, + dir = process.cwd() +): SvelteConfigCache | null => { + let cache: SvelteConfigCache; + try { + cache = JSON.parse(readFileSync(to_cache_path(dir), 'utf8')); + } catch { + return null; + } + if ( + cache?.version !== SVELTE_CONFIG_CACHE_VERSION || + typeof cache.alias !== 'object' || + cache.alias === null || + typeof cache.svelte_config_found !== 'boolean' || + typeof cache.stamps !== 'object' || + cache.stamps === null + ) { + return null; + } + for (const filename of CACHE_INPUT_FILENAMES) { + if (cache.stamps[filename] !== stamps[filename]) return null; + } + return cache; +}; + +/** + * Writes the cache for the config that `stamps` was taken before resolving. + * + * Best effort - a cache that can't be written just means the next invocation resolves again, + * so a read-only or otherwise unwritable project directory costs speed and nothing else. + */ +export const svelte_config_cache_write = ( + stamps: SvelteConfigCacheStamps, + alias: Record, + svelte_config_found: boolean, + dir = process.cwd() +): void => { + const cache: SvelteConfigCache = { + version: SVELTE_CONFIG_CACHE_VERSION, + stamps, + alias, + svelte_config_found + }; + const path = to_cache_path(dir); + try { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(cache, null, '\t') + '\n', 'utf8'); + } catch { + // see the doc comment - caching is an optimization, not a requirement + } +}; diff --git a/src/test/svelte_config_cache.test.ts b/src/test/svelte_config_cache.test.ts new file mode 100644 index 0000000000..af96fa3417 --- /dev/null +++ b/src/test/svelte_config_cache.test.ts @@ -0,0 +1,144 @@ +import { describe, test, expect } from 'vitest'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + SVELTE_CONFIG_CACHE_FILENAME, + SVELTE_CONFIG_CACHE_VERSION, + svelte_config_cache_read, + svelte_config_cache_stamps, + svelte_config_cache_write +} from '$lib/svelte_config_cache.ts'; +import { GRO_DIRNAME } from '$lib/constants.ts'; + +const ALIAS = { $lib: 'src/lib', $routes: 'src/routes' }; + +/** + * Runs `fn` in a fresh directory seeded with `files`, and cleans it up after. + * Unlike the `svelte_config` tests this doesn't need the cwd, since every entry point + * takes an explicit `dir` - only the loader relies on the cwd default. + */ +const in_dir = (files: Record, fn: (dir: string) => T): T => { + const dir = mkdtempSync(join(tmpdir(), 'gro_svelte_config_cache_')); + try { + for (const [filename, content] of Object.entries(files)) { + writeFileSync(join(dir, filename), content); + } + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}; + +const cache_path = (dir: string) => join(dir, GRO_DIRNAME, SVELTE_CONFIG_CACHE_FILENAME); + +describe('svelte_config_cache_stamps', () => { + test('stamps present files and records absent ones as null', () => { + in_dir({ 'vite.config.ts': 'a', 'package.json': '{}' }, (dir) => { + const stamps = svelte_config_cache_stamps(dir); + expect(stamps['vite.config.ts']).toBeTypeOf('string'); + expect(stamps['package.json']).toBeTypeOf('string'); + // Absent inputs are recorded rather than omitted, so that adding one invalidates. + expect(stamps['svelte.config.js']).toBe(null); + expect(stamps['vite.config.mjs']).toBe(null); + }); + }); +}); + +describe('svelte_config_cache_read', () => { + test('returns null when there is no cache', () => { + in_dir({ 'vite.config.ts': 'a' }, (dir) => { + expect(svelte_config_cache_read(svelte_config_cache_stamps(dir), dir)).toBe(null); + }); + }); + + test('round-trips a written cache', () => { + in_dir({ 'vite.config.ts': 'a', 'package.json': '{}' }, (dir) => { + const stamps = svelte_config_cache_stamps(dir); + svelte_config_cache_write(stamps, ALIAS, true, dir); + const cache = svelte_config_cache_read(stamps, dir); + expect(cache?.alias).toEqual(ALIAS); + expect(cache?.svelte_config_found).toBe(true); + expect(cache?.version).toBe(SVELTE_CONFIG_CACHE_VERSION); + }); + }); + + test('carries `svelte_config_found: false` so the warning can repeat', () => { + in_dir({ 'vite.config.ts': 'a' }, (dir) => { + const stamps = svelte_config_cache_stamps(dir); + svelte_config_cache_write(stamps, ALIAS, false, dir); + expect(svelte_config_cache_read(stamps, dir)?.svelte_config_found).toBe(false); + }); + }); + + test('misses when a tracked file changes', () => { + in_dir({ 'vite.config.ts': 'a', 'package.json': '{}' }, (dir) => { + svelte_config_cache_write(svelte_config_cache_stamps(dir), ALIAS, true, dir); + writeFileSync(join(dir, 'vite.config.ts'), 'a different length'); + expect(svelte_config_cache_read(svelte_config_cache_stamps(dir), dir)).toBe(null); + }); + }); + + // The reason absent inputs are stamped: a project that adds a Svelte config has to + // re-resolve, or it would keep running on aliases read before the file existed. + test('misses when a tracked file appears', () => { + in_dir({ 'vite.config.ts': 'a' }, (dir) => { + svelte_config_cache_write(svelte_config_cache_stamps(dir), ALIAS, true, dir); + writeFileSync(join(dir, 'svelte.config.js'), 'export default {};'); + expect(svelte_config_cache_read(svelte_config_cache_stamps(dir), dir)).toBe(null); + }); + }); + + test('misses when a tracked file is removed', () => { + in_dir({ 'vite.config.ts': 'a', 'svelte.config.js': 'b' }, (dir) => { + svelte_config_cache_write(svelte_config_cache_stamps(dir), ALIAS, true, dir); + rmSync(join(dir, 'svelte.config.js')); + expect(svelte_config_cache_read(svelte_config_cache_stamps(dir), dir)).toBe(null); + }); + }); + + test('misses on a stale version', () => { + in_dir({ 'vite.config.ts': 'a' }, (dir) => { + const stamps = svelte_config_cache_stamps(dir); + svelte_config_cache_write(stamps, ALIAS, true, dir); + const cache = JSON.parse(readFileSync(cache_path(dir), 'utf8')); + writeFileSync( + cache_path(dir), + JSON.stringify({ ...cache, version: SVELTE_CONFIG_CACHE_VERSION + 1 }) + ); + expect(svelte_config_cache_read(stamps, dir)).toBe(null); + }); + }); + + test('misses on malformed contents rather than throwing', () => { + in_dir({ 'vite.config.ts': 'a' }, (dir) => { + const stamps = svelte_config_cache_stamps(dir); + mkdirSync(join(dir, GRO_DIRNAME), { recursive: true }); + for (const contents of ['not json', 'null', '[]', '{"version":1}']) { + writeFileSync(cache_path(dir), contents); + expect(svelte_config_cache_read(stamps, dir)).toBe(null); + } + }); + }); +}); + +describe('svelte_config_cache_write', () => { + test('creates the .gro directory', () => { + in_dir({ 'vite.config.ts': 'a' }, (dir) => { + svelte_config_cache_write(svelte_config_cache_stamps(dir), ALIAS, true, dir); + expect(JSON.parse(readFileSync(cache_path(dir), 'utf8')).alias).toEqual(ALIAS); + }); + }); + + // Caching is an optimization, so an unwritable directory costs speed and nothing else. + test('swallows write failures', () => { + in_dir({ 'vite.config.ts': 'a' }, (dir) => { + // A file where the `.gro` directory would go makes `mkdirSync` fail. + writeFileSync(join(dir, GRO_DIRNAME), ''); + expect(() => + svelte_config_cache_write(svelte_config_cache_stamps(dir), ALIAS, true, dir) + ).not.toThrow(); + }); + }); +}); From 4e8b98907575b260292fbf9bb91bc8ffb25cc025 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 7 Aug 2026 22:25:42 -0400 Subject: [PATCH 10/10] refactor --- .changeset/lazy-svelte-config.md | 8 ++++++++ src/lib/constants.ts | 16 +++++++++++++--- .../esbuild_plugin_sveltekit_shim_alias.ts | 3 +-- src/lib/filer.ts | 13 +++++++++++-- src/lib/gro.config.default.ts | 4 ++++ src/lib/loader.ts | 13 ++++++------- src/lib/paths.ts | 15 ++++----------- src/lib/svelte_config_cache.ts | 16 ++++++++-------- src/lib/sveltekit_helpers.ts | 7 ++++--- src/test/gro_config.test.ts | 11 ++++++----- src/test/svelte_config_cache.test.ts | 19 +++++++++++-------- 11 files changed, 76 insertions(+), 49 deletions(-) diff --git a/.changeset/lazy-svelte-config.md b/.changeset/lazy-svelte-config.md index 91185b8684..61f23c4ea9 100644 --- a/.changeset/lazy-svelte-config.md +++ b/.changeset/lazy-svelte-config.md @@ -35,6 +35,11 @@ loader caches the alias map at `.gro/svelte_config.json` and defers the rest of to `load`. On a hit it resolves nothing; on a miss it loads at module scope as before, before the hooks go live, and writes the cache. +The `Filer` reads that cache too, since aliases are all it needs to resolve the import +specifiers it tracks - so `gro gen` no longer resolves the config on the main thread +either, and the Filer maps specifiers through the very map the loader uses rather than +through one that merely agrees with it. + An alias map is plain strings, so nothing is lost to serialization - which is why the new `svelte_config_cache.ts` caches a slice of the config rather than the config, whose preprocessors and `compilerOptions.warningFilter` are functions. The key is the mtime and @@ -117,6 +122,9 @@ Breaking changes: `ParsedSvelteConfig` instead - the `package.json` exports automation, the server plugin's entry point and `outbase`, and `has_sveltekit_library`. Point `task_root_dirs` at it in `gro.config.ts` if tasks live there +- `LIB_DIRNAME`, `LIB_PATH`, and `LIB_DIR` move from `paths.ts` to `constants.ts`, which + is where a value that reads no config belongs - they only lived in `paths.ts` because + they used to be derived from `kit.files.lib` - `package_json_sync`'s `exports_dir` param no longer defaults to `paths.lib`; when omitted it's the Svelte config's `lib_path`, which `has_sveltekit_library` has already resolved by then. Searching the conventional `src/lib` for a project that moved its lib diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 7611936d10..78cfce6f87 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -19,11 +19,21 @@ export const GRO_DIR = GRO_DIRNAME + '/'; /** @trailing_slash */ export const GRO_DEV_DIR = GRO_DEV_DIRNAME + '/'; export const GRO_CONFIG_FILENAME = 'gro.config.ts'; +/** + * The conventional library directory, not the SvelteKit `kit.files.lib`, which lives here + * rather than in `./paths.ts` because it's a constant that reads no config. Code that has to + * honor a customized `files.lib` reads `lib_path` off a `ParsedSvelteConfig` instead, and + * projects that move it can point `task_root_dirs` at the new location in `gro.config.ts`. + */ +export const LIB_DIRNAME = 'lib'; +export const LIB_PATH = SOURCE_DIR + LIB_DIRNAME; +/** @trailing_slash */ +export const LIB_DIR = LIB_PATH + '/'; /** * Every filename SvelteKit loads its config from, in SvelteKit's own precedence order. - * Gro reads the Svelte config through Vite, never from these directly, - * but SvelteKit still loads one when `sveltekit()` gets no inline options, - * so they're project files that Gro formats. + * Gro reads the Svelte config through Vite, never from these directly, but SvelteKit still + * loads one when `sveltekit()` gets no inline options - so they're project files that Gro + * formats, watches for a config it can't read through Vite, and keys its config cache on. * @see https://svelte.dev/docs/kit/configuration */ export const SVELTE_CONFIG_FILENAMES = ['svelte.config.js', 'svelte.config.ts']; diff --git a/src/lib/esbuild_plugin_sveltekit_shim_alias.ts b/src/lib/esbuild_plugin_sveltekit_shim_alias.ts index e5094fb6e5..a718e84966 100644 --- a/src/lib/esbuild_plugin_sveltekit_shim_alias.ts +++ b/src/lib/esbuild_plugin_sveltekit_shim_alias.ts @@ -2,8 +2,7 @@ import type * as esbuild from 'esbuild'; import { escape_regexp } from '@fuzdev/fuz_util/regexp.ts'; import { join } from 'node:path'; -import { SVELTEKIT_LIB_ALIAS } from './constants.ts'; -import { LIB_PATH } from './paths.ts'; +import { LIB_PATH, SVELTEKIT_LIB_ALIAS } from './constants.ts'; export interface EsbuildPluginSveltekitShimAliasOptions { dir?: string; diff --git a/src/lib/filer.ts b/src/lib/filer.ts index 3cca5fe462..be54d8a2a0 100644 --- a/src/lib/filer.ts +++ b/src/lib/filer.ts @@ -20,6 +20,7 @@ import { paths } from './paths.ts'; import { parse_imports } from './parse_imports.ts'; import { resolve_specifier } from './resolve_specifier.ts'; import { load_default_svelte_config } from './svelte_config.ts'; +import { svelte_config_cache_read, svelte_config_cache_stamps } from './svelte_config_cache.ts'; import { map_sveltekit_aliases } from './sveltekit_helpers.ts'; import { SVELTEKIT_GLOBAL_SPECIFIER } from './constants.ts'; import type { Disknode } from './disknode.ts'; @@ -27,11 +28,19 @@ import type { Disknode } from './disknode.ts'; let aliases: Array<[string, string]> | undefined; /** - * Loaded on demand so constructing a `Filer` doesn't read the SvelteKit config, + * Loaded on demand so constructing a `Filer` doesn't read the Svelte config, * and memoized because this is called for every import specifier of every changed file. + * + * Prefers the loader's alias cache, which is keyed on the config files' state and so is + * valid whoever wrote it. That saves a full Vite config resolution for tasks like `gro gen` + * that need nothing else from the config, and it makes the Filer resolve specifiers through + * the same map the loader does rather than through one that merely agrees with it. */ const load_aliases = async (): Promise> => - (aliases ??= Object.entries((await load_default_svelte_config()).alias)); + (aliases ??= Object.entries( + svelte_config_cache_read(svelte_config_cache_stamps())?.alias ?? + (await load_default_svelte_config()).alias + )); export type OnFilerChange = (change: WatcherChange, disknode: Disknode) => void; diff --git a/src/lib/gro.config.default.ts b/src/lib/gro.config.default.ts index 2f90fc97ec..0419e9e298 100644 --- a/src/lib/gro.config.default.ts +++ b/src/lib/gro.config.default.ts @@ -24,6 +24,10 @@ const config: CreateGroConfig = (cfg) => { cfg.plugins = async () => { const package_json = await package_json_load(); // TODO gets wastefully loaded by some plugins, maybe put in plugin/task context? how does that interact with `map_package_json`? + // `has_server` reads the Svelte config, because the server's location follows + // `kit.files.lib` and there's no way to find it without knowing where that points. + // So `dev` and `build` resolve the config once here no matter what the project is - + // which is the right place to pay for it, since they're the commands that need it. const has_sveltekit_app_result = has_sveltekit_app(package_json); const [has_server_result, has_sveltekit_library_result] = await Promise.all([ has_server(), diff --git a/src/lib/loader.ts b/src/lib/loader.ts index a04755a4d6..dbb6aa937c 100644 --- a/src/lib/loader.ts +++ b/src/lib/loader.ts @@ -98,16 +98,15 @@ if (cached_svelte_config) { warn_svelte_config_ignored(process.cwd(), NO_SVELTE_PLUGIN_REASON); } } else { - const svelte_config = await load_default_svelte_config(); - aliases = Object.entries(svelte_config.alias); + const parsed_svelte_config = await load_default_svelte_config(); + aliases = Object.entries(parsed_svelte_config.alias); // Skipped without a Vite config because that path resolves nothing to save, // and because its warning has to repeat rather than be cached away. if (has_vite_config()) { - svelte_config_cache_write( - cache_stamps, - svelte_config.alias, - svelte_config.svelte_config !== null - ); + svelte_config_cache_write(cache_stamps, { + alias: parsed_svelte_config.alias, + svelte_config_found: parsed_svelte_config.svelte_config !== null + }); } } diff --git a/src/lib/paths.ts b/src/lib/paths.ts index 894bbcea22..0e8c93cb33 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -8,6 +8,7 @@ import { GRO_CONFIG_FILENAME, GRO_DEV_DIR, GRO_DIR, + LIB_DIR, SOURCE_DIR, SVELTEKIT_DIST_DIRNAME } from './constants.ts'; @@ -21,20 +22,12 @@ It's the same name that Rollup uses. /* -These are the conventional locations, not the SvelteKit `files` config values, -so that `paths` stays cheap - reading the SvelteKit config costs a full Vite config -resolution, which is too expensive to do on every Gro invocation. -Code that needs to honor a customized `kit.files.lib` reads `lib_path` -off `ParsedSvelteConfig` instead, and projects that move it -can point `task_root_dirs` at the new location in `gro.config.ts`. +`paths` is built from the conventional locations in `./constants.ts`, never from the +SvelteKit `files` config values - reading those costs a full Vite config resolution, +which is too expensive for a module every Gro invocation imports. */ -export const LIB_DIRNAME = 'lib'; -export const LIB_PATH = SOURCE_DIR + LIB_DIRNAME; -/** @trailing_slash */ -export const LIB_DIR = LIB_PATH + '/'; - export interface Paths { /** @trailing_slash */ root: string; diff --git a/src/lib/svelte_config_cache.ts b/src/lib/svelte_config_cache.ts index 5d98908403..2e75c131c0 100644 --- a/src/lib/svelte_config_cache.ts +++ b/src/lib/svelte_config_cache.ts @@ -23,6 +23,11 @@ is why this caches a slice of the config rather than the config, whose preproces Validation is hand-rolled rather than a Zod schema because this module is on the loader's critical path, where the point is to be cheaper than the thing it replaces. +Unlike `./svelte_config.ts`, which reads the cwd's project and only that one, everything +here takes a `dir`: SvelteKit resolves its `files` and `env.dir` against its own cwd, so a +directory parameter there could only ever be half-honored, while a JSON file on disk has no +such tie. The loader always passes the cwd; the parameter is what makes this testable. + */ export const SVELTE_CONFIG_CACHE_FILENAME = 'svelte_config.json'; @@ -131,19 +136,14 @@ export const svelte_config_cache_read = ( * * Best effort - a cache that can't be written just means the next invocation resolves again, * so a read-only or otherwise unwritable project directory costs speed and nothing else. + * @param read - what resolving the config produced */ export const svelte_config_cache_write = ( stamps: SvelteConfigCacheStamps, - alias: Record, - svelte_config_found: boolean, + read: Pick, dir = process.cwd() ): void => { - const cache: SvelteConfigCache = { - version: SVELTE_CONFIG_CACHE_VERSION, - stamps, - alias, - svelte_config_found - }; + const cache: SvelteConfigCache = { version: SVELTE_CONFIG_CACHE_VERSION, stamps, ...read }; const path = to_cache_path(dir); try { mkdirSync(dirname(path), { recursive: true }); diff --git a/src/lib/sveltekit_helpers.ts b/src/lib/sveltekit_helpers.ts index e212c6297e..fdc061bbc4 100644 --- a/src/lib/sveltekit_helpers.ts +++ b/src/lib/sveltekit_helpers.ts @@ -41,9 +41,10 @@ export const has_sveltekit_library = async ( return has_sveltekit_app_result; } - // Checked before the lib directory because it's the cheaper of the two - // and it's what distinguishes a library from an app, - // so apps bail out without reading the Svelte config. + // Checked before the lib directory because it's the cheaper of the two and it's what + // distinguishes a library from an app, so this returns without reading the Svelte config + // for the tasks that call it on its own - `changeset`, `publish`, `release`, `gro sync`. + // `dev` and `build` resolve the config regardless, since `has_server` needs it too. // Peer deps don't count here either, for the same reason as `has_sveltekit_app`. if (!package_json_has_dependency(SVELTE_PACKAGE_DEP_NAME, package_json, false)) { return { diff --git a/src/test/gro_config.test.ts b/src/test/gro_config.test.ts index 49ea8042bc..5ff75f6fcf 100644 --- a/src/test/gro_config.test.ts +++ b/src/test/gro_config.test.ts @@ -7,12 +7,13 @@ import { load_gro_config } from '$lib/gro_config.ts'; -test('load_gro_config', async () => { - // Mock the dynamic import to avoid module resolution issues - vi.mock('node:fs', () => ({ - existsSync: vi.fn().mockReturnValue(false) - })); +// Makes `load_gro_config` see no `gro.config.ts` and fall back to the default config. +// At the top level because that's where Vitest hoists it to anyway. +vi.mock('node:fs', () => ({ + existsSync: vi.fn().mockReturnValue(false) +})); +test('load_gro_config', async () => { const config = await load_gro_config(); expect(config).toBeTruthy(); expect(config.plugins).toBeDefined(); diff --git a/src/test/svelte_config_cache.test.ts b/src/test/svelte_config_cache.test.ts index af96fa3417..c1962c3c5d 100644 --- a/src/test/svelte_config_cache.test.ts +++ b/src/test/svelte_config_cache.test.ts @@ -14,6 +14,9 @@ import { GRO_DIRNAME } from '$lib/constants.ts'; const ALIAS = { $lib: 'src/lib', $routes: 'src/routes' }; +/** What a successful config read produces, in the shape `svelte_config_cache_write` takes. */ +const READ = { alias: ALIAS, svelte_config_found: true }; + /** * Runs `fn` in a fresh directory seeded with `files`, and cleans it up after. * Unlike the `svelte_config` tests this doesn't need the cwd, since every entry point @@ -56,7 +59,7 @@ describe('svelte_config_cache_read', () => { test('round-trips a written cache', () => { in_dir({ 'vite.config.ts': 'a', 'package.json': '{}' }, (dir) => { const stamps = svelte_config_cache_stamps(dir); - svelte_config_cache_write(stamps, ALIAS, true, dir); + svelte_config_cache_write(stamps, READ, dir); const cache = svelte_config_cache_read(stamps, dir); expect(cache?.alias).toEqual(ALIAS); expect(cache?.svelte_config_found).toBe(true); @@ -67,14 +70,14 @@ describe('svelte_config_cache_read', () => { test('carries `svelte_config_found: false` so the warning can repeat', () => { in_dir({ 'vite.config.ts': 'a' }, (dir) => { const stamps = svelte_config_cache_stamps(dir); - svelte_config_cache_write(stamps, ALIAS, false, dir); + svelte_config_cache_write(stamps, { ...READ, svelte_config_found: false }, dir); expect(svelte_config_cache_read(stamps, dir)?.svelte_config_found).toBe(false); }); }); test('misses when a tracked file changes', () => { in_dir({ 'vite.config.ts': 'a', 'package.json': '{}' }, (dir) => { - svelte_config_cache_write(svelte_config_cache_stamps(dir), ALIAS, true, dir); + svelte_config_cache_write(svelte_config_cache_stamps(dir), READ, dir); writeFileSync(join(dir, 'vite.config.ts'), 'a different length'); expect(svelte_config_cache_read(svelte_config_cache_stamps(dir), dir)).toBe(null); }); @@ -84,7 +87,7 @@ describe('svelte_config_cache_read', () => { // re-resolve, or it would keep running on aliases read before the file existed. test('misses when a tracked file appears', () => { in_dir({ 'vite.config.ts': 'a' }, (dir) => { - svelte_config_cache_write(svelte_config_cache_stamps(dir), ALIAS, true, dir); + svelte_config_cache_write(svelte_config_cache_stamps(dir), READ, dir); writeFileSync(join(dir, 'svelte.config.js'), 'export default {};'); expect(svelte_config_cache_read(svelte_config_cache_stamps(dir), dir)).toBe(null); }); @@ -92,7 +95,7 @@ describe('svelte_config_cache_read', () => { test('misses when a tracked file is removed', () => { in_dir({ 'vite.config.ts': 'a', 'svelte.config.js': 'b' }, (dir) => { - svelte_config_cache_write(svelte_config_cache_stamps(dir), ALIAS, true, dir); + svelte_config_cache_write(svelte_config_cache_stamps(dir), READ, dir); rmSync(join(dir, 'svelte.config.js')); expect(svelte_config_cache_read(svelte_config_cache_stamps(dir), dir)).toBe(null); }); @@ -101,7 +104,7 @@ describe('svelte_config_cache_read', () => { test('misses on a stale version', () => { in_dir({ 'vite.config.ts': 'a' }, (dir) => { const stamps = svelte_config_cache_stamps(dir); - svelte_config_cache_write(stamps, ALIAS, true, dir); + svelte_config_cache_write(stamps, READ, dir); const cache = JSON.parse(readFileSync(cache_path(dir), 'utf8')); writeFileSync( cache_path(dir), @@ -126,7 +129,7 @@ describe('svelte_config_cache_read', () => { describe('svelte_config_cache_write', () => { test('creates the .gro directory', () => { in_dir({ 'vite.config.ts': 'a' }, (dir) => { - svelte_config_cache_write(svelte_config_cache_stamps(dir), ALIAS, true, dir); + svelte_config_cache_write(svelte_config_cache_stamps(dir), READ, dir); expect(JSON.parse(readFileSync(cache_path(dir), 'utf8')).alias).toEqual(ALIAS); }); }); @@ -137,7 +140,7 @@ describe('svelte_config_cache_write', () => { // A file where the `.gro` directory would go makes `mkdirSync` fail. writeFileSync(join(dir, GRO_DIRNAME), ''); expect(() => - svelte_config_cache_write(svelte_config_cache_stamps(dir), ALIAS, true, dir) + svelte_config_cache_write(svelte_config_cache_stamps(dir), READ, dir) ).not.toThrow(); }); });