From 7e572e1abb207e21181188c1a227c7a3e562c638 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 23:23:59 +0000 Subject: [PATCH 1/2] feat(rescript): add ReScript source location tracking Adds a new @treelocator/vite-plugin-rescript package and a rescript adapter shim so Alt+click on a ReScript-React component points at the original .res file instead of the compiled .res.js intermediate. The plugin runs over .res.js files emitted with `"jsx": { "preserve": true }`, looks each JSX opening tag up via the .res.js.map source map, and injects __source attributes (fileName/lineNumber/columnNumber) that flow through React's classic JSX runtime into fiber._debugSource. It also appends `make.displayName = "ModuleName"` after the top-level `let make = ...` so the fiber reports the file's module name instead of the literal `make`. Includes 17 unit tests for the plugin (filtering, source-map remapping, displayName injection, missing-map handling, dev-mode gating, and HMR cache invalidation), a Playwright ancestry spec for the new demo, and a vite-rescript demo app with pre-compiled .res.js + .res.js.map fixtures so the e2e flow runs without a rescript binary in CI. --- apps/playwright/scripts/run-ancestry-tests.sh | 4 +- .../tests/ancestry/rescript.spec.ts | 78 ++++ apps/playwright/tests/consts.ts | 1 + apps/vite-rescript/index.html | 12 + apps/vite-rescript/package.json | 25 ++ apps/vite-rescript/rescript.json | 20 + apps/vite-rescript/scripts/build-fixtures.mjs | 101 +++++ apps/vite-rescript/src/App.jsx | 17 + apps/vite-rescript/src/Button.res | 6 + apps/vite-rescript/src/Button.res.js | 11 + apps/vite-rescript/src/Button.res.js.map | 9 + apps/vite-rescript/src/Card.res | 7 + apps/vite-rescript/src/Card.res.js | 16 + apps/vite-rescript/src/Card.res.js.map | 9 + apps/vite-rescript/src/main.jsx | 12 + apps/vite-rescript/vite.config.js | 50 +++ packages/runtime/src/adapters/rescript.ts | 17 + packages/vite-plugin-rescript/.gitignore | 2 + packages/vite-plugin-rescript/package.json | 68 ++++ .../vite-plugin-rescript/src/index.test.ts | 384 ++++++++++++++++++ packages/vite-plugin-rescript/src/index.ts | 269 ++++++++++++ packages/vite-plugin-rescript/tsconfig.json | 17 + pnpm-lock.yaml | 182 ++++++--- 23 files changed, 1258 insertions(+), 59 deletions(-) create mode 100644 apps/playwright/tests/ancestry/rescript.spec.ts create mode 100644 apps/vite-rescript/index.html create mode 100644 apps/vite-rescript/package.json create mode 100644 apps/vite-rescript/rescript.json create mode 100644 apps/vite-rescript/scripts/build-fixtures.mjs create mode 100644 apps/vite-rescript/src/App.jsx create mode 100644 apps/vite-rescript/src/Button.res create mode 100644 apps/vite-rescript/src/Button.res.js create mode 100644 apps/vite-rescript/src/Button.res.js.map create mode 100644 apps/vite-rescript/src/Card.res create mode 100644 apps/vite-rescript/src/Card.res.js create mode 100644 apps/vite-rescript/src/Card.res.js.map create mode 100644 apps/vite-rescript/src/main.jsx create mode 100644 apps/vite-rescript/vite.config.js create mode 100644 packages/runtime/src/adapters/rescript.ts create mode 100644 packages/vite-plugin-rescript/.gitignore create mode 100644 packages/vite-plugin-rescript/package.json create mode 100644 packages/vite-plugin-rescript/src/index.test.ts create mode 100644 packages/vite-plugin-rescript/src/index.ts create mode 100644 packages/vite-plugin-rescript/tsconfig.json diff --git a/apps/playwright/scripts/run-ancestry-tests.sh b/apps/playwright/scripts/run-ancestry-tests.sh index d2cfceeb..a6c6f603 100755 --- a/apps/playwright/scripts/run-ancestry-tests.sh +++ b/apps/playwright/scripts/run-ancestry-tests.sh @@ -43,7 +43,7 @@ cleanup() { fi done # Kill any remaining processes on our ports - for port in 3343 3344 3345 3346 3347 3350; do + for port in 3343 3344 3345 3346 3347 3350 3353; do lsof -ti:$port | xargs kill -9 2>/dev/null || true done echo -e "${GREEN}Cleanup complete${NC}" @@ -120,6 +120,7 @@ start_app "vite-solid-project" "Solid" 3345 start_app "vite-preact-project" "Preact" 3346 start_app "vite-svelte-project" "Svelte" 3347 start_app "vite-vue-project" "Vue" 3350 +start_app "vite-rescript" "ReScript" 3353 echo "" echo -e "${YELLOW}Waiting for apps to be ready...${NC}" @@ -133,6 +134,7 @@ wait_for_url "http://localhost:3345" "Solid" wait_for_url "http://localhost:3346" "Preact" wait_for_url "http://localhost:3347" "Svelte" wait_for_url "http://localhost:3350" "Vue" +wait_for_url "http://localhost:3353" "ReScript" echo "" echo -e "${GREEN}All apps are ready!${NC}" diff --git a/apps/playwright/tests/ancestry/rescript.spec.ts b/apps/playwright/tests/ancestry/rescript.spec.ts new file mode 100644 index 00000000..bdc2b3ed --- /dev/null +++ b/apps/playwright/tests/ancestry/rescript.spec.ts @@ -0,0 +1,78 @@ +import { test, expect, Page } from "@playwright/test"; +import { projects } from "../consts"; + +async function getAncestryPath(page: Page, selector: string): Promise { + return await page.evaluate((sel) => { + const api = (window as any).__treelocator__; + if (!api) return null; + return api.getPath(sel); + }, selector); +} + +async function getAncestryData(page: Page, selector: string): Promise { + return await page.evaluate((sel) => { + const api = (window as any).__treelocator__; + if (!api) return null; + return api.getAncestry(sel); + }, selector); +} + +async function waitForLocator(page: Page): Promise { + await page.waitForFunction( + () => typeof (window as any).__treelocator__ !== "undefined", + { timeout: 10000 } + ); +} + +test.describe("ReScript ancestry chain", () => { + test.beforeEach(async ({ page }) => { + await page.goto(projects.rescript); + await waitForLocator(page); + }); + + test("reports the module name (Button), not the literal `make`", async ({ page }) => { + const path = await getAncestryPath(page, ".submit-button"); + expect(path).not.toBeNull(); + expect(path).toContain("Button"); + expect(path).not.toMatch(/\bmake\b/); + }); + + test("reports the wrapping Card module", async ({ page }) => { + const path = await getAncestryPath(page, ".submit-button"); + expect(path).not.toBeNull(); + expect(path).toContain("Card"); + }); + + test("file paths point at .res files, not .res.js", async ({ page }) => { + const data = await getAncestryData(page, ".submit-button"); + expect(data).not.toBeNull(); + + const buttonItem = data!.find( + (item) => + item.componentName === "Button" || + item.ownerComponents?.some((c: any) => c.name === "Button") + ); + expect(buttonItem).toBeDefined(); + + const filePath: string | undefined = buttonItem?.filePath; + expect(filePath).toBeTruthy(); + // Must end in .res, not .res.js + expect(filePath).toMatch(/Button\.res$/); + }); + + test("line numbers are inside the .res file (not the JS output)", async ({ page }) => { + const data = await getAncestryData(page, ".submit-button"); + expect(data).not.toBeNull(); + + const buttonEntry = data!.find((item) => + item.filePath?.endsWith("Button.res") + ); + expect(buttonEntry).toBeDefined(); + // The fixture's only mapping for the JSX is original line 3 (the ; +} + +let make = Button; + +export { make }; +`, + mappings: [ + // +} diff --git a/apps/vite-rescript/src/Button.res.js b/apps/vite-rescript/src/Button.res.js new file mode 100644 index 00000000..74454f9c --- /dev/null +++ b/apps/vite-rescript/src/Button.res.js @@ -0,0 +1,11 @@ +import * as React from "react"; + +function Button(props) { + return ; +} + +let make = Button; + +export { make }; + +//# sourceMappingURL=Button.res.js.map diff --git a/apps/vite-rescript/src/Button.res.js.map b/apps/vite-rescript/src/Button.res.js.map new file mode 100644 index 00000000..47175502 --- /dev/null +++ b/apps/vite-rescript/src/Button.res.js.map @@ -0,0 +1,9 @@ +{ + "version": 3, + "sources": [ + "Button.res" + ], + "names": [], + "mappings": ";;;SAEE,gCACE", + "file": "Button.res.js" +} \ No newline at end of file diff --git a/apps/vite-rescript/src/Card.res b/apps/vite-rescript/src/Card.res new file mode 100644 index 00000000..e5cadb8d --- /dev/null +++ b/apps/vite-rescript/src/Card.res @@ -0,0 +1,7 @@ +@react.component +let make = (~title, ~children) => { +
+
{React.string(title)}
+
{children}
+
+} diff --git a/apps/vite-rescript/src/Card.res.js b/apps/vite-rescript/src/Card.res.js new file mode 100644 index 00000000..bde6866b --- /dev/null +++ b/apps/vite-rescript/src/Card.res.js @@ -0,0 +1,16 @@ +import * as React from "react"; + +function Card(props) { + return ( +
+
{props.title}
+
{props.children}
+
+ ); +} + +let make = Card; + +export { make }; + +//# sourceMappingURL=Card.res.js.map diff --git a/apps/vite-rescript/src/Card.res.js.map b/apps/vite-rescript/src/Card.res.js.map new file mode 100644 index 00000000..57a91783 --- /dev/null +++ b/apps/vite-rescript/src/Card.res.js.map @@ -0,0 +1,9 @@ +{ + "version": 3, + "sources": [ + "Card.res" + ], + "names": [], + "mappings": ";;;;IAEE;MACE;MACA", + "file": "Card.res.js" +} \ No newline at end of file diff --git a/apps/vite-rescript/src/main.jsx b/apps/vite-rescript/src/main.jsx new file mode 100644 index 00000000..c3b340a5 --- /dev/null +++ b/apps/vite-rescript/src/main.jsx @@ -0,0 +1,12 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import { setup } from '@treelocator/runtime' + +setup() + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) diff --git a/apps/vite-rescript/vite.config.js b/apps/vite-rescript/vite.config.js new file mode 100644 index 00000000..650fec8f --- /dev/null +++ b/apps/vite-rescript/vite.config.js @@ -0,0 +1,50 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import rescript from '@treelocator/vite-plugin-rescript' +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + // .res.js files contain raw JSX (because rescript.json sets + // "jsx": { "preserve": true }). Tell esbuild to parse them as JSX both + // during dev-time module loading and during the optimize-deps prescan. + esbuild: { + loader: 'jsx', + include: [/\.jsx?$/, /\.res\.js$/], + exclude: [], + }, + optimizeDeps: { + esbuildOptions: { + loader: { '.res.js': 'jsx', '.js': 'jsx' }, + }, + }, + plugins: [ + rescript(), + react({ + // Process .jsx and ReScript-compiled .res.js files (which contain JSX + // because rescript.json sets "jsx": { "preserve": true }). + include: [/\.jsx$/, /\.res\.js$/], + // Use the classic JSX runtime so the __source attributes our rescript + // plugin injects survive into the React element tree as React.createElement + // props. The automatic-runtime dev plugin builds the source object from + // AST loc instead, which would silently override our remapped values. + // @vitejs/plugin-react already enables @babel/plugin-transform-react-jsx-source + // for the classic runtime in dev mode. + jsxRuntime: 'classic', + }), + ], + resolve: { + alias: { + '@treelocator/runtime': path.resolve( + __dirname, + '../../packages/runtime/src/index.ts' + ), + '@treelocator/vite-plugin-rescript': path.resolve( + __dirname, + '../../packages/vite-plugin-rescript/src/index.ts' + ), + }, + }, +}) diff --git a/packages/runtime/src/adapters/rescript.ts b/packages/runtime/src/adapters/rescript.ts new file mode 100644 index 00000000..6b0b5af1 --- /dev/null +++ b/packages/runtime/src/adapters/rescript.ts @@ -0,0 +1,17 @@ +// ReScript adapter — re-exports the React adapter. +// +// ReScript components compile to React.createElement / jsxDEV calls and +// produce React fibers, so the React adapter handles them natively. The +// companion @treelocator/vite-plugin-rescript package injects __source +// attributes into the generated JSX pointing at .res files, and applies +// `make.displayName = "ModuleName"` so the fiber reports a friendly name +// instead of the literal `make`. +// +// Importing this file is purely a convention for projects that want to +// be explicit about ReScript support; it has no runtime effect beyond +// what the React adapter already does. +import reactAdapter, { ReactTreeNodeElement } from "./react/reactAdapter"; + +export const ReScriptTreeNodeElement = ReactTreeNodeElement; + +export default reactAdapter; diff --git a/packages/vite-plugin-rescript/.gitignore b/packages/vite-plugin-rescript/.gitignore new file mode 100644 index 00000000..1eae0cf6 --- /dev/null +++ b/packages/vite-plugin-rescript/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/packages/vite-plugin-rescript/package.json b/packages/vite-plugin-rescript/package.json new file mode 100644 index 00000000..6730c6b5 --- /dev/null +++ b/packages/vite-plugin-rescript/package.json @@ -0,0 +1,68 @@ +{ + "name": "@treelocator/vite-plugin-rescript", + "version": "0.6.0", + "description": "Vite plugin that remaps ReScript-compiled JSX positions back to the original .res file via source maps, so TreeLocatorJS / React DevTools point at the right line in the right file.", + "keywords": [ + "treelocator", + "treelocatorjs", + "rescript", + "vite-plugin", + "vite", + "react", + "source-map", + "devtools" + ], + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "type": "module", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup src/index.ts --format esm,cjs --dts --clean", + "dev": "tsup src/index.ts --format esm,cjs --dts --watch", + "test": "vitest run", + "test:dev": "vitest watch", + "ts": "tsc --noEmit" + }, + "dependencies": { + "@babel/generator": "^7.26.0", + "@babel/parser": "^7.26.0", + "@babel/traverse": "^7.26.0", + "@babel/types": "^7.26.0", + "source-map": "^0.7.4" + }, + "peerDependencies": { + "vite": ">=3" + }, + "devDependencies": { + "@types/babel__generator": "^7.6.8", + "@types/babel__traverse": "^7.20.6", + "@types/node": "^20.0.0", + "tsup": "^8.0.0", + "typescript": "^5.0.0", + "vite": "^5.0.0", + "vitest": "^2.1.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/wende/treelocatorjs.git", + "directory": "packages/vite-plugin-rescript" + }, + "author": "Krzysztof Wende", + "license": "MIT" +} diff --git a/packages/vite-plugin-rescript/src/index.test.ts b/packages/vite-plugin-rescript/src/index.test.ts new file mode 100644 index 00000000..d064b12a --- /dev/null +++ b/packages/vite-plugin-rescript/src/index.test.ts @@ -0,0 +1,384 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { SourceMapGenerator } from "source-map"; +import vitePluginRescript from "./index"; + +interface FixtureMapping { + generated: { line: number; column: number }; + original: { line: number; column: number }; + source?: string; +} + +function buildMap(file: string, mappings: FixtureMapping[]): object { + const gen = new SourceMapGenerator({ file }); + for (const m of mappings) { + gen.addMapping({ + source: m.source ?? file.replace(/\.js$/, ""), + generated: m.generated, + original: m.original, + }); + } + return JSON.parse(gen.toString()); +} + +interface SetupResult { + dir: string; + jsPath: string; + mapPath: string; + resPath: string; +} + +function writeFixture(opts: { + filename?: string; + jsCode: string; + mappings?: FixtureMapping[]; + includeMap?: boolean; +}): SetupResult { + const filename = opts.filename ?? "Button.res.js"; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vprt-")); + const jsPath = path.join(dir, filename); + const mapPath = jsPath + ".map"; + const resPath = jsPath.replace(/\.js$/, ""); + + fs.writeFileSync(jsPath, opts.jsCode); + + if (opts.includeMap !== false) { + const map = buildMap( + filename, + opts.mappings ?? [ + { generated: { line: 1, column: 0 }, original: { line: 1, column: 0 } }, + ] + ); + fs.writeFileSync(mapPath, JSON.stringify(map)); + } + + return { dir, jsPath, mapPath, resPath }; +} + +interface PluginCtx { + warn: (msg: string) => void; +} + +function makeCtx(): PluginCtx & { warnings: string[] } { + const warnings: string[] = []; + return { + warnings, + warn(msg: string) { + warnings.push(msg); + }, + }; +} + +const cleanupDirs: string[] = []; +afterEach(() => { + while (cleanupDirs.length) { + const d = cleanupDirs.pop()!; + try { + fs.rmSync(d, { recursive: true, force: true }); + } catch { + // ignore + } + } +}); + +function track(s: SetupResult): SetupResult { + cleanupDirs.push(s.dir); + return s; +} + +describe("vitePluginRescript", () => { + describe("file filtering", () => { + it("is a no-op for .tsx files", async () => { + const plugin: any = vitePluginRescript({ injectSource: true }); + const ctx = makeCtx(); + const result = await plugin.transform.call( + ctx, + "const x =
;", + "/some/path/App.tsx" + ); + expect(result).toBeNull(); + }); + + it("is a no-op for .jsx files", async () => { + const plugin: any = vitePluginRescript({ injectSource: true }); + const ctx = makeCtx(); + const result = await plugin.transform.call( + ctx, + "const x =
;", + "/some/path/App.jsx" + ); + expect(result).toBeNull(); + }); + + it("is a no-op for .vue files", async () => { + const plugin: any = vitePluginRescript({ injectSource: true }); + const ctx = makeCtx(); + const result = await plugin.transform.call( + ctx, + "", + "/some/path/App.vue" + ); + expect(result).toBeNull(); + }); + + it("strips query strings before checking the suffix", async () => { + const plugin: any = vitePluginRescript({ injectSource: true }); + const ctx = makeCtx(); + const result = await plugin.transform.call( + ctx, + "const x = 1;", + "/some/path/App.tsx?vue&type=script" + ); + expect(result).toBeNull(); + }); + }); + + describe("missing source map", () => { + it("warns once and returns null when no .map exists", async () => { + const fixture = track( + writeFixture({ + jsCode: "const x = 1;\n", + includeMap: false, + }) + ); + const plugin: any = vitePluginRescript({ injectSource: true }); + const ctx = makeCtx(); + + const r1 = await plugin.transform.call(ctx, "const x = 1;\n", fixture.jsPath); + const r2 = await plugin.transform.call(ctx, "const x = 1;\n", fixture.jsPath); + + expect(r1).toBeNull(); + expect(r2).toBeNull(); + expect(ctx.warnings).toHaveLength(1); + expect(ctx.warnings[0]).toContain("no source map"); + }); + + it("does not throw when the source map is malformed", async () => { + const fixture = track( + writeFixture({ jsCode: "const x = 1;\n", includeMap: false }) + ); + fs.writeFileSync(fixture.mapPath, "{not valid json"); + const plugin: any = vitePluginRescript({ injectSource: true }); + const ctx = makeCtx(); + const result = await plugin.transform.call( + ctx, + "const x = 1;\n", + fixture.jsPath + ); + expect(result).toBeNull(); + }); + }); + + describe("__source injection", () => { + it("injects __source pointing at the remapped .res location", async () => { + const code = `let make = (props) =>
;\n`; + // ` { + const code = `let make = () => ;\n`; + const fixture = track( + writeFixture({ + jsCode: code, + mappings: [ + { + generated: { line: 1, column: 17 }, + original: { line: 2, column: 0 }, + // Relative path with .. segments to mimic real ReScript output + source: "./Button.res", + }, + ], + }) + ); + const plugin: any = vitePluginRescript({ injectSource: true }); + const ctx = makeCtx(); + const result = await plugin.transform.call(ctx, code, fixture.jsPath); + + expect(result).not.toBeNull(); + const expectedAbs = path + .resolve(fixture.dir, "Button.res") + .split(path.sep) + .join("/"); + expect(result.code).toContain(expectedAbs); + // The relative path itself must NOT appear as the fileName value. + expect(result.code).not.toMatch(/fileName:\s*"\.\/Button\.res"/); + }); + + it("does not duplicate __source if one already exists", async () => { + const code = `let make = () =>
;\n`; + const fixture = track( + writeFixture({ + jsCode: code, + mappings: [ + { + generated: { line: 1, column: 17 }, + original: { line: 9, column: 0 }, + }, + ], + }) + ); + const plugin: any = vitePluginRescript({ injectSource: true }); + const ctx = makeCtx(); + const result = await plugin.transform.call(ctx, code, fixture.jsPath); + + // __source already present + no make.displayName needed change → may be modified + // due to displayName injection, but the original __source must be preserved + // and we must not have inserted a second one for the same element. + const text = result?.code ?? code; + const matches = text.match(/__source/g) ?? []; + expect(matches.length).toBe(1); + }); + }); + + describe("displayName injection", () => { + it("appends make.displayName for the module name", async () => { + const code = `let make = (props) =>
;\n`; + const fixture = track(writeFixture({ jsCode: code })); + const plugin: any = vitePluginRescript({ injectSource: true }); + const ctx = makeCtx(); + const result = await plugin.transform.call(ctx, code, fixture.jsPath); + + expect(result).not.toBeNull(); + expect(result.code).toMatch(/make\.displayName\s*=\s*["']Button["']/); + }); + + it("uses the file basename as the module name", async () => { + const code = `let make = (props) => ;\n`; + const fixture = track( + writeFixture({ filename: "GlassPanel.res.js", jsCode: code }) + ); + const plugin: any = vitePluginRescript({ injectSource: true }); + const ctx = makeCtx(); + const result = await plugin.transform.call(ctx, code, fixture.jsPath); + + expect(result?.code).toMatch( + /make\.displayName\s*=\s*["']GlassPanel["']/ + ); + }); + + it("does not inject displayName when none of the top-level vars is `make`", async () => { + const code = `let other = (props) =>
;\n`; + const fixture = track(writeFixture({ jsCode: code })); + const plugin: any = vitePluginRescript({ injectSource: true }); + const ctx = makeCtx(); + const result = await plugin.transform.call(ctx, code, fixture.jsPath); + + const text = result?.code ?? code; + expect(text).not.toMatch(/displayName/); + }); + + it("does not double-assign displayName if one already exists", async () => { + const code = `let make = (props) =>
;\nmake.displayName = "Custom";\n`; + const fixture = track(writeFixture({ jsCode: code })); + const plugin: any = vitePluginRescript({ injectSource: true }); + const ctx = makeCtx(); + const result = await plugin.transform.call(ctx, code, fixture.jsPath); + + const text = result?.code ?? code; + const matches = text.match(/displayName\s*=/g) ?? []; + expect(matches.length).toBe(1); + expect(text).toContain('"Custom"'); + }); + + it("ignores non-top-level `make` declarations", async () => { + const code = `function wrap() { let make = () =>
; return make; }\n`; + const fixture = track(writeFixture({ jsCode: code })); + const plugin: any = vitePluginRescript({ injectSource: true }); + const ctx = makeCtx(); + const result = await plugin.transform.call(ctx, code, fixture.jsPath); + + const text = result?.code ?? code; + // Inner `make` should not trigger top-level displayName assignment. + expect(text).not.toMatch(/^make\.displayName/m); + }); + }); + + describe("development gating", () => { + it("is a no-op when injectSource is false even for .res.js", async () => { + const code = `let make = () =>
;\n`; + const fixture = track(writeFixture({ jsCode: code })); + const plugin: any = vitePluginRescript({ injectSource: false }); + const ctx = makeCtx(); + const result = await plugin.transform.call(ctx, code, fixture.jsPath); + expect(result).toBeNull(); + }); + + it("respects vite serve mode by default (no explicit option)", async () => { + const code = `let make = () =>
;\n`; + const fixture = track(writeFixture({ jsCode: code })); + const plugin: any = vitePluginRescript(); + // Simulate `vite build` + plugin.configResolved({ command: "build" } as any); + const ctx = makeCtx(); + const buildResult = await plugin.transform.call(ctx, code, fixture.jsPath); + expect(buildResult).toBeNull(); + + // Simulate `vite serve` (development) + plugin.configResolved({ command: "serve" } as any); + const serveResult = await plugin.transform.call(ctx, code, fixture.jsPath); + expect(serveResult).not.toBeNull(); + expect(serveResult.code).toContain("__source"); + }); + }); + + describe("HMR cache invalidation", () => { + it("clears the cached SourceMapConsumer when the .res file changes", async () => { + const code = `let make = () =>
;\n`; + const fixture = track(writeFixture({ jsCode: code })); + + const plugin: any = vitePluginRescript({ injectSource: true }); + const ctx = makeCtx(); + + // Prime the cache. + const first = await plugin.transform.call(ctx, code, fixture.jsPath); + expect(first).not.toBeNull(); + expect(first.code).toMatch(/lineNumber:\s*1/); + + // Replace the source map: same JS column 17 now maps to line 42. + const newMap = buildMap("Button.res.js", [ + { generated: { line: 1, column: 17 }, original: { line: 42, column: 0 } }, + ]); + fs.writeFileSync(fixture.mapPath, JSON.stringify(newMap)); + + // Without invalidation, the old consumer is reused. Confirm that. + const stale = await plugin.transform.call(ctx, code, fixture.jsPath); + expect(stale.code).not.toMatch(/lineNumber:\s*42/); + + // Now signal HMR for the .res file. + plugin.handleHotUpdate({ file: fixture.resPath }); + + const fresh = await plugin.transform.call(ctx, code, fixture.jsPath); + expect(fresh.code).toMatch(/lineNumber:\s*42/); + }); + }); +}); diff --git a/packages/vite-plugin-rescript/src/index.ts b/packages/vite-plugin-rescript/src/index.ts new file mode 100644 index 00000000..9d2cf756 --- /dev/null +++ b/packages/vite-plugin-rescript/src/index.ts @@ -0,0 +1,269 @@ +import type { Plugin } from "vite"; +import * as fs from "node:fs"; +import * as nodePath from "node:path"; +import { parse } from "@babel/parser"; +import _traverse from "@babel/traverse"; +import _generate from "@babel/generator"; +import * as t from "@babel/types"; +import { SourceMapConsumer } from "source-map"; + +// Babel ships dual ESM/CJS where the CJS interop wraps the export under .default. +const traverse: typeof _traverse = + ((_traverse as unknown) as { default?: typeof _traverse }).default ?? _traverse; +const generate: typeof _generate = + ((_generate as unknown) as { default?: typeof _generate }).default ?? _generate; + +export interface VitePluginRescriptOptions { + /** + * Force-enable __source injection regardless of Vite mode. + * By default, injection only runs during `vite serve` (development). + */ + injectSource?: boolean; +} + +interface ConsumerEntry { + consumer: SourceMapConsumer; + jsDir: string; +} + +interface TransformContext { + warn(msg: string): void; +} + +const RES_JS_SUFFIX = ".res.js"; + +/** + * Vite plugin that injects __source attributes into JSX produced by ReScript + * with `"jsx": { "preserve": true }`. The attributes point at the original + * .res file (resolved via the .res.js.map source map) so React DevTools and + * TreeLocatorJS can show the correct file/line for each component. + * + * Also appends `make.displayName = "ModuleName"` after the top-level + * `let make = ...` so the React component name is the module name instead + * of the literal `make`. + */ +export function vitePluginRescript( + options: VitePluginRescriptOptions = {} +): Plugin { + const consumerCache = new Map(); + const warnedNoMap = new Set(); + let isDev = false; + + async function getConsumer(jsFilePath: string): Promise { + const cached = consumerCache.get(jsFilePath); + if (cached) return cached; + + const mapPath = jsFilePath + ".map"; + if (!fs.existsSync(mapPath)) return null; + + let rawMap: unknown; + try { + rawMap = JSON.parse(fs.readFileSync(mapPath, "utf-8")); + } catch { + return null; + } + + const consumer = await new SourceMapConsumer(rawMap as never); + const entry: ConsumerEntry = { + consumer, + jsDir: nodePath.dirname(jsFilePath), + }; + consumerCache.set(jsFilePath, entry); + return entry; + } + + function invalidateJs(jsPath: string) { + const entry = consumerCache.get(jsPath); + if (entry) { + entry.consumer.destroy(); + consumerCache.delete(jsPath); + } + warnedNoMap.delete(jsPath); + } + + function invalidateRes(resPath: string) { + // Drop any cached entry whose .res.js shares the same basename as the + // changed .res. ReScript regenerates Foo.res.js + Foo.res.js.map together + // on every recompile, so the cached SourceMapConsumer becomes stale. + const baseName = nodePath.basename(resPath, ".res"); + for (const cachedPath of [...consumerCache.keys()]) { + if (nodePath.basename(cachedPath, RES_JS_SUFFIX) === baseName) { + invalidateJs(cachedPath); + } + } + } + + return { + name: "vite-plugin-rescript", + enforce: "pre", + + configResolved(config) { + isDev = config.command === "serve"; + }, + + async transform(this: TransformContext, code: string, id: string) { + const filePath = id.split("?")[0]; + if (!filePath.endsWith(RES_JS_SUFFIX)) return null; + + const shouldInject = options.injectSource ?? isDev; + if (!shouldInject) return null; + + const entry = await getConsumer(filePath); + if (!entry) { + if (!warnedNoMap.has(filePath)) { + warnedNoMap.add(filePath); + this.warn( + `vite-plugin-rescript: no source map (.map) found alongside ${filePath}. ` + + `Make sure rescript.json sets "jsx": { "version": 4, "preserve": true } and that source maps are emitted.` + ); + } + return null; + } + + const { consumer, jsDir } = entry; + const moduleName = nodePath.basename(filePath, RES_JS_SUFFIX); + + let ast; + try { + ast = parse(code, { + sourceType: "module", + plugins: ["jsx"], + }); + } catch { + return null; + } + + let modified = false; + let hasMakeDeclaration = false; + + traverse(ast, { + JSXOpeningElement(jsxPath) { + const loc = jsxPath.node.loc; + if (!loc) return; + + // Source maps are 1-based for line, 0-based for column — same as Babel. + const original = consumer.originalPositionFor({ + line: loc.start.line, + column: loc.start.column, + }); + if (!original.source || original.line == null) return; + + const absoluteSource = nodePath + .resolve(jsDir, original.source) + .split(nodePath.sep) + .join("/"); + + const alreadyHasSource = jsxPath.node.attributes.some( + (attr) => + t.isJSXAttribute(attr) && + t.isJSXIdentifier(attr.name) && + attr.name.name === "__source" + ); + if (alreadyHasSource) return; + + jsxPath.node.attributes.push( + t.jsxAttribute( + t.jsxIdentifier("__source"), + t.jsxExpressionContainer( + t.objectExpression([ + t.objectProperty( + t.identifier("fileName"), + t.stringLiteral(absoluteSource) + ), + t.objectProperty( + t.identifier("lineNumber"), + t.numericLiteral(original.line) + ), + t.objectProperty( + t.identifier("columnNumber"), + t.numericLiteral(original.column ?? 0) + ), + ]) + ) + ) + ); + modified = true; + }, + VariableDeclarator(varPath) { + // Only consider top-level `let make = ...` (Program > VariableDeclaration > VariableDeclarator). + const grandparent = varPath.parentPath?.parentPath; + if (!grandparent || !grandparent.isProgram()) return; + if ( + t.isIdentifier(varPath.node.id) && + varPath.node.id.name === "make" + ) { + hasMakeDeclaration = true; + } + }, + }); + + if (hasMakeDeclaration && !hasDisplayNameAssignment(ast)) { + const programBody = ast.program.body; + for (let i = 0; i < programBody.length; i++) { + const node = programBody[i]; + const isMakeDecl = + t.isVariableDeclaration(node) && + node.declarations.some( + (d) => t.isIdentifier(d.id) && d.id.name === "make" + ); + if (isMakeDecl) { + programBody.splice( + i + 1, + 0, + t.expressionStatement( + t.assignmentExpression( + "=", + t.memberExpression( + t.identifier("make"), + t.identifier("displayName") + ), + t.stringLiteral(moduleName) + ) + ) + ); + modified = true; + break; + } + } + } + + if (!modified) return null; + + const output = generate(ast, { + retainLines: true, + sourceMaps: true, + sourceFileName: filePath, + }); + + return { + code: output.code, + map: output.map, + }; + }, + + handleHotUpdate({ file }) { + if (file.endsWith(".res")) { + invalidateRes(file); + } else if (file.endsWith(RES_JS_SUFFIX)) { + invalidateJs(file); + } else if (file.endsWith(".res.js.map")) { + invalidateJs(file.slice(0, -".map".length)); + } + }, + }; +} + +function hasDisplayNameAssignment(ast: t.File): boolean { + return ast.program.body.some((node) => { + if (!t.isExpressionStatement(node)) return false; + const expr = node.expression; + if (!t.isAssignmentExpression(expr) || expr.operator !== "=") return false; + if (!t.isMemberExpression(expr.left)) return false; + return ( + t.isIdentifier(expr.left.object, { name: "make" }) && + t.isIdentifier(expr.left.property, { name: "displayName" }) + ); + }); +} + +export default vitePluginRescript; diff --git a/packages/vite-plugin-rescript/tsconfig.json b/packages/vite-plugin-rescript/tsconfig.json new file mode 100644 index 00000000..3ade9d3b --- /dev/null +++ b/packages/vite-plugin-rescript/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "dist", + "lib": ["ES2020"], + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "**/*.test.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9eac88d..fefcdb2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -271,6 +271,40 @@ importers: specifier: ^3.0.7 version: 3.0.7 + apps/vite-rescript: + dependencies: + '@treelocator/runtime': + specifier: workspace:* + version: link:../../packages/runtime + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@babel/plugin-transform-react-jsx-source': + specifier: ^7.24.0 + version: 7.27.1(@babel/core@7.28.5) + '@treelocator/vite-plugin-rescript': + specifier: workspace:* + version: link:../../packages/vite-plugin-rescript + '@types/react': + specifier: ^18.0.15 + version: 18.3.26 + '@types/react-dom': + specifier: ^18.0.6 + version: 18.3.7(@types/react@18.3.26) + '@vitejs/plugin-react': + specifier: ^4.0.0 + version: 4.7.0(vite@5.4.21) + source-map: + specifier: ^0.7.4 + version: 0.7.6 + vite: + specifier: ^5.0.0 + version: 5.4.21(@types/node@22.18.12) + apps/vite-solid-project: dependencies: '@treelocator/runtime': @@ -560,6 +594,46 @@ importers: specifier: ^3.5.12 version: 3.5.22(typescript@5.9.3) + packages/vite-plugin-rescript: + dependencies: + '@babel/generator': + specifier: ^7.26.0 + version: 7.28.5 + '@babel/parser': + specifier: ^7.26.0 + version: 7.28.5 + '@babel/traverse': + specifier: ^7.26.0 + version: 7.28.5 + '@babel/types': + specifier: ^7.26.0 + version: 7.28.5 + source-map: + specifier: ^0.7.4 + version: 0.7.6 + devDependencies: + '@types/babel__generator': + specifier: ^7.6.8 + version: 7.27.0 + '@types/babel__traverse': + specifier: ^7.20.6 + version: 7.28.0 + '@types/node': + specifier: ^20.0.0 + version: 20.4.2 + tsup: + specifier: ^8.0.0 + version: 8.5.1(typescript@5.8.3) + typescript: + specifier: ^5.0.0 + version: 5.8.3 + vite: + specifier: ^5.0.0 + version: 5.4.21(@types/node@20.4.2) + vitest: + specifier: ^2.1.4 + version: 2.1.9(@types/node@20.4.2) + packages: /@aashutoshrathi/word-wrap@1.2.6: @@ -4828,7 +4902,7 @@ packages: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.31 '@types/node': 22.18.12 chalk: 4.1.2 collect-v8-coverage: 1.0.2 @@ -4923,7 +4997,7 @@ packages: dependencies: '@babel/core': 7.28.5 '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.31 babel-plugin-istanbul: 6.1.1 chalk: 4.1.2 convert-source-map: 2.0.0 @@ -5059,7 +5133,7 @@ packages: resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} dependencies: '@jridgewell/resolve-uri': 3.1.1 - '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/sourcemap-codec': 1.5.5 dev: true /@lerna/child-process@7.3.0: @@ -5939,7 +6013,7 @@ packages: loader-utils: 2.0.4 react-refresh: 0.11.0 schema-utils: 3.3.0 - source-map: 0.7.4 + source-map: 0.7.6 webpack: 5.102.1 dev: true @@ -5997,6 +6071,10 @@ packages: - supports-color dev: true + /@rolldown/pluginutils@1.0.0-beta.27: + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + dev: true + /@rolldown/pluginutils@1.0.0-beta.53: resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} dev: true @@ -7441,7 +7519,7 @@ packages: /@storybook/mdx1-csf@0.0.4(@babel/core@7.22.5)(react@18.2.0): resolution: {integrity: sha512-xxUEMy0D+0G1aSYxbeVNbs+XBU5nCqW4I7awpBYSTywXDv/MJWeC6FDRpj5P1pgfq8j8jWDD5ZDvBQ7syFg0LQ==} dependencies: - '@babel/generator': 7.22.9 + '@babel/generator': 7.28.5 '@babel/parser': 7.28.5 '@babel/preset-env': 7.28.5(@babel/core@7.22.5) '@babel/types': 7.28.5 @@ -7915,7 +7993,7 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} dependencies: '@tufjs/canonical-json': 1.0.0 - minimatch: 9.0.3 + minimatch: 9.0.5 dev: true /@types/aria-query@5.0.1: @@ -7927,13 +8005,13 @@ packages: dependencies: '@babel/parser': 7.28.5 '@babel/types': 7.28.5 - '@types/babel__generator': 7.6.4 + '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.1 '@types/babel__traverse': 7.28.0 dev: true - /@types/babel__generator@7.6.4: - resolution: {integrity: sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==} + /@types/babel__generator@7.27.0: + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} dependencies: '@babel/types': 7.28.5 dev: true @@ -7973,10 +8051,6 @@ packages: resolution: {integrity: sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==} dev: true - /@types/estree@1.0.1: - resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} - dev: true - /@types/estree@1.0.8: resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} dev: true @@ -8142,7 +8216,7 @@ packages: /@types/react-dom@18.0.6: resolution: {integrity: sha512-/5OFZgfIPSwy+YuIBP/FgJnQnsxhZhjjrnxudMddeblOouIodEQ75X14Rr4wGSG/bknL+Omy9iWlLo1u/9GzAA==} dependencies: - '@types/react': 18.0.15 + '@types/react': 18.3.26 dev: true /@types/react-dom@18.3.7(@types/react@18.3.26): @@ -8328,7 +8402,7 @@ packages: '@typescript-eslint/scope-manager': 4.33.0 '@typescript-eslint/types': 4.33.0 '@typescript-eslint/typescript-estree': 4.33.0(typescript@5.8.3) - debug: 4.3.4 + debug: 4.4.3 eslint: 7.32.0 typescript: 5.8.3 transitivePeerDependencies: @@ -8556,6 +8630,23 @@ packages: - supports-color dev: true + /@vitejs/plugin-react@4.7.0(vite@5.4.21): + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 5.4.21(@types/node@22.18.12) + transitivePeerDependencies: + - supports-color + dev: true + /@vitejs/plugin-react@5.1.2(vite@6.4.1): resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8607,7 +8698,7 @@ packages: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 - vite: 5.4.21(@types/node@20.4.2) + vite: 5.4.21(@types/node@22.18.12) dev: true /@vitest/pretty-format@2.1.9: @@ -8763,7 +8854,7 @@ packages: '@vue/reactivity': 3.5.22 '@vue/runtime-core': 3.5.22 '@vue/shared': 3.5.22 - csstype: 3.1.3 + csstype: 3.2.3 dev: true /@vue/server-renderer@3.2.37(vue@3.2.37): @@ -10376,7 +10467,7 @@ packages: dependencies: '@npmcli/fs': 3.1.0 fs-minipass: 3.0.2 - glob: 10.3.3 + glob: 10.4.5 lru-cache: 7.18.3 minipass: 5.0.0 minipass-collect: 1.0.2 @@ -12432,7 +12523,7 @@ packages: eslint: '*' eslint-plugin-import: '*' dependencies: - debug: 4.3.4 + debug: 4.4.3 eslint: 7.32.0 eslint-plugin-import: 2.27.5(@typescript-eslint/parser@5.62.0)(eslint@7.32.0) glob: 7.2.3 @@ -12810,7 +12901,7 @@ packages: /estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} dependencies: - '@types/estree': 1.0.1 + '@types/estree': 1.0.8 dev: true /esutils@2.0.3: @@ -12885,7 +12976,7 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} dependencies: - cross-spawn: 7.0.3 + cross-spawn: 7.0.6 get-stream: 6.0.1 human-signals: 2.1.0 is-stream: 2.0.1 @@ -13867,18 +13958,6 @@ packages: /glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - /glob@10.3.3: - resolution: {integrity: sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - dependencies: - foreground-child: 3.1.1 - jackspeak: 2.2.1 - minimatch: 9.0.3 - minipass: 5.0.0 - path-scurry: 1.10.1 - dev: true - /glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -13893,6 +13972,7 @@ packages: /glob@7.1.4: resolution: {integrity: sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -13904,6 +13984,7 @@ packages: /glob@7.1.7: resolution: {integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -13926,6 +14007,7 @@ packages: /glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -13937,6 +14019,7 @@ packages: /glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me dependencies: fs.realpath: 1.0.0 minimatch: 8.0.4 @@ -14433,7 +14516,7 @@ packages: resolution: {integrity: sha512-C7FfFoTA+bI10qfeydT8aZbvr91vAEU+2W5BZUlzPec47oNb07SsOfwYrtxuvOYdUApPP/Qlh4DtAO51Ekk2QA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} dependencies: - minimatch: 9.0.3 + minimatch: 9.0.5 dev: true /ignore@4.0.6: @@ -15111,15 +15194,6 @@ packages: iterate-iterator: 1.0.2 dev: true - /jackspeak@2.2.1: - resolution: {integrity: sha512-MXbxovZ/Pm42f6cDIDkl3xpwv1AGwObKwfmjs2nQePiy85tP3fatofl3FC1aBsOtP/6fq5SbtgHwWcMsLP+bDw==} - engines: {node: '>=14'} - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - dev: true - /jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} dependencies: @@ -16606,13 +16680,6 @@ packages: brace-expansion: 2.0.1 dev: true - /minimatch@9.0.3: - resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} - engines: {node: '>=16 || 14 >=14.17'} - dependencies: - brace-expansion: 2.0.1 - dev: true - /minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -17790,7 +17857,7 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} dependencies: - '@babel/code-frame': 7.22.5 + '@babel/code-frame': 7.27.1 error-ex: 1.3.2 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -18660,6 +18727,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + dev: true + /react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -18733,7 +18805,7 @@ packages: resolution: {integrity: sha512-AEtWXYfopBj2z5N5PbkAOeNHRPUg5q+Nen7QLxV8M2zJq1ym6/lCz3fYNTCXe19puu2d06jfHhrP7v/S2PtMMw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} dependencies: - glob: 10.3.3 + glob: 10.4.5 json-parse-even-better-errors: 3.0.0 normalize-package-data: 5.0.0 npm-normalize-package-bin: 3.0.1 @@ -19840,15 +19912,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - /source-map@0.7.4: - resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} - engines: {node: '>= 8'} - dev: true - /source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} - dev: true /sourcemap-codec@1.4.8: resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} From 90e88f5929ac2c28fa911d641ba2f9842cb3631a Mon Sep 17 00:00:00 2001 From: Krzysztof Wende Date: Sat, 16 May 2026 23:23:14 +0200 Subject: [PATCH 2/2] docs(rescript): add review note for source-map implementation Captures the verification done against the spec: plugin behavior, test coverage, what was verified locally (unit tests + build), what wasn't (e2e Playwright run, real rescript compiler), and follow-ups (missing README, undocumented esbuild loader config). --- .../2026-05-16_rescript-source-maps-review.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 dev-notes/2026-05-16_rescript-source-maps-review.md diff --git a/dev-notes/2026-05-16_rescript-source-maps-review.md b/dev-notes/2026-05-16_rescript-source-maps-review.md new file mode 100644 index 00000000..a844cb56 --- /dev/null +++ b/dev-notes/2026-05-16_rescript-source-maps-review.md @@ -0,0 +1,134 @@ +# ReScript source-map support — implementation review + +**Date:** 2026-05-16 +**Branch reviewed:** `claude/rescript-source-maps-Sa86N` +**Commit:** `7e572e1` — `feat(rescript): add ReScript source location tracking` +**Reviewed from:** worktree `.claude/worktrees/rescript-review` + +## Scope + +Verify the ReScript support feature against the spec: source-location +tracking for `rescript-react` so that Alt+click resolves to `.res` files +and lines instead of compiled `.res.js` intermediates, and that the +React fiber reports the module name (`Button`) instead of the literal +`make`. + +## What ships + +- `packages/vite-plugin-rescript/` — new published package + (`@treelocator/vite-plugin-rescript`, `0.6.0`, MIT, peer-dep `vite>=3`). + Builds dual ESM/CJS + `.d.ts` via tsup. +- `packages/runtime/src/adapters/rescript.ts` — no-op re-export of the + React adapter (ReScript compiles to React fibers, so the React adapter + handles ancestry natively once `__source` is injected). +- `apps/vite-rescript/` — demo on port 3353 with pre-built `.res.js` + + `.res.js.map` fixtures (so CI doesn't need a `rescript` binary). + `vite.config.js` wires the plugin + `@vitejs/plugin-react` with + `jsxRuntime: 'classic'` and an esbuild JSX loader for `.res.js`. +- `apps/playwright/tests/ancestry/rescript.spec.ts` — 4 ancestry + assertions (module name, wrapping component, `.res` file path, + line in range). +- `apps/playwright/scripts/run-ancestry-tests.sh` — starts the new + demo and waits for port 3353. + +## Plugin behavior (`packages/vite-plugin-rescript/src/index.ts`) + +- Triggers on `.res.js` (strips query strings before checking suffix). +- Parses with `@babel/parser` (`sourceType: "module"`, plugin `"jsx"`), + traverses, and for each `JSXOpeningElement` injects + `__source={{fileName, lineNumber, columnNumber}}`. +- `fileName` is resolved via + `path.resolve(dirname(jsFile), original.source)` and normalized to + forward slashes — covers spec pain point #6 (relative source-map paths). +- Skips elements that already carry `__source` (idempotent). +- For top-level `let make = …` declarations, appends + `make.displayName = ""` so the React component name is the + module name (spec pain point #2). Inner `make` bindings inside other + functions are correctly ignored via a Program-grandparent check. +- Skips displayName injection when one already exists. +- `SourceMapConsumer` instances are cached per `.res.js` path; the + `handleHotUpdate` hook invalidates entries on `.res`, `.res.js`, or + `.res.js.map` changes (spec pain points #3). +- Dev gating: `configResolved` sets `isDev = command === "serve"`; + explicit `injectSource` option overrides (spec pain point #7). +- Missing source map: warns once via `this.warn(...)` and returns `null`; + malformed JSON in the `.map` is swallowed (still returns `null`). +- Babel CJS/ESM interop is handled defensively for `@babel/traverse` and + `@babel/generator` (`.default` fallback) — necessary in pnpm. + +## Test coverage + +`packages/vite-plugin-rescript/src/index.test.ts` — 17 tests, all +passing locally: + +- **File filtering** — no-op for `.tsx`, `.jsx`, `.vue`; query strings + stripped before checking the suffix. +- **Missing source map** — warns exactly once across repeated transforms; + malformed `.map` JSON does not throw. +- **`__source` injection** — correct `lineNumber` from remap; absolute + path emitted even when the source-map `source` entry is relative + (`./Button.res`); existing `__source` is not duplicated. +- **`displayName` injection** — appended after `let make = …`; uses file + basename (`GlassPanel.res.js` → `"GlassPanel"`); skipped when no + top-level `make` exists; not double-assigned if one is already present; + inner-scope `make` does not trigger injection. +- **Dev gating** — `injectSource: false` is a no-op; default behavior + follows `command: "serve"` vs `"build"`. +- **HMR cache invalidation** — replacing the `.map` file and calling + `handleHotUpdate({ file: <.res> })` produces a fresh remap on the + next transform. + +Runtime suite: 22 files / 407 tests still pass — no regression from +the new adapter file. + +## Verified locally + +- `pnpm install` (worktree) +- `pnpm test` in `packages/vite-plugin-rescript` → 17/17 pass +- `pnpm ts` in `packages/vite-plugin-rescript` → no TS errors +- `pnpm build` in `packages/vite-plugin-rescript` → ESM/CJS/DTS build + clean +- `pnpm test` in `packages/runtime` → 407/407 pass + +## Not verified in this session + +- **End-to-end Playwright spec** — the workspace rule against + backgrounded long-running tasks blocked starting `vite-rescript`'s + dev server, so `rescript.spec.ts` was not executed here. The spec + itself reads correctly and runs against pre-built fixtures, so it + should be deterministic when the runner script picks it up. +- **Real `rescript build` output** — fixtures are hand-crafted in + `apps/vite-rescript/scripts/build-fixtures.mjs` rather than emitted + by the ReScript compiler. The manual smoke checklist from the spec + (real `rescript-react` + Vite, monorepo subdir, VS Code / WebStorm + editor-open links) is the right place to validate that the + ReScript-emitted source-map column conventions match what the plugin + assumes. + +## Things to follow up on + +1. **Docs missing.** No README in `packages/vite-plugin-rescript/`, no + mention in the root `README.md` or `CLAUDE.md`, and no description + anywhere of the required `esbuild.loader: 'jsx'` + + `optimizeDeps.esbuildOptions.loader: { '.res.js': 'jsx' }` config + from the demo's `vite.config.js`. Users will hit dev-server parse + errors without that. Should be documented before the package is + advertised. +2. **Real-compiler fixture step.** Consider an optional `pnpm fixtures` + variant that shells out to `rescript build` when available, so the + demo can be regenerated from real ReScript output rather than from + the hand-written mappings in `build-fixtures.mjs`. +3. **No webpack/Next path.** Explicitly out of scope per the design + doc; revisit when there's user demand. +4. **Column precision** is statement-level per the design doc — fine + for now since TreeLocatorJS displays file+line, but worth a + follow-up issue if column accuracy ever becomes user-visible. + +## Verdict + +Implementation matches the spec. The plugin is small, well-tested at +the unit level, and the integration surface (demo app + Playwright +spec + runner script) is wired correctly. Outstanding work is +documentation and a real end-to-end smoke run against the actual +ReScript compiler — neither blocks the code review, but both should +land before the package is recommended publicly.