Skip to content

Commit 5bfcc67

Browse files
authored
improvement(url-state): use nuqs setters and derive state instead of mirroring it (#6486)
* improvement(url-state): use nuqs setters and derive state instead of mirroring it Wave 1 of a URL-state audit sweep. - files: replace the last hand-built same-path query mutation with the nuqs group setter, which no longer drops shareFileId/search/type/size/uploaded-by/sort/dir - suspense: give six page entries their co-located loading.tsx skeleton instead of fallback={null} - invite: derive isNewUser/urlError/token during render so the invitation query key is correct on first commit - resume: derive selectedStatus/queuePosition from the query cache the mutation already writes - verify, logs, terminal: delete dead and duplicate state - rules: document same-path router.replace as a query mutation, and the loading.tsx-as-Suspense-fallback convention * fix(invite): wait for the stored token before enabling the invitation query An authenticated user opening an invite without a token in the URL fired the query with a null token before the effect restored the session-stored one, producing a transient forbidden state and a redundant request under a second cache key. Distinguish 'storage not yet read' (undefined) from 'read and empty' (null) and gate the query on that.
1 parent 4228b04 commit 5bfcc67

19 files changed

Lines changed: 78 additions & 126 deletions

File tree

.claude/rules/sim-url-state.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Put state in the URL **only** when it is *all* of: shareable, deep-linkable, boo
3434
## Anti-patterns (forbidden)
3535

3636
- Direct `useSearchParams().get(...)` or `new URLSearchParams(window.location.search)` to **read** state.
37-
- Hand-built query strings + `router.replace`/`router.push` to **mutate** state.
37+
- Hand-built query strings + `router.replace`/`router.push` to **mutate** state. **If the target path equals the current path, it is a query mutation, not a navigation** — even when written as a full path template. Re-serializing the path by hand is lossy by construction: it drops every param the template forgets. Use the nuqs setter (`setParams({ key: null }, { history: 'replace', scroll: false })`) — `null` always removes the key, and only the params you name are touched. Both options are already nuqs defaults (see "Conventions"); write them explicitly because a group whose shared options set `history: 'push'` (e.g. `filesUrlKeys`) would otherwise push a back-stack entry for a strip.
3838
- `window.history.replaceState`/`pushState` to mutate a param.
3939
- Duplicating URL state into a store and syncing it with effects / `popstate` listeners.
4040
- High-frequency or large state in the URL (cursor, pan/zoom, un-debounced keystrokes, big JSON blobs).
@@ -44,7 +44,7 @@ These reads/mutations are **not** anti-patterns and stay as-is:
4444

4545
- **Outbound URL builders**`new URLSearchParams({...})` to construct a `href`, a download endpoint, an external WebSocket/API URL, or a `window.open(_, '_blank')` destination.
4646
- **Route navigations**`router.push('/path/[id]?folderId=x')` that changes the route *path*, not just the current query. A nuqs setter only mutates the query on the current path; cross-path navigation stays on `router`.
47-
- **Read-once auth / redirect signals**`token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `upgraded`, `redirect_workflow`, etc. These are navigation signals consumed once (often read-then-strip), not synced view-state. Leave them on `useSearchParams`.
47+
- **Read-once auth / redirect signals**`token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `new` (invite signup flow), `upgraded`, `redirect_workflow`, etc. These are navigation signals consumed once (often read-then-strip), not synced view-state. Leave them on `useSearchParams`. Key names are per-surface: files' `new` is a genuine nuqs param (`files/search-params.ts`), while invite's `new` is a one-shot signup signal.
4848

4949
## Per-feature `search-params.ts` — single source of truth
5050

@@ -128,7 +128,22 @@ If a client param must be re-read server-side after a change, set `shallow: fals
128128

129129
## Suspense boundary
130130

131-
`useQueryState`/`useQueryStates` read `useSearchParams` internally, so any client component using them must sit under a `<Suspense>` boundary (Next.js requirement). Wrap the page entry with a real-chrome fallback so a suspend never flashes a blank frame — see `apps/sim/app/workspace/[workspaceId]/files/page.tsx`.
131+
`useQueryState`/`useQueryStates` read `useSearchParams` internally, so any client component using them must sit under a `<Suspense>` boundary (Next.js requirement). Wrap the page entry with a real-chrome fallback so a suspend never flashes a blank frame.
132+
133+
**Never `fallback={null}` on a page entry.** The route's co-located `loading.tsx` default export *is* the correct fallback — one skeleton serves both the route-level navigation transition (which Next renders automatically) and the in-page suspend (which this boundary renders). If the segment has no `loading.tsx`, add one; the route transition needs it anyway. Import it absolutely (`sim-imports.md`):
134+
135+
```typescript
136+
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
137+
import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/loading'
138+
139+
<Suspense fallback={<KnowledgeBaseLoading />}>
140+
<KnowledgeBase id={id} knowledgeBaseName={kbName || 'Knowledge Base'} />
141+
</Suspense>
142+
```
143+
144+
Reference: `apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx`.
145+
146+
This applies to **page entries**. An inner `<Suspense>` wrapping a `lazy()` component is the exception: there `fallback={null}` is correct, precisely so the suspend resolves at the nearest boundary instead of flashing the whole route — see `sim-imports.md`, "Code-splitting through barrels".
132147

133148
## Debounced text inputs
134149

apps/sim/app/(auth)/login/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Suspense } from 'react'
22
import type { Metadata } from 'next'
33
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
44
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
5+
import LoginLoading from '@/app/(auth)/login/loading'
56
import LoginForm from '@/app/(auth)/login/login-form'
67

78
export const metadata: Metadata = {
@@ -15,7 +16,7 @@ export default async function LoginPage() {
1516
await getOAuthProviderStatus()
1617

1718
return (
18-
<Suspense fallback={null}>
19+
<Suspense fallback={<LoginLoading />}>
1920
<LoginForm
2021
githubAvailable={githubAvailable}
2122
googleAvailable={googleAvailable}

apps/sim/app/(auth)/sso/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Suspense } from 'react'
22
import type { Metadata } from 'next'
33
import { redirect } from 'next/navigation'
44
import { isRegistrationDisabled, isSsoEnabled } from '@/lib/core/config/env-flags'
5+
import SSOLoading from '@/app/(auth)/sso/loading'
56
import SSOForm from '@/ee/sso/components/sso-form'
67

78
export const metadata: Metadata = {
@@ -16,7 +17,7 @@ export default async function SSOPage() {
1617
}
1718

1819
return (
19-
<Suspense fallback={null}>
20+
<Suspense fallback={<SSOLoading />}>
2021
<SSOForm registrationDisabled={isRegistrationDisabled} />
2122
</Suspense>
2223
)

apps/sim/app/(auth)/verify/use-verification.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,20 +81,13 @@ export function useVerification({
8181
const [email, setEmail] = useState('')
8282
const [status, setStatus] = useState<VerificationStatus>('idle')
8383
const [isResending, setIsResending] = useState(false)
84-
const [isSendingInitialOtp, setIsSendingInitialOtp] = useState(false)
8584
const [errorMessage, setErrorMessage] = useState('')
8685

8786
useEffect(() => {
8887
const storedEmail = sessionStorage.getItem('verificationEmail')
8988
if (storedEmail) setEmail(storedEmail)
9089
}, [])
9190

92-
useEffect(() => {
93-
if (email && !isSendingInitialOtp && hasEmailService) {
94-
setIsSendingInitialOtp(true)
95-
}
96-
}, [email, isSendingInitialOtp, hasEmailService])
97-
9891
const isOtpComplete = otp.length === 6
9992

10093
async function verifyCode() {

apps/sim/app/(auth)/verify/verify-content.tsx

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,21 +46,15 @@ function VerificationForm({
4646
const isInvalidOtp = status === 'error'
4747

4848
const [countdown, setCountdown] = useState(0)
49-
const [isResendDisabled, setIsResendDisabled] = useState(false)
5049

5150
useEffect(() => {
52-
if (countdown > 0) {
53-
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000)
54-
return () => clearTimeout(timer)
55-
}
56-
if (countdown === 0 && isResendDisabled) {
57-
setIsResendDisabled(false)
58-
}
59-
}, [countdown, isResendDisabled])
51+
if (countdown <= 0) return
52+
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000)
53+
return () => clearTimeout(timer)
54+
}, [countdown])
6055

6156
const handleResend = () => {
6257
resendCode()
63-
setIsResendDisabled(true)
6458
setCountdown(30)
6559
}
6660

@@ -128,7 +122,7 @@ function VerificationForm({
128122
Resend in <span className='text-[var(--text-primary)]'>{countdown}s</span>
129123
</span>
130124
) : (
131-
<AuthTextLink onClick={handleResend} disabled={isLoading || isResendDisabled}>
125+
<AuthTextLink onClick={handleResend} disabled={isLoading}>
132126
Resend
133127
</AuthTextLink>
134128
)}

apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,9 @@ export default function ResumeExecutionPage({
185185
executionId,
186186
selectedContextId ?? undefined
187187
)
188-
const [selectedStatus, setSelectedStatus] =
189-
useState<PausePointWithQueue['resumeStatus']>('paused')
190-
const [queuePosition, setQueuePosition] = useState<number | null | undefined>(undefined)
188+
const selectedStatus: PausePointWithQueue['resumeStatus'] =
189+
selectedDetail?.pausePoint.resumeStatus ?? 'paused'
190+
const queuePosition = selectedDetail?.pausePoint.queuePosition
191191
const resumeInputsRef = useRef<Record<string, string>>({})
192192
const [resumeInput, setResumeInput] = useState('')
193193
const [formValuesByContext, setFormValuesByContext] = useState<
@@ -440,10 +440,7 @@ export default function ResumeExecutionPage({
440440
[]
441441
)
442442

443-
const selectedOperation = useMemo(
444-
() => selectedDetail?.pausePoint.response?.data?.operation || 'human',
445-
[selectedDetail]
446-
)
443+
const selectedOperation = selectedDetail?.pausePoint.response?.data?.operation || 'human'
447444
const isHumanMode = selectedOperation === 'human'
448445

449446
const inputFormatFields = useMemo(
@@ -524,8 +521,6 @@ export default function ResumeExecutionPage({
524521

525522
useEffect(() => {
526523
if (!selectedDetail) return
527-
setSelectedStatus(selectedDetail.pausePoint.resumeStatus)
528-
setQueuePosition(selectedDetail.pausePoint.queuePosition)
529524
seedFormFromDetail(selectedDetail)
530525
}, [selectedDetail, seedFormFromDetail])
531526

@@ -604,7 +599,6 @@ export default function ResumeExecutionPage({
604599
})
605600
if (!ok) {
606601
setError(payload.error || 'Failed to resume execution.')
607-
setSelectedStatus(selectedDetail.pausePoint.resumeStatus)
608602
return
609603
}
610604
const nextStatus = payload.status === 'queued' ? 'queued' : 'resuming'
@@ -641,8 +635,6 @@ export default function ResumeExecutionPage({
641635
}
642636
}
643637
)
644-
setSelectedStatus(nextStatus)
645-
setQueuePosition(nextQueuePosition)
646638
setSelectedContextId((prev) => (prev !== selectedContextId ? prev : fallbackContextId))
647639
setMessage(
648640
payload.status === 'queued' ? 'Resume request queued.' : 'Resume started successfully.'

apps/sim/app/invite/[id]/invite.tsx

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -278,35 +278,33 @@ export default function Invite({ registrationDisabled }: InviteProps) {
278278
const { data: session, isPending } = useSession()
279279
const queryClient = useQueryClient()
280280
const [actionError, setActionError] = useState<InviteError | null>(null)
281-
const [urlError, setUrlError] = useState<InviteError | null>(null)
282281
const [isAccepting, setIsAccepting] = useState(false)
283282
const [accepted, setAccepted] = useState(false)
284-
const [isNewUser, setIsNewUser] = useState(false)
285-
const [token, setToken] = useState<string | null>(null)
283+
/** `undefined` until the effect reads storage; `null` once read and empty. */
284+
const [storedToken, setStoredToken] = useState<string | null | undefined>(undefined)
286285

287-
useEffect(() => {
288-
const errorReason = searchParams.get('error')
289-
const isNew = searchParams.get('new') === 'true'
290-
setIsNewUser(isNew)
286+
const isNewUser = searchParams.get('new') === 'true'
287+
const errorReason = searchParams.get('error')
288+
const urlError = errorReason ? getInviteError(errorReason) : null
289+
const tokenFromQuery = searchParams.get('token')
290+
/**
291+
* Derived during render so the invitation query key is correct on the first
292+
* commit; an effect-set token refetches under a second key whenever the
293+
* session cache is already warm at mount.
294+
*/
295+
const token = tokenFromQuery ?? storedToken ?? null
296+
const isTokenResolved = tokenFromQuery !== null || storedToken !== undefined
291297

292-
const tokenFromQuery = searchParams.get('token')
298+
useEffect(() => {
293299
if (tokenFromQuery) {
294-
setToken(tokenFromQuery)
295300
sessionStorage.setItem(inviteTokenStorageKey, tokenFromQuery)
296-
} else {
297-
const storedToken = sessionStorage.getItem(inviteTokenStorageKey)
298-
if (storedToken) {
299-
setToken(storedToken)
300-
}
301-
}
302-
303-
if (errorReason) {
304-
setUrlError(getInviteError(errorReason))
301+
return
305302
}
306-
}, [searchParams, inviteId, inviteTokenStorageKey])
303+
setStoredToken(sessionStorage.getItem(inviteTokenStorageKey))
304+
}, [tokenFromQuery, inviteTokenStorageKey])
307305

308306
const invitationQuery = useInvitationDetails(inviteId, token, session?.user?.id ?? null, {
309-
enabled: Boolean(session?.user),
307+
enabled: Boolean(session?.user) && isTokenResolved,
310308
})
311309
const invitation = invitationQuery.data?.invitation ?? null
312310
const joinPreview = invitationQuery.data?.joinPreview ?? null

apps/sim/app/invite/[id]/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Suspense } from 'react'
22
import type { Metadata } from 'next'
33
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
44
import Invite from '@/app/invite/[id]/invite'
5+
import InviteLoading from '@/app/invite/[id]/loading'
56

67
export const metadata: Metadata = {
78
title: 'Invite',
@@ -12,7 +13,7 @@ export const dynamic = 'force-dynamic'
1213

1314
export default function InvitePage() {
1415
return (
15-
<Suspense fallback={null}>
16+
<Suspense fallback={<InviteLoading />}>
1617
<Invite registrationDisabled={isRegistrationDisabled} />
1718
</Suspense>
1819
)

apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Suspense } from 'react'
22
import type { Metadata } from 'next'
3-
import { Files } from '../files'
3+
import { Files } from '@/app/workspace/[workspaceId]/files/files'
4+
import FilesLoading from '@/app/workspace/[workspaceId]/files/loading'
45

56
export const metadata: Metadata = {
67
title: 'Files',
@@ -9,7 +10,7 @@ export const metadata: Metadata = {
910

1011
export default function FilesFilePage() {
1112
return (
12-
<Suspense fallback={null}>
13+
<Suspense fallback={<FilesLoading />}>
1314
<Files />
1415
</Suspense>
1516
)

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1534,13 +1534,9 @@ export function Files() {
15341534

15351535
useEffect(() => {
15361536
if (isNewFile && fileIdFromRoute) {
1537-
router.replace(
1538-
currentFolderId
1539-
? `/workspace/${workspaceId}/files/${fileIdFromRoute}?folderId=${currentFolderId}`
1540-
: `/workspace/${workspaceId}/files/${fileIdFromRoute}`
1541-
)
1537+
void setFilesParams({ new: null }, { history: 'replace', scroll: false })
15421538
}
1543-
}, [isNewFile, fileIdFromRoute, router, workspaceId, currentFolderId])
1539+
}, [isNewFile, fileIdFromRoute, setFilesParams])
15441540

15451541
useEffect(() => {
15461542
const handleKeyDown = (e: KeyboardEvent) => {

0 commit comments

Comments
 (0)