Skip to content

Conversation

@mwiencek
Copy link
Member

@mwiencek mwiencek commented Aug 28, 2025

This was the very first React component written for MBS, all the way back in 2015, and requires significant refactoring in order to follow the Rules of React [1] and work properly with React v19 and new tools like react-compiler.

Since this effort was blocking the React v19 upgrade, I also included that upgrade in this PR to make sure the tests actually pass with React v19. (They did when I tried locally; no more bizarre "Maximum update depth exceeded" errors.)

Fixes MBS-11889
Fixes MBS-11521

[1] https://react.dev/reference/rules

Copy link
Member

@reosarevok reosarevok left a comment

Choose a reason for hiding this comment

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

Leaving some comments for now, but still I need to keep reviewing the main refactor commit and actually test this locally.

release != null &&
release.entityType === 'release' &&
!release.isNewEntity,
);
Copy link
Member

Choose a reason for hiding this comment

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

Why not use getSourceEntityData for this one?

Copy link
Member Author

Choose a reason for hiding this comment

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

I pushed some more changes to use getSourceEntityData here. But the previous reason is that this is a server component, and getSourceEntityData used to only work in the browser (because it used to call getCatalystContext, which doesn't work on the server).

result.push(type[sourceType]);
}
return result.sort();
return result;
Copy link
Member

Choose a reason for hiding this comment

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

I think I probably added this because of hitting some test failure? But maybe I'm being an idiot.

Copy link
Member Author

Choose a reason for hiding this comment

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

That's what the original commit implied, but they did seem to pass fine without it locally. If they fail on CI I'll add it back (but ideally sorting once at the end rather than in a loop).

Copy link
Contributor

Choose a reason for hiding this comment

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

I couldn’t find the exact reason for this sort call, but here is another hint about it: #2339 (comment)

[...allowedType].sort(),
),
selectedTypes.length === allowedType.length &&
(new Set(selectedTypes)).isSubsetOf(new Set(allowedType)),
Copy link
Member

Choose a reason for hiding this comment

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

I guess there's no "is equal" set property that could be used - "is equally long and a subset of" is a funny way of writing it, but probably not worse than set1.symmetricDifference(set2).size === 0 anyway

Copy link
Member Author

Choose a reason for hiding this comment

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

Agreed, I'm not sure why there's no builtin method for it. The advantage of isSubsetOf over symmetricDifference is just that it doesn't require constructing a new set. If we find more uses for it, we could just define our own utility.

@mwiencek mwiencek force-pushed the external-links-editor-refactor branch 3 times, most recently from 3ebeda6 to 0bec1f3 Compare August 29, 2025 02:30
@mwiencek mwiencek force-pushed the external-links-editor-refactor branch 2 times, most recently from 573e899 to ae0ba12 Compare September 2, 2025 16:33
@mwiencek mwiencek force-pushed the external-links-editor-refactor branch 3 times, most recently from ddcc760 to 78f0175 Compare October 8, 2025 14:12
@mwiencek mwiencek changed the title External links editor refactor MBS-11889, MBS-11521: External links editor refactor Oct 22, 2025
@mwiencek mwiencek force-pushed the external-links-editor-refactor branch from 78f0175 to 4e2bd7a Compare October 22, 2025 14:15
@mwiencek
Copy link
Member Author

Rebased on master (no changes besides fixing conflicts).

Copy link
Contributor

@yvanzo yvanzo left a comment

Choose a reason for hiding this comment

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

Reviewed up to the commit edff139 for now. It requires rebasing to resolve conflicts.

result.push(type[sourceType]);
}
return result.sort();
return result;
Copy link
Contributor

Choose a reason for hiding this comment

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

I couldn’t find the exact reason for this sort call, but here is another hint about it: #2339 (comment)

Copy link
Contributor

@yvanzo yvanzo left a comment

Choose a reason for hiding this comment

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

Reviewed up to the commit b07e166 for now. Previous comments remain unanswered.

Copy link
Member

@reosarevok reosarevok left a comment

Choose a reason for hiding this comment

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

Only got to ExternalLinks.js in the big commit, but had a few comments for now.

@mwiencek mwiencek force-pushed the external-links-editor-refactor branch 2 times, most recently from 54c27c4 to 28e1089 Compare February 3, 2026 21:13
@mwiencek
Copy link
Member Author

mwiencek commented Feb 3, 2026

I had to force-push twice because I rebased an old version of the branch the first time. The changes in the second push aren't actually new.

Copy link
Contributor

@yvanzo yvanzo left a comment

Choose a reason for hiding this comment

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

I reviewed answers and each commit but the big commit mentioned by @reosarevok as it should probably be more detailed or further split.

@reosarevok reosarevok added React PRs directly related with React conversion Refactoring Refactoring-only PRs (eslint fixes etc) labels Feb 6, 2026
Copy link
Member

@reosarevok reosarevok left a comment

Choose a reason for hiding this comment

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

A few comments today about ExternalLinkRelationship and related functions only.

Copy link
Member

@reosarevok reosarevok left a comment

Choose a reason for hiding this comment

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

A few more comments, will continue with URLInputPopover tomorrow.

Comment on lines 150 to 151
let hiddenInputsContainer = document.getElementById(
'external-links-editor-submission',
Copy link
Member

Choose a reason for hiding this comment

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

When does this already exist? Is it if submission was attempted and failed?

Copy link
Member Author

Choose a reason for hiding this comment

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

It shouldn't exist, since the submissionInProgress check should now guarantee this only runs once, though that didn't exist in the previous code. I'll add a comment, though I think it's fine to leave prepareExternalLinksHtmlFormSubmission how it is.

Comment on lines +175 to +179
if (state.links.size && hasSessionStorage) {
sessionStorageWrapper.set(
getSubmittedLinksKey(source),
JSON.stringify(compactEntityJson(state.links)),
);
Copy link
Member

Choose a reason for hiding this comment

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

This is what keeps the state in case of an error, right? In which case do we not hasSessionStorage, and what happens on error in that case?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, if sessionStorage isn't available, then no links (and no changes to existing links) will be restored.

The hasSessionStorage export references https://bugzilla.mozilla.org/show_bug.cgi?id=365772 in a comment, which is now fixed. You can also toggle dom.storage.enabled in Firefox, though I don't think it's common for anybody to do that.

Copy link
Member

Choose a reason for hiding this comment

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

Should we then drop that comment? So, basically, it should mostly always be available nowadays unless someone actively went out of their way to make it not available, right? Guess that's fine then.

Copy link
Member Author

@mwiencek mwiencek Feb 12, 2026

Choose a reason for hiding this comment

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

The comment is still useful for explaining why the code exists, but it could be updated if someone wants to research all of the current scenarios in which those statements could throw (I'm not volunteering for this at the moment :)).

if (elementToFocus) {
elementToFocus.focus();
}
dispatch({focus: '', type: 'set-focus'});
Copy link
Member

Choose a reason for hiding this comment

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

Does this then cause the same function to run again, but return immediately because of !selector?

Copy link
Member Author

Choose a reason for hiding this comment

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

Correct, it will cause the component to re-render, and any effects whose dependencies have changed will run again.

Copy link
Member

Choose a reason for hiding this comment

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

Is that actually wanted/useful in any way, or just a side-effect of us storing focus in state like this? (not that I mind if that's the most effective way otherwise, I was just a bit surprised because AFAICT we don't do focus in a similar way elsewhere).

Copy link
Member Author

Choose a reason for hiding this comment

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

It's not desirable for the component to re-render in this case, but not a big deal either. Being able to sync focus changes with specific state changes is a lot more useful than not.

}
}, [links]);

const submissionInProgress = React.useRef(false);
Copy link
Member

Choose a reason for hiding this comment

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

useRef here is so that we don't try to recalculate this in future renderings, and it stays false or true as pre-set, right? (I'm more used to refs used for passing around)

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, false is just the initial value. Whatever value you set is preserved between renders. That's only used to store data that doesn't affect the component return value. In this case it just prevents prepareExternalLinksHtmlFormSubmission from running multiple times.

}, [state]);

const linkElements = [];
let linkIndex = 0;
Copy link
Member

Choose a reason for hiding this comment

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

While here we don't useRef because we want it to start from 0 in every re-render rather than from whatever ++linkIndex below left it as, right?

Copy link
Member Author

Choose a reason for hiding this comment

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

Correct, linkIndex is being used to calculate isLastLink, so it has to start from 0.

@mwiencek mwiencek force-pushed the external-links-editor-refactor branch 2 times, most recently from c5b8938 to a02829b Compare February 9, 2026 23:35
Copy link
Member

@reosarevok reosarevok left a comment

Choose a reason for hiding this comment

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

Now checked up to state.js (rechecked some previous bits that have changed too).

Comment on lines 86 to 87
* If there are no applicable types, show types that aren't
* restricted to specific sites.
Copy link
Member

Choose a reason for hiding this comment

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

If there are no applicable autoselected types, show all types that aren't restricted to autoselected sites only?

Copy link
Member Author

Choose a reason for hiding this comment

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

Don't we only say autoselected when there's only one possible type? For example, Apple Music links are type restricted but not autoselected.

Copy link
Member

Choose a reason for hiding this comment

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

I guess that's fair. My main concern was that we could specify that "applicable" here means "guessed / restricted", so maybe If there are no restricted types for this URL, show all types that aren't restricted by URL?

Copy link
Member Author

Choose a reason for hiding this comment

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

Updated the comment

/>
) : null}
) : null}
<ExternalLinkAttributeDialog
Copy link
Member

Choose a reason for hiding this comment

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

I do wonder if this should be renamed - the attribute "video" is edited outside of the dialog, and entity credits are not attributes. Why not URLRelationshipPopover, or ExternalLinkRelationshipPopover? Since it's a popover and we use URLInputPopover for the other one.

Copy link
Member Author

Choose a reason for hiding this comment

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

Renamed to ExternalLinkRelationshipPopover

link,
(linkCtx) => {
const relationshipsCtx = linkCtx.get('relationships');
// Move focus to the next <select>.
Copy link
Member

Choose a reason for hiding this comment

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

This only moves the focus if it's a new rel, though, right? So it should be I guess:

// If a new relationship, drop it and move focus to the next <select>

And then below over toggleRemoveMatchingRelationships something like // If preexisting, toggle its removed value

Copy link
Member Author

Choose a reason for hiding this comment

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

If preexisting, toggle its removed value

That code is also run for new relationships (in which case toggleRemoveMatchingRelationships deletes the relationship).

Copy link
Member Author

Choose a reason for hiding this comment

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

Reworded the first comment

ctx,
link,
linkCtx => {
const cleanUrl = linkCtx.read().url.trim();
Copy link
Member

Choose a reason for hiding this comment

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

Calling this cleanUrl is a bit confusing since it hasn't gone through a cleanup, right? Why not trimmedUrl?

Copy link
Member Author

Choose a reason for hiding this comment

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

url is always cleaned-up, so cleanUrl makes sense to me. I'm not sure why the trim is needed (it should have been trimmed in handle-url-change), but might have been copied from the previous code somehow.

Copy link
Member

Choose a reason for hiding this comment

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

Oh, so this is not a raw URL but the cleaned one. I was confused because we set it as rawUrl below, but that's I guess making the clean URL the new raw URL. I'm still not sure if we want it to remember the originally entered URL until the edit is submitted or not. I think it would make sense, but I guess both options have their benefits (as I commented elsewhere, rn we do it one way for the popover and the other for the main editor, which does not seem intentional).

I am also not sure why we would need to trim it then.

updateLink(ctx, link, linkCtx => {
const pendingLink = linkCtx.read().urlPopoverLinkState ?? link;
const updatedPendingLink = mutate(pendingLink)
.set('rawUrl', rawUrl)
Copy link
Member

Choose a reason for hiding this comment

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

I'm confused by one thing here when testing. When I paste https://open.spotify.com/artist/1m9WPOccw8sizsVYUhSVjZ?333 (so, a link with whatever random param), it gets cleaned up automatically and the rawUrl is set to the cleaned up version (the parameter is dropped). But if I edit the link manually from the popover, and change it to https://open.spotify.com/artist/1m9WPOccw8sizsVYUhSVjZ?333 there, it still shows cleaned up, but the rawUrl does include the parameter. Is this intentional? I'm not sure which option is best but I'd kinda expect both to work the same way.

At the very least, we should probably trim it?

Copy link
Member Author

Choose a reason for hiding this comment

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

That seems like a bug (a benign one at least). I changed accept-url-input-popover to update the rawUrl too.

Copy link
Member

@reosarevok reosarevok left a comment

Choose a reason for hiding this comment

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

Looked through the last bits, still need to test it properly locally.

*
* If the relationship has no changes, then `x.originalState === x`.
* If this is a new relationship, `originalState` is `null`.
*/
Copy link
Member

Choose a reason for hiding this comment

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

Maybe a stupid question, but how is x.originalState === x if x contains originalState but x.originalState does not? From what I can tell in state.js, during initialization we do linkRelationship.originalState = linkRelationship which I expected means the originalState is equivalent to linkRelationship before this assignment?

Copy link
Member Author

Choose a reason for hiding this comment

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

Maybe a stupid question, but how is x.originalState === x if x contains originalState but x.originalState does not?

It's a circular reference, so x.originalState.originalState === x is also true.

From what I can tell in state.js, during initialization we do linkRelationship.originalState = linkRelationship which I expected means the originalState is equivalent to linkRelationship before this assignment?

Before the assignment, originalState is null (not sure if I got what you meant here).

Copy link
Member

Choose a reason for hiding this comment

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

I did not realize that it was a reference in that way. Ok, I guess it works then (I expect it's not actually storing the data recursively and it's reference magic).

Copy link
Member Author

Choose a reason for hiding this comment

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

Right, we don't make a copy of the object or anything, we just assign a reference to itself on an own property.

import ko from 'knockout';

import '../common/entity.js';
import '../external-links-editor/components/StandaloneExternalLinksEditor.js';
Copy link
Member

Choose a reason for hiding this comment

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

For clarification, this needs to be added for series but not other entities because series uses SeriesRelationshipEditor.js, right? (I expect that's something we expect to consolidate eventually since that file still uses jquery).

Copy link
Member Author

Choose a reason for hiding this comment

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

Indirectly I think, since this is normally included via root/forms/relationship-editor.tt -> relationship-editor.js.

* If there are no applicable types, show types that aren't
* restricted to specific sites.
*/
: !RESTRICTED_LINK_TYPES.includes(option.data.gid)
Copy link
Member

Choose a reason for hiding this comment

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

While testing I noticed that this still shows for example "cover art" for releases, but that immediately errors with "This relationship type is deprecated and should not be used." - can we add an extra check to skip deprecated types?

Copy link
Member Author

Choose a reason for hiding this comment

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

It filters out deprecated types now.

Copy link
Member Author

Choose a reason for hiding this comment

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

This causes the deprecated type test in t/selenium/External_Links_Editor.json5 to fail since it's no longer selectable. Do you think I should remove that test entirely?

It's only used by the external links editor.
 * Merges `getSourceEntityDataForRelationshipEditor` into
   `getSourceEntityData`.

 * Caches the return value by `$c`, so that the same entity object is
   returned no matter how many times it's called per request. This is
   important for component/hook memoization (`React.memo`/`useMemo`).

 * Requires passing `$c` to `getSourceEntityData`. In some cases we already
   have a `$c` from the React context, which is better to rely on than the
   window global. See the comment above `getCatalystContext`.

   This also allows for passing a `CatalystContextT` on the server, where
   `getCatalystContext` doesn't work at all.

 * Allows passing an optional `entityType` parameter after `$c` which
   refines the expected entity type for you. For example, passing 'release'
   will return a `ReleaseT` rather than a generic `RelatableEntityT`.
Returning `object | false` is a bit confusing. JavaScript already has a type
for indicating no value: null.
Luckily, these arrays were very small.

It was added for the tests in e1151dc, but
they seem to pass fine without it.
All this did was call `filterApplicableTypes` and return null if the list was
empty. We can just cache the possible types on the checker object to begin
with.
`EntityTypes` sounds like a list of entity types, but based on the usage of
`EntityTypesMap`, it's actually relationship types associated with an entity.
We already have a type alias named `RelationshipTypeT` for that.
This used to be used in the knockout-based relationship editor a long time ago.
Nowadays it's only used to generate external link edits in the release editor.
The single call to `edit.relationshipEdit` that remains doesn't even pass the
third `relationship` parameter. You can deduce that this code does nothing
without a `relationship`, other than make an identical copy of
`args.attributes`.
Tried my best to derive these from
lib/MusicBrainz/Server/Controller/ReleaseEditor.pm.
Prior to this commit, seeded release data was embedded in a script tag which
invoked `releaseEditor.init`. However, this creates a dependency where the
external links editor can't be hydrated until after the release editor is
initialized. This commit removes that dependency by allowing seeded URLs to be
pulled from the stash freely.
This is a leftover from when the release editor used to load the initial
release data asynchronously. Nowadays, `releaseEditor.loadRelease` is only used
to load releases from the duplicates tab. A callback is always specified there,
so a fallback isn't needed.
…ntity` property

`source_entity` is currently undef here on /release/add. The `isNewEntity` flag
is used by `getSourceEntityData` in
root/static/scripts/common/utility/catalyst.js to create a new placeholder
object using one of the functions from root/static/scripts/common/entity2.js.

The release editor doesn't require this yet, but the new external links editor
code will, as it calls `getSourceEntityData`, which expects `source_entity` to
be defined.
`FormRowText` isn't memoized (currently at least), but
UrlRelationshipCreditFieldset shouldn't be concerned with that.
@mwiencek mwiencek force-pushed the external-links-editor-refactor branch from a02829b to 8915fb3 Compare February 12, 2026 19:04
@mwiencek
Copy link
Member Author

I reviewed answers and each commit but the big commit mentioned by @reosarevok as it should probably be more detailed or further split.

I split another couple of small commits from it, though I mostly spent time adding comments to the type definitions in root/static/scripts/external-links-editor/types.js.

This was the very first React component written for MBS, all the way back in
2015, and requires significant refactoring in order to follow the Rules of
React [1] and work properly with React v19 and new tools like react-compiler.

Fixes MBS-11889
Fixes MBS-11521

[1] https://react.dev/reference/rules
https://react.dev/blog/2024/04/25/react-19
https://react.dev/blog/2024/04/25/react-19-upgrade-guide

It seems that a new version of the react-dom libdefs have not been published to
flow-typed yet. I simply moved the existing v18 file and removed all removed
functions. I didn't add any of the new functions (we don't use them yet).
@mwiencek mwiencek force-pushed the external-links-editor-refactor branch from 8915fb3 to 621b7f6 Compare February 12, 2026 19:35
@mwiencek
Copy link
Member Author

Bit of a convoluted issue, but [...]

I've changed it so that the type removal buttons are shown if there's an error, which I think should resolve this generally.

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

Labels

React PRs directly related with React conversion Refactoring Refactoring-only PRs (eslint fixes etc)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants