Skip to content

Clear the 13 ui-components lint findings, with tests that catch what the obvious fixes break - #98

Merged
juicycleff merged 7 commits into
mainfrom
fix/ui-components-lint-findings
Aug 26, 2026
Merged

Clear the 13 ui-components lint findings, with tests that catch what the obvious fixes break#98
juicycleff merged 7 commits into
mainfrom
fix/ui-components-lint-findings

Conversation

@juicycleff

Copy link
Copy Markdown
Contributor

Closes #97.

The 13 findings are gone, the package has a test harness and 33 tests, and the
js CI job now runs lint and tests for the ui workspace so this cannot go quiet
again.

The issue's read of the findings was wrong on five of the eight

Worth knowing before you review, because it changes what the tests are aimed
at. The issue describes all eight set-state-in-effect sites as network
components with their own loading and error state, and flags sign-in.tsx and
sign-up.tsx as the highest risk. They are three different shapes, and only
three touch the network.

The three list loaders. device-list.tsx, session-list.tsx,
passkey-list.tsx. Each calls a useCallback from an effect, and that
callback opens with setIsLoading(true). This is the useOrganizations shape
from #94 exactly, with the same trap.

Three URL readers. sign-in.tsx:212 and sign-up.tsx:128 are both
useSubPath, byte for byte identical. device-authorization-form.tsx:274 is
useCodeFromURL. No network, no loading flag, no error state. sign-in and
sign-up have no spinner to break.

Two prop mirrors. device-authorization-form.tsx:133 copies initialCode
into the OTP value, and sign-up-form.tsx:258 seeds configured field
defaults.

Same lint rule, three unrelated hazards. One test strategy across all eight
would have been pointed at the wrong one five times.

Each family fails differently, and each test was checked against the failure

Every test here was run against a deliberately broken version of the code
before the real fix went in. Three things came out of that.

The list components behave as #94 warned. Moving setIsLoading(true) past the
await is lint clean and passes the mount test, because isLoading already
starts true. What it loses is every load after the first. Change your session
token and the previous account's devices sit there with nothing indicating a
refetch. passkey-list is worse than the other two, not better: its no-token
branch settles loading to false, so the pre-hydration pass kills the spinner
before the token arrives and it never returns. So loading is derived here now,
not stored. The list is loading whenever what is on screen does not belong to
the token it is being asked about.

The URL readers have the opposite trap. Each effect opens by recomputing what
the useState initializer already worked out, so the line looks dead and is
the obvious one to cut. Cut it and every mount test still passes. What breaks
is the resync when basePath changes. useSyncExternalStore has no such line
to lose, and useSubPath is one shared module now instead of two copies.

The prop mirrors are where the linter's own advice is the bug. exhaustive-deps
wants code and isSubmitting on the device form's dep list. Add them and
every keystroke re-runs the effect, sees your typing differ from the code in
the URL, and overwrites it. The suppressed dep list was hiding a real reason.
sign-up-form fails the other way round: seed only on a change and the mount
pass compares equal, so the defaults never appear.

One of the anys was hiding a live bug

social-login.ts:42 looked like a routine no-explicit-any. handleSocialLogin
was calling:

client.startOAuth(providerId, { redirect_url: window.location.href })

startOAuth takes frontend_url and redirect_url positionally and puts them
in the query string. The object went into frontend_url, the generated client
ran String() over it, and the request went out as
frontend_url=%5Bobject+Object%5D with no redirect_url at all. Sign in with a
social provider and you do not get returned to the page you started on.

It stayed invisible because frontend_url is checked against the allowlist in
plugins/social/plugin.go, "[object Object]" fails that check, and the
backend falls back to a trusted Origin. The flow finishes. You just end up
somewhere else.

The any and the bug are the same problem. No honest type accepts both an
object at the call site and the real (provider, string?, string?) method,
which is why the any was there. Typing it forces the call to be right. This
is the one behaviour change in the PR that goes past a lint cleanup, so it is
worth a second opinion.

The other two anys are ordinary. completeDeviceAuthorization is properly
typed on the AuthClient that ui-core actually ships, so that cast bought
nothing. The waitlist form still reaches for a private baseURL, the same way
ui-core's own client.ts does for /v1/client-config, but through a named
shape rather than any. A public getter on AuthClient would be better and
belongs in its own change.

The harness

Copied from ui-react's, added in #92. Turbo needed nothing, its test task
already declares dependsOn: ["^build"].

test-support.tsx gives you two ways in. withProvider drives the real
AuthProvider, AuthManager and AuthClient over an injected fetch and
storage, so a test exercises the code that ships. stubAuth hands back an
AuthContextValue the test owns, for the cases that need the session to change
while the component stays mounted. AuthContext is exported from ui-react for
that purpose.

Checking it

cd ui && pnpm install && pnpm exec turbo build typecheck lint test --force

--force matters. While building this, turbo served a cached ui-react build
from a worktree that no longer exists on disk, so a cached pass will report
green for a tree it never touched.

Run against a clean tree with every dist/ removed: build 5/5, typecheck 8/8,
lint 5/5, test 7/7. ui-components is 33 tests across 8 files.

ui-core still reports nine no-explicit-any warnings. eslint exits 0 on
warnings, so the new CI step is green without them, and they are somebody
else's afternoon.

Copies the setup ui-react got in #92: vitest, jsdom, and globals on so
@testing-library/react can register its own cleanup. Turbo needs no change
because its test task already declares dependsOn ["^build"].

test-support.tsx carries the scaffolding the component tests share. It gives
you two levers. withProvider drives the real AuthProvider, AuthManager and
AuthClient stack over an injected fetch and storage, which is how a test
proves a component works against the code that ships. stubAuth hands back an
AuthContextValue the test owns, for the cases that need the session to change
while the component stays mounted. AuthContext is exported from ui-react for
exactly that.

routedFetch dispatches on "METHOD /path" and 404s anything unrouted, so a
missing route shows up as a failed assertion instead of a hung test. It
serves /v1/me by default, since AuthManager.initialize fetches the profile
before it will report a session at all.
DeviceList, SessionList and PasskeyList all called a useCallback that opened
with setIsLoading(true) from inside an effect, which is what
react-hooks/set-state-in-effect reports. The cheap way out is to move the
loading flag past the await so nothing is set synchronously. It passes lint,
and it is the bug #94 shipped: isLoading starts true, so the mount case still
looks right, and every later load has no spinner at all. Change your session
token and you sit looking at the previous account's devices with nothing
saying the list is being refetched.

So loading is derived here rather than stored. The list is loading whenever
what is on screen does not belong to the token it is now being asked about.
That is true on the first render and true again the instant the token
changes, and no effect has to set it. The refetch a handler triggers keeps
its own flag, because a handler may set state synchronously.

PasskeyList is worse than the other two under the cheap fix, not better. Its
no-token branch settles loading to false, so the pre-hydration pass turns the
spinner off before the token ever arrives and it never comes back. Deriving
loading also lets that branch settle honestly instead of spinning forever
when you are signed out.

Each test was checked against a deliberately broken version. The mount test
passes under the trap in all three, which is the point: only the
token-changes test catches it.

Removing the useCallback from PasskeyList also clears the
preserve-manual-memoization finding on the same file.
useSubPath was duplicated byte for byte in sign-in.tsx and sign-up.tsx, and
useCodeFromURL in device-authorization-form.tsx is the same shape over the
query string. All three mirrored window.location into state and resynced it
from an effect, which is the set-state-in-effect finding on each.

The trap here is not the one the list components had. Each effect opens by
re-deriving the value the useState initializer already computed, so that line
reads as dead code and is the obvious one to delete. Delete it and every
mount test still passes. What breaks is the resync when basePath changes,
which is what happens when you mount SignIn and SignUp against the same URL.

useSyncExternalStore has no such line to lose. A new basePath is a new
snapshot function and React re-reads it, and the third argument reports
undefined during SSR the way the old window guard did.

useSubPath is now one shared module instead of two copies, so it is fixed and
tested once. sign-in.test.tsx covers the swap through the component, since
the screen you get is picked off the sub-path.
Two places mirrored a prop into state from an effect. DeviceAuthorizationForm
copies initialCode, or the code it read from the URL, into the OTP value.
SignUpForm seeds the configured defaults for its dynamic fields. Both now use
React's documented pattern for adjusting state when a prop changes, comparing
against the last value applied, so nothing is set inside an effect.

Worth reading before you touch the device form again: doing what
exhaustive-deps asks is the wrong move. It wants code and isSubmitting added
to the dep list. Add them and every keystroke re-runs the effect, sees the
typed value differ from the code in the URL, and overwrites what you just
typed. The suppressed dep list was hiding a real reason.

SignUpForm's risk runs the other way. Seeding it only on a change compares
equal on the mount pass and the defaults never appear at all, so the initial
values come from the useState initializer instead. prev still wins the
spread, since a default may only fill a field you have not set.

Dropping the `as any` on completeDeviceAuthorization goes here too. The
shipped AuthClient in ui-core extends the generated one and types that method
properly, so the cast was never buying anything.
This one started as the last no-explicit-any finding and turned out to be a
live bug. handleSocialLogin called startOAuth with an object:

    client.startOAuth(providerId, { redirect_url: window.location.href })

but startOAuth takes frontend_url and redirect_url positionally and puts them
in the query string. The object landed in frontend_url, the generated client
ran String() over it, and the request went out as
frontend_url=%5Bobject+Object%5D with no redirect_url at all. So the place
you started from was never sent, and after signing in you did not come back
to it.

It went unnoticed because frontend_url is validated against the allowlist in
plugins/social/plugin.go, "[object Object]" fails that check, and the backend
falls back to a trusted Origin. The flow completes. You just land somewhere
else.

The any and the bug are the same problem. No honest type accepts both an
object at the call site and the real (provider, string?, string?) method,
which is why any was there. Typing it properly forces the call to be right.

frontend_url is left unset on purpose so the backend keeps using the origin
it already trusts, rather than a value this function would be asserting.

The waitlist form's cast is narrowed to a named shape instead. There is no
waitlist method on the generated client and no public accessor for its base
URL, so it still reaches for the field, the same way ui-core's own client.ts
does for /v1/client-config. Worth a getter on AuthClient at some point.
The js job installed, built and typechecked, so pnpm lint had been failing in
ui-components for long enough to collect 13 findings and nothing said a word.
Turbo stopped at ui-react first, which is why #94 never reached the rest.

Tests go in for the same reason. A harness CI never runs is only slightly
more visible than a lint CI never runs.

Both pass across all five packages today. ui-core still reports nine
no-explicit-any warnings and eslint exits 0 on warnings, so the step is green
without them being fixed.
Comment thread ui/packages/components/src/lib/use-sub-path.ts Fixed
CodeQL flagged extractSubPath as js/polynomial-redos, high. The trailing-slash
trim used /\/+$/, and basePath is the caller's `path` prop rather than
anything this module controls.

The bad input is a run of slashes that does not end the string. The engine
consumes the run from every start position, fails the $, and backtracks the
whole way before shifting along one character. Measured on the old version:
46ms at 10k slashes, 1.0s at 50k, 4.2s at 100k, 16.8s at 200k. Clean
quadratic. The scan that replaces it does 200k in 0.11ms.

The leading-slash trim was never the problem, since /^\/+/ is anchored and
only ever tries one start position, but it goes the same way for consistency.

This regex is older than the harness. It was duplicated byte for byte in
sign-in.tsx and sign-up.tsx and CodeQL only saw it once it moved into a file
of its own. Both copies are gone now, so fixing it here fixes it everywhere.

The new test asserts elapsed time rather than waiting for the runner to time
out, so a regression says what it is instead of just hanging.
@juicycleff
juicycleff merged commit da5d5ec into main Aug 26, 2026
16 checks passed
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.

ui-components: 13 eslint findings, and no tests to fix them against

2 participants