Skip to content

refactor(frontend): drop synchronous setState-in-effect at four of #176's six sites - #201

Merged
tyler-rich merged 1 commit into
devfrom
claude/react-hooks-setstate-effect-757a18
Aug 11, 2026
Merged

refactor(frontend): drop synchronous setState-in-effect at four of #176's six sites#201
tyler-rich merged 1 commit into
devfrom
claude/react-hooks-setstate-effect-757a18

Conversation

@tyler-rich

@tyler-rich tyler-rich commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Part 1 of #176. Refactors findings 1, 2, 3 and 5 so none of them calls setState
synchronously from an effect body, preserving behaviour at every site.
frontend/eslint.config.js is untouchedreact-hooks/set-state-in-effect stays 'off',
override and comment exactly as they were. Findings 4 and 6 and the removal of the override are a
follow-up; #176 stays open.

What replaced each site

# Site Replacement
1 pages/LoginPage.tsx The oidc_error message is seeded by a lazy useState initializer over a module-level oidcErrorCode() helper. The effect keeps only the history.replaceState that strips the parameter — the part that genuinely touches something outside React.
2 components/settings/OidcLinkCard.tsx Same treatment for both oidc_link* banners, over a shared linkParams() helper. Both parameters are still honoured independently, including the unknown-code fallbacks.
3 pages/NewScanPage.tsx The scanner clamp moves out of an effect into a chooseTargetType handler — the target-type control is the only thing that can invalidate the pairing, since the scanner picker only offers the current type's own scanners.
5 pages/ScanDetailPage.tsx findingsLoading is derived from a settled-request key (findingsSettledKey !== findingsKey) instead of stored, and loadFindings settles in both post-await branches rather than a finally.

Two choices worth calling out:

  • Finding 3 is deliberately not a derive-during-render. allowed.includes(scanner) ? scanner : allowed[0] would shadow the displaced choice rather than overwrite it, so leaving a target type and coming back would resurrect a scanner the user is no longer on. The clamp stays destructive, as it was, and a test pins that difference.
  • Finding 5 fixes a real flash. The commit in which a scan became succeeded previously rendered an empty findings list with the loader already down — "No findings match the current filters" over a request that had not answered yet.

The one line touched inside finding 4

Removing findingsLoading leaves setFindingsLoading(false) in the per-:scanId reset effect
uncompilable. It becomes setFindingsSettledKey(null). The effect keeps its structure, comment and
intent; ScanDetailPage.scanIdReset.test.tsx is unedited and still passes. Nothing else in findings
4 or 6 was changed, and ScansPage.compare.test.tsx is untouched.

Tests

Four new test files, 16 new tests. Each contains one assertion verified to fail against the
pre-refactor version of its own file (reverted with git checkout HEAD --, re-run, restored):

Site Test file Biting assertion Pre-refactor result
1 LoginPage.oidcError.test.tsx banner present in the first commit expected false to be true
2 OidcLinkCard.callback.test.tsx exactly 1 commit before the status fetch lands expected 2 to be 1
3 NewScanPage.scannerClamp.test.tsx no commit has zero scanner options selected [1,1,…] to not include +0
5 ScanDetailPage.findingsSpinner.test.tsx no commit says "no findings match" while the first request is in flight [Array(11)] to not include true

Since these are behaviour-preserving refactors, the biting assertion is necessarily about which
commit
the correct value lands in — which is what the rule is about — while the surrounding
assertions pin the behaviour and pass against both versions. Per-commit observation uses
<Profiler onRender>; Testing Library's render flushes passive effects inside act, so a plain
post-render assertion cannot see the intermediate state at all.

Measurement with the rule temporarily enabled

18 findings before (matching #176's 6/12 split exactly), 15 after — not 14. Sites 1, 2 and 3 are
gone outright; ScanDetailPage's void loadFindings() still reports.

The genuine defect #176 named for finding 5 — a synchronous setFindingsLoading(true) before the
first await — is gone. What remains is the analyser artifact the issue documents for the other 12:
probing the installed 7.1.1 shows void load() reports even when load's only setState follows an
await and there is no try/catch, and that adding a try/catch reports even when the catch
calls no setState at all. The only shapes that go silent are ones where the compiler bails on the
function. So finding 5's line has migrated into the out-of-scope fetch-on-mount population.

A consequence for the follow-up, recorded in the archive: #176 pairs "remove the override" with
"npm run lint is clean", and those cannot both hold while the 12 fetch-on-mount findings report at
error and are out of scope. That needs a decision the issue does not yet contain.

Verification

npm run lint clean · npm run format:check clean · tsc -b clean · npm run build clean ·
npm test 26 files / 96 tests passing (was 22/80 at #178).

See docs/ARCHIVE.md § Deviations for the full account;
docs/upgrades/frontend-toolchain-86.md Step 3 now records that its held-back rule closes out in
two parts.

…'s six sites

Refactors findings 1, 2, 3 and 5 from #176 so none of them calls setState
synchronously from an effect body. Behaviour is preserved at every site.

- LoginPage: the oidc_error message is seeded by a lazy useState initializer;
  the effect keeps only the history.replaceState that strips the parameter.
- OidcLinkCard: same treatment for the oidc_link / oidc_link_error banners,
  over a shared linkParams() helper.
- NewScanPage: the scanner clamp moves out of an effect into the target-type
  handler, the one place that can invalidate the pairing. Deliberately not a
  derive-during-render, which would resurrect a displaced scanner choice
  instead of overwriting it.
- ScanDetailPage: findingsLoading is derived from a settled-request key rather
  than stored, which also removes a flash of "No findings match the current
  filters" over a request still in flight. The per-scanId reset effect keeps
  its shape; its one uncompilable line becomes setFindingsSettledKey(null).

Each site gains a test verified to fail against the pre-refactor version of its
own file. The react-hooks/set-state-in-effect override in eslint.config.js is
unchanged: findings 4 and 6 and the rule flip are a follow-up, per #176.

See docs/ARCHIVE.md § Deviations for the full account, including why
ScanDetailPage's void loadFindings() still reports after the refactor.
@tyler-rich
tyler-rich merged commit 76231ef into dev Aug 11, 2026
8 checks passed
@tyler-rich
tyler-rich deleted the claude/react-hooks-setstate-effect-757a18 branch August 11, 2026 07:20
tyler-rich added a commit that referenced this pull request Aug 11, 2026
The §14 entry documented the policy change but not its result, since it
was written before the PR was opened. Adds the outcome: the strip held on
this PR's own body — footer found on creation, one PATCH, clean confirming
re-read — which is the first success in this project's history.

Bounds it on both sides rather than claiming more than it shows. The
footer appeared on creation with #197's and #200's keys live and merged,
the same result #201 got, so the settings demonstrably did not stop the
append; whatever they may have contributed is confined to the re-append
after the PATCH, and that is now an open and unfalsifiable question since
the file is gone. The next PR is the harder test, running with no settings
file at all. Also notes that #196's failures were on an issue comment
while this success was on a PR body, so it does not refute #196.
tyler-rich added a commit that referenced this pull request Aug 11, 2026
…-attempt cap (#202)

* docs: delete .claude/settings.json; reinstate footer strip with a one-attempt cap

The committed project-scope settings file is removed in full — both the
attribution block from #197 and includeGitInstructions from #200. Three
sequential settings-layer attempts did not stop the attribution footer:
#197 added the attribution keys, #200 added includeGitInstructions after
#199 still showed a footer, and #201 ran with both merged and live and
still got one on its PR body. The approach is abandoned, not paused.

With no settings-level backstop left, the strip instruction returns to
CLAUDE.md § Attribution and CONTRIBUTING.md § Pull request process — but
capped at one PATCH per body, with a hard stop if the footer comes back.
The cap prices #197's evidence rather than disputing it: each PATCH runs
under a bot identity and leaves a permanent "claude (Bot)" entry in
GitHub's public edit history, and #196 showed a second attempt only
doubles that cost without changing the outcome.

Commit-authorship verification is unchanged. The .gitignore comment that
explained why the settings file was tracked is rewritten, and docs/ARCHIVE.md
§14 records the deletion, the cap, and the fact that this is the fourth
reversal of this policy.

* docs(archive): record the strip's first outcome on #202's own body

The §14 entry documented the policy change but not its result, since it
was written before the PR was opened. Adds the outcome: the strip held on
this PR's own body — footer found on creation, one PATCH, clean confirming
re-read — which is the first success in this project's history.

Bounds it on both sides rather than claiming more than it shows. The
footer appeared on creation with #197's and #200's keys live and merged,
the same result #201 got, so the settings demonstrably did not stop the
append; whatever they may have contributed is confined to the re-append
after the PATCH, and that is now an open and unfalsifiable question since
the file is gone. The next PR is the harder test, running with no settings
file at all. Also notes that #196's failures were on an issue comment
while this success was on a PR body, so it does not refute #196.
tyler-rich added a commit that referenced this pull request Aug 11, 2026
Runs the CONTRIBUTING.md § Releasing "Before you tag" checklist on dev,
ahead of the promotion PR.

- Version 0.3.0 -> 0.3.1 in sixteen places across nine files
  (frontend/package-lock.json written with `npm version`, never by hand).
- CHANGELOG [Unreleased] cut to [0.3.1] - 2026-08-11, with a fresh empty
  [Unreleased] above it and the compare links added and re-pointed.
- Adds a ### Fixed section for the #176 work landed in #201/#203, removes
  the test-only ### Added entry (§14 already carries it in full), and
  corrects the step-3 set-state-in-effect claims that #203 superseded.
- THIRD_PARTY_LICENSES re-verified byte-for-byte against upstream at the
  pinned Trivy/Grype/Syft tags; backend/requirements.lock confirmed
  drift-free; the Dependabot queue is empty.

Issue #194 is a stale published image rather than a defect: its only
gate finding is CVE-2026-69247 (cryptography 49.0.0), already closed on
dev at 50.0.0 and verified in upstream's source.

See docs/ARCHIVE.md § Deviations for the full record.
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