Skip to content

fix(editor): stop keystrokes being overwritten on large schemas - #809

Open
nycjay wants to merge 1 commit into
libredb:mainfrom
nycjay:fix/808-editor-cursor-jump
Open

fix(editor): stop keystrokes being overwritten on large schemas#809
nycjay wants to merge 1 commit into
libredb:mainfrom
nycjay:fix/808-editor-cursor-jump

Conversation

@nycjay

@nycjay nycjay commented Sep 11, 2026

Copy link
Copy Markdown

The query editor rendered <Editor value={...}> as a controlled component and fed that prop from onContentChange on every keystroke. @monaco-editor/react's controlled-value effect runs executeEdits over the full model range whenever the prop changes and differs from the buffer; when a keystroke lands between the state update and the re-render, the prop is one keystroke stale, so the full-range edit rewrites the buffer and snaps the caret to line 1. A large schemaContext widens that window (extra parse/render work), which is why it showed up on large-schema connections and not on the small samples.

Make the editor uncontrolled: pass defaultValue instead of value, so the library's value effect early-returns, and route external changes (tab switch, Format, Clear, setValue) through the existing useEffect([value]) that guards self-echoes via lastSyncedValueRef.

Description

Fixes a bug where typing in the query editor on a large-schema connection dropped and reordered characters and reset the cursor to line 1. The editor is now uncontrolled on the keystroke path, so a keystroke no longer races a stale value prop.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Code refactoring
  • Performance improvement
  • Test addition or update

Related Issue

Closes #808

Changes Made

  • src/components/QueryEditor.tsx: pass defaultValue={value} instead of value={value} so @monaco-editor/react's controlled-value effect stays at its early return and no longer runs a full-range executeEdits on a stale prop.
  • Route external value changes (tab switch, Format, Clear, programmatic setValue) through the single useEffect([value]), which guards self-echoes via lastSyncedValueRef so it does not clobber the buffer during active typing.
  • tests/components/QueryEditor.test.tsx: added regression tests for the uncontrolled-editor contract and the stale-echo guard.

Testing

  • I have tested this locally
  • I have added/updated tests
  • All existing tests pass

Reproduced and confirmed the fix in a real browser against a 400-table SQLite schema. Before: typing SELECT id FROM entity_0001 WHERE owner_id = 42 ORDER BY created_at DESC produced SEETi RMett_01WEEonri 2ODRB rae_tDSC. After: the text lands verbatim, the caret only moves forward, and instrumenting the live editor showed zero setValue/executeEdits calls during typing (one applyEdits per character, which is the normal insertion path).

Test Environment

  • LibreDB Studio Version: 0.15.0
  • Browser: Chrome
  • OS: macOS 26.6.2
  • Node.js/Bun Version: Bun 1.4.0
  • Database Type: SQLite

Screenshots (if applicable)

N/A

Reproducing manually

The bug only shows up on a large schema, so you need one to see it. This script builds a 400-table SQLite database you can connect to. Save it as gen-large-sqlite.mjs and run bun gen-large-sqlite.mjs (it writes to data/large-schema-test.db by default; pass a count and path to override, e.g. bun gen-large-sqlite.mjs 800 /tmp/big.db).

#!/usr/bin/env bun
// Generate a SQLite database with a few hundred tables to reproduce the
// QueryEditor large-schema cursor-jump bug. Uses bun:sqlite.
import { Database } from "bun:sqlite";
import * as fs from "node:fs";
import * as path from "node:path";

const tableCount = Number(process.argv[2] || 400);
const outPath = path.resolve(process.argv[3] || "data/large-schema-test.db");
if (!Number.isFinite(tableCount) || tableCount < 1) {
  console.error(`bad table count "${process.argv[2]}"`);
  process.exit(1);
}

fs.mkdirSync(path.dirname(outPath), { recursive: true });
for (const suffix of ["", "-wal", "-shm"]) fs.rmSync(`${outPath}${suffix}`, { force: true });

const db = new Database(outPath, { create: true, readwrite: true });
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA synchronous = NORMAL");

const columnTypes = ["INTEGER", "TEXT", "REAL", "NUMERIC", "BLOB"];
db.exec("BEGIN");
try {
  for (let t = 0; t < tableCount; t++) {
    const table = `entity_${String(t).padStart(4, "0")}`;
    const cols = [
      "id INTEGER PRIMARY KEY",
      "created_at TEXT NOT NULL DEFAULT (datetime('now'))",
      "updated_at TEXT",
      "owner_id INTEGER",
    ];
    // Extra typed columns to bulk up the serialised schema; left nullable so
    // the seed insert below satisfies every constraint.
    for (let c = 0; c < 6; c++) {
      const type = columnTypes[c % columnTypes.length];
      cols.push(`field_${c}_${type.toLowerCase()} ${type}`);
    }
    db.exec(`CREATE TABLE "${table}" (${cols.join(", ")})`);
    db.exec(`CREATE INDEX "idx_${table}_owner" ON "${table}" (owner_id)`);
    db.exec(`CREATE INDEX "idx_${table}_updated" ON "${table}" (updated_at)`);
    const ins = db.prepare(`INSERT INTO "${table}" (owner_id, updated_at) VALUES (?, ?)`);
    ins.run(t, "2020-01-15T00:00:00Z");
    ins.run(t + 1, "2020-02-20T00:00:00Z");
  }
  db.exec("COMMIT");
} catch (e) {
  db.exec("ROLLBACK");
  throw e;
}

const tables = db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").get();
db.close();
console.log(`Wrote ${tables.n} tables to ${outPath}`);
console.log(`Add a manual SQLite connection in the UI with that database path.`);

Then add a SQLite connection in the UI pointing at the generated file, open a query tab, and type a sentence at a normal pace. On main the characters scramble and the cursor jumps to line 1, with this fix typing is much more smooth. I didn't include this reproduction script in the codebase itself as a test fixture, but can if maintainers would prefer.

Checklist

  • My code follows the project's code style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have updated the documentation accordingly
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • The required CI test job passes the 100% line-coverage gate (bun run test:coverage and bun run coverage:check)
  • If I changed src/lib/db/providers/, I updated the matching docs/providers/ documentation and tests/integration/db/ tests in the same PR (provider triad)
  • Any dependent changes have been merged and published

Additional Notes

Documentation checkbox is left unchecked because this is an editor-behavior fix with no user-facing docs to update. The provider-triad checkbox does not apply: this PR does not touch src/lib/db/providers/.

On the coverage gate: I could not run the full merged gate locally (tests/run-core.sh uses mapfile, which needs bash 4+, and this machine is on bash 3.2; Helm is not installed so the chart checks cannot run either). I ran the component suite directly (108 pass) and measured the merged component lcov for the changed file at 100% (QueryEditor.tsx 427/427 lines). The CI job is the authoritative gate for the full merged report.

The query editor rendered <Editor value={...}> as a controlled component and
fed that prop from onContentChange on every keystroke. @monaco-editor/react's
controlled-value effect runs executeEdits over the full model range whenever
the prop changes and differs from the buffer; when a keystroke lands between
the state update and the re-render, the prop is one keystroke stale, so the
full-range edit rewrites the buffer and snaps the caret to line 1. A large
schemaContext widens that window (extra parse/render work), which is why it
showed up on large-schema connections and not on the small samples.

Make the editor uncontrolled: pass defaultValue instead of value, so the
library's value effect early-returns, and route external changes (tab switch,
Format, Clear, setValue) through the existing useEffect([value]) that guards
self-echoes via lastSyncedValueRef.
@cevheri cevheri added bug Something isn't working security Supply-chain, auth, or hardening work core-capabilities labels Sep 11, 2026
@codecov

codecov Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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

Labels

bug Something isn't working core-capabilities security Supply-chain, auth, or hardening work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Typing scrambles and the cursor jumps to line 1 in the query editor on large schemas

2 participants