You are migrating ONE legacy ClojureScript React component in the Penpot frontend (frontend/src/app/main/ui/) to the modern component syntax. Perform the migration step by step for a single component only.
Legacy components:
- Name does NOT end with
*(e.g.,my-component) - Defined with
(mf/defc my-component ...) - Often use
::mf/wrap-props false(props passed as raw JS object) - Props extracted via
unchecked-getor manual JS interop - Called with
[:& my-component {...}]
Modern components:
- Name ends with
*(e.g.,my-component*) - Defined with
(mf/defc my-component* ...) - Use standard destructuring on the params vector
- Called with
[:> my-component* {...}]
Important exception: Some legacy components use [:> because props are built as JS objects (#js {...}) and passed as symbols. Treat these carefully.
If the component has ::mf/register in its metadata, STOP and do NOT migrate it.
Example of a component that MUST be skipped:
(mf/defc my-component
{::mf/register :my-registry-key
::mf/wrap-props false}
[props]
...)Components with ::mf/register are very special cases that require separate, dedicated treatment. Do not attempt to rename, restructure, or refactor them. Report that the component was skipped due to ::mf/register presence and move on.
Use these commands to discover migration candidates in frontend/src/app/main/ui/:
# List all legacy mf/defc definitions (names NOT ending with *)
rg -n '^\(mf/defc [^*]+$' frontend/src/app/main/ui/ --type clojure
# Count how many files reference a specific component (to gauge scope)
rg -c 'component-name' frontend/src/ --type clojureSelection guidelines:
- Pick a component where the name does NOT end with
* - Confirm the component does not have
::mf/registerin its metadata - Start with a component that is relatively self-contained (few external callers, no complex
obj/merge!prop chains) to minimize risk - Avoid starting with components that are heavily used across 50+ files or have unusual prop-spread patterns until you have more experience with the migration
- Change
(mf/defc my-componentto(mf/defc my-component* - Add the
*suffix to the component name everywhere it is defined or referenced in the SAME namespace
- Identify all props that end with
?in the component's destructuring orunchecked-getcalls - Rename them to logical equivalents:
is-open?->is-openselected?->is-selecteddisabled?->is-disabledchecked?->is-checkedfocused?->is-focusedactive?->is-activevisible?->is-visibleexpanded?->is-expandedread-only?->is-read-onlynillable?->is-nillablewrap-value?->is-wrap-value(or better,wrap-valueif context allows)- Any other
?-suffixed boolean prop -> prefix withis-or remove?if it reads naturally
- Update ALL internal usages of these renamed props within the component body
Case A: Standard destructuring (most common) If the legacy component has:
(mf/defc my-component
{::mf/wrap-props false}
[props]
(let [foo (unchecked-get props "foo")
bar (unchecked-get props "bar")]
...))Replace with:
(mf/defc my-component*
[{:keys [foo bar]}]
...)Case B: Component needs to forward/spread props
If the component reads specific props AND needs to pass remaining props to a child element, use ::mf/props :obj:
(mf/defc my-component*
{::mf/props :obj}
[{:keys [foo bar] :rest props}]
...
[:> child-component* (mf/spread-props props {:extra "value"})])Case C: Props built with #js {...} at callsite
If callers build props with #js {...} and the component receives a JS object, after adding * the modern macro will automatically convert map literals to JS objects. You must:
- Remove
#jsfrom map literals passed directly to[:> my-component* ...] - If props are stored in a
#jsvariable and passed as a symbol, this props should use mf/props macro instead of#js {}syntax. This should be done ONLY if that props are passed to a component that has*suffix on the name (modern component)
Case D: Props manipulated with app.util.object helpers
If the component or its callers use obj/merge!, obj/merge, or similar helpers to combine props:
- Replace with
mf/spread-propsmacro when possible - Example:
;; Legacy (let [props (obj/merge! base-props #js {:on-click on-click})] [:> child props]) ;; Modern (let [props (mf/spread-props base-props {:on-click on-click})] [:> child* props])
The same constaint, the props constructed with mf/spread-props can ONLY be passed to components using [:> and component name has * suffix (modern component).
Case E: unchecked-get with string keys
Replace ALL unchecked-get calls with standard destructuring. If the component uses string keys that don't map directly to keywords (e.g., "className", "onClick"), use the corresponding keywords:
"className"->:class(Penpot uses:classwith CSS modules, but if passing to DOM, use:classNameor:classas appropriate)"onClick"->:on-click"onBlur"->:on-blur"onChange"->:on-change"data-wrap"->:data-wrap
- Find all namespaces that require the legacy component (e.g., via
[app.main.ui.components.forms :refer [input]]) - Update the require to use the new starred name:
[app.main.ui.components.forms :refer [input*]] - If the namespace uses
:asalias, no require change is needed unless the alias + name combo is used directly
- Find ALL usages of this component across the codebase
- Change
[:& my-component {...}]to[:> my-component* {...}] - If any callsite used
[:> my-component props-symbol](the exception), verify the props symbol:- If it's a
#jsobject, replace#jswithmf/propsormf/spread-props - If it's built with
obj/merge!or similar, refactor to usemf/spread-props
- If it's a
- Ensure renamed props from Step 2 are updated at every callsite
- Perform the verification steps:
- Component definition renamed with
*suffix - No
?-suffixed props remain (unless they are local bindings, not component params) - No
unchecked-getcalls remain in the component for props access - All
[:& old-namechanged to[:> new-name* - All requires in other namespaces updated to starred name
-
::mf/wrap-props falseand any legacy::mf/propsmetadata removed - Component still compiles and works logically the same
- Run formatting and linting from the
frontend/directory:
pnpm run fmt:clj
pnpm run lint:cljNote on formatting: The pnpm run fmt:clj command automatically fixes all indentation and formatting issues. Do not attempt to manually fix indentation — let the formatter handle it. Because fmt:clj already reformats the code, there is no need to also run pnpm run check-fmt:clj afterwards.
Ask @commiter for make a commit.
-
unchecked-getto destructuring: When props are received as a symbol and destructuring is done viaunchecked-get, always change to standard destructuring in the parameter vector. -
app.util.objecthelpers: If props are managed withobj/merge!,obj/merge, etc., try to replace withmf/spread-props. Ifapp.util.objectis no longer used in the namespace after migration, remove the[:require [app.util.object :as obj]]import. -
mf/propsmacro: If you need to build a props object explicitly to pass as a symbol, use(mf/props {:foo "bar"})instead of#js {:foo "bar"}. -
Remove legacy props metadata: When migrating, remove
::mf/wrap-props falseand any existing::mf/propsmetadata from the component. The modern*suffix handles props automatically. Keep::mf/wrap,::mf/privateor::mf/forward-refif present
For each step, briefly state what you changed and show the relevant code diff. After all steps, provide a summary of files modified and any props renamed.