[Bug]: Bare numeric author and labels URL filters crash the Pull Requests page

Open
#35,302 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
3/5
Estimated time
1-2 days
Newbie friendliness
76/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
playwright, react, typescript
Domain
frontend, testing

Research direction

The reproduction points to the Pull Requests page, its route search parsing, filter picker, and root error component in apps/web. Start by tracing how author and labels query values reach the page, then run the supplied browser reproduction against the baseline. Done means bare numeric author and labels URLs no longer crash, the picker’s quoted author URL still works after reload, and the reproduction’s recovery and control checks pass.

Written by the indexing model from the issue text.

Description

Before submitting
  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.
Area

apps/web

Steps to reproduce

Use a checkout of the baseline commit with its lockfile dependencies and the matching Playwright Chromium installed. The reproduction renders the real AppRoot, the production getRouter and generated route tree, the Pull Requests page, its hooks and the root error component in Chromium. Its in-process environment (numericEnvironment.ts, adapted from the repository's existing browser-test environment) replaces only the connection and HTTP transport: its pull request list handler validates each input against PullRequestListInput and each result against PullRequestListResult, and serves two open PRs authored by ana and 4089. It rejects a numeric author input instead of fabricating rows. No T3 server, live source-control host or provider agent runs.

  1. Save the three files below together in one directory outside the checkout. From the checkout root, run bash /path/to/dir/repro.sh "$PWD". Set PLAYWRIGHT_BROWSERS_PATH to an existing browser installation if it is not the default macOS Playwright cache. The script creates private HOME, TMPDIR and T3CODE_HOME directories, refuses to overwrite existing files, copies the test to apps/web/src/components/pullRequest/verifyNumericSearch.browser.test.tsx and the environment to apps/web/test/numericEnvironment.ts, runs only that file from apps/web with env -u NODE_ENV vp test run ... --project browser, removes both files on exit, and checks the CASE markers.
  2. The first test opens bare /pull-requests?author=4089 with no list loaded yet, then navigates to /pull-requests?author=ana and checks the ana PR row renders (text control).
  3. It clears the author, opens Filters → Author in the real menu, picks 4089, and checks that the URL becomes author=%224089%22, that author stays a string, and that a fresh router parse of that URL keeps it a string.
  4. With the list loaded, it navigates to bare /pull-requests?author=4089, checks for the root error screen, then navigates back to /pull-requests?author=ana and checks the row renders again.
  5. It navigates to bare /pull-requests?labels=4089, checks for the root error screen, then navigates back to ?author=ana and checks recovery.
  6. The second test mounts a fresh AppRoot at the quoted /pull-requests?author=%224089%22 and checks the 4089 row renders. The third mounts a fresh AppRoot at bare /pull-requests?labels=4089, with no list needed, and checks for the root error screen.

The script exits 0 only when all three tests pass and every failure and control marker is present.

Reproduction script: repro.sh
#!/bin/bash
# Run from an installed, disposable checkout at the reported baseline.
# Usage: PLAYWRIGHT_BROWSERS_PATH=<existing browser cache> ./repro.sh <checkout>
set -euo pipefail
checkout=$(cd "${1:?Pass a disposable checkout with dependencies installed}" && pwd -P)
script_dir=$(cd "$(dirname "$0")" && pwd -P)
fixture=$(mktemp -d "${TMPDIR:-/tmp}/numeric-search-repro.XXXXXX")
test_file="$checkout/apps/web/src/components/pullRequest/verifyNumericSearch.browser.test.tsx"
wire_file="$checkout/apps/web/test/numericEnvironment.ts"
if [[ -e "$test_file" || -e "$wire_file" ]]; then
  echo 'Refusing to overwrite an existing verification fixture.' >&2
  rmdir "$fixture"
  exit 2
fi
browsers=${PLAYWRIGHT_BROWSERS_PATH:-"$HOME/Library/Caches/ms-playwright"}
cleanup() { rm -f "$test_file" "$wire_file"; rm -rf "$fixture"; }
trap cleanup EXIT
mkdir -p "$fixture/fakehome" "$fixture/tmp" "$fixture/t3home"
cp "$script_dir/verifyNumericSearch.browser.test.tsx" "$fixture/test.tsx"
cp "$script_dir/numericEnvironment.ts" "$fixture/environment.ts"
cp "$fixture/test.tsx" "$test_file"
cp "$fixture/environment.ts" "$wire_file"
export HOME="$fixture/fakehome" TMPDIR="$fixture/tmp" T3CODE_HOME="$fixture/t3home"
export PLAYWRIGHT_BROWSERS_PATH="$browsers"
export PATH="$checkout/node_modules/.bin:$PATH"
printf 'REVISION %s\n' "$(git -C "$checkout" rev-parse HEAD)"
cd "$checkout/apps/web"
if env -u NODE_ENV vp test run src/components/pullRequest/verifyNumericSearch.browser.test.tsx --project browser --reporter verbose --silent=false > "$fixture/run.out" 2>&1; then
  cat "$fixture/run.out"
else
  cat "$fixture/run.out"
  exit 1
fi
for marker in \
 'CASE control author=ana real-page=healthy' \
 'CASE author=4089 real-page=root-error-boundary error=author.trim is not a function' \
 'CASE labels=4089 real-page=root-error-boundary error=search.labels.map is not a function' \
 'CASE author-recovery real-page=healthy' \
 'CASE labels-recovery real-page=healthy' \
 'CASE picker-reload real-page=healthy author-type=string' \
 'CASE cold-labels=4089 real-page=root-error-boundary error=search.labels.map is not a function'; do
  grep -Fq "$marker" "$fixture/run.out" || { printf 'Missing marker: %s\n' "$marker"; exit 1; }
done
printf '\nDEFECT PRESENT; textual control, numeric picker, quoted reload, and navigation recovery passed.\n'
Browser test: verifyNumericSearch.browser.test.tsx
import { createMemoryHistory } from "@tanstack/react-router";
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
import type * as Context from "effect/Context";
import { expect, it, vi } from "vite-plus/test";
import { page, userEvent } from "vite-plus/test/browser";
import "~/index.css";
import { AppRoot } from "~/AppRoot";
import { __setClientSettingsForTests } from "~/hooks/useSettings";
import { getRouter } from "~/router";
import { installFakeEnvironment, type FakeDriver } from "../../../test/numericEnvironment";
import { act, render } from "../../../test/render";

vi.mock("../../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => {
  const [actual, { fakeDriver }, Layer] = await Promise.all([
    importOriginal<{readonly ConnectionDriver: Context.Key<unknown, FakeDriver>}>(),
    import("../../../test/numericEnvironment"), import("effect/Layer"),
  ]);
  return {...actual, layer: Layer.succeed(actual.ConnectionDriver, fakeDriver)};
});
vi.mock(import("~/rpc/requestLatencyState"), async original => ({
  ...(await original()), trackRpcRequestSent: () => {},
}));

it("observes the real route, filter selection, numeric URLs, and recovery", async () => {
  using environment = installFakeEnvironment([]);
  __setClientSettingsForTests({...DEFAULT_CLIENT_SETTINGS, onboardingCompletedAt: "2026-01-01T00:00:00Z"});
  localStorage.clear();
  const router = getRouter(createMemoryHistory({initialEntries: ["/pull-requests?author=4089"]}));
  await router.load();
  const container = document.createElement("div");
  container.style.cssText = "height:900px;width:1400px";
  // AppRoot owns the production router, so render must not add a second router.
  await using screen = await render(<AppRoot router={router}/>, {container, router:false});
  await expect.element(page.getByText("Could not load pull requests", {exact:false})).toBeVisible();
  await expect.element(page.getByRole("heading", {name:"Pull requests", exact:true})).toBeVisible();
  console.log("CASE cold-author=4089 real-page=healthy list=request-validation-error");
  await act(() => router.navigate({href:"/pull-requests?author=ana"}));
  await expect.element(page.getByText("Fixture PR by ana", {exact:true})).toBeVisible();
  console.log("CASE control author=ana real-page=healthy");
  await act(() => router.navigate({to:"/pull-requests",search:{involvement:"all",state:"open"}}));
  await expect.element(page.getByText("Fixture PR by 4089", {exact:true})).toBeVisible();
  await userEvent.click(page.getByRole("button", {name:/Filters/}));
  await userEvent.click(page.getByRole("menuitem", {name:/Author/}));
  await userEvent.click(page.getByRole("menuitemradio", {name:/4089/}));
  await vi.waitFor(() => expect(router.state.location.search.author).toBe("4089"));
  expect(router.state.location.href).toContain("author=%224089%22");
  await expect.element(page.getByText("Fixture PR by 4089", {exact:true})).toBeVisible();
  console.log(`CASE picker url=${router.state.location.href} author-type=${typeof router.state.location.search.author} real-page=healthy`);
  const serialized = router.state.location.href;
  const reloadRouter = getRouter(createMemoryHistory({initialEntries:[serialized]}));
  await reloadRouter.load();
  expect(reloadRouter.matchRoutes(reloadRouter.latestLocation).at(-1)?.search.author).toBe("4089");
  console.log("CASE picker-reparse author-type=string");
  await act(() => router.navigate({href:"/pull-requests?author=4089"}));
  await expect.element(page.getByRole("heading", {name:"Something went wrong."})).toBeVisible();
  expect(container.textContent).toContain("author.trim is not a function");
  await expect.element(page.getByText("Fixture PR by 4089", {exact:true})).not.toBeInTheDocument();
  console.log("CASE author=4089 real-page=root-error-boundary error=author.trim is not a function");
  await act(() => router.navigate({href:"/pull-requests?author=ana"}));
  await expect.element(page.getByText("Fixture PR by ana", {exact:true})).toBeVisible();
  console.log("CASE author-recovery real-page=healthy");
  await act(() => router.navigate({href:"/pull-requests?labels=4089"}));
  await expect.element(page.getByRole("heading", {name:"Something went wrong."})).toBeVisible();
  expect(container.textContent).toContain("search.labels.map is not a function");
  console.log("CASE labels=4089 real-page=root-error-boundary error=search.labels.map is not a function");
  await act(() => router.navigate({href:"/pull-requests?author=ana"}));
  await expect.element(page.getByText("Fixture PR by ana", {exact:true})).toBeVisible();
  console.log("CASE labels-recovery real-page=healthy");
});


it("opens a fresh route using the numeric author URL serialized by the picker", async () => {
  using environment = installFakeEnvironment([]);
  __setClientSettingsForTests({...DEFAULT_CLIENT_SETTINGS, onboardingCompletedAt: "2026-01-01T00:00:00Z"});
  const router = getRouter(createMemoryHistory({initialEntries:["/pull-requests?author=%224089%22"]}));
  await router.load();
  await using screen = await render(<AppRoot router={router}/>, {router:false});
  await expect.element(page.getByText("Fixture PR by 4089", {exact:true})).toBeVisible();
  console.log("CASE picker-reload real-page=healthy author-type=string");
});

it("opens a fresh numeric labels URL at the root error boundary without needing rows", async () => {
  using environment = installFakeEnvironment([]);
  __setClientSettingsForTests({...DEFAULT_CLIENT_SETTINGS, onboardingCompletedAt: "2026-01-01T00:00:00Z"});
  const router = getRouter(createMemoryHistory({initialEntries:["/pull-requests?labels=4089"]}));
  await router.load();
  await using screen = await render(<AppRoot router={router}/>, {router:false});
  await expect.element(page.getByRole("heading", {name:"Something went wrong."})).toBeVisible();
  expect(screen.container.textContent).toContain("search.labels.map is not a function");
  console.log("CASE cold-labels=4089 real-page=root-error-boundary error=search.labels.map is not a function");
});
In-process environment: numericEnvironment.ts
// An in-process environment for app render scenarios: a fetch stub for its HTTP API and a
// session that answers the shell, thread and config subscriptions from fixtures. Other RPCs
// stay pending so nothing retries on timers. Everything above the wire is the app's own.
import {
  AuthStandardClientScopes,
  PullRequestListInput,
  PullRequestListResult,
  PullRequestListStatsInput,
  SourceControlProviderKind,
  EnvironmentId,
  EventId,
  ExecutionEnvironmentDescriptor,
  MessageId,
  ORCHESTRATION_PROTOCOL_VERSION,
  ORCHESTRATION_V2_WS_METHODS,
  ProjectId,
  ProviderInstanceId,
  RunId,
  ServerConfig,
  ThreadId,
  TurnItemId,
  WS_METHODS,
  WsRpcGroup,
  type VcsStatusStreamEvent,
  type OrchestrationProjectShell,
  type OrchestrationV2Command,
  type OrchestrationV2ProjectedTurnItem,
  type OrchestrationV2Run,
  type OrchestrationV2ShellStreamItem,
  type OrchestrationV2ThreadProjection,
  type OrchestrationV2ThreadShell,
  type OrchestrationV2ThreadStreamItem,
  type OrchestrationV2TurnItem,
  type TerminalMetadataStreamEvent,
} from "@t3tools/contracts";
import type {
  ConnectionAttemptError,
  ConnectionCatalogEntry,
  EnvironmentConnectionLease,
  RouteCheck,
} from "@t3tools/client-runtime/connection";
import type { WsRpcProtocolClient } from "@t3tools/client-runtime/rpc";
import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
import { APP_VERSION } from "~/branding";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Queue from "effect/Queue";
import type * as Scope from "effect/Scope";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { RpcSchema } from "effect/rpc";

export const ENVIRONMENT_ID = EnvironmentId.make("environment-render");
export const PROJECT_ID = ProjectId.make("project-render");
const CODEX = ProviderInstanceId.make("codex");
const MODEL = { instanceId: CODEX, model: "gpt-5.4" };
const AT = DateTime.makeUnsafe("2026-03-17T19:12:28.000Z");

const descriptor = {
  environmentId: ENVIRONMENT_ID,
  label: "Render scenarios",
  platform: { os: "linux", arch: "x64" },
  // A matching version, since a mismatch adds an update banner a typical user never sees.
  serverVersion: APP_VERSION,
  orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION,
  capabilities: { repositoryIdentity: true },
};
const auth = {
  policy: "loopback-browser",
  bootstrapMethods: ["one-time-token"],
  sessionMethods: ["browser-session-cookie"],
  sessionCookieName: "t3_session",
};

const baseServerConfig: ServerConfig = {
  ...Schema.decodeUnknownSync(ServerConfig)({
    environment: descriptor,
    auth,
    cwd: "/workspace/render",
    keybindingsConfigPath: "/workspace/.t3/keybindings.json",
    keybindings: [],
    issues: [],
    providers: [
      {
        instanceId: CODEX,
        driver: "codex",
        enabled: true,
        installed: true,
        version: "1.0.0",
        status: "ready",
        auth: { status: "authenticated" },
        checkedAt: "2026-03-17T19:12:28.000Z",
        models: [
          { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, capabilities: null },
          { slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities: null },
        ],
      },
    ],
    availableEditors: [],
    observability: {
      logsDirectoryPath: "/workspace/.t3/logs",
      localTracingEnabled: false,
      otlpTracesEnabled: false,
      otlpMetricsEnabled: false,
    },
    settings: {},
    threadSnapshotPagination: true,
    threadResumeCompletionMarker: true,
    shellResumeCompletionMarker: true,
  }),
  keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
};
// A server only leaves threads out of the shell when it serves their families.
const serverConfig = (): ServerConfig =>
  threads.some((thread) => thread.olderSettled)
    ? { ...baseServerConfig, shellThreadFamilies: true }
    : baseServerConfig;

const project: OrchestrationProjectShell = {
  id: PROJECT_ID,
  title: "render",
  workspaceRoot: "/workspace/render",
  repositoryIdentity: null,
  defaultModelSelection: null,
  scripts: [],
  createdAt: "2026-03-17T19:12:28.000Z",
  updatedAt: "2026-03-17T19:12:28.000Z",
};

const base = (threadId: ThreadId, runId: RunId, id: string, ordinal: number) => ({
  id: TurnItemId.make(id),
  threadId,
  runId,
  nodeId: null,
  providerThreadId: null,
  providerTurnId: null,
  nativeItemRef: null,
  parentItemId: null,
  ordinal,
  status: "completed" as const,
  title: null,
  startedAt: AT,
  completedAt: AT,
  updatedAt: AT,
});

function userItem(threadId: ThreadId, runId: RunId, id: string, ordinal: number, text: string) {
  return {
    ...base(threadId, runId, id, ordinal),
    type: "user_message",
    messageId: MessageId.make(id),
    text,
    attachments: [],
    inputIntent: "turn_start",
    createdBy: "user",
    creationSource: "web",
  } satisfies OrchestrationV2TurnItem;
}

export function assistantItem(
  threadId: ThreadId,
  runId: RunId,
  id: string,
  ordinal: number,
  text: string,
  streaming = false,
) {
  return {
    ...base(threadId, runId, id, ordinal),
    type: "assistant_message",
    messageId: MessageId.make(id),
    text,
    streaming,
    ...(streaming ? { status: "running" as const, completedAt: null } : {}),
  } satisfies OrchestrationV2TurnItem;
}

function run(threadId: ThreadId, id: RunId, ordinal: number, userMessageId: string) {
  return {
    id,
    threadId,
    ordinal,
    providerInstanceId: CODEX,
    modelSelection: MODEL,
    providerThreadId: null,
    userMessageId: MessageId.make(userMessageId),
    rootNodeId: null,
    activeAttemptId: null,
    status: "completed",
    requestedAt: AT,
    startedAt: AT,
    completedAt: AT,
    checkpointId: null,
    contextHandoffId: null,
  } satisfies OrchestrationV2Run;
}

export interface FakeThread {
  /** Makes this a side chat of that thread. */
  readonly sideChatOf?: ThreadId;
  /** Makes this an older settled thread the shell leaves out; only its family read returns it. */
  readonly olderSettled?: true;
  readonly id: ThreadId;
  readonly title: string;
  readonly runs: ReadonlyArray<OrchestrationV2Run>;
  readonly items: ReadonlyArray<OrchestrationV2TurnItem>;
}

/** A thread of `turns` user prompts, each answered in markdown with a list and inline code. */
export function threadWithHistory(name: string, turns: number): FakeThread {
  const id = ThreadId.make(name);
  const runs: OrchestrationV2Run[] = [];
  const items: OrchestrationV2TurnItem[] = [];
  for (let turn = 0; turn < turns; turn++) {
    const runId = RunId.make(`${name}-run-${turn}`);
    runs.push(run(id, runId, turn + 1, `${name}-user-${turn}`));
    items.push(
      userItem(id, runId, `${name}-user-${turn}`, turn * 2, `${name}: question ${turn}`),
      assistantItem(
        id,
        runId,
        `${name}-assistant-${turn}`,
        turn * 2 + 1,
        `Answer ${turn} for ${name}.\n\n- checked \`Sidebar.tsx\`\n- ran the tests\n\n` +
          "The width is now `clamp(next, MIN_WIDTH, MAX_WIDTH)`, so the layout no longer jumps.",
      ),
    );
  }
  return { id, title: `Thread ${name}`, runs, items };
}

function shellOf(thread: FakeThread): OrchestrationV2ThreadShell {
  const latest = thread.runs.at(-1);
  return {
    id: thread.id,
    projectId: PROJECT_ID,
    title: thread.title,
    providerInstanceId: CODEX,
    modelSelection: MODEL,
    runtimeMode: "full-access",
    interactionMode: "default",
    branch: "main",
    worktreePath: null,
    activeProviderThreadId: null,
    lineage: {
      rootThreadId: thread.sideChatOf ?? thread.id,
      parentThreadId: thread.sideChatOf ?? null,
      relationshipToParent: null,
      ...(thread.sideChatOf ? { sideChat: true } : {}),
    },
    forkedFrom: null,
    createdBy: "user",
    creationSource: "web",
    latestRunId: latest?.id ?? null,
    activeRunId: null,
    // Idle reads as waiting on the user, which shows a per-second duration in the sidebar.
    status: latest ? "completed" : "idle",
    pendingRuntimeRequest: null,
    latestVisibleMessage: null,
    latestUserMessageAt: AT,
    hasActionableProposedPlan: false,
    itemCount: thread.items.length,
    visibleItemCount: thread.items.length,
    createdAt: AT,
    updatedAt: AT,
    archivedAt: null,
    settledOverride: thread.olderSettled ? "settled" : null,
    settledAt: thread.olderSettled ? AT : null,
    lastVisitedAt: AT,
    deletedAt: null,
  };
}

function projectionOf(thread: FakeThread): OrchestrationV2ThreadProjection {
  const shell = shellOf(thread);
  const visibleTurnItems: OrchestrationV2ProjectedTurnItem[] = thread.items.map(
    (item, position) => ({
      position,
      visibility: "local",
      sourceThreadId: thread.id,
      sourceItemId: item.id,
      item,
    }),
  );
  return {
    thread: {
      id: shell.id,
      projectId: shell.projectId,
      title: shell.title,
      providerInstanceId: shell.providerInstanceId,
      modelSelection: shell.modelSelection,
      runtimeMode: shell.runtimeMode,
      interactionMode: shell.interactionMode,
      branch: shell.branch,
      worktreePath: shell.worktreePath,
      activeProviderThreadId: null,
      lineage: shell.lineage,
      forkedFrom: null,
      createdBy: "user",
      creationSource: "web",
      createdAt: AT,
      updatedAt: AT,
      archivedAt: null,
      settledOverride: shell.settledOverride,
      settledAt: shell.settledAt,
      lastVisitedAt: AT,
      deletedAt: null,
    },
    runs: [...thread.runs],
    attempts: [],
    nodes: [],
    subagents: [],
    providerSessions: [],
    providerThreads: [],
    providerTurns: [],
    runtimeRequests: [],
    messages: [],
    plans: [],
    turnItems: [...thread.items],
    checkpointScopes: [],
    checkpoints: [],
    contextHandoffs: [],
    contextTransfers: [],
    visibleTurnItems,
    updatedAt: AT,
  };
}

const json = (body: unknown, status = 200) =>
  new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });

// Keyed by thread id: a live thread subscription's queue, for pushing events to it.
const threadSubscribers = new Map<string, Queue.Queue<OrchestrationV2ThreadStreamItem>>();
let terminalMetadataSubscriber: Queue.Queue<TerminalMetadataStreamEvent> | null = null;
let threads: ReadonlyArray<FakeThread> = [];
let dispatched: OrchestrationV2Command[] = [];
let acceptCommands = false;
let sequence = 1;
let vcsStatusSubscriber: Queue.Queue<VcsStatusStreamEvent> | null = null;

const subscribe = <A>(first: ReadonlyArray<A>, register?: (queue: Queue.Queue<A>) => () => void) =>
  Stream.unwrap(
    Effect.gen(function* () {
      const queue = yield* Queue.unbounded<A>();
      yield* Queue.offerAll(queue, first);
      if (register) {
        const unregister = register(queue);
        yield* Effect.addFinalizer(() => Effect.sync(unregister));
      }
      return Stream.fromQueue(queue);
    }),
  );


const fixtureEntries = ["ana", "4089"].map((login, i) => ({
  provider: SourceControlProviderKind.make("github"), host: "github.com",
  projectId: PROJECT_ID, projectTitle: "render", repository: "acme/web",
  number: i + 1, title: `Fixture PR by ${login}`, url: `https://github.com/acme/web/pull/${i + 1}`,
  author: { login, name: null, avatarUrl: null }, headBranch: "feature", baseBranch: "main",
  state: "open", isDraft: false, mergeability: "mergeable", additions: 1, deletions: 0,
  createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z",
  viewerReviewRequested: false, labels: [{name: "4089", color: null}],

}));
const fixtureProviders = [{host: "github.com", kind: SourceControlProviderKind.make("github"),
  configured: true, searchesOnHost: true, projectCount: 1, projectIds: [PROJECT_ID], detail: null}];

const handlers: Record<string, (input: never) => unknown> = {
  [WS_METHODS.pullRequestsList]: (raw: unknown) => Effect.try({
    try: () => {
      const input = Schema.decodeUnknownSync(PullRequestListInput)(raw);
      const entries = input.involvement === "reviewing" || input.involvement === "authored" ? [] :
        fixtureEntries.filter(row => !input.filters?.author || row.author.login === input.filters.author);
      return Schema.decodeUnknownSync(PullRequestListResult)({viewers: {"github.com": "viewer"},
        providers: fixtureProviders, entries, errors: [], truncated: false, nextCursors: {}});
    }, catch: error => error as Error,
  }),
  [WS_METHODS.pullRequestsListStats]: (raw: unknown) => Effect.try({
    try: () => { Schema.decodeUnknownSync(PullRequestListStatsInput)(raw); return {stats: []}; },
    catch: error => error as Error,
  }),
  [WS_METHODS.pullRequestsFilterOptions]: () => Effect.succeed({authors: fixtureEntries.map(e => e.author), labels: [{name:"4089",color:null}], references: []}),

  // Recorded, and accepted only when the scenario asks, since a pending command holds every
  // later command on its thread. Accepted commands' events never arrive.
  [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) =>
    Effect.suspend(() => {
      dispatched.push(command);
      return acceptCommands ? Effect.succeed({ sequence: sequence + 1 }) : Effect.never;
    }),
  [WS_METHODS.subscribeTerminalMetadata]: () =>
    subscribe<TerminalMetadataStreamEvent>([{ type: "snapshot", terminals: [] }], (queue) => {
      terminalMetadataSubscriber = queue;
      return () => {
        if (terminalMetadataSubscriber === queue) terminalMetadataSubscriber = null;
      };
    }),
  [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () =>
    subscribe<OrchestrationV2ShellStreamItem>([
      {
        kind: "snapshot",
        snapshot: {
          schemaVersion: 1,
          snapshotSequence: sequence,
          projects: [project],
          threads: threads.filter((thread) => !thread.olderSettled).map(shellOf),
          archivedThreads: [],
        },
      },
      { kind: "synchronized" },
    ]),
  [ORCHESTRATION_V2_WS_METHODS.subscribeThread]: (input: { readonly threadId: ThreadId }) => {
    const thread = threads.find((candidate) => candidate.id === input.threadId);
    if (!thread) return Stream.never;
    return subscribe<OrchestrationV2ThreadStreamItem>(
      [
        { kind: "snapshot", snapshotSequence: sequence, projection: projectionOf(thread) },
        { kind: "synchronized" },
      ],
      (queue) => {
        threadSubscribers.set(thread.id, queue);

        // A replaced subscription closes after its successor registers.
        return () => {
          if (threadSubscribers.get(thread.id) === queue) threadSubscribers.delete(thread.id);
        };
      },
    );
  },
  [ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: (input: { readonly threadId: ThreadId }) => {
    const rootOf = (thread: FakeThread) => thread.sideChatOf ?? thread.id;
    const requested = threads.find((candidate) => candidate.id === input.threadId);
    return Effect.succeed({
      snapshotSequence: sequence,
      threads: requested
        ? threads.filter((thread) => rootOf(thread) === rootOf(requested)).map(shellOf)
        : [],
    });
  },
  [WS_METHODS.subscribeVcsStatus]: () =>
    subscribe<VcsStatusStreamEvent>([], (queue) => {
      vcsStatusSubscriber = queue;
      return () => {
        if (vcsStatusSubscriber === queue) vcsStatusSubscriber = null;
      };
    }),
  [WS_METHODS.subscribeServerConfig]: () =>
    subscribe([{ version: 1 as const, type: "snapshot" as const, config: serverConfig() }]),
};

// Unanswered calls stay pending, like a server that has not replied yet.
const client = new Proxy(handlers, {
  get: (target, method: string) =>
    target[method] ??
    (() => {
      const rpc = WsRpcGroup.requests.get(method);
      return rpc && RpcSchema.isStreamSchema(rpc.successSchema) ? Stream.never : Effect.never;
    }),
});

const session: EnvironmentConnectionLease["session"] = {
  client: client as unknown as WsRpcProtocolClient,
  initialConfig: Effect.sync(serverConfig),
  subscribeServerConfig: () =>
    subscribe([{ version: 1 as const, type: "snapshot" as const, config: serverConfig() }]),
  ready: Effect.void,
  probe: Effect.void,
  inboundFrames: Effect.succeed(0),
  closed: Effect.never,
};

/** Stands in for client-runtime's ConnectionDriver: every connection opens the session above. */
export const fakeDriver = {
  connect: (
    entry: ConnectionCatalogEntry,
  ): Effect.Effect<EnvironmentConnectionLease, ConnectionAttemptError, Scope.Scope> =>
    Effect.succeed({
      prepared: {
        environmentId: ENVIRONMENT_ID,
        label: descriptor.label,
        httpBaseUrl: window.location.origin,
        socketUrl: window.location.origin.replace(/^http/, "ws"),
        httpAuthorization: null,
        target: entry.target,
      },
      session,
    }),
  preflight: () => Effect.succeed(true),
  checkHost: () => Effect.succeed<RouteCheck>("unchecked"),
};
export type FakeDriver = typeof fakeDriver;

const realFetch = globalThis.fetch;
const encodedDescriptor = Schema.encodeSync(ExecutionEnvironmentDescriptor)(
  Schema.decodeUnknownSync(ExecutionEnvironmentDescriptor)(descriptor),
);

/** Serves the environment's HTTP API and these threads until disposed. */
export function installFakeEnvironment(
  fixtures: ReadonlyArray<FakeThread>,
  options?: { readonly acceptCommands?: boolean },
): Disposable {
  threads = fixtures;
  dispatched = [];
  acceptCommands = options?.acceptCommands === true;
  globalThis.fetch = async (input, init) => {
    const url = new URL(input instanceof Request ? input.url : String(input), window.location.href);
    if (url.origin !== window.location.origin) return realFetch(input, init);
    if (url.pathname === "/.well-known/t3/environment") return json(encodedDescriptor);
    if (url.pathname === "/api/auth/session") {
      return json({
        authenticated: true,
        auth,
        scopes: AuthStandardClientScopes,
        sessionMethod: "browser-session-cookie",
        expiresAt: "2099-01-01T00:00:00.000Z",
      });
    }
    // Snapshot endpoints fall back to the socket, which serves the same fixtures.
    if (/^\/(api|oauth|\.well-known)\//.test(url.pathname)) return json({}, 404);
    return realFetch(input, init);
  };
  return {
    [Symbol.dispose]: () => {
      globalThis.fetch = realFetch;
      threads = [];
      dispatched = [];
      acceptCommands = false;
    },
  };
}

/** Commands the app dispatched to the environment, oldest first. */
export function dispatchedCommands(): ReadonlyArray<OrchestrationV2Command> {
  return dispatched;
}

type ThreadEvent = Extract<OrchestrationV2ThreadStreamItem, { readonly kind: "event" }>["event"];

export async function emitTerminalMetadata(event: TerminalMetadataStreamEvent): Promise<void> {
  if (terminalMetadataSubscriber === null) throw new Error("No terminal metadata subscription");
  await Effect.runPromise(Queue.offer(terminalMetadataSubscriber, event));
}

/** Whether the app holds a live subscription to the thread, so events reach it. */
export function isThreadSubscribed(threadId: ThreadId): boolean {
  return threadSubscribers.has(threadId);
}

/** Delivers one domain event, such as a run or turn-item update, to the thread's subscription. */
export async function emitThreadEvent(
  threadId: ThreadId,
  event: Pick<ThreadEvent, "type" | "payload">,
): Promise<void> {
  const queue = threadSubscribers.get(threadId);
  if (!queue) throw new Error(`No live subscription for ${threadId}`);
  sequence += 1;
  await Effect.runPromise(
    Queue.offer(queue, {
      kind: "event",
      sequence,
      event: { ...event, id: EventId.make(`event-${sequence}`), threadId, occurredAt: AT },
    } as OrchestrationV2ThreadStreamItem),
  );
}

export function runningRun(threadId: ThreadId, id: string, ordinal: number, userMessageId: string) {
  return {
    ...run(threadId, RunId.make(id), ordinal, userMessageId),
    status: "running" as const,
    completedAt: null,
  };
}

export { userItem };

export async function emitVcsStatus(event: VcsStatusStreamEvent): Promise<void> {
  if (vcsStatusSubscriber === null) throw new Error("No VCS status subscription");
  await Effect.runPromise(Queue.offer(vcsStatusSubscriber, event));
}
Expected behavior

A URL search value with the wrong parsed type is removed or normalized before the page uses it, and the Pull Requests page stays usable. Issue #18640 established this contract for /pull-requests?q=4089: a malformed URL must not crash the page. Its fix, PR #18666 (df26ef9985), made validateSearch always return its own q key so the router's merge overwrites the raw parent value. That PR scoped itself to q and named the remaining keys as an open risk: "Every other optional key this validator leaves out still reaches the page as the raw value. From reading the code, a non-text repository or a non-array labels looks like it can crash the page too (not run). Those keys are not changed here." This report covers the author and labels part of that remaining contract; it is not a regression from #18666. The route's validateSearch declares a string author and a string-array labels, and the query-filter hook relies on those types. For the reproduction's inputs, bare ?author=4089 and ?labels=4089 should either filter by the text 4089 or drop the filter, without the root error screen.

Actual behavior

Summary. Bare numeric author and labels values in the /pull-requests URL crash the Pull Requests page to the root "Something went wrong." screen (search.labels.map is not a function, author.trim is not a function), because validateSearch normalizes only q (#18666) and still lets these raw numbers through. labels crashes on any visit; author crashes once the list has rows. Picking an all-digit login in the app's author filter writes a quoted string and is safe; the failure needs a hand-edited or externally built link.

Bare ?labels=4089 replaces the whole route with the root "Something went wrong." screen showing search.labels.map is not a function. This happens on a fresh AppRoot mount, before any list data is needed, and after navigation from a loaded list.

Bare ?author=4089 replaces the route with the same root error screen showing author.trim is not a function when the page already has PR rows (navigating a loaded list to that URL). The list's row filter passes the numeric author into the string-only resolvePullRequestAuthorFilter. A cold visit to bare ?author=4089 with no rows yet does not crash in this fixture: the page shell stays and the list shows a request error ("Could not load pull requests"), because the fixture's schema-checked list handler rejects the numeric author.

In both crash cases the root error component replaces the route shell, and the loaded rows disappear. Navigating back to ?author=ana restores the working list without reloading the app.

The ordinary path is safe: picking the all-digit login 4089 through Filters → Author writes author=%224089%22, author stays a string, and a fresh mount of that quoted URL renders the row. The failure needs a bare numeric value in the URL, such as a hand-edited link or one built outside the app. All-digit logins exist (for example the GitHub user 4089, and GitLab and Forgejo username rules allow them; Bitbucket was not checked), so a link built from a login without JSON quoting reaches this path.

From source, the router's default search parser JSON-parses bare digits into a number, and its merge keeps parent search keys that the child validator omits; author and labels still omit invalid values, unlike q. PR #33522 touched this validator but kept the conditional omission for author and returned an empty object for invalid labels, so its diff would not have fixed these inputs; it was closed without merging.

Evidence
  • Expected source: issue #18640 and PR #18666's accepted contract that a malformed /pull-requests URL must not crash the page, and the typed author and labels fields in apps/web/src/routes/_chat.pull-requests.tsx lines 62-77 consumed by apps/web/src/components/pullRequest/usePullRequestListView.ts lines 204-215.
  • Failure source: the browser reproduction output under Logs or stack traces, through apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts lines 63-68 into packages/contracts/src/pullRequest.ts lines 953-959 for author, and search.labels.map in the query-filter hook for labels.
  • Evidence provenance: observed
  • Local verification: reproduced
  • Reproduction completeness: complete

The run is a browser integration test with a schema-checked transport fixture, not a live remote-host session; it passed on the baseline and again from a clean checkout of the baseline for this report. On current main the validator, query-filter hook, row filter, page and author resolver are unchanged by source comparison, but rendered behavior there was not rerun.

Restoration check

With the reproduction's inputs, bare /pull-requests?author=4089 (with a loaded list) and bare /pull-requests?labels=4089 (fresh mount and after navigation) render the Pull Requests page without the root error screen, either filtering by the text 4089 or dropping the filter. Controls: ?author=ana still filters to the ana row, picking 4089 in Filters → Author still writes a quoted string and shows the 4089 row, and numeric q stays handled as in #18666. The script asserts the current failing results, so its root-error-boundary assertions need flipping for this check.

Impact

Minor bug or occasional failure

Version or commit

0.1.9 at d9805b2ca667be7caec8c9035950564ab96704c3, rerun from a clean checkout; on main at 6807c3fb865d3f9fcc0679b2e283cfd6064a4ea7 the validator, hook, row filter and page are byte-identical and the author resolver is unchanged (source comparison only; not rerun on main)

Environment

Darwin arm64, Node v26.12.1, repository-pinned Vite+/Vitest 5.0.5, Playwright headless Chromium (Google Chrome for Testing 156.0.8078.4); browser integration test with an in-process transport, no T3 server, live source-control host or provider

Logs or stack traces

An excerpt of the script output from the clean-checkout rerun: its result lines, the first React error for each crash, and the test summary (stack traces, repeated errors and local paths omitted):

CASE cold-author=4089 real-page=healthy list=request-validation-error
CASE control author=ana real-page=healthy
CASE picker url=/pull-requests?involvement=all&state=open&author=%224089%22 author-type=string real-page=healthy
CASE picker-reparse author-type=string
[TypeError: author.trim is not a function]
The above error occurred in the <PullRequestsPage> component.
CASE author=4089 real-page=root-error-boundary error=author.trim is not a function
CASE author-recovery real-page=healthy
[TypeError: search.labels.map is not a function]
The above error occurred in the <PullRequestsPage> component.
CASE labels=4089 real-page=root-error-boundary error=search.labels.map is not a function
CASE labels-recovery real-page=healthy
CASE picker-reload real-page=healthy author-type=string
CASE cold-labels=4089 real-page=root-error-boundary error=search.labels.map is not a function
 Test Files  1 passed (1)
      Tests  3 passed (3)
DEFECT PRESENT; textual control, numeric picker, quoted reload, and navigation recovery passed.
Screenshots, recordings, or supporting files

No response

Workaround

Remove the malformed filter from the URL or open the unfiltered Pull Requests page. To filter by an all-digit login, pick it in Filters → Author, or use the quoted form /pull-requests?author=%224089%22. The reproduction verifies navigation recovery, the picker selection and a fresh mount of the quoted URL.

Dominant language
TypeScript
Stars
24.8k
Forks
6.4k
Avg merge
4h 16m
Merged PRs (30d)
352

Getting set up

Open in Codespaces

Starts the project's dev container in your browser, under your own GitHub account.

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from pingdotgg/t3code

All issues in pingdotgg/t3code

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.