Skip to content

Content mappers: saving a watchedFiles file that is open in the editor never re-runs openProject (stale types until the language server is restarted) #65084

Open

Description

🔎 Search Terms

"content mapper", "contentMappers", "watchedFiles", "dynamicConfig", "configIdentity", "didChangeWatchedFiles", "didSave", "overlay", "stale", "restart language server"

🕗 Version & Regression Information

  • I was unable to test this on prior versions because content mappers are new in TypeScript 7.1.

Seen on typescript@7.1.0-beta (published 2026-10-20), driven through tsc --lsp --stdio. The same logic is on main (6161772, 2026-10-30).

⏯ Playground Link

No response

💻 Code

A mapper with dynamicConfig: true returns the files its configuration depends on in openProject's watchedFiles. If one of those files is a .ts file that the user edits in the editor, the mapper's types never update after the file is saved.

Self-contained repro (no dependencies besides typescript@7.1.0-beta)

package.json

{
  "private": true,
  "type": "module",
  "devDependencies": { "toy-mapper": "file:./toy-mapper", "typescript": "7.1.0-beta" }
}

tsconfig.json

{
  "compilerOptions": { "module": "preserve", "strict": true, "noEmit": true },
  "contentMappers": [{ "package": "toy-mapper", "extensions": [".kinds"] }],
  "include": ["*.ts"]
}

kinds.ts (the mapper's configuration; the mapper reads it from disk)

export default ["A", "B"];

values.kinds (any non-empty text; the mapper ignores it)

# the content of this file is ignored; the toy mapper reads kinds.ts

index.ts

import values from "./values.kinds";

// Error while kinds.ts lists "A" and "B". The error goes away once kinds.ts lists only "A".
export const first: "A" = values[0];

toy-mapper/package.json

{
  "name": "toy-mapper",
  "version": "1.0.0",
  "type": "module",
  "typescript": {
    "contentMapper": { "exec": ["node", "server.js"], "compilerOptions": [], "dynamicConfig": true }
  }
}

toy-mapper/server.js

import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import path from "node:path";

const projects = new Map();

function readKinds(configFileName) {
  const file = path.join(path.dirname(configFileName), "kinds.ts");
  const list = JSON.parse(/\[.*\]/.exec(readFileSync(file, "utf8"))[0]);
  return { file, list };
}

const handlers = {
  initialize: (p) => ({ protocolVersion: p?.protocolVersion, positionEncoding: "utf-16", diagnosticSource: "toy" }),
  openProject: ({ configFileName, projectHandle }) => {
    const { file, list } = readKinds(configFileName);
    projects.set(projectHandle, list);
    return {
      configIdentity: createHash("sha256").update(JSON.stringify(list)).digest("hex"),
      watchedFiles: [file],
    };
  },
  transform: ({ content, projectHandle }) => {
    const union = projects.get(projectHandle).map((k) => JSON.stringify(k)).join(" | ");
    const text = `declare const values: readonly (${union})[];\nexport default values;\n`;
    return {
      extension: ".ts",
      text,
      diagnostics: [],
      mappings: [[0, text.length, 0, content.length, 1, (1 << 3) | (1 << 4)]],
    };
  },
  closeProject: ({ projectHandle }) => (projects.delete(projectHandle), {}),
};

let buffer = Buffer.alloc(0);
process.stdin.on("data", (chunk) => {
  buffer = Buffer.concat([buffer, chunk]);
  for (;;) {
    const end = buffer.indexOf("\r\n\r\n");
    if (end < 0) return;
    const length = Number(/Content-Length:\s*(\d+)/i.exec(buffer.subarray(0, end).toString("ascii"))[1]);
    if (buffer.length < end + 4 + length) return;
    const message = JSON.parse(buffer.subarray(end + 4, end + 4 + length).toString("utf8"));
    buffer = buffer.subarray(end + 4 + length);
    if (message.id === undefined) continue;
    const handler = handlers[message.method];
    const body = Buffer.from(JSON.stringify({ jsonrpc: "2.0", id: message.id, ...(handler ? { result: handler(message.params) ?? {} } : { error: { code: -32601, message: "Unknown method" } }) }));
    process.stdout.write(Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`), body]));
  }
});

npx tsc --noEmit --runExternalCode reports Type '"A" | "B"' is not assignable to type '"A"' for index.ts, and reports nothing after kinds.ts is changed to ["A"], as expected.

Steps, at the protocol level (tsc --lsp --stdio with initializationOptions: { runExternalCode: true }):

  1. didOpen index.ts and kinds.ts. index.ts has the error.
  2. didChange kinds.ts to export default ["A"]; and pull diagnostics once, as an editor does after an edit.
  3. Write kinds.ts to disk, then send didSave and workspace/didChangeWatchedFiles for it.
  4. The error in index.ts stays. Restarting the language server removes it.

🙁 Actual behavior

The error in index.ts stays after kinds.ts is saved. Driven through tsc --lsp --stdio the way an editor does (an editor asks for diagnostics after every edit). Between the edit and the save the client pulls diagnostics once; this matters, see "Additional information about the issue".

Trace of what reaches the mapper (client events and mapper calls on one clock):

CLIENT textDocument/didChange kinds.ts        <- user types in kinds.ts, buffer now lists only "A"
mapper <- closeProject
mapper <- openProject  config=tsconfig.json   <- re-read, but kinds.ts on disk still says ["A","B"]
mapper -> openProject  identity=b64e3448
CLIENT (client pulls diagnostics)
CLIENT disk write kinds.ts                    <- user saves
CLIENT textDocument/didSave kinds.ts
CLIENT workspace/didChangeWatchedFiles kinds.ts
                                               <- no mapper call at all; types stay stale

Time until the error in index.ts goes away (3 s timeout):

What happens to kinds.ts Result
Not open in the editor: written on disk, didChangeWatchedFiles sent updated after 9 ms
Open: edit, pull diagnostics, then save (didChange, disk write, didSave, didChangeWatchedFiles) still stale after 3 s
...then close the tab still stale after 3 s
...then close and reopen it still stale after 3 s
...then edit index.ts still stale after 3 s
...then type one more character in kinds.ts (didChange) updated after 5 ms

🙂 Expected behavior

As documented in microsoft/typescript-go#4712 ("TypeScript watches the absolute paths returned in watchedFiles. A change invalidates only projects that reported that path, closes their current mapper project configuration, obtains a fresh identity and watch set, and performs a normal project rebuild"), saving a file listed in watchedFiles should re-run openProject, whether or not the file is open in the editor.

Additional information about the issue

Related to #64943 (a change on disk to a content-mapped file does not refresh diagnostics), but the cause is different, and #64944 does not touch this code path.

tsc/internal/project/overlayfs.go (main, lines 534-538), in processChanges:

if events.watchChanged && o == nil {
    result.Changed.Add(uri)
}

if len(events.changes) > 0 && o != nil {
    result.Changed.Add(uri)
    ...

A watch event adds the file to result.Changed only when it is not open (o == nil). When it is open, the overlay is the source of truth, which is right for files TypeScript reads itself. A content mapper reads watchedFiles from disk, so an open file is the case where the overlay and the disk differ.

refreshContentMapperProjectForChanges (projectcollectionbuilder.go, main, line 474, called from DidChangeFiles at line 413) only looks at the paths in summary.Changed/Deleted/Created. So for an open watched file, the only thing that re-runs openProject is didChange, and at that moment the file on disk still has the old content. The save that follows reaches processChanges as a Save and a WatchChange for a file with an overlay, and neither adds it to Changed.

If the client sends no request between the edit and the save, the pending didChange, didSave and watch event are flushed together, Changed contains the file, and openProject reads the saved file. So the problem shows up only in an editor, which sends requests continuously.

A possible fix: also record watch changes of files that have an overlay (for example in a separate set on the summary) and let refreshContentMapperProjectForChanges consider them, or treat a save of a file in contentMapperWatchedFiles as an invalidation.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions