DEV Community

Suraj Mishra
Suraj Mishra

Posted on Originally published at kingpin.ltd

A variable that looked in scope, and wasn't

Part 1: the sign-out button that only sometimes existed

The report

A founder came back to /dashboard/ after their session had naturally expired — the
dashboard deliberately issues a 24-hour session (FOUNDER_SESSION_TTL_S, shorter than the
7-day Certified-voter session, because this one gates money-moving actions) — and saw an
empty "Your cards" list. Not an error. Not a sign-in prompt. Just... nothing, as if they'd
never had a listing.

That's a worse failure than an error message. An error says "something's wrong, here's what
to do." An empty list says "you have nothing," which for a founder who just paid to be on
Kingpin is a small, specific kind of alarming.

The fix mandate was direct: if the session is dead, say so and bounce back to sign-in.
Not silently render nothing.

The fix, and the bug it introduced

getFounderListings() in web/lib/api.js already distinguishes a session-dead 401 from any
other failure (/founder/listings takes no listing id, so every 401 on it is unambiguous —
unlike /refuel or /media, there's no "valid session, wrong resource" 401 to confuse it
with). Wiring that into the UI meant: catch that 401, call the dashboard's existing
signOut(), done.

The actual edit was one line inside web/app/dashboard/page.js:

<MyListings onSessionExpired={() => signOut('Your session expired — sign in again.')} />
Enter fullscreen mode Exit fullscreen mode

This compiled. It passed lint. It looked correct sitting next to the rest of the dashboard's
JSX. And it threw ReferenceError: signOut is not defined the instant a real browser tried
to run it — but only on the one path that actually exercises it (an expired session), so a
normal signed-in load never touched the broken line at all.

Why "it's right there" was wrong

web/app/dashboard/page.js has several top-level function components in one file:
Dashboard() (where signOut is actually defined, around line 129), CardLookup(), and
MyListings(). In the rendered page, <MyListings> visually sits "inside" the dashboard.
In the source, Dashboard() doesn't render <MyListings> directly — it renders
<CardLookup session={session} />, and CardLookup is the one that renders <MyListings>.

Dashboard()             <- signOut() is defined HERE
  └─ <CardLookup>        <- a SEPARATE top-level function. No access to Dashboard's locals.
       └─ <MyListings>   <- and this is a THIRD one, nested inside CardLookup's own return
Enter fullscreen mode Exit fullscreen mode

signOut is a plain local variable of Dashboard. React component nesting in JSX has
nothing to do with JavaScript closure scope — a function component only sees what's in
its own closure (module scope, its own body) plus whatever arrives as props. CardLookup
never captured signOut, because CardLookup is not defined inside Dashboard's function
body; it's a sibling declaration in the same file. The visual nesting in the returned JSX is
a red herring — it tells you about the DOM tree, not the JavaScript scope chain.

This is an easy mistake precisely because it reads right. signOut is right there, a few
dozen lines up, doing exactly the job you want. Nothing about the code around the call site
signals "this identifier is not actually reachable from here."

How it was actually found

Not by reading the code again — a second read of code that looks obviously correct rarely
catches its own mistake. By running it:

  1. Wrote the failing-session e2e test first (web/e2e/specs/dashboard.spec.js, "an expired session auto-signs-out instead of silently showing 'no listings'"), matching the mock.js router's api.fail('founderListings', 401, ...) pattern already used elsewhere in the suite.
  2. Ran it. It failed — not with an assertion mismatch, but a timeout waiting for the sign-in form to reappear. A timeout tells you "nothing happened," not "why."
  3. Added Playwright's page.on('pageerror', ...) fixture temporarily (a consoleErrors array pushed to on every uncaught page error) and printed it. That's what surfaced the exact line: "pageerror: signOut is not defined".
  4. Confirmed the scope story by mapping every top-level function declaration in the file (grep -n "^function ") rather than trusting indentation — indentation in a return statement tells you nothing about which function keyword actually owns a variable.

The fix

Thread onSessionExpired explicitly as a prop through every layer that needs it, instead of
reaching for a variable that "should" be in scope:

Dashboard() defines signOut, passes onSessionExpired={() => signOut(...)} to CardLookup
CardLookup receives onSessionExpired as a prop, forwards it unchanged to MyListings
MyListings calls onSessionExpired?.() — never touches signOut directly
Enter fullscreen mode Exit fullscreen mode

The generalisable lesson

In a component tree, "visually nested" and "in scope" are different graphs. JSX
indentation shows you the render tree. Scope is decided by where a function keyword's
body literally begins and ends in the source file. When one top-level component's JSX
contains another top-level component's tag, nothing written by the first is reachable
inside the second except through explicit props.

A cheap habit that would have caught this before running anything: before referencing a
name inside a component, ask "whose function { ... } am I physically inside of right now,"
not "what does this render next to."


Part 2: the card with a hole in the middle

The report

A screenshot: the Arena ladder cards on /board at phone width had a large, dead patch of
empty vertical space between the climbing avatar and the row below it. "Product listing card
is not very nicely designed for mobile devices."

Root cause

Adding vote buttons to each row (this session's board-voting feature) meant adding a new row
to the card's compact layout — call it row 4, sitting under the existing title/stat rows.
That made the card taller. Nothing else about the card's code changed.

But the Climber avatar — the little circular monogram that visually races up the ladder —
is position: absolute with no explicit top. An absolutely-positioned element with no
inset value doesn't just float free; as a flex child, it still participates in the flex
container's cross-axis alignment (items-center on the row). Its resting position is
wherever the center of the row currently is.

Before the vote row existed, the row was short, so "centered in the row" and "centered on
the title" were close enough to look intentional. Add a row underneath and the row's center
moves down — but the visual anchor the eye expects (the title text) didn't move. The avatar
drifted away from the thing it's supposed to be racing next to, leaving a gap that reads as
"broken," even though every individual style rule was doing exactly what it was told to do.

The fix

Not a fix to the avatar's positioning — the actual fix was to not grow the card in the first
place. The vote count was previously a separate dl "votes" stat and the vote buttons were
a separate row; those got merged into one ArenaVoteControl (fire icon, live count, trash
icon, all inline, h-7 w-7 buttons) placed inside the row that already existed, with
flex-wrap so it only spills to a second line on the very narrowest phones instead of
unconditionally reserving the space.

The generalisable lesson

A layout bug that "makes no sense" from the changed code alone is often downstream of an
element with an implicit anchor
position: absolute with no inset, flex alignment
with no explicit align-self, a CSS variable scoped to the wrong ancestor (see entry 001's
footer). The element didn't change. The thing it was silently anchored to did.

Before adding a new row/column to any layout that has an absolutely-positioned or
implicitly-aligned child, check what that child's position is actually relative to — and
whether growing the container moves that reference point.


Originally published on the Kingpin build log.

Top comments (0)