<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Luis Rodriguez</title>
    <description>The latest articles on DEV Community by Luis Rodriguez (@luisrrv).</description>
    <link>https://dev.to/luisrrv</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1082801%2F340f124e-4f1c-4c8f-bc83-93249ee6c326.jpeg</url>
      <title>DEV Community: Luis Rodriguez</title>
      <link>https://dev.to/luisrrv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/luisrrv"/>
    <language>en</language>
    <item>
      <title>Themes, slots and AI edits for untrusted components</title>
      <dc:creator>Luis Rodriguez</dc:creator>
      <pubDate>Thu, 01 Oct 2026 09:19:34 +0000</pubDate>
      <link>https://dev.to/luisrrv/themes-slots-and-ai-edits-for-untrusted-components-3fid</link>
      <guid>https://dev.to/luisrrv/themes-slots-and-ai-edits-for-untrusted-components-3fid</guid>
      <description>&lt;p&gt;The &lt;a href="https://dev.to/luisrrv/running-untrusted-react-components-in-the-browser-3mda"&gt;first note&lt;/a&gt; was about running someone else's React component without letting it break, hang, or reach into the page. That covers the code itself. Real products that run user code usually go one step further: users customize a shared component library, and more and more often, an AI writes part of the code for them.&lt;/p&gt;

&lt;p&gt;So I added three things to &lt;a href="https://playground.lrod.dev" rel="noopener noreferrer"&gt;the playground&lt;/a&gt;: theme tokens, slots, and an AI edit panel. Each one comes with failure cases you can trigger, same as before. This note covers the design decisions behind them and the details that mattered most.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: compose, don't edit
&lt;/h2&gt;

&lt;p&gt;The first decision shaped everything else. Users can customize the component kit, but they can't change it. There's no forking a component or editing its source. Customization goes through two narrow doors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Theme tokens:&lt;/strong&gt; a handful of typed values (colors, radius, spacing, font scale).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slots:&lt;/strong&gt; named places inside a component where you can put your own content, like a card's header or a button's icon.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Narrow doors are easier to check. If users could edit kit source, every customization would be arbitrary code with access to the kit's internals. With tokens and slots, the kit decides what can vary, and everything else stays fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Theme tokens instead of custom CSS
&lt;/h2&gt;

&lt;p&gt;The obvious way to let people theme a component is to accept CSS. The problem is that CSS can do much more than change colors. &lt;code&gt;background: url(...)&lt;/code&gt; makes a network request, &lt;code&gt;position: fixed&lt;/code&gt; can cover the whole page, and a stray &lt;code&gt;}&lt;/code&gt; can end one rule and start another.&lt;/p&gt;

&lt;p&gt;So the theme panel accepts six tokens, each with a narrow format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^#&lt;/span&gt;&lt;span class="se"&gt;(?:[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{3}&lt;/span&gt;&lt;span class="sr"&gt;|&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{6})&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ThemeOverrides&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;accent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;space&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;fontScale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;partial&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strict&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A color must be a hex value, so &lt;code&gt;red; background: url(https://evil.example/pixel.png)&lt;/code&gt; is rejected before it goes anywhere. &lt;code&gt;.strict()&lt;/code&gt; rejects unknown tokens instead of silently dropping them, so a typo shows up as an error rather than a theme that mysteriously doesn't apply.&lt;/p&gt;

&lt;p&gt;Some rules need the whole theme, not one value. Text on the surface color has to meet 4.5:1 contrast, so a theme can't make the kit unreadable. That check runs after the overrides are merged over the defaults.&lt;/p&gt;

&lt;p&gt;Like everything else, the theme is checked twice. The host validates it to show issues next to the panel, and the sandbox checks it again before applying anything. Each token then becomes a CSS variable, set one at a time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;themeToCssVars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;setProperty&lt;/code&gt; sets one value. Even if something slipped past the schema, it couldn't add another declaration the way string concatenation into a stylesheet could. The kit's CSS reads only those variables, and derives borders, muted text and hover states from them with &lt;code&gt;color-mix()&lt;/code&gt;. That keeps the theme to six tokens instead of twenty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Slots, each with its own error boundary
&lt;/h2&gt;

&lt;p&gt;Slots let users change a component's layout without touching its code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Card&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Team"&lt;/span&gt; &lt;span class="na"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;aside&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Badge&lt;/span&gt; &lt;span class="na"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;popular&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Badge&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="na"&gt;footer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Buy&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  …
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Card&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Slot content is user code, so it can throw. The playground already had an error boundary around the whole component, but if a slot failing took down the entire card, that would look the same as any other render error. So each slot gets its own small boundary. A slot that throws is replaced with a placeholder, and the rest of the card keeps rendering. The host shows a &lt;code&gt;slot error&lt;/code&gt; that names which slot failed.&lt;/p&gt;

&lt;p&gt;The kit is also read-only in code, not just by convention. User code receives a frozen object of frozen components, so &lt;code&gt;ui.Button = MyButton&lt;/code&gt; throws a &lt;code&gt;TypeError&lt;/code&gt; instead of quietly changing the kit for everything else on the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI edits, without a server
&lt;/h2&gt;

&lt;p&gt;The AI panel lets you describe a change ("add a subtitle prop") and get back an edited version of the component. I wanted it to stay a static site with no backend, and nothing that spends someone else's money. So it's bring-your-own-key: you paste your own OpenAI API key, and the request goes straight from the browser to OpenAI.&lt;/p&gt;

&lt;p&gt;That makes two things worth protecting: the key, and the page from whatever the model sends back.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keeping the key in one place
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Memory only.&lt;/strong&gt; The key lives in the AI panel's state. It isn't saved, isn't in the URL, and closing the panel forgets it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One destination.&lt;/strong&gt; The host page has its own Content Security Policy, with &lt;code&gt;connect-src 'self' https://api.openai.com&lt;/code&gt;. Even if something on the page misbehaved, the key can't be sent anywhere else. &lt;code&gt;script-src 'self'&lt;/code&gt; rules out injected scripts on the page that holds it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never near the sandbox.&lt;/strong&gt; No message to the sandbox has a field that could carry it. A test enforces this as an architecture rule: it fails if the sandbox or messaging code ever imports the AI modules, or if the message protocol gains a field named like a key or token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Least privilege.&lt;/strong&gt; The panel recommends a restricted key (Responses: write, nothing else) with a hard spend limit, so a leaked key can only make model calls, up to a few dollars.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Treating the model's output as untrusted
&lt;/h3&gt;

&lt;p&gt;The model's reply is just more untrusted code, so it gets the same treatment as anything typed by hand, plus a review step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Strict shape.&lt;/strong&gt; The request asks for JSON matching a schema (&lt;code&gt;{ summary, code }&lt;/code&gt;), and the reply is validated with Zod anyway. Refusals, cut-off answers and wrong-shaped JSON each get a clear message instead of a crash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review mode.&lt;/strong&gt; The proposal replaces the editor with a diff. Changed lines are marked, with counts, so it's obvious what you're looking at isn't your code yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-check.&lt;/strong&gt; The proposal is compiled and import-scanned without running it, so the review shows &lt;em&gt;"would be blocked (import): 'axios' is not available"&lt;/em&gt; before you decide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accept means "put it in the editor".&lt;/strong&gt; From there it goes through the normal pipeline: compile, scope, validate, sandbox. Accepting can't skip a single check.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To show that without needing a key, the panel has two canned responses. One is a reasonable edit. The other is what a compromised or confused model might return: it imports &lt;code&gt;axios&lt;/code&gt; and adds a &lt;code&gt;fetch&lt;/code&gt; that tries to send &lt;code&gt;document.cookie&lt;/code&gt; somewhere. The pre-check flags the import during review, and if you accept it anyway, the import allowlist stops it and the sandbox's CSP would block the &lt;code&gt;fetch&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxm3w194ewinwb4qwl8uj.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxm3w194ewinwb4qwl8uj.webp" alt="Review mode in the playground: the editor is replaced by a diff headed 'Reviewing AI proposal'. The pre-check reads: would be blocked (import): 'axios' is not available in this sandbox. Added lines import axios and post document.cookie with fetch." width="800" height="686"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The malicious demo response in review mode. The pre-check flags the import before anything runs.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Prompt injection deserves an honest note. Instructions hidden in a code comment could steer the model, and the prompt only asks it to ignore them. That isn't a defense on its own. What makes it acceptable here is that nothing the model returns is trusted: the worst a hijacked reply can do is propose code that you review and the sandbox contains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Details that mattered
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Freezing the kit isn't enough on its own.&lt;/strong&gt; Compiled &lt;code&gt;import * as ui&lt;/code&gt; goes through an interop helper, and for an object without an &lt;code&gt;__esModule&lt;/code&gt; marker, that helper copies the exports into a new, writable object. With only a freeze, &lt;code&gt;ui.Button = MyButton&lt;/code&gt; succeeds: user code patches the copy while the frozen original sits untouched, so the "read-only" kit looks editable. A non-enumerable &lt;code&gt;__esModule&lt;/code&gt; flag makes the helper use the frozen object as-is, and the assignment throws.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slot errors happen before "rendered".&lt;/strong&gt; The sandbox reports success from an effect, after React commits the tree. But error boundaries catch during the commit itself, which runs before effects. Sent separately, a slot's error would arrive first and the "rendered" message right after would clear it. So slot errors from the first render are collected and sent along with "rendered", and errors that happen later (after a click, say) are sent on their own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model names in the docs and on the pricing page didn't match.&lt;/strong&gt; One page listed one generation of models and another page a newer one, and I nearly hardcoded names from the stale one. The account's own limits page was the source of truth. The panel now also maps a "model not found" error to a readable message, in case names change again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A spend limit looks like a rate limit.&lt;/strong&gt; When the hard spend limit is reached, the API answers with HTTP 429, the same status as "too many requests". A generic 429 handler would say "wait a moment and try again", which would never work. The panel tells the two apart by the error code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A diff above the editor was confusing.&lt;/strong&gt; The first version showed the proposal as a box above the real code, and it was easy to read one as the other. Swapping the editor for the diff while reviewing fixed it: one code view at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this doesn't cover
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The key is readable by the page that holds it.&lt;/strong&gt; The CSP limits where it can be sent, but code running on the host page could still read it. That's why the panel pushes restricted keys with spend limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Themes are deliberately limited.&lt;/strong&gt; Six tokens can't express everything a designer might want. That's the tradeoff for being able to validate every value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime behavior isn't reviewed.&lt;/strong&gt; The pre-check only catches what's visible statically. A proposal that compiles cleanly can still misbehave when it runs, and it's the sandbox that contains that, not the review.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;In the &lt;a href="https://playground.lrod.dev" rel="noopener noreferrer"&gt;playground&lt;/a&gt;, the theme tab has presets and two theme examples that fail on purpose, the pricing card shows slots, and &lt;code&gt;✦ ai edit&lt;/code&gt; works with your own key or the two demo responses. The &lt;a href="https://github.com/luisrrv/component-playground" rel="noopener noreferrer"&gt;README&lt;/a&gt; lists every safeguard next to the example that triggers it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lrod.dev/notes/theming-slots-and-ai-edits/" rel="noopener noreferrer"&gt;lrod.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>security</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Running untrusted React components in the browser</title>
      <dc:creator>Luis Rodriguez</dc:creator>
      <pubDate>Wed, 30 Sep 2026 02:49:11 +0000</pubDate>
      <link>https://dev.to/luisrrv/running-untrusted-react-components-in-the-browser-3mda</link>
      <guid>https://dev.to/luisrrv/running-untrusted-react-components-in-the-browser-3mda</guid>
      <description>&lt;p&gt;Page builders, plugin systems and customizable dashboards all have the same problem somewhere in them: users write UI code, and your app has to run it. That code might be broken or it might be hostile, and either way the rest of the page has to keep working.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://playground.lrod.dev" rel="noopener noreferrer"&gt;a small playground&lt;/a&gt; to work through that problem end to end. You write a React component in TSX, it compiles and renders live, and a gallery of examples tries to break it in nine different ways. This note walks through how it works, layer by layer, and what each layer does and doesn't protect against.&lt;/p&gt;

&lt;p&gt;The source is on &lt;a href="https://github.com/luisrrv/component-playground" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can go wrong
&lt;/h2&gt;

&lt;p&gt;Before choosing any tools, I listed what a user's component could do to the page hosting it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fail to compile.&lt;/strong&gt; A syntax error or a half-typed line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Import things it shouldn't.&lt;/strong&gt; Anything from a network client to your app's internal modules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get the wrong data.&lt;/strong&gt; Props that don't match what the component expects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throw.&lt;/strong&gt; During render, or later from a click handler or a timer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hang.&lt;/strong&gt; An infinite loop, or a regex that backtracks forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reach out.&lt;/strong&gt; Read the host's DOM, cookies or storage, or send data somewhere with &lt;code&gt;fetch&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these needs a different defense, and no single one covers them all. An error boundary doesn't stop a &lt;code&gt;fetch&lt;/code&gt;, and a sandboxed iframe doesn't stop an infinite loop. So the playground is built as layers, each with one job:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbzbvfdqzv47mw0kh3ff5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbzbvfdqzv47mw0kh3ff5.png" alt="Diagram of the five layers: the host compiles, scans imports and instruments loops, then posts the code to a sandboxed iframe, which resolves imports from an allowlist, validates props and renders inside an error boundary. A watchdog pings the sandbox every second." width="800" height="375"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Numbers match the sections below. Compiling happens in the host; everything that runs user code happens in the sandbox.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Compile in the browser
&lt;/h2&gt;

&lt;p&gt;The code starts as TSX, which browsers can't run. I wanted the demo to be a static site with no server, so compiling had to happen in the page.&lt;/p&gt;

&lt;p&gt;I used &lt;a href="https://github.com/alangpierce/sucrase" rel="noopener noreferrer"&gt;Sucrase&lt;/a&gt;. It strips TypeScript types and rewrites JSX, and it's small and fast because that's all it does. esbuild-wasm and Babel standalone can do more, but both are much bigger downloads, and a live preview doesn't need bundling or older-browser output.&lt;/p&gt;

&lt;p&gt;The part that matters most for safety is Sucrase's &lt;code&gt;imports&lt;/code&gt; transform. It rewrites every &lt;code&gt;import&lt;/code&gt; into a &lt;code&gt;require()&lt;/code&gt; call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Card&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kit/ui&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="c1"&gt;// becomes&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;_ui&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kit/ui&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives the runtime a single place where every dependency is requested, which the next layer uses.&lt;/p&gt;

&lt;p&gt;Compiling runs in the host rather than the sandbox. It's a pure string-to-string transform, so it's safe to run there, and syntax errors still show up with a line and column even if the sandbox is down.&lt;/p&gt;

&lt;p&gt;The tradeoff: Sucrase doesn't type-check. Type errors in user code are silently ignored, which is fine for a preview but worth knowing.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Scope what the code can import
&lt;/h2&gt;

&lt;p&gt;The obvious approach is to block the dangerous modules. That list never ends. The playground does the opposite: user code gets a fixed set of modules (&lt;code&gt;react&lt;/code&gt;, &lt;code&gt;zod&lt;/code&gt;, and a tiny UI kit), and everything else doesn't exist.&lt;/p&gt;

&lt;p&gt;This is checked twice. First, the host scans the compiled code for &lt;code&gt;require('...')&lt;/code&gt; calls and rejects anything off the list before the code is sent anywhere. The user gets a clear message and nothing runs.&lt;/p&gt;

&lt;p&gt;A static scan misses computed names like &lt;code&gt;require(someVar)&lt;/code&gt;, so the sandbox enforces the same list again at runtime. The code runs with a &lt;code&gt;require&lt;/code&gt; that only knows about the allowed modules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;require&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasOwn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`'&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;' is not available in this sandbox.`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;require&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;module&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;exports&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;require&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;module&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Object.hasOwn&lt;/code&gt; matters there. A plain &lt;code&gt;modules[name]&lt;/code&gt; lookup would also find inherited properties, so &lt;code&gt;require('constructor')&lt;/code&gt; would return something.&lt;/p&gt;

&lt;p&gt;One limit to be honest about: the allowlist covers modules, not globals. User code can still reach &lt;code&gt;window&lt;/code&gt;, &lt;code&gt;document&lt;/code&gt; and &lt;code&gt;fetch&lt;/code&gt; without importing anything. Containing those is the job of layer 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Validate props against a contract
&lt;/h2&gt;

&lt;p&gt;A component that gets the wrong props usually crashes somewhere deep inside, with an error that says nothing about the actual problem. So each module can export a contract alongside the component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;propsSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;member&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;exampleProps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Ada&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProfileCard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;propsSchema&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before every render, the props are checked against &lt;code&gt;propsSchema&lt;/code&gt;. If they don't match, the component never runs, and the user sees a list of what's wrong instead of a stack trace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;props don't match propsSchema:
name: Too small: expected string to have &amp;gt;=1 characters
role: Invalid option: expected one of "admin"|"member"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The previous render stays on screen, so a typo in the props editor doesn't blank out the preview.&lt;/p&gt;

&lt;p&gt;Two details here took some thought. The validation runs inside the sandbox, not the host, because the schema is itself user code, and user code only runs in the sandbox. And the check is duck-typed: anything with a &lt;code&gt;safeParse&lt;/code&gt; method counts as a schema. An &lt;code&gt;instanceof&lt;/code&gt; check would break the moment the schema came from a different copy of Zod.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Isolate it in a sandboxed iframe
&lt;/h2&gt;

&lt;p&gt;Layers 1 to 3 handle mistakes. This is the layer that handles bad intent, and it's the one that's enforced by the browser rather than by my code.&lt;/p&gt;

&lt;p&gt;Rendering happens in an iframe with &lt;code&gt;sandbox="allow-scripts"&lt;/code&gt;. What matters is what's missing: without &lt;code&gt;allow-same-origin&lt;/code&gt;, the frame gets an opaque origin. As far as the browser is concerned, it's a different site from the host, so it can't read the host's DOM, cookies or &lt;code&gt;localStorage&lt;/code&gt;. &lt;code&gt;window.parent.document&lt;/code&gt; throws a &lt;code&gt;SecurityError&lt;/code&gt;. The other flags that are left off also block forms, popups and navigating the top-level page.&lt;/p&gt;

&lt;p&gt;On top of that, the sandbox document has a strict Content Security Policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;default-src 'none';
script-src 'self' 'unsafe-eval';
connect-src 'none';
form-action 'none';
base-uri 'none'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;connect-src 'none'&lt;/code&gt; is the important line: &lt;code&gt;fetch&lt;/code&gt;, XHR and WebSocket all fail, so user code can't send anything anywhere. &lt;code&gt;'unsafe-eval'&lt;/code&gt; is needed because user code runs through &lt;code&gt;new Function&lt;/code&gt;, and that's acceptable here because the whole document exists to run untrusted code.&lt;/p&gt;

&lt;p&gt;The host and sandbox talk only through &lt;code&gt;postMessage&lt;/code&gt;, and both sides treat every message as untrusted input:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check the sender.&lt;/strong&gt; The host ignores anything that isn't from its own iframe (&lt;code&gt;event.source&lt;/code&gt;), and the sandbox ignores anything that isn't from its parent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate the shape.&lt;/strong&gt; Every message is parsed with a Zod schema on both sides, and error messages are length-capped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tag each render with an id.&lt;/strong&gt; If a slow render from three edits ago reports back late, the host sees an old id and drops it. Without this, a stale result can overwrite a newer one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because the sandbox has an opaque origin, messages have to be sent with a target origin of &lt;code&gt;'*'&lt;/code&gt;. That's fine only because nothing sensitive ever goes into them: just the user's own code and the render status.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Recover from crashes and hangs
&lt;/h2&gt;

&lt;p&gt;The last layer assumes something got through and makes sure the page recovers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Render errors&lt;/strong&gt; are caught by an error boundary around the component. &lt;strong&gt;Errors outside render&lt;/strong&gt;, like a throw in a click handler or a timer, never reach an error boundary, so the sandbox also listens for &lt;code&gt;error&lt;/code&gt; and &lt;code&gt;unhandledrejection&lt;/code&gt; on &lt;code&gt;window&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hangs&lt;/strong&gt; are harder. Error boundaries can't help with &lt;code&gt;while (true) {}&lt;/code&gt; because nothing is thrown, and a hung script can't report anything. I ended up with two mechanisms.&lt;/p&gt;

&lt;p&gt;The first is a &lt;strong&gt;loop guard&lt;/strong&gt;. Before the code is sent, the host parses it (with acorn) and rewrites every loop to check a timer on each iteration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// becomes&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;__t1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;__loopGuard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__t1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After one second, &lt;code&gt;__loopGuard&lt;/code&gt; throws, and the infinite loop becomes an ordinary render error. It works in every browser. It's also easy to get around if you're trying to, so it's a usability feature, not a security boundary.&lt;/p&gt;

&lt;p&gt;The second is a &lt;strong&gt;watchdog&lt;/strong&gt;, which catches everything else, like a regex that backtracks forever. The host pings the sandbox every second. If three seconds pass without a reply, the host throws the whole iframe away and mounts a fresh one: new document, new JavaScript realm. It deliberately doesn't resend the code that hung, or the new iframe would hang too.&lt;/p&gt;

&lt;p&gt;The watchdog has a catch: it only works if the stuck iframe isn't blocking the host. Chromium runs sandboxed cross-origin frames in their own process, so the host keeps running and can pull the plug. Browsers that share a thread between the two freeze the whole tab until the loop guard fires. That's why the loop guard matters, even though the watchdog looks like the stronger tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that surprised me
&lt;/h2&gt;

&lt;p&gt;A few bugs taught me more than the parts that worked the first time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The sandbox couldn't load its own scripts.&lt;/strong&gt; With an opaque origin, even the sandbox's own JavaScript files count as cross-origin, and module scripts are fetched with CORS. The iframe sat on "loading" until I served &lt;code&gt;/assets/*&lt;/code&gt; with &lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt;. That's safe here because those files are public anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An infinite loop hit the watchdog instead of the loop guard.&lt;/strong&gt; React retries a render that throws before giving up, so the guard threw, React tried again, and the same loop ran for another second, then another. The fix was to make the guard "sticky": once it trips, it throws immediately until the next render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Rendered" was reported before anything rendered.&lt;/strong&gt; I posted success right after calling &lt;code&gt;root.render()&lt;/code&gt;, but rendering happens later. A component that crashed during render would briefly report success, then error. Now a small wrapper sends "rendered" from an effect, which only runs once React has actually committed the tree.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this doesn't cover
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Type-checking.&lt;/strong&gt; Types are stripped, not checked, and nothing verifies that &lt;code&gt;propsSchema&lt;/code&gt; matches the component's prop types.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPU and memory limits.&lt;/strong&gt; The watchdog recovers from hangs, but a component can still use a lot of memory or CPU before anything notices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Side channels.&lt;/strong&gt; Timing tricks and layout measurement aren't in scope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The browser's own sandbox.&lt;/strong&gt; Everything in layer 4 relies on the browser enforcing origins and CSP correctly, which is a reasonable bet but still a bet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For production use, the next steps would be running the compiler in a Web Worker, type-checking where it matters, and serving the sandbox from a separate domain rather than relying only on the opaque origin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://playground.lrod.dev" rel="noopener noreferrer"&gt;playground&lt;/a&gt; has a working example and one example per failure case, so you can watch each layer catch something. The &lt;a href="https://github.com/luisrrv/component-playground" rel="noopener noreferrer"&gt;source and README&lt;/a&gt; go into each decision in more detail.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lrod.dev/notes/running-untrusted-components/" rel="noopener noreferrer"&gt;lrod.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>security</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Never-Ending Game of Whack-a-Mole: A Hilarious Take on JavaScript Frameworks</title>
      <dc:creator>Luis Rodriguez</dc:creator>
      <pubDate>Mon, 15 May 2023 03:07:25 +0000</pubDate>
      <link>https://dev.to/luisrrv/the-never-ending-game-of-whack-a-mole-a-hilarious-take-on-javascript-frameworks-1n67</link>
      <guid>https://dev.to/luisrrv/the-never-ending-game-of-whack-a-mole-a-hilarious-take-on-javascript-frameworks-1n67</guid>
      <description>&lt;p&gt;Let's face it, frameworks have made our lives easier and our code more efficient. But sometimes, it's good to take a step back and laugh at the absurdity of it all. So, without further ado, let's dive into the wild world of JavaScript frameworks.&lt;/p&gt;

&lt;p&gt;Oh boy, where do I even begin with JavaScript frameworks? It seems like every day there's a new one popping up, promising to be the ultimate solution for all your front-end development woes. And let's be real, half of them are just fancy buzzwords slapped together in a way that sounds impressive but ultimately ends up being more confusing than helpful.&lt;/p&gt;

&lt;p&gt;Don't get me wrong, I love a good framework as much as the next developer. But sometimes it feels like we're just creating more work for ourselves by constantly jumping from one to the next, trying to stay on top of the latest trends. It's like a never-ending game of whack-a-mole, except instead of moles, it's just different variations of React.&lt;/p&gt;

&lt;p&gt;And let's not forget the joy of trying to integrate these frameworks with each other. It's like trying to fit a square peg into a round hole, except the peg is made of spaghetti code and the hole is a black hole of dependencies. You spend hours trying to get everything to work together, only to have it break again the moment you add a new feature.&lt;/p&gt;

&lt;p&gt;But hey, at least we can all bond over our shared frustration, right? Maybe one day we'll all just give up and go back to plain old vanilla JavaScript. Or maybe we'll just keep creating new frameworks until the end of time. Who knows? All I know is that I need a drink after trying to figure out which version of Angular I should be using. Cheers to all my fellow JavaScript devs out there, may our code always compile and our dependencies never break.&lt;/p&gt;

</description>
      <category>aigenerated</category>
      <category>javascript</category>
      <category>frameworks</category>
    </item>
  </channel>
</rss>
