From 37c837ffd337d054bcada6879c94008781db5a0d Mon Sep 17 00:00:00 2001 From: Robin Jehn <71655429+RobinJehn@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:03:41 +0200 Subject: [PATCH] Send languageId 'typescriptreact' / 'javascriptreact' for .tsx / .jsx files (#1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix: send languageId 'typescriptreact' / 'javascriptreact' for .tsx / .jsx Without this, .tsx files are opened with languageId 'typescript', so tsserver parses JSX as syntax errors and recovers by truncating the enclosing symbol's range at the first multi-line JSX expression. The resulting symptoms: - find_symbol(name, include_body=True) on a React component returns a body that ends mid-component, hiding everything below. - The same component shows up as multiple Method-kind symbols (one per call site) plus a separately-tracked Constant for the definition, because tsserver mistakes JSX-tag tokens for member access. Reproducer (before fix, real-world TSX file): SidebarPDF range = [11:1-25:207] (truncated at first JSX, line 25) After fix: SidebarPDF range = [11:1-281:2] (full body) The base SolidLanguageServer._get_language_id_for_file is explicitly designed for this — its docstring says "Override in subclasses to return file-specific language IDs." This change just exercises that hook for the React file extensions. Adds a regression test + jsx_component.tsx fixture covering the same arrow-function-with-multi-line-JSX-ternary pattern that triggers the truncation in real codebases (see oraios/serena#1029 for an earlier report whose root cause was not identified). --- .../typescript_language_server.py | 9 ++++ .../typescript/test_repo/jsx_component.tsx | 44 +++++++++++++++++++ .../repos/typescript/test_repo/tsconfig.json | 5 ++- .../typescript/test_typescript_basic.py | 31 +++++++++++++ 4 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 test/resources/repos/typescript/test_repo/jsx_component.tsx diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 5b7ec4fe..ddf3f605 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -217,6 +217,15 @@ class TypeScriptLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] + def _get_language_id_for_file(self, relative_file_path: str) -> str: + # JSX is parsed as TS without this, which silently truncates symbol + # ranges at the first multi-line JSX expression. + if relative_file_path.endswith(".tsx"): + return "typescriptreact" + if relative_file_path.endswith(".jsx"): + return "javascriptreact" + return self.language_id + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the TypeScript Language Server. diff --git a/test/resources/repos/typescript/test_repo/jsx_component.tsx b/test/resources/repos/typescript/test_repo/jsx_component.tsx new file mode 100644 index 00000000..c8dea77d --- /dev/null +++ b/test/resources/repos/typescript/test_repo/jsx_component.tsx @@ -0,0 +1,44 @@ +// Regression fixture: mirrors the real-world pattern that caused +// languageId=typescript (instead of typescriptreact) to truncate symbol +// ranges at the first multi-line JSX expression. + +import * as React from "react" + +type SectionProps = { title: string; emphasised?: boolean } + +function Section({ title, emphasised }: SectionProps) { + return emphasised ? ( +

{title}

+ ) : ( +

{title}

+ ) +} + +export function JsxComponent({ heading, items }: { heading: string; items: string[] }) { + const renderHeader = (title: string) => + title.length > 10 ? ( +
+ ) : ( +
+ ) + + // The truncation bug used to cut JsxComponent's range right around here, + // hiding everything below from find_symbol. Keep this comment so the + // regression test can assert the function body extends past it. + const renderItem = (item: string, idx: number) => ( +
  • + {item} +
  • + ) + + return ( +
    + {renderHeader(heading)} +
      {items.map(renderItem)}
    +
    + ) +} + +export function trailingHelper(): string { + return "this symbol is invisible to find_symbol when JsxComponent's range is truncated" +} diff --git a/test/resources/repos/typescript/test_repo/tsconfig.json b/test/resources/repos/typescript/test_repo/tsconfig.json index ba0514f5..7267a686 100644 --- a/test/resources/repos/typescript/test_repo/tsconfig.json +++ b/test/resources/repos/typescript/test_repo/tsconfig.json @@ -5,7 +5,8 @@ "strict": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, - "skipLibCheck": true + "skipLibCheck": true, + "jsx": "react" }, - "include": ["**/*.ts"] + "include": ["**/*.ts", "**/*.tsx"] } diff --git a/test/solidlsp/typescript/test_typescript_basic.py b/test/solidlsp/typescript/test_typescript_basic.py index ba86ba04..2caf6b11 100644 --- a/test/solidlsp/typescript/test_typescript_basic.py +++ b/test/solidlsp/typescript/test_typescript_basic.py @@ -33,6 +33,37 @@ class TestTypescriptLanguageServer: "index.ts should reference helperFunction (tried all positions in selectionRange)" ) + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_tsx_symbol_range_not_truncated_by_jsx(self, language_server: SolidLanguageServer) -> None: + # Regression: when the language id is sent as "typescript" instead of + # "typescriptreact" for .tsx files, tsserver parses JSX as syntax + # errors and recovers by truncating the enclosing symbol's range at + # the first multi-line JSX expression. find_symbol then returns a + # body that ends mid-component and hides everything below. + file_path = "jsx_component.tsx" + roots = language_server.request_document_symbols(file_path).root_symbols + + jsx_component = next((s for s in roots if s.get("name") == "JsxComponent"), None) + assert jsx_component is not None, "JsxComponent not found at root level of jsx_component.tsx" + + end_line = jsx_component["location"]["range"]["end"]["line"] + # JsxComponent's body extends to line 38 (0-based 37) in the fixture; + # the truncation bug cut it at the first multi-line JSX (~line 21). + # Use a generous lower bound so the test survives small fixture edits + # that don't affect the regression behaviour we care about. + assert end_line >= 30, ( + f"JsxComponent symbol range truncated at line {end_line + 1} (1-based); " + f"expected end at or past line 31 (1-based). " + f"This indicates the .tsx file was opened with the wrong languageId." + ) + + # The trailing helper must be visible as a top-level symbol — it lives + # past the truncation point and disappears entirely when the bug is + # active because tsserver stops emitting symbols after the parse error. + assert any(s.get("name") == "trailingHelper" for s in roots), ( + "trailingHelper missing from jsx_component.tsx root symbols; tsserver likely stopped parsing at the first JSX expression." + ) + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) def test_bare_symbol_names(self, language_server) -> None: all_symbols = request_all_symbols(language_server)