From fa56256c49d7b7e3fb1ead02cc550d0e97be0679 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sat, 10 Oct 2026 08:22:50 +0000 Subject: [PATCH 1/4] fix(cpp): a call a method makes with no receiver links to its class's own or inherited method (#2479) A C++ call written with no receiver, or through `this->`, inside a method was matched by its name alone, so another class's method of that name won by file proximity: protobuf's generated `Api::operator=` calling `InternalSwap(&from)` reached `Any::InternalSwap`, `Any::InternalSwap`'s inherited `GetArena()` reached `Arena::InternalHelper::GetArena`, and googletest's `~linked_ptr() { depart(); }` reached `linked_ptr_internal::depart`. matchMethodCall now resolves C++'s implicit `this` the way it resolves a typed receiver: the calling method's class, then the classes it derives from (its own base edges, through #2440's cppMethodOf), then the classes it is nested in, with the overload the arguments fit. The call's shape is read at its column (the extractor drops `this->` and some receivers it can't spell). A free function, a parameter or local of the name, a dependent base reached by a bare call, a namesake class in another translation unit and a member a macro may declare all leave the call to the name strategies. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/cpp-implicit-this-calls.test.ts | 524 ++++++++++++++++++++++ src/resolution/name-matcher.ts | 351 +++++++++++++++ 3 files changed, 876 insertions(+) create mode 100644 __tests__/cpp-implicit-this-calls.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f615431b3..82fdfc544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a class written with attribute macros the parser can't read now keeps its members. That covers several macros between `class` and the class name, like protocolbuffers/protobuf's generated `class PROTOBUF_EXPORT PROTOBUF_FUTURE_ADD_EARLY_WARN_UNUSED Any final`, a macro with arguments there, like RocksDB's `struct ALIGN_AS(64U) HandleImpl`, or one before a template specialization, and macros on a member: in front of it (`PROTOBUF_FUTURE_ADD_EARLY_NODISCARD`), between a pointer and its name (`PROTOBUF_NONNULL`), after its parameters or its name (`ABSL_ATTRIBUTE_LIFETIME_BOUND`, google/leveldb's `LOCKS_EXCLUDED(mu_)` and `GUARDED_BY(mu_)`), or alone on a line above a comment ({fmt}'s `FMT_BEGIN_EXPORT`). Before, protobuf's generated message classes were missing and their members, like `GetDescriptor`, `GetReflection` and `unknown_fields`, were indexed as functions outside any class, and leveldb's annotated methods were indexed under the annotation's name, so calls to them linked to nothing. Re-index C++ projects after upgrading. - In C++, a call on a range-based `for` loop's variable, like `suite->SetVerbose(verbose)` inside `for (ConformanceTestSuite *suite : suites)`, now links to the method of the type the loop declares. Before, the loop's declaration was never read, so the call was matched by the variable's name alone: in RocksDB, `log->file()` on a `log::Writer` went to `BlobLogWriter::file`, and a loop variable named like a member of its class was taken for that member. The variable counts only inside its loop, and loops over `auto` elements resolve as before. Re-index C++ projects after upgrading. - In C++, a call made with `->` on a variable, parameter or member declared as a `std::unique_ptr`, `std::shared_ptr` or `std::optional`, like `iter->Valid()` on a `std::unique_ptr iter`, now links to that method of the type it holds, or of a class that type inherits from, with the type looked up the way C++ does it, so a class of the same name nested elsewhere is not mistaken for it. Before, the call went to whichever project method shared its name: in facebook/rocksdb, `iter->Valid()` reached `ArenaWrappedDBIter::Valid` and `fs->GetFileSize(…)` on a `std::shared_ptr` reached a test's stand-in file system, and calls like `it->Next()` were often not linked at all. When what it holds is a template parameter or a standard-library type, like `std::unique_ptr>`, the call no longer links to a project method. Re-index C++ projects after upgrading. +- In C++, a call a method makes with no receiver, or through `this->`, now links to the method C++ itself would call: one of the method's own class, else one the class inherits from a base class, else one of a class it is nested in. The same goes for a call in a lambda inside the method, and of a class's overloads the one the call's arguments fit is taken. Before, the call was matched by its name alone, so another class's method of that name could win because it was declared nearer or found first: in protocolbuffers/protobuf, every generated message's `Swap` and move assignment calling `InternalSwap(…)` reached `Any::InternalSwap`, and the `GetArena()` messages inherit from `MessageLite` reached an arena helper's `GetArena`, while googletest's `~linked_ptr() { depart(); }`, vendored in facebook/rocksdb, reached `linked_ptr_internal::depart`. Calls C++ doesn't look up in the class are matched as before: a free function's, a call to a parameter or local of that name, and a template's bare call into a base class that depends on its template parameters. Re-index C++ projects after upgrading. Thanks @danusha2345 for reporting the C++ case in #1952. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In C and C++, a call written from the global scope, like `::leveldb::RepairDB(…)` or `::hpb::CreateMessage(…)`, now links to the function of exactly that name, and one through a namespace alias, like protobuf's `::_pbi::…` calls, to the function in the aliased namespace. Before, these calls were dropped unless the repository also held Swift and Objective-C files, and even then a name spelled out in full, like `::google::protobuf::internal::TSanWrite(…)`, linked to nothing, while `::operator delete(…)` linked to one class's own `operator delete`. A global call never links to a function or method of the same name inside a namespace or class, such as a wrapper `open()` that calls the C library's `::open(…)`, and `::std::…` and `::testing::…` calls stay unlinked unless that library's source is part of your project. Re-index C and C++ projects after upgrading. diff --git a/__tests__/cpp-implicit-this-calls.test.ts b/__tests__/cpp-implicit-this-calls.test.ts new file mode 100644 index 000000000..7d7edc923 --- /dev/null +++ b/__tests__/cpp-implicit-this-calls.test.ts @@ -0,0 +1,524 @@ +/** + * A C++ call written with no receiver, or on `this`, inside a member function + * is a call on the function's object: C++ looks the name up in the function's + * class, then in the classes it derives from, then in the classes it is nested + * in, and only then at namespace scope. The resolver matched it by name alone, + * so a same-named member of another class won by file proximity: + * - protobuf's generated `Api::operator=` calling `InternalSwap(&from)` went to + * `Any::InternalSwap`, in every generated message class; + * - `Any::InternalSwap` calling the `GetArena()` it inherits from `MessageLite` + * went to `Arena::InternalHelper::GetArena`; + * - googletest's `~linked_ptr() { depart(); }` (vendored in rocksdb) went to + * `linked_ptr_internal::depart`, declared just above it. + * Nothing changes for a call the lookup can't place: a free function's, one + * through a receiver the extractor dropped, one naming a parameter, or one + * into a base that depends on a template parameter. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +function writeProject(prefix: string, files: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return root; +} + +/** The edges carrying a call's name out of the function or method `qualifiedName` in `file`. */ +function callEdgesFrom(cg: CodeGraph, file: string, qualifiedName: string) { + const from = cg.getNodesInFile(file).filter((n) => n.qualifiedName === qualifiedName && (n.kind === 'function' || n.kind === 'method')); + expect(from.length).toBeGreaterThan(0); + return cg.getOutgoingEdgesFrom(from.map((n) => n.id)).flatMap((e) => { + const metadata = e.metadata as { refName?: string; resolvedBy?: string } | undefined; + return metadata?.refName ? [{ refName: metadata.refName, resolvedBy: metadata.resolvedBy, target: cg.getNode(e.target)!.qualifiedName }] : []; + }); +} + +/** `name -> target qualified name` for each call (or construction) the named function or method in `file` makes. */ +function callsFrom(cg: CodeGraph, file: string, qualifiedName: string): string[] { + return callEdgesFrom(cg, file, qualifiedName).map((e) => `${e.refName} -> ${e.target}`).sort(); +} + +/** Whether C++ name lookup from the calling function (not a guess by name) placed the call `refName`. */ +function placedByLookup(cg: CodeGraph, file: string, qualifiedName: string, refName: string): boolean { + return callEdgesFrom(cg, file, qualifiedName).some((e) => e.refName === refName && e.resolvedBy === 'instance-method'); +} + +const MESSAGE_LITE = `#pragma once +namespace pb { +class Arena { + public: + class InternalHelper { + public: + static Arena* GetArena(const void* object) { return nullptr; } + }; +}; + +class MessageLite { + public: + Arena* GetArena() const { return arena_; } + void Clear() {} + + private: + Arena* arena_ = nullptr; +}; + +class Message : public MessageLite {}; +} // namespace pb +`; + +// Two generated message classes, each defining InternalSwap out of line; +// Any's comes first in the index. +const ANY_H = `#pragma once +#include "pb/message.h" +namespace pb { +class Any final : public Message { + public: + Any& operator=(Any&& from) noexcept { + InternalSwap(&from); + return *this; + } + void InternalSwap(Any* other); +}; +} // namespace pb +`; + +const API_H = `#pragma once +#include "pb/message.h" +namespace pb { +class Api final : public Message { + public: + Api& operator=(Api&& from) noexcept { + InternalSwap(&from); + return *this; + } + void Swap(Api* other) { + if (other == this) return; + this->InternalSwap(other); + } + void InternalSwap(Api* other); +}; +} // namespace pb +`; + +describe('C++ calls on the calling function’s own object', () => { + let root = ''; + let cg: CodeGraph; + + beforeAll(async () => { + root = writeProject('cg-cpp-this-', { + 'src/pb/message.h': MESSAGE_LITE, + 'src/pb/any.pb.h': ANY_H, + 'src/pb/any.pb.cc': `#include "pb/any.pb.h" +namespace pb { +void Any::InternalSwap(Any* other) { + auto* arena = GetArena(); + (void)arena; +} +} // namespace pb +`, + 'src/pb/api.pb.h': API_H, + 'src/pb/api.pb.cc': `#include "pb/api.pb.h" +namespace pb { +void Api::InternalSwap(Api* other) { + (*this).Clear(); +} +} // namespace pb +`, + 'third_party/gtest/linked_ptr.h': `#pragma once +namespace testing { +namespace internal { +class linked_ptr_internal { + public: + bool depart() { return next_ == this; } + + private: + mutable linked_ptr_internal const* next_; +}; + +template +class linked_ptr { + public: + ~linked_ptr() { depart(); } + void reset(T* ptr = nullptr) { + depart(); + value_ = ptr; + } + + T* get() const { return value_; } + T* operator->() const { return value_; } + T& operator*() const { return *value_; } + + bool operator==(T* p) const { return value_ == p; } + bool operator!=(T* p) const { return value_ != p; } + + template + bool operator==(linked_ptr const& ptr) const { + return value_ == ptr.get(); + } + template + bool operator!=(linked_ptr const& ptr) const { + return value_ != ptr.get(); + } + + private: + template + friend class linked_ptr; + + // Takes ownership of p, which must be the only linked_ptr to it: + // the link starts out as a one-element ring. + void capture(T* p) { + value_ = p; + } + + // Joins the ring another linked_ptr belongs to, sharing what it points to. + template + void copy(linked_ptr const* ptr) { + value_ = ptr->get(); + } + + // Leaves the ring; the last one out deletes the object. + void depart() { + if (link_.depart()) delete value_; + } + + T* value_; + linked_ptr_internal link_; +}; +} // namespace internal +} // namespace testing +`, + }); + cg = await CodeGraph.init(root, { index: true }); + }, 60_000); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('links a bare call to the calling class’s own method, not another class’s namesake', () => { + expect(callsFrom(cg, 'src/pb/api.pb.h', 'pb::Api::operator=')).toEqual(['InternalSwap -> pb::Api::InternalSwap']); + expect(callsFrom(cg, 'src/pb/any.pb.h', 'pb::Any::operator=')).toEqual(['InternalSwap -> pb::Any::InternalSwap']); + }); + + it('links a call on `this->` or `(*this).` the same way', () => { + expect(callsFrom(cg, 'src/pb/api.pb.h', 'pb::Api::Swap')).toEqual(['InternalSwap -> pb::Api::InternalSwap']); + expect(callsFrom(cg, 'src/pb/api.pb.cc', 'pb::Api::InternalSwap')).toEqual(['Clear -> pb::MessageLite::Clear']); + }); + + it('links a method the class inherits, through its own base classes', () => { + expect(callsFrom(cg, 'src/pb/any.pb.cc', 'pb::Any::InternalSwap')).toEqual(['GetArena -> pb::MessageLite::GetArena']); + }); + + it('prefers the class’s own method over a namesake declared nearer the call', () => { + expect(callsFrom(cg, 'third_party/gtest/linked_ptr.h', 'testing::internal::linked_ptr::~linked_ptr')) + .toEqual(['depart -> testing::internal::linked_ptr::depart']); + expect(callsFrom(cg, 'third_party/gtest/linked_ptr.h', 'testing::internal::linked_ptr::reset')) + .toEqual(['depart -> testing::internal::linked_ptr::depart']); + }); +}); + +describe('C++ name lookup from a member function', () => { + let root = ''; + let cg: CodeGraph; + + beforeAll(async () => { + root = writeProject('cg-cpp-this-scopes-', { + 'src/db/db.h': `#pragma once +#include +namespace db { +class Stats { + public: + static int Count() { return 1; } +}; + +class Table { + public: + static int Count(); + void Flush(); + void Compact(); + void Reset() {} + void Close() {} + void OnDone(std::function Close) { Close(); } + + class Builder { + public: + int Size() const { return Count(); } + }; +}; + +struct Item { + void Clear() {} +}; +} // namespace db +`, + 'src/db/table.cc': `#include "db/db.h" +namespace db { +static void Reset() {} +static void Trim() {} + +class Job { + public: + void Compact() {} +}; + +void Table::Flush() { + Reset(); + Trim(); + auto finish = [this]() { Compact(); }; + finish(); +} + +void Rebuild() { + Trim(); + Reset(); +} +} // namespace db +`, + 'src/db/compact.cc': `#include "db/db.h" +namespace db { +int Table::Count() { return 0; } + +void Table::Compact() { + Item items[2]; + items[0].Clear(); +} +} // namespace db +`, + }); + cg = await CodeGraph.init(root, { index: true }); + }, 60_000); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('takes the class’s member over a file-static function of its name, and the function when the class has none', () => { + const calls = callsFrom(cg, 'src/db/table.cc', 'db::Table::Flush'); + expect(calls).toContain('Reset -> db::Table::Reset'); + expect(calls).toContain('Trim -> db::Trim'); + }); + + it('links a bare call in a lambda to the member function’s class', () => { + expect(callsFrom(cg, 'src/db/table.cc', 'db::Table::Flush')).toContain('Compact -> db::Table::Compact'); + }); + + it('links a nested class’s bare call to the enclosing class’s static member', () => { + expect(callsFrom(cg, 'src/db/db.h', 'db::Table::Builder::Size')).toEqual(['Count -> db::Table::Count']); + }); + + it('leaves a free function, static or not, to find what its namespace declares', () => { + expect(callsFrom(cg, 'src/db/table.cc', 'db::Rebuild')).toEqual(['Reset -> db::Reset', 'Trim -> db::Trim']); + }); + + it('never takes a receiver the extractor dropped for the calling object', () => { + expect(callsFrom(cg, 'src/db/compact.cc', 'db::Table::Compact')).toEqual(['Clear -> db::Item::Clear']); + }); + + it('does not take a parameter named like a member for the member', () => { + expect(placedByLookup(cg, 'src/db/db.h', 'db::Table::OnDone', 'Close')).toBe(false); + }); +}); + +describe('C++ overloads a call’s arguments pick', () => { + let root = ''; + let cg: CodeGraph; + + beforeAll(async () => { + root = writeProject('cg-cpp-this-overloads-', { + 'include/opts/configurable.h': `#pragma once +#include +namespace opts { +class Configurable { + public: + template + void RegisterOptions(T* opt_ptr, const int* opt_map) { + RegisterOptions(T::kClassName(), opt_ptr, opt_map); + } + void RegisterOptions(const std::string& name, void* opt_ptr, const int* opt_map); + + void Add(int key) { Add(key, 1); } + void Add(int key, int count); +}; +} // namespace opts +`, + 'src/opts/configurable.cc': `#include "opts/configurable.h" +namespace opts { +void Configurable::RegisterOptions(const std::string& name, void* opt_ptr, const int* opt_map) {} +void Configurable::Add(int key, int count) {} +} // namespace opts +`, + 'test/simple.cc': `#include "opts/configurable.h" +namespace opts { +class SimpleConfigurable : public Configurable { + public: + SimpleConfigurable() { + RegisterOptions(std::string("Simple") + "Unique", &unique_, &info_); + } + + private: + int unique_ = 0; + int info_ = 0; +}; +} // namespace opts +`, + }); + cg = await CodeGraph.init(root, { index: true }); + }, 60_000); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('takes the inherited overload the arguments fit', () => { + // Both overloads are `opts::Configurable::RegisterOptions`: the three-parameter one is defined in the .cc. + const threeParameters = cg.getNodesInFile('src/opts/configurable.cc').find((n) => n.name === 'RegisterOptions'); + const ctor = cg.getNodesInFile('test/simple.cc').find((n) => n.qualifiedName === 'opts::SimpleConfigurable::SimpleConfigurable'); + const edge = cg.getOutgoingEdges(ctor!.id).find((e) => (e.metadata as { refName?: string } | undefined)?.refName === 'RegisterOptions'); + expect(edge?.target).toBe(threeParameters?.id); + }); + + it('weighs a class’s out-of-line overloads with its inline ones', () => { + const add = cg.getNodesInFile('include/opts/configurable.h').find((n) => n.name === 'Add'); + const outOfLine = cg.getNodesInFile('src/opts/configurable.cc').find((n) => n.name === 'Add'); + const edge = cg.getOutgoingEdges(add!.id).find((e) => (e.metadata as { refName?: string } | undefined)?.refName === 'Add'); + expect(edge?.target).toBe(outOfLine?.id); + }); +}); + +describe('C++ classes the index can only see part of', () => { + let root = ''; + let cg: CodeGraph; + + beforeAll(async () => { + root = writeProject('cg-cpp-this-partial-', { + // Two translation units each declare a `FileState` of their own. + 'helpers/memenv.cc': `namespace kv { +namespace { +class FileState { + public: + void Truncate() {} + ~FileState() { Truncate(); } +}; +} // namespace +} // namespace kv +`, + 'db/fault_injection_test.cc': `namespace kv { +namespace { +int Truncate(const char* name, long length) { return 0; } + +struct FileState { + const char* filename_; + long pos_; + int DropUnsyncedData() const; +}; +} // namespace + +int FileState::DropUnsyncedData() const { + return Truncate(filename_, pos_); +} +} // namespace kv +`, + 'src/reflection.cc': `#define LOCAL_VAR_ACCESSOR(type, name) \\ + type Get##name() const { return value; } + +namespace pb { +class Message {}; + +class Reflection { + public: + Message* GetMessage() const { return nullptr; } + void SwapField() const; +}; + +void Reflection::SwapField() const { + struct LocalVarWrapper { + LOCAL_VAR_ACCESSOR(Message*, Message); + Message* UnsafeGetMessage() const { return GetMessage(); } + Message* value; + }; +} +} // namespace pb +`, + }); + cg = await CodeGraph.init(root, { index: true }); + }, 60_000); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('never lends one translation unit’s class members to another’s class of the same name', () => { + expect(callsFrom(cg, 'helpers/memenv.cc', 'kv::FileState::~FileState')).toEqual(['Truncate -> kv::FileState::Truncate']); + expect(placedByLookup(cg, 'db/fault_injection_test.cc', 'kv::FileState::DropUnsyncedData', 'Truncate')).toBe(false); + expect(callsFrom(cg, 'db/fault_injection_test.cc', 'kv::FileState::DropUnsyncedData')).not.toContain('Truncate -> kv::FileState::Truncate'); + }); + + it('stops at a class whose macro may declare the name, rather than take an outer class’s', () => { + const wrapper = cg.getNodesInFile('src/reflection.cc').find((n) => n.name === 'UnsafeGetMessage'); + expect(wrapper).toBeDefined(); + expect(placedByLookup(cg, 'src/reflection.cc', wrapper!.qualifiedName, 'GetMessage')).toBe(false); + }); +}); + +describe('C++ name lookup in a class template', () => { + let root = ''; + let cg: CodeGraph; + + beforeAll(async () => { + root = writeProject('cg-cpp-this-template-', { + 'src/util/list.h': `#pragma once +namespace util { +template +class ListBase { + public: + void Grow(int n) {} + void Shrink() {} +}; + +inline void Grow(int n) {} + +template +class List : public ListBase { + public: + void Push() { + Grow(1); + this->Shrink(); + } +}; + +class IntList : public ListBase { + public: + void Push() { Grow(1); } +}; +} // namespace util +`, + }); + cg = await CodeGraph.init(root, { index: true }); + }, 60_000); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('looks a bare name up in a base that depends on a template parameter only when written `this->`', () => { + expect(callsFrom(cg, 'src/util/list.h', 'util::List::Push')).toContain('Shrink -> util::ListBase::Shrink'); + expect(placedByLookup(cg, 'src/util/list.h', 'util::List::Push', 'Shrink')).toBe(true); + // `ListBase` is not searched for a bare `Grow(1)`: C++ finds the namespace's `util::Grow`. + expect(placedByLookup(cg, 'src/util/list.h', 'util::List::Push', 'Grow')).toBe(false); + }); + + it('looks it up in a base with no template parameter of the class in it', () => { + expect(callsFrom(cg, 'src/util/list.h', 'util::IntList::Push')).toEqual(['Grow -> util::ListBase::Grow']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 2cfcd124c..079139e45 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -9218,6 +9218,351 @@ function cppMethodOf(cls: Node, name: string, ref: UnresolvedRef, context: Resol return null; } +/** + * A C++ call written with no receiver, or on `this` (`this->Clear()`, + * `(*this).Clear()`), in a member function — or in a lambda in one, whose + * calls the extractor gives to the function — calls what C++ name lookup + * finds there: a member of the function's class, else of a class it derives + * from, else of a class it is nested in, before anything at namespace scope. + * By its name alone, protobuf's generated `Api::operator=` calling + * `InternalSwap(&from)` reached `Any::InternalSwap`, and `Any::InternalSwap` + * calling the `GetArena()` it inherits from `MessageLite` reached + * `Arena::InternalHelper::GetArena`. + * - Bases are the class's own base edges, so a namesake class elsewhere + * lends it nothing. A bare call skips a base that depends on the class's + * template parameters, which C++ doesn't look in (`this->` is how such a + * member is called). + * - `this` is an object of the innermost class: never an enclosing class's. + * - A parameter or local the function declares before a bare call is what + * it calls: `void Set(Callback callback) { callback(); }`. + * - A class that may declare the name through a macro (`Get##name`), which + * no node shows, ends the walk. + * Undefined when the call is not of that shape or no class on the way has a + * method of that name in the index — a free function, `static` or not, has + * no class; a base outside the project lends no node: the name strategies + * decide, as before. + */ +function matchCppImplicitThisCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | undefined { + const name = ref.referenceName; + if (ref.referenceKind !== 'calls' || !/^[A-Za-z_]\w*$/.test(name)) return undefined; + const receiver = cppImplicitReceiver(ref, context); + if (receiver === null) return undefined; + const caller = context.getNodeById?.(ref.fromNodeId); + const owner = caller ? cppMemberOwner(caller, context) : null; + if (!caller || owner === null) return undefined; + let found: Node | null | undefined = null; + for (const scope of cppEnclosingClasses(owner, caller, context)) { + found = cppScopeMethod(scope, name, receiver, ref, context); + if (found !== null || receiver === 'this') break; + } + // (A recursive call names the function itself, which no local shadows.) + if (!found || (receiver === 'bare' && name !== caller.name && isCppLocalName(name, caller, ref, context))) return undefined; + return { original: ref, targetNodeId: found.id, confidence: 0.9, resolvedBy: 'instance-method' }; +} + +/** `this->` / `(*this).` before a member's name, with a `template` keyword after it. */ +const CPP_THIS_RECEIVER = /^(?:this\s*->|\(\s*\*\s*this\s*\)\s*\.)\s*(?:template\s+)?/; + +/** + * How a C++ call the extractor recorded by its bare name is written, read at + * its column: `'bare'` with no receiver, `'this'` on `this->` / `(*this).`, + * null for anything else. The extractor also drops receivers it can't spell + * (`arr_[0].Foo()`, `(p_)->Foo()`, `this->p_->Foo()`), whose calls are no + * member of the caller's class; and a name its argument list doesn't follow + * is no call of it. + */ +function cppImplicitReceiver(ref: UnresolvedRef, context: ResolutionContext): 'bare' | 'this' | null { + const line = (context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/))?.[ref.line - 1]; + if (line === undefined) return null; + const at = (column: number): 'bare' | 'this' | null => { + const text = line.slice(column); + const self = CPP_THIS_RECEIVER.exec(text)?.[0] ?? ''; + const before = line.slice(0, column).replace(/\s+$/, ''); + // A column past the receiver: on `this`, or some other object's call. + const onThis = self !== '' || /(?:^|[^\w$])(?:this\s*->|\(\s*\*\s*this\s*\)\s*\.)$/.test(before); + if (!onThis && /(?:\.|->|::)$/.test(before)) return null; + const rest = text.slice(self.length); + if (!rest.startsWith(ref.referenceName) || /[\w$]/.test(rest.charAt(ref.referenceName.length))) return null; + return cppArgumentListFollows(rest, ref.referenceName.length) ? (onThis ? 'this' : 'bare') : null; + }; + const verdict = at(Math.max(0, ref.column)); + if (verdict !== null || !/[^\x00-\x7f]/.test(line)) return verdict; + // The column may count UTF-8 bytes, which wider characters before the call outnumber. + let bytes = 0; + let index = 0; + while (index < line.length && bytes < ref.column) { + const point = line.codePointAt(index)!; + bytes += point < 0x80 ? 1 : point < 0x800 ? 2 : point < 0x10000 ? 3 : 4; + index += point > 0xffff ? 2 : 1; + } + return bytes === ref.column && index !== ref.column ? at(index) : null; +} + +/** Whether `text` from `from` on is an argument list, after any template arguments (`(`). */ +function cppArgumentListFollows(text: string, from: number): boolean { + let i = from; + while (i < text.length && /\s/.test(text[i]!)) i++; + if (text[i] === '<') { + let depth = 0; + for (; i < text.length; i++) { + const c = text[i]!; + if (c === '<') depth++; + else if (c === '>' && --depth === 0) break; + else if (c === ';' || c === '{' || c === '}') return false; + } + if (depth !== 0) return false; + i++; + while (i < text.length && /\s/.test(text[i]!)) i++; + } + return text[i] === '('; +} + +/** The C and C++ class, struct and union declarations of a qualified name. */ +function cppClassDecls(qualifiedName: string, context: ResolutionContext): Node[] { + return context.getNodesByQualifiedName(qualifiedName).filter((n) => + CPP_CLASS_KINDS.has(n.kind) && (n.language === 'cpp' || n.language === 'c')); +} + +/** + * The qualified name of the class whose scope a C++ caller's body is in: a + * member function's, defined in the class or out of line (`void + * Api::InternalSwap(…) {…}`), static or not — or a class's own, for code in + * its body. A function node counts only when its definition is written + * `Class::name(…)`. Null for a free function, and for one defined through a + * namespace (`void detail::helper() {}` is a method node of no class). + */ +function cppMemberOwner(caller: Node, context: ResolutionContext): string | null { + if (CPP_CLASS_KINDS.has(caller.kind)) return caller.qualifiedName; + if (caller.kind !== 'method' && caller.kind !== 'function') return null; + const cut = caller.qualifiedName.lastIndexOf('::'); + if (cut <= 0) return null; + const owner = caller.qualifiedName.slice(0, cut); + if (caller.kind === 'function') { + const lines = context.getFileLines?.(caller.filePath) ?? context.readFile(caller.filePath)?.split(/\r?\n/); + const head = lines?.slice(caller.startLine - 1, caller.startLine + 2).join(' ') ?? ''; + const cls = cppLastSegment(owner).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const fn = caller.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if (!new RegExp(`\\b${cls}\\s*(?:<[^<>;{}]*>\\s*)?::\\s*~?${fn}\\s*\\(`).test(head)) return null; + } + if (cppClassDecls(owner, context).length > 0) return owner; + // A class declared in another and defined elsewhere, which the index has + // no node for: protobuf's `Any::_Internal`, declared in `Any`. + const outer = owner.lastIndexOf('::'); + return outer > 0 && cppClassDecls(owner.slice(0, outer), context).length > 0 ? owner : null; +} + +/** One class C++ lookup passes through, with the declarations that are the class there. */ +interface CppClassScope { + qualifiedName: string; + decls: Node[]; + /** `decls[0]` holds the code the lookup starts from. */ + holds: boolean; + /** Other classes of that qualified name were left out: another translation unit's. */ + narrowed: boolean; +} + +/** + * The classes a C++ caller's name lookup passes through, innermost first: + * the caller's class, then each class it is nested in. After a class + * declared in a function body comes that function's class. Several classes + * can share a qualified name — another translation unit's local fixture or + * helper — so each scope keeps only the declaration holding the code, else + * those in its file, else those its file includes, else all of them. + */ +function cppEnclosingClasses(owner: string, caller: Node, context: ResolutionContext): CppClassScope[] { + const scopes: CppClassScope[] = []; + let qualifiedName = owner; + let inner = caller; + for (let hops = 0; hops < 8 && qualifiedName !== ''; hops++) { + const decls = cppClassDecls(qualifiedName, context); + if (decls.length === 0 && hops > 0) { + // A namespace: lookup leaves the classes. Unless it is the function a + // local class is declared in (`Clear::Local`), whose class comes next. + const fn = (context.getNodesInFileNamed?.(inner.filePath, cppLastSegment(qualifiedName)) ?? []) + .find((n) => (n.kind === 'method' || n.kind === 'function') && rangeWithin(inner, n) && !sameRange(inner, n)); + const fnOwner = fn ? cppMemberOwner(fn, context) : null; + if (!fn || fnOwner === null) break; + qualifiedName = fnOwner; + inner = fn; + continue; + } + const scope = cppScopeDeclarations(qualifiedName, decls, inner, context); + scopes.push(scope); + if (scope.decls.length > 0) inner = scope.decls[0]!; + const cut = qualifiedName.lastIndexOf('::'); + qualifiedName = cut > 0 ? qualifiedName.slice(0, cut) : ''; + } + return scopes; +} + +/** A class scope's declarations as cppEnclosingClasses keeps them. */ +function cppScopeDeclarations(qualifiedName: string, decls: Node[], inner: Node, context: ResolutionContext): CppClassScope { + const scope = (kept: Node[], holds: boolean): CppClassScope => ({ qualifiedName, decls: kept, holds, narrowed: kept.length < decls.length }); + // (Code in a class's own body, a member's initializer, has that class as `inner`.) + const holding = decls.filter((d) => d.filePath === inner.filePath && rangeWithin(inner, d)); + if (holding.length > 0) return scope(holding, true); + const local = decls.filter((d) => d.filePath === inner.filePath); + if (local.length > 0 || decls.length < 2) return scope(local.length > 0 ? local : decls, false); + const included = decls.filter((d) => cppIncluders(d.filePath, context).has(inner.filePath)); + return scope(included.length > 0 ? included : decls, false); +} + +/** + * The method `name` a class scope gives C++ lookup: the class's own — first + * one written in the very declaration holding the code, as an inline member + * — else the nearest one of a class it derives from, through the kept + * declarations' base edges. Undefined when the class may declare `name` + * through a macro, which no node shows: lookup would stop there. + */ +function cppScopeMethod(scope: CppClassScope, name: string, receiver: 'bare' | 'this', ref: UnresolvedRef, context: ResolutionContext): Node | null | undefined { + const own = context.getNodesByQualifiedName(`${scope.qualifiedName}::${name}`).filter((n) => + n.kind === 'method' && (n.language === 'cpp' || n.language === 'c') && + // A namesake class's members are defined where it is: leveldb's + // fault-injection test declares a `FileState` of its own, with no + // `Truncate`, beside memenv.cc's. + (!scope.narrowed || scope.decls.some((d) => d.filePath === n.filePath || cppIncluders(d.filePath, context).has(n.filePath)))); + const holder = scope.holds ? scope.decls[0]! : null; + const inline = holder ? own.filter((n) => n.filePath === holder.filePath && rangeWithin(n, holder)) : []; + // A class with no name (a partial specialization, an unnamed struct) + // shares its qualified name with every other: only its inline members are its own. + const outOfLine = cppLastSegment(scope.qualifiedName) === '' ? [] : own.filter((n) => !inline.includes(n)); + if (inline.length + outOfLine.length > 0) { + return cppOverloadFor([...inline, ...preferCallSiteFile(outOfLine, ref.filePath)], name, ref, context); + } + if (scope.decls.some((d) => cppMemberMacroArguments(d, context).some((a) => name.startsWith(a) || name.endsWith(a)))) return undefined; + if (!context.getSupertypeNodes) return null; + for (const decl of scope.decls) { + for (const base of context.getSupertypeNodes(decl.id)) { + if (!CPP_CLASS_KINDS.has(base.kind)) continue; + if (receiver === 'bare' && isCppDependentBase(decl, base, context)) continue; + const inherited = cppMethodOf(base, name, ref, context, 1, new Set([scope.qualifiedName])); + if (!inherited) continue; + // Its class's other overloads, which the arguments may fit better + // (rocksdb's `RegisterOptions(name, &opts, &info)` is not the + // two-parameter template beside it). + const siblings = context.getNodesByQualifiedName(inherited.qualifiedName).filter((n) => + n.id !== inherited.id && n.kind === 'method' && (n.language === 'cpp' || n.language === 'c')); + return cppOverloadFor([inherited, ...preferCallSiteFile(siblings, ref.filePath)], name, ref, context); + } + } + return null; +} + +/** Of one class's overloads of `name` (in preference order), the one the call's arguments fit best. */ +function cppOverloadFor(overloads: Node[], name: string, ref: UnresolvedRef, context: ResolutionContext): Node { + if (overloads.length === 1) return overloads[0]!; + const args = cppCallArguments(ref, name, context); + if (!args) return overloads[0]!; + let best = overloads[0]!; + let bestFit = cppOverloadFit(best, name, args, context); + for (const n of overloads.slice(1)) { + const fit = cppOverloadFit(n, name, args, context); + if (fit > bestFit) { + best = n; + bestFit = fit; + } + } + return best; +} + +const CPP_MEMBER_MACRO_ARGS = new WeakMap>(); + +/** + * The words in the arguments of the macros a C++ class declaration calls + * among its members, like `Message` in protobuf's + * `LOCAL_VAR_ACCESSOR(Message*, Message);`: such a macro can declare a member + * the index has no node for (`Get##name` makes `GetMessage`). + */ +function cppMemberMacroArguments(decl: Node, context: ResolutionContext): string[] { + let memo = CPP_MEMBER_MACRO_ARGS.get(context); + if (!memo) CPP_MEMBER_MACRO_ARGS.set(context, (memo = new Map())); + const hit = memo.get(decl.id); + if (hit) return hit; + const words: string[] = []; + const lines = context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split(/\r?\n/) ?? []; + let depth = 0; + for (let i = decl.startLine - 1; i < Math.min(lines.length, decl.endLine); i++) { + const code = cppCodeOf(i === decl.startLine - 1 ? lines[i]!.slice(decl.startColumn) : lines[i]!) ?? ''; + const call = depth === 1 ? /^\s*[A-Z][A-Z0-9_]*\s*\(([^;{}]*)\)\s*;?\s*$/.exec(code) : null; + if (call) words.push(...call[1]!.split(/\W+/).filter((w) => w.length >= 3)); + for (const c of code) { + if (c === '{') depth++; + else if (c === '}') depth--; + } + } + memo.set(decl.id, words); + return words; +} + +const CPP_BASE_SPECS = new WeakMap>(); + +/** + * The base specifiers a C or C++ class declaration writes (`public + * Base`, `private Mixin`), read from its own head; none for a forward + * declaration. + */ +function cppBaseSpecifiers(decl: Node, context: ResolutionContext): string[] { + let memo = CPP_BASE_SPECS.get(context); + if (!memo) CPP_BASE_SPECS.set(context, (memo = new Map())); + const hit = memo.get(decl.id); + if (hit) return hit; + const specs: string[] = []; + const lines = context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split(/\r?\n/); + const head = lines?.slice(decl.startLine - 1, decl.startLine + 11) ?? []; + if (head.length > 0) { + head[0] = head[0]!.slice(decl.startColumn); + const text = head.map((l) => cppCodeOf(l) ?? '').join('\n'); + const brace = text.indexOf('{'); + const colon = brace < 0 ? -1 : text.slice(0, brace).search(/(?= 0 && !text.slice(0, brace).includes(';')) { + specs.push(...splitCppTopLevel(text.slice(colon + 1, brace)).map((s) => s.replace(/\b(?:public|protected|private|virtual)\b/g, ' ').trim())); + } + } + memo.set(decl.id, specs); + return specs; +} + +/** + * Whether `base`, a base of the C++ class declaration `decl`, depends on the + * template parameters of `decl` or a class around it: `template + * class Foo : public Base`. A bare name is not looked up in such a base. + */ +function isCppDependentBase(decl: Node, base: Node, context: ResolutionContext): boolean { + const parameters = cppTemplateParameters(decl, context); + if (parameters.size === 0) return false; + const spec = cppBaseSpecifiers(decl, context).find((s) => cppLastSegment(stripCppTemplateArguments(s).trim()) === base.name); + return spec !== undefined && [...parameters].some((p) => new RegExp(`(? done;` reads as a declaration once its template arguments are gone. + for (const variant of [code, stripCppTemplateArguments(code)]) { + const type = declarator.exec(variant)?.[1]; + // `Owner::name(` is a definition's own declarator; `return name(` no declaration. + if (type !== undefined && !/::\s*$/.test(type) && normalizeCppTypeName(type) !== null) return true; + } + } + return false; +} + /** * A C++ source line as code, for reading declarations: null for a line of a * comment (`// …`, ` * …`), else the line with its comments blanked, columns @@ -9861,6 +10206,8 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { KOTLIN_FRAMES.delete(context); clearCppNamespaceMemos(context); CPP_CLASSES_IN_FILE.delete(context); + CPP_BASE_SPECS.delete(context); + CPP_MEMBER_MACRO_ARGS.delete(context); SOLIDITY_SUPERS.delete(context); DECLARED_SUPERS.delete(context); INHERITED_METHODS.delete(context); @@ -10989,6 +11336,10 @@ export function matchMethodCall( const match = dotMatch || colonMatch || luaColonMatch || rDollarMatch; if (!match) { + // A C++ call written with no receiver, or on `this`, in a member function + // is a call on the function's object: a member of its class, of a class + // it derives from or of one it is nested in, when one has that name. + if (ref.language === 'cpp') return nmTimedT('mc-cppthis', ref, () => matchCppImplicitThisCall(ref, context)) ?? null; return null; } From a9781b0e054918c262c00872d1a2d76f8192bbf1 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sat, 10 Oct 2026 08:47:19 +0000 Subject: [PATCH 2/4] fix(react-router): a lazy route links the page its loader picks (#2480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A data-router `lazy` loader resolves to the route's properties, and many pick their page from the module they load: luci-go milo/ui's `lazy: async () => { const { TestTab } = await import('…/tabs'); return { Component: TestTab }; }`, `const { default: Component } = await import('./pages/Login')`, `import('./x').then((m) => ({ Component: m.Page }))`, `({ Component: (await import('./x')).Page })`, React Router 7's `lazy: { Component: async () => (await import('./x')).Page }`. scanRoutes recorded only the first `import('…')` as `lazy-import:`, and resolution linked the module's default export, else its `Component`: a different page, or nothing (milo/ui's tabs barrel has neither). lazyRouteReference reads the loader: its `Component`, or the page its `element` shows, followed through the loader's bindings (destructured `await import`, a module binding, `.then` callbacks, `Promise.all`, the v7.5 object form), and the one lazily imported export a guard shows (``). A loader that returns the module keeps `lazy-import:`; one this does not read (a helper's, `.then(convert)`) keeps its first import, as before; one that hands over only a `loader` renders nothing. `async lazy() { … }` methods, which scanRoutes skipped as entries, are read too, and comments no longer hide an import's specifier. The picked export is named the way Vue Router and Angular name a lazy component, `import:#`, so sync's module-tail retry (#2422) parks and retries it unchanged, and it resolves through #2436's exportedComponent, barrels included. React is registered before Vue Router and Angular, so it answers only its own routes' references (tsx or jsx, which theirs never are). lazyModules (#2452) names the files a picked export is read from, so a sync that moves it redraws the route. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/react-router-lazy-member.test.ts | 525 +++++++++++++++++++++ docs/design/framework-coverage.md | 16 +- src/resolution/frameworks/react.ts | 501 +++++++++++++++++++- 4 files changed, 1026 insertions(+), 17 deletions(-) create mode 100644 __tests__/react-router-lazy-member.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 82fdfc544..8b6389bf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - React Router routes written as JSX now read `` the way route objects do: inside `}>`, `} />` is the page at `/`, and `Layout` counts as the layout of every route nested in it, as does a guard with no path of its own like `}>`. Before, the index route was never read, so `/` led to the layout itself and a `` holding only an index route led to nothing. A route's `element` written on lines of its own, the way Prettier formats a long one, or wrapped in `}>`, now links to the page it shows rather than to nothing or to the fallback, and a path written in braces, like `path={"agents"}`, is read like any other. Re-index React projects after upgrading. - In JavaScript and TypeScript, a module loaded with `require('./x').default` or `const { default: X } = require('./x')` now links to its default export when it is written as an ES module: bitwarden's desktop app loads `export default class OsBiometricsServiceMac` this way before calling `new OsBiometricsServiceMac(…)`, and React Native's own libraries load many of their modules like this. Before, `.default` was looked up only as an export named `default`, which `export default` is not, so the `new` and the calls made through it linked to nothing, to the local variable holding the module, or to a method of the same name somewhere else. A Svelte component loaded this way and created with `new App({ target })` now links to the component. A CommonJS module that sets `exports.default` itself, or both `module.exports` and `module.exports.default` as fastify does, still links to what it sets, and one that sets no `default` still links to nothing. Re-index JavaScript and TypeScript projects after upgrading. - A React Router route that lazily loads a folder, like `lazy: () => import('@/pages/invocation_page')`, now links to the page the folder's `index.ts` passes on, whether it writes `export * from './invocation_page'`, `export { default } from './page'` or `export { Page as Component } from './page'`, even through another `index.ts`. A route module that names its page with `export { Page as Component }` links to it as well, and so does a page loaded with `lazy(() => import(…))` through such an `index.ts`. Before, these routes linked to nothing, so their pages had no route leading to them. Re-index React projects after upgrading. +- A React Router route whose `lazy` loader picks its page from the module it loads now links to that page: `lazy: async () => { const { TestTab } = await import('./tabs'); return { Component: TestTab }; }`, `const { default: Component } = await import('./pages/Login')`, `import('./reports').then((m) => ({ Component: m.ReportList }))`, `({ Component: (await import('./reports')).ReportPage })`, React Router 7's `lazy: { Component: async () => (await import('./show')).Show }`, and a page the loader shows inside a guard, like `element: `. Before, such a route linked to the module's default export, else its `Component` export, which is a different page or none at all, as with luci-go's tabs loaded from a folder that forwards them. A loader written as a method, `async lazy() { … }`, is now read too, so its route is no longer missing, and a loader that hands React Router only a `loader` no longer makes the module's default export the route's page or the layout of the routes inside it. Re-index React projects after upgrading. - In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way. - In React, a component's JSX no longer links it to a class or component that only shares a name with one of its type arguments or with a variable of its own. A type in angle brackets, like `Document` in ` items={…} />`, `User` in `useState()` or `Entry` in a generic `(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `` in `widgets.map((Widget) => )`, now links to nothing, unless the component declares a component of that name inside itself. Before, these linked to an unrelated class or component elsewhere in the repository: outline's document lists showed up among the callers of its `Document` model class, and its menus among the callers of the command bar's `Content`, so `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading. - In JavaScript and TypeScript, a name imported from a package your `package.json` lists no longer links to a project symbol that only shares its name when `tsconfig.json` or `jsconfig.json` has a catch-all path alias, like `"*": ["./typings/*"]` or `"*": ["src/*", "node_modules/*"]`, or an alias that points the package at a file in `node_modules`, like `"lit/decorators": ["./node_modules/lit/decorators.js"]`. Such an alias made every package look like part of the project, so `import { Typography } from '@mui/material'` was linked to the project's own `Typography` and every lit `@property()` decorator to an unrelated class's `property` field, and `codegraph callers`, impact and `codegraph affected` listed code that never used them. An import the alias does map to a file of your project, like a `.d.ts` you keep for an untyped package or `components/Button` through `"*": ["src/*"]`, links as before. Re-index affected projects after upgrading. diff --git a/__tests__/react-router-lazy-member.test.ts b/__tests__/react-router-lazy-member.test.ts new file mode 100644 index 000000000..d20ecae98 --- /dev/null +++ b/__tests__/react-router-lazy-member.test.ts @@ -0,0 +1,525 @@ +/** + * A data-router `lazy` loader that picks the export a route renders (luci-go's + * milo/ui): `lazy: async () => { const { TestTab } = await import('…/tabs'); + * return { Component: TestTab }; }`. The route renders `TestTab`, which the + * `tabs` barrel forwards; before, the route was linked to the module's default + * export, else its `Component` export, and a barrel of tabs has neither. + */ +import { describe, it, expect, afterAll, afterEach, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-lazy-member-')); + const files: Record = { + 'package.json': JSON.stringify({ name: 'ui', private: true, dependencies: { react: '^18', 'react-router': '^7' } }), + 'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['./src/*'] } } }), + 'src/router.tsx': `import { createBrowserRouter, Route, createRoutesFromElements } from 'react-router'; +import { AgeGate, PrivateRoute, RequireAuth } from './guards'; +import { SettingsPage } from './pages/settings'; + +export const router = createBrowserRouter([ + { + path: '/invocations/:id', + lazy: async () => { + const { InvocationPage } = await import('@/invocation_page'); + return { Component: InvocationPage }; + }, + children: [ + { + path: 'tests', + lazy: async () => { + const { TestTab } = await import( + '@/invocation_page/tabs' + ); + return { Component: TestTab }; + }, + }, + { + path: 'details', + lazy: async () => { + const { DetailsTab } = await import('@/invocation_page/tabs'); + return { Component: DetailsTab }; + }, + }, + ], + }, + { + path: '/login', + lazy: async () => { + const { default: Component } = await import(/* webpackChunkName: "login" */ './pages/login'); + return { Component }; + }, + }, + { path: '/reports', lazy: () => import('./pages/reports').then((m) => ({ Component: m.ReportList })) }, + { path: '/reports/:id', lazy: async () => ({ Component: (await import('./pages/reports')).ReportPage }) }, + { + path: '/profile', + lazy: async () => { + const page = await import('./pages/profile'); + return { Component: page.ProfilePage }; + }, + }, + { + path: '/projects', + async lazy() { + const { ProjectsPage } = await import('./pages/projects'); + return { Component: ProjectsPage }; + }, + }, + { + path: '/team', + lazy: async () => { + const { teamLoader } = await import('./loaders/team'); + const { TeamPage } = await import('./pages/team'); + return { loader: teamLoader, Component: TeamPage }; + }, + }, + { + path: '/shows/:id', + lazy: { + loader: async () => (await import('./shows/loader')).loader, + Component: async () => (await import('./shows/show')).Show, + }, + }, + { + path: '/admin', + lazy: async () => { + const { AdminPage } = await import('./pages/admin'); + return { + element: ( + + + + ), + }; + }, + }, + { + path: '/fireworks', + lazy: async () => { + const { default: FireworksPage } = await import('./pages/fireworks'); + return { element: }; + }, + }, + { + path: '/favorites', + async lazy() { + let { Favorites } = await import('./pages/favorites'); + return { Component: () => } /> }; + }, + }, + { + path: '/feed', + lazy: async () => { + const [{ Feed }] = await Promise.all([import('./pages/feed'), loadMessages()]); + return { Component: Feed }; + }, + }, + { + path: '/settings', + lazy: async () => { + const { settingsLoader } = await import('./loaders/settings'); + return { loader: settingsLoader, Component: SettingsPage }; + }, + }, + { + path: '/logout', + lazy: async () => { + const { loader } = await import('./pages/logout'); + return { loader }; + }, + }, + { + path: '/missing', + lazy: async () => { + const { Missing } = await import('./pages/reports'); + return { Component: Missing }; + }, + }, + { + path: '/named-only', + lazy: async () => { + const { default: Component } = await import('./pages/named_only'); + return { Component }; + }, + }, +]); + +export const jsxRoutes = createRoutesFromElements( + { const { HelpPage } = await import('./pages/help'); return { Component: HelpPage }; }} /> +); + +async function loadMessages() { + return {}; +} +`, + 'src/guards.tsx': `export function RequireAuth({ children }: { children: unknown }) { + return children; +} + +export function AgeGate({ children }: { children: unknown; minAge: number }) { + return children; +} + +export function PrivateRoute({ component }: { component: unknown; redirectTo: string }) { + return component; +} +`, + 'src/pages/fireworks.tsx': `export default function FireworksPage() { + return null; +} +`, + 'src/pages/favorites.tsx': `export function Favorites() { + return null; +} +`, + 'src/invocation_page/index.ts': `export * from './invocation_page'; +`, + 'src/invocation_page/invocation_page.tsx': `export function InvocationPage() { + return null; +} +`, + // A barrel of tabs: no default export and no \`Component\`. + 'src/invocation_page/tabs/index.ts': `export * from './test_tab'; +export * from './details_tab'; +`, + 'src/invocation_page/tabs/test_tab.tsx': `export function TestTab() { + return null; +} +`, + 'src/invocation_page/tabs/details_tab.tsx': `export function DetailsTab() { + return null; +} +`, + 'src/pages/login.tsx': `export default function LoginPage() { + return null; +} +`, + // A default export the loaders do not pick. + 'src/pages/reports.tsx': `export function ReportList() { + return null; +} + +export function ReportPage() { + return null; +} + +export default function ReportsHome() { + return null; +} +`, + 'src/pages/profile.tsx': `export function ProfilePage() { + return null; +} + +export default function ProfileCard() { + return null; +} +`, + 'src/pages/projects.tsx': `export function ProjectsPage() { + return null; +} +`, + 'src/loaders/team.ts': `export async function teamLoader() { + return null; +} +`, + 'src/pages/team.tsx': `export function TeamPage() { + return null; +} +`, + 'src/shows/loader.ts': `export async function loader() { + return null; +} +`, + 'src/shows/show.tsx': `export function Show() { + return null; +} +`, + 'src/pages/admin.tsx': `export function AdminPage() { + return null; +} +`, + 'src/pages/feed.tsx': `export function Feed() { + return null; +} +`, + 'src/loaders/settings.ts': `export async function settingsLoader() { + return null; +} +`, + 'src/pages/settings.tsx': `export function SettingsPage() { + return null; +} +`, + // A page module whose default export the loader does not hand over. + 'src/pages/logout.tsx': `export async function loader() { + return null; +} + +export default function LogoutPage() { + return null; +} +`, + // No default export: \`{ default: Component }\` is undefined. + 'src/pages/named_only.tsx': `export function Component() { + return null; +} +`, + 'src/pages/help.tsx': `export function HelpPage() { + return null; +} +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}, 60_000); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +/** What a route's `references` edges reach, as `name@file`: what it renders, or the layouts around it. */ +const bindings = (routeName: string, layout: boolean): string[] => { + const route = cg.getNodesByKind('route').find((r) => r.name === routeName); + if (!route) return [`no route ${routeName}`]; + return cg.getOutgoingEdges(route.id) + .filter((e) => e.kind === 'references' && Boolean((e.metadata as Record | undefined)?.layout) === layout) + .map((e) => cg.getNode(e.target)!) + .map((n) => `${n.name}@${n.filePath}`); +}; +const renders = (routeName: string): string[] => bindings(routeName, false); + +describe('a data-router lazy loader that picks the export it renders', () => { + it('renders the export it destructures, through a barrel', () => { + expect(renders('/invocations/:id/tests')).toEqual(['TestTab@src/invocation_page/tabs/test_tab.tsx']); + expect(renders('/invocations/:id/details')).toEqual(['DetailsTab@src/invocation_page/tabs/details_tab.tsx']); + }); + + it('renders inside the layout its parent route picks', () => { + expect(renders('/invocations/:id')).toEqual(['InvocationPage@src/invocation_page/invocation_page.tsx']); + expect(bindings('/invocations/:id/tests', true)).toEqual(['InvocationPage@src/invocation_page/invocation_page.tsx']); + }); + + it('renders the default export `{ default: Component }` picks', () => { + expect(renders('/login')).toEqual(['LoginPage@src/pages/login.tsx']); + }); + + it('renders the export a `.then` callback, an `(await import(…)).X` or a module binding picks, not the default', () => { + expect(renders('/reports')).toEqual(['ReportList@src/pages/reports.tsx']); + expect(renders('/reports/:id')).toEqual(['ReportPage@src/pages/reports.tsx']); + expect(renders('/profile')).toEqual(['ProfilePage@src/pages/profile.tsx']); + }); + + it('reads a loader written as a method', () => { + expect(renders('/projects')).toEqual(['ProjectsPage@src/pages/projects.tsx']); + }); + + it('reads the import the component comes from, not the first one', () => { + expect(renders('/team')).toEqual(['TeamPage@src/pages/team.tsx']); + expect(renders('/feed')).toEqual(['Feed@src/pages/feed.tsx']); + }); + + it("reads React Router 7's object of lazy properties", () => { + expect(renders('/shows/:id')).toEqual(['Show@src/shows/show.tsx']); + }); + + it('renders the page an `element` shows inside its guard', () => { + expect(renders('/admin')).toEqual(['AdminPage@src/pages/admin.tsx']); + }); + + it('renders the one export the loader imports, past a guard of the route file', () => { + expect(renders('/fireworks')).toEqual(['FireworksPage@src/pages/fireworks.tsx']); + expect(renders('/favorites')).toEqual(['Favorites@src/pages/favorites.tsx']); + }); + + it("renders a component of the route's own file the loader returns", () => { + expect(renders('/settings')).toEqual(['SettingsPage@src/pages/settings.tsx']); + }); + + it('reads a `` the same way', () => { + expect(renders('/help')).toEqual(['HelpPage@src/pages/help.tsx']); + }); + + it('links nothing a loader does not hand over', () => { + // A loader that returns only a `loader` renders nothing: the route is no page. + expect(cg.getNodesByKind('route').map((r) => r.name)).not.toContain('/logout'); + // A picked export the module lacks is not its default, nor its `Component`. + expect(renders('/missing')).toEqual([]); + expect(renders('/named-only')).toEqual([]); + }); +}); + +describe('a lazy route in a project that also has Vue Router', () => { + let vueRoot = ''; + let graph: CodeGraph; + beforeAll(async () => { + vueRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-lazy-member-vue-')); + const files: Record = { + 'package.json': JSON.stringify({ name: 'mixed', private: true, dependencies: { react: '^18', 'react-router': '^7', vue: '^3', 'vue-router': '^4' } }), + 'react/router.tsx': `import { createBrowserRouter } from 'react-router'; + +export const router = createBrowserRouter([ + { path: '/react-login', lazy: async () => { const { LoginView } = await import('./LoginView'); return { Component: LoginView }; } }, +]); +`, + 'react/LoginView.tsx': `export function LoginView() { + return null; +} +`, + // Vue Router names this route's component `import:./views/Login#Login`, + // the form a React route's picked export is named in too. + 'vue/router.ts': `import { createRouter, createWebHistory } from 'vue-router'; + +export default createRouter({ + history: createWebHistory(), + routes: [{ path: '/vue-login', component: () => import('./views/Login') }], +}); +`, + 'vue/views/Login.ts': `import { h } from 'vue'; + +export function Login() { + return h('div'); +} +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(vueRoot, rel)), { recursive: true }); + fs.writeFileSync(path.join(vueRoot, rel), content); + } + graph = await CodeGraph.init(vueRoot, { index: true }); + }, 60_000); + afterAll(() => { + graph?.close(); + if (vueRoot) fs.rmSync(vueRoot, { recursive: true, force: true }); + }); + + /** Each route's outgoing edges to what it renders, with the framework that resolved them. */ + const linked = (routeName: string): string[] => { + const route = graph.getNodesByKind('route').find((r) => r.name === routeName); + if (!route) return [`no route ${routeName}`]; + return graph.getOutgoingEdges(route.id) + .filter((e) => e.kind === 'references' || e.kind === 'calls') + .map((e) => `${graph.getNode(e.target)!.filePath} by ${String((e.metadata as Record | undefined)?.framework)}`); + }; + + it("leaves Vue Router's lazy component references to Vue Router", () => { + expect(linked('/react-login')).toEqual(['react/LoginView.tsx by react']); + expect(linked('/vue-login')).toEqual(['vue/views/Login.ts by vue-router']); + }); +}); + +describe('sync, for a lazy route that picks an export', () => { + type Files = Record; + const ROUTER: Files = { + 'package.json': JSON.stringify({ name: 'ui', private: true, dependencies: { react: '^18', 'react-router': '^7' } }), + 'src/router.tsx': `import { createBrowserRouter } from 'react-router'; + +export const router = createBrowserRouter([ + { + path: '/reports', + lazy: async () => { + const { ReportList } = await import('./reports'); + return { Component: ReportList }; + }, + }, +]); +`, + }; + const PAGE: Files = { + 'src/reports/index.ts': `export * from './report_list'; +`, + 'src/reports/report_list.tsx': `export function ReportList() { + return null; +} +`, + }; + const LINKED = { '/reports': ['ReportList@src/reports/report_list.tsx'] }; + + let dirs: string[] = []; + let graphs: CodeGraph[] = []; + afterEach(() => { + for (const graph of graphs) graph.close(); + for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true }); + graphs = []; + dirs = []; + }); + + const write = (dir: string, files: Files): void => { + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), content); + } + }; + /** A project of `files`, indexed from scratch in a folder of its own. */ + const indexed = async (files: Files): Promise<{ dir: string; graph: CodeGraph }> => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-lazy-member-sync-')); + dirs.push(dir); + write(dir, files); + const graph = await CodeGraph.init(dir, { index: true }); + graphs.push(graph); + return { dir, graph }; + }; + /** Each route's `references` edges, as `name@file`. */ + const routeLinks = (graph: CodeGraph): Record => Object.fromEntries( + graph.getNodesByKind('route').map((route) => [route.name, graph.getOutgoingEdges(route.id) + .filter((e) => e.kind === 'references') + .map((e) => graph.getNode(e.target)!) + .map((n) => `${n.name}@${n.filePath}`) + .sort()]) + ); + + it('links the picked export once its module appears', async () => { + const { dir, graph } = await indexed(ROUTER); + expect(routeLinks(graph)).toEqual({ '/reports': [] }); + write(dir, PAGE); + expect((await graph.sync()).filesAdded).toBe(2); + expect(routeLinks(graph)).toEqual(LINKED); + expect(routeLinks((await indexed({ ...ROUTER, ...PAGE })).graph)).toEqual(LINKED); + }, 60_000); + + it('follows a picked default export that moves to another component', async () => { + const files: Files = { + 'package.json': ROUTER['package.json']!, + 'src/router.tsx': `import { createBrowserRouter } from 'react-router'; + +export const router = createBrowserRouter([ + { + path: '/login', + lazy: async () => { + const { default: Component } = await import('./pages/login'); + return { Component }; + }, + }, +]); +`, + 'src/pages/login.tsx': 'export default function LoginPage() {\n return null;\n}\n', + }; + const { dir, graph } = await indexed(files); + expect(routeLinks(graph)).toEqual({ '/login': ['LoginPage@src/pages/login.tsx'] }); + // The old component stays, so the route's edge would follow it through the re-index. + const edit = { 'src/pages/login.tsx': 'export function LoginPage() {\n return null;\n}\n\nexport default function SignIn() {\n return null;\n}\n' }; + write(dir, edit); + await graph.sync(); + expect(routeLinks(graph)).toEqual({ '/login': ['SignIn@src/pages/login.tsx'] }); + expect(routeLinks((await indexed({ ...files, ...edit })).graph)).toEqual({ '/login': ['SignIn@src/pages/login.tsx'] }); + }, 60_000); + + it('links the picked export once an edit adds it', async () => { + const { dir, graph } = await indexed({ ...ROUTER, ...PAGE, 'src/reports/report_list.tsx': 'export const placeholder = 1;\n' }); + expect(routeLinks(graph)).toEqual({ '/reports': [] }); + write(dir, { 'src/reports/report_list.tsx': PAGE['src/reports/report_list.tsx']! }); + expect((await graph.sync()).filesModified).toBe(1); + expect(routeLinks(graph)).toEqual(LINKED); + }, 60_000); +}); diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index cff08ecdc..ce773f35a 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -38,7 +38,7 @@ guessed. |---|---|---|---|---| | Expo Router | `frameworks/expo-router.ts` | `expo-router-synthesizer.ts` | `expo-router.test.ts` | — | | Next.js | `frameworks/nextjs.ts` | `next-router-synthesizer.ts` | `nextjs.test.ts` | next-saas-starter | -| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges), proshop-v2 (28), react-redux-realworld (22), react-boilerplate (`styled(Link)`), takenote (v5 ``), bulletproof-react (nested `children`, `lazy` routes, `paths.x.path` constants through an app's own tsconfig alias: 0 → 9 named, linked routes), and its `paths.x.getHref(id)` links and `navigate(paths.x.getHref())` calls (0 → 11 navigates). Route tables another file hands the router (`tableRoutes` in `frameworks/react.ts`, trap 13): the ASP.NET Core React template's `AppRoutes.map(… )` on jasontaylordev/CleanArchitecture's `ClientApp-React` (0 → 6 routes, 11 navigates), replaysMike/Binner (0 → 46, 87), GavinLonDigital/RankingApp, GarrettHays/PaydirtPickem, charlessolar/eShopOnContainersDDD; `useRoutes(routes)` from another file and `[...sessionRoutes]` on uilibrary/matx-react (0 → 7, 16); codedthemes' `createBrowserRouter([MainRoutes, LoginRoutes])` on berry and mantis (9 and 8 routes, no navigation they can resolve); a table `.map`ped inside `` on RADeveloping/chickadeeinvest; `useRoutes([...])` in place on minimal-ui-kit/material-kit-react (0 → 7, bound through `lazy(() => import(…))` to each page). JSX index routes and layouts (trap 14): proshop-v2's `createRoutesFromElements` (`/` is HomeScreen inside App, not App; 30 layout edges, the `path=''` guards included; Screens 30 → 74 transitions, the guards' redirects drawn from the screens they guard), chickadeeinvest's table inside `}>` (its 4 routes get the layout), React Router 6.29's own examples and tests, refine's 281 examples (one `/` per app, 191 layout edges, 21 new navigations), crwn-clothing, the vanlife course app, volun-mern (105 layout edges, six levels deep), spotify/vispana, react_persist_login, chushi (`path={"agents"}`: 2 → 6 routes), hathor-explorer (`path=""` at the root), cboard, react-router-auth-v6: 578 of 581 new layout edges name a component of an enclosing `` (vanlife's 3 inherit a default-import resolution bug that already misbinds its pages), 27 of 27 new navigations precise, no navigation lost, 16 control repos byte-identical. Lazy route modules that are barrels (`exportedComponent` in `frameworks/react.ts`): luci-go's milo/ui (`export * from './x'` index files and `export { X as Component }` clauses: 0 → 51 lazy routes linked; the other 40 load a file under `src/build/`, which the default ignore skips, export only a `loader` or a `handle`, or pick a named export in an async `lazy`), Medusa's admin dashboard (all 193 route-map modules resolve through its `export { X as Component } from` barrels, but its routes are not extracted: `getRouteMap()` returns the table), four more apps (9 routes), 16 control repos byte-identical | +| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges), proshop-v2 (28), react-redux-realworld (22), react-boilerplate (`styled(Link)`), takenote (v5 ``), bulletproof-react (nested `children`, `lazy` routes, `paths.x.path` constants through an app's own tsconfig alias: 0 → 9 named, linked routes), and its `paths.x.getHref(id)` links and `navigate(paths.x.getHref())` calls (0 → 11 navigates). Route tables another file hands the router (`tableRoutes` in `frameworks/react.ts`, trap 13): the ASP.NET Core React template's `AppRoutes.map(… )` on jasontaylordev/CleanArchitecture's `ClientApp-React` (0 → 6 routes, 11 navigates), replaysMike/Binner (0 → 46, 87), GavinLonDigital/RankingApp, GarrettHays/PaydirtPickem, charlessolar/eShopOnContainersDDD; `useRoutes(routes)` from another file and `[...sessionRoutes]` on uilibrary/matx-react (0 → 7, 16); codedthemes' `createBrowserRouter([MainRoutes, LoginRoutes])` on berry and mantis (9 and 8 routes, no navigation they can resolve); a table `.map`ped inside `` on RADeveloping/chickadeeinvest; `useRoutes([...])` in place on minimal-ui-kit/material-kit-react (0 → 7, bound through `lazy(() => import(…))` to each page). JSX index routes and layouts (trap 14): proshop-v2's `createRoutesFromElements` (`/` is HomeScreen inside App, not App; 30 layout edges, the `path=''` guards included; Screens 30 → 74 transitions, the guards' redirects drawn from the screens they guard), chickadeeinvest's table inside `}>` (its 4 routes get the layout), React Router 6.29's own examples and tests, refine's 281 examples (one `/` per app, 191 layout edges, 21 new navigations), crwn-clothing, the vanlife course app, volun-mern (105 layout edges, six levels deep), spotify/vispana, react_persist_login, chushi (`path={"agents"}`: 2 → 6 routes), hathor-explorer (`path=""` at the root), cboard, react-router-auth-v6: 578 of 581 new layout edges name a component of an enclosing `` (vanlife's 3 inherit a default-import resolution bug that already misbinds its pages), 27 of 27 new navigations precise, no navigation lost, 16 control repos byte-identical. Lazy route modules that are barrels (`exportedComponent` in `frameworks/react.ts`): luci-go's milo/ui (`export * from './x'` index files and `export { X as Component }` clauses: 0 → 51 lazy routes linked; the other 40 load a file under `src/build/`, which the default ignore skips, export only a `loader` or a `handle`, or pick a named export in an async `lazy`), Medusa's admin dashboard (all 193 route-map modules resolve through its `export { X as Component } from` barrels, but its routes are not extracted: `getRouteMap()` returns the table), four more apps (9 routes), 16 control repos byte-identical. Lazy loaders that pick their page (`lazyRouteReference` in `frameworks/react.ts`, trap 18): milo/ui's three tabs (`const { TestTab } = await import('…/tabs')`), 20 apps from a GitHub survey of `createBrowserRouter` + `lazy` (636 picked-page edges, each checked against the target file's own export: 467 by name, 169 as its `export default`; 42 routes written with `async lazy()` now indexed, among them React Router's own `lazy-loading-router-provider` example), an AST oracle on the TypeScript compiler agreeing with the text reader on all 2,538 loaders of 876 surveyed router files, a late-file sync equal to a fresh index on 8 of those apps, and 26 control repos byte-identical, among them ghostfolio, whose 30 Angular lazy routes stay Angular's with React detected in the same project | | TanStack Router | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts` | TanStack examples, fastapi-template frontend | | Vue Router / Nuxt | `frameworks/vue-router.ts` (Nuxt file routes: `nuxtResolver` in `frameworks/vue.ts`) | `vue-router-synthesizer.ts` | `vue-router.test.ts` | vue-realworld (23 edges); vue-element-admin (62 routes), vue-admin-template (14), vben (192), halo console (34) — named tables, module files, `children` + layouts; Nuxt: mealie, elk, nuxt/movies | | SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts`, `sveltekit-route-names.test.ts` | sveltekit-realworld (31 edges); shadcn-svelte and skeleton (`(group)` layouts: 13 and 23 edges), svelte.dev (74), kit's test apps (47) | @@ -344,6 +344,20 @@ Each of these cost real debugging time; they are not hypothetical. route's reference back for its resolution sweep. The same holds for the JSX a component renders: a sync that adds a component redraws the `jsx-render` edges, since a tag in a file it never touched may name it. +18. **A lazy loader is code, and its page is what it hands over.** A React + Router `lazy` resolves to the route's properties, so `const { TestTab } = + await import('./tabs'); return { Component: TestTab }` renders `TestTab`, + not the module's default export; reading the first `import('…')` in the + loader linked luci-go's tab routes to nothing and other apps' routes to + another page of the same module (`lazyRouteReference` in + `frameworks/react.ts` follows the loader's bindings to its `Component`, or + the one lazily imported export its `element` shows past a guard). The + picked export is named the way Vue Router and Angular name a lazy + component, `import:#`, so sync's module-tail retry covers it + (`MODULE_REFERENCE`), but React is registered before both of them: it + answers only references a React route makes (`isReactRouteRef`: tsx or jsx, + which Vue's and Angular's route references never are), or it takes theirs + first, as an Angular app with React in a `package.json` would let it. --- diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index 51a80549b..57736357b 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -35,9 +35,10 @@ export const reactResolver: FrameworkResolver = { }, // A data-router `lazy: () => import('./routes/x')` route names a module, not - // a symbol; a route's `layout:MainLayout` names the component around it. + // a symbol, and `import:./x#Page` the export its loader picks; a route's + // `layout:MainLayout` names the component around it. claimsReference(name: string): boolean { - return name.startsWith(LAZY_ROUTE_PREFIX) || name.startsWith(LAYOUT_PREFIX); + return name.startsWith(LAZY_ROUTE_PREFIX) || name.startsWith(LAYOUT_PREFIX) || PICKED_EXPORT.test(name); }, resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { @@ -48,6 +49,13 @@ export const reactResolver: FrameworkResolver = { const target = lazyRouteComponent(ref.referenceName.slice(LAZY_ROUTE_PREFIX.length), ref.filePath, context); return target ? { original: ref, targetNodeId: target, confidence: 0.9, resolvedBy: 'framework' } : null; } + // The export a lazy route's loader picks. Vue Router and Angular name a + // lazily loaded component the same way, and their routes' are theirs. + const picked = PICKED_EXPORT.exec(ref.referenceName); + if (picked) { + const target = isReactRouteRef(ref) ? pickedExport(picked[1]!, picked[2]!, ref.filePath, context) : null; + return target ? { original: ref, targetNodeId: target, confidence: 0.9, resolvedBy: 'framework' } : null; + } // The layout a route renders inside: what happens in it — its header's // links, its logout — happens on the route's screen too. if (ref.referenceName.startsWith(LAYOUT_PREFIX)) { @@ -203,12 +211,19 @@ export const reactResolver: FrameworkResolver = { /** * The modules a route's answer is read from: a lazily loaded page or layout - * (`lazy-import:` references), or the one a same-file lazy value the route - * renders loads — the references Pattern 1 asks `declaredComponent` about — - * with the modules a barrel there forwards the lookup to. + * (`lazy-import:` references, and `import:…#…` for the export a loader + * picks), or the one a same-file lazy value the route renders loads — the + * references Pattern 1 asks `declaredComponent` about — with the modules a + * barrel there forwards the lookup to. */ lazyModules(ref: UnresolvedRef, context: ResolutionContext): readonly string[] { const name = ref.referenceName; + const picked = isReactRouteRef(ref) ? PICKED_EXPORT.exec(name.startsWith(LAYOUT_PREFIX) ? name.slice(LAYOUT_PREFIX.length) : name) : null; + if (picked) { + const reads = new Set(); + pickedExport(picked[1]!, picked[2]!, ref.filePath, context, reads); + return [...reads]; + } let spec: string | null | undefined; if (name.startsWith(LAZY_ROUTE_PREFIX)) spec = name.slice(LAZY_ROUTE_PREFIX.length); else if (name.startsWith(LAYOUT_PREFIX + LAZY_ROUTE_PREFIX)) spec = name.slice(LAYOUT_PREFIX.length + LAZY_ROUTE_PREFIX.length); @@ -222,6 +237,13 @@ const LAZY_ROUTE_PREFIX = 'lazy-import:'; const ROUTE_PARTS_PREFIX = 'route-parts:'; const LAYOUT_PREFIX = 'layout:'; +/** + * The export a lazy route's loader picks, named the way Vue Router and + * Angular name a lazily loaded component: `import:./tabs#TestTab`, + * `import:./pages/login#default`. The module's path may hold a `#` itself. + */ +const PICKED_EXPORT = /^import:(.+)#([A-Za-z_$][\w$]*)$/; + /** One segment of a nested route's path: a literal, or a constant's member expression. */ interface RoutePart { lit?: string; @@ -433,6 +455,24 @@ function lazyRouteFiles(spec: string, fromFile: string, context: ResolutionConte return [...reads]; } +/** + * The component a lazy route's loader picks from the module `spec` loads: the + * export named `name`, through barrels. `reads` collects the files it reads. + */ +function pickedExport(spec: string, name: string, fromFile: string, context: ResolutionContext, reads?: Set): string | null { + const file = lazyModuleFile(spec, fromFile, context); + return file ? exportedComponent(file, name, context, new Set(), reads) : null; +} + +/** + * True for a reference a React route makes: from a `route::…` node, in + * tsx or jsx, the language React gives its routes even in a `.ts` routes + * file. Vue Router's and Angular's routes are never in either. + */ +function isReactRouteRef(ref: UnresolvedRef): boolean { + return (ref.language === 'tsx' || ref.language === 'jsx') && ref.fromNodeId.startsWith(`route:${ref.filePath}:`); +} + /** What a module can export as a component: a declaration, or a value holding one. */ const EXPORTED_COMPONENT_KINDS = new Set(['function', 'component', 'class', 'constant', 'variable']); @@ -527,10 +567,13 @@ function declaredLoader(name: string, filePath: string, context: ResolutionConte /** * The component a route's `layout:` reference names, found the way the * route's own component is: through the route file's import of it, else - * declared in reach — or a lazily loaded layout module's component. + * declared in reach — or a lazily loaded layout module's component, or the + * export its loader picks. */ function layoutComponent(spec: string, ref: UnresolvedRef, context: ResolutionContext): string | null { if (spec.startsWith(LAZY_ROUTE_PREFIX)) return lazyRouteComponent(spec.slice(LAZY_ROUTE_PREFIX.length), ref.filePath, context); + const picked = isReactRouteRef(ref) ? PICKED_EXPORT.exec(spec) : null; + if (picked) return pickedExport(picked[1]!, picked[2]!, ref.filePath, context); const mapping = context.getImportMappings(ref.filePath, ref.language).find((m) => m.localName === spec); if (!mapping) return resolveComponent(spec, ref.filePath, context); const viaImport = context.resolveImport?.({ ...ref, referenceName: spec })?.targetNodeId; @@ -548,11 +591,11 @@ interface RouteDeclaration { /** Its path parts, outermost first. */ parts: RoutePart[]; component?: string; - /** A `lazy: () => import('…')` module. */ + /** What its `lazy` loader renders, as the route's reference names it (`lazyRouteReference`). */ lazy?: string; /** * The route objects around it that render something, outermost first: the - * layouts it renders inside, each a component name or `lazy-import:…`. + * layouts it renders inside, each a component name, `lazy-import:…` or `import:…#…`. */ layouts: string[]; at: number; @@ -682,6 +725,8 @@ function scanRoutes( // then the innermost wrapper: `` renders the guard. return own.find(content) ?? props.find(content) ?? [...own].reverse().find((t) => t !== 'Outlet') ?? own[0]; }; + // The page an `element` shows: `element={}` renders the route inside it and nothing of its own. + const elementPage = (value: string | undefined): string | undefined => componentName(value, true)?.replace(/^Outlet$/, '') || undefined; // The few characters before `at`, trailing whitespace skipped: enough for the // end-anchored checks below without copying the whole prefix per `/` or `<`. const tokenBefore = (at: number): string => { @@ -801,6 +846,11 @@ function scanRoutes( // Skip a whole entry (spread, method, shorthand), but still visit nested units. const entry = i; while ((i = trivia(i)) < source.length && source[i] !== ',' && source[i] !== close) i = unit(i); + // `async lazy() { … }`: a route's loader written as a method. + const method = objects && ch === '{' ? /^(async\s+)?lazy\s*\(/.exec(source.slice(entry, entry + 32)) : null; + if (method) { + fields.set('lazy', { value: `${method[1] ?? ''}function ${source.slice(entry + method[0].length - 1, i).trim()}`, at: entry }); + } if (routeList && ch === '[') { const text = source.slice(entry, i).replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '').trim(); const named = NAMED_ENTRY.exec(text); @@ -818,7 +868,7 @@ function scanRoutes( const expr = pathField && path === undefined && /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(pathField.value) ? pathField.value : undefined; const component = componentName(fields.get('element')?.value, true) ?? componentName(fields.get('Component')?.value, false); - const lazy = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/.exec(fields.get('lazy')?.value ?? '')?.[1]; + const lazy = lazyRouteReference(fields.get('lazy')?.value, elementPage); const index = fields.get('index'); if (pathField && (path !== undefined || expr)) { scopes.push({ part: path !== undefined ? { lit: path } : { expr }, at: pathField.at, start: at, end: i, component, lazy }); @@ -870,10 +920,9 @@ function scanRoutes( // `path="team"`, or the same string in braces: `path={"team"}`. const path = literal(pathText ?? '') ?? literal(expression('path') ?? ''); routeTags.push({ at, spreads, path: expression('path')?.trim() }); - // `element={}` renders the route inside it and nothing of its own. - const element = componentName(expression('element'), true)?.replace(/^Outlet$/, '') || undefined; + const element = elementPage(expression('element')); const component = componentName(expression('component'), false) ?? element; - const lazy = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/.exec(expression('lazy') ?? '')?.[1]; + const lazy = lazyRouteReference(expression('lazy'), elementPage); const route: RouteScope = { part: { lit: path ?? '' }, at, start: at, end: source.length, component, lazy, jsx: true }; if (path) scope = route; else if (pathText === undefined || path === '') { @@ -933,7 +982,7 @@ function scanRoutes( const around = (start: number, end: number): RouteScope[] => scopes.filter((outer) => holds(outer, start, end)).sort((a, b) => a.start - b.start); const shown = (p: RoutePart): string => p.lit ?? `{${p.expr}}`; - const layoutOf = (s: RouteScope): string => s.component ?? LAZY_ROUTE_PREFIX + s.lazy; + const layoutOf = (s: RouteScope): string => (s.component ?? s.lazy)!; const partsOf = new Map(); const pathOf = new Map(); for (const scope of scopes) { @@ -1067,6 +1116,11 @@ function elementTags(text: string): { own: string[]; props: string[] } { /** The comma-separated entries of a list's inside, nested brackets kept whole. */ function topLevelEntries(text: string): string[] { + return topLevelItems(text).filter(Boolean); +} + +/** The same, an empty entry kept for each hole: `[, second]` is `['', 'second']`. */ +function topLevelItems(text: string): string[] { const out: string[] = []; let depth = 0; let from = 0; @@ -1083,8 +1137,423 @@ function topLevelEntries(text: string): string[] { from = i + 1; } } - out.push(text.slice(from).trim()); - return out.filter(Boolean); + // A trailing comma ends a list without adding a hole. + const last = text.slice(from).trim(); + if (last) out.push(last); + return out; +} + +// ============================================================================= +// What a data router's `lazy` loader renders +// ============================================================================= + +/** + * A value a lazy loader hands over, as far as its source says: the module an + * `import('./x')` loads, one export of it, a name of the route's own file, an + * object literal (read in the scope it is written in), or the array a + * `Promise.all([…])` resolves to. + */ +type Loaded = + | { kind: 'module'; spec: string } + | { kind: 'export'; spec: string; name: string } + | { kind: 'name'; name: string } + | { kind: 'object'; text: string; scope: LoaderScope } + | { kind: 'array'; items: Array }; + +/** The names a loader binds, each to what it holds: null for a value it does not read. */ +type LoaderScope = ReadonlyMap; + +const FIRST_IMPORT = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/; + +/** + * What a data-router `lazy` loader renders, as the route's reference names + * it. The loader resolves to the route's properties, and their `Component`, + * or the page their `element` shows, is what the route renders: + * + * - `() => import('./x')` hands over the module's exports: `lazy-import:./x`. + * - A loader that picks the page names the export it picks, `import:./x#Page`: + * `const { Page } = await import('./x'); return { Component: Page }`, + * `import('./x').then((m) => ({ Component: m.Page }))`, `({ Component: + * (await import('./x')).Page })`, or React Router 7's `{ Component: async () + * => (await import('./x')).Page }`; `import:./x#default` for `const { + * default: Component } = await import('./x')`. + * - A component of the route's own file it returns is named as written: + * `return { loader, Component: SettingsPage }`. + * + * Undefined when it renders nothing, handing over only a `loader`. A loader + * this does not read, such as a helper's (`lazyPage(() => import('./x'))`) + * or `.then(convert)`, loads the module its first import names, as before. + */ +function lazyRouteReference(loader: string | undefined, elementPage: (jsx: string) => string | undefined): string | undefined { + if (!loader) return undefined; + const code = stripCommentsForRegex(loader, 'typescript').trim(); + const rendered = code.startsWith('{') ? lazyPropertiesRender(code) : routeRender(loaderValue(code, new Map()), elementPage); + if (rendered === null) return undefined; + if (rendered !== undefined) return rendered; + const spec = FIRST_IMPORT.exec(code)?.[1]; + return spec ? LAZY_ROUTE_PREFIX + spec : undefined; +} + +/** + * What route properties a loader resolves to render: the reference for its + * page, null for none, undefined for properties this does not read. + */ +function routeRender(value: Loaded | null, elementPage: (jsx: string) => string | undefined): string | null | undefined { + if (value?.kind === 'module') return LAZY_ROUTE_PREFIX + value.spec; + if (value?.kind !== 'object') return undefined; + const { props, spreads } = objectEntries(value.text); + const component = props.get('Component'); + const element = props.get('element'); + const shown = component ?? element; + if (shown !== undefined) { + const named = component !== undefined ? pageReference(valueOf(component, value.scope)) : undefined; + if (named) return named; + // The one export the loader imported that it shows, past a guard of the + // route file's own: ``, `() => + // } />`, `withAuth(Page)`. + const loaded = loadedExports(shown, value.scope); + if (loaded.length === 1) return pageReference(loaded[0]!); + const tag = component === undefined && loaded.length === 0 ? elementPage(element!) : undefined; + return tag ? pageReference(nameValue(tag, value.scope)) : undefined; + } + // `{ ...module }`: the module's exports are the route's properties. + if (spreads.length === 0) return null; + const spread = spreads.length === 1 ? valueOf(spreads[0]!, value.scope) : null; + return spread?.kind === 'module' ? LAZY_ROUTE_PREFIX + spread.spec : undefined; +} + +/** + * React Router 7's object of lazy properties, `lazy: { loader: …, Component: + * async () => (await import('./x')).Page }`: each loads on its own, and the + * `Component`'s loader resolves to the page itself. + */ +function lazyPropertiesRender(code: string): string | null | undefined { + if (matchBracket(code, 0) !== code.length - 1) return undefined; + const component = objectEntries(code.slice(1, -1)).props.get('Component'); + if (component === undefined) return null; + const page = pageReference(loaderValue(component, new Map())); + if (page) return page; + const spec = FIRST_IMPORT.exec(component)?.[1]; + return spec ? LAZY_ROUTE_PREFIX + spec : undefined; +} + +/** The distinct exports `text` uses through the names a loader bound them to: `Page`, `m.Page`. */ +function loadedExports(text: string, scope: LoaderScope): Loaded[] { + const found = new Map(); + for (const m of text.matchAll(/(? bindPattern(scope, parameterPattern(p), i === 0 ? param : null)); + return fn.block ? blockValue(fn.body, scope) : valueOf(fn.body, scope); +} + +/** + * An arrow function's or a `function`'s parameter list and body, its + * `async` and return type skipped. Null for any other expression. + */ +function functionParts(text: string): { params: string; body: string; block: boolean } | null { + let s = text.trim().replace(/^async\b\s*/, ''); + const keyword = /^function\b\s*\*?\s*(?:[A-Za-z_$][\w$]*\s*)?/.exec(s); + if (keyword) s = s.slice(keyword[0].length); + let params: string; + let rest: string; + if (s.startsWith('(')) { + const close = matchBracket(s, 0); + if (close < 0) return null; + params = s.slice(1, close); + rest = s.slice(close + 1).trimStart(); + } else { + const id = keyword ? null : /^[A-Za-z_$][\w$]*/.exec(s); + if (!id) return null; + params = id[0]; + rest = s.slice(id[0].length).trimStart(); + } + // `async (): Promise => …` + if (!keyword && rest.startsWith(':')) { + const arrow = arrowAt(rest); + if (arrow < 0) return null; + rest = rest.slice(arrow); + } + if (keyword) { + return rest.startsWith('{') && matchBracket(rest, 0) === rest.length - 1 ? { params, body: rest.slice(1, -1), block: true } : null; + } + if (!rest.startsWith('=>')) return null; + const body = rest.slice(2).trim(); + if (!body.startsWith('{')) return { params, body, block: false }; + return matchBracket(body, 0) === body.length - 1 ? { params, body: body.slice(1, -1), block: true } : null; +} + +/** Where the first `=>` outside brackets and strings is in `text`, or -1. */ +function arrowAt(text: string): number { + for (let i = 0; i < text.length; i++) { + const ch = text[i]!; + if (ch === '"' || ch === "'" || ch === '`') { + i = skipString(text, i); + if (i < 0) return -1; + } else if (ch === '{' || ch === '(' || ch === '[') { + i = matchBracket(text, i); + if (i < 0) return -1; + } else if (ch === '=' && text[i + 1] === '>') return i; + } + return -1; +} + +/** A parameter's binding pattern, without its type or default: `{ default: Page }` of `{ default: Page }: Module`. */ +function parameterPattern(param: string): string { + param = param.replace(/^\.\.\.\s*/, ''); + if (param.startsWith('{') || param.startsWith('[')) { + const close = matchBracket(param, 0); + return close < 0 ? '' : param.slice(0, close + 1); + } + return /^[A-Za-z_$][\w$]*/.exec(param)?.[0] ?? ''; +} + +/** + * The value a function body hands back from its first top-level `return`, + * its declarations bound on the way there. Nested blocks and functions are + * stepped over: what is declared or returned in there is theirs. + */ +function blockValue(body: string, scope: Map): Loaded | null { + const word = /[A-Za-z_$][\w$]*/y; + for (let i = 0; i < body.length;) { + const ch = body[i]!; + if (ch === '"' || ch === "'" || ch === '`' || ch === '{' || ch === '(' || ch === '[') { + const end = ch === '{' || ch === '(' || ch === '[' ? matchBracket(body, i) : skipString(body, i); + if (end < 0) return null; + i = end + 1; + continue; + } + word.lastIndex = i; + const w = word.exec(body)?.[0]; + if (!w) { + i++; + continue; + } + const after = i + w.length; + const statement = i === 0 || !/[\w$.]/.test(body[i - 1]!); + i = after; + if (!statement) continue; + if (w === 'return') return valueOf(body.slice(after, statementEnd(body, after)), scope); + if (w === 'const' || w === 'let' || w === 'var') i = declare(body, after, scope); + else if (w === 'function' || w === 'class') { + const name = /^\s*\*?\s*([A-Za-z_$][\w$]*)/.exec(body.slice(after, after + 128)); + if (name) scope.set(name[1]!, null); + } + } + return null; +} + +/** Bind each declarator of a `const`, `let` or `var` whose first pattern starts at `at`; where the declaration ends. */ +function declare(body: string, at: number, scope: Map): number { + for (let i = at; ;) { + while (/\s/.test(body[i] ?? '')) i++; + let end: number; + if (body[i] === '{' || body[i] === '[') end = matchBracket(body, i) + 1; + else end = i + (/^[A-Za-z_$][\w$]*/.exec(body.slice(i, i + 128))?.[0].length ?? 0); + if (end <= i) return Math.max(end, i + 1); + const pattern = body.slice(i, end); + const init = initializerAt(body, end); + let stop = end; + if (init < 0) bindPattern(scope, pattern, null); + else { + stop = statementEnd(body, init, true); + bindPattern(scope, pattern, valueOf(body.slice(init, stop), scope)); + } + while (/\s/.test(body[stop] ?? '')) stop++; + if (body[stop] !== ',') return stop; + i = stop + 1; + } +} + +/** Where a declarator's initializer starts, past its `: Type` and `=`; -1 when it has none. */ +function initializerAt(body: string, at: number): number { + for (let i = at; i < body.length; i++) { + const ch = body[i]!; + if (ch === '"' || ch === "'" || ch === '`') { + i = skipString(body, i); + if (i < 0) return -1; + } else if (ch === '{' || ch === '(' || ch === '[') { + i = matchBracket(body, i); + if (i < 0) return -1; + } else if (ch === '=') { + if (body[i + 1] === '>' || body[i + 1] === '=') i++; + else return i + 1; + } else if (ch === ';' || ch === ',' || ch === '}' || ch === ')') return -1; + } + return -1; +} + +/** + * Where the statement or declarator from `from` ends: at a `;`, a closing + * bracket, a `,` when `comma`, or at a line break neither line continues + * across, where JavaScript ends a statement without a semicolon. + */ +function statementEnd(text: string, from: number, comma = false): number { + let last = ''; + for (let i = from; i < text.length; i++) { + const ch = text[i]!; + if (ch === '"' || ch === "'" || ch === '`' || ch === '{' || ch === '(' || ch === '[') { + const end = ch === '{' || ch === '(' || ch === '[' ? matchBracket(text, i) : skipString(text, i); + if (end < 0) return text.length; + i = end; + last = text[end]!; + continue; + } + if (ch === ';' || ch === '}' || ch === ')' || ch === ']' || (comma && ch === ',')) return i; + if (ch === '\n') { + let j = i + 1; + while (j < text.length && /\s/.test(text[j]!)) j++; + if (last && !/[=(,[{?:&|+\-*/%<>!.]/.test(last) && !/[.?:&|+\-*/%=<>,([]/.test(text[j] ?? '')) return i; + continue; + } + if (!/\s/.test(ch)) last = ch; + } + return text.length; +} + +/** What expression `text` evaluates to in a loader, or null when it is not one this reads. */ +function valueOf(text: string, scope: LoaderScope): Loaded | null { + let s = text.trim(); + // `await`, and parentheses around the whole expression, change nothing here. + for (let before = ''; before !== s;) { + before = s; + s = s.replace(/^await\b\s*/, ''); + if (s.startsWith('(') && matchBracket(s, 0) === s.length - 1) s = s.slice(1, -1).trim(); + } + if (s.startsWith('{') || s.startsWith('[')) { + if (matchBracket(s, 0) !== s.length - 1) return null; + const inside = s.slice(1, -1); + return s[0] === '{' + ? { kind: 'object', text: inside, scope } + : { kind: 'array', items: topLevelItems(inside).map((item) => (item ? valueOf(item, scope) : null)) }; + } + // The head: `import('./x')`, `Promise.all([…])`, a name, or a parenthesized expression. + const head = /^(?:(import)\s*\(|(Promise)\s*\.\s*all\s*\(|([A-Za-z_$][\w$]*)|\()/.exec(s); + if (!head) return null; + let value: Loaded | null; + let i: number; + if (head[3]) { + value = nameValue(head[3], scope); + i = head[0].length; + } else { + const open = head[0].length - 1; + const close = matchBracket(s, open); + if (close < 0) return null; + const inside = s.slice(open + 1, close); + if (head[1]) { + const spec = /^\s*(["'])([^"'`]+)\1\s*$/.exec(inside)?.[2]; + value = spec ? { kind: 'module', spec } : null; + } else if (head[2]) { + const all = valueOf(inside, scope); + value = all?.kind === 'array' ? all : null; + } else value = valueOf(inside, scope); + i = close + 1; + } + // Then each `.name`, `?.name`, `['name']` and `.then(…)` on it. + while (value && i < s.length) { + const rest = s.slice(i); + const then = /^\s*\??\.\s*then\s*\(/.exec(rest); + if (then) { + const open = i + then[0].length - 1; + const close = matchBracket(s, open); + if (close < 0 || value.kind !== 'module') return null; + const callback = topLevelItems(s.slice(open + 1, close))[0]; + value = callback ? loaderValue(callback, scope, value) : null; + i = close + 1; + continue; + } + const dot = /^\s*\??\.\s*([A-Za-z_$][\w$]*)/.exec(rest); + const bracket = dot ? null : /^\s*\[\s*(["'])([A-Za-z_$][\w$]*)\1\s*\]/.exec(rest); + const name = dot?.[1] ?? bracket?.[2]; + if (name === undefined) return null; + value = memberOf(value, name); + i += (dot ?? bracket)![0].length; + } + return value; +} + +/** What a name holds in a loader: what it binds there, or else a name of the route's own file. */ +function nameValue(name: string, scope: LoaderScope): Loaded | null { + return scope.has(name) ? scope.get(name) ?? null : { kind: 'name', name }; +} + +/** A property of a value: an export of a module, or what an object literal holds under `key`. */ +function memberOf(value: Loaded | null, key: string): Loaded | null { + if (value?.kind === 'module') return { kind: 'export', spec: value.spec, name: key }; + if (value?.kind !== 'object') return null; + const text = objectEntries(value.text).props.get(key); + return text === undefined ? null : valueOf(text, value.scope); +} + +/** + * Bind the names `pattern` declares to the parts of `value` they take: `{ + * Page, default: Home }` of a module to its exports, `[{ Page }]` to the first + * item of a `Promise.all`. A name whose value this does not read is bound to + * null, so it never counts as a name of the route's file. + */ +function bindPattern(scope: Map, pattern: string, value: Loaded | null): void { + const p = pattern.trim(); + if (!p) return; + if (/^[A-Za-z_$][\w$]*$/.test(p)) { + scope.set(p, value); + return; + } + const close = p.startsWith('{') || p.startsWith('[') ? matchBracket(p, 0) : -1; + if (close !== p.length - 1) { + for (const name of p.matchAll(/[A-Za-z_$][\w$]*/g)) scope.set(name[0], null); + return; + } + const items = topLevelItems(p.slice(1, -1)); + items.forEach((item, index) => { + if (!item) return; + const rest = /^\.\.\.\s*([\s\S]*)$/.exec(item); + if (rest) return bindPattern(scope, rest[1]!, null); + if (p[0] === '[') { + return bindPattern(scope, item.replace(/\s*=(?![=>])[\s\S]*$/, ''), value?.kind === 'array' ? value.items[index] ?? null : null); + } + // `Page`, `Page = Fallback`, `default: Home`, `'default': Home`. + const prop = /^(?:([A-Za-z_$][\w$]*)|(["'])([^"']*)\2)\s*(?::\s*([\s\S]+?))?\s*(?:=(?![=>])[\s\S]*)?$/.exec(item); + if (!prop) return bindPattern(scope, item.replace(/^[^:]*:/, ''), null); + const key = prop[1] ?? prop[3]!; + bindPattern(scope, prop[4] ?? key, memberOf(value, key)); + }); +} + +/** An object literal's own properties, each name to its value's text — shorthand and methods included — and its spreads. */ +function objectEntries(text: string): { props: Map; spreads: string[] } { + const props = new Map(); + const spreads: string[] = []; + for (const entry of topLevelEntries(text)) { + const spread = /^\.\.\.\s*([\s\S]+)$/.exec(entry); + const pair = spread ? null : /^(?:([A-Za-z_$][\w$]*)|(["'])([A-Za-z_$][\w$]*)\2)\s*:\s*([\s\S]+)$/.exec(entry); + const method = spread || pair ? null : /^(async\s+)?([A-Za-z_$][\w$]*)\s*\(/.exec(entry); + if (spread) spreads.push(spread[1]!); + else if (pair) props.set(pair[1] ?? pair[3]!, pair[4]!); + else if (method) props.set(method[2]!, `${method[1] ?? ''}function ${entry.slice(method[0].length - 1)}`); + else if (/^[A-Za-z_$][\w$]*$/.test(entry)) props.set(entry, entry); + } + return { props, spreads }; } // ============================================================================= @@ -1111,7 +1580,7 @@ function routeReferences(route: RouteDeclaration, fromNodeId: string, filePath: const ref = (referenceName: string): UnresolvedRef => ({ fromNodeId, referenceName, referenceKind: 'references', line, column: 0, filePath, language }); const refs: UnresolvedRef[] = []; - const target = route.component ?? (route.lazy ? LAZY_ROUTE_PREFIX + route.lazy : undefined); + const target = route.component ?? route.lazy; if (target) refs.push(ref(target)); for (const layout of route.layouts) refs.push(ref(LAYOUT_PREFIX + layout)); return refs; From 1ce1167e6f647f584220926befd86fd1881d3f70 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sat, 10 Oct 2026 09:04:33 +0000 Subject: [PATCH 3/4] test(js,ts): pin require().default and abstract classes to the declared default (#2481) #2432 made a default import read the module's own `export default` statement, and its merge pointed #2433's `require('./x').default` fallback (esmDefaultExport) at the same reading. Nothing in the suite pinned that path with a guessable helper above the default: #2433's fixture keeps its helper unexported. Two cases: - React Native's AnimatedColor shape, an exported helper above `export default class AnimatedColor`, loaded with `require("./AnimatedColor").default` and `const { default: Color } = require("./AnimatedColor")`. With the first-exported guess both `new` calls instantiate the helper. - `export default abstract class Repository` below an exported `connect()`, extended by `class Users extends Repository`. With the guess the `extends` edge is missing. Both fail with e47cb258's resolver (before #2432). On main, putting the guess back on the require path fails only the first, and dropping `abstract` from the declaration pattern fails only the second. The CHANGELOG's default-import bullet now says `require('./x').default` is read the same way. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- __tests__/default-export-declaration.test.ts | 63 +++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b6389bf6..4a3a3cde4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,7 +115,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In TypeScript React code, a styled-components or emotion component declared with a type argument, like `const Wrapper = styled.div` followed by its CSS, or `styled(Base)`, is now indexed as a component, as one written without a type argument already was. Before, it was indexed as a plain constant, so `codegraph callers` and impact found none of the code that renders it, and its tags could link to another file's component of the same name instead: in outline/outline, `NudeButton` and `HStack` had no callers, and `` tags linked to a different `Text` than the one each file imports. Re-index TypeScript React projects after upgrading. - `codegraph sync` and the file watcher now move a React Router route to its page when the router file loads that page lazily into a value of its own, like `const Docs = lazy(() => import('./pages/Docs'))` rendered by `} />`, and the page's file is added after the router was indexed, comes back after being deleted, or gets or changes its default export in a later edit. Before, the route kept pointing at the `Docs` declaration, or at the page's old component, until the router file itself changed or the project was re-indexed, unless the page's component happened to share the declaration's name. The same now holds for a `lazy: () => import('./pages/Docs')` route whose page switches its default export to another component. Re-index React projects after upgrading to correct routes an earlier version left behind. - `codegraph sync` and the file watcher now link a component to the JSX that renders it, like ``, when the component is added in a file with no JSX of its own, as in `export default function Team() { return null; }`. Before, the link waited for an unrelated change to a file with JSX, or for a re-index, so the new component looked unused. -- In JavaScript and TypeScript, a default import now links to what the module's `export default` statement exports, wherever the statement sits in the file. Before, a module that declares its default, like `export default function Vans() {…}` or `export default class Store {…}`, had its default import linked to the first function it exports instead: in a React Router data-router app, where a page exports its `loader` or `action` above the page component, `import Vans, { loader as vansLoader } from './pages/Vans'` made the route `/vans` and every use of `Vans` lead to the loader. A component file that also exports a styled or memoized component no longer hands its default import to that component, and an anonymous default, like `export default function () {…}` or `export default () => …`, is no longer linked to a function exported beside it or nested inside it. Re-index JavaScript and TypeScript projects after upgrading. +- In JavaScript and TypeScript, a default import now links to what the module's `export default` statement exports, wherever the statement sits in the file. Before, a module that declares its default, like `export default function Vans() {…}` or `export default class Store {…}`, had its default import linked to the first function it exports instead: in a React Router data-router app, where a page exports its `loader` or `action` above the page component, `import Vans, { loader as vansLoader } from './pages/Vans'` made the route `/vans` and every use of `Vans` lead to the loader. A component file that also exports a styled or memoized component no longer hands its default import to that component, and an anonymous default, like `export default function () {…}` or `export default () => …`, is no longer linked to a function exported beside it or nested inside it. A module loaded with `require('./x').default` is read the same way. Re-index JavaScript and TypeScript projects after upgrading. - In React and Vue, a tag for a component the file imports from a package, like `