Skip to content

Commit 2f2999c

Browse files
authored
perf(incremental-tokenize): checkpoint every 32 lines instead of every line (#481)
parseIncremental stored a full tokenizer snapshot after each line, so a large editor document paid O(lines × state) memory and snapshot time on first paint. Record checkpoints on a fixed interval (and always at the document end), matching the density approach tokenized-document already uses for windowed rendering. reparseIncremental keeps the same interval when recording the dirty region so density does not grow back to one per line after edits.
1 parent 5eeeb16 commit 2f2999c

3 files changed

Lines changed: 116 additions & 13 deletions

File tree

src/incremental-tokenize.js

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,16 @@ function stateConverges(a, b) {
9494
}
9595

9696
/**
97-
* Full parse with a line checkpoint after every line. First paint or language
98-
* change.
97+
* Default gap between line checkpoints. Matches the spirit of
98+
* `tokenized-document`'s interval: denser than its windowed default (100)
99+
* so mid-document edits still resume nearby, sparse enough that a 10k-line
100+
* file stores O(hundreds) of snapshots rather than O(lines).
101+
*/
102+
export const CHECKPOINT_INTERVAL = 32;
103+
104+
/**
105+
* Full parse with a line checkpoint every `CHECKPOINT_INTERVAL` lines (and
106+
* always at the document end). First paint or language change.
99107
* @param {Registry} registry
100108
* @param {string} language
101109
* @param {string} code
@@ -104,8 +112,18 @@ function stateConverges(a, b) {
104112
export function parseIncremental(registry, language, code) {
105113
const session = registry.createSession(language);
106114
const checkpoints = [session.snapshot()];
115+
let linesSinceCheckpoint = 0;
107116
for (const line of splitKeepEnds(code)) {
108117
session.append(line);
118+
linesSinceCheckpoint++;
119+
if (linesSinceCheckpoint >= CHECKPOINT_INTERVAL) {
120+
checkpoints.push(session.snapshot());
121+
linesSinceCheckpoint = 0;
122+
}
123+
}
124+
// Always retain an end checkpoint so resume can land on the final state
125+
// even when the last interval is incomplete.
126+
if (linesSinceCheckpoint > 0 || checkpoints.length === 1) {
109127
checkpoints.push(session.snapshot());
110128
}
111129
const { events } = session.finish();
@@ -173,16 +191,19 @@ export function reparseIncremental(registry, language, previous, code) {
173191
const newLines = splitKeepEnds(code.slice(resumeCheckpoint.pos));
174192
let convergedAtOldIndex = -1;
175193
let oldIndex = resumeIndex;
194+
let linesSinceCheckpoint = 0;
176195

177-
for (const line of newLines) {
178-
session.append(line);
196+
for (let li = 0; li < newLines.length; li++) {
197+
session.append(/** @type {string} */ (newLines[li]));
179198
const snap = session.snapshot();
180-
// snap.eventCount is session-local; shift to index the combined array.
181-
checkpoints.push({
182-
...snap,
183-
eventCount: snap.eventCount + prefixEvents.length,
184-
});
185-
199+
linesSinceCheckpoint++;
200+
// Check every line for convergence against previous checkpoints.
201+
// Store every line for a window right after the edit (typing tends to
202+
// stay near the cursor, so a follow-up edit here resumes in O(1)
203+
// instead of walking to the next interval boundary), then fall back to
204+
// the sparse interval so density doesn't stay O(lines) further out.
205+
let shouldStore =
206+
li < CHECKPOINT_INTERVAL || linesSinceCheckpoint >= CHECKPOINT_INTERVAL;
186207
if (snap.pos >= newSuffixStart) {
187208
const targetOldPos = snap.pos - posOffset;
188209
while (
@@ -201,9 +222,19 @@ export function reparseIncremental(registry, language, previous, code) {
201222
stateConverges(snap, oldCheckpoint)
202223
) {
203224
convergedAtOldIndex = oldIndex;
204-
break;
225+
shouldStore = true;
205226
}
206227
}
228+
if (!shouldStore && li === newLines.length - 1) shouldStore = true;
229+
if (shouldStore) {
230+
// snap.eventCount is session-local; shift to index the combined array.
231+
checkpoints.push({
232+
...snap,
233+
eventCount: snap.eventCount + prefixEvents.length,
234+
});
235+
linesSinceCheckpoint = 0;
236+
}
237+
if (convergedAtOldIndex >= 0) break;
207238
}
208239

209240
if (convergedAtOldIndex >= 0) {

tests/e2e/e2e.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1538,12 +1538,19 @@ test("Typewriter - pausing then resuming continues without restarting", async ({
15381538
mount,
15391539
page,
15401540
}) => {
1541+
// Under CI parallel load Firefox's setInterval can run well slower than
1542+
// `speed`, so a fixed 5s post-resume wait is enough to flake. Give the
1543+
// remaining ticks room, and poll for progress instead of sleeping.
1544+
test.setTimeout(20_000);
1545+
15411546
await mount(Typewriter, { props: { speed: 15 } });
15421547

15431548
const tw = page.getByTestId("tw");
15441549
const revealed = tw.locator(".typewriter-unit:not(.typewriter-hidden)");
1550+
const done = page.getByTestId("done");
15451551

1546-
await page.waitForTimeout(120);
1552+
await expect.poll(async () => revealed.count()).toBeGreaterThan(0);
1553+
await expect(done).toHaveText("0");
15471554
await page.getByTestId("toggle-play").click(); // pause
15481555

15491556
const pausedCount = await revealed.count();
@@ -1555,7 +1562,8 @@ test("Typewriter - pausing then resuming continues without restarting", async ({
15551562
expect(await revealed.count()).toBe(pausedCount);
15561563

15571564
await page.getByTestId("toggle-play").click(); // resume
1558-
await expect(page.getByTestId("done")).toHaveText("1");
1565+
await expect.poll(async () => revealed.count()).toBeGreaterThan(pausedCount);
1566+
await expect(done).toHaveText("1", { timeout: 15_000 });
15591567

15601568
// Same DOM node from before the pause: resume picked up where it left
15611569
// off instead of re-tokenizing/rebuilding.

tests/incremental-tokenize.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
toRanges,
66
} from "../src/engine.js";
77
import {
8+
CHECKPOINT_INTERVAL,
89
parseIncremental,
910
reparseIncremental,
1011
} from "../src/incremental-tokenize.js";
@@ -254,3 +255,66 @@ describe("incremental re-tokenization survives a realistic mixed editing session
254255
]);
255256
});
256257
});
258+
259+
describe("parseIncremental checkpoint density", () => {
260+
/** Build N one-statement lines of javascript. */
261+
function manyLines(n: number): string {
262+
let code = "";
263+
for (let i = 0; i < n; i++) code += `const v${i} = ${i};\n`;
264+
return code;
265+
}
266+
267+
it("stores O(lines / interval) checkpoints, not one per line", () => {
268+
const lineCount = 320;
269+
const code = manyLines(lineCount);
270+
const state = parseIncremental(registry, "javascript", code);
271+
// Start checkpoint + one per interval + final (if not already on boundary).
272+
// With interval 32: 320/32 = 10 interior boundaries → at most ~12 checkpoints,
273+
// never one per line (321).
274+
expect(state.checkpoints.length).toBeLessThan(lineCount / 4);
275+
expect(state.checkpoints.length).toBeGreaterThan(2);
276+
// Still ends at the document end so resume can reach the tail.
277+
const last = state.checkpoints[state.checkpoints.length - 1];
278+
expect(last?.pos).toBe(code.length);
279+
});
280+
281+
it("reparse after a mid-document edit still matches a full re-parse", () => {
282+
const code = manyLines(100);
283+
const edited = code.replace("const v50 = 50;", "const v50 = 500;");
284+
assertEditSequenceMatchesOneShot("javascript", [code, edited]);
285+
});
286+
287+
it("follow-up edit near a prior edit converges in O(1) appended lines", () => {
288+
const code = manyLines(320);
289+
let state = parseIncremental(registry, "javascript", code);
290+
291+
const edit1 = code.replace("const v50 = 50;", "const v50 = 500;");
292+
state = reparseIncremental(registry, "javascript", state, edit1);
293+
expect(render(state.events)).toEqual(oneShotRender(edit1, "javascript"));
294+
295+
// First mid-doc edit densifies a pocket; the second edit on the same
296+
// line must resume from that pocket and converge without walking a
297+
// full CHECKPOINT_INTERVAL of sparse boundaries.
298+
let appendCount = 0;
299+
const createSession = registry.createSession.bind(registry);
300+
registry.createSession = ((...args: Parameters<typeof createSession>) => {
301+
const session = createSession(...args);
302+
const append = session.append.bind(session);
303+
session.append = (chunk: string) => {
304+
appendCount++;
305+
return append(chunk);
306+
};
307+
return session;
308+
}) as typeof registry.createSession;
309+
310+
try {
311+
const edit2 = edit1.replace("const v50 = 500;", "const v50 = 5000;");
312+
state = reparseIncremental(registry, "javascript", state, edit2);
313+
expect(render(state.events)).toEqual(oneShotRender(edit2, "javascript"));
314+
expect(appendCount).toBeLessThan(CHECKPOINT_INTERVAL);
315+
expect(appendCount).toBeLessThan(4);
316+
} finally {
317+
registry.createSession = createSession;
318+
}
319+
});
320+
});

0 commit comments

Comments
 (0)