fix(viewer): pages that went blank — repeated ids, and Steps on a function full of checks (#2264)

* fix(viewer): a repeated id no longer blanks Entry points or a component's Symbol view

The viewer keys its lists by id and a repeat stops the page drawing. The
routes list was a row per (route, edge), so an inline handler came back once
per call in its body; it is now one row per route — its bound handler, or
the route itself as an inline handler. A Vue/Svelte/Astro component's
members listed its script's symbols twice, plus the component and its file;
each member is now listed once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(viewer): Steps reads a function full of checks in one pass

The in-order reading kept every way forward through an `if` with no
`else` twice — under its condition and without — so eighty checks that
draw nothing (jsoup's `parse`) made 2^80 ways and the page died on a
stack overflow. Ways waiting at the same place are merged, keeping the
conditions they all share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-01 20:23:31 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d45edca3fd
commit 81e08b76ed
8 changed files with 233 additions and 12 deletions
+9 -2
View File
@@ -244,10 +244,17 @@ function buildMembers(
endpoints: Map<string, Node>,
overrides?: Map<string, WireOverride>
): { items: WireMember[]; total: number } {
// Each member once, and never the symbol itself or a file: a component in a
// Vue / Svelte / Astro file was recorded as containing its script's file node
// and every symbol in it, so the walk one level down met those symbols
// again — and a member listed twice is a repeated key the viewer cannot draw.
const seen = new Set<string>([focal.id]);
const admit = (child: Node | undefined): child is Node =>
!!child && child.kind !== 'file' && !seen.has(child.id) && !!seen.add(child.id);
const direct: Array<{ node: Node; parentId: string; depth: number }> = [];
for (const edge of containsOut) {
const child = endpoints.get(edge.target);
if (child) direct.push({ node: child, parentId: focal.id, depth: 1 });
if (admit(child)) direct.push({ node: child, parentId: focal.id, depth: 1 });
}
const containerIds = direct
@@ -260,7 +267,7 @@ function buildMembers(
const grandNodes = cg.getNodesByIds(grandEdges.map((e) => e.target));
for (const edge of grandEdges) {
const child = grandNodes.get(edge.target);
if (child) nested.push({ node: child, parentId: edge.source, depth: 2 });
if (admit(child)) nested.push({ node: child, parentId: edge.source, depth: 2 });
}
}
+64 -6
View File
@@ -23,6 +23,8 @@
import type { CodeGraph } from '../../index';
import { intParam } from './respond';
import { toPosixPath } from './wire';
import { routeRoots } from './route-roots';
import { isTestFile } from '../../search/query-utils';
/**
* HTTP verbs a route name may lead with, plus the two stand-ins the resolvers
@@ -56,6 +58,8 @@ export interface WireRoute {
routeFile: string;
routeLine: number;
routeId: string;
/** The handler is written inline at the registration: the route stands in for it. */
inline: boolean;
}
export interface WireRoutes {
@@ -86,11 +90,62 @@ export function splitRouteName(url: string): { method: string | null; path: stri
*/
const MIN_LIMIT = 3;
/** Manifest rows fetched per route shown, so folding repeats still fills the page. */
const ROWS_PER_ROUTE = 4;
type ManifestRow = NonNullable<ReturnType<CodeGraph['getRoutingManifest']>>['entries'][number];
/**
* The manifest's rows folded to one per route, in its order: the row naming
* the route's root (the handler a resolver bound, or the component a screen
* renders), else the route's first row. A route whose root is its own inline
* body is that route, labelled as an inline handler — not whichever call in
* the body sorted first — and is left out when it is written in a test, as
* the manifest leaves out test handlers.
*/
function oneRowPerRoute(cg: CodeGraph, entries: readonly ManifestRow[]): Array<ManifestRow & { inline: boolean }> {
const byRoute = new Map<string, ManifestRow[]>();
for (const entry of entries) {
const list = byRoute.get(entry.routeId);
if (list) list.push(entry);
else byRoute.set(entry.routeId, [entry]);
}
const routes = [...cg.getNodesByIds([...byRoute.keys()]).values()];
const roots = routeRoots(cg, routes);
const out: Array<ManifestRow & { inline: boolean }> = [];
for (const [routeId, list] of byRoute) {
const root = roots.get(routeId);
if (root?.inline) {
const first = list[0]!;
if (isTestFile(first.routeFile)) continue;
out.push({
...first,
handler: 'inline handler',
handlerKind: 'route',
handlerFile: first.routeFile,
handlerLine: first.routeLine,
inline: true,
});
continue;
}
const named = root
? list.find((e) => e.handlerFile === root.node.filePath && e.handlerLine === root.node.startLine && e.handler === root.node.name)
: undefined;
out.push({ ...(named ?? list[0]!), inline: false });
}
return out;
}
export function buildRoutes(cg: CodeGraph, query: URLSearchParams): WireRoutes {
const limit = intParam(query, 'limit', { min: MIN_LIMIT, max: 500, default: 200 });
// One row over the limit, purely to learn whether there were more.
const manifest = cg.getRoutingManifest(limit + 1);
// The engine's manifest is a row per (route, edge): a route bound to two
// symbols, or an inline handler whose every call reads as a "handler"
// (hono's `GET /stream/text` came back three times, as `streamText`,
// `writeln` and `sleep`), repeats. A route has ONE answer to "what serves
// this" — route-roots.ts's — so rows are over-fetched and folded onto it.
const fetched = limit * ROWS_PER_ROUTE + 1;
const manifest = cg.getRoutingManifest(fetched);
const routeCount = cg.getStats().nodesByKind.route ?? 0;
if (!manifest) {
@@ -105,8 +160,9 @@ export function buildRoutes(cg: CodeGraph, query: URLSearchParams): WireRoutes {
};
}
const truncated = manifest.entries.length > limit;
const rows = manifest.entries.slice(0, limit);
const folded = oneRowPerRoute(cg, manifest.entries);
const truncated = folded.length > limit || manifest.entries.length >= fetched;
const rows = folded.slice(0, limit);
// Every row's handler file in one batched query, so a project that scatters
// handlers across hundreds of files still costs one query per chunk, and no
@@ -127,11 +183,13 @@ export function buildRoutes(cg: CodeGraph, query: URLSearchParams): WireRoutes {
handlerKind: entry.handlerKind,
file: toPosixPath(entry.handlerFile),
line: entry.handlerLine,
handlerId:
byFileLineName.get(`${entry.handlerFile} ${entry.handlerLine} ${entry.handler}`) ?? null,
handlerId: entry.inline
? entry.routeId
: byFileLineName.get(`${entry.handlerFile} ${entry.handlerLine} ${entry.handler}`) ?? null,
routeFile: toPosixPath(entry.routeFile),
routeLine: entry.routeLine,
routeId: entry.routeId,
inline: entry.inline,
}));
return {