diff --git a/.changeset/lingui-macro-windows-hang.md b/.changeset/lingui-macro-windows-hang.md new file mode 100644 index 0000000000..05fd4f1899 --- /dev/null +++ b/.changeset/lingui-macro-windows-hang.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes the admin UI hanging indefinitely on "Loading EmDash…" in local dev on Windows, with no visible error. The Lingui macro compiler used to build admin source in dev mode compared file paths using a Windows-style backslash path against Vite's forward-slash-normalized module ids, so the comparison always failed and macro compilation silently never ran — shipping raw, uncompiled `@lingui/*/macro` imports to the browser, which then threw during hydration. A second, previously-masked bug in the same code path passed a raw Windows drive-letter path to a dynamic `import()`, which Node's ESM loader rejects. diff --git a/packages/core/src/astro/integration/vite-config.ts b/packages/core/src/astro/integration/vite-config.ts index 8a02c83c18..46a8a52c14 100644 --- a/packages/core/src/astro/integration/vite-config.ts +++ b/packages/core/src/astro/integration/vite-config.ts @@ -5,10 +5,10 @@ * Vite-specific configuration for EmDash. */ -import { existsSync } from "node:fs"; +import { existsSync, realpathSync } from "node:fs"; import { createRequire } from "node:module"; -import { dirname, isAbsolute, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import type { AstroConfig } from "astro"; import type { Plugin } from "vite"; @@ -79,7 +79,9 @@ const LOCALE_MESSAGES_RE = /[/\\]([a-z]{2}(?:-[A-Z]{2})?)[/\\]messages\.mjs$/; function linguiMacroPlugin(adminSourcePath: string, adminDistPath: string): Plugin { // Resolve @babel/core from admin's devDependencies, not core's. const adminRequire = createRequire(resolve(adminDistPath, "index.js")); - const babelCorePath = adminRequire.resolve("@babel/core"); + // import() requires a file:// URL for absolute paths on Windows — a raw + // drive-letter path throws ERR_UNSUPPORTED_ESM_URL_SCHEME. + const babelCoreUrl = pathToFileURL(adminRequire.resolve("@babel/core")).href; return { name: "emdash-lingui-macro", @@ -96,7 +98,7 @@ function linguiMacroPlugin(adminSourcePath: string, adminDistPath: string): Plug }, async transform(code, id) { if (!id.startsWith(adminSourcePath) || !code.includes("@lingui")) return; - const { transformAsync } = (await import(babelCorePath)) as typeof import("@babel/core"); + const { transformAsync } = (await import(babelCoreUrl)) as typeof import("@babel/core"); const result = await transformAsync(code, { filename: id, plugins: ["@lingui/babel-plugin-lingui-macro"], @@ -143,7 +145,13 @@ function resolveAdminSource(projectRoot: string): string | undefined { try { if (existsSync(srcEntry) && isInside(repoRoot, projectRoot)) { - return resolve(packageRoot, "src"); + // Vite normalizes module ids to forward slashes and their on-disk + // casing before calling plugin hooks; on Windows this can differ + // from path.resolve's backslash output and from the casing the + // current process happened to launch with. Match both normalizations + // here so id.startsWith(adminSourcePath) in linguiMacroPlugin + // doesn't silently fail the prefix check. + return realpathSync(resolve(packageRoot, "src")).split(sep).join("/"); } } catch { // Not in local repo — fall back to dist diff --git a/packages/core/tests/unit/astro/vite-config.test.ts b/packages/core/tests/unit/astro/vite-config.test.ts index e8cc9d74db..2728352ee7 100644 --- a/packages/core/tests/unit/astro/vite-config.test.ts +++ b/packages/core/tests/unit/astro/vite-config.test.ts @@ -54,6 +54,19 @@ describe("createViteConfig admin aliasing", () => { expect(replacement).toMatch(adminSourcePattern); }); + // Regression: Vite normalizes module ids to forward slashes (and, on + // case-insensitive filesystems, to their on-disk casing) before calling + // plugin hooks. The Lingui macro transform compares its own id against + // this same alias path with a plain id.startsWith(adminSourcePath) check, + // so a backslash-separated path here makes that check always fail — + // silently skipping macro compilation for every admin source file. + it("returns the admin source path with forward slashes only", () => { + const config = buildConfig(monorepoDemoRoot); + const replacement = getAdminAliasReplacement(config); + + expect(replacement).not.toMatch(/\\/); + }); + it("uses built admin dist for external app dev", () => { const config = buildConfig(externalProjectRoot); const replacement = getAdminAliasReplacement(config);