Files
windmill/cli/test/raw_app_svelte_module_unit.test.ts
Ruben Fiszel 9c37b0217c fix(cli): compile runes in .svelte.ts / .svelte.js modules (#10400)
* fix(cli): compile runes in .svelte.ts / .svelte.js modules

The svelte plugin only ran on `/\.svelte$/`, so a rune module like
`lib.svelte.ts` was bundled as plain TypeScript: the types were stripped
and `$state(0)` survived as a call to an undefined global, blowing up at
runtime with "ReferenceError: $state is not defined".

Route those files through `compileModule`. It parses with plain acorn and
chokes on TypeScript, so types come off first via esbuild's transform —
which is what vite-plugin-svelte gets for free by running after Vite's own
esbuild transform.

`wmill app dev` picks this up too; watch mode builds its plugin list with
the same `createFrameworkPlugins`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: pin ui_builder to 1ffb28e

Picks up the matching rune-module fix in the in-editor builder
(windmill-labs/windmill-code-ui-builder#24), so `.svelte.ts` modules
compile in the editor as well as through the CLI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cli): compile raw apps with the app's own svelte compiler

Svelte 5.52.0 moved delegated event handlers off `element.__click` onto a
Symbol-keyed map. A raw app supplies its own Svelte *runtime* via
package.json, but `import("svelte/compiler")` resolves against the CLI,
whose own svelte floats independently — so the two can land on opposite
sides of that change and the app builds, renders, and has every
onclick/oninput silently dead.

Resolve the compiler from the app's node_modules instead, so compiler and
runtime are the same install by construction, and raise the CLI's own
floor past the break for the fallback path.

Also pin ui_builder to 013bf67, which carries the matching fix for the
in-editor builder (windmill-labs/windmill-code-ui-builder#25), and move
the Svelte raw-app template onto the same range. Those two go together:
the new builder rejects a runtime that sits on the far side of the ABI
break from its compiler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 11:13:16 +02:00

119 lines
3.2 KiB
TypeScript

/**
* `lib.svelte.ts` / `lib.svelte.js` modules are plain modules that may use
* runes. They need `svelte.compileModule`; without it esbuild happily bundles
* `$state(...)` as an ordinary call and the app dies at runtime with
* "ReferenceError: $state is not defined".
*/
import { afterAll, beforeAll, describe, 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 } from "../src/commands/app/bundle.ts";
let tempDir: string;
let originalCwd: string;
// Runes used as bare calls, ignoring the `$state(...)` mentions inside Svelte's
// own warning message template literals.
function bareRuneCalls(js: string): string[] {
return [...js.matchAll(/(^|[^.\w$`])\$(state|derived|effect|props)\s*\(/g)].map(
(m) => m[0]
);
}
function writeApp(files: Record<string, string>) {
for (const [name, content] of Object.entries(files)) {
fs.writeFileSync(path.join(tempDir, name), content, "utf-8");
}
}
async function bundle(entry: string): Promise<string> {
const { js } = await createBundle({
entryPoint: path.join(tempDir, entry),
minify: false,
sourcemap: false,
});
return js;
}
beforeAll(() => {
originalCwd = process.cwd();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "raw-app-svelte-module-"));
fs.writeFileSync(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "app", private: true, dependencies: { svelte: "*" } }),
"utf-8"
);
// Reuse the CLI's own svelte install instead of paying for an npm install;
// `ensureNodeModules` only checks that the directory is there.
fs.symlinkSync(
path.join(originalCwd, "node_modules"),
path.join(tempDir, "node_modules")
);
process.chdir(tempDir);
});
afterAll(() => {
process.chdir(originalCwd);
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe("svelte plugin: .svelte.ts modules", () => {
test("compiles runes in a TypeScript rune module and the bundle runs", async () => {
writeApp({
"lib.svelte.ts": `export class Cycle<T> {
#options: T[] = [];
#index = $state(0);
current = $derived(this.#options[this.#index]);
constructor(options: T[]) {
this.#options = options;
}
next() {
this.#index = (this.#index + 1) % this.#options.length;
}
}
`,
"ts_entry.ts": `import { Cycle } from './lib.svelte';
const cycle = new Cycle(['a', 'b', 'c']);
cycle.next();
(globalThis as any).__cycleResult = cycle.current;
`,
});
const js = await bundle("ts_entry.ts");
expect(bareRuneCalls(js)).toEqual([]);
// Runtime is the real check: unfixed, this throws
// "ReferenceError: $state is not defined".
new Function(js)();
expect((globalThis as any).__cycleResult).toBe("b");
});
test("compiles runes in a JavaScript rune module", async () => {
writeApp({
"counter.svelte.js": `export const counter = $state({ n: 0 });
export function bump() {
counter.n += 1;
}
`,
"js_entry.ts": `import { counter, bump } from './counter.svelte.js';
bump();
bump();
(globalThis as any).__counterResult = counter.n;
`,
});
const js = await bundle("js_entry.ts");
expect(bareRuneCalls(js)).toEqual([]);
new Function(js)();
expect((globalThis as any).__counterResult).toBe(2);
});
});