From 3ee745192c510b90a41499972fca025cf8e33a90 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 28 Oct 2026 06:08:28 +0100 Subject: [PATCH] fix(cli): resolve /ui/ in app dev and lint, bare ui/ imports from the app (#12050) * fix(cli): resolve /ui/ in app dev and lint, and its bare imports from the app Co-Authored-By: Claude Opus 5.5 * fix(cli): match shared ui/ files by real path so a symlinked ui/ still redirects Co-Authored-By: Claude Opus 5.5 * fix(cli): keep relative css @import in shared ui/ resolving next to the file Co-Authored-By: Claude Opus 5.5 * fix(cli): let esbuild resolve relative css @import in ui/ so suffixes keep working Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cli/src/commands/app/bundle.ts | 120 ++++++++++++++++-------- cli/src/commands/app/dev.ts | 9 ++ cli/src/commands/app/lint.ts | 3 +- cli/test/raw_app_shared_ui_unit.test.ts | 58 ++++++++++++ 4 files changed, 151 insertions(+), 39 deletions(-) create mode 100644 cli/test/raw_app_shared_ui_unit.test.ts diff --git a/cli/src/commands/app/bundle.ts b/cli/src/commands/app/bundle.ts index 287d01ebed..fb329a4de3 100644 --- a/cli/src/commands/app/bundle.ts +++ b/cli/src/commands/app/bundle.ts @@ -285,6 +285,84 @@ export async function ensureNodeModules(appDir?: string): Promise { } } +/** + * The workspace's shared `ui/` folder for an app: `ui/` next to the nearest + * wmill.yaml above `appDir`, the root `wmill app push` and `wmill app bundle` + * run from. Undefined when no wmill.yaml is found. + */ +export function findSharedUiDir(appDir: string): string | undefined { + let dir = path.resolve(appDir); + while (true) { + if (fs.existsSync(path.join(dir, "wmill.yaml"))) { + return path.join(dir, "ui"); + } + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + +/** + * Resolves `/ui/` imports to the workspace's shared `ui/` folder, and bare + * package imports made from those files against the app's own node_modules: the + * server-side bundler places `ui/` inside the app, so walking up from the shared + * folder instead would miss the app's packages or load a second copy of them + * (two Reacts break hooks at runtime). + */ +export function createSharedUiPlugin(sharedUiDir: string, appDir: string): any { + // Real paths throughout: esbuild follows symlinks for relative imports, so a + // symlinked ui/ would otherwise give its files two different prefixes. + const uiPrefix = fs.realpathSync(sharedUiDir) + path.sep; + const resolveDir = path.resolve(appDir); + return { + name: "wmill-shared-ui", + setup(build: any) { + build.onResolve({ filter: /^\/ui\// }, (args: any) => { + const rel = args.path.slice("/ui/".length); + const candidates = [rel]; + if (!path.extname(rel)) { + candidates.push( + rel + ".tsx", + rel + ".ts", + rel + ".jsx", + rel + ".js", + rel + ".css", + path.join(rel, "index.tsx"), + path.join(rel, "index.ts"), + ); + } + for (const c of candidates) { + const full = path.join(sharedUiDir, c); + if (fs.existsSync(full)) { + return { path: fs.realpathSync(full) }; + } + } + return { + errors: [ + { + text: `Could not resolve shared UI import "${args.path}" in ${sharedUiDir}`, + }, + ], + }; + }); + build.onResolve({ filter: /^[^./]/ }, async (args: any) => { + if (!args.importer.startsWith(uiPrefix)) return undefined; + // CSS reads `@import "base.css"` as relative first; keep that when the + // sibling resolves, and only send package names to the app. + if (args.kind === "import-rule" || args.kind === "url-token") { + const sibling = await build.resolve("./" + args.path, { + resolveDir: args.resolveDir, + kind: args.kind, + }); + if (sibling.errors.length === 0) return sibling; + } + // No importer is passed, so this nested resolve does not re-enter here. + return build.resolve(args.path, { resolveDir, kind: args.kind }); + }); + }, + }; +} + /** * Creates an esbuild bundle for the app * @param options Bundle configuration options @@ -365,44 +443,10 @@ export async function createBundle( }, }; - const sharedUiPlugins: any[] = []; - if (options.sharedUiDir && fs.existsSync(options.sharedUiDir)) { - const sharedUiDir = options.sharedUiDir; - sharedUiPlugins.push({ - name: "wmill-shared-ui", - setup(build: any) { - // Intercept imports of /ui/ and resolve to the workspace ui/ folder. - build.onResolve({ filter: /^\/ui\// }, (args: any) => { - const rel = args.path.slice("/ui/".length); - const candidates = [rel]; - if (!path.extname(rel)) { - candidates.push( - rel + ".tsx", - rel + ".ts", - rel + ".jsx", - rel + ".js", - rel + ".css", - path.join(rel, "index.tsx"), - path.join(rel, "index.ts"), - ); - } - for (const c of candidates) { - const full = path.join(sharedUiDir, c); - if (fs.existsSync(full)) { - return { path: full }; - } - } - return { - errors: [ - { - text: `Could not resolve shared UI import "${args.path}" in ${sharedUiDir}`, - }, - ], - }; - }); - }, - }); - } + const sharedUiPlugins: any[] = + options.sharedUiDir && fs.existsSync(options.sharedUiDir) + ? [createSharedUiPlugin(options.sharedUiDir, appDir)] + : []; const buildOptions = { ...DEFAULT_BUILD_OPTIONS, diff --git a/cli/src/commands/app/dev.ts b/cli/src/commands/app/dev.ts index 6957899a36..a0483b7dba 100644 --- a/cli/src/commands/app/dev.ts +++ b/cli/src/commands/app/dev.ts @@ -19,8 +19,10 @@ import { detectAuthGatewayChallenge } from "../../utils/http_guards.ts"; import { WebSocket, WebSocketServer } from "ws"; import { createFrameworkPlugins, + createSharedUiPlugin, detectFrameworks, ensureNodeModules, + findSharedUiDir, getDevBuildOptions, } from "./bundle.ts"; import { wmillTsDev as wmillTs } from "./wmillTsDev.ts"; @@ -593,6 +595,12 @@ async function dev(opts: DevOptions, appFolder?: string) { // Load framework-specific plugins (svelte) based on package.json const frameworkPlugins = await createFrameworkPlugins(appDir); + const sharedUiDir = findSharedUiDir(appDir); + const sharedUiPlugins = + sharedUiDir && fs.existsSync(sharedUiDir) + ? [createSharedUiPlugin(sharedUiDir, appDir)] + : []; + const wmillPlugin = { name: "wmill-virtual", setup(build: any) { @@ -663,6 +671,7 @@ async function dev(opts: DevOptions, appFolder?: string) { }, }, wmillPlugin, + ...sharedUiPlugins, ], }); diff --git a/cli/src/commands/app/lint.ts b/cli/src/commands/app/lint.ts index c90a22105d..fbdf9c27b2 100644 --- a/cli/src/commands/app/lint.ts +++ b/cli/src/commands/app/lint.ts @@ -6,7 +6,7 @@ import { colors } from "@cliffy/ansi/colors"; import * as log from "../../core/log.ts"; import { yamlParseFile } from "../../utils/yaml.ts"; import { GlobalOptions } from "../../types.ts"; -import { createBundle, detectFrameworks } from "./bundle.ts"; +import { createBundle, detectFrameworks, findSharedUiDir } from "./bundle.ts"; import { APP_BACKEND_FOLDER } from "./app_metadata.ts"; import { loadRunnablesFromBackend } from "./raw_apps.ts"; import { inferSchema } from "../../utils/metadata.ts"; @@ -173,6 +173,7 @@ async function validateBuild( entryPoint, production: true, minify: false, + sharedUiDir: findSharedUiDir(appDir), }); log.info(colors.green("✅ Build successful")); diff --git a/cli/test/raw_app_shared_ui_unit.test.ts b/cli/test/raw_app_shared_ui_unit.test.ts new file mode 100644 index 0000000000..24e5282e87 --- /dev/null +++ b/cli/test/raw_app_shared_ui_unit.test.ts @@ -0,0 +1,58 @@ +/** + * `/ui/...` imports, driven through `createBundle` the way `app dev` and + * `app lint` find the shared folder: from the app up to its wmill.yaml. + */ + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { createBundle, findSharedUiDir } from "../src/commands/app/bundle.ts"; + +let root: string; +let originalCwd: string; + +function write(rel: string, content: string) { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content, "utf-8"); +} + +beforeAll(() => { + originalCwd = process.cwd(); + root = fs.mkdtempSync(path.join(os.tmpdir(), "raw-app-shared-ui-")); + write("wmill.yaml", "defaultTs: bun\n"); + // ui/ as a symlink, with the bare import one relative hop away: esbuild reaches + // inner.ts by its real path, not through the link. + write("shared/button.ts", `export { label } from "./inner";\n`); + write("shared/inner.ts", `import { who } from "pkg";\nimport "./styles.css";\nexport const label = who;\n`); + write("shared/styles.css", `@import "base.css?theme";\n`); + write("shared/base.css", `.from-ui-css { color: red; }\n`); + fs.symlinkSync(path.join(root, "shared"), path.join(root, "ui")); + write("f/demo.raw_app/package.json", `{"name":"demo"}`); + write("f/demo.raw_app/index.tsx", `import { label } from "/ui/button";\nconsole.log(label);\n`); + write("f/demo.raw_app/node_modules/pkg/index.js", `export const who = "from-app";\n`); + // A copy above ui/ must not win: it would be a second instance of the package. + write("node_modules/pkg/index.js", `export const who = "from-root";\n`); + process.chdir(path.join(root, "f/demo.raw_app")); +}); + +afterAll(() => { + process.chdir(originalCwd); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("bare imports in ui/ resolve from the app's node_modules", async () => { + const appDir = path.join(root, "f/demo.raw_app"); + const sharedUiDir = findSharedUiDir(appDir); + expect(sharedUiDir).toBe(path.join(root, "ui")); + + const { js, css } = await createBundle({ + entryPoint: path.join(appDir, "index.tsx"), + minify: false, + sharedUiDir, + }); + expect(js).toContain("from-app"); + expect(js).not.toContain("from-root"); + expect(css).toContain("from-ui-css"); +});