Skip to content

fix(dashboard): the board's fan-out trait index never reached the memo - #2993

Merged
gsxdsm merged 1 commit into
blocker-fanout-dashboard-lanesfrom
consolidate/fanout-board-coverage
Jul 31, 2026
Merged

fix(dashboard): the board's fan-out trait index never reached the memo#2993
gsxdsm merged 1 commit into
blocker-fanout-dashboard-lanesfrom
consolidate/fanout-board-coverage

Conversation

@gsxdsm

@gsxdsm gsxdsm commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Against #2990's branch. This is me folding #2989 into yours rather than running two PRs over the same seam — see my comment there.

The Board half of #2990 is inert

useBlockerFanout memoizes on:

[tasks, options.staleHighFanoutAgeThresholdMs]

columnFlagsByTaskId isn't in the list. Board builds that index from boardWorkflows, which is null until an async fetch resolves — so the first computation runs against an empty map and takes the documented legacy fallback. When the index populates, neither dependency has changed, the memo never recomputes, and the pre-load answer survives for the life of the mount.

The flagsByTaskId.size > 0 guard is right. It only ever ran against the empty map.

Threaded end to end, correctly typed, and never arriving — the first failure shape from the learnings doc, in the one place lint can't see it: this repo has no react-hooks/exhaustive-deps rule, and a disable directive for it fails CI, so the dep list is maintained by hand.

Measured

state result
this branch before the commit expected +0 to be 2
after 2 passed
useBlockerFanout + ExecutorStatusBar + new Board suite 87 tests green

Census and FNXC gates green; lint and tsc clean.

The test is the point

It drives the real Board with a mocked Column that captures the map it was handed. The existing suites call computeBlockerFanoutMap directly and pass a populated map from the first call — so they exercise the pure function and never the memo, which is exactly where the value has to survive an async arrival. That's why a correct implementation and green tests coexisted with a board that read legacy lanes.

It also asserts the tree actually rendered before trusting the captured map: a Board that throws mid-render yields undefined rather than a wrong number, and I'd rather that fail loudly than read as a passing zero.

What I'm dropping

#2989 and its stacked #2991 are superseded by yours — I'll close #2989 once you've taken what you want. Yours is broader (reviewColumns, the mergeOrchestration arm, scheduler parity, the census baseline), so this carries over only the two things it didn't have: the memo dep and the producer-level test.

Also withdrawn: I claimed on #2991 that the ExecutorColumnFlags Pick dropping humanReview produced a wrong answer. It doesn't — the sole supplier (App.tsx:553) passes the whole column.flags object, so the trait is there at runtime. Latent type-safety at most; your type is fine as-is.

Folding my #2989 into this branch rather than running two PRs over the same seam.

The Board half of the parent fix is inert. `useBlockerFanout` memoizes on
`[tasks, options.staleHighFanoutAgeThresholdMs]`, and `columnFlagsByTaskId` is not in the
list. Board builds that index from `boardWorkflows`, which is null until an async fetch
resolves, so the FIRST computation runs against an empty map and takes the documented
legacy fallback. When the index populates, neither dependency has changed, so the memo
never recomputes and the pre-load answer survives for the life of the mount.

The `flagsByTaskId.size > 0` guard is right. It only ever ran against the empty map.

Threaded end to end, correctly typed, never arriving — the first failure shape from the
learnings doc, and invisible here because this repo has no `react-hooks/exhaustive-deps`
rule (a disable directive for it also fails CI, so the list is maintained by hand).

Adds the Board-level regression test that caught it. It drives the REAL Board with a mocked
`Column` capturing the map it was handed, rather than calling `computeBlockerFanoutMap`
directly — the existing suites do the latter and pass a populated map from the first call,
so they exercise the pure function and never the memo, which is where the value has to
survive an async arrival.

MEASURED
- before this commit, on this branch: `expected +0 to be 2`
- after: 2 passed
- useBlockerFanout + ExecutorStatusBar + the new Board suite: 87 tests green
- census and fnxc gates green; lint and tsc clean

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gsxdsm
gsxdsm merged commit 28b6cd8 into blocker-fanout-dashboard-lanes Jul 31, 2026
@gsxdsm
gsxdsm deleted the consolidate/fanout-board-coverage branch July 31, 2026 06:40
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0e4da0b3-1b62-446a-956c-dea162b23087

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

gsxdsm added a commit that referenced this pull request Jul 31, 2026
…named board (#2996)

## How this was found

By generalizing the memo-dependency defect in the blocker fan-out
(#2993) into a sweep for memoized hooks that read a lane value absent
from their dependency list — rather than treating that one as a one-off.
**13 raw hits, 12 benign** (refs, or values reached through a covered
object). This is the one that's a live defect.

## The defect

`wantsLiveTimeIndicator` decides whether a card subscribes to the shared
time ticker. It reads `isWipColumn`, `isReviewColumn` and
`taskColumnFlags` — all derived from the `taskColumnFlags` **prop** —
while its dependency array listed only `task.*` fields.

Those flags arrive **after first paint**: the board resolves workflow
traits asynchronously. So:

1. first computation runs with flags `undefined`;
2. role helpers fall back to legacy ids — `isWipColumnRole(undefined,
"building")` is **false**;
3. the card declines the ticker;
4. flags arrive, but `task.column` hasn't changed, so nothing in the dep
array changed;
5. the memo never recomputes. **No live elapsed time, for the life of
the mount.**

## Why it survived

On a legacy board the fallback already answers `true` on the very first
paint (`column === "in-progress"`), so the memo's initial value is
correct and the stale list costs nothing. The defect is
**renamed-board-only**.

This repo also has no `react-hooks/exhaustive-deps` rule, so the entire
class is invisible to lint — and a disable directive for that rule fails
CI, so these lists are maintained by hand.

## Measured

The test was written **first** and was red for the right reason before
any fix — the control and the negative passed, and only the renamed case
failed:

| stage | result |
|---|---|
| before the fix | `expected false to be true` (renamed case only) |
| after | 3 passed |
| `TaskCard.test` + `cli-states` + `oversight` + new suite | **456 tests
green** |
| gates | census + FNXC green; lint and `tsc` clean |

The assertion is on `useLiveTimeTicker(enabled)` — `enabled` *is*
`wantsLiveTimeIndicator`, so it observes the subscription itself rather
than a proxy for it.

## The negative case is the one that matters

Recomputing must not degrade into "every card subscribes". A card in the
renamed **complete** lane must stay off the shared ticker, or the fix
trades one stalled indicator for sixty cards waking a backgrounded tab —
the exact cost the shared-ticker refactor documented at this site (it
replaced 60 per-card `setInterval`s precisely because mobile browsers
discard a page that never goes idle).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
gsxdsm added a commit that referenced this pull request Jul 31, 2026
…after a memo has answered (#2998)

## The shape

Three defects this session, all the same, none visible to any instrument
here:

A lane value resolved **asynchronously** (the board fetches workflow
traits after first paint) is read inside a `useMemo`/`useCallback` whose
dependency list omits it. The first computation runs with the flags
`undefined`, the role helpers correctly fall back to legacy ids, and on
a **renamed** board that answer is wrong. When the flags arrive nothing
in the dep list changed, so the memo never recomputes.

| defect | severity |
|---|---|
| blocker fan-out trait index (#2993) | permanent — empty index for the
mount |
| card live elapsed-time indicator (#2996) | permanent — never
subscribes |
| near-duplicate chip (#2997) | bounded — self-heals on the next task
refresh |

A legacy board hides all three: there the fallback already answers
correctly on the first paint, so the stale list costs nothing. **Every
instance is renamed-board-only**, which is why they accumulated — and
this repo has no `react-hooks/exhaustive-deps` rule, so the class is
invisible to lint.

## Two properties decide severity, both readable off the dep list

1. **Does any dependency refresh quickly?** `allTasks`, a live clock, a
task identity — any of them rebuilds the closure on the next update,
making the wrong answer a bounded window. The chip keys on `allTasks`
and recovers; the indicator keys on `task.column`, which never changes,
so it never does.
2. **Is the value covered transitively?** A dependency that itself lists
the flags gets a new identity when they arrive, and that propagates.

## A gate was built and rejected — the part worth writing down

The scanner reports **19 sites; two were real.** Property 2 is why:
transitive coverage is invisible to any purely syntactic check and would
need a real dependency graph.

`TaskCard`'s context-menu memo omits all three role flags and is
**nonetheless correct** — it depends on `taskActionMenuModel.actions`,
and that model lists `taskColumnFlags`, so the whole chain recomputes. I
checked that before filing it, which is the only reason this PR isn't a
bug report about missing Archive/Revert menu entries.

Freezing 19 would have baselined mostly noise and trained everyone to
skip the report — the exact failure this document already records for
`sortTasksForDisplayColumn`, where an annotation saying "ignore these"
hid a real defect for days. **A good investigative tool is not
automatically a good ratchet**, and the next person deserves to know the
turn was considered rather than missed.

The triage that does work is cheap: run the scan, then ask the two
questions above. Nine of nineteen survive question 1; hand-checking
those is an afternoon, not a project.

Docs only — no code, no baselines.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
gsxdsm added a commit that referenced this pull request Jul 31, 2026
…thout asking (#3011)

The **sixth** instance of the async-memo shape #2998 documents — and the
only one that **loses work** rather than mis-rendering.

`handleDrop` gates the "Preserve Progress?" confirmation on the lane's
role:

```js
const shouldPrompt = hasStepProgress && isPreImplementationColumnRole(columnFlags, column);
if (shouldPrompt) { const keepProgress = await confirm({ … }); … }
```

…but its `useCallback` deps were `[addToast, allTasks, column, confirm,
onMoveTask, tasks, t]` — no `columnFlags`. The board resolves workflow
traits after first paint, so the DOM keeps the closure built during the
pre-load render, where `columnFlags` is `undefined` and the helper falls
back to `LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS`. A renamed intake lane is
not a member.

**Result: a card with completed steps dropped into that lane moves with
`shouldPrompt === false`.** The user is never offered "Keep Progress",
and the steps are reset silently.

## How this was found

It is the last unverified candidate from the derivation-aware scan I
posted on #2998, where I explicitly declined to file it as a bug without
checking. Checking it is what turned it from a scanner hit into this.

## Severity, stated honestly

`allTasks` and `tasks` are in the dep list and change identity on any
task-list refresh, so the stale closure is rebuilt within seconds on a
busy board. The exposure is the quiet gap right after the traits land —
**bounded**, like the near-duplicate chip (#2997), not permanent like
the ticker (#2996) whose only refreshing dependency fired at local
midnight.

Bounded still matters here because the cost is not a wrong pixel: it is
completed steps discarded without a prompt, and the window is exactly
when someone has just opened a board and starts dragging.

## Verification

| state | result |
|---|---|
| clean | 2/2 pass |
| revert `columnFlags` from the deps | **1 failed / 1 passed** |

The paired negative asserts a non-pre-implementation lane still moves
**without** prompting — a fix that prompts everywhere turns the dialog
into noise that gets clicked through, costing the same progress it
protects.

The observable is `confirm`, not `onMoveTask`: whether the user was
*asked* is the contract, and asserting on the move alone passes either
way.

`tsc -p tsconfig.app.json` 0 errors in the new file, lint clean, FNXC
gate exit 0, 4/4 across both `Column` flags-arrival suites.

## Running tally of this shape

ticker (#2996) · near-duplicate chip (#2997) · fan-out trait index
(#2993) · merge signature (#3001) · lifecycle dates (#3007) · this. Six,
in two components plus the fan-out path. #3001 called the merge
signature "the last live site"; it was the last of *that* sweep's nine
candidates, and two more have surfaced since from a different scan.
Worth knowing before anyone declares the class closed again.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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