Skip to content

Instantly share code, notes, and snippets.

@niwinz
Created April 30, 2026 09:11
Show Gist options
  • Select an option

  • Save niwinz/d0fc272d0c9ff321d57911113f80ace4 to your computer and use it in GitHub Desktop.

Select an option

Save niwinz/d0fc272d0c9ff321d57911113f80ace4 to your computer and use it in GitHub Desktop.
Prompt: Migrate a Legacy UI Component to Modern Syntax

Migrate a Legacy UI Component to Modern Syntax

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.

Context

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-get or 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.

Pre-flight Check: SKIP Condition

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.

Finding the Next Candidate

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 clojure

Selection guidelines:

  1. Pick a component where the name does NOT end with *
  2. Confirm the component does not have ::mf/register in its metadata
  3. Start with a component that is relatively self-contained (few external callers, no complex obj/merge! prop chains) to minimize risk
  4. 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

Step-by-Step Migration (One Component)

Step 1: Rename the Component Definition

  • Change (mf/defc my-component to (mf/defc my-component*
  • Add the * suffix to the component name everywhere it is defined or referenced in the SAME namespace

Step 2: Rename Props with ? Suffix

  • Identify all props that end with ? in the component's destructuring or unchecked-get calls
  • Rename them to logical equivalents:
    • is-open? -> is-open
    • selected? -> is-selected
    • disabled? -> is-disabled
    • checked? -> is-checked
    • focused? -> is-focused
    • active? -> is-active
    • visible? -> is-visible
    • expanded? -> is-expanded
    • read-only? -> is-read-only
    • nillable? -> is-nillable
    • wrap-value? -> is-wrap-value (or better, wrap-value if context allows)
    • Any other ?-suffixed boolean prop -> prefix with is- or remove ? if it reads naturally
  • Update ALL internal usages of these renamed props within the component body

Step 3: Fix Props Handling / Destructuring

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 #js from map literals passed directly to [:> my-component* ...]
  • If props are stored in a #js variable 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-props macro 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 :class with CSS modules, but if passing to DOM, use :className or :class as appropriate)
  • "onClick" -> :on-click
  • "onBlur" -> :on-blur
  • "onChange" -> :on-change
  • "data-wrap" -> :data-wrap

Step 4: Update Requires in Other Namespaces

  • 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 :as alias, no require change is needed unless the alias + name combo is used directly

Step 5: Update Callsites

  • 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 #js object, replace #js with mf/props or mf/spread-props
    • If it's built with obj/merge! or similar, refactor to use mf/spread-props
  • Ensure renamed props from Step 2 are updated at every callsite

Step 6: Verification

  1. Perform the verification steps:
  • Component definition renamed with * suffix
  • No ?-suffixed props remain (unless they are local bindings, not component params)
  • No unchecked-get calls remain in the component for props access
  • All [:& old-name changed to [:> new-name*
  • All requires in other namespaces updated to starred name
  • ::mf/wrap-props false and any legacy ::mf/props metadata removed
  • Component still compiles and works logically the same
  1. Run formatting and linting from the frontend/ directory:
pnpm run fmt:clj
pnpm run lint:clj

Note 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.

Step 7: Commit

Ask @commiter for make a commit.

Special Cases and Considerations

  1. unchecked-get to destructuring: When props are received as a symbol and destructuring is done via unchecked-get, always change to standard destructuring in the parameter vector.

  2. app.util.object helpers: If props are managed with obj/merge!, obj/merge, etc., try to replace with mf/spread-props. If app.util.object is no longer used in the namespace after migration, remove the [:require [app.util.object :as obj]] import.

  3. mf/props macro: If you need to build a props object explicitly to pass as a symbol, use (mf/props {:foo "bar"}) instead of #js {:foo "bar"}.

  4. Remove legacy props metadata: When migrating, remove ::mf/wrap-props false and any existing ::mf/props metadata from the component. The modern * suffix handles props automatically. Keep ::mf/wrap, ::mf/private or ::mf/forward-ref if present

Output Format

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.

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