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

441 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 copy** — `ctf/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.cljc`**canonical 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-component``cll/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-container``generate-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-component``cll/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-swap``cll/generate-component-swap`. It
removes the old shape and instantiates the target in place
(`generate-new-shape-for-swap``generate-instantiate-component`
`make-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-component``add-component2``cll/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-variant` — **treat 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-shape`**this 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/`](./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.