Bundling around the Figma Plugin Runtime Limits

16 Aug 2023
Reliving the glory days of javascript preprocessing

Figma exposes an extensive API for creating plugins in javascript. While working on Relume Sitemaps, we wanted to share typescript code between our main application (outside of Figma) and our figma plugin; things like our API clients and data schemas.

At first this sounded easy. I've since learned that not all "javascript" runtimes are created equal.

Through trial and error, we worked around these oddities by reviving the "glorious" bundling practices of the 2010s.

A tale of two runtimes

A Figma plugin actually runs 2 separate scripts in 2 very different runtimes:

  1. Page. Figma will display an iframe with your app. You supply figma some HTML code (with your <script> tags inline) and figma will load it as a data-url. This iframe can't access any figma APIs, so it can't edit the user's figma design; must post, but it can post messages to your background.js script.
  2. Background.js. This script can access the figma APIs, message your page script and make fetch requests.

The page runtime is relatively normal. Figma is an Electron app, so it runs a recent version of Chromium. You can write code and it'll run like in a normal browser. We did run into some CORS when requesting assets (since you're in window.​location.​origin = "null") and storage limitations (data URLs can't use cookies or localStorage), but hose are pretty self explanatory.

On the other hand, the background runtime is full of surprises.

What's the background runtime?

The background.js code runs inside of Figma's origin. This would pose a security risk (your plugin could steal session cookies, etc), so Figma built something to sandbox the background.js execution. Originally, Figma tried using the Realms API which would've run your code using the regular browser VM. Some might say they mucked up because following valunerability findings, they totally changed tact 2 months later. Now the background.js script runs in a custom JS VM, compiled to WASM, running inside of Figma's origin.

a custom JS VM,
compiled to WASM,
running in V8

When Figma announced the switch in 2019, they used QuickJS as their custom VM. Either Figma hasn't updated QuickJS since, or they've moved to a different VM, because Figma's VM is missing many of the features QuickJS now supports.

Problem 1: Unknown token "..."

figma_app.min.js.br:5 Syntax error on line 96: Unexpected token ...
  ..._sentry_core__WEBPACK_IMPORTED_MODULE_0__.TRACING_DEFAULTS,
  ^

Aside: make sure to enable "Use developer VM" from the Figma menu, as it adds the code previews to these error messages.

Cause: Many of our dependencies (from node_modules) were compiled targeting ES6. Destructuring (e.g. x = {​ y, ...​z }​) is an ES6 feature, and has been widely supported since 2016. However, Figma's VM can't parse it.

Solution: We added Babel to our build stack. Babel converts all the code (including node_modules) to ES5. Specially we use @babel/preset-env which targets ancient browsers when no configuration is given.

Problem 2: Missing globals

Error: [MobX] MobX requires global 'Symbol' to be available or polyfilled

Cause: Figma's background runtime doesn't have a global object. There's no window (the old fashioned one), no self (the webworker-inclusive one), no global (the node one) nor is there a globalThis (the modern one).

This isn't usually a problem, as most of the object you'd expect in the global object (e.g. window.​Symbol) are more commonly accessed via the global scope (e.g. just Symbol). Figma's runtime still supports the later.

This becomes a problem when some libraries try to feature-detect. It's easier to check if globalThis.​Symbol === undefined than catch a reference error thrown by Symbol. Libraries doing the former will erroneously detect Figma's background environment as missing the feature in question.

Solution: We create a global object with the features needed by our library. We're bundling with webpack, which compiles Node's global into __webpack_require__.​g. So it was easy to drop in this line before our library imports:

// mobx feature detects for Symbol, Map & Set
__webpack_require__.g = { Symbol, Map, Set };

import { observable } from 'mobx';

Problem 3: Falsely detected imports

SyntaxError: possible import expression rejected around line 12819

Cause: Figma only lets you submit one background.​js script per plugin. So they don't support import statements; there's no way to supply the other file you're importing from. I guess in the name of error-prevention / support-ticket-reduction, Figma checks for import statements in your code.

But here's the mystery: we didn't have any import statements in our code! Webpack removes import statement while bundling.

As any seasoned developer knows, a regex is the best way to parse HTML. Figma's developers are well seasoned, so they opted to use a regex on JS too:

/\bimport\s*(?:\(|\/[/*]|<!--|-->)/

I'm not sure why they use this regex as it doesn't match well formed import statement; neither import 'foo.​js' or import {​ bar }​ from 'foo.​js match. Perhaps it runs after another part of Figma's stack mangles the import statements, which would also explain the incorrect line numbers.

In our case it matched a comment in our dependency, which is clearly not an import statement:

// browser and replay packages should `@sentry/browser` import
// from `@sentry/replay` in the future

Solution: Strip out all comments. Any minification you perform on production builds probably already does this, we just needed to enable comment stripping in babel for our development builds too.

Exploring Figma

Thanks to Figma's interesting JS runtime, you can relive the glory days of JavaScript. With enough bundler & preprocessing steps, you make IE7 Figma run your ES6 (aka ES2015) code!

Now that we've figured out how to run code, let's hope their actual API doesn't have as many pitfalls...

This post is about work I've been doing at Relume.
Relume helps agencies and freelancers design websites faster.
Learn more at relume.io.