Skip to content

[Editor][Typing] Fixing missing types in perseus-editor/src/components#3448

Open
catandthemachines wants to merge 10 commits intomainfrom
catjohnson/lems-2748
Open

[Editor][Typing] Fixing missing types in perseus-editor/src/components#3448
catandthemachines wants to merge 10 commits intomainfrom
catjohnson/lems-2748

Conversation

@catandthemachines
Copy link
Copy Markdown
Member

@catandthemachines catandthemachines commented Apr 1, 2026

Summary:

Adding missing types to perseus-editor/src/components folder. This is important to minimize regressions and create a more stable editor experiences.

I also moved the associated stories for components back into the proper folder type so it now appears in our Storybook again.

Issue: LEMS-2748

Test plan:

Run pnpm test
And because this is old legacy code, will create a ZND and test this in our editor to double check no glaring type issues.

ZND PR: https://github.com/Khan/frontend/pull/9938

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

🗄️ Schema Change: No Changes ✅

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

🛠️ Item Splitting: No Changes ✅

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

Size Change: +18 B (0%)

Total Size: 495 kB

Filename Size Change
packages/perseus-editor/dist/es/index.js 102 kB +18 B (+0.02%)
ℹ️ View Unchanged
Filename Size
packages/kas/dist/es/index.js 20.5 kB
packages/keypad-context/dist/es/index.js 1 kB
packages/kmath/dist/es/index.js 6.21 kB
packages/math-input/dist/es/index.js 98.5 kB
packages/math-input/dist/es/strings.js 1.61 kB
packages/perseus-core/dist/es/index.item-splitting.js 11.9 kB
packages/perseus-core/dist/es/index.js 25.1 kB
packages/perseus-linter/dist/es/index.js 9.3 kB
packages/perseus-score/dist/es/index.js 9.66 kB
packages/perseus-utils/dist/es/index.js 403 B
packages/perseus/dist/es/index.js 193 kB
packages/perseus/dist/es/strings.js 8.09 kB
packages/pure-markdown/dist/es/index.js 1.39 kB
packages/simple-markdown/dist/es/index.js 6.71 kB

compressed-size-action


type Props = {
onDrop: (e: DragEvent) => void;
component?: any;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Removing this property as it appears none of components that reference DragTarget actually use this functionality. Might as well delete it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yes! We do have some patterns where there's a feature to override/customize something that isn't actually used anymore. Thanks for simplifying!

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

npm Snapshot: Published

Good news!! We've packaged up the latest commit from this PR (9c22821) and published it to npm. You
can install it using the tag PR3448.

Example:

pnpm add @khanacademy/perseus@PR3448

If you are working in Khan Academy's frontend, you can run the below command.

./dev/tools/bump_perseus_version.ts -t PR3448

If you are working in Khan Academy's webapp, you can run the below command.

./dev/tools/bump_perseus_version.js -t PR3448

if (pressedKey === "ArrowDown" && focusedElement.nextElementSibling) {
event.preventDefault();
focusedElement.nextSibling.focus();
(focusedElement.nextElementSibling as HTMLElement).focus();
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I don't like all this usage of as. Let me follow up and see if there's something I can change here.

snapStep: [number, number];
valid: boolean;
backgroundImage: any;
backgroundImage: PerseusImageBackground;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Question: is this always guaranteed? 🤔

@catandthemachines
Copy link
Copy Markdown
Member Author

@claude review once

@catandthemachines catandthemachines marked this pull request as ready for review April 2, 2026 17:35
Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@catandthemachines
Copy link
Copy Markdown
Member Author

@claude review once

Comment on lines 17 to 25
import * as React from "react";

type Props = {
onDrop: (e: DragEvent) => void;
component?: any;
shouldDragHighlight: (any) => boolean;
style?: any;
children?: any;
onDrop: (e: React.MouseEvent) => unknown;
shouldDragHighlight: (e: React.MouseEvent) => boolean;
style?: React.CSSProperties;
children?: React.ReactNode;
className?: string;
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 The drag event handler types in drag-target.tsx are incorrectly set to React.MouseEvent instead of React.DragEvent<HTMLElement>. This caused a concrete downstream workaround in editor.tsx, where handleDrop must use a fragile "dataTransfer" in e runtime check to access e.dataTransfer — a property TypeScript cannot see on React.MouseEvent but which is always present at runtime on actual drag events.

Extended reasoning...

What the bug is

The PR changes the type of all drag-related event parameters in drag-target.tsx from the correct native DragEvent (before the PR) to React.MouseEvent (after the PR). The affected signatures are:

  • Props.onDrop: (e: React.MouseEvent) => unknown
  • Props.shouldDragHighlight: (e: React.MouseEvent) => boolean
  • handleDrop(e: React.MouseEvent)
  • handleDragOver(e: React.MouseEvent)
  • handleDragEnter(e: React.MouseEvent)

The correct React type for these drag-specific events is React.DragEvent<HTMLElement>, which extends React.MouseEvent and adds the essential dataTransfer: DataTransfer property.

The specific code path that triggers it

DragTarget passes this.handleDrop directly to <View onDrop={...}>. At runtime, the browser fires a real DragEvent (which has dataTransfer). However, TypeScript only sees React.MouseEvent, which has no dataTransfer property. When editor.tsx consumes the onDrop prop typed as (e: React.MouseEvent) => unknown, it cannot access e.dataTransfer directly without a cast or runtime guard.

Why existing code does not prevent it

Because React.DragEvent<T> extends React.MouseEvent<T>, TypeScript structural typing means a (e: React.MouseEvent) => void handler is assignable where a React.DragEventHandler is expected (function contravariance). So no compile error surfaces at the call site, and runtime behavior is preserved because browsers correctly fire DragEvent objects regardless of what TypeScript thinks.

Impact and the resulting workaround

The incorrect type forced editor.tsx to add a defensive runtime guard:

handleDrop = (e: React.MouseEvent) => {
    const dataTransfer =
        "dataTransfer" in e
            ? (e.dataTransfer as DataTransfer | null)
            : null;

The "dataTransfer" in e check will always be true at runtime, but TypeScript cannot know this because the type is wrong. The subsequent cast as DataTransfer | null is also forced by the incorrect typing. With React.DragEvent<HTMLElement>, e.dataTransfer would be directly accessible with no guard or cast needed.

Step-by-step proof

  1. User drags an image file onto the editor textarea.
  2. Browser fires a native DragEvent on the <View> element.
  3. React wraps it in a synthetic event; DragTarget.handleDrop(e: React.MouseEvent) is called, but at runtime e is actually a React.DragEvent with e.dataTransfer populated.
  4. DragTarget calls this.props.onDrop(e) typed as (e: React.MouseEvent) => unknown.
  5. editor.tsx's handleDrop receives e. TypeScript sees React.MouseEvent with no dataTransfer, so direct access would be a compile-time error.
  6. The workaround "dataTransfer" in e always evaluates to true, and the cast is applied — the image upload proceeds.
  7. If the workaround were removed and e.dataTransfer accessed directly, TypeScript would flag it as an error even though it works at runtime.

How to fix it

Change all affected types in drag-target.tsx from React.MouseEvent to React.DragEvent<HTMLElement>, and update editor.tsx's handleDrop signature to match, allowing e.dataTransfer to be accessed directly without the runtime guard or cast.

Copy link
Copy Markdown
Member Author

@catandthemachines catandthemachines Apr 2, 2026

Choose a reason for hiding this comment

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

This was on purpose to try and use WB View component over a div element. I want to double check this implementation in a ZND to confirm it works. But yes I don't care for the casting to a different element and afraid it could cause issues with dragging images. 😬

@catandthemachines catandthemachines requested a review from a team April 2, 2026 21:25
};

handleDrop: (e: DragEvent) => void = (e: DragEvent) => {
handleDrop = (e: React.MouseEvent) => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The original DragEvent seemed to be correct though. Why did this need to change?

Image

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I added this in the comment above, but I'm trying to avoid using a div element and use a WB's View, which does not expose a DragEvent. I want to double check if it works. If not I'll go back to the div element.

#3448 (comment)

type Props = {
onChange: (
newProps: Record<string, unknown>,
callback?: () => unknown,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I was looking at something else the other day related to this and we might not even use this callback parameter anywhere anymore.

No action required.


type Props = {
onDrop: (e: DragEvent) => void;
component?: any;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yes! We do have some patterns where there's a feature to override/customize something that isn't actually used anymore. Thanks for simplifying!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants