Fix blank screen crash and text selection loss in video prompt box (#1315)

## Summary
- **Fix blank screen crash** during video recreate flow: typing text,
adjusting time slider, typing again, then clicking the text box caused
an uncaught DOM exception in MentionTextarea's contentEditable caret
operations, which unmounted the entire React tree (no error boundaries
existed)
- **Fix text selection disappearing** on mouse release in
MentionTextarea: click handler and sync useEffect were destroying active
selections by rewriting innerHTML and restoring only a collapsed caret
- **Add React Error Boundary** around PromptBoxVideo as a safety net

## Changes
### MentionTextarea.tsx
- Wrap `getCaretOffset`, `setCaretOffset`, `scrollCaretIntoView`,
`handleInput`, sync `useEffect`, and `getOffsetRect` in try/catch to
prevent uncaught DOMExceptions
- Add null guard for `childNodes` iteration and `parentNode` access
- Skip `detectMention` in `handleClick` when user has active text
selection
- Skip innerHTML rewrite in sync useEffect when editor has selected text

### PromptBoxVideo.tsx
- Stabilize `characterNames` with `useMemo` to prevent `mentionColorMap`
from rebuilding every render
- Move empty characters array to module-level constant for stable
reference
- Add cleanup `useEffect` for `durationTimerRef` to prevent stale timer
after unmount

### New: PromptBoxErrorBoundary.tsx
- React Error Boundary with "Try again" recovery button

### ImageToVideo.tsx
- Wrap `<PromptBoxVideo>` with `<PromptBoxErrorBoundary>`

## Reproduction steps (original bug)
1. Click "Recreate" on a video
2. Type text into the prompt box
3. Adjust the time slider
4. Type text again
5. Adjust the time slider and click the text box → **UI goes blank**

## Test plan
- [x] Reproduced blank screen bug on Mac before fix
- [x] Verified fix: repeated recreation flow no longer causes blank
screen
- [x] Verified text selection persists after mouse release
- [x] Video generation with @mentions, reference images, and duration
changes works correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
frankywashere
2026-04-09 09:03:59 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 51e5db6b12
commit 5fc27a5153
5 changed files with 274 additions and 176 deletions
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { JobContextType } from "@storyteller/common";
import { PromptBoxVideo, usePromptVideoStore } from "@storyteller/ui-promptbox";
import { PromptBoxVideo, usePromptVideoStore, PromptBoxErrorBoundary } from "@storyteller/ui-promptbox";
import { UploadImageMedia, UploadVideoMedia, UploadAudioMedia } from "@storyteller/api";
import BackgroundGallery from "./BackgroundGallery";
import {
@@ -199,24 +199,26 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
</div>
)} */}
<div ref={promptContentRef}>
<PromptBoxVideo
useJobContext={() => jobContext}
selectedModel={selectedVideoModel}
selectedProvider={selectedProvider}
imageMediaId={imageMediaId}
url={imageUrl ?? undefined}
onImageRowVisibilityChange={setImageRowVisible}
uploadImage={UploadImageMedia}
uploadVideo={UploadVideoMedia}
uploadAudio={UploadAudioMedia}
credits={videoCredits}
onEnqueuePressed={async (prompt, subscriberIds) => {
const modelLabel = selectedVideoModel?.fullName ?? "";
for (const subscriberId of subscriberIds) {
startBatch(prompt, modelLabel, subscriberId);
}
}}
/>
<PromptBoxErrorBoundary>
<PromptBoxVideo
useJobContext={() => jobContext}
selectedModel={selectedVideoModel}
selectedProvider={selectedProvider}
imageMediaId={imageMediaId}
url={imageUrl ?? undefined}
onImageRowVisibilityChange={setImageRowVisible}
uploadImage={UploadImageMedia}
uploadVideo={UploadVideoMedia}
uploadAudio={UploadAudioMedia}
credits={videoCredits}
onEnqueuePressed={async (prompt, subscriberIds) => {
const modelLabel = selectedVideoModel?.fullName ?? "";
for (const subscriberId of subscriberIds) {
startBatch(prompt, modelLabel, subscriberId);
}
}}
/>
</PromptBoxErrorBoundary>
</div>
</animated.div>
@@ -4,3 +4,4 @@ export * from "./lib/PromptBoxEdit";
export * from "./lib/PromptBoxVideo";
export * from "./lib/PromptBoxImage";
export * from "./lib/promptStore";
export * from "./lib/PromptBoxErrorBoundary";
@@ -55,99 +55,109 @@ function escapeHTML(text: string): string {
// ---------------------------------------------------------------------------
function getCaretOffset(el: HTMLElement): number {
const sel = window.getSelection();
if (!sel?.rangeCount || !sel.anchorNode || !el.contains(sel.anchorNode)) {
try {
const sel = window.getSelection();
if (!sel?.rangeCount || !sel.anchorNode || !el.contains(sel.anchorNode)) {
return 0;
}
const anchorNode = sel.anchorNode;
const anchorOffset = sel.anchorOffset;
let offset = 0;
if (anchorNode === el) {
for (let i = 0; i < anchorOffset; i++) {
const child = el.childNodes[i];
if (!child) break;
if (child.nodeType === Node.TEXT_NODE) {
offset += child.textContent?.length ?? 0;
} else if (child.nodeName === "BR") {
offset += 1;
} else {
offset += child.textContent?.length ?? 0;
}
}
return offset;
}
function countBefore(parent: Node): boolean {
for (const child of Array.from(parent.childNodes)) {
if (child === anchorNode) {
if (child.nodeType === Node.TEXT_NODE) {
offset += anchorOffset;
}
return true;
}
if (child.contains(anchorNode)) {
return countBefore(child);
}
if (child.nodeType === Node.TEXT_NODE) {
offset += child.textContent?.length ?? 0;
} else if (child.nodeName === "BR") {
offset += 1;
} else {
offset += child.textContent?.length ?? 0;
}
}
return false;
}
countBefore(el);
return offset;
} catch (e) {
console.debug("getCaretOffset: DOM/selection changed during read", e);
return 0;
}
const anchorNode = sel.anchorNode;
const anchorOffset = sel.anchorOffset;
let offset = 0;
if (anchorNode === el) {
for (let i = 0; i < anchorOffset; i++) {
const child = el.childNodes[i];
if (child.nodeType === Node.TEXT_NODE) {
offset += child.textContent?.length ?? 0;
} else if (child.nodeName === "BR") {
offset += 1;
} else {
offset += child.textContent?.length ?? 0;
}
}
return offset;
}
function countBefore(parent: Node): boolean {
for (const child of Array.from(parent.childNodes)) {
if (child === anchorNode) {
if (child.nodeType === Node.TEXT_NODE) {
offset += anchorOffset;
}
return true;
}
if (child.contains(anchorNode)) {
return countBefore(child);
}
if (child.nodeType === Node.TEXT_NODE) {
offset += child.textContent?.length ?? 0;
} else if (child.nodeName === "BR") {
offset += 1;
} else {
offset += child.textContent?.length ?? 0;
}
}
return false;
}
countBefore(el);
return offset;
}
function setCaretOffset(el: HTMLElement, offset: number) {
let remaining = offset;
try {
let remaining = offset;
function findPosition(parent: Node): boolean {
for (let i = 0; i < parent.childNodes.length; i++) {
const child = parent.childNodes[i];
if (child.nodeType === Node.TEXT_NODE) {
const len = child.textContent?.length ?? 0;
if (remaining <= len) {
const sel = window.getSelection();
const range = document.createRange();
range.setStart(child, remaining);
range.collapse(true);
sel?.removeAllRanges();
sel?.addRange(range);
return true;
}
remaining -= len;
} else if (child.nodeName === "BR") {
if (remaining === 0) {
const sel = window.getSelection();
const range = document.createRange();
range.setStart(parent, i);
range.collapse(true);
sel?.removeAllRanges();
sel?.addRange(range);
return true;
}
remaining -= 1;
} else if (child.nodeType === Node.ELEMENT_NODE) {
if (findPosition(child)) {
return true;
function findPosition(parent: Node): boolean {
for (let i = 0; i < parent.childNodes.length; i++) {
const child = parent.childNodes[i];
if (child.nodeType === Node.TEXT_NODE) {
const len = child.textContent?.length ?? 0;
if (remaining <= len) {
const sel = window.getSelection();
const range = document.createRange();
range.setStart(child, remaining);
range.collapse(true);
sel?.removeAllRanges();
sel?.addRange(range);
return true;
}
remaining -= len;
} else if (child.nodeName === "BR") {
if (remaining === 0) {
const sel = window.getSelection();
const range = document.createRange();
range.setStart(parent, i);
range.collapse(true);
sel?.removeAllRanges();
sel?.addRange(range);
return true;
}
remaining -= 1;
} else if (child.nodeType === Node.ELEMENT_NODE) {
if (findPosition(child)) {
return true;
}
}
}
return false;
}
return false;
}
if (!findPosition(el)) {
const sel = window.getSelection();
if (sel) {
sel.selectAllChildren(el);
sel.collapseToEnd();
if (!findPosition(el)) {
const sel = window.getSelection();
if (sel) {
sel.selectAllChildren(el);
sel.collapseToEnd();
}
}
} catch (e) {
console.debug("setCaretOffset: DOM changed during caret restore", e);
}
}
@@ -156,39 +166,47 @@ function setCaretOffset(el: HTMLElement, offset: number) {
* Uses a zero-width space span to get a measurable rect on empty lines.
*/
function scrollCaretIntoView(el: HTMLElement) {
const sel = window.getSelection();
if (!sel?.rangeCount || !el.contains(sel.anchorNode)) return;
try {
const sel = window.getSelection();
if (!sel?.rangeCount || !el.contains(sel.anchorNode)) return;
const range = sel.getRangeAt(0).cloneRange();
range.collapse(false);
const range = sel.getRangeAt(0).cloneRange();
range.collapse(false);
const span = document.createElement("span");
span.textContent = "\u200B";
range.insertNode(span);
const span = document.createElement("span");
span.textContent = "\u200B";
range.insertNode(span);
const spanRect = span.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const spanRect = span.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
if (spanRect.bottom > elRect.bottom) {
el.scrollTop += spanRect.bottom - elRect.bottom;
} else if (spanRect.top < elRect.top) {
el.scrollTop -= elRect.top - spanRect.top;
if (spanRect.bottom > elRect.bottom) {
el.scrollTop += spanRect.bottom - elRect.bottom;
} else if (spanRect.top < elRect.top) {
el.scrollTop -= elRect.top - spanRect.top;
}
// Remove temp node and restore caret after it
const parent = span.parentNode;
if (parent) {
const next = span.nextSibling;
parent.removeChild(span);
const restored = document.createRange();
if (next) {
restored.setStartBefore(next);
} else if (parent.lastChild) {
restored.setStartAfter(parent.lastChild);
} else {
restored.selectNodeContents(parent);
}
restored.collapse(true);
sel.removeAllRanges();
sel.addRange(restored);
}
} catch (e) {
console.debug("scrollCaretIntoView: DOM changed during measurement", e);
}
// Remove temp node and restore caret after it
const parent = span.parentNode!;
const next = span.nextSibling;
parent.removeChild(span);
const restored = document.createRange();
if (next) {
restored.setStartBefore(next);
} else {
restored.setStartAfter(parent.lastChild ?? parent);
}
restored.collapse(true);
sel.removeAllRanges();
sel.addRange(restored);
}
// ---------------------------------------------------------------------------
@@ -397,38 +415,52 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
const el = editorRef.current;
if (!el) return;
const caret = pendingCaret.current ?? getCaretOffset(el);
el.innerHTML = buildHTML(value);
if (pendingCaret.current !== null) {
setCaretOffset(el, pendingCaret.current);
pendingCaret.current = null;
} else if (document.activeElement === el) {
setCaretOffset(el, caret);
// Don't rewrite innerHTML while the user has text selected
const sel = window.getSelection();
if (document.activeElement === el && sel && !sel.isCollapsed) return;
try {
const caret = pendingCaret.current ?? getCaretOffset(el);
el.innerHTML = buildHTML(value);
if (pendingCaret.current !== null) {
setCaretOffset(el, pendingCaret.current);
pendingCaret.current = null;
} else if (document.activeElement === el) {
setCaretOffset(el, caret);
}
} catch (e) {
console.debug("MentionTextarea sync: DOM changed during caret restore", e);
el.innerHTML = buildHTML(value);
}
}, [value, buildHTML]);
// Get pixel coordinates of a text offset relative to the wrapper
const getOffsetRect = useCallback((charOffset: number) => {
const el = editorRef.current;
if (!el) return null;
try {
const el = editorRef.current;
if (!el) return null;
// Temporarily place caret at charOffset to measure position
const saved = getCaretOffset(el);
setCaretOffset(el, charOffset);
const sel = window.getSelection();
if (!sel?.rangeCount) {
// Temporarily place caret at charOffset to measure position
const saved = getCaretOffset(el);
setCaretOffset(el, charOffset);
const sel = window.getSelection();
if (!sel?.rangeCount) {
setCaretOffset(el, saved);
return null;
}
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
const wrapperRect = el.parentElement!.getBoundingClientRect();
setCaretOffset(el, saved);
return {
left: rect.left - wrapperRect.left,
bottom: wrapperRect.bottom - rect.top,
};
} catch (e) {
console.debug("getOffsetRect: DOM changed during measurement", e);
return null;
}
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
const wrapperRect = el.parentElement!.getBoundingClientRect();
setCaretOffset(el, saved);
return {
left: rect.left - wrapperRect.left,
bottom: wrapperRect.bottom - rect.top,
};
}, []);
// Detect @mention trigger from cursor position
@@ -466,26 +498,33 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
const el = editorRef.current;
if (!el) return;
let text = el.innerText;
if (text.endsWith("\n")) {
text = text.slice(0, -1);
try {
let text = el.innerText;
if (text.endsWith("\n")) {
text = text.slice(0, -1);
}
const caret = getCaretOffset(el);
const html = buildHTML(text);
if (el.innerHTML !== html) {
el.innerHTML = html;
setCaretOffset(el, caret);
}
isInternalUpdate.current = true;
onChange(text);
detectMention(text, caret);
// Keep caret visible when content overflows (contentEditable doesn't auto-scroll)
requestAnimationFrame(() => {
scrollCaretIntoView(el);
});
} catch (e) {
console.debug("handleInput: DOM changed during input processing", e);
const text = el.innerText?.replace(/\n$/, "") ?? "";
isInternalUpdate.current = true;
onChange(text);
}
const caret = getCaretOffset(el);
const html = buildHTML(text);
if (el.innerHTML !== html) {
el.innerHTML = html;
setCaretOffset(el, caret);
}
isInternalUpdate.current = true;
onChange(text);
detectMention(text, caret);
// Keep caret visible when content overflows (contentEditable doesn't auto-scroll)
requestAnimationFrame(() => {
scrollCaretIntoView(el);
});
}, [onChange, buildHTML, detectMention]);
const handleCompositionStart = useCallback(() => {
@@ -586,9 +625,10 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
const handleClick = useCallback(() => {
const el = editorRef.current;
if (el) {
detectMention(value, getCaretOffset(el));
}
if (!el) return;
const sel = window.getSelection();
if (sel && !sel.isCollapsed) return; // preserve text selection
detectMention(value, getCaretOffset(el));
}, [value, detectMention]);
const handlePaste = useCallback(
@@ -0,0 +1,47 @@
import { Component, Fragment } from "react";
import type { ReactNode, ErrorInfo } from "react";
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
resetKey: number;
}
export class PromptBoxErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, resetKey: 0 };
static getDerivedStateFromError(): Partial<State> {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("PromptBox crashed:", error, info.componentStack);
}
render() {
if (this.state.hasError) {
return (
<div className="flex items-center justify-center rounded-xl border border-red-500/30 bg-red-500/10 px-6 py-4 text-sm text-red-300">
<span>Something went wrong with the prompt box.&nbsp;</span>
<button
className="underline hover:text-red-200"
onClick={() =>
this.setState((prev) => ({
hasError: false,
resetKey: prev.resetKey + 1,
}))
}
>
Try again
</button>
</div>
);
}
return (
<Fragment key={this.state.resetKey}>{this.props.children}</Fragment>
);
}
}
@@ -31,6 +31,7 @@ import {
RefImage,
VideoInputMode,
useCharactersStore,
StoredCharacter,
} from "./promptStore";
import { gtagEvent } from "@storyteller/google-analytics";
import { ImagePromptRow } from "./ImagePromptRow";
@@ -58,6 +59,8 @@ declare global {
type GROK_ASPECT_RATIO = "landscape" | "portrait" | "square";
const EMPTY_CHARACTERS: StoredCharacter[] = [];
const DEFAULT_RESOLUTIONS: SizeOption[] = [
{
tauriValue: "720p",
@@ -335,7 +338,9 @@ export const PromptBoxVideo = ({
const [localDuration, setLocalDuration] = useState(effectiveDuration);
const durationTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
clearTimeout(durationTimerRef.current);
setLocalDuration(effectiveDuration);
return () => clearTimeout(durationTimerRef.current);
}, [effectiveDuration]);
const handleDurationSlide = (v: number) => {
setLocalDuration(v);
@@ -419,10 +424,13 @@ export const PromptBoxVideo = ({
// Characters are only supported for seedance_2p0
const isSeedance2p0 = selectedModel?.id === "seedance_2p0";
const activeCharacters = isSeedance2p0 ? storedCharacters : [];
const activeCharacters = isSeedance2p0 ? storedCharacters : EMPTY_CHARACTERS;
// Build a set of character names for highlight matching
const characterNames = activeCharacters.map((c) => c.name);
const characterNames = useMemo(
() => activeCharacters.map((c) => c.name),
[activeCharacters],
);
const hasAnyMentionables = hasAnyRefs || activeCharacters.length > 0;