Skip to content

Add vite-plugin-rescript for ReScript JSX source mapping#4

Open
wende wants to merge 2 commits into
mainfrom
claude/rescript-source-maps-Sa86N
Open

Add vite-plugin-rescript for ReScript JSX source mapping#4
wende wants to merge 2 commits into
mainfrom
claude/rescript-source-maps-Sa86N

Conversation

@wende
Copy link
Copy Markdown
Owner

@wende wende commented May 16, 2026

Summary

Introduces @treelocator/vite-plugin-rescript, a Vite plugin that remaps ReScript-compiled JSX positions back to the original .res source files via source maps. This enables TreeLocatorJS and React DevTools to display correct file/line information for ReScript components.

Key Changes

  • New package: packages/vite-plugin-rescript/ with full implementation

    • Parses ReScript-compiled .res.js files (which contain preserved JSX)
    • Consumes .res.js.map source maps to remap JSX element positions back to .res files
    • Injects __source attributes into JSX elements with absolute file paths and line numbers
    • Appends make.displayName = "ModuleName" to top-level make declarations so React shows the module name instead of the literal make
    • Implements HMR cache invalidation to handle ReScript recompilation
  • Comprehensive test suite: packages/vite-plugin-rescript/src/index.test.ts

    • Tests file filtering (ignores non-.res.js files)
    • Tests missing/malformed source map handling
    • Tests __source injection with correct remapping
    • Tests displayName injection and deduplication
    • Tests development mode gating
    • Tests HMR cache invalidation
  • Demo app: apps/vite-rescript/

    • Example ReScript project with Vite + React setup
    • Pre-built fixture files (Button.res.js, Card.res.js + source maps)
    • Build script (scripts/build-fixtures.mjs) to regenerate fixtures from .res sources
    • Vite config with proper JSX handling for .res.js files
  • E2E tests: apps/playwright/tests/ancestry/rescript.spec.ts

    • Verifies module names appear in ancestry chains (not literal make)
    • Verifies file paths point to .res files, not .res.js
    • Verifies line numbers map correctly to original source

Implementation Details

  • Uses Babel parser/traverse/generator to safely manipulate JSX AST
  • Caches SourceMapConsumer instances per .res.js file for performance
  • Warns once per file if source map is missing (avoids spam)
  • Handles relative paths in source maps by resolving to absolute paths
  • Respects Vite's dev/build mode (injection only in dev by default, overridable via injectSource option)
  • Integrates with Vite's HMR to invalidate cache when .res files change

Source-map status / follow-up

Manual testing against stable ReScript compiler output found that current stable ReScript does not emit sibling .res.js.map files in the tested setups. Stable rescript@12.2.0 also ignores the proposed sourceMap config as an unknown field. Details are recorded in dev-notes/2026-05-16_rescript-real-compiler-output.md.

The source for treating native ReScript source maps as planned/experimental future work is the upstream ReScript PR rescript-lang/rescript#8393, "Introduce Sourcemap". The related ReScript forum feedback thread, Experimental ReScript source map support, says to install a compiler build from that PR, enable a sourceMap config, and notes that it currently generates linked Source Map v3 files such as src/App.js.map. The same thread also calls out that Vite needs a plugin to pass the generated input maps through, with this reference config: mununki/rescript-sourcemap-test vite.config.js.

Until upstream native source-map support lands in a stable ReScript release, this PR should be considered fixture-backed / experimental rather than verified support for stable real ReScript projects.

https://claude.ai/code/session_017tC3BAbezWe1xDaexUR4V6

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.
@github-actions
Copy link
Copy Markdown

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • Consider adding a buildEnd/closeBundle/closeWatcher hook that iterates consumerCache and calls destroy() on each SourceMapConsumer to ensure there are no lingering native resources in long-lived dev/CI processes.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider adding a `buildEnd`/`closeBundle`/`closeWatcher` hook that iterates `consumerCache` and calls `destroy()` on each `SourceMapConsumer` to ensure there are no lingering native resources in long-lived dev/CI processes.

## Individual Comments

### Comment 1
<location path="packages/vite-plugin-rescript/src/index.ts" line_range="115-118" />
<code_context>
+      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.`
+          );
</code_context>
<issue_to_address>
**suggestion:** Malformed source maps are reported as missing, which can mislead debugging.

In `getConsumer`, JSON parse failures for `.map` files also return `null`, so this warning currently conflates missing maps with malformed ones. That makes it harder to distinguish configuration issues from corrupted/invalid maps. Please either emit a distinct warning for parse failures (e.g., via a separate `warnedBadMap` set) or adjust this message to mention that the source map may be missing *or malformed*.

```suggestion
          this.warn(
            `vite-plugin-rescript: no valid source map (.map) found alongside ${filePath}. ` +
              `The source map may be missing or malformed. Make sure rescript.json sets "jsx": { "version": 4, "preserve": true } and that source maps are emitted.`
          );
```
</issue_to_address>

### Comment 2
<location path="packages/vite-plugin-rescript/src/index.ts" line_range="48-50" />
<code_context>
+export function vitePluginRescript(
+  options: VitePluginRescriptOptions = {}
+): Plugin {
+  const consumerCache = new Map<string, ConsumerEntry>();
+  const warnedNoMap = new Set<string>();
+  let isDev = false;
+
+  async function getConsumer(jsFilePath: string): Promise<ConsumerEntry | null> {
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Cached SourceMapConsumers are never destroyed on plugin shutdown.

`consumerCache` is only cleaned up via `invalidateJs`/`invalidateRes`/HMR, so if the dev server stops or a build finishes without those being called, `SourceMapConsumer.destroy()` is never invoked. This can leak native resources in long dev sessions or large projects. Please add a `buildEnd`/`closeBundle` (and possibly `closeWatcher`) hook to iterate `consumerCache.values()`, call `destroy()` on each, and clear the map.

Suggested implementation:

```typescript
export function vitePluginRescript(
  options: VitePluginRescriptOptions = {}
): Plugin {
  const consumerCache = new Map<string, ConsumerEntry>();
  const warnedNoMap = new Set<string>();
  let isDev = false;

  function cleanupConsumers() {
    for (const entry of consumerCache.values()) {
      try {
        entry.consumer.destroy();
      } catch {
        // ignore errors during cleanup
      }
    }
    consumerCache.clear();
  }

  async function getConsumer(jsFilePath: string): Promise<ConsumerEntry | null> {

```

To fully implement the cleanup:

1. In the object that `vitePluginRescript` returns (the `Plugin`), add lifecycle hooks that call `cleanupConsumers()`:
   - `buildEnd() { cleanupConsumers(); }`
   - `closeBundle() { cleanupConsumers(); }`
   - Optionally `closeWatcher() { cleanupConsumers(); }` if the plugin is used in watch mode and you want deterministic cleanup when the watcher stops.
2. Ensure these hooks are added alongside any existing hooks (e.g. `name`, `transform`, `handleHotUpdate`, etc.), reusing the `cleanupConsumers()` helper so the logic is centralized and safe to call multiple times.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +115 to +118
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.`
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Malformed source maps are reported as missing, which can mislead debugging.

In getConsumer, JSON parse failures for .map files also return null, so this warning currently conflates missing maps with malformed ones. That makes it harder to distinguish configuration issues from corrupted/invalid maps. Please either emit a distinct warning for parse failures (e.g., via a separate warnedBadMap set) or adjust this message to mention that the source map may be missing or malformed.

Suggested change
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.`
);
this.warn(
`vite-plugin-rescript: no valid source map (.map) found alongside ${filePath}. ` +
`The source map may be missing or malformed. Make sure rescript.json sets "jsx": { "version": 4, "preserve": true } and that source maps are emitted.`
);

Comment on lines +48 to +50
const consumerCache = new Map<string, ConsumerEntry>();
const warnedNoMap = new Set<string>();
let isDev = false;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Cached SourceMapConsumers are never destroyed on plugin shutdown.

consumerCache is only cleaned up via invalidateJs/invalidateRes/HMR, so if the dev server stops or a build finishes without those being called, SourceMapConsumer.destroy() is never invoked. This can leak native resources in long dev sessions or large projects. Please add a buildEnd/closeBundle (and possibly closeWatcher) hook to iterate consumerCache.values(), call destroy() on each, and clear the map.

Suggested implementation:

export function vitePluginRescript(
  options: VitePluginRescriptOptions = {}
): Plugin {
  const consumerCache = new Map<string, ConsumerEntry>();
  const warnedNoMap = new Set<string>();
  let isDev = false;

  function cleanupConsumers() {
    for (const entry of consumerCache.values()) {
      try {
        entry.consumer.destroy();
      } catch {
        // ignore errors during cleanup
      }
    }
    consumerCache.clear();
  }

  async function getConsumer(jsFilePath: string): Promise<ConsumerEntry | null> {

To fully implement the cleanup:

  1. In the object that vitePluginRescript returns (the Plugin), add lifecycle hooks that call cleanupConsumers():
    • buildEnd() { cleanupConsumers(); }
    • closeBundle() { cleanupConsumers(); }
    • Optionally closeWatcher() { cleanupConsumers(); } if the plugin is used in watch mode and you want deterministic cleanup when the watcher stops.
  2. Ensure these hooks are added alongside any existing hooks (e.g. name, transform, handleHotUpdate, etc.), reusing the cleanupConsumers() helper so the logic is centralized and safe to call multiple times.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for ReScript by adding a new Vite plugin, @treelocator/vite-plugin-rescript, which remaps compiled JSX positions back to original .res files using source maps. It also includes a new demo application (apps/vite-rescript), a ReScript adapter in the runtime package, and comprehensive Playwright tests. Feedback highlights potential race conditions and memory leaks in the Vite plugin's getConsumer function, suggesting that SourceMapConsumer instances be explicitly destroyed and that Promises should be cached to prevent redundant allocations.

Comment on lines +48 to +73
const consumerCache = new Map<string, ConsumerEntry>();
const warnedNoMap = new Set<string>();
let isDev = false;

async function getConsumer(jsFilePath: string): Promise<ConsumerEntry | null> {
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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The getConsumer function has a potential race condition and memory leak. If multiple transform calls for the same file occur concurrently, multiple SourceMapConsumer instances may be created, but only the last one will be stored in the cache, leading to a leak of the previous ones. Additionally, SourceMapConsumer instances in source-map v0.7+ are WASM-based and must be explicitly destroyed. Consider caching the Promise of the consumer to avoid the race condition and ensure that every created consumer is eventually tracked and destroyed.

} else if (file.endsWith(".res.js.map")) {
invalidateJs(file.slice(0, -".map".length));
}
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The plugin should implement a cleanup mechanism to destroy all cached SourceMapConsumer instances when the Vite process ends or the plugin is disposed. Failing to do so can lead to memory leaks due to the WASM resources used by source-map.

    },

    buildEnd() {
      for (const entry of consumerCache.values()) {
        entry.consumer.destroy();
      }
      consumerCache.clear();
    },

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).
@wende wende added the deferred label May 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants