openpencil/packages/fig/docs/instance-evaluation.md
Danila Poyarkov c47b5f8d1d
fix(fig): read, render, and write Figma slots (#850)
* fix(fig): read, render, and write Figma slots

Instances of a component with a slot showed the component's default
content instead of their own, and saving to .fig dropped slot properties,
their settings, and every instance's content.

Figma stores an instance's slot content as a frame on the internal canvas
and assigns the slot property that frame's GUID. The reader follows the
assignment while expanding the slot frame and pulls content frames into
the dependency closure without making them layers. The scene graph gains
a SLOT property type with its settings, a SLOT_CONTENT binding, and the
rule that an assigned slot's content belongs to the instance, which
component sync now leaves alone. The writer emits content frames on the
internal canvas and binds slot frames through the parameter map, as
Figma does.

The occurrence and diagnostic types move from the interpreter to
instance-overrides/occurrence.ts to keep it under the size limit.

* fix(fig): keep slot content through swaps and missing content frames

A slot assignment whose content frame the archive lacks no longer refuses
the document: the reader reports it through onMissingSlotContent, like a
missing component, and the slot keeps its component's content.

Swapping an instance's component, which variant switches do, now carries
the instance's own slot content to the new component's slot of the same
name instead of dropping it, as Figma does.

Component sync reads which slot a frame is from the component, whose
bindings instance copies do not receive. Clipboard export numbers slot
content after the other records on its dependency canvas, and the reader
and writer share Figma's default slot value.

* docs: note slot content kept across variant switches

* fix(fig): pair clipboard text by record and drop dangling slot assignments

The Figma clipboard paired text records with source text nodes by
traversal order, but instance-owned slot content is written after the
selected layers, so slotted text and the text after it swapped shaping
data. Records are now paired with their nodes through their GUIDs.

An instance assignment whose slot content frame is missing is dropped
along with the reported diagnostic, so the slot keeps following its
component instead of looking instance-owned.

* ci: pull the slots fixture for unit tests

* test: use GUID and Array.from in the slot tests

* refactor(fig): group occurrence types and paths in one folder

occurrence.ts and occurrence-path.ts became sibling prefixes when the
occurrence types moved out of the interpreter; they now live in
instance-overrides/occurrence/ as types.ts and path.ts.
2026-10-04 00:45:10 +04:00

6.7 KiB

Instance evaluation

flowchart TD
  Layers[Owner layers: swaps, assignments, property claims] --> Route[Route structural layers to the instance they configure]
  Route --> Expand[Expand each occurrence once with its effective component and bindings]
  Expand --> Claims[Owners apply property claims onto the built subtree, inner to outer]
  Claims --> Derived[Saved occurrence-derived data]
  Derived --> Result[Effective values and provenance]

The model has three parts. An instance expands its component's subtree. Every owner contributes layers: a partial record at a path relative to that owner. Where layers overlap, the outermost owner wins.

A layer is structural when it carries a swap (overriddenSymbolID) or component-property assignments; it selects what a nested instance expands. Everything else is a property layer. Structural layers are routed down to the instance they address before it expands, so each occurrence expands exactly once with its effective component and complete assignment list. Property layers are applied by their declaring owner after its subtree is built, which keeps their values in that owner's coordinate space and orders inner owners before outer ones.

Addressing

A property path is relative to its declaring owner. Ordinary containers may be traversed without adding an instance boundary; nested instances require an explicit segment.

Owner
  +-- instance A -> Label (source L)
  +-- instance B -> Label (source L)

[A, L] != [B, L]                 valid distinct addresses
[L] across an instance boundary  not an implicit recursive search

Component GUIDs and component override keys can address the root. A binding-driven replacement can retain the source component's root identity without retaining its old descendant targets. A placed instance's explicit size remains separate from unscaled root-override size.

Provenance

Origin Interpretation
Component default Inherited unless superseded.
Property assignment Supplies a binding value; not equivalent to its default.
Explicit path override Declared by an owner against a complete path.
Saved derived data Effective geometry/typography; not automatically a user override.
Editor mutation Recorded through the shared editing domains after materialization.
default Label = "Badge"
  +-- untouched instance -> inherits later component changes
  +-- explicit "Badge"  -> remains overridden despite equal text

Definitions with parentPropDefId inherit semantics within source ancestry while retaining local property identity. Current typed varValue and PROP_REF parameter records are normalized alongside older property forms.

Implemented: an outer owner's assignment supersedes an inner owner's explicit claim on the same field; unrelated fields of that claim are kept. A claim whose path passes through a swapped instance and resolved in the replaced component is stale and dropped. A missing target that did not resolve there either is reported, never remapped onto a similarly named replacement child.

Worked precedence example

Contribution                text            opacity
--------------------------  --------------  -------
Component default           "Badge"         1.0
Intermediate explicit claim "Custom"        0.4
Outer text assignment       "Assigned"      --

Effective result            "Assigned"      0.4
Retained intermediate claim --              0.4

The outer assignment supersedes the intermediate text claim without erasing unrelated opacity. Within one owner, its assignments bind before its explicit claims, so an explicit child claim wins over that owner's own assignment to the same field.

BEFORE SWAP                         AFTER SWAP
Component A                         Component B
  A/Label [explicit text claim]        B/Icon
  A/Icon                              B/Caption

A/Label does NOT become B/Caption merely because both contain text.

Names

Stored name alone is not proof of an explicit rename. Figma save captures distinguish root-targeted name claims. Untouched replacement instances use the component name, or the component-set name for variants. Initial stored names are not globally rewritten.

Known limitation: complex compositions of inherited renames, bindings, and structural swaps still have unresolved oracle cases. Direct swapComponent() behavior must not be assumed to describe every saved-record composition.

Derived text

Changing text or shaping properties invalidates inherited glyph caches unless a patch supplies replacement data. Later occurrence-derived glyph data can replace that invalidated cache. Paint-only changes do not alter glyph positions; every claim application applies the same validity rule. See materialization for rendering and editing boundaries.

Diagnostics

The library is strict by default: a missing or ambiguous target throws. InterpretInstanceOptions lets a caller skip and report instead: onUnresolvedProperty and onUnresolvedAssignment for records that address nodes the archive no longer contains, and onMissingComponent for an instance of a deleted component, which then stays a childless instance with its saved reference. A swap reports the same way when its target layer no longer exists, which Figma retains as readily as a stale property override; an address that matches more than one record is a wrong path rather than a stale one and stays fatal. Reports retain owner, effective component context, complete path, and assignment payload where applicable.

Figma retains such records after deletions, so the application reader opts into all three (readerSessionOptions in Core) and exposes the collected records through readerDiagnostics(graph). Oracle tooling stays strict unless a flag names the concession.

Implementation and tests

Known limitation: field coverage and provenance transitions are not complete. Assignments whose definition only exists on a detached ancestor are reported as unresolved rather than remapped through detachedSymbolId lineage.