From 0b2f0c4b90e6e578c7e947e54310112a0f4a34d5 Mon Sep 17 00:00:00 2001 From: Birk Skyum Date: Sat, 25 Jul 2026 21:20:04 +0200 Subject: [PATCH 1/3] scope the built-in --- .changeset/tidy-paths-resolve.md | 5 + .../start/src/config/app-root-alias.spec.ts | 114 ++++++++++++++++++ packages/start/src/config/app-root-alias.ts | 98 +++++++++++++++ packages/start/src/config/index.ts | 3 +- 4 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 .changeset/tidy-paths-resolve.md create mode 100644 packages/start/src/config/app-root-alias.spec.ts create mode 100644 packages/start/src/config/app-root-alias.ts diff --git a/.changeset/tidy-paths-resolve.md b/.changeset/tidy-paths-resolve.md new file mode 100644 index 000000000..1ff6a27ed --- /dev/null +++ b/.changeset/tidy-paths-resolve.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +Scope the built-in `~` alias to the app package so importer-aware Vite plugins can resolve the same alias within workspace packages. diff --git a/packages/start/src/config/app-root-alias.spec.ts b/packages/start/src/config/app-root-alias.spec.ts new file mode 100644 index 000000000..ff26861a4 --- /dev/null +++ b/packages/start/src/config/app-root-alias.spec.ts @@ -0,0 +1,114 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createServer, type Plugin, type ViteDevServer } from "vite"; + +import { appRootAlias } from "./app-root-alias.ts"; + +const temporaryDirectories: string[] = []; +const servers: ViteDevServer[] = []; + +function createProject() { + const root = mkdtempSync(join(tmpdir(), "solid-start-app-root-alias-")); + temporaryDirectories.push(root); + + for (const directory of ["src", "shared", "lib/src"]) { + mkdirSync(join(root, directory), { recursive: true }); + } + writeFileSync(join(root, "package.json"), "{}"); + writeFileSync(join(root, "lib/package.json"), "{}"); + writeFileSync(join(root, "src/demo.ts"), 'export default "app";'); + writeFileSync(join(root, "lib/src/demo.ts"), 'export default "package";'); + + return { + root, + appImporter: join(root, "src/app.tsx"), + sharedAppImporter: join(root, "shared/example.ts"), + packageImporter: join(root, "lib/src/index.ts"), + }; +} + +function resolverFor(root: string) { + const plugin = appRootAlias(root, "./src") as any; + const resolve = vi.fn(async (id: string) => ({ id })); + + return { + resolve, + resolveId(id: string, importer?: string) { + return plugin.resolveId.call({ resolve }, id, importer, {}); + }, + }; +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => server.close())); + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true }); + } +}); + +describe("appRootAlias", () => { + it("resolves ~ from files owned by the app package", async () => { + const { root, appImporter, sharedAppImporter } = createProject(); + const resolver = resolverFor(root); + + await expect(resolver.resolveId("~/demo", appImporter)).resolves.toEqual({ + id: join(root, "src/demo"), + }); + await expect(resolver.resolveId("~/demo", `${sharedAppImporter}?raw`)).resolves.toEqual({ + id: join(root, "src/demo"), + }); + }); + + it("leaves ~ imports from a workspace package to importer-aware resolvers", async () => { + const { root, packageImporter } = createProject(); + const resolver = resolverFor(root); + + await expect(resolver.resolveId("~/demo", packageImporter)).resolves.toBe(null); + expect(resolver.resolve).not.toHaveBeenCalled(); + }); + + it("allows an importer-aware Vite plugin to resolve a package-local ~ path", async () => { + const { root, appImporter, packageImporter } = createProject(); + const packageRoot = join(root, "lib"); + const packagePaths: Plugin = { + name: "test:package-paths", + enforce: "pre", + async resolveId(id, importer, options) { + if (id !== "~/demo" || !importer?.startsWith(packageRoot)) return null; + return this.resolve(join(packageRoot, "src/demo"), importer, { + ...options, + skipSelf: true, + }); + }, + }; + const server = await createServer({ + configFile: false, + logLevel: "silent", + root, + server: { middlewareMode: true }, + plugins: [appRootAlias(root, "./src"), packagePaths], + }); + servers.push(server); + + await expect(server.pluginContainer.resolveId("~/demo", appImporter)).resolves.toMatchObject({ + id: realpathSync.native(join(root, "src/demo.ts")), + }); + await expect( + server.pluginContainer.resolveId("~/demo", packageImporter), + ).resolves.toMatchObject({ + id: realpathSync.native(join(root, "lib/src/demo.ts")), + }); + }); + + it("resolves entry aliases without an importer and ignores similar bare ids", async () => { + const { root } = createProject(); + const resolver = resolverFor(root); + + await expect(resolver.resolveId("~")).resolves.toEqual({ + id: join(root, "src"), + }); + await expect(resolver.resolveId("~package/example")).resolves.toBe(null); + }); +}); diff --git a/packages/start/src/config/app-root-alias.ts b/packages/start/src/config/app-root-alias.ts new file mode 100644 index 000000000..eeb826411 --- /dev/null +++ b/packages/start/src/config/app-root-alias.ts @@ -0,0 +1,98 @@ +import { existsSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Plugin } from "vite"; + +const FS_PREFIX = "/@fs/"; + +function canonicalPath(path: string): string { + try { + return realpathSync.native(path); + } catch { + return path; + } +} + +function filePathFromId(id: string): string | undefined { + let filename = id.replace(/[?#].*$/s, ""); + + if (filename.startsWith("\0")) return; + if (filename.startsWith(FS_PREFIX)) filename = filename.slice(FS_PREFIX.length); + if (filename.startsWith("file://")) filename = fileURLToPath(filename); + + return isAbsolute(filename) ? filename : undefined; +} + +/** + * Provides SolidStart's `~` app-root alias without claiming imports made by + * workspace packages. Those packages may define their own `~` mapping through + * an importer-aware resolver such as vite-tsconfig-paths. + */ +export function appRootAlias(projectRoot: string, appRoot: string): Plugin { + const absoluteProjectRoot = resolve(projectRoot); + const absoluteAppRoot = resolve(absoluteProjectRoot, appRoot); + const canonicalProjectRoot = canonicalPath(absoluteProjectRoot); + const packageRootCache = new Map(); + + function findPackageRoot(start: string): string | undefined { + let directory = start; + const visited: string[] = []; + + while (true) { + if (packageRootCache.has(directory)) { + const packageRoot = packageRootCache.get(directory); + for (const visitedDirectory of visited) { + packageRootCache.set(visitedDirectory, packageRoot); + } + return packageRoot; + } + + visited.push(directory); + if (existsSync(join(directory, "package.json"))) { + const packageRoot = canonicalPath(directory); + for (const visitedDirectory of visited) { + packageRootCache.set(visitedDirectory, packageRoot); + } + return packageRoot; + } + + const parent = dirname(directory); + if (parent === directory) { + for (const visitedDirectory of visited) { + packageRootCache.set(visitedDirectory, undefined); + } + return; + } + directory = parent; + } + } + + const appPackageRoot = findPackageRoot(absoluteAppRoot); + + function isAppImporter(importer: string): boolean { + const importerPath = filePathFromId(importer); + if (!importerPath) return false; + + if (appPackageRoot) { + return findPackageRoot(dirname(importerPath)) === appPackageRoot; + } + + const relativeImporter = relative(canonicalProjectRoot, canonicalPath(importerPath)); + return ( + relativeImporter === "" || + (!relativeImporter.startsWith("..") && !isAbsolute(relativeImporter)) + ); + } + + return { + name: "solid-start:app-root-alias", + enforce: "pre", + async resolveId(id, importer, options) { + if (id !== "~" && !id.startsWith("~/")) return null; + if (importer && !isAppImporter(importer)) return null; + + const target = id === "~" ? absoluteAppRoot : join(absoluteAppRoot, id.slice(2)); + return (await this.resolve(target, importer, { ...options, skipSelf: true })) ?? target; + }, + }; +} diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index 4ec6ab107..ba25fa0d5 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -4,6 +4,7 @@ import { basename, extname, isAbsolute, join } from "node:path"; import type { PluginOption } from "vite"; import solid, { type Options as SolidOptions } from "vite-plugin-solid"; import { type ServerFunctionsOptions, serverFunctionsPlugin } from "../directives/index.ts"; +import { appRootAlias } from "./app-root-alias.ts"; import { boundaryModules } from "./boundary-modules.ts"; import { DEFAULT_EXTENSIONS, VIRTUAL_MODULES, VITE_ENVIRONMENTS } from "./constants.ts"; import { devServer } from "./dev-server.ts"; @@ -238,7 +239,6 @@ export function solidStart(options?: SolidStartOptions): Array { resolve: { alias: { "@solidjs/start/server/entry": handlers.server, - "~": join(process.cwd(), start.appRoot), ...(!start.ssr ? { "@solidjs/start/server": "@solidjs/start/server/spa", @@ -287,6 +287,7 @@ export function solidStart(options?: SolidStartOptions): Array { }; }, }, + appRootAlias(root, start.appRoot), manifest(start), fsRoutes({ routers: { From 468854ee7fc9ff28abe94e8d77f72e1395401745 Mon Sep 17 00:00:00 2001 From: Birk Skyum Date: Sun, 26 Jul 2026 00:50:43 +0200 Subject: [PATCH 2/3] simplify --- packages/start/src/config/app-root-alias.ts | 103 ++++++-------------- 1 file changed, 28 insertions(+), 75 deletions(-) diff --git a/packages/start/src/config/app-root-alias.ts b/packages/start/src/config/app-root-alias.ts index eeb826411..9c863dc0c 100644 --- a/packages/start/src/config/app-root-alias.ts +++ b/packages/start/src/config/app-root-alias.ts @@ -1,87 +1,40 @@ -import { existsSync, realpathSync } from "node:fs"; -import { dirname, isAbsolute, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import type { Plugin } from "vite"; - -const FS_PREFIX = "/@fs/"; - -function canonicalPath(path: string): string { - try { - return realpathSync.native(path); - } catch { - return path; - } -} - -function filePathFromId(id: string): string | undefined { - let filename = id.replace(/[?#].*$/s, ""); - - if (filename.startsWith("\0")) return; - if (filename.startsWith(FS_PREFIX)) filename = filename.slice(FS_PREFIX.length); - if (filename.startsWith("file://")) filename = fileURLToPath(filename); - - return isAbsolute(filename) ? filename : undefined; -} +import { existsSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { normalizePath, type Plugin } from "vite"; /** * Provides SolidStart's `~` app-root alias without claiming imports made by - * workspace packages. Those packages may define their own `~` mapping through - * an importer-aware resolver such as vite-tsconfig-paths. + * other workspace packages. Those packages may define their own `~` mapping + * through an importer-aware resolver such as vite-tsconfig-paths. */ export function appRootAlias(projectRoot: string, appRoot: string): Plugin { - const absoluteProjectRoot = resolve(projectRoot); - const absoluteAppRoot = resolve(absoluteProjectRoot, appRoot); - const canonicalProjectRoot = canonicalPath(absoluteProjectRoot); - const packageRootCache = new Map(); - - function findPackageRoot(start: string): string | undefined { - let directory = start; - const visited: string[] = []; - - while (true) { - if (packageRootCache.has(directory)) { - const packageRoot = packageRootCache.get(directory); - for (const visitedDirectory of visited) { - packageRootCache.set(visitedDirectory, packageRoot); - } - return packageRoot; - } - - visited.push(directory); - if (existsSync(join(directory, "package.json"))) { - const packageRoot = canonicalPath(directory); - for (const visitedDirectory of visited) { - packageRootCache.set(visitedDirectory, packageRoot); - } - return packageRoot; - } + const appDir = normalizePath(resolve(projectRoot, appRoot)); + const packageRoots = new Map(); + /** Nearest directory at or above `directory` that holds a `package.json`. */ + function packageRoot(directory: string): string | undefined { + if (!packageRoots.has(directory)) { const parent = dirname(directory); - if (parent === directory) { - for (const visitedDirectory of visited) { - packageRootCache.set(visitedDirectory, undefined); - } - return; - } - directory = parent; + packageRoots.set( + directory, + existsSync(join(directory, "package.json")) + ? directory + : parent === directory + ? undefined + : packageRoot(parent), + ); } + return packageRoots.get(directory); } - const appPackageRoot = findPackageRoot(absoluteAppRoot); - - function isAppImporter(importer: string): boolean { - const importerPath = filePathFromId(importer); - if (!importerPath) return false; - - if (appPackageRoot) { - return findPackageRoot(dirname(importerPath)) === appPackageRoot; - } + const appPackage = packageRoot(appDir); - const relativeImporter = relative(canonicalProjectRoot, canonicalPath(importerPath)); - return ( - relativeImporter === "" || - (!relativeImporter.startsWith("..") && !isAbsolute(relativeImporter)) - ); + /** True only when the importer demonstrably belongs to another package. */ + function isForeignImporter(importer: string) { + const file = normalizePath(importer.replace(/[?#].*$/s, "")); + if (!isAbsolute(file)) return false; + const owner = packageRoot(dirname(file)); + return owner !== undefined && owner !== appPackage; } return { @@ -89,9 +42,9 @@ export function appRootAlias(projectRoot: string, appRoot: string): Plugin { enforce: "pre", async resolveId(id, importer, options) { if (id !== "~" && !id.startsWith("~/")) return null; - if (importer && !isAppImporter(importer)) return null; + if (importer && isForeignImporter(importer)) return null; - const target = id === "~" ? absoluteAppRoot : join(absoluteAppRoot, id.slice(2)); + const target = join(appDir, id.slice(1)); return (await this.resolve(target, importer, { ...options, skipSelf: true })) ?? target; }, }; From 262d253c3c29f14edd56916eb950ba9358a71a10 Mon Sep 17 00:00:00 2001 From: Birk Skyum Date: Sun, 26 Jul 2026 00:58:43 +0200 Subject: [PATCH 3/3] cleanup --- .changeset/tidy-paths-resolve.md | 2 +- .../start/src/config/app-root-alias.spec.ts | 139 +++++++----------- packages/start/src/config/app-root-alias.ts | 18 ++- 3 files changed, 68 insertions(+), 91 deletions(-) diff --git a/.changeset/tidy-paths-resolve.md b/.changeset/tidy-paths-resolve.md index 1ff6a27ed..693bb8647 100644 --- a/.changeset/tidy-paths-resolve.md +++ b/.changeset/tidy-paths-resolve.md @@ -2,4 +2,4 @@ "@solidjs/start": patch --- -Scope the built-in `~` alias to the app package so importer-aware Vite plugins can resolve the same alias within workspace packages. +Scope the built-in `~` alias to the app package, so files in other workspace packages can map `~` to their own root through an importer-aware plugin such as `vite-tsconfig-paths`. In stylesheets and asset URLs (CSS `@import`, `url()`, `new URL(..., import.meta.url)`) `~` still always means the app root, since Vite resolves those without running plugins. diff --git a/packages/start/src/config/app-root-alias.spec.ts b/packages/start/src/config/app-root-alias.spec.ts index ff26861a4..8680adf0f 100644 --- a/packages/start/src/config/app-root-alias.spec.ts +++ b/packages/start/src/config/app-root-alias.spec.ts @@ -1,114 +1,77 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createServer, type Plugin, type ViteDevServer } from "vite"; +import { build, createServer, type Plugin, type ViteDevServer } from "vite"; +import { afterEach, expect, it } from "vitest"; import { appRootAlias } from "./app-root-alias.ts"; -const temporaryDirectories: string[] = []; +const roots: string[] = []; const servers: ViteDevServer[] = []; +/** An app package with a `src` app root, plus a nested `lib` workspace package. */ function createProject() { - const root = mkdtempSync(join(tmpdir(), "solid-start-app-root-alias-")); - temporaryDirectories.push(root); + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "solid-start-app-root-alias-"))); + roots.push(root); - for (const directory of ["src", "shared", "lib/src"]) { - mkdirSync(join(root, directory), { recursive: true }); - } + mkdirSync(join(root, "src")); + mkdirSync(join(root, "lib/src"), { recursive: true }); writeFileSync(join(root, "package.json"), "{}"); writeFileSync(join(root, "lib/package.json"), "{}"); writeFileSync(join(root, "src/demo.ts"), 'export default "app";'); writeFileSync(join(root, "lib/src/demo.ts"), 'export default "package";'); - return { - root, - appImporter: join(root, "src/app.tsx"), - sharedAppImporter: join(root, "shared/example.ts"), - packageImporter: join(root, "lib/src/index.ts"), - }; -} - -function resolverFor(root: string) { - const plugin = appRootAlias(root, "./src") as any; - const resolve = vi.fn(async (id: string) => ({ id })); - - return { - resolve, - resolveId(id: string, importer?: string) { - return plugin.resolveId.call({ resolve }, id, importer, {}); - }, - }; + return root; } afterEach(async () => { await Promise.all(servers.splice(0).map(server => server.close())); - for (const directory of temporaryDirectories.splice(0)) { - rmSync(directory, { recursive: true }); - } + for (const root of roots.splice(0)) rmSync(root, { recursive: true }); }); -describe("appRootAlias", () => { - it("resolves ~ from files owned by the app package", async () => { - const { root, appImporter, sharedAppImporter } = createProject(); - const resolver = resolverFor(root); - - await expect(resolver.resolveId("~/demo", appImporter)).resolves.toEqual({ - id: join(root, "src/demo"), - }); - await expect(resolver.resolveId("~/demo", `${sharedAppImporter}?raw`)).resolves.toEqual({ - id: join(root, "src/demo"), - }); - }); - - it("leaves ~ imports from a workspace package to importer-aware resolvers", async () => { - const { root, packageImporter } = createProject(); - const resolver = resolverFor(root); - - await expect(resolver.resolveId("~/demo", packageImporter)).resolves.toBe(null); - expect(resolver.resolve).not.toHaveBeenCalled(); +it("resolves ~ for the app package, leaving other packages to importer-aware plugins", async () => { + const root = createProject(); + const packageRoot = join(root, "lib"); + // Stands in for vite-tsconfig-paths: maps `~` to the importing package's root. + const packagePaths: Plugin = { + name: "test:package-paths", + enforce: "pre", + resolveId(id, importer, options) { + if (id !== "~/demo" || !importer?.startsWith(packageRoot)) return null; + return this.resolve(join(packageRoot, "src/demo"), importer, { ...options, skipSelf: true }); + }, + }; + const server = await createServer({ + configFile: false, + logLevel: "silent", + root, + server: { middlewareMode: true }, + plugins: [appRootAlias(root, "./src"), packagePaths], }); + servers.push(server); + + await expect( + server.pluginContainer.resolveId("~/demo", join(root, "src/app.tsx")), + ).resolves.toMatchObject({ id: join(root, "src/demo.ts") }); + await expect( + server.pluginContainer.resolveId("~/demo", join(root, "lib/src/index.ts")), + ).resolves.toMatchObject({ id: join(root, "lib/src/demo.ts") }); +}); - it("allows an importer-aware Vite plugin to resolve a package-local ~ path", async () => { - const { root, appImporter, packageImporter } = createProject(); - const packageRoot = join(root, "lib"); - const packagePaths: Plugin = { - name: "test:package-paths", - enforce: "pre", - async resolveId(id, importer, options) { - if (id !== "~/demo" || !importer?.startsWith(packageRoot)) return null; - return this.resolve(join(packageRoot, "src/demo"), importer, { - ...options, - skipSelf: true, - }); - }, - }; - const server = await createServer({ - configFile: false, - logLevel: "silent", - root, - server: { middlewareMode: true }, - plugins: [appRootAlias(root, "./src"), packagePaths], - }); - servers.push(server); - - await expect(server.pluginContainer.resolveId("~/demo", appImporter)).resolves.toMatchObject({ - id: realpathSync.native(join(root, "src/demo.ts")), - }); - await expect( - server.pluginContainer.resolveId("~/demo", packageImporter), - ).resolves.toMatchObject({ - id: realpathSync.native(join(root, "lib/src/demo.ts")), - }); - }); +it("resolves ~ in stylesheets, which are resolved without user plugins", async () => { + const root = createProject(); + writeFileSync(join(root, "src/theme.css"), "body { color: red; }"); + writeFileSync(join(root, "src/app.css"), '@import "~/theme.css";'); + writeFileSync(join(root, "src/main.js"), 'import "./app.css";'); - it("resolves entry aliases without an importer and ignores similar bare ids", async () => { - const { root } = createProject(); - const resolver = resolverFor(root); + const result = (await build({ + configFile: false, + logLevel: "silent", + root, + plugins: [appRootAlias(root, "./src")], + build: { write: false, rollupOptions: { input: join(root, "src/main.js") } }, + })) as { output: { fileName: string; source?: unknown }[] }; - await expect(resolver.resolveId("~")).resolves.toEqual({ - id: join(root, "src"), - }); - await expect(resolver.resolveId("~package/example")).resolves.toBe(null); - }); + const css = result.output.find(chunk => chunk.fileName.endsWith(".css")); + expect(String(css?.source)).toContain("color:red"); }); diff --git a/packages/start/src/config/app-root-alias.ts b/packages/start/src/config/app-root-alias.ts index 9c863dc0c..a3fca0801 100644 --- a/packages/start/src/config/app-root-alias.ts +++ b/packages/start/src/config/app-root-alias.ts @@ -3,8 +3,19 @@ import { dirname, isAbsolute, join, resolve } from "node:path"; import { normalizePath, type Plugin } from "vite"; /** - * Provides SolidStart's `~` app-root alias without claiming imports made by - * other workspace packages. Those packages may define their own `~` mapping + * `~/app.css`, `~/logo.svg`, ... but not `~/lib/api.ts` or `~/components/Counter`. + * + * CSS `@import`, `url()`, preprocessor imports and `new URL(..., import.meta.url)` + * are resolved by internal Vite resolvers that only run the alias plugin, never + * user plugins, so those ids can never reach `resolveId` below. Keeping non-module + * ids on a plain alias preserves `~` in stylesheets and asset URLs, where it always + * means the app root. + */ +const NON_MODULE_ID = /^~\/([^?#]*\.(?![cm]?[jt]sx?(?:[?#]|$))[^./?#]+(?:[?#].*)?)$/; + +/** + * Provides SolidStart's `~` app-root alias without claiming module imports made + * by other workspace packages. Those packages may map `~` to their own root * through an importer-aware resolver such as vite-tsconfig-paths. */ export function appRootAlias(projectRoot: string, appRoot: string): Plugin { @@ -40,6 +51,9 @@ export function appRootAlias(projectRoot: string, appRoot: string): Plugin { return { name: "solid-start:app-root-alias", enforce: "pre", + config() { + return { resolve: { alias: [{ find: NON_MODULE_ID, replacement: `${appDir}/$1` }] } }; + }, async resolveId(id, importer, options) { if (id !== "~" && !id.startsWith("~/")) return null; if (importer && isForeignImporter(importer)) return null;