ReviewOS
@if (!issue)

No such issue

stacks/stx has no issue numbered 2039.

@else

stacks/stx

div-balance warning miscounts a well-formed file: three openings inside false `@if` branches are dropped from the count but only one of their closers

#2039
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Summary

The build emits

[stx] analytics.stx: <div> 56 opened, 58 closed — 2 stray </div>.

for a file that is well-formed. The page renders correctly in production. I could not reduce this to a minimal repro, so this is a lead rather than a finished diagnosis — but the shape of the miscount is specific and reproducible, and it points at branch stripping dropping opening tags without their closers.

The file is balanced

checkresult
<div> vs </div> in source59 / 59
stx comment markers6 , none containing divs, none nested
opening tags spanning multiple lines0
self-closing <div … />0
divs inside <script> / <style>0
signed-in render path, Python html.parser0 errors, nothing left open
@else render path, same parser0 errors, nothing left open
rendered signed-out page4 / 4
other files in the app flaggednone

The miscount is asymmetric, and it is stable

Copying the file under another name reproduces the warning identically, so it is content-driven. Deleting the whole region (lines 405-575, internally 53 / 53) leaves a file with six divs:

L372  <div class="grain" aria-hidden="true"></div>     +1 / -1
L374  <div class="px-5 py-10 …">                       +1       ─┐ closed at L577
L379  <div class="mx-auto max-w-5xl">                  +1       ─┤ closed at L576
L385  <div class="py-24 text-center">  …  L388 </div>  +1 / -1   │  inside @if (!user)
L392  <div class="py-24 text-center">  …  L395 </div>  +1 / -1   │  inside @if (user && projectsUnavailable)
L399  <div class="py-24 text-center">  …  L402 </div>  +1 / -1   │  inside @if (user && !projectsUnavailable && !projectId)
L576  </div>                                               -1   ─┘
L577  </div>                                               -1

Six opened, six closed. stx reports 3 opened, 5 closed — 2 stray </div>.

So in both cases the same thing happens:

sourcestxdropped
full file59 / 5956 / 583 opens, 1 close
auth block removed6 / 63 / 53 opens, 1 close

Three openings vanish from the count and only one of their three closers does. The three that vanish are exactly L385, L392 and L399 — each an internally balanced <div>…</div> pair inside an @if whose condition is false at build time (user is null there).

If a false branch is stripped before counting, stripping it symmetrically leaves the total balanced. Dropping three openers while keeping two of their closers produces precisely the "2 stray </div>" that is reported. That is the only hypothesis I have that fits both data points.

What does NOT reproduce it

Four minimal files, all built in the same app, none of which warn — worth knowing so nobody re-walks these:

  1. plain nested wrappers inside @section
  2. (the straddle this file has)
  3. a <script server> exporting user = null, with @if (!user), @if (user && other) and @if (user && !other) each wrapping one balanced div — i.e. the shape above, written out small

So something further in the real file is required to trigger it, and I did not isolate what.

Two of my intermediate reductions produced convincing but false results, which may be useful if anyone else bisects this: truncating the file removes trailing closers by definition, and stripping the <script>/<style> blocks cut through a {{-- comment and left it unterminated, which swallowed the following divs and reported 0 opened. Both numbers looked like findings and were artifacts.

Impact

Low but not zero. It is a warning on a correct file, so the cost is that it trains people to ignore the div-balance warning — which is the one that would catch a genuinely malformed page. A counter that cries wolf on well-formed markup is worse than no counter.

Environment

  • @stacksjs/stx 0.2.379, stacks 0.75.79, Bun 1.4.3-canary.1, macOS
  • Reproduces under bun run build; the file is a 579-line page with <script server>, <script client>, a <style> block, @extends / @section, and ten @if regions
9 days ago

Fixed in 0.2.382. Your measurements were the whole diagnosis — the mechanism just turned out to be one layer earlier than the hypothesis.

Not branch stripping

The check cannot strip a branch: it runs on the raw template before any processing, from processDirectives (process.ts, the #1769 heads-up) — so at the point it counts, no @if has been evaluated and both sides of every branch are still present. Worth saying plainly because it is the natural reading of the data and it sent me looking in the right place only after I ruled it out.

The actual cause

The tag matcher's attribute region allowed a bare <:

<(\/?)([A-Za-z][\w-]*)\b(?:[^>"']|"[^"]*"|'[^']*')*?(\/?)>

So any < followed by a letter outside markup began a tag match, which then ran forward to the next > — and that > belongs to the following real opening tag, which gets consumed with it. Instrumented on a three-line file:

@if (count<limit)
<div class="wrap">
  <div class="inner">x</div>
</div>
open  <limit>   consumed: "<limit)\n<div class=\"wrap\">"
open  <div>     consumed: "<div class=\"inner\">"
close <div>
close <div>

One open, two closes — 1 stray </div> on balanced markup, which is your signature at n=1.

That accounts for the asymmetry exactly: one opening vanishes per swallow, because it is eaten along with the phantom, while a closing tag vanishes only when the consumed span happens to contain one. Three swallows → three openings and however many closers fell inside those spans. It is content-driven and stable for the same reason.

It also explains the two things that did not reproduce. Your minimal files had no < followed by a letter outside markup, so there was nothing to swallow; and the shape has nothing to do with @if as such —