Repository: Khan/perseus
Pull request #2444: Ensure Orderer Editor updates Options property in change callback
Author: login: SonicScrewdriver
Labels: name: olc-5.0.65aec
Created: 2025-05-06T21:46:37Z | Merged: 2025-05-12T17:55:27Z

## Summary:
We were facing a bug with the Orderer Editor where the state was always a step out-of-date. Thanks to @handeyeco 's insights, it sounds like we've deprecated `serialize`, and we're now handling everything in the onChange events. Unfortunately, the logic for `onOptionsChange` did not handle the updating of the dynamic options property, thus resulting in it being out of sync while editing pre-existing content. 

This PR adds the missing logic to `onOptionsChange`, while also modernizing it to remove underscore. 

Issue: LEMS-3082

## Test plan:
- New tests for onChange response AND sort order 
- Confirmed fixes work upstream via snapshot testing 


Diff:
diff --git a/.changeset/fluffy-onions-camp.md b/.changeset/fluffy-onions-camp.md
new file mode 100644
index 00000000000..68127548a19
--- /dev/null
+++ b/.changeset/fluffy-onions-camp.md
@@ -0,0 +1,5 @@
+---
+"@khanacademy/perseus-editor": patch
+---
+
+Fixing OrdererEditor so that it properly returns fully updated options onOptionsChange.
diff --git a/packages/perseus-editor/src/editor-page.test.tsx b/packages/perseus-editor/src/editor-page.test.tsx
new file mode 100644
index 00000000000..359376b1aa7
--- /dev/null
+++ b/packages/perseus-editor/src/editor-page.test.tsx
@@ -0,0 +1,116 @@
+import {Dependencies} from "@khanacademy/perseus";
+import {
+    type PerseusOrdererWidgetOptions,
+    type PerseusRenderer,
+} from "@khanacademy/perseus-core";
+import {render, screen} from "@testing-library/react";
+import {userEvent as userEventLib} from "@testing-library/user-event";
+import * as React from "react";
+
+import {testDependencies} from "../../../testing/test-dependencies";
+
+import EditorPage from "./editor-page";
+import {registerAllWidgetsAndEditorsForTesting} from "./util/register-all-widgets-and-editors-for-testing";
+
+import type {UserEvent} from "@testing-library/user-event";
+
+describe("EditorPage", () => {
+    beforeAll(() => {
+        registerAllWidgetsAndEditorsForTesting();
+    });
+
+    let userEvent: UserEvent;
+    beforeEach(() => {
+        userEvent = userEventLib.setup({
+            advanceTimers: jest.advanceTimersByTime,
+        });
+
+        jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
+            testDependencies,
+        );
+    });
+
+    it("updates Orderer state correctly", async () => {
+        const onChangeMock = jest.fn();
+
+        const imageUrl =
+            "https://ka-perseus-graphie.s3.amazonaws.com/6e68574abb61d2b4a12644d777006fcfa8a73cff.png";
+        const noAltImage = `![](${imageUrl})`;
+        const altImage = `![a fox](${imageUrl})`;
+        // "[" is a special char in UserEvent, so I need to do it twice when typing
+        const altImageTyped = `![[a fox](${imageUrl})`;
+
+        const startWidgetOptions: PerseusOrdererWidgetOptions = {
+            options: [
+                {
+                    content: noAltImage,
+                    widgets: {},
+                    images: {},
+                },
+            ],
+            correctOptions: [
+                {
+                    content: noAltImage,
+                    widgets: {},
+                    images: {},
+                },
+            ],
+            otherOptions: [],
+            height: "normal",
+            layout: "horizontal",
+        };
+
+        const startRenderer: PerseusRenderer = {
+            content: "[[☃ orderer 1]]",
+            widgets: {
+                "orderer 1": {
+                    type: "orderer",
+                    options: startWidgetOptions,
+                },
+            },
+            images: {},
+        };
+
+        render(
+            <EditorPage
+                question={startRenderer}
+                onChange={onChangeMock}
+                onPreviewDeviceChange={() => {}}
+                previewDevice="desktop"
+                previewURL=""
+                frameSource=""
+                itemId="itemId"
+                developerMode={false}
+                jsonMode={false}
+                widgetsAreOpen={true}
+            />,
+        );
+
+        const input = screen.getByDisplayValue(noAltImage);
+        await userEvent.clear(input);
+        await userEvent.type(input, altImageTyped);
+
+        // Verify the mock was called
+        expect(onChangeMock).toHaveBeenCalled();
+
+        // Get the widget options directly to avoid having to mock the entire PerseusItem
+        const widgetOptions =
+            onChangeMock.mock.lastCall[0].question.widgets["orderer 1"].options;
+
+        // Verify the options were updated correctly
+        expect(widgetOptions).toEqual(
+            expect.objectContaining({
+                correctOptions: [
+                    {
+                        content: altImage,
+                    },
+                ],
+                options: [
+                    {
+                        content: altImage,
+                    },
+                ],
+            }),
+        );
+    });
+});
diff --git a/packages/perseus-editor/src/widgets/__tests__/orderer-editor.test.tsx b/packages/perseus-editor/src/widgets/__tests__/orderer-editor.test.tsx
new file mode 100644
index 00000000000..166b349876c
--- /dev/null
+++ b/packages/perseus-editor/src/widgets/__tests__/orderer-editor.test.tsx
@@ -0,0 +1,246 @@
+import {Dependencies} from "@khanacademy/perseus";
+import {render, screen} from "@testing-library/react";
+import {userEvent as userEventLib} from "@testing-library/user-event";
+import * as React from "react";
+
+import {testDependencies} from "../../../../../testing/test-dependencies";
+import OrdererEditor, {getUpdatedOptions} from "../orderer-editor";
+
+import type {PerseusOrdererWidgetOptions} from "@khanacademy/perseus-core";
+import type {UserEvent} from "@testing-library/user-event";
+
+describe("OrdererEditor", () => {
+    let userEvent: UserEvent;
+    beforeEach(() => {
+        userEvent = userEventLib.setup({
+            advanceTimers: jest.advanceTimersByTime,
+        });
+
+        jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
+            testDependencies,
+        );
+    });
+
+    it("updates state correctly", async () => {
+        // Arrange
+        const onChangeMock = jest.fn();
+        const editorRef = React.createRef<OrdererEditor>();
+
+        const imageUrl =
+            "https://ka-perseus-graphie.s3.amazonaws.com/6e68574abb61d2b4a12644d777006fcfa8a73cff.png";
+        const noAltImage = `![](${imageUrl})`;
+        const altImage = `![a fox](${imageUrl})`;
+        // "[" is a special char in UserEvent, so I need to do it twice when typing
+        const altImageTyped = `![[a fox](${imageUrl})`;
+        const startWidgetOptions: PerseusOrdererWidgetOptions = {
+            options: [
+                {
+                    content: noAltImage,
+                    widgets: {},
+                    images: {},
+                },
+            ],
+            correctOptions: [
+                {
+                    content: noAltImage,
+                    widgets: {},
+                    images: {},
+                },
+            ],
+            otherOptions: [],
+            height: "normal",
+            layout: "horizontal",
+        };
+
+        render(
+            <OrdererEditor
+                ref={editorRef}
+                onChange={onChangeMock}
+                {...startWidgetOptions}
+            />,
+        );
+
+        // Act
+        const input = screen.getByDisplayValue(noAltImage);
+        await userEvent.clear(input);
+        await userEvent.type(input, altImageTyped);
+
+        // Assert
+        expect(onChangeMock).toHaveBeenCalled();
+        // Verify the options were updated correctly
+        expect(onChangeMock).toHaveBeenCalledWith(
+            expect.objectContaining({
+                correctOptions: [
+                    {
+                        content: altImage,
+                    },
+                ],
+                options: [
+                    {
+                        content: altImage,
+                    },
+                ],
+            }),
+            undefined,
+        );
+    });
+
+    it("serializes content correctly", () => {
+        // Arrange
+        const onChangeMock = jest.fn();
+        const editorRef = React.createRef<OrdererEditor>();
+
+        const widgetOptions: PerseusOrdererWidgetOptions = {
+            // While technically an invalid state, it's helpful for emphasizing
+            // that serialize() handles the updating of options correctly
+            options: [],
+            correctOptions: [
+                {content: "Option 1", widgets: {}, images: {}},
+                {content: "Option 3", widgets: {}, images: {}},
+            ],
+            otherOptions: [{content: "Option 2", widgets: {}, images: {}}],
+            height: "normal",
+            layout: "horizontal",
+        };
+
+        render(
+            <OrdererEditor
+                ref={editorRef}
+                onChange={onChangeMock}
+                {...widgetOptions}
+            />,
+        );
+
+        // Act
+        const serialized = editorRef.current?.serialize();
+
+        // Assert
+        expect(serialized).toEqual({
+            options: [
+                {content: "Option 1"},
+                {content: "Option 2"},
+                {content: "Option 3"},
+            ],
+            correctOptions: [
+                {content: "Option 1", widgets: {}, images: {}},
+                {content: "Option 3", widgets: {}, images: {}},
+            ],
+            otherOptions: [{content: "Option 2", widgets: {}, images: {}}],
+            height: "normal",
+            layout: "horizontal",
+        });
+    });
+});
+
+describe("getUpdatedOptions", () => {
+    it("correctly updates correctOptions", () => {
+        // Arrange
+        const existingCorrect = [{content: "existing correct"}];
+        const existingOther = [{content: "existing other"}];
+        const newOptions = ["new option"];
+
+        // Act
+        const props = getUpdatedOptions(
+            existingCorrect,
+            existingOther,
+            "correctOptions",
+            newOptions,
+        );
+
+        // Assert
+        expect(props).toEqual({
+            correctOptions: [{content: "new option"}],
+            options: [{content: "existing other"}, {content: "new option"}],
+        });
+    });
+
+    it("correctly updates otherOptions", () => {
+        // Arrange
+        const existingCorrect = [{content: "existing correct"}];
+        const existingOther = [{content: "existing other"}];
+        const newOptions = ["new other"];
+
+        // Act
+        const props = getUpdatedOptions(
+            existingCorrect,
+            existingOther,
+            "otherOptions",
+            newOptions,
+        );
+
+        // Assert
+        expect(props).toEqual({
+            otherOptions: [{content: "new other"}],
+            options: [{content: "existing correct"}, {content: "new other"}],
+        });
+    });
+
+    it("sorts options correctly", () => {
+        // Arrange
+        const existingCorrect = [{content: "3"}, {content: "$b$"}];
+        const existingOther = [{content: "2"}, {content: "1"}];
+        const newOptions = ["2", "a"];
+
+        // Act
+        const props = getUpdatedOptions(
+            existingCorrect,
+            existingOther,
+            "otherOptions",
+            newOptions,
+        );
+
+        // Assert
+        // Sort order should be:
+        // 1. Numbers (2, 3)
+        // 2. $tex$ without numbers ($b$)
+        // 3. Everything else (a)
+        expect(props.options).toEqual([
+            {content: "2"},
+            {content: "3"},
+            {content: "$b$"},
+            {content: "a"},
+        ]);
+    });
+
+    it("removes duplicate options", () => {
+        // Arrange
+        const existingCorrect = [{content: "duplicate"}, {content: "unique"}];
+        const existingOther = [{content: "duplicate"}];
+        const newOptions = ["duplicate"];
+
+        // Act
+        const props = getUpdatedOptions(
+            existingCorrect,
+            existingOther,
+            "otherOptions",
+            newOptions,
+        );
+
+        // Assert
+        expect(props.options).toEqual([
+            {content: "duplicate"},
+            {content: "unique"},
+        ]);
+    });
+
+    it("filters out empty strings", () => {
+        // Arrange
+        const existingCorrect = [{content: ""}, {content: "existing"}];
+        const existingOther = [{content: ""}];
+        const newOptions = ["", "valid"];
+
+        // Act
+        const props = getUpdatedOptions(
+            existingCorrect,
+            existingOther,
+            "otherOptions",
+            newOptions,
+        );
+
+        // Assert
+        expect(props.options).toEqual([
+            {content: "existing"},
+            {content: "valid"},
+        ]);
+    });
+});
diff --git a/packages/perseus-editor/src/widgets/orderer-editor.tsx b/packages/perseus-editor/src/widgets/orderer-editor.tsx
index 68d69362cd5..75a826e9ccf 100644
--- a/packages/perseus-editor/src/widgets/orderer-editor.tsx
+++ b/packages/perseus-editor/src/widgets/orderer-editor.tsx
@@ -18,6 +18,65 @@ const VERTICAL = "vertical";
 
 type Props = any;
 
+export const getUpdatedOptions = (
+    correctOptions: Array<{content: string}>,
+    otherOptions: Array<{content: string}>,
+    whichOptions?: string,
+    options?: string[],
+): Record<string, any> => {
+    // Update the changed options by mapping the options to an array of objects with a content property
+    const props: Record<string, any> = {};
+    if (whichOptions && options !== undefined) {
+        // eslint-disable-next-line functional/immutable-data
+        props[whichOptions] = options.map((option) => ({
+            content: option,
+        }));
+    }
+
+    // Get content from correctOptions (either updated or existing)
+    const correctOptionsToUse =
+        whichOptions === "correctOptions"
+            ? props.correctOptions
+            : correctOptions;
+
+    // Get content from otherOptions (either updated or existing)
+    const otherOptionsToUse =
+        whichOptions === "otherOptions" ? props.otherOptions : otherOptions;
+
+    // Combine all content items
+    const allOptions = [...correctOptionsToUse, ...otherOptionsToUse];
+
+    // Get unique content items
+    // eslint-disable-next-line functional/immutable-data
+    const updatedOptions = [...new Set(allOptions.map((item) => item.content))]
+        // filter out empty strings
+        .filter((content) => content !== "")
+        // Alphabetical sort
+        .sort()
+        // Category sort
+        .sort((a, b) => {
+            const getCategoryScore = (content) => {
+                // 1. Any content that contains numbers
+                if (/\d/.test(content)) {
+                    return 0;
+                }
+                // 2. $tex$ or variables without any numbers
+                if (/^\$?[a-zA-Z]+\$?$/.test(content)) {
+                    return 2;
+                }
+                // 3. Everything else
+                return 1;
+            };
+            return getCategoryScore(a) - getCategoryScore(b);
+        })
+        .map((content) => ({content}));
+
+    return {
+        ...props,
+        options: updatedOptions,
+    };
+};
+
 class OrdererEditor extends React.Component<Props> {
     static propTypes = {
         correctOptions: PropTypes.array,
@@ -37,11 +96,14 @@ class OrdererEditor extends React.Component<Props> {
         arg2: any,
         arg3: any,
     ) => any = (whichOptions, options, cb) => {
-        const props: Record<string, any> = {};
-        props[whichOptions] = _.map(options, function (option) {
-            return {content: option};
-        });
-        this.props.onChange(props, cb);
+        const updatedOptions = getUpdatedOptions(
+            this.props.correctOptions || [],
+            this.props.otherOptions || [],
+            whichOptions,
+            options,
+        );
+
+        this.props.onChange(updatedOptions, cb);
     };
 
     onLayoutChange: (arg1: React.ChangeEvent<HTMLInputElement>) => void = (
@@ -60,26 +122,10 @@ class OrdererEditor extends React.Component<Props> {
         // We combine the correct answer and the other cards by merging them,
         // removing duplicates and empty cards, and sorting them into
         // categories based on their content
-        const options = _.chain(_.pluck(this.props.correctOptions, "content"))
-            .union(_.pluck(this.props.otherOptions, "content"))
-            .uniq()
-            .reject(function (content) {
-                return content === "";
-            })
-            .sort()
-            .sortBy(function (content) {
-                if (/\d/.test(content)) {
-                    return 0;
-                }
-                if (/^\$?[a-zA-Z]+\$?$/.test(content)) {
-                    return 2;
-                }
-                return 1;
-            })
-            .map(function (content) {
-                return {content: content};
-            })
-            .value();
+        const {options} = getUpdatedOptions(
+            this.props.correctOptions || [],
+            this.props.otherOptions || [],
+        );
 
         return {
             options: options,


Reviews:
user: login: handeyeco
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T19:11:04Z
user: login: SonicScrewdriver
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T19:23:46Z

Review comments:
diff_hunk: @@ -0,0 +1,147 @@
+import {Dependencies} from "@khanacademy/perseus";
+import {render, screen} from "@testing-library/react";
+import {userEvent as userEventLib} from "@testing-library/user-event";
+import * as React from "react";
+
+import {testDependencies} from "../../../../../testing/test-dependencies";
+import OrdererEditor from "../orderer-editor";
+
+import type {PerseusOrdererWidgetOptions} from "@khanacademy/perseus-core";
+import type {UserEvent} from "@testing-library/user-event";
+
+describe("OrdererEditor", () => {
+    let userEvent: UserEvent;
+    beforeEach(() => {
+        userEvent = userEventLib.setup({
+            advanceTimers: jest.advanceTimersByTime,
+        });
+
+        jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
+            testDependencies,
+        );
+    });
+
+    it("updates state correctly", async () => {
+        const onChangeMock = jest.fn();
+        const editorRef = React.createRef<OrdererEditor>();
path: packages/perseus-editor/src/widgets/__tests__/orderer-editor.test.tsx
user: login: handeyeco
body: I don't think you need this in either test
created_at: 2025-05-07T17:50:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,147 @@
+import {Dependencies} from "@khanacademy/perseus";
+import {render, screen} from "@testing-library/react";
+import {userEvent as userEventLib} from "@testing-library/user-event";
+import * as React from "react";
+
+import {testDependencies} from "../../../../../testing/test-dependencies";
+import OrdererEditor from "../orderer-editor";
+
+import type {PerseusOrdererWidgetOptions} from "@khanacademy/perseus-core";
+import type {UserEvent} from "@testing-library/user-event";
+
+describe("OrdererEditor", () => {
+    let userEvent: UserEvent;
+    beforeEach(() => {
+        userEvent = userEventLib.setup({
+            advanceTimers: jest.advanceTimersByTime,
+        });
+
+        jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
+            testDependencies,
+        );
+    });
+
+    it("updates state correctly", async () => {
+        const onChangeMock = jest.fn();
+        const editorRef = React.createRef<OrdererEditor>();
+
+        const imageUrl =
+            "https://ka-perseus-graphie.s3.amazonaws.com/6e68574abb61d2b4a12644d777006fcfa8a73cff.png";
+        const noAltImage = `![](${imageUrl})`;
+        const altImage = `![a fox](${imageUrl})`;
+        // "[" is a special char in UserEvent, so I need to do it twice when typing
+        const altImageTyped = `![[a fox](${imageUrl})`;
+        const startWidgetOptions: PerseusOrdererWidgetOptions = {
+            options: [
+                {
+                    content: noAltImage,
+                    widgets: {},
+                    images: {},
+                },
+            ],
+            correctOptions: [
+                {
+                    content: noAltImage,
+                    widgets: {},
+                    images: {},
+                },
+            ],
+            otherOptions: [],
+            height: "normal",
+            layout: "horizontal",
+        };
+
+        render(
+            <OrdererEditor
+                ref={editorRef}
+                onChange={onChangeMock}
+                {...startWidgetOptions}
+            />,
+        );
+
+        const input = screen.getByDisplayValue(noAltImage);
+        await userEvent.clear(input);
+        await userEvent.type(input, altImageTyped);
+
+        expect(onChangeMock).toHaveBeenCalled();
+        // Verify the options were updated correctly
+        expect(onChangeMock).toHaveBeenCalledWith(
+            expect.objectContaining({
+                correctOptions: [
+                    {
+                        content: altImage,
+                    },
+                ],
+                options: [
+                    {
+                        content: altImage,
+                    },
+                ],
+            }),
+            undefined,
+        );
+    });
+
+    it("sorts options correctly", async () => {
+        const onChangeMock = jest.fn();
+        const editorRef = React.createRef<OrdererEditor>();
+
+        const startWidgetOptions: PerseusOrdererWidgetOptions = {
+            correctOptions: [
+                {
+                    content: "3",
+                    widgets: {},
+                    images: {},
+                },
+                {
+                    content: "$b$",
+                    widgets: {},
+                    images: {},
+                },
+            ],
+            otherOptions: [
+                {
+                    content: "2",
+                    widgets: {},
+                    images: {},
+                },
+                {
+                    content: "1",
+                    widgets: {},
+                    images: {},
+                },
+            ],
+            height: "normal",
+            layout: "horizontal",
+            options: [],
path: packages/perseus-editor/src/widgets/__tests__/orderer-editor.test.tsx
user: login: handeyeco
body: I don't know that it matters, but I think this is an invalid state for Orderer widget options to be in. `options` should be the combination of `correctOptions` and `otherOptions`; it wouldn't be empty in this scenario.
created_at: 2025-05-07T17:52:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -41,6 +41,50 @@ class OrdererEditor extends React.Component<Props> {
         props[whichOptions] = _.map(options, function (option) {
             return {content: option};
         });
+
+        // Get content from correctOptions (either updated or existing)
+        const correctOptionsToUse =
path: packages/perseus-editor/src/widgets/orderer-editor.tsx
user: login: handeyeco
body: `onOptionsChange` seems to be doing a lot and it's a little tricky to test because it's attached to a React component. Also now the general concept is duplicated between this function and `serialize`.

What if we extracted most of the logic into a helper (could even stay in this file) so that we can have more complete test coverage without having to muck about with React or PerseusItems. Then we could do something like:

```TS
    onOptionsChange: (
        arg1: "correctOptions" | "otherOptions",
        arg2: any,
        arg3: any,
    ) => any = (whichOptions, options, cb) => {
        const next = generateNextOptions(
            whichOptions,
            options,
            this.props.correctOptions,
            this.props.otherOptions
        )
        this.props.onChange(next, cb);
    };
```

Benefits:
1. Cleaner component
2. Less duplication
3. Easier to test

Not a blocker, just a suggestion.
created_at: 2025-05-07T19:10:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -41,6 +41,50 @@ class OrdererEditor extends React.Component<Props> {
         props[whichOptions] = _.map(options, function (option) {
             return {content: option};
         });
+
+        // Get content from correctOptions (either updated or existing)
+        const correctOptionsToUse =
path: packages/perseus-editor/src/widgets/orderer-editor.tsx
user: login: SonicScrewdriver
body: Makes perfect sense to me, I'll get right on it! 
created_at: 2025-05-08T19:23:46Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-06T21:48:14Z
body: **Size Change:** +154 B (+0.03%) 

**Total Size:** 467 kB

| Filename | Size | Change |
| :--- | :---: | :---: |
| `packages/perseus-editor/dist/es/index.js` | 88.6 kB | +154 B (+0.17%) |

<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-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/index.js` | 199 kB |
| `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-06T21:48:52Z
body: # npm Snapshot: Published

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

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

If you are working in Khan Academy's webapp, you can run the below command. 
If this is for backend code, change the static flag (-s) from y/yes to n/no.
```sh
./dev/tools/bump_perseus_version.js -s y -t PR2444
```
author_association: CONTRIBUTOR