Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
eca376c
feat: memoize the boundGetAsset function
sempostma Feb 8, 2026
854af21
feat: make parent ids stable
sempostma Feb 8, 2026
9f79c39
feat: make each child onChange callback stable and also made parentId…
sempostma Feb 8, 2026
c0b8ddc
feat: optimize editor control components and widgets
sempostma Feb 8, 2026
e4fdab6
feat: fixed the last violating unstable component
sempostma Feb 8, 2026
b22022d
fix: remove performance logging comments
sempostma Feb 8, 2026
e8176da
refactor: code formatting
sempostma Feb 8, 2026
030898f
Update packages/decap-cms-widget-object/src/ObjectControl.js
sempostma Feb 9, 2026
e50ce07
fix: resolver
sempostma Feb 9, 2026
047d97b
fix: code and memoization issues
sempostma Feb 9, 2026
031ac70
fix: bad memoization cache key
sempostma Feb 9, 2026
ce58957
fix: editor control issues
sempostma Feb 9, 2026
3a5e748
fix: typo on deprecation comment
sempostma Feb 9, 2026
e3d8cec
chore(deps-dev): bump axios from 1.13.5 to 1.15.0 (#7778)
dependabot[bot] Apr 13, 2026
9bdb539
chore(deps): bump path-to-regexp from 0.1.12 to 0.1.13 (#7769)
dependabot[bot] Apr 13, 2026
5577d3d
chore(deps): bump lodash-es from 4.17.23 to 4.18.1 (#7771)
dependabot[bot] Apr 13, 2026
12c7142
Add linked image support for richtext widget (#7779)
martinjagodic Apr 14, 2026
c98e722
feat: add break element to widget richtext (#7782)
martinjagodic Apr 15, 2026
2963430
Fix/propagate errors for i18n entries (#7773)
sempostma Apr 15, 2026
e562145
fix(frontmatter): improve duplicate frontmatter key error handling (#…
codeafridi Apr 15, 2026
8fab823
feat: remove HTML comments from pasted content (#7775)
yanthomasdev Apr 15, 2026
7dd2de7
perf: use Clipboard API (#7780)
florian-lefebvre Apr 15, 2026
ee3919b
feat: add config option to enable Signed-off-by in commit messages (#…
emersion Apr 15, 2026
71d01f1
chore(deps): bump dompurify from 3.3.3 to 3.4.0 (#7784)
dependabot[bot] Apr 16, 2026
009ce09
fix(markdown): extra whitespace before and after pasted content #7364…
hip3r Apr 16, 2026
f57da6f
chore(release): publish
martinjagodic Apr 16, 2026
e88f151
fix: add decap-cms-widget-richtext dependency
martinjagodic Apr 16, 2026
6e09a5a
fix: add decap-cms-widget-richtext dependency to package-lock
martinjagodic Apr 16, 2026
fe66365
chore(release): publish
martinjagodic Apr 16, 2026
b2614be
feat: allow special characters to pass through slugification (#6220)
mikestopcontinues Apr 17, 2026
641605f
chore: add `mdast-util-to-string` to direct deps of `decap-cms-widget…
fedetibaldo Apr 17, 2026
8d8ca25
chore(release): publish
martinjagodic Apr 17, 2026
09320d4
Fix(#7442): dynamic loading of codemirror language modes (#7443)
fgnass Apr 20, 2026
6f00066
fix: adhere to remark's tokenizer rules #7315 (#7444)
fgnass Apr 20, 2026
ea93dd5
fix(core): return full entry object from invokeEvent instead of just …
adbw-pge Apr 22, 2026
e1b4053
Update packages/decap-cms-core/src/components/Editor/EditorControlPan…
sempostma May 23, 2026
971bf90
Update packages/decap-cms-core/src/actions/media.ts
sempostma May 23, 2026
59cf237
Update packages/decap-cms-core/src/components/Editor/EditorControlPan…
sempostma May 23, 2026
5a4c351
fix: small refactor
sempostma May 23, 2026
9358b44
fix: package-lock
sempostma May 23, 2026
ed0b377
Merge branch 'main' into feature/optimize-editor-performance
sempostma May 23, 2026
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
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 12 additions & 11 deletions packages/decap-cms-core/src/actions/media.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isAbsolutePath } from 'decap-cms-lib-util';
import memoize from 'lodash/memoize';

import { createAssetProxy } from '../valueObjects/AssetProxy';
import { selectMediaFilePath } from '../reducers/entries';
Expand Down Expand Up @@ -80,18 +81,18 @@ const emptyAsset = createAssetProxy({
}),
});

export function boundGetAsset(
dispatch: ThunkDispatch<State, {}, AnyAction>,
collection: Collection,
entry: EntryMap,
) {
function bound(path: string, field: EntryField) {
const asset = dispatch(getAsset({ collection, entry, path, field }));
return asset;
}
export const boundGetAsset = memoize(
(dispatch: ThunkDispatch<State, {}, AnyAction>, collection: Collection, entry: EntryMap) => {
function bound(path: string, field: EntryField) {
const asset = dispatch(getAsset({ collection, entry, path, field }));
return asset;
}
return bound;
},
(_dispatch, _collection, entry) => entry,
);

return bound;
}
boundGetAsset.cache = new WeakMap();

export function getAsset({ collection, entry, path, field }: GetAssetArgs) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@ import { ClassNames, Global, css as coreCss } from '@emotion/react';
import styled from '@emotion/styled';
import partial from 'lodash/partial';
import uniqueId from 'lodash/uniqueId';
import memoize from 'lodash/memoize';
import { connect } from 'react-redux';
import { FieldLabel, colors, transitions, lengths, borders } from 'decap-cms-ui-default';
import ReactMarkdown from 'react-markdown';
import gfm from 'remark-gfm';

import { resolveWidget, getEditorComponents } from '../../../lib/registry';
import { clearFieldErrors, tryLoadEntry, validateMetaField } from '../../../actions/entries';
import { addAsset, boundGetAsset } from '../../../actions/media';
import { addAsset, getAsset } from '../../../actions/media';
import { selectIsLoadingAsset } from '../../../reducers/medias';
import { query, clearSearch } from '../../../actions/search';
import { store } from '../../../redux';
import {
openMediaLibrary,
removeInsertedMedia,
Expand Down Expand Up @@ -147,11 +149,11 @@ class EditorControl extends React.Component {
clearSearch: PropTypes.func.isRequired,
clearFieldErrors: PropTypes.func.isRequired,
loadEntry: PropTypes.func.isRequired,
getEntry: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
isEditorComponent: PropTypes.bool,
isNewEditorComponent: PropTypes.bool,
parentIds: PropTypes.arrayOf(PropTypes.string),
entry: ImmutablePropTypes.map.isRequired,
collection: ImmutablePropTypes.map.isRequired,
isDisabled: PropTypes.bool,
isHidden: PropTypes.bool,
Expand Down Expand Up @@ -187,18 +189,22 @@ class EditorControl extends React.Component {
return false;
};

onChange = (newValue, newMetadata) => {
this.props.onChange(this.props.field, newValue, newMetadata);
this.props.clearFieldErrors(this.uniqueFieldId); // We are deleting errors for this field only.
};

render() {
const {
value,
entry,
getEntry,
collection,
config,
field,
fieldsMetaData,
fieldsErrors,
mediaPaths,
boundGetAsset,
onChange,
openMediaLibrary,
clearMediaControl,
removeMediaControl,
Expand Down Expand Up @@ -308,18 +314,15 @@ class EditorControl extends React.Component {
${styleStrings.labelActive};
`}
controlComponent={widget.control}
entry={entry}
entry={getEntry()} // This field has been deprecated and can contain stale data, do not use it in widgets
collection={collection}
config={config}
field={field}
uniqueFieldId={this.uniqueFieldId}
value={value}
mediaPaths={mediaPaths}
metadata={metadata}
onChange={(newValue, newMetadata) => {
onChange(field, newValue, newMetadata);
clearFieldErrors(this.uniqueFieldId); // Видаляємо помилки лише для цього поля
}}
onChange={this.onChange}
onValidate={onValidate && partial(onValidate, this.uniqueFieldId)}
onOpenMediaLibrary={openMediaLibrary}
onClearMediaControl={clearMediaControl}
Expand All @@ -338,6 +341,7 @@ class EditorControl extends React.Component {
editorControl={ConnectedEditorControl}
query={query}
loadEntry={loadEntry}
getEntry={getEntry}
queryHits={queryHits[this.uniqueFieldId] || []}
clearSearch={clearSearch}
clearFieldErrors={clearFieldErrors}
Expand Down Expand Up @@ -385,32 +389,56 @@ class EditorControl extends React.Component {
}
}

function mapStateToProps(state) {
const { collections, entryDraft } = state;
const entry = entryDraft.get('entry');
const collection = collections.get(entryDraft.getIn(['entry', 'collection']));
const isLoadingAsset = selectIsLoadingAsset(state.medias);

async function loadEntry(collectionName, slug) {
const stable = {
loadEntry: async function stable_loadEntry(collectionName, slug) {
const state = store.getState();
const { collections } = state;
const targetCollection = collections.get(collectionName);
if (targetCollection) {
const loadedEntry = await tryLoadEntry(state, targetCollection, slug);
return loadedEntry;
} else {
throw new Error(`Can't find collection '${collectionName}'`);
}
}
},

// Will return the same function instance for the same collection.
validateMetaField: memoize(collection => {
return (field, value, t) => {
const state = store.getState();
validateMetaField(state, collection, field, value, t);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shouldn't this be returned as well?

Suggested change
validateMetaField(state, collection, field, value, t);
return validateMetaField(state, collection, field, value, t);

};
}),

getEntry() {
const state = store.getState();
return state.entryDraft.get('entry');
},

getBoundedAsset: memoize(collection => {
return (path, field) => {
const state = store.getState();
const entry = state.entryDraft.get('entry');
return store.dispatch(getAsset({ collection, entry, path, field }));
};
}),
};

function mapStateToProps(state) {
const { collections, entryDraft } = state;
const collection = collections.get(entryDraft.getIn(['entry', 'collection']));
const isLoadingAsset = selectIsLoadingAsset(state.medias);

return {
mediaPaths: state.mediaLibrary.get('controlMedia'),
isFetching: state.search.isFetching,
queryHits: state.search.queryHits,
config: state.config,
entry,
collection,
isLoadingAsset,
loadEntry,
validateMetaField: (field, value, t) => validateMetaField(state, collection, field, value, t),
getEntry: stable.getEntry,
loadEntry: stable.loadEntry,
validateMetaField: stable.validateMetaField(collection),
};
}

Expand All @@ -431,7 +459,7 @@ function mapDispatchToProps(dispatch) {
);
return {
...creators,
boundGetAsset: (collection, entry) => boundGetAsset(dispatch, collection, entry),
boundGetAsset: stable.getBoundedAsset,
};
}

Expand All @@ -440,7 +468,7 @@ function mergeProps(stateProps, dispatchProps, ownProps) {
...stateProps,
...dispatchProps,
...ownProps,
boundGetAsset: dispatchProps.boundGetAsset(stateProps.collection, stateProps.entry),
boundGetAsset: dispatchProps.boundGetAsset(stateProps.collection),
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import memoize from 'lodash/memoize';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
Expand Down Expand Up @@ -107,9 +108,9 @@ export default class ControlPane extends React.Component {
this.childRefs[name] = wrappedControl;
};

getControlRef = field => wrappedControl => {
getControlRef = memoize(field => wrappedControl => {
this.controlRef(field, wrappedControl);
};
});

handleLocaleChange = val => {
this.setState({ selectedLocale: val });
Expand Down Expand Up @@ -179,9 +180,37 @@ export default class ControlPane extends React.Component {
}
}

getI18n() {
const { collection } = this.props;
const { locales, defaultLocale } = getI18nInfo(collection);
const locale = this.state.selectedLocale;
return (
locales && {
currentLocale: locale,
locales,
defaultLocale,
}
);
}

onChange = (field, newValue, newMetadata) => {
this.props.onChange(field, newValue, newMetadata, this.getI18n());
};

isFieldDuplicate = field => {
const locale = this.state.selectedLocale;
const { defaultLocale } = getI18nInfo(this.props.collection);
return isFieldDuplicate(field, locale, defaultLocale);
};

isFieldHidden = field => {
const locale = this.state.selectedLocale;
const { defaultLocale } = getI18nInfo(this.props.collection);
return isFieldHidden(field, locale, defaultLocale);
};

render() {
const { collection, entry, fields, fieldsMetaData, fieldsErrors, onChange, onValidate, t } =
this.props;
const { collection, entry, fields, fieldsMetaData, fieldsErrors, onValidate, t } = this.props;

if (!collection || !fields) {
return null;
Expand Down Expand Up @@ -237,17 +266,15 @@ export default class ControlPane extends React.Component {
})}
fieldsMetaData={fieldsMetaData}
fieldsErrors={fieldsErrors}
onChange={(field, newValue, newMetadata) => {
onChange(field, newValue, newMetadata, i18n);
}}
onChange={this.onChange}
onValidate={onValidate}
controlRef={this.getControlRef(field)}
entry={entry}
// entry={entry} For compatibility with existing controls, we pass the (stale) entry down to the widget.
collection={collection}
isDisabled={isDuplicate}
isHidden={isHidden}
isFieldDuplicate={field => isFieldDuplicate(field, locale, defaultLocale)}
isFieldHidden={field => isFieldHidden(field, locale, defaultLocale)}
isFieldDuplicate={this.isFieldDuplicate}
isFieldHidden={this.isFieldHidden}
locale={locale}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ export default class Widget extends Component {
onValidateObject: PropTypes.func,
isEditorComponent: PropTypes.bool,
isNewEditorComponent: PropTypes.bool,
/**
* @deprecated Every update creates a new entry, passing a live value down is too expensive. Use the getEntry callback instead or get the value from the store directly in the widget via `useSelector` or `connect`.
*/
entry: ImmutablePropTypes.map.isRequired,
getEntry: PropTypes.func.isRequired,
isDisabled: PropTypes.bool,
isFieldDuplicate: PropTypes.func,
isFieldHidden: PropTypes.func,
Expand Down Expand Up @@ -281,7 +285,8 @@ export default class Widget extends Component {
render() {
const {
controlComponent,
entry,
entry, // TODO: Remove this prop in favor of getEntry
getEntry,
collection,
config,
field,
Expand Down Expand Up @@ -329,7 +334,8 @@ export default class Widget extends Component {
} = this.props;

return React.createElement(controlComponent, {
entry,
entry, // TODO: Remove this deprecated prop in favor of getEntry
getEntry,
collection,
config,
field,
Expand Down
20 changes: 17 additions & 3 deletions packages/decap-cms-widget-list/src/ListControl.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { css, ClassNames } from '@emotion/react';
import { List, Map, fromJS } from 'immutable';
import partial from 'lodash/partial';
import isEmpty from 'lodash/isEmpty';
import memoize from 'lodash/memoize';
import uniqueId from 'lodash/uniqueId';
import { v4 as uuid } from 'uuid';
import DecapCmsWidgetObject from 'decap-cms-widget-object';
Expand Down Expand Up @@ -256,10 +257,18 @@ export default class ListControl extends React.Component {

uniqueFieldId = uniqueId(`${this.props.field.get('name')}-field-`);
/**
* Old comment:
*
* Always update so that each nested widget has the option to update. This is
* required because ControlHOC provides a default `shouldComponentUpdate`
* which only updates if the value changes, but every widget must be allowed
* to override this.
*
* New comment:
*
* Each Widget is wrapped with EditorControl which already tries to update every time.
* Is there a specific reason we need to always rerender the list?
* This seems overkill.
*/
shouldComponentUpdate() {
return true;
Expand Down Expand Up @@ -419,7 +428,7 @@ export default class ListControl extends React.Component {
*/
getObjectValue = idx => this.props.value.get(idx) || Map();

handleChangeFor(index) {
handleChangeFor = memoize(index => {
return (f, newValue, newMetadata) => {
const { value, metadata, onChange, field } = this.props;
const collectionName = field.get('name');
Expand All @@ -435,7 +444,7 @@ export default class ListControl extends React.Component {
};
onChange(value.set(index, newObjectValue), parsedMetadata);
};
}
});

handleRemove = (index, event) => {
event.preventDefault();
Expand Down Expand Up @@ -630,6 +639,11 @@ export default class ListControl extends React.Component {
}
}

getStableParentIds = memoize(
(parentIds, forID, key) => [...parentIds, forID, key],
(parentIds, forID, key) => JSON.stringify([...parentIds, forID, key]),
);

// eslint-disable-next-line react/display-name
renderItem = (item, index) => {
const {
Expand Down Expand Up @@ -714,7 +728,7 @@ export default class ListControl extends React.Component {
collapsed={collapsed}
data-testid={`object-control-${key}`}
hasError={hasError}
parentIds={[...parentIds, forID, key]}
parentIds={this.getStableParentIds(parentIds, forID, key)}
/>
)}
</ClassNames>
Expand Down
Loading
Loading