From 0830114977a83a6855ab7db6b8607676190078f9 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sat, 10 Oct 2026 03:42:07 -0500 Subject: [PATCH] fix(react-router): a lazy route links the page its loader picks 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;