Skip to content

Make the retro board feel quick - #37

Merged
ellgreen merged 12 commits into
mainfrom
snappy-ui
Aug 18, 2026
Merged

Make the retro board feel quick#37
ellgreen merged 12 commits into
mainfrom
snappy-ui

Conversation

@ellgreen

@ellgreen ellgreen commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Two reported bugs turned into a look at why the board feels heavy in general. Nine commits, each independently reviewable, ordered so the riskiest change sits in the middle with verification either side.

The two reported bugs

Duplicate status pill when the header is collapsed. Deleting the badge outright would have left a gap — between sm and md the stage rail's <ol> is hidden, so the badge was the only stage label in that range. Instead the rail renders at every width and hides its non-active steps below md. One component owns the status indicator and can't duplicate itself.

New-note dialog kept the last note's text, in a tiny box. It never called form.reset(), and its only reset was guarded on content, which is undefined when creating. Now resets on open, the way column-dialog.tsx already did. The 36px single-line Input for a 255-character field becomes the AutoTextarea that already existed in the repo, and the dialog widens to hold it. Enter saves (Shift+Enter for a newline) — the chat-input convention, and simpler than the Cmd/Ctrl+Enter this went through first. task-dialog.tsx had the identical reset bug and gets the same fix.

Why it felt slow

RetroContext carried the whole useWebSocket return in an inline object literal, so its identity changed on every inbound frame and re-rendered every consumer — including each note, where a layoutId turns a re-render into a layout projection pass. react-use-websocket also writes lastMessage inside flushSync, so that render was synchronous and unbatched, blocking the socket handler.

Frames now fan out to a ref'd listener set. onMessage runs before filter, and filter gates only the lastMessage write, so filter: () => false plus onMessage delivers every frame with no render from the hook at all (verified against attach-shared-listeners.js). readyState gets its own context so a reconnect touches only ConnectionIndicator.

Other things that were costing time:

  • Stage changes threw away the notes. Each stage called useNotes, which owned a reducer and a mount fetch, so mode="wait" unmounting the outgoing stage reset to empty, flashed skeletons, refetched, and dropped the rollback map for any optimistic edit in flight. One NotesProvider above the board instead.
  • Votes waited a full round trip before the button, the ring or the counter moved, and the response replaced the list wholesale so quick clicks could race.
  • Notes loaded after the retro plus a mount. Both only need the id from the path, so they go in the loader together.
  • Paint cost: an always-mounted backdrop-blur-sm toolbar per note, a second sticky backdrop-blur-xl stacked under the nav's, transition-all across a font-size change, three infinite blur-3xl loops on the home page, and a TooltipProvider per tooltip instance.

Measured

Verified in the running app with a render counter and the resource timing API:

Before After
Note re-renders from one connection_info frame 1 per note 0
Note state resets across 6 stage transitions 6 0
Skeleton flash on stage change yes no
Retro + notes requests sequential both at 94ms
Vote feedback after the round trip ~1.3s before the request is sent

A stage change does still re-run the route loader, deliberately: see the
cache-invalidation commit below. That result is discarded, so the notes state
and the absence of a skeleton are unaffected — what changed is that the board
no longer throws its notes away and refetches them behind a skeleton.

Rollback verified by stopping the backend mid-session: the vote reverts and toasts. Reconnect resync verified the same way — fires exactly once, notes intact.

Bug found while testing

NoteFromModel resolves created_by_name from the user it's handed, and both socket broadcasts passed nil, falling through to an "unknown" default. The REST index passes a real user, which is why notes looked right on load and then flipped the moment anyone moved, grouped or edited one — visible on every note in the group and vote stages. Predates this work; found while checking drag-and-drop still worked.

Creates pass the acting user, who is the author. Updates can't — moving and grouping other people's notes is allowed — so the author is looked up by note.UserID.

Second bug found, also pre-existing

Leaving a retro and coming back showed the stage you left rather than the one it
is on, until a hard refresh. The route loader's result is cached by the router
and survives navigating away, but while the board is open it is the socket, not
the loader, that moves the retro forward — so the cached copy silently goes
stale. Returning mounts the board and seeds it from that copy, and the seed
happens once, so a later revalidation never reaches the screen.

Fixed by invalidating on retro_updated and status_updated. Confirmed
pre-existing by running main against the same backend and reproducing it
identically.

Tried and reverted

React.memo on Note and DraggableNote, with the action props taking a note id so callbacks could be stable. It made no measurable difference: AnimatePresence deliberately rebuilds its context value when presenceAffectsLayout is on ("we want to make a new context value to ensure they get re-rendered"PresenceChild.mjs), and a context read isn't something memo can block. Adding one note re-rendered each sibling the same number of times either way, so the API churn across four files bought nothing. Memoising above an AnimatePresence works — that's why BoardForStatus pays off — below it does not.

Product decisions taken

Letter avatars come off notes and stay in the presence stack. With no profile images the circle on a note is a missing-image fallback sitting next to the full name it stands for; a dot in the same per-person colour carries the grouping without pretending to be an avatar. The overlapping stack in the header keeps its initials, where compressing several people into a corner is real work.

ConnectionIndicator also moves into the always-visible header row — it lived inside CollapsibleContent, which Radix unmounts, so collapsing the header made presence vanish.

Reviewer notes

  • .claude/launch.json is included — a dev-server config added for tooling during this work, swept in by git add -A. Happy to drop it if unwanted.
  • Votes are still REST-only and never published to event.Broker, so other people's votes still don't appear live during the vote stage. That needs a server event and isn't this change.
  • useVotes is deliberately vote-stage only — in discuss the same endpoint returns everyone's votes with counts, a different shape.
  • Test coverage is unchanged in kind: the project has no jsdom or testing-library, so only reducers are unit-testable. use-notes.test.ts keeps passing untouched apart from one added case; use-votes.test.ts is new (7 cases). Everything else — the provider split, the memoisation, the stage transitions — was verified by driving the real app, not by tests.
  • Not benchmarked: the blur removals are reasoned from how backdrop-filter behaves on sticky elements. I couldn't capture frame timings with the tooling available.

Full gate green: go build, go vet, go test, pnpm lint, tsc -b, pnpm test (27 passing), pnpm build.

🤖 Generated with Claude Code

ellgreen and others added 12 commits August 17, 2026 15:32
The collapsed header drew its own outline Badge alongside StageRail's
active pill. Deleting the Badge outright would have left nothing between
sm and md, where the rail's <ol> is hidden, so the rail now renders at
every width and hides its non-active steps below md instead. One
component owns the status indicator and it cannot duplicate itself.

NoteDialog never reset, and its only reset was guarded on `content`,
which is undefined when creating — so a new note opened holding the last
one's text. Reset on open, the way ColumnDialog already does.

The field also accepted 255 characters in a 36px single-line Input.
AutoTextarea grows to its cap instead, the dialog widens to hold it, and
Cmd/Ctrl+Enter saves now that Enter inserts a newline.

TaskDialog had the same reset bug and gets the same fix.

ConnectionIndicator moves into the always-visible row: it sat inside
CollapsibleContent, which Radix unmounts, so collapsing the header made
presence disappear.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every note kept an always-mounted opacity-0 action toolbar carrying
backdrop-blur-sm, so a board of 50 notes held 50 backdrop-filter layers
that reinvalidated whenever anything under them repainted. The
background was already near-opaque; making it opaque loses nothing.
Its transition-all also swept ring, shadow and background for what is
only ever an opacity change.

The board header stacked a second sticky backdrop-blur-xl directly under
Nav's, so both re-rasterised on every scroll frame. Nav keeps its blur;
the header goes opaque.

The header title also had transition-all across a font-size change,
which animates layout on every frame of the collapse.

The hero's three blur-3xl blobs ran infinite motion loops for an 18-26s
drift nobody notices, keeping a rAF alive on the home page forever. They
stay as static decoration.

Tooltip mounted its own TooltipProvider per instance, which in the group
stage is one state machine per note. One provider at the app root
instead, which also lets the shared delay work between tooltips.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RetroContext carried the whole useWebSocket return in an inline object
literal, so its identity changed on every inbound frame and re-rendered
every consumer — including each note on the board, where a layoutId
turns a re-render into a layout projection pass. react-use-websocket
also writes lastMessage inside flushSync, so that render was synchronous
and unbatched, blocking the socket handler.

SocketProvider now owns the connection and fans frames out to a ref'd
listener set. onMessage runs before filter, and filter gates only the
lastMessage write, so filter: () => false plus onMessage delivers every
frame with no render at all from the hook. readyState gets its own
context so a reconnect touches only ConnectionIndicator.

RetroContext is down to { retro, setRetro }, memoised on retro, and each
useEffect on lastJsonMessage becomes useSocketEvent with the same body.
Consumers still re-render when a frame is theirs; they no longer
re-render when it is not.

The notes reducer is untouched, so its tests are too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each stage component called useNotes, and useNotes owned a reducer and a
mount fetch, so AnimatePresence mode="wait" unmounting the outgoing
stage threw the notes away: every stage change reset to empty, flashed
skeletons, refetched, and dropped the rollback map for any optimistic
edit still in flight. One NotesProvider above the board instead.

That remount was also an accidental resync — it papered over whatever
the socket missed while it was down. Replaced with a deliberate one on
reconnect, so it is the same request count per connection but now it
actually fires when a connection drops rather than when someone happens
to click to the next stage.

The panel AnimatePresence goes with it: mode="wait" made the incoming
stage wait out the outgoing one's exit, and every alternative keeps both
mounted, which would put each note's layoutId on screen twice.

notesByColumn replaces a notes.filter per column in Brainstorm, which
was O(columns x notes) and handed AnimatePresence a new array each
render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With no profile images the circle is a missing-image fallback, and on a
note there is already room for the whole name next to it. A small dot in
the same per-person colour carries the same at-a-glance grouping without
pretending to be an avatar. The presence stack keeps its initials, where
overlapping circles are doing real work compressing several people into
a corner of the header.

The optimistic note also had no created_by_name, so the author line
popped in when the echo landed. The name is stamped onto the local
dispatch only, not the frame sent to the server, which already knows who
is writing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Voting awaited the POST before anything moved, so the button label, the
group's ring and the votes-left counter all sat still for a round trip.
The response also replaced the list wholesale, so two quick clicks could
race and the slower reply would win.

useVotes applies the click locally and keeps the previous list to put
back if the request fails, the same shape use-notes already uses. Each
group carries a ticket so a late reply to an older click cannot undo a
newer one, and only the first stash is kept per group, so a rollback
lands on what the server last confirmed rather than on another pending
click.

It stays vote-stage only on purpose: in discuss the same endpoint
returns everyone's votes with counts, which is a different shape.

Worth knowing: votes are REST-only and never published to event.Broker,
so other people's votes still do not appear live during the vote stage.
That needs a server-side event and is not this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The board opened on a chain: load the retro, mount, then ask for the
notes, showing skeletons in between. Both requests only need the id from
the path, so they go in the loader together. With defaultPreload set to
"intent", hovering a card on the home page now warms the notes too, and
the board can paint populated on click.

NotesProvider seeds from that data through the reducer's own note_index
path, so loaded starts true and the skeletons never appear on a cold
open. The mount fetch is gone; the reconnect resync stays and is now the
only place that refetches.

Votes and tasks stay where they are. They depend on the stage, so
loading them here would either add back a waterfall or fetch them on
every visit to a board that never reaches those stages.

Also a pendingComponent, the first in the app: with two requests in the
loader a cold visit otherwise holds the previous screen with no feedback
until both land.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Board holds connectionInfo and votesRemaining, so someone joining,
leaving or voting re-rendered the entire stage and every note under it.
Memoising BoardForStatus confines those to the header: measured with a
render counter, a connection_info frame now causes zero note renders,
where before it caused one each.

Discuss recomputed the board-wide vote total once per group, allocated a
Set of authors per group, and re-sorted the tasks inline on every
render. Those move into memos keyed on votes and tasks.

Tried and reverted: React.memo on Note and DraggableNote, with the
action props taking a note id so the callbacks could be stable. It makes
no difference, because AnimatePresence deliberately rebuilds its context
value when presenceAffectsLayout is on ("we want to make a new context
value to ensure they get re-rendered", PresenceChild.mjs), and a context
read is not something memo can block. Adding one note still re-renders
each sibling the same number of times either way, so the API churn
bought nothing. Memoising above an AnimatePresence works; below it does
not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NoteFromModel resolves created_by_name from the user it is handed, and
both socket broadcasts passed nil, so it fell through to the "unknown"
default. The REST index passes a real user, which is why notes looked
right on load and then flipped the moment anyone moved, grouped or
edited one — visible on every note in the group and vote stages.

Creates can pass the acting user, who is the author. Updates cannot:
moving and grouping other people's notes is allowed, so the author is
looked up by note.UserID.

Found while checking drag-and-drop still worked after the frontend
changes; the bug predates them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cmd+Enter saves a note, but nothing on screen said so. Added shadcn's
kbd component and put the combo on the Save button itself, where it sits
next to the action it triggers.

The tag input was the other undiscoverable one: Enter adds a tag and the
arrows move through suggestions, with only a comma in the placeholder to
hint at any of it. The suggestion popover now carries a footer, which
appears exactly while someone is typing a tag.

The modifier is resolved per platform so the hint reads Ctrl off Apple
devices, rather than showing a key that isn't on the keyboard.

Only shortcuts that already exist are labelled. Enter already submits
the single-field dialogs natively and Escape already closes them; those
need no decoration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Leaving a retro and coming back showed the stage you left rather than
the one it is on, until a hard refresh.

The route loader's result is cached by the router and survives
navigating away. While the board is open the socket, not the loader, is
what moves the retro forward, so the cached copy silently goes out of
date. Coming back mounts the board and seeds it from that copy, and the
seed only happens once, so a later revalidation never reaches the
screen.

Invalidating on retro_updated and status_updated keeps the cache honest.
While the board is open this costs a loader re-run whose result is
discarded — the socket has already applied it — so nothing on screen
moves, and stage changes still keep their notes with no skeleton.

Predates the work on this branch: verified by running main against the
same backend, where advancing a stage and returning shows the old stage
in exactly the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Shift+Enter still inserts a newline; plain Enter now submits, matching
the chat-input convention most people already expect there. The
Cmd/Ctrl+Enter path is gone rather than kept as an alt, so there's only
one thing to learn.

lib/keys.ts (the platform-aware ⌘/Ctrl label) was added for this hint
and has no other caller, so it goes with it rather than sitting unused.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ellgreen
ellgreen merged commit 038d6bb into main Aug 18, 2026
2 checks passed
@ellgreen
ellgreen deleted the snappy-ui branch August 18, 2026 08:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant