Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(ui): handle React 18 batching for "Submit" button on details page. Fixes #13453 #13593

Merged
merged 7 commits into from
Oct 1, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import {NotificationType} from 'argo-ui/src/components/notifications/notificatio
import {Page} from 'argo-ui/src/components/page/page';
import {SlidingPanel} from 'argo-ui/src/components/sliding-panel/sliding-panel';
import * as React from 'react';
import {useContext, useEffect, useState} from 'react';
import {useContext, useEffect, useMemo, useState} from 'react';
import {RouteComponentProps} from 'react-router';

import * as models from '../../models';
import {ClusterWorkflowTemplate, Workflow} from '../../models';
import {uiUrl} from '../shared/base';
import {ErrorNotice} from '../shared/components/error-notice';
import {Loading} from '../shared/components/loading';
import {isEqual} from '../shared/components/object-parser';
import {useCollectEvent} from '../shared/use-collect-event';
import {ZeroState} from '../shared/components/zero-state';
import {Context} from '../shared/context';
Expand Down Expand Up @@ -37,7 +38,14 @@ export function ClusterWorkflowTemplateDetails({history, location, match}: Route

const [error, setError] = useState<Error>();
const [template, setTemplate] = useState<ClusterWorkflowTemplate>();
const [edited, setEdited] = useState(false);
const [initialTemplate, setInitialTemplate] = useState<ClusterWorkflowTemplate>();

const edited = useMemo(() => !isEqual(template, initialTemplate), [template, initialTemplate]);

function resetTemplate(template: ClusterWorkflowTemplate) {
setTemplate(template);
setInitialTemplate(template);
}

useEffect(
useQueryParams(history, p => {
Expand All @@ -47,7 +55,6 @@ export function ClusterWorkflowTemplateDetails({history, location, match}: Route
[history]
);

useEffect(() => setEdited(true), [template]);
Copy link
Contributor

Choose a reason for hiding this comment

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

Was thinking about the useMemo's dependencies list, and I'm thinking that we could simplify it even further if we only wanted to match this logic: given that the dependencies are object refs, this is pure ref equality, so we could technically simplify edited to template !== initialTemplate.

The current memoization logic is almost that, just that when the refs are equal, it will do the JSON comparison. But because we use setTemplate on any edit, I think it is effectively exactly the same. So I'm pretty sure the simplification would be equivalent.

What do you think?

(something something "react compiler" something something "dependencies list are too easy to make mistakes with" something something)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I tried that initially, but there's a couple problems with relying on object equality:

  1. If you make an edit, then undo that edit, edited will remain true.
  2. If you click "Update", edited will remain true. I think this is happening because of how the <ObjectEditor> serializes/deserializes the value. When "Update" is fired, the <ObjectEditor> component will be re-rendered with the new value, which gets serialized here:
    const [text, setText] = useState<string>(stringify(value, lang));

    Then, the onChange handler is fired (which is bound to setTemplate()), which parses it back to an object:
    Even though the object is going to have the same properties, it's still considered distinct.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, that's correct; this would've been the previous behavior (prior to this bug) as well if I'm not mistaken.
So while the JSON equality is more accurate than the previous behavior, it does have a performance trade-off, which is perhaps not worthwhile solely for the sake of UI button accuracy.

just that when the refs are equal, it will do the JSON comparison. But because we use setTemplate on any edit,

I did make a mistake in my previous statement^ though, "equal" -> "unequal". Meaning the JSON comparison will pretty much happen on every change, so the useMemo is effectively not used as the refs are never equal.

Since this is an input box that is latency sensitive, I'm thinking that simplifying it to the previous behavior, even if it's not entirely accurate, would be worthwhile compared to a JSON comparison on each change, as the useMemo is effectively ignored.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I checked, and on the main branch, the stringify() function is called 4 times for every change made in <ObjectEditor>:

  1. const [text, setText] = useState<string>(stringify(value, lang));
  2. useEffect(() => setText(stringify(value, lang)), [value]);
  3. useEffect(() => setText(stringify(value, lang)), [value]);
    (again)
  4. const editorText = stringify(parse(editor.current.editor.getValue()), lang);

It'd surprise me if adding a couple more calls would have a significant performance impact, but you're right that making performance worse might not be worth fixing this bug. I pushed 24c0206 to delete the isEqual() logic and go back to direct object comparisons.

To fix this without affecting performance, I think we need to refactor <ObjectEditor> to only work with strings, not parsed objects. Parsing will have to be lifted up to the details component (<ClusterWorkflowTemplateDetails> in this case), which can then easily compare strings to see what's changed. That's not hard, but it does involve quite a lot of changes, so it's probably best done in a separate PR.

Copy link
Contributor

@agilgur5 agilgur5 Sep 30, 2024

Choose a reason for hiding this comment

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

Yea that first one could be made into a callback function instead very simply. The last one has that comment around it regarding whitespace

// we ONLY want to change the text, if the normalized version has changed, this prevents white-space changes
// from resulting in a significant change

I've optimized the rest of the UI by code-splitting this component before in #12150 so this component is a bit of a hassle 😕

but you're right that making performance worse might not be worth fixing this bug.

yea the buttons working even though there has been no semantic change is pretty tiny and fairly harmless. I'd argue it's maybe even worth further simplifying if possible just to remove more complex code.

Otherwise if we wanted to keep a bunch of features while being latency sensitive, a separate thread / web worker might be the way to go to keep the UI thread light.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here's a POC of what I was suggesting: main...MasonM:argo-workflows:poc-fix-13453

The <ObjectEditorPlain> component is a copy of <ObjectEditor> for POC purposes. The parsing and language handling code has been lifted up to the details component, where it's encapsulated in the useEditableObject() hook.

This completely eliminates all the unnecessary calls to stringify() when there's a change, and also fixes the bugs with the "Update" button. It does call parse() once on a change, but that's an improvement from the main branch, where it's called twice.

If that looks good to you, I can clean that up and enter a PR.

useEffect(() => {
history.push(historyUrl('cluster-workflow-templates/{name}', {name, sidePanel, tab}));
}, [name, sidePanel, tab]);
Expand All @@ -56,8 +63,7 @@ export function ClusterWorkflowTemplateDetails({history, location, match}: Route
(async () => {
try {
const newTemplate = await services.clusterWorkflowTemplate.get(name);
setTemplate(newTemplate);
setEdited(false); // set back to false
resetTemplate(newTemplate);
setError(null);
} catch (err) {
setError(err);
Expand Down Expand Up @@ -106,15 +112,14 @@ export function ClusterWorkflowTemplateDetails({history, location, match}: Route
action: () => {
services.clusterWorkflowTemplate
.update(template, name)
.then(setTemplate)
.then(resetTemplate)
.then(() =>
notifications.show({
content: 'Updated',
type: NotificationType.Success
})
)
.then(() => setError(null))
.then(() => setEdited(false))
.catch(setError);
}
},
Expand Down
26 changes: 14 additions & 12 deletions ui/src/app/cron-workflows/cron-workflow-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import {NotificationType} from 'argo-ui/src/components/notifications/notificatio
import {Page} from 'argo-ui/src/components/page/page';
import {SlidingPanel} from 'argo-ui/src/components/sliding-panel/sliding-panel';
import * as React from 'react';
import {useContext, useEffect, useState} from 'react';
import {useContext, useEffect, useMemo, useState} from 'react';
import {RouteComponentProps} from 'react-router';

import * as models from '../../models';
import {CronWorkflow, Link, Workflow} from '../../models';
import {uiUrl} from '../shared/base';
import {ErrorNotice} from '../shared/components/error-notice';
import {openLinkWithKey} from '../shared/components/links';
import {isEqual} from '../shared/components/object-parser';
import {Loading} from '../shared/components/loading';
import {useCollectEvent} from '../shared/use-collect-event';
import {ZeroState} from '../shared/components/zero-state';
Expand All @@ -36,9 +37,16 @@ export function CronWorkflowDetails({match, location, history}: RouteComponentPr
const [columns, setColumns] = useState<models.Column[]>([]);

const [cronWorkflow, setCronWorkflow] = useState<CronWorkflow>();
const [edited, setEdited] = useState(false);
const [initialCronWorkflow, setInitialCronWorkflow] = useState<CronWorkflow>();
agilgur5 marked this conversation as resolved.
Show resolved Hide resolved
const [error, setError] = useState<Error>();

const edited = useMemo(() => !isEqual(cronWorkflow, initialCronWorkflow), [cronWorkflow, initialCronWorkflow]);

function resetCronWorkflow(cronWorkflow: CronWorkflow) {
setCronWorkflow(cronWorkflow);
setInitialCronWorkflow(cronWorkflow);
}

useEffect(
useQueryParams(history, p => {
setSidePanel(p.get('sidePanel'));
Expand All @@ -63,14 +71,11 @@ export function CronWorkflowDetails({match, location, history}: RouteComponentPr
useEffect(() => {
services.cronWorkflows
.get(name, namespace)
.then(setCronWorkflow)
.then(() => setEdited(false))
.then(resetCronWorkflow)
.then(() => setError(null))
.catch(setError);
}, [namespace, name]);

useEffect(() => setEdited(true), [cronWorkflow]);

useEffect(() => {
(async () => {
const workflowList = await services.workflows.list(namespace, null, [`${models.labels.cronWorkflow}=${name}`], {limit: 50});
Expand All @@ -91,8 +96,7 @@ export function CronWorkflowDetails({match, location, history}: RouteComponentPr
action: () =>
services.cronWorkflows
.suspend(name, namespace)
.then(setCronWorkflow)
.then(() => setEdited(false))
.then(resetCronWorkflow)
.then(() => setError(null))
.catch(setError),
disabled: !cronWorkflow || edited
Expand All @@ -103,8 +107,7 @@ export function CronWorkflowDetails({match, location, history}: RouteComponentPr
action: () =>
services.cronWorkflows
.resume(name, namespace)
.then(setCronWorkflow)
.then(() => setEdited(false))
.then(resetCronWorkflow)
.then(() => setError(null))
.catch(setError),
disabled: !cronWorkflow || !cronWorkflow.spec.suspend || edited
Expand Down Expand Up @@ -142,10 +145,9 @@ export function CronWorkflowDetails({match, location, history}: RouteComponentPr
cronWorkflow.metadata.namespace
)
)
.then(setCronWorkflow)
.then(resetCronWorkflow)
.then(() => notifications.show({content: 'Updated', type: NotificationType.Success}))
.then(() => setError(null))
.then(() => setEdited(false))
.catch(setError);
}
},
Expand Down
20 changes: 12 additions & 8 deletions ui/src/app/event-sources/event-source-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import {Page} from 'argo-ui/src/components/page/page';
import {SlidingPanel} from 'argo-ui/src/components/sliding-panel/sliding-panel';
import {Tabs} from 'argo-ui/src/components/tabs/tabs';
import * as React from 'react';
import {useContext, useEffect, useState} from 'react';
import {useContext, useEffect, useMemo, useState} from 'react';
import {RouteComponentProps} from 'react-router';

import {EventSource} from '../../models';
import {ID} from '../event-flow/id';
import {uiUrl} from '../shared/base';
import {ErrorNotice} from '../shared/components/error-notice';
import {Loading} from '../shared/components/loading';
import {isEqual} from '../shared/components/object-parser';
import {useCollectEvent} from '../shared/use-collect-event';
import {Context} from '../shared/context';
import {historyUrl} from '../shared/history';
Expand Down Expand Up @@ -52,9 +53,16 @@ export function EventSourceDetails({history, location, match}: RouteComponentPro
[namespace, name, tab, selectedNode]
);

const [edited, setEdited] = useState(false);
const [error, setError] = useState<Error>();
const [eventSource, setEventSource] = useState<EventSource>();
const [initialEventSource, setInitialEventSource] = useState<EventSource>();

const edited = useMemo(() => !isEqual(eventSource, initialEventSource), [eventSource, initialEventSource]);

function resetEventSource(eventSource: EventSource) {
setEventSource(eventSource);
setInitialEventSource(eventSource);
}

const selected = (() => {
if (!selectedNode) {
Expand All @@ -69,17 +77,14 @@ export function EventSourceDetails({history, location, match}: RouteComponentPro
(async () => {
try {
const newEventSource = await services.eventSource.get(name, namespace);
setEventSource(newEventSource);
setEdited(false); // set back to false
resetEventSource(newEventSource);
setError(null);
} catch (err) {
setError(err);
}
})();
}, [name, namespace]);

useEffect(() => setEdited(true), [eventSource]);

useCollectEvent('openedEventSourceDetails');

return (
Expand All @@ -100,14 +105,13 @@ export function EventSourceDetails({history, location, match}: RouteComponentPro
action: () =>
services.eventSource
.update(eventSource, name, namespace)
.then(setEventSource)
.then(resetEventSource)
.then(() =>
notifications.show({
content: 'Updated',
type: NotificationType.Success
})
)
.then(() => setEdited(false))
.then(() => setError(null))
.catch(setError)
},
Expand Down
20 changes: 12 additions & 8 deletions ui/src/app/sensors/sensor-details.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import {NotificationType} from 'argo-ui/src/components/notifications/notifications';
import {Page} from 'argo-ui/src/components/page/page';
import * as React from 'react';
import {useContext, useEffect, useState} from 'react';
import {useContext, useEffect, useMemo, useState} from 'react';
import {RouteComponentProps} from 'react-router';

import {Sensor} from '../../models';
import {ID} from '../event-flow/id';
import {uiUrl} from '../shared/base';
import {ErrorNotice} from '../shared/components/error-notice';
import {isEqual} from '../shared/components/object-parser';
import {Node} from '../shared/components/graph/types';
import {Loading} from '../shared/components/loading';
import {useCollectEvent} from '../shared/use-collect-event';
Expand All @@ -30,10 +31,17 @@ export function SensorDetails({match, location, history}: RouteComponentProps<an
const [tab, setTab] = useState<string>(queryParams.get('tab'));

const [sensor, setSensor] = useState<Sensor>();
const [edited, setEdited] = useState(false);
const [initialSensor, setInitialSensor] = useState<Sensor>();
const [selectedLogNode, setSelectedLogNode] = useState<Node>(queryParams.get('selectedLogNode'));
const [error, setError] = useState<Error>();

const edited = useMemo(() => !isEqual(sensor, initialSensor), [sensor, initialSensor]);

function resetSensor(sensor: Sensor) {
setSensor(sensor);
setInitialSensor(sensor);
}

useEffect(
useQueryParams(history, p => {
setTab(p.get('tab'));
Expand All @@ -58,14 +66,11 @@ export function SensorDetails({match, location, history}: RouteComponentProps<an
useEffect(() => {
services.sensor
.get(name, namespace)
.then(setSensor)
.then(() => setEdited(false))
.then(resetSensor)
.then(() => setError(null))
.catch(setError);
}, [namespace, name]);

useEffect(() => setEdited(true), [sensor]);

useCollectEvent('openedSensorDetails');

const selected = (() => {
Expand Down Expand Up @@ -94,9 +99,8 @@ export function SensorDetails({match, location, history}: RouteComponentProps<an
action: () =>
services.sensor
.update(sensor, namespace)
.then(setSensor)
.then(resetSensor)
.then(() => notifications.show({content: 'Updated', type: NotificationType.Success}))
.then(() => setEdited(false))
.then(() => setError(null))
.catch(setError)
},
Expand Down
17 changes: 17 additions & 0 deletions ui/src/app/shared/components/object-parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {isEqual} from './object-parser';

describe('isEqual', () => {
test('two objects', () => {
expect(isEqual({}, {})).toBe(true);
expect(isEqual({a: 1, b: 2}, {a: 1, b: 2})).toBe(true);
expect(isEqual({a: 1, b: 2}, {a: 1, b: 3})).toBe(false);
expect(isEqual({a: 1, b: 2}, {a: 1, c: 2})).toBe(false);
});

test('two strings', () => {
expect(isEqual('foo', 'foo')).toBe(true);
expect(isEqual('foo', 'bar')).toBe(false);
expect(isEqual('', 'bar')).toBe(false);
expect(isEqual('', '')).toBe(true);
});
});
5 changes: 5 additions & 0 deletions ui/src/app/shared/components/object-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,8 @@ export function parse<T>(value: string): T {
export function stringify<T>(value: T, type: string) {
return type === 'yaml' ? jsyaml.dump(value, {noRefs: true}) : JSON.stringify(value, null, ' ');
}

/** Use JSON.stringify() to compare given values */
export function isEqual<T>(value1: T, value2: T) {
agilgur5 marked this conversation as resolved.
Show resolved Hide resolved
return JSON.stringify(value1) === JSON.stringify(value2);
}
18 changes: 11 additions & 7 deletions ui/src/app/workflow-templates/workflow-template-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import {NotificationType} from 'argo-ui/src/components/notifications/notificatio
import {Page} from 'argo-ui/src/components/page/page';
import {SlidingPanel} from 'argo-ui/src/components/sliding-panel/sliding-panel';
import * as React from 'react';
import {useContext, useEffect, useState} from 'react';
import {useContext, useEffect, useMemo, useState} from 'react';
import {RouteComponentProps} from 'react-router';

import * as models from '../../models';
import {WorkflowTemplate, Workflow} from '../../models';
import {uiUrl} from '../shared/base';
import {ErrorNotice} from '../shared/components/error-notice';
import {Loading} from '../shared/components/loading';
import {isEqual} from '../shared/components/object-parser';
import {useCollectEvent} from '../shared/use-collect-event';
import {ZeroState} from '../shared/components/zero-state';
import {Context} from '../shared/context';
Expand All @@ -36,9 +37,14 @@ export function WorkflowTemplateDetails({history, location, match}: RouteCompone

const [template, setTemplate] = useState<WorkflowTemplate>();
const [error, setError] = useState<Error>();
const [edited, setEdited] = useState(false);
const [initialTemplate, setInitialTemplate] = useState<WorkflowTemplate>();

useEffect(() => setEdited(true), [template]);
const edited = useMemo(() => !isEqual(template, initialTemplate), [template, initialTemplate]);

function resetTemplate(template: WorkflowTemplate) {
setTemplate(template);
setInitialTemplate(template);
}

useEffect(
useQueryParams(history, p => {
Expand All @@ -64,8 +70,7 @@ export function WorkflowTemplateDetails({history, location, match}: RouteCompone
useEffect(() => {
services.workflowTemplate
.get(name, namespace)
.then(setTemplate)
.then(() => setEdited(false)) // set back to false
.then(resetTemplate)
.then(() => setError(null))
.catch(setError);
}, [name, namespace]);
Expand Down Expand Up @@ -106,9 +111,8 @@ export function WorkflowTemplateDetails({history, location, match}: RouteCompone
action: () =>
services.workflowTemplate
.update(template, name, namespace)
.then(setTemplate)
.then(resetTemplate)
.then(() => notifications.show({content: 'Updated', type: NotificationType.Success}))
.then(() => setEdited(false))
.then(() => setError(null))
.catch(setError)
},
Expand Down
Loading