<?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: Jiwon Yoon</title>
    <description>The latest articles on DEV Community by Jiwon Yoon (@jiwonyoondev).</description>
    <link>https://dev.to/jiwonyoondev</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%2F4035269%2F7757e082-90bc-4d6d-bbf7-bdc40cd99b19.png</url>
      <title>DEV Community: Jiwon Yoon</title>
      <link>https://dev.to/jiwonyoondev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jiwonyoondev"/>
    <language>en</language>
    <item>
      <title>Clear the Lineup — doesNotEqual was always true for single-select survey answers in Formbricks</title>
      <dc:creator>Jiwon Yoon</dc:creator>
      <pubDate>Sat, 18 Jul 2026 12:35:33 +0000</pubDate>
      <link>https://dev.to/jiwonyoondev/clear-the-lineup-doesnotequal-was-always-true-for-single-select-survey-answers-in-formbricks-702</link>
      <guid>https://dev.to/jiwonyoondev/clear-the-lineup-doesnotequal-was-always-true-for-single-select-survey-answers-in-formbricks-702</guid>
      <description>&lt;p&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/formbricks/formbricks" rel="noopener noreferrer"&gt;Formbricks&lt;/a&gt; is an open-source survey and experience-management platform. Its &lt;code&gt;packages/surveys&lt;/code&gt; package ships the client-side survey runner, and inside it, &lt;code&gt;packages/surveys/src/lib/logic.ts&lt;/code&gt; is the module that decides, for every question and every "skip logic" / branching rule a survey author configures, whether a given condition (&lt;code&gt;equals&lt;/code&gt;, &lt;code&gt;doesNotEqual&lt;/code&gt;, &lt;code&gt;contains&lt;/code&gt;, &lt;code&gt;isEmpty&lt;/code&gt;, and friends) is true for a respondent's answer. This is pure, unglamorous logic — but it's load-bearing: it's what routes respondents through the correct sequence of questions.&lt;/p&gt;

&lt;p&gt;I picked up &lt;a href="https://github.com/formbricks/formbricks/issues/8527" rel="noopener noreferrer"&gt;issue #8527&lt;/a&gt; for the "Clear the Lineup" track, which reports that &lt;code&gt;doesNotEqual&lt;/code&gt; conditions weren't behaving correctly for single-choice questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;doesNotEqual&lt;/code&gt; operator is supposed to be the exact logical negation of &lt;code&gt;equals&lt;/code&gt;. For most answer shapes it was. But for &lt;code&gt;MultipleChoiceSingle&lt;/code&gt; answers, the survey runtime sometimes represents the selected value as a single-element array rather than a bare string (this happens via &lt;code&gt;getLeftOperandValue&lt;/code&gt;, e.g. when a choice answer is merged with an "other" option path). To handle that shape, both &lt;code&gt;equals&lt;/code&gt; and &lt;code&gt;doesNotEqual&lt;/code&gt; had a special-case clause that unwraps a one-element array and compares its only entry to the right-hand value.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;equals&lt;/code&gt;'s fallback:&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;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;leftValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;leftValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;rightValue&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;leftValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rightValue&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="nx"&gt;leftValue&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;rightValue&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;doesNotEqual&lt;/code&gt;'s fallback (before the fix):&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;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;leftValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;leftValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;rightValue&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;leftValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rightValue&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="nx"&gt;leftValue&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;rightValue&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At a glance this looks like a faithful "negate every sub-expression" mirror of &lt;code&gt;equals&lt;/code&gt;. It isn't. The array-includes clause was correctly inverted (&lt;code&gt;!leftValue.includes(rightValue)&lt;/code&gt;), but the second disjunct, &lt;code&gt;leftValue !== rightValue&lt;/code&gt;, is not the negation of the whole &lt;code&gt;equals&lt;/code&gt; expression — it's the negation of only the plain-comparison branch.&lt;/p&gt;

&lt;p&gt;Here's the JavaScript gotcha that makes this a real bug and not just a stylistic quibble: when &lt;code&gt;leftValue&lt;/code&gt; is an array like &lt;code&gt;["Option 1"]&lt;/code&gt; and &lt;code&gt;rightValue&lt;/code&gt; is the string &lt;code&gt;"Option 1"&lt;/code&gt; (or a choice id like &lt;code&gt;"opt1"&lt;/code&gt;), &lt;code&gt;leftValue !== rightValue&lt;/code&gt; is &lt;em&gt;always&lt;/em&gt; &lt;code&gt;true&lt;/code&gt; — an array and a string are never &lt;code&gt;===&lt;/code&gt;, regardless of contents, so the &lt;code&gt;!==&lt;/code&gt; comparison can never be &lt;code&gt;false&lt;/code&gt; for this input shape. Because the whole fallback is an OR of two clauses, one clause that's unconditionally &lt;code&gt;true&lt;/code&gt; makes the entire expression unconditionally &lt;code&gt;true&lt;/code&gt;. The array-includes clause's correct, careful negation gets swallowed by the second clause and never has a chance to matter. In other words: &lt;code&gt;doesNotEqual&lt;/code&gt; was a tautology whenever the left value was a single-element array — it returned &lt;code&gt;true&lt;/code&gt; no matter what the respondent actually selected.&lt;/p&gt;

&lt;p&gt;The practical impact: any survey using skip logic or branching with "does not equal" against a single-choice question would silently misroute every respondent, since the condition was always satisfied. This is the kind of bug that's invisible in casual manual testing — you only notice it if you specifically test the branch that's supposed to be &lt;em&gt;excluded&lt;/em&gt;, which is exactly the path people tend not to click through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;PR: &lt;a href="https://github.com/jiwonyoon-dev/formbricks/pull/1" rel="noopener noreferrer"&gt;https://github.com/jiwonyoon-dev/formbricks/pull/1&lt;/a&gt; (merged)&lt;/p&gt;

&lt;p&gt;A transparency note on where this PR lives: the upstream &lt;code&gt;formbricks/formbricks&lt;/code&gt; repository currently restricts opening pull requests to existing collaborators, so I couldn't submit this fix upstream directly. The fix is a merged PR on my fork instead — the challenge rules explicitly accept fork merges — and the branch is ready to upstream the moment the maintainers open contributions or pick it up from &lt;a href="https://github.com/formbricks/formbricks/issues/8527" rel="noopener noreferrer"&gt;issue #8527&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The fix replaces the piecewise-negated fallback with a literal De Morgan negation of the entire &lt;code&gt;equals&lt;/code&gt; expression, guaranteeing &lt;code&gt;equals&lt;/code&gt; and &lt;code&gt;doesNotEqual&lt;/code&gt; can never agree on the same inputs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-        return (
&lt;/span&gt;&lt;span class="gi"&gt;+        return !(
&lt;/span&gt;           (Array.isArray(leftValue) &amp;amp;&amp;amp;
             leftValue.length === 1 &amp;amp;&amp;amp;
             typeof rightValue === "string" &amp;amp;&amp;amp;
&lt;span class="gd"&gt;-            !leftValue.includes(rightValue)) ||
-          leftValue !== rightValue
&lt;/span&gt;&lt;span class="gi"&gt;+            leftValue.includes(rightValue)) ||
+          leftValue === rightValue
&lt;/span&gt;         );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the regression test added to &lt;code&gt;packages/surveys/src/lib/logic.test.ts&lt;/code&gt;, in the "Edge Cases" describe block right after the existing "evaluates multiple choice conditions for equals/doesNotEqual" test, reusing the same &lt;code&gt;mockSurvey&lt;/code&gt; / &lt;code&gt;mockVariablesData&lt;/code&gt; fixtures already in scope:&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;evaluates doesNotEqual for single-selection answers stored as an array (#8527)&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Regression test: doesNotEqual must be the exact negation of equals. Previously, when the&lt;/span&gt;
  &lt;span class="c1"&gt;// left value was a single-element array (e.g. a MultipleChoiceSingle answer merged with&lt;/span&gt;
  &lt;span class="c1"&gt;// "other" options) matching a static right value, doesNotEqual incorrectly returned true.&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
  &lt;span class="c1"&gt;// The single selected answer matches the compared option, so doesNotEqual must be false.&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;evaluateLogic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;singleSelectSurvey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;singleSelectQ&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Option 1&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="nx"&gt;mockVariablesData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;doesNotEqualMatchingCondition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default&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="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// The single selected answer does not match the compared option, so doesNotEqual must be true.&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;evaluateLogic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;singleSelectSurvey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;singleSelectQ&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Option 1&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="nx"&gt;mockVariablesData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;doesNotEqualNonMatchingCondition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default&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="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Plain string comparisons must remain unaffected by the fix.&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;evaluateLogic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mockSurvey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mockData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mockVariablesData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;doesNotEqualStringCondition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default&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="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;evaluateLogic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mockSurvey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mockData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mockVariablesData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;doesNotEqualStringMismatchCondition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default&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="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&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;The commit follows the conventional format already used across the repo's last 15 commits on &lt;code&gt;main&lt;/code&gt;: &lt;code&gt;fix(surveys): make doesNotEqual the exact negation of equals for single-select answers (#8527)&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;I'll be upfront: I worked through this with an AI coding agent (Claude) doing the hands-on triage, reproduction, and patch drafting, while I reviewed and directed each step. That's the honest shape of how this fix came together, and I think it's a fair way to work a bug like this — the agent is good at mechanically tracing through boolean algebra and running the test suite over and over; I was there to sanity-check the reasoning and make the call on scope.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before the fix&lt;/strong&gt;, we added the regression test above to the unmodified &lt;code&gt;logic.ts&lt;/code&gt; and ran it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;npx vitest run src/lib/logic.test.ts
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AssertionError: expected true to be false // Object.is equality
- Expected: false
+ Received: true
  at src/lib/logic.test.ts:1436:9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the exact tautology from the triage, caught red-handed: &lt;code&gt;doesNotEqual&lt;/code&gt; returned &lt;code&gt;true&lt;/code&gt; for a matching single-select answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After the fix&lt;/strong&gt;, the same focused run passed 42/42, including all three assertions in the new regression test (single-select array matching → &lt;code&gt;false&lt;/code&gt;, single-select array not matching → &lt;code&gt;true&lt;/code&gt;, plain-string match/mismatch → unaffected). Running the full package suite, &lt;code&gt;npx vitest run&lt;/code&gt; in &lt;code&gt;packages/surveys&lt;/code&gt;, gave 26 test files / 651 tests, all passing — no regressions elsewhere. &lt;code&gt;npx eslint src/lib/logic.ts src/lib/logic.test.ts --ext .ts&lt;/code&gt; produced zero output (clean).&lt;/p&gt;

&lt;p&gt;I also verified the change algebraically, not just empirically: when the array-single-element-and-string condition is false, both the old and new fallback reduce to the same thing (&lt;code&gt;leftValue !== rightValue&lt;/code&gt; vs. the negation of &lt;code&gt;leftValue === rightValue&lt;/code&gt; — identical). The &lt;em&gt;only&lt;/em&gt; input shape whose behavior changes is the tautology case itself, which is exactly the invariant we wanted: &lt;code&gt;equals&lt;/code&gt; and &lt;code&gt;doesNotEqual&lt;/code&gt; can never both be true, or both be false, for the same inputs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope decisions and honest limitations.&lt;/strong&gt; I deliberately left the &lt;code&gt;PictureSelection&lt;/code&gt;/&lt;code&gt;Date&lt;/code&gt; special-case branches inside &lt;code&gt;doesNotEqual&lt;/code&gt; untouched, even though they don't have a symmetric branch on the &lt;code&gt;equals&lt;/code&gt; side — that asymmetry predates this issue and fixing it isn't part of a minimal, reviewable patch for #8527. I also ran &lt;code&gt;tsc --noEmit&lt;/code&gt; for awareness and saw only pre-existing, unrelated module-resolution errors from &lt;code&gt;@formbricks/survey-ui&lt;/code&gt; and &lt;code&gt;@formbricks/i18n-utils&lt;/code&gt; — artifacts of only &lt;code&gt;packages/surveys&lt;/code&gt; being pnpm-installed with &lt;code&gt;--filter&lt;/code&gt; rather than a full monorepo install. Neither touched file appears in that error list, but I want to note the gap honestly rather than pretend the build was 100% clean end-to-end; I didn't attempt a full monorepo install to clear it since it's environmental noise, not something the code change caused. No e2e/integration tests exist for this pure-function logic module in this package — unit tests in the 1500+-line &lt;code&gt;logic.test.ts&lt;/code&gt; are the established convention here, so that's what I extended.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson worth keeping.&lt;/strong&gt; The generalizable gotcha is: &lt;code&gt;someArray !== someString&lt;/code&gt; is &lt;em&gt;always&lt;/em&gt; &lt;code&gt;true&lt;/code&gt; in JavaScript, because they're different types and can never be triple-equal. Any fallback shaped like &lt;code&gt;(specialCase) || leftValue !== rightValue&lt;/code&gt; is a landmine the moment &lt;code&gt;leftValue&lt;/code&gt; can be an array — the "obviously true when types differ" clause silently overrides whatever careful special-casing you did in the first disjunct. When you're negating a multi-clause boolean expression, negate the &lt;em&gt;whole&lt;/em&gt; expression (&lt;code&gt;!(...)&lt;/code&gt;), don't negate each clause independently and OR them back together — those are not equivalent once more than one clause is in play. That's the one-line rule that would have prevented this bug from ever shipping.&lt;/p&gt;

</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Clear the Lineup — Astro's `popover` Attribute Had No Way to Say False</title>
      <dc:creator>Jiwon Yoon</dc:creator>
      <pubDate>Sat, 18 Jul 2026 12:26:10 +0000</pubDate>
      <link>https://dev.to/jiwonyoondev/clear-the-lineup-astros-popover-attribute-had-no-way-to-say-false-15e1</link>
      <guid>https://dev.to/jiwonyoondev/clear-the-lineup-astros-popover-attribute-had-no-way-to-say-false-15e1</guid>
      <description>&lt;p&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://astro.build" rel="noopener noreferrer"&gt;Astro&lt;/a&gt; is the content-first web framework much of the JS ecosystem reaches for — docs sites, blogs, increasingly full apps — built around server-rendering &lt;code&gt;.astro&lt;/code&gt; templates to plain HTML with an island-based hydration model. Buried in that renderer is a small, unglamorous function called &lt;code&gt;addAttribute()&lt;/code&gt;, whose entire job is deciding what string, if any, gets printed for a given prop value and attribute name. Nobody thinks about it — until it starts emitting invalid HTML on every page using one specific attribute on a custom element.&lt;/p&gt;

&lt;p&gt;That's &lt;a href="https://github.com/withastro/astro/issues/17398" rel="noopener noreferrer"&gt;issue #17398&lt;/a&gt;: "For Custom HTML Tags Astro Compile Rewrites popover Attribute to Invalid Value."&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt; here's a piece of web-platform trivia I didn't know until this bug bit me: the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Popover_API" rel="noopener noreferrer"&gt;Popover API&lt;/a&gt; has no valid &lt;code&gt;"false"&lt;/code&gt;. &lt;code&gt;popover&lt;/code&gt; isn't a plain boolean HTML attribute like &lt;code&gt;disabled&lt;/code&gt; or &lt;code&gt;checked&lt;/code&gt; — it's an &lt;em&gt;enumerated&lt;/em&gt; attribute with exactly three legal states: absent, &lt;code&gt;"auto"&lt;/code&gt;, or &lt;code&gt;"manual"&lt;/code&gt;. There is no string spelling of "off." Write &lt;code&gt;popover="false"&lt;/code&gt; into the DOM and the browser sees an attribute present with an invalid value, which per spec falls back to a &lt;em&gt;valid&lt;/em&gt; popover state. So &lt;code&gt;popover="false"&lt;/code&gt; doesn't disable a popover — it enables one.&lt;/p&gt;

&lt;p&gt;That's exactly what Astro was doing on custom elements. Write &lt;code&gt;&amp;lt;el-popover popover class="..."&amp;gt;&lt;/code&gt; in a &lt;code&gt;.astro&lt;/code&gt; file, and the compiled output was &lt;code&gt;&amp;lt;el-popover popover="true" class="..."&amp;gt;&lt;/code&gt;. The issue's reporter had a working StackBlitz repro on Astro 7.0.9 with Tailwind Plus Elements, showing this silently breaking light-dismiss (click-outside-to-close) behavior — no console warning, no type error, nothing pointing at the actual cause. It's the kind of bug you only find by staring at rendered HTML and knowing the spec well enough to notice something's off.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hunt
&lt;/h3&gt;

&lt;p&gt;I'll be upfront about how this went: I worked through it with Claude, using Claude Code as an agentic pair rather than reading 500+ files solo. The initial pass had already narrowed things to &lt;code&gt;handleBooleanAttribute()&lt;/code&gt; in &lt;code&gt;packages/astro/src/runtime/server/render/util.ts&lt;/code&gt; and named two suspect PRs. A named suspect isn't a confirmed root cause, though, so the real work was reading the current source myself and checking the story against it line by line.&lt;/p&gt;

&lt;p&gt;It held up, and it's a genuinely nice case of two individually-correct PRs colliding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PR #13037&lt;/strong&gt; taught &lt;code&gt;addAttribute()&lt;/code&gt; that &lt;code&gt;popover&lt;/code&gt; is special: it's boolean-shaped (&lt;code&gt;true&lt;/code&gt;/&lt;code&gt;false&lt;/code&gt; in JS) but &lt;em&gt;also&lt;/em&gt; accepts the strings &lt;code&gt;"auto"&lt;/code&gt;/&lt;code&gt;"manual"&lt;/code&gt;, so it can't just live in the simple &lt;code&gt;htmlBooleanAttributes&lt;/code&gt; regex like &lt;code&gt;hidden&lt;/code&gt; does. It routes &lt;code&gt;popover&lt;/code&gt; through the shared &lt;code&gt;handleBooleanAttribute()&lt;/code&gt; helper instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PR #13909&lt;/strong&gt;, later and unrelated, taught that same shared helper a different rule: for &lt;em&gt;custom elements&lt;/em&gt; (any tag name containing a hyphen), always stringify boolean attributes into &lt;code&gt;key="true"&lt;/code&gt;/&lt;code&gt;key="false"&lt;/code&gt;, fixing real bugs where Lit components and things like &lt;code&gt;autoplay&lt;/code&gt; need an actual attribute value rather than presence/absence, matching how custom-element property reflection works.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither PR was wrong on its own. They just both modified the same chokepoint function for opposite reasons, and &lt;code&gt;popover&lt;/code&gt; is the one attribute that's boolean-shaped in JS but has no valid "off" string in HTML — so it fell straight through the crack. The function was checking "is this a custom element?" before it ever asked "is this specifically &lt;code&gt;popover&lt;/code&gt;?" — so &lt;code&gt;popover&lt;/code&gt; inherited stringification semantics meant for a completely different problem (Lit prop reflection).&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;PR: &lt;a href="https://github.com/withastro/astro/pull/17422" rel="noopener noreferrer"&gt;https://github.com/withastro/astro/pull/17422&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix, in &lt;code&gt;packages/astro/src/runtime/server/render/util.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; function handleBooleanAttribute(
    key: string,
    value: boolean | string,
    shouldEscape: boolean,
    tagName?: string,
 ): string {
&lt;span class="gi"&gt;+   // The Popover API only accepts "auto", "manual", or the attribute being absent.
+   // There's no valid string value for "off", so it must always be rendered as a
+   // boolean attribute, even on custom elements.
+   if (key === 'popover') {
+       return markHTMLString(value ? ` ${key}` : '');
+   }
&lt;/span&gt;    // For custom elements, always render as string attributes
    if (tagName &amp;amp;&amp;amp; isCustomElement(tagName)) {
        return markHTMLString(` ${key}="${toAttributeString(value, shouldEscape)}"`);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six lines. A guard that checks &lt;code&gt;popover&lt;/code&gt; first and always returns it as a bare-or-omitted boolean attribute, regardless of tag name, before the custom-element stringify branch ever runs. &lt;code&gt;download&lt;/code&gt; and &lt;code&gt;hidden&lt;/code&gt; — the other two attributes that flow through this same helper — are deliberately left untouched, since their custom-element stringification is correct and unrelated to this bug.&lt;/p&gt;

&lt;p&gt;I added a matching regression test to the existing suite, &lt;code&gt;packages/astro/test/units/app/astro-attrs.test.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;     &amp;lt;custom-el id="popover-custom-el-true"${addAttribute(true, 'popover', true, 'custom-el')} /&amp;gt;
     &amp;lt;custom-el id="popover-custom-el-false"${addAttribute(false, 'popover', true, 'custom-el')} /&amp;gt;
     &amp;lt;custom-el id="popover-custom-el-auto"${addAttribute('auto', 'popover', true, 'custom-el')} /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with expectations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;            'popover-custom-el-true': { attribute: 'popover', value: '' },
            'popover-custom-el-false': { attribute: 'popover', value: undefined },
            'popover-custom-el-auto': { attribute: 'popover', value: 'auto' },
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;plus a changeset documenting the patch for the release notes. Three files, 18 lines, all additions.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Confirming the bug before touching code.&lt;/strong&gt; Before writing a fix, I reproduced the failure using the test file's own existing convention — calling &lt;code&gt;addAttribute()&lt;/code&gt; directly rather than compiling a full &lt;code&gt;.astro&lt;/code&gt; file, since every other popover case in that file already does it that way. Adding the custom-element case and running the suite gave a clean, unambiguous failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AssertionError [ERR_ASSERTION]: Expected popover to be  for #popover-custom-el-true
'true' !== ''
actual: 'true', expected: '', operator: 'strictEqual'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the bug, byte for byte: the renderer emitting &lt;code&gt;popover="true"&lt;/code&gt; where it should emit bare &lt;code&gt;popover&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Writing a guard, not a special case pile-up.&lt;/strong&gt; The tempting quick fix is an &lt;code&gt;if (tagName?.includes('-'))&lt;/code&gt; check tucked into &lt;code&gt;addAttribute()&lt;/code&gt; itself. I didn't want popover logic living in two places, so the guard went directly inside &lt;code&gt;handleBooleanAttribute()&lt;/code&gt;, ahead of the custom-element branch. Every caller of that function now gets the correct behavior for free, and the diff reads as "popover is exempt from custom-element stringification" rather than a scattered patch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After the fix&lt;/strong&gt;, the same test command passed all three new cases — &lt;code&gt;popover-custom-el-true&lt;/code&gt; → bare &lt;code&gt;popover&lt;/code&gt;, &lt;code&gt;popover-custom-el-false&lt;/code&gt; → attribute omitted, &lt;code&gt;popover-custom-el-auto&lt;/code&gt; → &lt;code&gt;popover="auto"&lt;/code&gt; preserved — and every plain-element popover case kept passing unchanged, since the new branch is byte-identical output to the old fallback on that path. I widened the net past that one file: the class-list/style, directives, html-primitives, slots, hydration, and URL-attribute-XSS suites (124/124), the custom-element escaping suite (18/18), and the JSX custom-elements rendering suite (5/5). 150 passing tests total, directly touching the changed function and its neighbors, zero failures.&lt;/p&gt;

&lt;p&gt;I'll be equally honest about what I &lt;em&gt;didn't&lt;/em&gt; run: the full 235-file unit suite. Astro is a 517-package monorepo, and a full &lt;code&gt;pnpm install&lt;/code&gt; was blocked by an unrelated npm registry trust-downgrade flag on a Netlify integration's dependency — nothing to do with this bug. Scoping the install with &lt;code&gt;pnpm install --filter ...astro&lt;/code&gt; got me a working build, but left packages like &lt;code&gt;@astrojs/markdown-remark&lt;/code&gt; unbuilt, and since the test runner concatenates all matched files into one process, a single missing import aborts the whole run before anything executes. So I ran the focused, targeted slice instead of claiming a full green suite I didn't actually get.&lt;/p&gt;

&lt;p&gt;The toolchain had its own side quest: the repo's &lt;code&gt;pnpm-lock.yaml&lt;/code&gt; is a multi-document YAML file — a self-pin block for &lt;code&gt;pnpm@11.10.0&lt;/code&gt; concatenated ahead of the real lockfile — that only pnpm 11.x parses correctly. My globally-installed pnpm 10.x quietly treated it as malformed and fell back to full re-resolution from the registry, which is what surfaced the unrelated trust-downgrade error in the first place. A "broken lockfile" warning was masking a different supply-chain gate one layer down. I also learned that setting &lt;code&gt;NODE_OPTIONS&lt;/code&gt; mid-process to add &lt;code&gt;--experimental-strip-types&lt;/code&gt; does nothing: Node reads that variable once at boot, and Astro's own test runner needs it passed to the initial &lt;code&gt;node&lt;/code&gt; invocation directly, not toggled at runtime.&lt;/p&gt;

&lt;p&gt;The broader lesson I'm carrying past this specific bug: not every HTML attribute that &lt;em&gt;looks&lt;/em&gt; boolean &lt;em&gt;is&lt;/em&gt; boolean. &lt;code&gt;disabled&lt;/code&gt;, &lt;code&gt;hidden&lt;/code&gt;, and &lt;code&gt;checked&lt;/code&gt; are true booleans — present or absent, nothing in between. &lt;code&gt;popover&lt;/code&gt; looks like one in JS (&lt;code&gt;true&lt;/code&gt;/&lt;code&gt;false&lt;/code&gt;), but it's actually an enumerated attribute with a closed set of legal string values and no spelling for "off." Any framework's attribute-serialization layer that treats "this prop is boolean-typed" as equivalent to "render it as a bare HTML boolean attribute" is exactly where enumerated attributes like &lt;code&gt;popover&lt;/code&gt; — or tristate ones like &lt;code&gt;aria-checked&lt;/code&gt; — will quietly fall through the crack. Two correct PRs, one shared chokepoint function, and the one attribute that doesn't fit either PR's mental model cleanly: that's the whole bug.&lt;/p&gt;

</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>astro</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Clear the Lineup — Chasing a Stack Overflow in Typst's `#eval` Down to One Wrong Word</title>
      <dc:creator>Jiwon Yoon</dc:creator>
      <pubDate>Sat, 18 Jul 2026 12:23:29 +0000</pubDate>
      <link>https://dev.to/jiwonyoondev/clear-the-lineup-chasing-a-stack-overflow-in-typsts-eval-down-to-one-wrong-word-5427</link>
      <guid>https://dev.to/jiwonyoondev/clear-the-lineup-chasing-a-stack-overflow-in-typsts-eval-down-to-one-wrong-word-5427</guid>
      <description>&lt;p&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/typst/typst" rel="noopener noreferrer"&gt;Typst&lt;/a&gt; is the Rust-based markup typesetting system that's been quietly eating LaTeX's lunch for the last couple of years — you write plain-text markup, Typst compiles it to PDF (or HTML) with a real incremental compiler underneath instead of a multi-pass macro soup. Part of what makes it pleasant is that it exposes its own evaluator to itself: a document can call &lt;code&gt;#eval("some typst code")&lt;/code&gt; and get back whatever that code produces, at runtime, inside the document being compiled. It's a small feature, but it opens up a surprisingly deep rabbit hole, which is exactly where this bug was hiding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt; &lt;a href="https://github.com/typst/typst/issues/8632" rel="noopener noreferrer"&gt;typst/typst#8632&lt;/a&gt; reports that Typst crashes instead of erroring when &lt;code&gt;#eval&lt;/code&gt; is used to recursively re-import the file it's running in. The repro is one line. Save this as &lt;code&gt;overflow.typ&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#eval("import \"overflow.typ\"")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;./target/debug/typst compile overflow.typ overflow.pdf
&lt;span class="go"&gt;thread 'main' (750833) has overflowed its stack
fatal runtime error: stack overflow, aborting
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No PDF, no diagnostic, no exit code you can act on — just a native stack overflow and a SIGABRT. Typst has had cyclic-import detection since forever; a normal top-level &lt;code&gt;import "overflow.typ"&lt;/code&gt; inside itself gets caught cleanly with an &lt;code&gt;error: cyclic import&lt;/code&gt; message. Something about routing the import through &lt;code&gt;#eval&lt;/code&gt; specifically was bypassing that check.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hunt
&lt;/h3&gt;

&lt;p&gt;I'll be upfront about how this investigation actually went: I worked through it with Claude (Anthropic's coding agent) rather than solo. The triage pass it did before I sat down with the code had already narrowed the search to one function — &lt;code&gt;eval_string&lt;/code&gt; in &lt;code&gt;crates/typst-eval/src/lib.rs&lt;/code&gt; — and named the suspect line. That's a big head start, but a named suspect isn't a confirmed root cause, so the actual work was reading that function next to its sibling, the file-level &lt;code&gt;eval()&lt;/code&gt; a few lines above it, and checking the claim against the source myself rather than taking the plan on faith.&lt;/p&gt;

&lt;p&gt;And it held up. Side by side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;File-level &lt;code&gt;eval()&lt;/code&gt; builds its engine with &lt;code&gt;route: Route::extend(route).with_id(id)&lt;/code&gt;, and &lt;code&gt;import_file&lt;/code&gt; bails with a cyclic-import error whenever &lt;code&gt;route.contains(id)&lt;/code&gt; is true for the file it's about to open.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;eval_string&lt;/code&gt; — the routine that actually backs the &lt;code&gt;#eval()&lt;/code&gt; builtin — builds a deliberately "detached" engine (fresh &lt;code&gt;Scope&lt;/code&gt;, &lt;code&gt;EmptyIntrospector&lt;/code&gt;, &lt;code&gt;Context::none()&lt;/code&gt;), and for its route it just did &lt;code&gt;route: Route::default()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the entire bug, in one word: &lt;code&gt;default()&lt;/code&gt; instead of &lt;code&gt;extend()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root cause deep-dive
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Route&lt;/code&gt; is a linked structure — each frame in a call chain points back at its caller, and Typst uses it for two independent guards: &lt;code&gt;route.contains(id)&lt;/code&gt; for cyclic-import detection, and &lt;code&gt;Route::check_call_depth&lt;/code&gt; (an &lt;code&gt;~80&lt;/code&gt;-deep recursion cap — the same guard that trips on deeply nested show rules or recursive calls elsewhere in Typst). Both guards work by walking that &lt;code&gt;outer&lt;/code&gt; chain.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Route::default()&lt;/code&gt; has no &lt;code&gt;outer&lt;/code&gt; chain and no &lt;code&gt;id&lt;/code&gt;. So from the guards' point of view, every single &lt;code&gt;#eval()&lt;/code&gt; call looks like the very first evaluation ever performed — depth zero, no ancestors — no matter how many stack frames of real recursion are sitting underneath it. Trace what actually happens with &lt;code&gt;overflow.typ&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Evaluating &lt;code&gt;overflow.typ&lt;/code&gt; builds route &lt;code&gt;R0&lt;/code&gt;, tagged with id &lt;code&gt;overflow.typ&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;#eval("import \"overflow.typ\"")&lt;/code&gt; call invokes &lt;code&gt;eval_string&lt;/code&gt;, which throws away &lt;code&gt;R0&lt;/code&gt; and builds &lt;code&gt;R_fresh = Route::default()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Inside that fresh, historyless engine, it evaluates &lt;code&gt;import "overflow.typ"&lt;/code&gt;. &lt;code&gt;import_file&lt;/code&gt; checks &lt;code&gt;R_fresh.contains(overflow.typ)&lt;/code&gt; — false, because &lt;code&gt;R_fresh&lt;/code&gt; remembers nothing.&lt;/li&gt;
&lt;li&gt;That import re-enters &lt;code&gt;eval()&lt;/code&gt; → &lt;code&gt;eval_string()&lt;/code&gt; → another fresh, disconnected route → another import → ... forever.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing ever trips, because nothing is ever checked against real history. The only thing left to stop it is Rust's stack guard page, and that failure mode is a process abort, not a &lt;code&gt;SourceResult&lt;/code&gt; error — which is exactly what made this bug both easy to trigger and unpleasant to hit.&lt;/p&gt;

&lt;p&gt;The tell, once you're looking for it: &lt;code&gt;eval_closure&lt;/code&gt;, the sibling routine used for calling ordinary closures, already threads &lt;code&gt;route: Tracked&amp;lt;Route&amp;gt;&lt;/code&gt; through and does &lt;code&gt;Route::extend(route)&lt;/code&gt;. The correct pattern already existed in the same file. &lt;code&gt;eval_string&lt;/code&gt; was just the one routine that didn't follow it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;PR: &lt;a href="https://github.com/typst/typst/pull/8661" rel="noopener noreferrer"&gt;https://github.com/typst/typst/pull/8661&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix threads the caller's real &lt;code&gt;Route&lt;/code&gt; through &lt;code&gt;eval_string&lt;/code&gt; instead of manufacturing a disconnected one. Because Typst splits its compiler across crates and links these "routines" via function-pointer tables rather than trait objects (to dodge circular crate dependencies), adding one parameter meant touching every signature and every call site — 7 files, 18 insertions, 3 deletions total.&lt;/p&gt;

&lt;p&gt;The core fix, in &lt;code&gt;crates/typst-eval/src/lib.rs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;     &lt;span class="n"&gt;introspector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Tracked&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="n"&gt;Introspector&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&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="o"&gt;+&lt;/span&gt;    &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Tracked&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Tracked&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="n"&gt;spans&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SpanMode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="o"&gt;-&lt;/span&gt;        &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;        &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trait signature in &lt;code&gt;crates/typst-library/src/routines.rs&lt;/code&gt; gains the matching parameter, and the real call site — the &lt;code&gt;#eval()&lt;/code&gt; builtin in &lt;code&gt;crates/typst-library/src/foundations/mod.rs&lt;/code&gt; — now passes the engine's actual route instead of nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;         &lt;span class="n"&gt;EmptyIntrospector&lt;/span&gt;&lt;span class="nf"&gt;.track&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;        &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="py"&gt;.route&lt;/span&gt;&lt;span class="nf"&gt;.track&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
         &lt;span class="nn"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;none&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.track&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
         &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="nn"&gt;SpanMode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three other call sites had no engine/route in scope at all, because they're genuine top-level entry points: the CSL bibliography math renderer, and the &lt;code&gt;typst eval&lt;/code&gt; / &lt;code&gt;typst query&lt;/code&gt; CLI subcommands. Those got &lt;code&gt;Route::default().track()&lt;/code&gt; passed explicitly, which preserves their exact prior behavior instead of silently changing it — keeping the diff minimal and honest about what actually needed to change versus what was just plumbing.&lt;/p&gt;

&lt;p&gt;One deliberate detail: &lt;code&gt;eval_string&lt;/code&gt; does &lt;em&gt;not&lt;/em&gt; call &lt;code&gt;.with_id(...)&lt;/code&gt; on its extended route, unlike file-level &lt;code&gt;eval()&lt;/code&gt;. That's correct, not an oversight — &lt;code&gt;eval_string&lt;/code&gt; evaluates a bare string, which has no &lt;code&gt;FileId&lt;/code&gt; of its own to attach. Cycle detection still works fine because the calling file's id is already baked into the outer chain being threaded in.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Before/after, same binary, same repro:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;BEFORE &lt;span class="o"&gt;(&lt;/span&gt;unmodified main&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;./target/debug/typst compile overflow.typ overflow.pdf
&lt;span class="go"&gt;thread 'main' (750833) has overflowed its stack
fatal runtime error: stack overflow, aborting

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;AFTER &lt;span class="o"&gt;(&lt;/span&gt;fix applied&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;./target/debug/typst compile overflow.typ overflow.pdf
&lt;span class="go"&gt;error: cyclic import
  ┌─ overflow.typ:1:6
  │
&lt;/span&gt;&lt;span class="gp"&gt;1 │ #&lt;/span&gt;&lt;span class="nb"&gt;eval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"import &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;overflow.typ&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;  │       ^^^^^^^^^^^^^^^^^^^^^^^^^
EXIT CODE: 1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Non-cyclic &lt;code&gt;#eval&lt;/code&gt; usage — &lt;code&gt;#eval("1 + 1")&lt;/code&gt;, cross-file &lt;code&gt;#eval("import \"other.typ\"; other.x")&lt;/code&gt;, &lt;code&gt;typst eval "1+1"&lt;/code&gt;, &lt;code&gt;typst query&lt;/code&gt; — all still work unchanged.&lt;/p&gt;

&lt;p&gt;A crash bug is annoying to test-first: a stack overflow SIGABRTs the whole test process, so you can't just run the unfixed code under &lt;code&gt;cargo test&lt;/code&gt; and expect a failing assertion — you'd just crash the test runner. So verification happened at two layers. First, an actual CLI build of the vulnerable binary against a kill-guard to capture the real "before" crash text above. Then a snapshot test, added to &lt;code&gt;tests/suite/foundations/eval.typ&lt;/code&gt;, mirroring the existing file-level &lt;code&gt;import-cyclic&lt;/code&gt; test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- eval-string-cyclic-import eval ---
// Cyclic import of this very file through `eval`.
// Error: 7-30 cyclic import
#eval("import \"./eval.typ\"")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test results: the new test passes on its own (1/1), and nothing nearby regressed — &lt;code&gt;eval&lt;/code&gt; (24 passed), &lt;code&gt;import&lt;/code&gt; (99 passed), &lt;code&gt;recursion&lt;/code&gt; (9 passed), &lt;code&gt;cite&lt;/code&gt;/&lt;code&gt;bibliography&lt;/code&gt; (66 passed), and the full integration suite comes back clean at &lt;strong&gt;3720 passed, 0 failed, 0 skipped&lt;/strong&gt;. Unit tests across &lt;code&gt;typst-eval&lt;/code&gt;, &lt;code&gt;typst-library&lt;/code&gt;, and &lt;code&gt;typst-cli&lt;/code&gt; add another 34 passed. &lt;code&gt;cargo fmt --check&lt;/code&gt; and &lt;code&gt;cargo clippy&lt;/code&gt; are clean on every touched file.&lt;/p&gt;

&lt;p&gt;Two things I want to flag honestly. First, the AI-assisted part: the triage that named &lt;code&gt;eval_string&lt;/code&gt; and &lt;code&gt;Route::default()&lt;/code&gt; came out of an earlier pass with Claude, and I worked the actual fix and verification with it too rather than purely solo — I want to be upfront about that rather than presenting it as a from-scratch human discovery. What I didn't outsource was believing it; I read the sibling functions myself, traced the recursion by hand, and built both binaries to see the crash and the fix with my own eyes before trusting either.&lt;/p&gt;

&lt;p&gt;Second, a real lesson that'll outlast this specific bug: this is a codebase where several near-identical "routine" functions exist side by side (&lt;code&gt;eval_string&lt;/code&gt;, &lt;code&gt;eval_closure&lt;/code&gt;, and others), and one of them was quietly missing a parameter that all its siblings had. That asymmetry is a strong, checkable signal — worth grepping for the next time a similar "only one code path forgets to do the bookkeeping" bug shows up, in this codebase or any other with a family of nearly-parallel functions. It's also a nice reminder that "detached" or "sandboxed" execution contexts (which &lt;code&gt;eval_string&lt;/code&gt; is deliberately trying to be) need real care about &lt;em&gt;which&lt;/em&gt; invariants they're allowed to detach from — isolating the introspector and style context was intentional and fine; silently disconnecting the recursion guard was not.&lt;/p&gt;

</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>rust</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
