fix: clear the 12 pre-existing lint errors (1 React refs correctness fix + typing debt) - #36
Merged
Conversation
useConnectionManager kept the latest authNetworkClient/removeNetworkClient in
refs with bare assignments in the hook body:
authFnRef.current = authNetworkClient;
removeFnRef.current = removeNetworkClient;
Those refs are read by the ConnectionManager, which is built once (useCallback
with [] deps) and outlives every render. It calls the auth thunk from timers --
silentRenew() at 80% of the proxy lifetime and the scheduleReconnect() retry --
so the value published during render is consumed long after that render is
over.
Writing a ref during render is unsound: React may start a render and throw it
away, and the discarded render's value would already have been published into
the live manager. A renew or reconnect firing afterwards would then use a
callback the committed UI never adopted. React's own eslint-plugin-react-hooks
v7 flags this as react-hooks/refs, "Cannot update ref during render".
Move both writes into a passive effect with no dependency array, which runs
after every commit and therefore stores the latest *committed* value. The
useRef seeds are kept, so the refs are never empty before the first commit.
Behaviour today is unchanged: ConnectScreen (the hook's only caller) is gated
behind isAuthenticated, so it never renders with a null token, and both SDK
callbacks are memoised for the hook's whole lifetime -- the effect stores
exactly what the render-phase assignment stored. The value is in removing an
unsound pattern before something introduces a discardable render (Suspense,
startTransition, or the React Compiler, which react-hooks v7 clean is a
precondition for).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MAXFxG1EK4jTxQ1iW73BUr
…param Pure typing/style debt, no behaviour change -- `as any` erases at compile time and the one runtime edit (dropping a trailing listener parameter) is invisible to the caller. Firefox `browser` global (7 sites). @types/chrome is the only extension typing installed and it declares `chrome` alone; nothing in the tree declares Firefox's `browser`, so every access was cast through `any`. Add src/types/firefox-webext.d.ts declaring only the members this code actually calls -- proxy.onRequest add/remove/hasListener, the optional proxy.onError, and a request shape with just `url` -- and cast `globalThis` to it at each feature-detecting call site. Nothing about the rest of the namespace is asserted, and every member is optional because Chrome has no `browser` at all. The types are a module that call sites `import type`, not a `declare global` var: keeping `browser` unreachable as a bare identifier matters, since `browser?.x` throws a ReferenceError on Chrome while `globalThis.browser?.x` is just a property read. It also keeps the declarations visible to tsconfig.test.json, whose `include` is limited to tests/ and would not pick up an ambient file under src/. proxy-manager.ts had byte-identical local copies of FirefoxProxyInfo and FirefoxProxyDetails; they move into the shared file rather than being duplicated. Two consequences of typing getFirefoxProxyApi() properly: ensureFirefoxListener() now returns the listener it guarantees (instead of void, which left the caller holding `Listener | null`), and enableMultiIp() narrows proxy.onRequest explicitly -- redundant at runtime behind the existing isFirefoxProxyApiAvailable() check, but it is what the type checker needs. sso.ts and use-provider-list-enhanced.ts needed no new types at all: `browser` is already declared as `typeof chrome | undefined` in that file, and URNetworkAPI.networkProviderLocations() is declared in the SDK's own api.d.ts. Both casts were simply unnecessary and are deleted. background/index.ts: drop the unused third parameter of the SSO tab listener. tsconfig sets noUnusedParameters and tsc honours the `_tab` underscore convention, but @typescript-eslint/no-unused-vars resolves here with no argsIgnorePattern, so the two tools disagreed. It is the trailing parameter, and Chrome passes all three arguments regardless of declared arity, so removing it changes nothing. The other three underscore-prefixed params in the repo are positional placeholders followed by used arguments and are correctly left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MAXFxG1EK4jTxQ1iW73BUr
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
This repo has never had CI, so nobody ever saw that
npm run lintfails with 12 errors onmain. The new build-and-test workflow in #35 surfaced them, and the same lint step fails inurnetwork/build#19. This branch fixes all 12 in the code — noeslint-disable, no config loosening, no rule severity changes, no renames-to-silence.Verified on this branch (Node 18.19.1, eslint 9.39, tsc 5.9, vitest 4.1):
npm run lintnpx tsc -b./test.sh(tsc -p tsconfig.test.json+ vitest)Two commits, deliberately separated, because they are not the same kind of change.
Commit 1 — correctness: stop writing refs during render
This is the one worth real review. The other 10 errors are typing debt; these 2 were
react-hooks/refscatching an actually-unsound pattern.useConnectionManagerkept the latest SDK callbacks in refs using bare assignments in the hook body:Those refs are read by the
ConnectionManager, which is constructed once (useCallbackwith[]deps) and outlives every render. It invokes the auth thunk from timers —silentRenew()at 80% of the proxy lifetime, and thescheduleReconnect()retry — i.e. long after the render that published the value has finished.That is the unsound part: React may begin a render and discard it, but the discarded render has already published its callback into a live, long-lived object. A renew or reconnect firing afterwards would use a callback the committed UI never adopted.
The fix moves both writes into a dependency-array-less effect, which runs after every commit and therefore stores the latest committed value. The
useRefseeds stay, so the refs are never empty before the first commit.What changes for users today: nothing — and I want to be straight about why
I could not find a reachable failure path on
main, and I don't want to oversell this as a live bug. Three things independently close every path, all re-verified in the tree:grepoversrc/andelements/src/finds nostartTransition,useDeferredValue,Suspense, or<Activity>, and the React Compiler is not enabled (vite.config.tsuses plain@vitejs/plugin-react). StrictMode is on, but its double-render writes the same value twice, which is idempotent.authNetworkClienton[api, token]andremoveNetworkClienton[api];apiis pinned in auseRefbyURNetworkAPIProvider, andtokencannot change while the hook is mounted (see 3). So both lines only ever rewrite the value already stored.AppRoutes.tsx:27gates<ConnectScreen/>behindisAuthenticated(!!token), anduseConnectionManageris called only fromConnectScreen.tsx:48.setAuthis called only inAuthInitial(unmounted by then);clearAuthis called only from ConnectScreen's own logout path, which unmounts ConnectScreen rather than re-rendering it with a new token.So: a genuine violation of a rule that is right about the mechanism, fixed because the mechanism is unsafe — not because a user is hitting it today. It is also a precondition for ever turning on the React Compiler, which may skip re-executing a straight-line statement like that assignment regardless of concurrency.
Rejected alternatives:
useEffectEvent(these functions are stored insideConnectionManagerand called from bare timers outside React, which effect events may not do); recreating theConnectionManagerwhen the callback identity changes (it owns live state —multiClientIds, ping interval, renew timeout,operationLock— and rebuilding it would drop the provisioned proxy pool mid-session).Commit 2 — typing debt: 9 ×
no-explicit-any+ 1 ×no-unused-varsNo behaviour change.
as anyerases at compile time, and the single runtime edit (dropping a trailing listener parameter) is invisible to the caller.Note for reviewers scanning for weasel-typing: no type was narrowed to
unknown, and no interface was invented to describe something I couldn't substantiate. Everyanyfell into one of two buckets:Bucket A — a real external typing gap (7 sites)
@types/chromeis the only extension typing installed and it declareschromeonly; nothing in the dependency tree declares Firefox'sbrowserglobal, which is why every access was cast throughany. Newsrc/types/firefox-webext.d.tsdeclares only the members this codebase actually calls —proxy.onRequestadd/remove/hasListener, the optionalproxy.onError, and a request shape containing justurl. Every member is optional, matching the fact that Chrome has nobrowserat all and every call site feature-detects.Two design points worth a look:
import type, notdeclare global { var browser }. Partly because keepingbrowserunreachable as a bare identifier is correct (browser?.xthrows aReferenceErroron Chrome — optional chaining doesn't guard an undeclared identifier — whereasglobalThis.browser?.xis a plain property read); partly becausetsconfig.test.jsonsets"include": ["tests"], so an ambient file undersrc/would not reliably reach the test program.kill-switch-apply.tsandproxy-manager.tsare test-reachable viatests/bridge/*, so this matters. An explicitimport typefollows module resolution and works in both programs.proxy-manager.tshad byte-identical local copies ofFirefoxProxyInfo/FirefoxProxyDetails; they move into the shared file instead of being duplicated a second time.Bucket B — casts that were simply unnecessary (2 sites)
sso.ts:51—(browser as any).runtime?.lastError?.message.browseris already declared astypeof chrome | undefinedat the top of that same file, andtypeof browser !== "undefined"narrows it. Cast deleted, nothing added.use-provider-list-enhanced.ts:193—(api as any).networkProviderLocations().useAPI()returnsURNetworkAPI, andnetworkProviderLocations(): Promise<FindLocationsResult>is declared in the SDK's owndist/api.d.ts:14. Cast deleted, nothing added.The unused-var
background/index.ts:126— dropped the unused third parameter of the SSO tab listener. tsconfig setsnoUnusedParametersand tsc honours the_tabunderscore convention, but@typescript-eslint/no-unused-varsresolves here as bare[2]with noargsIgnorePattern, so the two tools disagreed. It is the trailing parameter and Chrome passes all three arguments regardless of declared arity, so removing it changes nothing.Things a reviewer who owns this code should check
proxy-manager.tsgrew two small structural changes that typinggetFirefoxProxyApi()forced, and they're the only non-mechanical edits in commit 2:ensureFirefoxListener()now returns the listener it guarantees, instead ofvoid(which left callers holdingFirefoxProxyRequestListener | nulland failing the typecheck).addFirefoxProxyListeneruses that return value.enableMultiIp()now narrowsproxy.onRequestwith an explicitif (!onRequest) return;. This is redundant at runtime — the existingisFirefoxProxyApiAvailable()guard on the first line already establishes it — but the type checker can't see through that function. If you'd rather express it differently, say so.browser.*member the extension touches (or plans to), it belongs in that file. Adding@types/firefox-webext-browseras a devDependency would be the alternative; I didn't, because it pulls in a full second WebExtension namespace to type six property reads.tests/bridge/{origin-gating,refresh-jwt,verbs}.test.tsis bridge-only and exercises neither the hook nor the proxy manager. A green suite here is not evidence of behaviour preservation — the argument above is. Firefox proxying and the connect/renew path are worth a manual smoke test.declare var browserwas tried and rejected, in case it looks like the obvious approach: it typechecks, but eslint then fails onno-var(severity 2, from typescript-eslint'seslint-recommendedlayer), anddeclare const/letcan't back aglobalThis.browseraccess since onlyvar/functionglobal declarations become properties oftypeof globalThis.Deliberately not fixed
eslint.config.jsis untouched. AddingargsIgnorePattern: '^_'would also have made the_taberror disappear, but it's a lint-config change, it's unnecessary once the trailing param is gone, and it would permanently hide future trailing-unused-arg bugs. Worth recording that the repo has a real convention mismatch here (tsc honours_, eslint doesn't) — but the fix for that is a conversation, not this PR._senderatbackground/index.ts:200and:265,_targetatcontent/geo-main.ts:152). ESLint's defaultargs: "after-used"correctly doesn't flag them: each is followed by a used parameter, so deleting one would shift the arguments. Confirmed by the typecheck failing when tried.isFirefox()were not deduplicated. Merging them is a refactor beyond what the errors require; they now share the type, not the function.useRemoveNetworkClientis memoised on[api]while its body closes overtoken, so a token change can never be delivered to it. If a token change during mount ever became possible,releaseMultiClientIdswould call with a stale token and get back a resolved{ error: ... }— not a rejection — which.catch(() => {})atconnection-manager.ts:339never sees andPromise.allSettleddiscards, leaking network clients silently. That's upstream in@urnetwork/sdk-js, not here.🤖 Generated with Claude Code
https://claude.ai/code/session_01MAXFxG1EK4jTxQ1iW73BUr