Modal: drop transform from open animation so clicks don't shift targets

Root cause of the upload-modal file-picker regression: the modal's
React Spring enter spring transitions
`transform: scale(0.95) translateY(-10px)` → `scale(1) translateY(0)`.
With PageScene's heavier render loop, the spring takes long enough
that a click on a small deep child (the file uploader's "Upload a
file" `<u>` link, ~10px tall) lands its mousedown on the `<u>` and
its mouseup on a different element after the modal shifted. WebView2
strictly requires same-target mousedown/mouseup to synthesize
`click`, so no `click` fires, the library's native
`addEventListener("click", u)` on the label never runs, and
`inputRef.current.click()` is never called.

Animate opacity only on enter/leave. Same fade visual, no element
movement under the cursor.

Also strip the now-unused TEMP click tracer from PageEditor.tsx.
This commit is contained in:
Tanish Manku committed 2026-05-01 18:27:27 -07:00
1 parent de0d15cd97
commit 4167bf22ce
2 files changed
+13 -86

No files matched your search

@@ -123,76 +123,6 @@ export const PageEditor = () => {
}
};
// TEMP debug: trace click / mousedown / pointerdown events at every
// listenable phase so we can locate whatever is silencing the
// upload-modal file-picker click. Remove once the bug is fixed.
useEffect(() => {
const describe = (t: EventTarget | null): string => {
if (!(t instanceof HTMLElement)) return String(t);
const id = t.id ? `#${t.id}` : "";
const cls =
typeof t.className === "string" && t.className
? `.${t.className.split(/\s+/).slice(0, 2).join(".")}`
: "";
const label =
t.closest("label")?.getAttribute("for") ??
(t.closest("label") ? "(unnamed)" : null);
return `${t.tagName.toLowerCase()}${id}${cls}${label ? ` [in label for=${label}]` : ""}`;
};
const trace = (phase: string, type: string) => (e: Event) => {
// eslint-disable-next-line no-console
console.debug(
`[click-trace] ${type} ${phase}`,
{
target: describe(e.target),
currentTarget: describe(e.currentTarget),
defaultPrevented: e.defaultPrevented,
bubbles: e.bubbles,
composedPath: (e as any).composedPath?.()?.slice(0, 6).map(describe),
},
);
};
const types: Array<
"click" | "mousedown" | "mouseup" | "pointerdown" | "pointerup"
> = ["click", "mousedown", "mouseup", "pointerdown", "pointerup"];
const handlers = types.flatMap((type) => {
const cap = trace("CAPTURE", type);
const bub = trace("BUBBLE", type);
document.addEventListener(type, cap, true);
document.addEventListener(type, bub, false);
return [
() => document.removeEventListener(type, cap, true),
() => document.removeEventListener(type, bub, false),
];
});
// Monkey-patch setPointerCapture to see who's grabbing the pointer
// (a mid-click capture explains the missing click — mouseup fires
// on the captured element instead of the original mousedown target).
const proto = Element.prototype as any;
const originalSet = proto.setPointerCapture;
proto.setPointerCapture = function (id: number) {
// eslint-disable-next-line no-console
console.debug("[click-trace] setPointerCapture", {
on: describe(this),
pointerId: id,
stack: new Error().stack?.split("\n").slice(1, 6).join("\n"),
});
return originalSet.call(this, id);
};
// eslint-disable-next-line no-console
console.debug("[click-trace] installed");
return () => {
handlers.forEach((off) => off());
proto.setPointerCapture = originalSet;
// eslint-disable-next-line no-console
console.debug("[click-trace] removed");
};
}, []);
useEffect(() => {
window.onbeforeunload = () => {
return "You may have unsaved changes.";
@@ -229,20 +229,19 @@ export const Modal = ({
// Track last non-expanded position
const lastNonExpandedPosition = useRef<{ x: number; y: number } | null>(null);
// Animation transitions
// Animation transitions. Transform (translate / scale) is
// intentionally omitted — when the React tree under the modal is
// busy (e.g. the 3D scene is rendering), the spring takes long
// enough that a quick click on a deep child (like the file
// uploader's "Upload a file" link) lands its mousedown on one
// element and its mouseup on another after the modal shifts under
// the cursor, breaking WebView2's strict same-target rule for
// synthesizing `click`. The file picker then never opens. Opacity
// alone gives the fade without moving any clickable surface.
const transitions = useTransition(isOpen, {
from: {
opacity: 0,
transform: "scale(0.95) translateY(-10px)",
},
enter: {
opacity: 1,
transform: "scale(1) translateY(0px)",
},
leave: {
opacity: 0,
transform: "scale(0.95) translateY(10px)",
},
from: { opacity: 0 },
enter: { opacity: 1 },
leave: { opacity: 0 },
config: {
tension: 300,
friction: 30,
@@ -986,9 +985,7 @@ export const Modal = ({
style={{
...getModalStyle(),
opacity: styles.opacity,
transform: styles.transform,
transformOrigin: "center center",
willChange: "transform, opacity", // Optimize for animations
willChange: "opacity",
outline: "none",
}}
>