Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions browser-features/modules/actors/NRWebScraperChild.sys.mts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ export class NRWebScraperChild extends JSWindowActorChild {
return domOps.waitForElement(
message.data.selector,
message.data.timeout || 5000,
undefined,
message.data.state || "attached",
);
}
break;
Comment on lines 202 to 203
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "WebScraper:WaitForReady" case is added at line 202 in this PR, but the same case label already exists at line 141 (within the same switch (message.name) block). In JavaScript/TypeScript, switch cases are matched in order, so the case at line 202 will never be reached — it is dead code. The original handler at line 141 already handles this message correctly. This duplicate case should be removed.

Suggested change
}
break;

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -364,6 +366,16 @@ export class NRWebScraperChild extends JSWindowActorChild {
);
}
break;
case "WebScraper:DispatchTextInput": {
const text = (
message.data as (NRWebScraperMessageData & { text?: string }) | undefined
)?.text;
if (message.data?.selector && typeof text === "string") {
return domOps.dispatchTextInput(message.data.selector, text);
}
break;
}

}
return null;
}
Expand Down
8 changes: 7 additions & 1 deletion browser-features/modules/actors/webscraper/DOMOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,9 @@ export class DOMOperations {
selector: string,
timeout = 5000,
signal?: AbortSignal,
state?: "attached" | "visible" | "hidden" | "detached",
): Promise<boolean> {
return this.waitOps.waitForElement(selector, timeout, signal);
return this.waitOps.waitForElement(selector, timeout, signal, state);
}

waitForReady(timeout = 15000, signal?: AbortSignal): Promise<boolean> {
Expand Down Expand Up @@ -239,6 +240,11 @@ export class DOMOperations {
}
}


dispatchTextInput(selector: string, text: string): Promise<boolean> {
return this.writeOps.dispatchTextInput(selector, text);
}

destroy(): void {
this.highlightManager.destroy();
}
Expand Down
164 changes: 147 additions & 17 deletions browser-features/modules/actors/webscraper/DOMWaitOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,27 @@ import type { DOMOpsDeps } from "./DOMDeps.ts";
const { setTimeout: timerSetTimeout, clearTimeout: timerClearTimeout } =
ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs");

export type WaitForElementState =
| "attached"
| "visible"
| "hidden"
| "detached";

/**
* Wait helpers (element readiness/document readiness)
*/
export class DOMWaitOperations {
constructor(private deps: DOMOpsDeps) {}

/**
* Check if an error is a "dead object" error (occurs when document is navigated away)
*/
private isDeadObjectError(e: unknown): boolean {
if (!e || typeof e !== "object") return false;
const message = (e as { message?: unknown }).message;
return typeof message === "string" && message.includes("dead object");
}

private get contentWindow(): (Window & typeof globalThis) | null {
return this.deps.getContentWindow();
}
Expand All @@ -22,16 +37,68 @@ export class DOMWaitOperations {
return this.deps.getDocument();
}

/**
* Check if an element is visible (not hidden by CSS)
*/
private isVisible(element: Element | null): boolean {
if (!element) return false;

const win = this.contentWindow;
if (!win) return false;

try {
const style = win.getComputedStyle(element);
const rect = element.getBoundingClientRect();

return (
style.display !== "none" &&
style.visibility !== "hidden" &&
style.opacity !== "0" &&
rect.width > 0 &&
rect.height > 0
);
} catch {
return false;
}
}

/**
* Check if element matches the desired state
*/
private matchesState(
element: Element | null,
state: WaitForElementState,
): boolean {
switch (state) {
case "attached":
return !!element;
case "visible":
return this.isVisible(element);
case "hidden":
return !element || !this.isVisible(element);
case "detached":
return !element;
default:
return !!element;
}
}

async waitForElement(
selector: string,
timeout = 5000,
signal?: AbortSignal,
state: WaitForElementState = "attached",
): Promise<boolean> {
const doc = this.document;
if (!doc) return false;

if (signal?.aborted) return false;

// For detached state, we need to wait for element to be removed
if (state === "detached") {
return this.waitForDetached(selector, timeout, signal);
}

return await new Promise<boolean>((resolve) => {
let finished = false;

Expand Down Expand Up @@ -60,18 +127,88 @@ export class DOMWaitOperations {
const safeCheck = () => {
try {
const found = doc.querySelector(selector);
if (found) {
if (this.matchesState(found, state)) {
finish(true);
}
} catch (e) {
if (
e &&
typeof e === "object" &&
"message" in e &&
typeof (e as { message?: unknown }).message === "string" &&
((e as { message: string }).message?.includes("dead object") ??
false)
) {
if (this.isDeadObjectError(e)) {
finish(false);
return;
}
console.error("DOMWaitOperations: Error waiting for element:", e);
finish(false);
}
};

const win = this.contentWindow;
const MutationObs = win?.MutationObserver ?? globalThis.MutationObserver;
let observer: MutationObserver | null = null;

const timeoutId = Number(
timerSetTimeout(() => finish(false), Math.max(0, timeout)),
);

// Initial check before observing
safeCheck();
if (finished) return;

if (MutationObs) {
try {
observer = new MutationObs(() => safeCheck());
observer.observe(doc, { childList: true, subtree: true });
} catch {
// Fallback: final timeout will resolve
}
}
});
}

/**
* Wait for an element to be detached from the DOM
*/
private async waitForDetached(
selector: string,
timeout: number,
signal?: AbortSignal,
): Promise<boolean> {
const doc = this.document;
if (!doc) return false;

if (signal?.aborted) return false;

return await new Promise<boolean>((resolve) => {
let finished = false;

const finish = (result: boolean) => {
if (finished) return;
finished = true;
try {
observer?.disconnect();
} catch {
// ignore
}
if (timeoutId !== null) {
timerClearTimeout(timeoutId);
}
if (signal && abortHandler) {
signal.removeEventListener("abort", abortHandler);
}
resolve(result);
};

const abortHandler = () => finish(false);
if (signal) {
signal.addEventListener("abort", abortHandler, { once: true });
}

const safeCheck = () => {
try {
const found = doc.querySelector(selector);
if (!found) {
finish(true);
}
} catch (e) {
if (this.isDeadObjectError(e)) {
finish(false);
return;
}
Expand Down Expand Up @@ -152,14 +289,7 @@ export class DOMWaitOperations {
finish(true);
}
} catch (e) {
if (
e &&
typeof e === "object" &&
"message" in e &&
typeof (e as { message?: unknown }).message === "string" &&
((e as { message: string }).message?.includes("dead object") ??
false)
) {
if (this.isDeadObjectError(e)) {
finish(false);
return;
}
Expand Down
99 changes: 99 additions & 0 deletions browser-features/modules/actors/webscraper/DOMWriteOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,105 @@ export class DOMWriteOperations {
}
}


/**
* Dispatches a proper text input event sequence for rich text editors.
* This fires beforeinput with inputType: insertText, which Draft.js and similar
* frameworks listen for to update their internal state.
*
* Unlike setTextContent, this does NOT set textContent directly - it lets the
* editor handle the text insertion via the beforeinput event.
*
* Event sequence:
* 1. beforeinput (cancelable) - If cancelled, editor handles insertion
* 2. input (non-cancelable) - Fired only if beforeinput was not cancelled
* 3. change (bubbles) - Fired only if beforeinput was not cancelled
*
* Note: The actual text insertion is expected to be handled by the editor
* in response to the beforeinput event. We don't set textContent directly
* as it would break Draft.js's internal state.
*/
async dispatchTextInput(selector: string, text: string): Promise<boolean> {
try {
const doc = this.document;
if (!doc) return false;

const element = doc.querySelector(selector) as HTMLElement | null;
if (!element) {
console.warn(
`DOMWriteOperations: Element not found for dispatchTextInput: ${selector}`,
);
return false;
}

this.deps.eventDispatcher.scrollIntoViewIfNeeded(element);
this.deps.eventDispatcher.focusElementSoft(element);

const win = this.contentWindow;
const rawWin = unwrapWindow(win);
const rawDoc = this.document
? unwrapDocument(
this.document as Document & Partial<{ wrappedJSObject: Document }>,
)
: null;
const rawElement = unwrapElement(
element as HTMLElement & Partial<{ wrappedJSObject: HTMLElement }>,
);
if (!rawWin || !rawDoc) return false;

const InputEv = rawWin.InputEvent ?? null;
const EventCtor = rawWin.Event ?? globalThis.Event;

// 1. Fire beforeinput (this is what Draft.js listens for)
if (InputEv) {
const beforeInputEvent = new InputEv("beforeinput", {
bubbles: true,
cancelable: true,
inputType: "insertText",
data: text,
});
const notCancelled = rawElement.dispatchEvent(beforeInputEvent);

// If the editor cancelled the event, it will handle the insertion itself
// Don't set textContent - let the editor do it
if (notCancelled) {
// 2. Fire input event for good measure
rawElement.dispatchEvent(
new InputEv("input", {
bubbles: true,
cancelable: false,
inputType: "insertText",
data: text,
}),
);

// 3. Fire change event
rawElement.dispatchEvent(new EventCtor("change", { bubbles: true }));
return true;
}

// Editor handled it via beforeinput
return true;
}

// Fallback: try execCommand (deprecated but works in many cases)
// Note: tryExecCommand is responsible for dispatching input/change events.
if (this.tryExecCommand(rawWin, rawDoc, rawElement, "insertText", text)) {
return true;
}

// No fallback available - setting textContent directly breaks Draft.js
// because it doesn't update the editor's internal EditorState
console.warn(
"DOMWriteOperations: dispatchTextInput failed - no fallback available for this editor"
);
return false;
} catch (e) {
console.error("DOMWriteOperations: Error in dispatchTextInput:", e);
return false;
}
}

setCookieString(
cookieString: string,
cookieName?: string,
Expand Down
Loading