fix: load a text style's font before writing it, and let its typography bind to variables - #168
Merged
Merged
Conversation
Measured against a live file: `update_text_style` with nothing but a fontSize answers `in set_fontSize: Cannot write to node with unloaded font "Inter Regular"`. So does lineHeight, and so does letterSpacing. Reading a style does not load its font, and these handlers only loaded one when the call also passed `fontName` (or textWrapStyle, which had already been found to need it) — so updating a size on a style the caller had not also re-fonted simply failed, which is most styles. `create_text_style` had the same hole with a worse ending: it wrote after creating, so the throw left an orphan style behind in the design-system panel, to be published to the library from there. Reproduced, and now covered. The load is hoisted to just after the style exists, which is the earliest it can happen when fontName is omitted — the face is then whatever default Figma put on the fresh style, not knowable beforehand. That is fine where the caller-input load is not: a default face cannot fail on a bad argument. The test that asserted the opposite went with it. It read "a size-only change touches no glyphs → no font work", which is a plausible thing to believe and not what Figma does.
The last shape #164 exposed on read but could not write back. A text style's values are scalars, so unlike a paint or an effect there is no per-object level to hang bindings on — the style itself carries them, and create_text_style / update_text_style now take the same `boundVariables` get_styles reports. Two differences from the paints and effects, both because a text style write is a PATCH rather than a whole-array replacement: omitting a field leaves it as it was instead of unbinding it, so `null` is how a caller unbinds — the shape set_text_range already uses; and bindings apply after the literals, so a bound field wins over a literal passed for the same field in one call. Binding a font field has a prerequisite the numeric ones do not: Figma answers `unloaded font "<family> <style>"` unless the face the binding resolves to is already loaded. The faces are walked as a chain in the order the bindings are applied — family swap, then style swap — and preloaded best effort. Failures there are swallowed on purpose: a guessed face may not exist (a family from one mode with a style from another never co-occurs), and rejecting the write for that would fail a binding Figma would have taken. Anything genuinely missing still surfaces, from Figma, naming the exact face. Ids resolve before `createTextStyle()`, so an id matching no variable creates nothing at all. What that cannot cover — the target FACE is only computable once the style's base face is, i.e. after creation when fontName was omitted — is undone with a remove instead.
Records the two ways it differs from binding a paint or an effect, both consequences of a text style write being a patch: null unbinds, and a bound field beats a literal sent for the same field in the same call.
awdr74100
added a commit
that referenced
this pull request
Aug 22, 2026
… measured (#169) #168 shipped this preload with a hole in its evidence: the account it was built on caps a collection at one mode, so the multi-mode reasoning was argued rather than observed, and its comment said so. Measured now, on a two-mode collection: only the face the style actually RESOLVES to has to be loadable. A variable whose non-default mode names a font nobody has installed binds fine — which makes swallowing a failed preload load-bearing rather than defensive, since letting that failure through would reject a binding Figma accepts. Also confirmed live: a family and a style binding compose into one face (Roboto + Bold -> Roboto Bold), which is what the chain exists to keep loadable at each step. Three tests pin what was previously only argued. The multi-mode one is written so narrowing the preload to a single mode fails it — the first attempt asserted a family that happened to be in the first mode, and a mutation that read only that mode survived it. The faces are keyed now too: with a family binding and no style binding the intermediate and final face are the same, and asking twice was pointless even against a cached loadFontAsync.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The last shape #164 exposed on read but could not write back — and, found while measuring it, a
bug that had nothing to do with bindings.
The bug found on the way
update_text_stylewith nothing but afontSizefails:So does
lineHeight, and so doesletterSpacing. Reading a style does not load its font, andthese handlers only loaded one when the call also passed
fontName— so updating a size on anystyle the caller had not also re-fonted in the same session simply failed. That is most styles.
create_text_stylehad the same hole with a worse ending: it wrote after creating, so the throwleft an orphan style behind in the design-system panel — reproduced live, style intact in the file
with the default
fontSize: 12it never got past.Both fixed by loading the style's own face before any typography write. That load is hoisted to
just after creation, the earliest possible when
fontNameis omitted (the face is then whateverdefault Figma put on the fresh style). Safe where the caller-input load is not: a default face
cannot fail on a bad argument.
The test that asserted the opposite went with it — it read "a size-only change touches no glyphs
→ no font work", which is a plausible thing to believe and not what Figma does.
Text-style bindings
create_text_style/update_text_stylenow take the sameboundVariablesget_stylesreports.A text style's values are scalars, so unlike a paint or an effect there is no per-object level to
hang them on — the style itself carries them.
Two differences from the paints and effects in #167, both because a text style write is a patch
rather than a whole-array replacement:
nullis how a caller unbinds — the shapeset_text_rangealready uses for a run's bindings.
in one call.
Binding a font field has a prerequisite the numeric ones do not — measured:
fontSize,lineHeight,letterSpacing,paragraphSpacing,paragraphIndentfontFamilyunloaded font "<family> <style>"otherwisefontStyle,fontWeightSo the faces are walked as a chain in the order the bindings apply — family swap, then style swap —
and preloaded best effort. Failures there are swallowed deliberately: a guessed face may not exist
(a family from one mode paired with a style from another never co-occurs), and rejecting the write
for that would fail a binding Figma would have taken. Anything genuinely missing still surfaces,
from Figma, naming the exact face.
Ids resolve before
createTextStyle(), so an id matching no variable creates nothing at all.What that cannot cover — the target face is only computable once the style's base face is, i.e.
after creation when
fontNamewas omitted — is undone with aremove()instead.Measured limits
A multi-mode variable could not be tested: this account's plan caps a collection at one mode
(
in addMode: Limited to 1 modes only). The preload takes every mode's value into account, so thesingle-mode case is exact and the multi-mode one is covered by the best-effort pass rather than by
evidence.
Verification
fontSize+lineHeight+letterSpacingupdate that threw an hourearlier now succeeds; a
fontFamilybinding resolves the style's face toRoboto Regularwithno
unloaded font; a boundfontSizereads back as the variable's42;nullremoves onebinding and leaves its sibling; and an unresolvable id fails with
create_text_style: variable VariableID:9999:9999 not found, creating nothing.