mirror of
https://github.com/penpot/penpot.git
synced 2026-07-23 06:28:14 +00:00
441 lines
25 KiB
Markdown
441 lines
25 KiB
Markdown
# 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 ~30–67):
|
||
|
||
`: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.
|