penpot/docs/component-subsystem-handoff.md
alonso.torres a343104d54 wip
2026-06-02 09:21:23 +02:00

25 KiB
Raw Permalink Blame History

Penpot Component Subsystem — Engineering Hand-off

Audience: agents/engineers tasked with fixing bugs in Penpot's component, copy, and variant machinery. This is a map of the territory, not a tutorial. It tells you where things live, how the main flows work, and where the mines are buried. Read the "Sharp edges & unclear areas" section before you touch sync logic.

All paths are repo-relative. Line numbers drift; treat them as starting points and re-locate symbols by name.


1. What the subsystem does

A component is a reusable shape tree. Users instantiate it to get copies (instances). A copy can diverge from its source ("overrides", tracked with :touched flags). Changes can flow in two directions:

  • Copy → Main ("update main" / inverse sync): push a copy's local changes back into the component definition.
  • Main → Copies ("sync" / direct sync): propagate the component definition to every copy, in every page, even across files (remote libraries).

Variants are a newer layer on top: a set of related components grouped in a variant container frame, distinguished by named properties, with a UI to switch a copy from one variant to another (which is implemented as a component swap).

This is the most complex, most error-prone area of the codebase. It crosses the common/frontend/backend boundary, mutates a shared tree, and has many special cases (nested copies, swaps, fostered children, remote libraries, grid/flex layout, text, paths).


2. Mental model & glossary

Shape roles (a shape can hold several at once)

Role Marker attrs Predicate
Main instance / master :main-instance true, :component-id, :component-file ctk/main-instance?
Copy / non-main instance :shape-ref (points up the inheritance chain) ctk/in-component-copy? (≈ (some? (:shape-ref shape)))
Component root :component-root true, plus :component-id / :component-file ctk/instance-root?
Instance head (top of a nested sub-instance) ctk/instance-head?, subinstance-head?, subcopy-head?
Variant master main instance + component root + :variant-id ctk/is-variant?
Variant container frame with :is-variant-container true ctk/is-variant-container?

⚠️ Roles are not mutually exclusive. A variant master is a main instance and a component root, and its descendants can themselves be copies (nested instances). Any logic that assumes "this shape is only a master" or "only a copy" is suspect.

Key concepts

  • :shape-ref — a copy shape points to the equivalent shape one level up the inheritance hierarchy (the "near main"). Chains can be multiple levels deep (nested components) and can cross files for remote libraries.
  • :touched — a set of override-group keywords (:geometry-group, :fill-group, :content-group, :name-group, …). Presence means "this copy diverged from its main for attrs in that group, so don't overwrite them on sync".
  • swap slot (:swap-slot-* via ctk/get-swap-slot/set-swap-slot) — records that a sub-instance was swapped for a different component, so sync knows the head no longer matches its original :shape-ref position.
  • direct vs remote copyctf/direct-copy? distinguishes a copy whose :shape-ref points directly into the nearest component vs. one inheriting through intermediate components. Normal sync only touches direct/near mains.

Namespace aliases you will see everywhere

Alias Namespace Role
ctk app.common.types.component predicates, touched/swap-slot helpers, data model
ctkl app.common.types.components-list the library's component registry (CRUD over :components)
ctf app.common.types.file ref-shape resolution, component lookup across files
ctn app.common.types.container shape-tree access + make-component-instance
cll app.common.logic.libraries the sync/instantiate/swap engine
clv app.common.logic.variants variant switch / keep-touched logic
clvp app.common.logic.variant-properties variant property name/value edits
ctv app.common.types.variant variant data types
cfv app.common.files.variant variant container/file-level helpers
pcb app.common.files.changes_builder fluent change-set builder
dwl app.main.data.workspace.libraries (frontend) Potok events

3. Data model — where state lives

In the file/library data (:data)

  • :components — map of component-id → component record. Managed by ctkl (app.common.types.components-list). A component record holds: :id :name :path :main-instance-id :main-instance-page, optional :variant-id :variant-properties, :deleted (soft-delete / "deleted component" used by undo & detached-but-referenced copies), and for deleted components an embedded :objects snapshot.
  • The main instance lives in the page tree, not inside the component record. The record only points to it via :main-instance-id + :main-instance-page. This indirection is a frequent source of "invalid main instance" validation errors — see validate.cljc.

On shapes (page objects)

:component-id :component-file :component-root :main-instance :shape-ref :touched plus swap-slot and variant attrs (:variant-id :variant-name).

Component v2

Full referential/semantic validation only runs when the file's features contain "components/v2". v1 behavior is mostly legacy; on v2 the main instance is a real shape in a page (note make-component-instance forces :parent-id nil / :frame-id uuid/zero on the cloned component-shape "to behave like v1").


4. File map — the main files to check

common/ — the engine (runtime-agnostic, the real logic)

File What's in it
common/src/app/common/logic/libraries.cljc (~3100 lines) The core. Instantiate, both sync directions, attribute-copy algorithm, swap, detach, reset, add/duplicate component. Start here for almost any bug.
common/src/app/common/logic/variants.cljc Variant switch support: generate-keep-touched, find-shape-ref-child-of, add-touched-from-ref-chain, generate-add-new-variant.
common/src/app/common/logic/variant_properties.cljc Variant property name/value generation.
common/src/app/common/logic/shapes.cljc Generic shape ops that special-case copies (e.g. delete-inside-copy, swap).
common/src/app/common/types/component.cljc Data model: predicates, sync-attrs (attr→group map), swap-keep-attrs, touched/swap-slot get/set, detach-shape, diff-components.
common/src/app/common/types/components_list.cljc Component registry CRUD; soft delete (mark-component-deleted/undeleted), update-component, set-component-modified.
common/src/app/common/types/file.cljc Ref-shape resolution (the chain walkers): get-ref-shape, find-ref-shape, find-remote-shape, get-component-root, direct-copy?, find-swap-slot, get-ref-chain-until-target-ref, find-near-match, advance-shape-ref.
common/src/app/common/types/container.cljc make-component-instance (the clone-and-relink primitive), shape-tree accessors.
common/src/app/common/types/variant.cljc / files/variant.cljc Variant types and container/file helpers.
common/src/app/common/files/changes_builder.cljc (pcb) How every mutation is expressed; :ignore-touched, set-translation?, update-shapes.
common/src/app/common/files/changes.cljc process-operation multimethod, set-shape-attr, second-pass touched handling.
common/src/app/common/files/validate.cljc (~980 lines) All component invariants and the error codes they raise. Read this to learn what "correct" means.

frontend/ — events & UI

File What's in it
frontend/src/app/main/data/workspace/libraries.cljs (dwl, ~1600 lines) Potok events: instantiate-component, detach-component(s), reset-component(s), update-component, update-component-sync, component-swap, component-multi-swap, sync-file, watch-component-changes, restore/delete/duplicate/rename component. The UI→common bridge.
frontend/src/app/main/data/workspace/variants.cljs Variant events: variant-switch, variants-switch, add-new-variant, combine-as-variants, property edits. Switch ultimately calls dwl/component-swap.
frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs (~1340 lines) The component options panel: update main, reset, detach, swap UI, show-main, annotations, variant property editor. The main user entry surface.
frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs The assets library panel (list, group, instantiate via drag, context menu).
frontend/src/app/main/ui/inspect/.../variant*.cljs Inspect/codegen of variants.

Backend

The backend stores and re-validates file data; it does not re-run component sync logic, but it does run validation/repair on persisted files and an async process that remaps media references after instantiation (see the WARNING in make-component-instance — media refs are fixed up server-side, not at clone time).

Tests (canonical behavior references)

  • common/test/common_tests/logic/comp_sync_test.cljc — direct/inverse sync.
  • common/test/common_tests/logic/copying_and_duplicating_test.cljc
  • common/test/common_tests/logic/text_sync_test.cljc
  • common/test/common_tests/logic/variants_switch_test.cljccanonical swap+touched suite.
  • common/test/common_tests/logic/variants_test.cljc
  • common/test/common_tests/types/components_test.cljc, types/variant_test.cljc, variant_test.cljc
  • frontend/test/frontend_tests/logic/components_and_tokens.cljs
  • Test helpers: common/src/app/common/test_helpers/{components,compositions,variants,files}.cljc. Notably compositions/swap-component-in-shape drives the real swap pipeline; use {:keep-touched? true} to exercise variant-switch behavior. thf/apply-changes is the production applier analog and validates by default.

5. Core code flows

5.1 Instantiate a component

dwl/instantiate-componentcll/generate-instantiate-component (libraries.cljc:236) → ctn/make-component-instance (container.cljc:299).

make-component-instance clones the component's shape tree, assigns new ids/names, and for each cloned shape (update-new-shape):

  • moves it by delta and dissociates :touched :variant-id :variant-name (clean copy);
  • for main instances: sets :main-instance, drops :shape-ref;
  • for copies: sets :shape-ref to the original shape id (the near instance);
  • sets :component-root/:component-id/:component-file on the root only.

generate-instantiate-component then fixes parent/frame ids, handles dropping into grid layouts, and emits :add-obj changes with :ignore-touched true.

When debugging "instance came out wrong", determine which clone path produced it: make-component-instance (clean) vs duplicate-component/generate-duplicate-* (does not clean inherited attrs — source :touched etc. can leak in).

5.2 Direct sync (Main → Copies)

cll/generate-sync-file (libraries.cljc:483) iterates every container (pages + deleted-component objects) → generate-sync-containergenerate-sync-shape (multimethod on asset type :components/:colors/:typographies) → generate-sync-shape-direct (:796) → generate-sync-shape-direct-recursive.

  • Only operates when in-component-copy? and (direct-copy? or reset?).
  • reset? resolves the main against the ref-shape (find-ref-shape); normal sync resolves against the component's stored ref (get-ref-shape).
  • Recursion compares children (compare-children) to add/remove/move shapes and calls update-attrs per matched pair.

Frontend triggers: dwl/update-component-sync, dwl/sync-file, dwl/watch-component-changes (auto-sync after edits to a main), and the explicit "Update main component" action.

5.3 Inverse sync (Copy → Main) — "Update main"

dwl/update-componentcll/generate-sync-shape-inverse (:1010) → generate-sync-shape-inverse-recursive. Resolves the remote shape with ctf/find-remote-shape (walks :shape-ref across files), pushes copy values up, renames the component if :name-group is touched, and marks the component modified.

5.4 Reset / Detach

  • Reset (dwl/reset-component(s)): direct sync with reset? = true — discards overrides by syncing the copy back to its ref-shape. cll/generate-reset-component.
  • Detach (dwl/detach-component(s)): cll/generate-detach-component / generate-detach-recursive / generate-detach-immediate — strips :shape-ref, component attrs, swap slots via ctk/detach-shape. advance-nesting-level handles nested cases.

5.5 Component swap (and variant switch)

Single swap workhorse: dwl/component-swapcll/generate-component-swap. It removes the old shape and instantiates the target in place (generate-new-shape-for-swapgenerate-instantiate-componentmake-component-instance), preserving identity (swap slot) so sync still works.

component-multi-swap batches and calls component-swap with keep-touched? = false.

keep-touched? = true (single swap / variant switch) additionally runs clv/generate-keep-touched (variants.cljc):

  1. walk pre-swap children, union chain-derived touched via add-touched-from-ref-chain;
  2. for each, find the equivalent target child via find-shape-ref-child-of;
  3. call cll/update-attrs-on-switch to carry user overrides onto the fresh copy.

Variant switch: variants.cljs/variant-switch / variants-switch (driven by the property-toggle UI and the Plugin API switchVariant) compute the target component and delegate to dwl/component-swap with keep-touched? true.

5.6 Add component from selection

dwl/add-componentadd-component2cll/generate-add-component / generate-add-component-changes: converts selected shapes into a main instance, registers the component in :components, and (for variants) may create a variant container. Dropping a shape into a variant container can auto-convert it to a variant via generate-make-shapes-varianttreat drag/drop into a variant container as a component operation, not a plain reparent.


6. The attribute-sync algorithm (update-attrs, libraries.cljc:1790)

The heart of direct/inverse sync. For a (dest-shape, origin-shape) pair it loops updatable-attrs and copies origin→dest, with these rules:

  • Geometry is relative: reposition-shape moves origin-shape so its root aligns with dest-root before comparison (coordinates are absolute, but only the relative position should sync). For subinstances, comparison is always against the near component.
  • omit-touched? true ⇒ skip attrs whose sync-group is in dest's :touched.
  • Skip when origin == dest for that attr.
  • :position-data is derived: reset to nil (recomputed) when geometry is touched and text position-data changed.
  • Text :content is special: text and formatting share one :content attr; on partial change it merges (text-change-value) and forces a position-data reset.
  • After the loop: check-detached-main, check-swapped-main, and token sync (generate-update-tokens).

add-update-attr-changes/add-update-attr-operations build the :mod-obj :set operations (with inverse ops for undo).

update-attrs-on-switch (libraries.cljc, swap-specific)

Separate from update-attrs. Compares three shapes: current-shape (fresh target copy), previous-shape (pre-swap shape with chain-derived touched), origin-ref-shape (source variant master's equivalent). Loops sync-attrs except swap-keep-attrs, copying overrides through several guards (skip equal values, skip equal composite geometry, require the touched group, require source/target masters to agree, dedicated fixed-layout geometry handling, specialized text/path conversion). The generic fallback copies from previous-shapethis is where most swap bugs surface when a guard fails to reject incompatible geometry or a master mismatch.


7. Touched flags & overrides

  • sync-attrs (component.cljc) maps every syncable attr to its touched group. Any new syncable shape attr must be added here or sync silently ignores it.
  • set-touched-group is the only legitimate setter; the central set-shape-attr path calls it only for copies and only when ignore flags allow.
  • Masters are not normally touched, but touched flags can leak onto masters via clone/duplicate paths, and add-touched-from-ref-chain unions ancestors' touched into a copy — so a shape that looks untouched locally may behave as touched.
  • Width/height are excluded from the is-geometry? ignore branch in set-shape-attr — don't assume all geometry-group attrs behave identically.
  • Geometry differences under ~1px are treated as equal (approximate equality) for touched purposes. See mem:common/decimals-and-coordinates.

8. Variants

  • A variant container is a frame :is-variant-container true; children are variant masters carrying :variant-id (→ container) and :variant-name (the value).
  • Component records carry :variant-properties.
  • Predicates are deliberately broad: ctk/is-variant? matches both variant master shapes and component rows; is-variant-container? checks the frame flag.
  • Switch flow: §5.5. Property edits: variant_properties.cljc + variants.cljs.
  • find-shape-ref-child-of walks the ref chain to find the equivalent master child in the target variant — central to mapping overrides across a switch.

9. Validation & repair (common/src/app/common/files/validate.cljc)

Runs full referential/semantic checks only on components/v2 files. It is the best single source of "what invariants must hold". Error codes worth knowing (line ~3067):

:component-not-main, :component-main-external, :component-not-found, :invalid-main-instance-id/-page/-instance, :component-main, :shape-ref-in-main, :component-id-mismatch, :component-nil-objects-not-allowed, :shape-ref-cycle, :component-duplicate-slot.

repair-file does not mutate directly — it reduces validation errors into redo changes via changes-builder; callers must apply/persist them. When a fix "doesn't stick", check that the repair changes were actually applied.


10. Debugging recipes (from mem:common/component-debugging-recipes)

Two runtimes are in play for live work: mcp__penpot__execute_code (Plugin API, can crash silently) and mcp__penpot__cljs_repl (raw REPL, survives crashes). Detect a crash with (some? (:exception @app.main.store/state)).

Inspect what a UI action emitted (last undo entries / :mod-obj operations): walk [:workspace-undo :items] in the store — snippet in the debugging-recipes memory.

Trace update-attrs-on-switch during a real swap by runtime-patching the var in cljs_repl (capture curr/prev/origin-ref), trigger the UI action, inspect the buffer, then restore. Runtime patching beats source instrumentation (no recompile).

Tests: thf/dump-file file :keys [...] prints a shape tree; prefer production-path helpers (cls/generate-update-shapes + thf/apply-changes) over ad hoc map mutation; use tho/swap-component-in-shape {:keep-touched? true} for swaps.

⚠️ The Serena project root resolves to /home/penpot/penpot (devenv container), but local file tools use /home/alotor/kaleidos/penpot/penpot. Use the right prefix per tool.


11. Sharp edges & areas not obvious from a superficial read

  1. Role overlap. Variant masters are simultaneously main + root, and copies nest. Never assume exclusivity. Bugs hide in code that branches on a single role.
  2. Two clone paths with different cleanliness. make-component-instance produces clean copies (drops :touched/variant attrs); duplicate-component / generate-duplicate-* do not — inherited source attrs survive. Identify the path before changing sync logic.
  3. update-attrs vs update-attrs-on-switch are different functions with different guard sets. Fixing one doesn't fix the other.
  4. Composite geometry (:selrect, :points) bypasses the simple different-master skip in swap; width/height checks catch some but not all positional mismatches. Classic source of "swap moved/resized my shape".
  5. previous-shape may be repositioned (destination-root minus origin-root) before copy. Often zero for variant switch — do not assume zero for other swap entry points (Plugin API, multi-swap).
  6. Inherited touched via ref chain. add-touched-from-ref-chain makes a locally untouched shape behave as touched. Check the effective touched set, not the stored one.
  7. Cross-file ref chains. :shape-ref and find-remote-shape walk across remote libraries. A missing/unlinked library makes the main "not found" and sync silently no-ops (generate-sync-shape-direct returns changes unchanged). Distinguish "no-op because unlinked" from "no-op because of a bug".
  8. Swap slots are the only thing that keeps a swapped sub-instance syncing correctly. If a swap slot is missing/duplicated, expect :component-duplicate-slot or wrong-position syncs. See find-swap-slot, match-swap-slot?.
  9. The main instance lives in a page, referenced indirectly by :main-instance-id/-page. Moving/deleting/duplicating pages or the main shape can desync the component record → :invalid-main-instance-*.
  10. Media references are remapped asynchronously on the backend after instantiation, not at clone time. A freshly instantiated copy may have "unreferenced" fills/strokes until the backend fixes them — don't "fix" this in the clone path.
  11. generate-sync-file early-return & concat-changes carry a TODO Remove concat changes; the change-set plumbing here is known-awkward.
  12. set-translation? true marks a change set translation-only so sync skips expensive work — a wrong/missing flag changes whether sync runs.
  13. Touched second pass. Change application does a second pass for collected touched changes (process-touched-change), which can mark a component modified from a plain shape op. See mem:common/file-change-validation-migration-subtleties.
  14. Tokens interact with sync (generate-update-tokens inside update-attrs). Token application/propagation has its own subtleties — see mem:frontend/workspace-token-subtleties, mem:common/tokens-schema-subtleties.

12. Where to start, by symptom

Symptom Start here
Override lost / overwritten on sync update-attrs + :touched/sync-attrs; check omit-touched? and the touched group.
Swap moves/resizes/loses overrides update-attrs-on-switch guards; generate-keep-touched; composite geometry skip.
"Update main" doesn't propagate generate-sync-shape-inverse / find-remote-shape; was component marked modified?
Copy doesn't update from main generate-sync-shape-direct; direct-copy?; is the library linked?
Validation errors on save validate.cljc error code → the specific check-* fn; then repair-file.
Variant switch wrong child find-shape-ref-child-of, add-touched-from-ref-chain in variants.cljc.
New attr not syncing add it to ctk/sync-attrs with the right group.
Nested / fostered / swapped child weirdness ref-chain walkers in types/file.cljc; swap slots in ctk.

13. Authoritative references

Serena memories (read before deep work — they are the project's primary guidance): mem:common/component-data-model, mem:common/component-swap-pipeline, mem:common/component-debugging-recipes, mem:common/changes-architecture, mem:common/file-change-validation-migration-subtleties, mem:common/data-model-change-checklist, mem:common/core, mem:frontend/core.

The canonical behavioral spec is the test suite under common/test/common_tests/logic/ — read neighboring tests before adding a case.


14. Findings — correctness / stability / performance audit

The findings from auditing this subsystem have been split into self-contained, independently-actionable bug reports under component-bug-reports/ (see its README.md for the index, suggested order, and severity table). Summary:

Report Title Category Confidence
BUG-01 concat-changes accumulates :undo-changes as nested lazy concat Stability + Perf 🟢 High
BUG-02 Un-memoized ref-chain walking during variant switch Perf 🟡 Medium
BUG-03 compare-children fallback is O(n²) with an expensive constant Perf 🟡 Medium
BUG-04 update-attrs-on-switch composite-geometry guard audit Correctness (audit) 🔵 Low
BUG-05 Silent no-op vs. bug ambiguity in direct sync Diagnosability 🔵 Low
BUG-06 d/index-of linear scan inside per-child sync callbacks Perf 🟢 High
BUG-07 Inverse sync re-maps the entire change list at every tree node Perf + Stability 🟢 High
BUG-08 variant-switch crashes on empty / out-of-range target (plugin-facing) Correctness 🟢 High

Each report is self-contained (onboarding pointers, affected code, root cause, fix direction, reproduction, test guardrails, acceptance criteria) so it can be picked up cold in a separate context. Update a report's status there as it is fixed.