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.
This commit is contained in:
Waleed
2026-08-09 23:19:20 -07:00
committed by GitHub
parent 4228b04eaa
commit 5bfcc679c8
19 changed files with 78 additions and 126 deletions
+18 -3
View File
@@ -34,7 +34,7 @@ Put state in the URL **only** when it is *all* of: shareable, deep-linkable, boo
## Anti-patterns (forbidden)
- Direct `useSearchParams().get(...)` or `new URLSearchParams(window.location.search)` to **read** state.
- Hand-built query strings + `router.replace`/`router.push` to **mutate** state.
- 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.
- `window.history.replaceState`/`pushState` to mutate a param.
- Duplicating URL state into a store and syncing it with effects / `popstate` listeners.
- 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:
- **Outbound URL builders** — `new URLSearchParams({...})` to construct a `href`, a download endpoint, an external WebSocket/API URL, or a `window.open(_, '_blank')` destination.
- **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`.
- **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`.
- **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.
## Per-feature `search-params.ts` — single source of truth
@@ -128,7 +128,22 @@ If a client param must be re-read server-side after a change, set `shallow: fals
## Suspense boundary
`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`.
`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.
**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`):
```typescript
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/loading'
<Suspense fallback={<KnowledgeBaseLoading />}>
<KnowledgeBase id={id} knowledgeBaseName={kbName || 'Knowledge Base'} />
</Suspense>
```
Reference: `apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx`.
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".
## Debounced text inputs
+2 -1
View File
@@ -2,6 +2,7 @@ import { Suspense } from 'react'
import type { Metadata } from 'next'
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
import LoginLoading from '@/app/(auth)/login/loading'
import LoginForm from '@/app/(auth)/login/login-form'
export const metadata: Metadata = {
@@ -15,7 +16,7 @@ export default async function LoginPage() {
await getOAuthProviderStatus()
return (
<Suspense fallback={null}>
<Suspense fallback={<LoginLoading />}>
<LoginForm
githubAvailable={githubAvailable}
googleAvailable={googleAvailable}
+2 -1
View File
@@ -2,6 +2,7 @@ import { Suspense } from 'react'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { isRegistrationDisabled, isSsoEnabled } from '@/lib/core/config/env-flags'
import SSOLoading from '@/app/(auth)/sso/loading'
import SSOForm from '@/ee/sso/components/sso-form'
export const metadata: Metadata = {
@@ -16,7 +17,7 @@ export default async function SSOPage() {
}
return (
<Suspense fallback={null}>
<Suspense fallback={<SSOLoading />}>
<SSOForm registrationDisabled={isRegistrationDisabled} />
</Suspense>
)
@@ -81,7 +81,6 @@ export function useVerification({
const [email, setEmail] = useState('')
const [status, setStatus] = useState<VerificationStatus>('idle')
const [isResending, setIsResending] = useState(false)
const [isSendingInitialOtp, setIsSendingInitialOtp] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
useEffect(() => {
@@ -89,12 +88,6 @@ export function useVerification({
if (storedEmail) setEmail(storedEmail)
}, [])
useEffect(() => {
if (email && !isSendingInitialOtp && hasEmailService) {
setIsSendingInitialOtp(true)
}
}, [email, isSendingInitialOtp, hasEmailService])
const isOtpComplete = otp.length === 6
async function verifyCode() {
+5 -11
View File
@@ -46,21 +46,15 @@ function VerificationForm({
const isInvalidOtp = status === 'error'
const [countdown, setCountdown] = useState(0)
const [isResendDisabled, setIsResendDisabled] = useState(false)
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000)
return () => clearTimeout(timer)
}
if (countdown === 0 && isResendDisabled) {
setIsResendDisabled(false)
}
}, [countdown, isResendDisabled])
if (countdown <= 0) return
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000)
return () => clearTimeout(timer)
}, [countdown])
const handleResend = () => {
resendCode()
setIsResendDisabled(true)
setCountdown(30)
}
@@ -128,7 +122,7 @@ function VerificationForm({
Resend in <span className='text-[var(--text-primary)]'>{countdown}s</span>
</span>
) : (
<AuthTextLink onClick={handleResend} disabled={isLoading || isResendDisabled}>
<AuthTextLink onClick={handleResend} disabled={isLoading}>
Resend
</AuthTextLink>
)}
@@ -185,9 +185,9 @@ export default function ResumeExecutionPage({
executionId,
selectedContextId ?? undefined
)
const [selectedStatus, setSelectedStatus] =
useState<PausePointWithQueue['resumeStatus']>('paused')
const [queuePosition, setQueuePosition] = useState<number | null | undefined>(undefined)
const selectedStatus: PausePointWithQueue['resumeStatus'] =
selectedDetail?.pausePoint.resumeStatus ?? 'paused'
const queuePosition = selectedDetail?.pausePoint.queuePosition
const resumeInputsRef = useRef<Record<string, string>>({})
const [resumeInput, setResumeInput] = useState('')
const [formValuesByContext, setFormValuesByContext] = useState<
@@ -440,10 +440,7 @@ export default function ResumeExecutionPage({
[]
)
const selectedOperation = useMemo(
() => selectedDetail?.pausePoint.response?.data?.operation || 'human',
[selectedDetail]
)
const selectedOperation = selectedDetail?.pausePoint.response?.data?.operation || 'human'
const isHumanMode = selectedOperation === 'human'
const inputFormatFields = useMemo(
@@ -524,8 +521,6 @@ export default function ResumeExecutionPage({
useEffect(() => {
if (!selectedDetail) return
setSelectedStatus(selectedDetail.pausePoint.resumeStatus)
setQueuePosition(selectedDetail.pausePoint.queuePosition)
seedFormFromDetail(selectedDetail)
}, [selectedDetail, seedFormFromDetail])
@@ -604,7 +599,6 @@ export default function ResumeExecutionPage({
})
if (!ok) {
setError(payload.error || 'Failed to resume execution.')
setSelectedStatus(selectedDetail.pausePoint.resumeStatus)
return
}
const nextStatus = payload.status === 'queued' ? 'queued' : 'resuming'
@@ -641,8 +635,6 @@ export default function ResumeExecutionPage({
}
}
)
setSelectedStatus(nextStatus)
setQueuePosition(nextQueuePosition)
setSelectedContextId((prev) => (prev !== selectedContextId ? prev : fallbackContextId))
setMessage(
payload.status === 'queued' ? 'Resume request queued.' : 'Resume started successfully.'
+18 -20
View File
@@ -278,35 +278,33 @@ export default function Invite({ registrationDisabled }: InviteProps) {
const { data: session, isPending } = useSession()
const queryClient = useQueryClient()
const [actionError, setActionError] = useState<InviteError | null>(null)
const [urlError, setUrlError] = useState<InviteError | null>(null)
const [isAccepting, setIsAccepting] = useState(false)
const [accepted, setAccepted] = useState(false)
const [isNewUser, setIsNewUser] = useState(false)
const [token, setToken] = useState<string | null>(null)
/** `undefined` until the effect reads storage; `null` once read and empty. */
const [storedToken, setStoredToken] = useState<string | null | undefined>(undefined)
const isNewUser = searchParams.get('new') === 'true'
const errorReason = searchParams.get('error')
const urlError = errorReason ? getInviteError(errorReason) : null
const tokenFromQuery = searchParams.get('token')
/**
* Derived during render so the invitation query key is correct on the first
* commit; an effect-set token refetches under a second key whenever the
* session cache is already warm at mount.
*/
const token = tokenFromQuery ?? storedToken ?? null
const isTokenResolved = tokenFromQuery !== null || storedToken !== undefined
useEffect(() => {
const errorReason = searchParams.get('error')
const isNew = searchParams.get('new') === 'true'
setIsNewUser(isNew)
const tokenFromQuery = searchParams.get('token')
if (tokenFromQuery) {
setToken(tokenFromQuery)
sessionStorage.setItem(inviteTokenStorageKey, tokenFromQuery)
} else {
const storedToken = sessionStorage.getItem(inviteTokenStorageKey)
if (storedToken) {
setToken(storedToken)
}
return
}
if (errorReason) {
setUrlError(getInviteError(errorReason))
}
}, [searchParams, inviteId, inviteTokenStorageKey])
setStoredToken(sessionStorage.getItem(inviteTokenStorageKey))
}, [tokenFromQuery, inviteTokenStorageKey])
const invitationQuery = useInvitationDetails(inviteId, token, session?.user?.id ?? null, {
enabled: Boolean(session?.user),
enabled: Boolean(session?.user) && isTokenResolved,
})
const invitation = invitationQuery.data?.invitation ?? null
const joinPreview = invitationQuery.data?.joinPreview ?? null
+2 -1
View File
@@ -2,6 +2,7 @@ import { Suspense } from 'react'
import type { Metadata } from 'next'
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
import Invite from '@/app/invite/[id]/invite'
import InviteLoading from '@/app/invite/[id]/loading'
export const metadata: Metadata = {
title: 'Invite',
@@ -12,7 +13,7 @@ export const dynamic = 'force-dynamic'
export default function InvitePage() {
return (
<Suspense fallback={null}>
<Suspense fallback={<InviteLoading />}>
<Invite registrationDisabled={isRegistrationDisabled} />
</Suspense>
)
@@ -1,6 +1,7 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { Files } from '../files'
import { Files } from '@/app/workspace/[workspaceId]/files/files'
import FilesLoading from '@/app/workspace/[workspaceId]/files/loading'
export const metadata: Metadata = {
title: 'Files',
@@ -9,7 +10,7 @@ export const metadata: Metadata = {
export default function FilesFilePage() {
return (
<Suspense fallback={null}>
<Suspense fallback={<FilesLoading />}>
<Files />
</Suspense>
)
@@ -1534,13 +1534,9 @@ export function Files() {
useEffect(() => {
if (isNewFile && fileIdFromRoute) {
router.replace(
currentFolderId
? `/workspace/${workspaceId}/files/${fileIdFromRoute}?folderId=${currentFolderId}`
: `/workspace/${workspaceId}/files/${fileIdFromRoute}`
)
void setFilesParams({ new: null }, { history: 'replace', scroll: false })
}
}, [isNewFile, fileIdFromRoute, router, workspaceId, currentFolderId])
}, [isNewFile, fileIdFromRoute, setFilesParams])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -3,9 +3,9 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { getSession } from '@/lib/auth'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { Files } from '@/app/workspace/[workspaceId]/files/files'
import FilesLoading from '@/app/workspace/[workspaceId]/files/loading'
import { prefetchFilesBrowser } from '@/app/workspace/[workspaceId]/files/prefetch'
import { Files } from './files'
import FilesLoading from './loading'
export const metadata: Metadata = {
title: 'Files',
@@ -1,6 +1,7 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { Document } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document'
import DocumentLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading'
interface DocumentPageProps {
params: Promise<{
@@ -24,7 +25,7 @@ export default async function DocumentChunksPage({ params, searchParams }: Docum
const [{ id, documentId }, { kbName, docName }] = await Promise.all([params, searchParams])
return (
<Suspense fallback={null}>
<Suspense fallback={<DocumentLoading />}>
<Document
knowledgeBaseId={id}
documentId={documentId}
@@ -1,6 +1,7 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/loading'
interface PageProps {
params: Promise<{
@@ -20,7 +21,7 @@ export default async function KnowledgeBasePage({ params, searchParams }: PagePr
const [{ id }, { kbName }] = await Promise.all([params, searchParams])
return (
<Suspense fallback={null}>
<Suspense fallback={<KnowledgeBaseLoading />}>
<KnowledgeBase id={id} knowledgeBaseName={kbName || 'Knowledge Base'} />
</Suspense>
)
@@ -2,9 +2,9 @@ import { Suspense } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { Knowledge } from '@/app/workspace/[workspaceId]/knowledge/knowledge'
import KnowledgeLoading from '@/app/workspace/[workspaceId]/knowledge/loading'
import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch'
import { Knowledge } from './knowledge'
export const metadata: Metadata = {
title: 'Knowledge Base',
@@ -1,6 +1,5 @@
import { useCallback, useRef, useState } from 'react'
import type { ParsedFilter } from '@/lib/logs/query-parser'
import { parseQuery } from '@/lib/logs/query-parser'
import type {
Suggestion,
SuggestionGroup,
@@ -11,21 +10,16 @@ interface UseSearchStateOptions {
onFiltersChange: (filters: ParsedFilter[], textSearch: string) => void
getSuggestions: (input: string) => SuggestionGroup | null
debounceMs?: number
initialQuery?: string
}
export function useSearchState({
onFiltersChange,
getSuggestions,
debounceMs = 100,
initialQuery,
}: UseSearchStateOptions) {
const [initialParsed] = useState(() =>
initialQuery ? parseQuery(initialQuery) : { filters: [] as ParsedFilter[], textSearch: '' }
)
const [appliedFilters, setAppliedFilters] = useState<ParsedFilter[]>(initialParsed.filters)
const [appliedFilters, setAppliedFilters] = useState<ParsedFilter[]>([])
const [currentInput, setCurrentInput] = useState('')
const [textSearch, setTextSearch] = useState<string>(initialParsed.textSearch)
const [textSearch, setTextSearch] = useState('')
const [isOpen, setIsOpen] = useState(false)
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
@@ -1,4 +1,4 @@
export type { SortConfig, SortDirection, SortField, TerminalFilters } from '../types'
export type { SortDirection, TerminalFilters } from '../types'
export { useOutputPanelResize } from './use-output-panel-resize'
export { useTerminalFilters } from './use-terminal-filters'
export { useTerminalResize } from './use-terminal-resize'
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useState } from 'react'
import type {
SortConfig,
SortDirection,
TerminalFilters,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
import type { ConsoleEntry } from '@/stores/terminal'
@@ -17,14 +17,8 @@ export function useTerminalFilters() {
statuses: new Set(),
})
const [sortConfig, setSortConfig] = useState<SortConfig>({
field: 'timestamp',
direction: 'desc',
})
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
/**
* Toggles a block filter by block ID
*/
const toggleBlock = useCallback((blockId: string) => {
setFilters((prev) => {
const newBlockIds = new Set(prev.blockIds)
@@ -37,9 +31,6 @@ export function useTerminalFilters() {
})
}, [])
/**
* Toggles a status filter
*/
const toggleStatus = useCallback((status: 'error' | 'info') => {
setFilters((prev) => {
const newStatuses = new Set(prev.statuses)
@@ -52,19 +43,10 @@ export function useTerminalFilters() {
})
}, [])
/**
* Toggles sort direction between ascending and descending
*/
const toggleSort = useCallback(() => {
setSortConfig((prev) => ({
field: prev.field,
direction: prev.direction === 'desc' ? 'asc' : 'desc',
}))
setSortDirection((prev) => (prev === 'desc' ? 'asc' : 'desc'))
}, [])
/**
* Clears all filters
*/
const clearFilters = useCallback(() => {
setFilters({
blockIds: new Set(),
@@ -72,12 +54,7 @@ export function useTerminalFilters() {
})
}, [])
/**
* Checks if any filters are active
*/
const hasActiveFilters = useMemo(() => {
return filters.blockIds.size > 0 || filters.statuses.size > 0
}, [filters])
const hasActiveFilters = filters.blockIds.size > 0 || filters.statuses.size > 0
/**
* Filters and sorts console entries based on current filter and sort state
@@ -108,17 +85,17 @@ export function useTerminalFilters() {
// Sort by executionOrder (monotonically increasing integer from server)
result = [...result].sort((a, b) => {
const comparison = a.executionOrder - b.executionOrder
return sortConfig.direction === 'asc' ? comparison : -comparison
return sortDirection === 'asc' ? comparison : -comparison
})
return result
},
[filters, hasActiveFilters, sortConfig]
[filters, hasActiveFilters, sortDirection]
)
return {
filters,
sortConfig,
sortDirection,
toggleBlock,
toggleStatus,
toggleSort,
@@ -715,7 +715,7 @@ export const Terminal = memo(function Terminal() {
const {
filters,
sortConfig,
sortDirection,
toggleBlock,
toggleStatus,
toggleSort,
@@ -1328,10 +1328,10 @@ export const Terminal = memo(function Terminal() {
aria-label='Sort by timestamp'
className='!p-1.5 -m-1.5'
>
{sortConfig.direction === 'desc' ? (
<ArrowDown className='h-3.5 w-3.5' />
{sortDirection === 'desc' ? (
<ArrowDown className='size-3.5' />
) : (
<ArrowUp className='h-3.5 w-3.5' />
<ArrowUp className='size-3.5' />
)}
</Button>
</Tooltip.Trigger>
@@ -14,24 +14,11 @@ export interface ContextMenuPosition {
y: number
}
/**
* Sort field options for terminal entries
*/
export type SortField = 'timestamp'
/**
* Sort direction options
*/
export type SortDirection = 'asc' | 'desc'
/**
* Sort configuration for terminal entries
*/
export interface SortConfig {
field: SortField
direction: SortDirection
}
/**
* Status type for console entries
*/