Skip to content

[Bug]: Project content search accepts overlong queries then shows a generic schema error #34989

Closed

Description

@coygeek

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 the commit under Version or commit, with lockfile dependencies and the repo's Chromium browser-test binary installed. Save the script below outside the checkout as repro.sh and run sh /path/to/repro.sh "$PWD" from the checkout root. The script sets a private HOME, so it defaults PLAYWRIGHT_BROWSERS_PATH to the macOS Playwright cache under the original home directory; set that variable to the existing browser installation on other platforms.

  1. The script temporarily installs one browser test, apps/web/src/components/search/zzverify-long-query.browser.test.tsx, and runs vp test run --project browser src/components/search/zzverify-long-query.browser.test.tsx --silent=false --reporter=verbose from apps/web.
  2. The test mounts the production ProjectContentSearchDialog, the same component the command palette renders in content mode. It keeps the real dialog, the useProjectContentSearch debounce hook, the environment query family, the environment request boundary and the schema-aware RpcClient.make(WsRpcGroup) with its JSON codec. Test fixtures supply only the active project, an authorized connected environment and an in-memory transport that answers a valid request with one match in fixture.txt. No search result, search error or content-search hook is mocked.
  3. The test fills the search input with 256 ASCII a characters, then 257, then 256 again, and asserts the current behavior at each step, so exit 0 means both the defect and the working control were observed. It removes its probe test and fixture directory on exit. No live server, dev server, desktop shell or mobile client is involved; a live server is not needed because the failing query is rejected before the transport's send is called.
Reproduction script: repro.sh
#!/bin/sh
# Run from a dependency-installed t3code checkout at the baseline (or pass its root).
# Uses the real browser dialog/hook/query/RPC encoder; the connected environment and transport are test fixtures.
set -eu
checkout=${1:-$(pwd)}
checkout=$(cd "$checkout" && pwd)
original_home=$HOME
fixture=$(mktemp -d "${TMPDIR:-/tmp}/content-search-verification.XXXXXX")
probe="$checkout/apps/web/src/components/search/zzverify-long-query.browser.test.tsx"
if [ -e "$probe" ]; then echo 'Refusing to overwrite existing probe' >&2; exit 2; fi
cleanup() { rm -f "$probe"; rm -rf "$fixture"; }
trap cleanup EXIT INT TERM
mkdir -p "$fixture/fake-home" "$fixture/tmp" "$fixture/t3home"
export PLAYWRIGHT_BROWSERS_PATH=${PLAYWRIGHT_BROWSERS_PATH:-$original_home/Library/Caches/ms-playwright}
export HOME="$fixture/fake-home" TMPDIR="$fixture/tmp" T3CODE_HOME="$fixture/t3home"
export PATH="$checkout/node_modules/.bin:$PATH"
cat > "$fixture/dialog.browser.test.tsx" <<'BROWSER_TEST'
import { EnvironmentId, ThreadId, WS_METHODS, WsRpcGroup } from "@t3tools/contracts";
import { RegistryContext } from "@effect/atom-react";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as SubscriptionRef from "effect/SubscriptionRef";
import { Atom, AtomRegistry } from "effect/reactivity";
import { RpcClient, RpcSerialization } from "effect/rpc";
import { expect, it, vi } from "vite-plus/test";
import { page } from "vite-plus/test/browser";
import * as EnvironmentRegistry from "../../../../../packages/client-runtime/src/connection/registry";
import * as EnvironmentSupervisor from "../../../../../packages/client-runtime/src/connection/supervisor";
import { AVAILABLE_CONNECTION_STATE, PrimaryConnectionTarget } from "@t3tools/client-runtime/connection";
import { createEnvironmentRpcQueryAtomFamily } from "@t3tools/client-runtime/state/runtime";
import { render } from "../../../test/render";
import { ProjectContentSearchDialog } from "./ProjectContentSearchDialog";

const observed = vi.hoisted(() => ({ sent: [] as number[], requested: [] as number[] }));
vi.mock("~/hooks/useActiveProjectTarget", () => ({
  useActiveProjectTarget: () => ({ environmentId: EnvironmentId.make("probe"), cwd: "/fixture", projectName: "fixture", threadRef: { environmentId: EnvironmentId.make("probe"), threadId: ThreadId.make("thread") } }),
}));
vi.mock("~/hooks/useTheme", () => ({ useRenderedAppearance: () => "light", useTheme: () => ({ theme: "t3-chat", resolvedTheme: "light", appearanceMode: "light" }) }));
vi.mock("~/state/filesystem", () => ({ useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }) }));
vi.mock("~/state/session", async (original) => ({ ...(await original<typeof import("~/state/session")>()), readEnvironmentScope: () => true }));
vi.mock("~/state/projects", async () => {
  const target = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("probe"), label: "Probe", httpBaseUrl: "http://fixture.invalid", wsBaseUrl: "ws://fixture.invalid" });
  const layer = Layer.effect(EnvironmentRegistry.EnvironmentRegistry, Effect.gen(function* () {
    const serialization = yield* RpcSerialization.RpcSerialization;
    const protocol = yield* RpcClient.Protocol.make(Effect.fnUntraced(function* (writeResponse) {
      return {
        supportsAck: false, supportsTransferables: false, codecFor: serialization.codecFor,
        send: (clientId, request) => {
          if (request._tag !== "Request") return Effect.void;
          const payload = request.payload as { query: string };
          observed.sent.push(payload.query.length);
          return writeResponse(clientId, { _tag: "Exit", requestId: request.id, exit: { _tag: "Success", value: { matches: [{ path: "fixture.txt", lineNumber: 1, lineContent: "a".repeat(257), matchRanges: [{ start: 0, end: payload.query.length }] }], truncated: false } } });
        },
      };
    }));
    const client = yield* RpcClient.make(WsRpcGroup).pipe(Effect.provideService(RpcClient.Protocol, protocol));
    const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
      target, state: yield* SubscriptionRef.make({ ...AVAILABLE_CONNECTION_STATE, desired: true, network: "online", phase: "connected", attempt: 1, generation: 1 }),
      session: yield* SubscriptionRef.make(Option.some({ client })),
      prepared: yield* SubscriptionRef.make(Option.none()), connect: Effect.void, disconnect: Effect.void, retryNow: Effect.void,
    } as any);
    return EnvironmentRegistry.EnvironmentRegistry.of({
      run: (_id, effect) => Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
      followStream: (_id, stream) => Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
      stateChanges: () => SubscriptionRef.changes(supervisor.state),
    } as any);
  })).pipe(Layer.provide(RpcSerialization.layerJson));
  const family = createEnvironmentRpcQueryAtomFamily(Atom.runtime(layer), { label: "environment-data:projects:search-contents", tag: WS_METHODS.projectsSearchContents, staleTimeMs: 5000, idleTtlMs: 60000 });
  return { projectEnvironment: {}, projectContentSearch: (target) => { observed.requested.push(target.input.query.length); return family(target); } };
});

it("observes 256 and 257 character input through the real dialog, hook, query family, and schema-aware RPC client", async () => {
  observed.sent.length = 0; observed.requested.length = 0;
  console.log("BROWSER=" + navigator.userAgent);
  const registry = AtomRegistry.make();
  await using screen = await render(<RegistryContext.Provider value={registry}><ProjectContentSearchDialog onOpenChange={() => {}} /></RegistryContext.Provider>);
  const input = page.getByRole("combobox");
  expect((input.element() as HTMLInputElement).maxLength).toBe(-1);
  await input.fill("a".repeat(256));
  await expect.element(page.getByText("1 results in 1 files")).toBeVisible();
  expect(observed.sent).toEqual([256]);
  console.log("CONTROL_256=" + JSON.stringify({ inputLength: (input.element() as HTMLInputElement).value.length, maxLength: (input.element() as HTMLInputElement).maxLength, text: screen.container.textContent, sent: [...observed.sent] }));
  await input.fill("a".repeat(257));
  await expect.element(page.getByText("Schema validation failed")).toBeVisible();
  await expect.element(page.getByText("Searching…")).not.toBeInTheDocument();
  expect((input.element() as HTMLInputElement).value).toHaveLength(257);
  expect(observed.requested).toContain(257);
  expect(observed.sent).toEqual([256]);
  console.log("FAILURE_257=" + JSON.stringify({ inputLength: (input.element() as HTMLInputElement).value.length, maxLength: (input.element() as HTMLInputElement).maxLength, text: screen.container.textContent, sent: [...observed.sent], searching: screen.container.textContent?.includes("Searching…"), alerts: document.querySelectorAll('[role="alert"]').length }));
  await input.fill("a".repeat(256));
  await expect.element(page.getByText("1 results in 1 files")).toBeVisible();
  console.log("RECOVERY_256=1 results in 1 files");
});
BROWSER_TEST
cp "$fixture/dialog.browser.test.tsx" "$probe"
cd "$checkout/apps/web"
if vp test run --project browser src/components/search/zzverify-long-query.browser.test.tsx --silent=false --reporter=verbose > "$fixture/result.out" 2>&1; then
  cat "$fixture/result.out"
else
  cat "$fixture/result.out"
  exit 2
fi
for marker in 'CONTROL_256=' 'FAILURE_257=' 'RECOVERY_256='; do
  if ! grep -q "$marker" "$fixture/result.out"; then echo "Missing evidence: $marker" >&2; exit 2; fi
done
printf '%s\n' 'RESULT: defect present; 257 characters show inline Schema validation failed before transport, 256 characters render a result, and shortening recovers.'

Expected behavior

The content-search request schema bounds the query to 256 UTF-16 code units (query: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256)) in project.ts), and the projects.searchContents RPC uses that schema as its payload (rpc.ts). The dialog should keep what it accepts consistent with that limit, or tell the user that the query exceeds it. A 257-character query should not be sent to RPC encoding as though it were a supported request and then surface only a generic internal validation error. This report does not claim that content queries longer than 256 characters must be supported. Silently truncating the query, as #30714 did for path searches, would change the meaning of a literal or regular-expression content query, so it is not assumed here to be the right fix.

Actual behavior

Summary. The project content search dialog in the command palette lets the user type a query longer than the 256-character limit that the content-search request schema enforces. Once the query passes 256 characters, the dialog shows only Schema validation failed in its status row and no results, and it never says that the query is too long. A 256-character query against the same fixture returns its match, and shortening the query back to 256 characters restores results. This is a sibling of merged #30714 (#30714), which bounded path-search and file-suggestion requests on web and mobile; that PR did not cover project content search and made no statement about it, so this is a separate validation gap, not a regression of #30714. No mobile caller of project content search was found, so this report covers the web dialog only.

Observed in Chromium. With 256 characters, the status row shows 1 results in 1 files and the result list shows fixture.txt. With 257 characters, the input keeps all 257 characters, the status row shows Schema validation failed in the dialog's destructive text style, and the empty area says Type to search across your project. The Searching… spinner ends. The 257-character query reaches the production query family but never reaches the transport's send; the RPC client's encoder rejects it. The mounted document contains no role="alert" element, so the error is inline only, not a toast, a silent empty result or a stuck spinner. Typing back down to 256 characters shows 1 results in 1 files again.

The native input reports maxLength === -1; the dialog's input props set no length limit. The useProjectContentSearch hook sends the unchanged debounced query, and the displayed text comes from the dialog's error and empty-state branches. The command palette renders this dialog in content mode (CommandPalette.tsx).

On current main under Version or commit, ProjectContentSearchDialog.tsx, apps/web/src/state/queries.ts, packages/contracts/src/project.ts and packages/contracts/src/rpc.ts are unchanged from the tested commit, and CommandPalette.tsx changed only in unrelated source-control provider code. The browser test could not load on a newer main build because the tested commit's dependency copy lacks gt-react, which main now imports, so the main confirmation is from source only.

Related, not duplicates: merged #30714 bounded path-search and file-suggestion requests only. Open #23584 fixes the same kind of mismatch for the ACP registry search in Settings, which also shows Schema validation failed past its 120-character limit, and does not touch project content search. Open #29242 and #33977 address server responsiveness during content searches, open #33138 addresses recovery after a slow first index, and merged #6633 handles oversized thread-message searches under a separate 200-character limit.

Evidence

  • Expected source: ProjectSearchContentsInput.query with Schema.isMaxLength(256) at packages/contracts/src/project.ts lines 262-272, used as the projects.searchContents payload at packages/contracts/src/rpc.ts lines 1342-1346.
  • Failure source: the observed Chromium output under Logs or stack traces, the unbounded input props at apps/web/src/components/search/ProjectContentSearchDialog.tsx lines 197-226, and the unchanged query forwarding at apps/web/src/state/queries.ts lines 359-386.
  • Evidence provenance: observed
  • Local verification: reproduced
  • Reproduction completeness: complete

Restoration check

With the script's fixture, a 257-character query no longer ends in the generic Schema validation failed status: the dialog either keeps the input within the 256-character limit or tells the user that the query exceeds it, and it does not send an over-limit request. As a control, a 256-character query still returns 1 results in 1 files with fixture.txt, and a query with whitespace or regular-expression syntax keeps its exact text up to the limit. The script asserts the current failure, so its 257-character assertions need updating to the restored behavior for this check.

Impact

Minor bug or occasional failure

Version or commit

d9805b2 (the four content-search files named under Actual behavior are unchanged from the tested commit through main at b68aedf)

Environment

macOS arm64, Node v26.12.1, Vitest v5.0.5, HeadlessChrome/156.0.8078.4 through the repo's browser test project; no live server, dev server, desktop shell or mobile client

Logs or stack traces

The test's console output, condensed from its JSON lines:

CONTROL_256: inputLength=256, maxLength=-1, status="1 results in 1 files", transportQueries=[256]
FAILURE_257: inputLength=257, maxLength=-1, status="Schema validation failed", emptyArea="Type to search across your project.", searching=false, transportQueries=[256], alerts=0
RECOVERY_256: status="1 results in 1 files"
Test Files  1 passed (1)
Tests       1 passed (1)

Screenshots, recordings, or supporting files

No response

Workaround

Shorten the query to 256 characters or fewer; results return immediately, as the test's recovery step shows. The dialog does not state the limit, so the user has to guess it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions