Improve editor performance
I am trying to implement an auto-save feature for carta-md editor. In my implementation there's a noticeable lag in large documents because of how reactivity work.. I'm not sure if it's something I'm doing or something on carta-md side. Any help would be appreciated. The lag occurs only when editing long documents. For short ones it works fine. Really struggling to optimize this code and I'm still not able to fix it after several attempts. Even backspacing stuff lags behind.. typing also lags..
Below is my +page.svelte
<script lang="ts">
import { Carta, MarkdownEditor, Markdown } from "carta-md";
import "carta-md/default.css";
import { getCartaInstance } from "./getCarta";
import "../app.css";
import "./tw.css";
// Removed invoke - now handled by store
import { onMount, onDestroy } from "svelte";
import HoverToolbar from "./HoverToolbar.svelte";
import FilePicker from "./FilePicker.svelte";
import JournalPicker from "./JournalPicker.svelte";
// Import the store and the READ-ONLY derived stores
import {
noteStore,
isLoading,
isSaving,
isDirty,
errorMessage,
value as noteValue,
} from "$lib/noteStore";
import { listen, type Event as TauriEvent } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window"; // Use correct v2 import
// Note: Renamed imported 'value' to 'noteValue' to avoid conflict if needed,
// or just use $noteStore.value directly in template
const carta = getCartaInstance("light");
// --- Component Local State ---
let viewMode = $state<"edit" | "render">("edit");
let filePickerInstance: FilePicker | null = $state(null); // Keep instance ref
let journalPickerInstance: JournalPicker | null = $state(null);
// --- Input Handler (Calls Store Update Method) ---
function handleEditorInput(event: Event) {
// Log to verify if this handler is actually being called
// console.log("--- handleEditorInput Fired ---", event);
const target = event.target as HTMLTextAreaElement | HTMLInputElement;
if (!target || typeof target.value === "undefined") {
console.warn(
"handleEditorInput: event target not found or has no value.",
event.target,
);
return;
}
// Call the store's update method, which handles debounce and save
noteStore.updateValue(target.value);
}
// --- Event Handlers (Call Store Methods) ---
function handleKeyDown(event: KeyboardEvent) {
const isModifier = event.metaKey || event.ctrlKey;
if (isModifier) {
if (event.metaKey && (event.key === "e" || event.key === "E")) {
event.preventDefault();
viewMode = viewMode === "edit" ? "render" : "edit";
} else if (event.metaKey && (event.key === "n" || event.key === "N")) {
event.preventDefault();
noteStore.createNewNote();
} // Call store method
else if (event.key === "o" || event.key === "O") {
event.preventDefault();
filePickerInstance?.openDialog();
} else if (event.key === "s" || event.key === "S") {
event.preventDefault();
noteStore.manualSave();
} // Call store method
else if (event.key === "t" || event.key === "T") {
event.preventDefault();
noteStore.loadJournal();
} else if (event.key === "j" || event.key === "J") {
// <-- Add shortcut for JournalPicker
event.preventDefault();
console.log("Cmd+J detected, opening JournalPicker...");
journalPickerInstance?.openDialog();
}
} /* else if (event.key === "Escape" && viewMode === "render") {
viewMode = "edit";
} */
}
function handleNoteSelected(event: CustomEvent<string>) {
noteStore.loadNoteById(event.detail); // Call store method
}
// --- Lifecycle ---
let unlistenJournal: (() => void) | null = null;
let unlistenNewNote: (() => void) | null = null;
onMount(() => {
noteStore.loadJournal(); // Initial load via store
window.addEventListener("keydown", handleKeyDown);
// --- ADD BACK Backend Event Listeners ---
listen<null>("global-shortcut-journal", async (event) => {
console.log("Received global-shortcut-journal event", event);
const currentWindow = getCurrentWindow();
await currentWindow.setFocus();
await noteStore.loadJournal(); // Call store method
}).then((unlistener) => {
unlistenJournal = unlistener;
});
listen<null>("global-shortcut-new", async (event) => {
console.log("Received global-shortcut-new event", event);
const currentWindow = getCurrentWindow();
await currentWindow.setFocus();
await noteStore.createNewNote(); // Call store method
}).then((unlistener) => {
unlistenNewNote = unlistener;
});
// --- End Listeners ---
});
onDestroy(() => {
window.removeEventListener("keydown", handleKeyDown);
if (unlistenJournal) unlistenJournal();
if (unlistenNewNote) unlistenNewNote();
// Optional: Add noteStore.destroy() if you implement timeout clearing there
});
// --- NO $effect for saving needed here! ---
</script>
{#if $isLoading}
<p>Loading...</p>
{:else if $errorMessage}
<p class="text-red-600 ...">Error: {$errorMessage}</p>
{/if}
<FilePicker bind:this={filePickerInstance} on:selectnote={handleNoteSelected} />
<JournalPicker bind:this={journalPickerInstance} />
<div
class="relative max-w-[1000px] mx-auto p-16 h-screen flex flex-col overflow-hidden"
>
<HoverToolbar on:newnote={noteStore.createNewNote} />
{#if $isSaving}
<div
title="Saving..."
class="absolute top-2 right-10 ... animate-spin"
></div>
{:else if $isDirty}
<div
title="Unsaved changes"
class="absolute top-2 right-10 w-3 h-3 bg-fuchsia-400 rounded-full z-10 animate-pulse"
></div>
{/if}
<div
class="max-w-none h-full w-[800px] mx-auto prose flex-grow font-[Noto_Sans] overflow-auto content-scroll-area"
>
{#if !$isLoading}
{#if viewMode === "edit"}
<MarkdownEditor
{carta}
value={$noteValue}
disableToolbar={true}
theme="tw"
scroll="async"
mode="tabs"
textarea={{
// Attempt to pass the oninput handler
oninput: handleEditorInput,
}}
/>
{:else}
<div class="carta-prose h-full overflow-y-auto content-scroll-area">
<Markdown {carta} value={$noteValue} />
</div>
{/if}
{/if}
</div>
</div>
<style>
/* Keep existing styles */
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=IBM+Plex+Serif:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Noto+Sans+Mono:[email protected]&family=Noto+Sans:ital,wght@0,100..900;1,100..900&family=Noto+Serif:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap");
:global(.carta-font-code) {
font-family: "IBM Plex Mono", monospace;
line-height: 1.5rem;
letter-spacing: normal;
}
:global(.carta-input *) {
margin: 0;
padding: 0;
}
:global(.carta-editor),
:global(.carta-editor > textarea) {
height: 100% !important; /* Use !important cautiously */
min-height: 100%;
/* Add box-sizing if needed */
box-sizing: border-box;
}
.content-scroll-area {
scrollbar-width: none;
-ms-overflow-style: none;
overflow-y: auto;
}
.content-scroll-area::-webkit-scrollbar {
width: 0 !important;
display: none;
}
.content-scroll-area::-webkit-scrollbar-track {
background: transparent;
}
.content-scroll-area::-webkit-scrollbar-thumb {
background: transparent;
}
</style>
and below is noteStore.ts:
// src/lib/noteStore.ts
import { writable, derived, get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
// --- Helper functions (keep as is) ---
function getTodayDateString(): string {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, "0");
const day = String(today.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
// --- Store State Definition ---
type NoteState = {
value: string;
originalContent: string; // Keep for potential revert/cancellation logic in _canLoad
isLoading: boolean;
isSaving: boolean;
isDirty: boolean; // Explicit boolean flag
currentNoteId: string | null;
currentNoteType: "journal" | "note";
errorMessage: string | null;
};
const initialState: NoteState = {
value: "",
originalContent: "",
isLoading: true,
isSaving: false,
isDirty: false,
currentNoteId: null,
currentNoteType: "journal",
errorMessage: null,
};
// --- Create Writable Store ---
const store = writable<NoteState>(initialState);
// --- Derived Stores (Read-only access for UI) ---
export const isLoading = derived(store, ($store) => $store.isLoading);
export const isSaving = derived(store, ($store) => $store.isSaving);
export const isDirty = derived(store, ($store) => $store.isDirty); // Directly from state
export const currentNoteId = derived(store, ($store) => $store.currentNoteId);
export const currentNoteType = derived(
store,
($store) => $store.currentNoteType,
);
export const errorMessage = derived(store, ($store) => $store.errorMessage);
export const value = derived(store, ($store) => $store.value);
// --- Internal Store Logic ---
let saveTimeout: number | null = null;
const debounceTime = 1500; // ms
async function _saveCurrentNote() {
const currentState = get(store); // Get current state snapshot
// **Removed internal isDirty check**: We now rely on the debounce mechanism
// only calling this if isDirty was true when the timeout was set.
// We still check if we are already saving or have no ID.
if (currentState.isSaving || !currentState.currentNoteId) {
// console.log("Save skipped: Already saving or no ID");
saveTimeout = null; // Ensure timeout is cleared if skipped
return;
}
store.update((s) => ({ ...s, isSaving: true, errorMessage: null }));
try {
let savedContent = currentState.value; // Capture content being saved
if (currentState.currentNoteType === "journal") {
// Keep the debug log and format check from previous step
console.log(
`Store: Invoking save_journal with date: '${currentState.currentNoteId}' (Type: ${typeof currentState.currentNoteId})`,
);
if (!/^\d{4}-\d{2}-\d{2}$/.test(currentState.currentNoteId)) {
console.error(
"Store: Invalid date format detected before invoke:",
currentState.currentNoteId,
);
throw new Error(
`Invalid date format before saving journal: ${currentState.currentNoteId}`,
);
}
await invoke("save_journal", {
date: currentState.currentNoteId,
content: savedContent,
});
} else {
// Assuming type 'note'
await invoke("save_note", {
noteId: currentState.currentNoteId,
content: savedContent,
});
}
console.log("Store: Successfully saved");
// Update originalContent to the saved value and reset isDirty
// Only reset isDirty if the content hasn't changed *again* since save started
store.update((s) => {
// If the value hasn't changed while saving was in progress,
// then the current state is no longer dirty relative to the saved state.
const stillDirty = s.value !== savedContent;
return {
...s,
originalContent: savedContent, // Update baseline to what was saved
isDirty: stillDirty, // Reset flag only if no new changes occurred during save
errorMessage: null,
};
});
} catch (error) {
console.error("Store: Error saving:", error);
const errorString =
typeof error === "string"
? error
: error instanceof Error
? error.message
: "Unknown save error";
store.update((s) => ({
...s,
errorMessage: `Failed to save: ${errorString}`,
}));
} finally {
store.update((s) => ({ ...s, isSaving: false }));
saveTimeout = null; // Clear timeout variable once done (success or fail)
}
}
// --- Actions Exposed by the Store ---
// Action called by the input handler - Updates value, sets dirty, triggers debounce
function updateValueAndDebounceSave(newValue: string) {
// Update value and immediately set isDirty to true
store.update((s) => {
// Avoid update if value is identical (minor optimization)
if (s.value === newValue) {
return s;
}
return { ...s, value: newValue, isDirty: true }; // Set dirty unconditionally
});
// Always clear existing timeout and set a new one if not currently saving
if (saveTimeout) clearTimeout(saveTimeout);
const isCurrentlySaving = get(isSaving); // Check if a save is already in progress
if (!isCurrentlySaving) {
saveTimeout = setTimeout(() => {
_saveCurrentNote();
}, debounceTime);
} else {
// If currently saving, don't set a new timeout yet.
// The check within the save function's success handler
// will determine if isDirty should remain true, triggering a new save later if needed.
saveTimeout = null;
}
}
// Checks for unsaved changes before performing a loading action
function _canLoad(): boolean {
const currentState = get(store);
if (currentState.isSaving) {
console.warn("Store: Action aborted, currently saving.");
return false; // Don't proceed if saving
}
if (currentState.isDirty) {
// Check the explicit flag
const confirmed = confirm(
"Unsaved changes will be lost. Load new content anyway?",
);
if (!confirmed) {
return false; // User cancelled
}
// If confirmed, reset the dirty flag as we are discarding changes
store.update((s) => ({ ...s, isDirty: false }));
}
// Clear pending save if proceeding
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = null;
return true; // Ok to proceed
}
// --- Loading Actions (ensure they reset isDirty and update originalContent) ---
async function loadJournal() {
if (!_canLoad()) return;
store.update((s) => ({ ...s, isLoading: true, errorMessage: null }));
const todayDate = getTodayDateString();
try {
const content = await invoke<string>("get_todays_journal");
store.set({
// Use store.set to completely replace state
value: content,
originalContent: content, // Update baseline
isLoading: false,
isSaving: false,
isDirty: false, // Reset dirty flag
currentNoteId: todayDate,
currentNoteType: "journal",
errorMessage: null,
});
} catch (e) {
const errorString =
typeof e === "string"
? e
: e instanceof Error
? e.message
: "Unknown error";
store.update((s) => ({
...s,
isLoading: false,
errorMessage: `Failed to load journal: ${errorString}`,
}));
}
}
async function loadNoteById(noteId: string) {
if (!noteId || !_canLoad()) return;
store.update((s) => ({ ...s, isLoading: true, errorMessage: null }));
try {
const content = await invoke<string>("get_note_content", {
noteId: noteId,
});
store.set({
// Use store.set
value: content,
originalContent: content, // Update baseline
isLoading: false,
isSaving: false,
isDirty: false, // Reset dirty flag
currentNoteId: noteId,
currentNoteType: "note",
errorMessage: null,
});
} catch (e) {
const errorString =
typeof e === "string"
? e
: e instanceof Error
? e.message
: "Unknown error";
store.update((s) => ({
...s,
isLoading: false,
errorMessage: `Failed to load note ${noteId}: ${errorString}`,
}));
}
}
async function createNewNote() {
if (!_canLoad()) return;
store.update((s) => ({ ...s, isLoading: true, errorMessage: null }));
try {
const [newNoteId, initialContent] =
await invoke<[string, string]>("create_new_note");
store.set({
// Use store.set
value: initialContent,
originalContent: initialContent, // Update baseline
isLoading: false,
isSaving: false,
isDirty: false, // Reset dirty flag
currentNoteId: newNoteId,
currentNoteType: "note",
errorMessage: null,
});
} catch (e) {
const errorString =
typeof e === "string"
? e
: e instanceof Error
? e.message
: "Unknown error";
store.update((s) => ({
...s,
isLoading: false,
errorMessage: `Failed to create note: ${errorString}`,
}));
}
}
async function loadOrCreateJournal(dateString: string) {
if (!dateString || !_canLoad()) return;
store.update((s) => ({ ...s, isLoading: true, errorMessage: null }));
console.log(`Store: Loading or creating journal for date: ${dateString}`);
try {
const content = await invoke<string>("get_or_create_journal_for_date", {
date: dateString,
});
store.set({
// Use store.set
value: content,
originalContent: content, // Update baseline
isLoading: false,
isSaving: false,
isDirty: false, // Reset dirty flag
currentNoteId: dateString,
currentNoteType: "journal",
errorMessage: null,
});
console.log(`Store: Successfully loaded/created journal for ${dateString}`);
} catch (e) {
const errorString =
typeof e === "string"
? e
: e instanceof Error
? e.message
: "Unknown error";
console.error(
`Store: Error loading/creating journal for ${dateString}:`,
errorString,
);
store.update((s) => ({
...s,
isLoading: false,
errorMessage: `Failed to load/create journal for ${dateString}: ${errorString}`,
}));
}
}
// Manual save clears any pending auto-save and triggers immediately
async function manualSave() {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = null;
// Check if dirty before attempting manual save
if (get(isDirty)) {
await _saveCurrentNote();
} else {
console.log("Manual save skipped: No changes detected.");
}
}
// Export the store interface (remains the same structure)
export const noteStore = {
isLoading: { subscribe: isLoading.subscribe },
isSaving: { subscribe: isSaving.subscribe },
isDirty: { subscribe: isDirty.subscribe },
currentNoteId: { subscribe: currentNoteId.subscribe },
currentNoteType: { subscribe: currentNoteType.subscribe },
errorMessage: { subscribe: errorMessage.subscribe },
value: { subscribe: value.subscribe },
loadJournal,
loadNoteById,
createNewNote,
manualSave,
updateValue: updateValueAndDebounceSave,
loadOrCreateJournal,
};
Hello,
You provided a lot of code and it's hard to determine what exactly is causing the lag. It seems like you are already deboncing the save function, which was the first thing I thought of.
You should know that Carta has already a sort of saving function, which is used to allow user to ctrl-z back and forth. Here are its options and here is the implementation.
Also note that there is a dedicated option to provide listeners to the editor: you need to provide them when creating a Plugin class to be used in Carta.
I also suggest you look into your browser profiler to debug this kind of issues. It allows you to see exactly what is slowing down the page. I used the Firefox profiler myself for this kind of things.
as a quick change to debug, I removed auto save and implemented manually invoking save. So it's basically just the editor component running.. Even now I notice a lag.. Happens only in long documents.. short ones it's fast and performant.. It's very minute.. but typing fast makes it noticeable as the characters appear after a very small delay. This is easily seen in backspacing though.. in a short note, if you press and hold backspace it stops the moment you release it. but in long documents, it overshoots and keeps deleting a few more characters after you release the key as well..
Well, in that case, it's the editor fault. However, I'm not sure how performances could be improved. That's the current situtation:
Highlighting the input content takes a lot of time when there's much to highlight, and it's not feasible to do every time the user types a character. For this reason, I created a way to update the content while waiting for the user to stop typing, using a debounced callback, that "immediately" shows an updated content overlay, trying to guess what the new highlighting will be. I called these type of updates "speculative" and are implemented here: speculative.ts.
This improve performances a lot, but it still needs to compute the whole diff with new and old content for every input, as it is the only way I've found to be able to tell what changes (there might be some other more efficient way using some tree manipulation technique, but I was unsuccessful making those work). While faster than highlighting, it still takes some time and becomes slower the bigger the input is.
I'll leave this open if anyone has any suggestion on how to improve this. At the moment, I've got no idea.
I understand to an extent on a high level what the challenge. Unfortunately I'm not well versed in typescript enough to provide any implementation insights. But from a technical perspective, we cannot avoid the diff, but maybe it's worth a shot to do something with rendering? Something like running the highlighting only for the changed content and (maybe some before it) and merging it with what's already there ? Or probably something like rendering only the content that's right now visible ? This might not help with raw performance, but from a UX perspective things might be faster? I am not an expert in web dev so apologies if these sound dumb 😂
Eitherways thanks for the awesome library. It's been super easy to work with and extend.
But from a technical perspective, we cannot avoid the diff, but maybe it's worth a shot to do something with rendering? Something like running the highlighting only for the changed content and (maybe some before it) and merging it with what's already there ? Or probably something like rendering only the content that's right now visible ? This might not help with raw performance, but from a UX perspective things might be faster?
The issue is not related to rendering the content, but it's the fact that, for every character added/removed, it needs to compute the diff for all the text. It woud be great to be able to remove this diff step entirely, like by sort of propagating the input events to the overlay, and leaving the rest to the DOM. I've tried to do that but I've had no success, also due to the fact that there are some browser protections to prevent spoofing events.
I'll leave the issue open: maybe someone will come with an idea on how to improve this.
It looks like the editor is repeatedly calling highlight when it shouldn't need to - this is running the project locally, adding some console.log statements shows it going in a loop before even editing anything:
https://github.com/user-attachments/assets/f1db85c7-e63c-45e3-8fb1-3c695438fc35
I was actually looking at a different issue, where having HTML in the markdown initially renders it in the input editor rather than the text of the HTML itself, e.g. an embedded youtube video or img element.
I haven't dug into it much other than just come across it, but maybe the initial render of the input should be textContent until the highlighter is available and active. I'd also try to make everything use input events or $derived as much as possible rather than $effect, which may be a source of the looping - it should really be a one-direction thing right (?)
But that may be a source of some performance issues.
https://github.com/BearToCode/carta/issues/157#issuecomment-2852326924
This is 100% the issue!
I was having a ton of performance issues with a large document. The editor was basically unusable. After reading your comment, I manually set const USE_HIGHLIGHTER = false; in https://github.com/BearToCode/carta/blob/master/packages/carta-md/src/lib/internal/carta.ts and everything is fast now.
I added an $inspect(highlighted) which showed the value toggling between > vs > which may be why it gets in a loop. Removing any content with those characters from the sample.md markdown stopped the looping, but I don't see in the code where the highlight changing modifies the value to trigger the onValueChange effect, but I think it's around speculativeHighlightUpdate
https://github.com/user-attachments/assets/ca55cabf-1896-4e2b-a640-84cb34cd7964
https://github.com/user-attachments/assets/8c76a645-46fb-4856-b129-79701b1d6c91
I'm releasing a fix for the infinite loops you noticed(nice catch! 👍 ). I'm not really sure it is also the issue noticed by @colinrobinsonuib, but we'll see. Let me know!
I'm releasing a fix for the infinite loops you noticed(nice catch! 👍 ). I'm not really sure it is also the issue noticed by @colinrobinsonuib, but we'll see. Let me know!
That was it, smooth sailing for me now. But @rishikanthc was the one who first opened the ticket.
Update: The scrolling was completely fixed by https://github.com/BearToCode/carta/commit/0aa07c3e61fc8e45bbd3ea5607588bcbc90fbdd2 but typing has quite some lag.
https://github.com/user-attachments/assets/d590b17d-1060-481c-be74-f5c7f3da4921
Keypress display is from screenkey (not in the browser).
Setting const USE_HIGHLIGHTER = false fixes this, so the issue is still with the highlighter. Here is the document if anyone wants to test.
I modified speculativeHighlight to log some timestamps
function speculativeHighlight(value: string) {
console.log("Start speculativeHighlight", performance.now())
const timestamp = new Date().getTime();
if (!mounted) return { html: '', timestamp };
const currentOverlay = highlightElem.innerHTML;
if (highlightElem) {
try {
const html = speculativeHighlightUpdate(currentlyHighlightedValue, value, currentOverlay);
currentlyHighlightedValue = value;
console.log("Return speculativeHighlight", performance.now())
return { html, timestamp };
} catch (e) {
console.error(`Error executing speculative update: ${e}.`);
}
}
return {
html: highlightElem.innerHTML,
timestamp
};
}
I then pressed 3 keys in the editor simultaneously and it's taking 400ms to render those 3 characters. Here is the output:
Input.svelte:131 Start speculativeHighlight 8656.800000000047
Input.svelte:142 Return speculativeHighlight 8772.900000000023
Input.svelte:131 Start speculativeHighlight 8801.5
Input.svelte:142 Return speculativeHighlight 8926.600000000035
Input.svelte:131 Start speculativeHighlight 8959.700000000012
Input.svelte:142 Return speculativeHighlight 9071.900000000023
I'm going to see what improvements I can make.
Update: The scrolling was completely fixed by 0aa07c3 but typing has quite some lag.
Isn't that because it's debouncing the edit / highlighting not throttling updates ... i.e. it doesn't do anything until you pause, vs limiting how often it runs so it updates while you're typing.
I wonder if the speculative highlighting is really the right approach. I did a quick experiment changing the input to be visible, so typing always displays immediately, and the highlight layer is on top but inert (no pointer-events etc...) and it seemed faster to just let that run even with very long documents. There's probably some scenario I am not thinking about for why that isn't doable (?)
@colinrobinsonuib I tried the same thing with the document you provided and the issue is the same I mentioned above:
@BearToCode said: The issue is not related to rendering the content, but it's the fact that, for every character added/removed, it needs to compute the diff for all the text. It woud be great to be able to remove this diff step entirely, like by sort of propagating the input events to the overlay, and leaving the rest to the DOM. I've tried to do that but I've had no success, also due to the fact that there are some browser protections to prevent spoofing events.
However, using the Firefox Profiler I've been able to track down some performance bottlenecks. It is still not fluid, but you should see some improvements. More can probably be done with a little more of digging.
@CaptainCodeman said: Isn't that because it's debouncing the edit / highlighting not throttling updates ... i.e. it doesn't do anything until you pause, vs limiting how often it runs so it updates while you're typing.
I wonder if the speculative highlighting is really the right approach. I did a quick experiment changing the input to be visible, so typing always displays immediately, and the highlight layer is on top but inert (no pointer-events etc...) and it seemed faster to just let that run even with very long documents. There's probably some scenario I am not thinking about for why that isn't doable (?)
I think you got what I called speculative updates wrong. Speculative updates happen immediately, while the actual highlight updates are debounced. If you were to use only full updates (i.e. fully highlighting all the text for every change) it would be much slower.
I'm not sure of what you mean with leaving the input visible: changing the value would result in the input text changing immediately, but the overlay would not match, as it takes a while to update. Maybe I got it wrong though.
I'm not sure of what you mean with leaving the input visible: changing the value would result in the input text changing immediately, but the overlay would not match, as it takes a while to update. Maybe I got it wrong though.
I was thinking if you made the textarea input visible, anything you typed is shown immediately, just not highlighted until it runs (and put the highlight on top with pointer events disabled). But of course that only works for new appended text, and not edits which would be out-of-sync, and I guess the reason for the speculative code.