Repository: Khan/perseus
Pull request #2452: Use interface for shared ref type
Author: login: jeremywiebe
Labels: name: olc-5.0.f25a3
Created: 2025-05-07T19:45:08Z | Merged: 2025-05-08T14:19:40Z

## Summary:

The `numeric-input` widget uses two different input components depending on whether a custom keypad is enabled or not. We want to retain a ref to either of these inputs within the widget. Originally, the ref was defined as the union of these two components, but that leads to sporadic type check failures. 

This PR adjusts things so we define a common `interface` for these two components and then use that interface as the type for hte ref. This makes it so the ref is a very narrow type referencing only the fields that are used.

Issue: "none"

## Test plan:

`yarn typecheck`
`yarn tsc --watch` (make some changes, save, then revert to ensure the type checking doesn't fail)


Diff:
diff --git a/.changeset/good-trains-dream.md b/.changeset/good-trains-dream.md
new file mode 100644
index 00000000000..785c85a9ea2
--- /dev/null
+++ b/.changeset/good-trains-dream.md
@@ -0,0 +1,5 @@
+---
+"@khanacademy/perseus": patch
+---
+
+Fix types used for forwardref in `expression` widget
diff --git a/packages/perseus/src/components/simple-keypad-input.tsx b/packages/perseus/src/components/simple-keypad-input.tsx
index bf409b3c1bf..4fa54e03154 100644
--- a/packages/perseus/src/components/simple-keypad-input.tsx
+++ b/packages/perseus/src/components/simple-keypad-input.tsx
@@ -13,7 +13,12 @@ import {KeypadInput, keypadElementPropType} from "@khanacademy/math-input";
 import PropTypes from "prop-types";
 import * as React from "react";
 
-export default class SimpleKeypadInput extends React.Component<any> {
+import type {Focusable} from "../types";
+
+export default class SimpleKeypadInput
+    extends React.Component<any>
+    implements Focusable
+{
     static contextType = KeypadContext;
     declare context: React.ContextType<typeof KeypadContext>;
     _isMounted = false;
diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts
index f1a6d65f038..d375336403f 100644
--- a/packages/perseus/src/types.ts
+++ b/packages/perseus/src/types.ts
@@ -607,3 +607,8 @@ export type SharedRendererProps = {
     apiOptions: APIOptions;
     linterContext: LinterContextProps;
 };
+
+export interface Focusable {
+    focus: () => void;
+    blur: () => void;
+}
diff --git a/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx b/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx
index 5b121c51ef1..4150fd08cb1 100644
--- a/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx
+++ b/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx
@@ -10,6 +10,7 @@ import {ClassNames as ApiClassNames} from "../../perseus-api";
 import Renderer from "../../renderer";
 import Util from "../../util";
 
+import type {Focusable} from "../../types";
 import type {LinterContextProps} from "@khanacademy/perseus-linter";
 import type {StyleType} from "@khanacademy/wonder-blocks-core";
 
@@ -18,19 +19,19 @@ const {captureScratchpadTouchStart} = Util;
 type Props = {
     value: string;
     onChange: any;
-    className: string;
+    className?: string;
     examples: ReadonlyArray<string>;
     shouldShowExamples: boolean;
     convertDotToTimes?: boolean;
     buttonSet?: string;
     buttonsVisible?: "always" | "never" | "focused";
     labelText?: string;
-    onFocus: () => void;
-    onBlur: () => void;
-    disabled: boolean;
+    onFocus?: () => void;
+    onBlur?: () => void;
+    disabled?: boolean;
     style?: StyleType;
     id: string;
-    linterContext: LinterContextProps;
+    linterContext?: LinterContextProps;
 };
 
 // [LEMS-2411](Jan 2025) Third: This component has been moved to the NumericInput
@@ -43,143 +44,143 @@ type Props = {
  * for the desktop versions of these widgets, and displays a tooltip with
  * examples of how to input the selected answer forms.
  */
-const InputWithExamples = forwardRef<
-    React.RefObject<typeof InputWithExamples>,
-    Props
->((props, ref) => {
-    // Desctructure the props to set default values
-    const {
-        shouldShowExamples = true,
-        onFocus = () => {},
-        onBlur = () => {},
-        disabled = false,
-        linterContext = PerseusLinter.linterContextDefault,
-        className = "",
-    } = props;
-
-    const context = React.useContext(PerseusI18nContext);
-    const inputRef = React.useRef<TextInput>(null);
-    const [inputFocused, setInputFocused] = React.useState<boolean>(false);
-    const id = useId();
-    const ariaId = `aria-for-${id}`;
-
-    useImperativeHandle(ref, () => ({
-        current: inputRef.current,
-        focus: () => {
-            if (inputRef.current) {
-                inputRef.current.focus();
-            }
+const InputWithExamples = forwardRef<Focusable, Props>(
+    (
+        {
+            shouldShowExamples = true,
+            onFocus = () => {},
+            onBlur = () => {},
+            disabled = false,
+            linterContext = PerseusLinter.linterContextDefault,
+            className = "",
+            ...props
         },
-        blur: () => {
-            if (inputRef.current) {
-                inputRef.current.blur();
+        ref,
+    ) => {
+        const context = React.useContext(PerseusI18nContext);
+        const inputRef = React.useRef<TextInput>(null);
+        const [inputFocused, setInputFocused] = React.useState<boolean>(false);
+        const id = useId();
+        const ariaId = `aria-for-${id}`;
+
+        useImperativeHandle(ref, () => ({
+            current: inputRef.current,
+            focus: () => {
+                if (inputRef.current) {
+                    inputRef.current.focus();
+                }
+            },
+            blur: () => {
+                if (inputRef.current) {
+                    inputRef.current.blur();
+                }
+            },
+        }));
+
+        const getInputClassName = () => {
+            let inputClassName = ApiClassNames.INPUT;
+            if (inputFocused) {
+                inputClassName += " " + ApiClassNames.FOCUSED;
             }
-        },
-    }));
-
-    const getInputClassName = () => {
-        let inputClassName = ApiClassNames.INPUT;
-        if (inputFocused) {
-            inputClassName += " " + ApiClassNames.FOCUSED;
-        }
-        if (className) {
-            inputClassName += " " + className;
-        }
-        return inputClassName;
-    };
-
-    const handleFocus = () => {
-        onFocus();
-        setInputFocused(true);
-    };
-
-    const handleBlur = () => {
-        onBlur();
-        setInputFocused(false);
-    };
-
-    const renderInput = () => {
-        // Generate the provided examples in simple language for screen readers.
-        const examplesAria = props.shouldShowExamples
-            ? `${props.examples[0]}
+            if (className) {
+                inputClassName += " " + className;
+            }
+            return inputClassName;
+        };
+
+        const handleFocus = () => {
+            onFocus();
+            setInputFocused(true);
+        };
+
+        const handleBlur = () => {
+            onBlur();
+            setInputFocused(false);
+        };
+
+        const renderInput = () => {
+            // Generate the provided examples in simple language for screen readers.
+            const examplesAria = shouldShowExamples
+                ? `${props.examples[0]}
                    ${props.examples.slice(1).join(", or\n")}`
-                  // @ts-expect-error TS2550: Property replaceAll does not exist on type string.
-                  .replaceAll("*", "")
-                  .replaceAll("$", "")
-                  .replaceAll("\\ \\text{pi}", " pi")
-                  .replaceAll("\\ ", " and ")
-            : "";
-
-        const inputProps = {
-            id: id,
-            // If we have examples, we want to provide the aria-describedby attribute
-            "aria-describedby": props.shouldShowExamples ? ariaId : undefined,
-            ref: inputRef,
-            className: getInputClassName(),
-            labelText: props.labelText,
-            value: props.value,
-            onFocus: handleFocus,
-            onBlur: handleBlur,
-            disabled: disabled,
-            style: props.style,
-            onChange: props.onChange,
-            onTouchStart: captureScratchpadTouchStart,
-            autoCapitalize: "off",
-            autoComplete: "off",
-            autoCorrect: "off",
-            spellCheck: "false",
+                      // @ts-expect-error TS2550: Property replaceAll does not exist on type string.
+                      .replaceAll("*", "")
+                      .replaceAll("$", "")
+                      .replaceAll("\\ \\text{pi}", " pi")
+                      .replaceAll("\\ ", " and ")
+                : "";
+
+            const inputProps = {
+                id: id,
+                // If we have examples, we want to provide the aria-describedby attribute
+                "aria-describedby": shouldShowExamples ? ariaId : undefined,
+                ref: inputRef,
+                className: getInputClassName(),
+                labelText: props.labelText,
+                value: props.value,
+                onFocus: handleFocus,
+                onBlur: handleBlur,
+                disabled: disabled,
+                style: props.style,
+                onChange: props.onChange,
+                onTouchStart: captureScratchpadTouchStart,
+                autoCapitalize: "off",
+                autoComplete: "off",
+                autoCorrect: "off",
+                spellCheck: "false",
+            };
+            return (
+                <>
+                    <TextInput {...inputProps} />
+                    <span id={ariaId} style={{display: "none"}}>
+                        {examplesAria}
+                    </span>
+                </>
+            );
         };
-        return (
-            <>
-                <TextInput {...inputProps} />
-                <span id={ariaId} style={{display: "none"}}>
-                    {examplesAria}
-                </span>
-            </>
-        );
-    };
 
-    const renderTooltipContent = () => {
+        const renderTooltipContent = () => {
+            return (
+                <TooltipContent>
+                    <div id={id} className="input-with-examples-tooltip">
+                        <Renderer
+                            content={examplesContent}
+                            linterContext={PerseusLinter.pushContextStack(
+                                linterContext,
+                                "input-with-examples",
+                            )}
+                            strings={context.strings}
+                        />
+                    </div>
+                </TooltipContent>
+            );
+        };
+        // Display the examples as a string when there are less than or equal to 2 examples.
+        // Otherwise, display the examples as a list.
+        const examplesContent =
+            props.examples.length <= 2
+                ? props.examples.join(" ")
+                : props.examples
+                      .map((example, index) => {
+                          return index === 0 && example.startsWith("**")
+                              ? `${example}\n`
+                              : `- ${example}`;
+                      })
+                      .join("\n");
+
+        // Display the examples when they are enabled (shouldShowExamples) and the input is focused.
+        const showExamplesTooltip = shouldShowExamples && inputFocused;
+
         return (
-            <TooltipContent>
-                <div id={id} className="input-with-examples-tooltip">
-                    <Renderer
-                        content={examplesContent}
-                        linterContext={PerseusLinter.pushContextStack(
-                            linterContext,
-                            "input-with-examples",
-                        )}
-                        strings={context.strings}
-                    />
-                </div>
-            </TooltipContent>
+            <Tooltip
+                content={renderTooltipContent()}
+                opened={showExamplesTooltip}
+                placement="bottom"
+            >
+                {renderInput()}
+            </Tooltip>
         );
-    };
-    // Display the examples as a string when there are less than or equal to 2 examples.
-    // Otherwise, display the examples as a list.
-    const examplesContent =
-        props.examples.length <= 2
-            ? props.examples.join(" ")
-            : props.examples
-                  .map((example, index) => {
-                      return index === 0 && example.startsWith("**")
-                          ? `${example}\n`
-                          : `- ${example}`;
-                  })
-                  .join("\n");
-
-    // Display the examples when they are enabled (shouldShowExamples) and the input is focused.
-    const showExamplesTooltip = shouldShowExamples && inputFocused;
-
-    return (
-        <Tooltip
-            content={renderTooltipContent()}
-            opened={showExamplesTooltip}
-            placement="bottom"
-        >
-            {renderInput()}
-        </Tooltip>
-    );
-});
+    },
+);
 
 export default InputWithExamples;
diff --git a/packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx b/packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx
index 0d0c6573ea6..d6e3a131487 100644
--- a/packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx
+++ b/packages/perseus/src/widgets/numeric-input/numeric-input.class.tsx
@@ -8,9 +8,13 @@ import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/numeric-inp
 import {NumericInputComponent} from "./numeric-input";
 import {unionAnswerForms} from "./utils";
 
-import type InputWithExamples from "./input-with-examples";
-import type SimpleKeypadInput from "../../components/simple-keypad-input";
-import type {FocusPath, Widget, WidgetExports, WidgetProps} from "../../types";
+import type {
+    FocusPath,
+    Focusable,
+    Widget,
+    WidgetExports,
+    WidgetProps,
+} from "../../types";
 import type {NumericInputPromptJSON} from "../../widget-ai-utils/numeric-input/prompt-utils";
 import type {
     PerseusNumericInputWidgetOptions,
@@ -77,7 +81,7 @@ export class NumericInput
     extends React.Component<NumericInputProps>
     implements Widget
 {
-    inputRef = React.createRef<SimpleKeypadInput | typeof InputWithExamples>();
+    inputRef = React.createRef<Focusable>();
 
     static defaultProps: DefaultProps = {
         currentValue: "",
diff --git a/packages/perseus/src/widgets/numeric-input/numeric-input.tsx b/packages/perseus/src/widgets/numeric-input/numeric-input.tsx
index d94ad0c3671..22e17cde305 100644
--- a/packages/perseus/src/widgets/numeric-input/numeric-input.tsx
+++ b/packages/perseus/src/widgets/numeric-input/numeric-input.tsx
@@ -15,17 +15,17 @@ import InputWithExamples from "./input-with-examples";
 import {type NumericInputProps} from "./numeric-input.class";
 import {generateExamples, shouldShowExamples} from "./utils";
 
-type InputRefType = SimpleKeypadInput | typeof InputWithExamples | null;
+import type {Focusable} from "../../types";
 
 /**
  * The NumericInputComponent is a child component of the NumericInput class
  * component. It is responsible for rendering the UI elements of the Numeric
  * Input widget.
  */
-export const NumericInputComponent = forwardRef(
-    (props: NumericInputProps, ref) => {
+export const NumericInputComponent = forwardRef<Focusable, NumericInputProps>(
+    (props, ref) => {
         const context = useContext(PerseusI18nContext);
-        const inputRef = useRef<InputRefType>(null);
+        const inputRef = useRef<Focusable>(null);
         const [isFocused, setIsFocused] = useState<boolean>(false);
 
         // Pass the focus and blur methods to the Numeric Input Class component
@@ -100,7 +100,7 @@ export const NumericInputComponent = forwardRef(
         // (desktop-only) Otherwise, use the InputWithExamples component
         return (
             <InputWithExamples
-                ref={inputRef as React.RefObject<typeof InputWithExamples>}
+                ref={inputRef}
                 value={props.currentValue}
                 onChange={handleChange}
                 labelText={props.labelText}


Reviews:
user: login: benchristel
body: Awesome! Thanks for this fix! I left a few comments inline about tightening up the types.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T20:58:31Z
user: login: jeremywiebe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T21:18:51Z
user: login: jeremywiebe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T21:20:32Z
user: login: benchristel
body: LGTM!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-07T23:12:23Z
user: login: jeremywiebe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T23:18:51Z

Review comments:
diff_hunk: @@ -607,3 +607,9 @@ export type SharedRendererProps = {
     apiOptions: APIOptions;
     linterContext: LinterContextProps;
 };
+
+export interface NumericInputHandle {
+    current: any;
path: packages/perseus/src/types.ts
user: login: benchristel
body: Does `current` have to be `any`? I don't get any type errors if I remove `current`, so is it possible it's not used?

(I don't see any usages in `numeric-input.tsx` or `input-number.tsx`, which are the places `InputWithExamples` is used.)
created_at: 2025-05-07T20:49:46Z
author_association: MEMBER
diff_hunk: @@ -18,19 +19,19 @@ const {captureScratchpadTouchStart} = Util;
 type Props = {
     value: string;
     onChange: any;
-    className: string;
+    className?: string;
     examples: ReadonlyArray<string>;
     shouldShowExamples: boolean;
     convertDotToTimes?: boolean;
     buttonSet?: string;
     buttonsVisible?: "always" | "never" | "focused";
     labelText?: string;
-    onFocus: () => void;
-    onBlur: () => void;
-    disabled: boolean;
+    onFocus?: () => void;
+    onBlur?: () => void;
+    disabled?: boolean;
path: packages/perseus/src/widgets/numeric-input/input-with-examples.tsx
user: login: benchristel
body: Question: Why did these become optional? Is it because they have defaults in the component?
created_at: 2025-05-07T20:51:27Z
author_association: MEMBER
diff_hunk: @@ -25,7 +25,7 @@ type InputRefType = SimpleKeypadInput | typeof InputWithExamples | null;
 export const NumericInputComponent = forwardRef(
path: packages/perseus/src/widgets/numeric-input/numeric-input.tsx
user: login: benchristel
body: I think this `forwardRef` might need an explicit type argument for the ref type:

```suggestion
export const NumericInputComponent = forwardRef<NumericInputHandle, NumericInputProps>(
```

Currently, the type of the ref value is inferred as `undefined`. Typechecking is passing in spite of this, because of TypeScript's lax handling of variance.
created_at: 2025-05-07T20:57:59Z
author_association: MEMBER
diff_hunk: @@ -607,3 +607,9 @@ export type SharedRendererProps = {
     apiOptions: APIOptions;
     linterContext: LinterContextProps;
 };
+
+export interface NumericInputHandle {
+    current: any;
path: packages/perseus/src/types.ts
user: login: jeremywiebe
body: Good catch! `current` isn't used so I can remove it! Thanks for catching that. 
created_at: 2025-05-07T21:18:51Z
author_association: COLLABORATOR
diff_hunk: @@ -18,19 +19,19 @@ const {captureScratchpadTouchStart} = Util;
 type Props = {
     value: string;
     onChange: any;
-    className: string;
+    className?: string;
     examples: ReadonlyArray<string>;
     shouldShowExamples: boolean;
     convertDotToTimes?: boolean;
     buttonSet?: string;
     buttonsVisible?: "always" | "never" | "focused";
     labelText?: string;
-    onFocus: () => void;
-    onBlur: () => void;
-    disabled: boolean;
+    onFocus?: () => void;
+    onBlur?: () => void;
+    disabled?: boolean;
path: packages/perseus/src/widgets/numeric-input/input-with-examples.tsx
user: login: jeremywiebe
body: They are optional because they have default props. We can make them optional now because I moved the restructuring into the functional component definition instead of being the first thing _inside_ the functional component. That helps TypeScript see that we are providing a value for these optional props. 

created_at: 2025-05-07T21:20:32Z
author_association: COLLABORATOR
diff_hunk: @@ -607,3 +607,8 @@ export type SharedRendererProps = {
     apiOptions: APIOptions;
     linterContext: LinterContextProps;
 };
+
+export interface NumericInputHandle {
path: packages/perseus/src/types.ts
user: login: benchristel
body: Now that I'm looking at this a second time, I'm realizing that this interface is just `Focusable`, and not anything specific to numeric input.

```suggestion
export interface Focusable {
```
created_at: 2025-05-07T23:10:26Z
author_association: MEMBER
diff_hunk: @@ -607,3 +607,8 @@ export type SharedRendererProps = {
     apiOptions: APIOptions;
     linterContext: LinterContextProps;
 };
+
+export interface NumericInputHandle {
path: packages/perseus/src/types.ts
user: login: jeremywiebe
body: Right! I like how this has distilled to a really narrow, well-named concept. 
created_at: 2025-05-07T23:18:51Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-07T19:46:46Z
body: **Size Change:** +7 B (0%) 

**Total Size:** 467 kB

| Filename | Size | Change |
| :--- | :---: | :---: |
| `packages/perseus/dist/es/index.js` | 199 kB | +7 B (0%) |

<details><summary>ℹ️ <strong>View Unchanged</strong></summary>

| Filename | Size |
| :--- | :---: |
| `packages/kas/dist/es/index.js` | 20.7 kB |
| `packages/keypad-context/dist/es/index.js` | 1 kB |
| `packages/kmath/dist/es/index.js` | 5.98 kB |
| `packages/math-input/dist/es/index.js` | 98.6 kB |
| `packages/math-input/dist/es/strings.js` | 1.61 kB |
| `packages/perseus-core/dist/es/index.js` | 18.8 kB |
| `packages/perseus-editor/dist/es/index.js` | 88.5 kB |
| `packages/perseus-linter/dist/es/index.js` | 7.05 kB |
| `packages/perseus-score/dist/es/index.js` | 9.04 kB |
| `packages/perseus-utils/dist/es/index.js` | 403 B |
| `packages/perseus/dist/es/strings.js` | 7.49 kB |
| `packages/pure-markdown/dist/es/index.js` | 1.22 kB |
| `packages/simple-markdown/dist/es/index.js` | 6.71 kB |

</details>



<a href="https://github.com/preactjs/compressed-size-action"><sub>compressed-size-action</sub></a>
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-07T19:54:14Z
body: # npm Snapshot: Published

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

Example:
```sh
pnpm add @khanacademy/perseus@PR2452
```

If you are working in Khan Academy's webapp, you can run:
```sh
./dev/tools/bump_perseus_version.js -t PR2452
```
author_association: CONTRIBUTOR