From 13666b162dbfcb9e89341afc9b6f4b3ccf6b9b65 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 9 May 2026 16:07:12 -0700 Subject: [PATCH 1/3] fix(zustand): v5 selector stability issues (#4539) * fix(zustand): v5 selector stability issues * address comments --- .github/workflows/test-build.yml | 3 + .../components/action-bar/action-bar.tsx | 39 +- .../connection-blocks/connection-blocks.tsx | 4 +- .../credential-selector.tsx | 2 +- .../components/file-upload/file-upload.tsx | 2 +- .../components/tag-dropdown/tag-dropdown.tsx | 17 +- .../workflow-search-replace.tsx | 17 +- .../hooks/use-block-properties.ts | 27 +- .../sidebar/hooks/use-folder-expand.ts | 13 +- .../emcn/components/badge/badge.tsx | 22 +- apps/sim/hooks/use-reactive-conditions.ts | 5 +- .../lib/copilot/generated/tool-schemas-v1.ts | 174 ++++---- apps/sim/stores/workflows/subblock/store.ts | 5 + package.json | 1 + scripts/check-zustand-v5-selectors.ts | 371 ++++++++++++++++++ scripts/format-generated-source.ts | 19 + scripts/sync-mothership-stream-contract.ts | 13 +- scripts/sync-request-trace-contract.ts | 20 +- scripts/sync-tool-catalog.ts | 3 +- .../sync-trace-attribute-values-contract.ts | 24 +- scripts/sync-trace-attributes-contract.ts | 32 +- scripts/sync-trace-events-contract.ts | 32 +- scripts/sync-trace-spans-contract.ts | 32 +- 23 files changed, 641 insertions(+), 236 deletions(-) create mode 100644 scripts/check-zustand-v5-selectors.ts create mode 100644 scripts/format-generated-source.ts diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 68c5a9901de..e59102ebd58 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -109,6 +109,9 @@ jobs: - name: API contract boundary audit run: bun run check:api-validation:strict + - name: Zustand v5 selector audit + run: bun run check:zustand-v5 + - name: Verify realtime prune graph run: bun run check:realtime-prune diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx index 19bc1606022..f667857479c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx @@ -1,5 +1,6 @@ import { memo, useCallback } from 'react' import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Lock, LogOut, Unlock } from 'lucide-react' +import { useShallow } from 'zustand/react/shallow' import { Button, Copy, PlayOutline, Tooltip, Trash2 } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' @@ -51,7 +52,7 @@ export const ActionBar = memo( collaborativeBatchToggleBlockHandles, collaborativeBatchToggleLocked, } = useCollaborativeWorkflow() - const { setPendingSelection } = useWorkflowRegistry() + const setPendingSelection = useWorkflowRegistry((state) => state.setPendingSelection) const { handleRunFromBlock } = useWorkflowExecution() const addNotification = useNotificationStore((s) => s.addNotification) @@ -94,26 +95,28 @@ export const ActionBar = memo( isParentLocked, isParentDisabled, } = useWorkflowStore( - useCallback( - (state) => { - const block = state.blocks[blockId] - const parentId = block?.data?.parentId - const parentBlock = parentId ? state.blocks[parentId] : undefined - return { - isEnabled: block?.enabled ?? true, - horizontalHandles: block?.horizontalHandles ?? false, - parentId, - parentType: parentBlock?.type, - isLocked: block?.locked ?? false, - isParentLocked: parentBlock?.locked ?? false, - isParentDisabled: parentBlock ? !parentBlock.enabled : false, - } - }, - [blockId] + useShallow( + useCallback( + (state) => { + const block = state.blocks[blockId] + const parentId = block?.data?.parentId + const parentBlock = parentId ? state.blocks[parentId] : undefined + return { + isEnabled: block?.enabled ?? true, + horizontalHandles: block?.horizontalHandles ?? false, + parentId, + parentType: parentBlock?.type, + isLocked: block?.locked ?? false, + isParentLocked: parentBlock?.locked ?? false, + isParentDisabled: parentBlock ? !parentBlock.enabled : false, + } + }, + [blockId] + ) ) ) - const { activeWorkflowId } = useWorkflowRegistry() + const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) const isExecuting = useIsCurrentWorkflowExecuting() const getLastExecutionSnapshot = useExecutionStore((s) => s.getLastExecutionSnapshot) const userPermissions = useUserPermissionsContext() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx index e2e5f0e8cb9..9115cd280fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx @@ -14,7 +14,7 @@ import type { ConnectedBlock } from '@/app/workspace/[workspaceId]/w/[workflowId import { useBlockOutputFields } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields' import { getBlock } from '@/blocks/registry' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { EMPTY_SUBBLOCK_VALUES, useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('ConnectionBlocks') @@ -148,7 +148,7 @@ export function ConnectionBlocks({ connections, currentBlockId }: ConnectionBloc const workflowId = useWorkflowRegistry((state) => state.activeWorkflowId) const workflowSubBlockValues = useSubBlockStore((state) => - workflowId ? (state.workflowValues[workflowId] ?? {}) : {} + workflowId ? (state.workflowValues[workflowId] ?? EMPTY_SUBBLOCK_VALUES) : EMPTY_SUBBLOCK_VALUES ) const getMergedSubBlocks = useCallback( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index 3eeb5173545..907bb34808e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -54,7 +54,7 @@ export function CredentialSelector({ const [showOAuthModal, setShowOAuthModal] = useState(false) const [editingValue, setEditingValue] = useState('') const [isEditing, setIsEditing] = useState(false) - const { activeWorkflowId } = useWorkflowRegistry() + const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) const requiredScopes = subBlock.requiredScopes || [] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index e8dc74c8b19..b41fe5fc2f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -160,7 +160,7 @@ export function FileUpload({ const fileInputRef = useRef(null) - const { activeWorkflowId } = useWorkflowRegistry() + const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) const params = useParams() const workspaceId = params?.workspaceId as string diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx index 71b64970ff8..a6db55d099b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx @@ -1,6 +1,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { isEqual } from 'es-toolkit' import { RepeatIcon, SplitIcon } from 'lucide-react' import { useShallow } from 'zustand/react/shallow' +import { useStoreWithEqualityFn } from 'zustand/traditional' import { Popover, PopoverAnchor, @@ -37,6 +39,8 @@ import { EMPTY_SUBBLOCK_VALUES, useSubBlockStore } from '@/stores/workflows/subb import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { BlockState } from '@/stores/workflows/workflow/types' +const EMPTY_VARIABLES: Variable[] = [] + /** * Context for sharing nested navigation state between components. * This enables unlimited nesting depth with a single back button. @@ -997,8 +1001,17 @@ export const TagDropdown: React.FC = ({ [blocks, workflowSubBlockValues] ) - const getVariablesByWorkflowId = useVariablesStore((state) => state.getVariablesByWorkflowId) - const workflowVariables = workflowId ? getVariablesByWorkflowId(workflowId) : [] + const workflowVariables = useStoreWithEqualityFn( + useVariablesStore, + useCallback( + (state) => + workflowId + ? Object.values(state.variables).filter((variable) => variable.workflowId === workflowId) + : EMPTY_VARIABLES, + [workflowId] + ), + isEqual + ) const searchTerm = useMemo( () => getTagSearchTerm(inputValue, cursorPosition), diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx index ac71ed7921b..18c70958f61 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronDown, ChevronRight, ChevronUp, X } from 'lucide-react' import { useParams } from 'next/navigation' +import { useShallow } from 'zustand/react/shallow' import { Button, Input } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies' @@ -126,7 +127,21 @@ export function WorkflowSearchReplace() { setQuery, setReplacement, setActiveMatchId, - } = useWorkflowSearchReplaceStore() + } = useWorkflowSearchReplaceStore( + useShallow((state) => ({ + isOpen: state.isOpen, + query: state.query, + replacement: state.replacement, + activeMatchId: state.activeMatchId, + position: state.position, + close: state.close, + open: state.open, + setPosition: state.setPosition, + setQuery: state.setQuery, + setReplacement: state.setReplacement, + setActiveMatchId: state.setActiveMatchId, + })) + ) const { data: workspaceCredentials } = useWorkspaceCredentials({ workspaceId, enabled: isOpen }) useRegisterGlobalCommands([ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-block-properties.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-block-properties.ts index aa9cd142dd6..1b3bf3efe0c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-block-properties.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-block-properties.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react' +import { useShallow } from 'zustand/react/shallow' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { WorkflowBlockProps } from '../types' @@ -43,18 +44,20 @@ export function useBlockProperties( storeBlockAdvancedMode, storeBlockTriggerMode, } = useWorkflowStore( - useCallback( - (state) => { - const block = state.blocks[blockId] - return { - storeHorizontalHandles: block?.horizontalHandles ?? true, - storeBlockHeight: block?.height ?? 0, - storeBlockLayout: block?.layout, - storeBlockAdvancedMode: block?.advancedMode ?? false, - storeBlockTriggerMode: block?.triggerMode ?? false, - } - }, - [blockId] + useShallow( + useCallback( + (state) => { + const block = state.blocks[blockId] + return { + storeHorizontalHandles: block?.horizontalHandles ?? true, + storeBlockHeight: block?.height ?? 0, + storeBlockLayout: block?.layout, + storeBlockAdvancedMode: block?.advancedMode ?? false, + storeBlockTriggerMode: block?.triggerMode ?? false, + } + }, + [blockId] + ) ) ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-expand.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-expand.ts index 5d2624fdf44..60e4a21d2af 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-expand.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-expand.ts @@ -1,6 +1,9 @@ import { useCallback } from 'react' import { useFolderStore } from '@/stores/folders/store' +const toggleFolderExpanded = useFolderStore.getState().toggleExpanded +const setFolderExpanded = useFolderStore.getState().setExpanded + interface UseFolderExpandProps { folderId: string } @@ -13,22 +16,22 @@ interface UseFolderExpandProps { * @returns Expansion state and event handlers */ export function useFolderExpand({ folderId }: UseFolderExpandProps) { - const { expandedFolders, toggleExpanded, setExpanded } = useFolderStore() + const expandedFolders = useFolderStore((state) => state.expandedFolders) const isExpanded = expandedFolders.has(folderId) /** * Toggle folder expansion state */ const handleToggleExpanded = useCallback(() => { - toggleExpanded(folderId) - }, [folderId, toggleExpanded]) + toggleFolderExpanded(folderId) + }, [folderId]) /** * Expand the folder (useful when creating items inside) */ const expandFolder = useCallback(() => { - setExpanded(folderId, true) - }, [folderId, setExpanded]) + setFolderExpanded(folderId, true) + }, [folderId]) /** * Handle keyboard navigation (Enter/Space) diff --git a/apps/sim/components/emcn/components/badge/badge.tsx b/apps/sim/components/emcn/components/badge/badge.tsx index 91d7afa0693..48a38a9f98d 100644 --- a/apps/sim/components/emcn/components/badge/badge.tsx +++ b/apps/sim/components/emcn/components/badge/badge.tsx @@ -1,4 +1,4 @@ -import type * as React from 'react' +import { forwardRef, type HTMLAttributes } from 'react' import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/core/utils/cn' @@ -72,7 +72,7 @@ const ICON_SIZES: Record = { } export interface BadgeProps - extends React.HTMLAttributes, + extends HTMLAttributes, VariantProps { /** Displays a dot indicator before content (only for color variants) */ dot?: boolean @@ -92,20 +92,15 @@ export interface BadgeProps * Status color variants can display a dot indicator via the `dot` prop. * All variants support an optional `icon` prop for leading icons. */ -function Badge({ - className, - variant, - size, - dot = false, - icon: Icon, - children, - ...props -}: BadgeProps) { +const Badge = forwardRef(function Badge( + { className, variant, size, dot = false, icon: Icon, children, ...props }, + ref +) { const isStatusVariant = STATUS_VARIANTS.includes(variant as (typeof STATUS_VARIANTS)[number]) const effectiveSize = size ?? 'md' return ( -
+
{isStatusVariant && dot && (
)} @@ -113,6 +108,7 @@ function Badge({ {children}
) -} +}) +Badge.displayName = 'Badge' export { Badge, badgeVariants } diff --git a/apps/sim/hooks/use-reactive-conditions.ts b/apps/sim/hooks/use-reactive-conditions.ts index 0971eee2cd3..d5323a8b85f 100644 --- a/apps/sim/hooks/use-reactive-conditions.ts +++ b/apps/sim/hooks/use-reactive-conditions.ts @@ -3,7 +3,7 @@ import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibilit import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import type { SubBlockConfig } from '@/blocks/types' import { useWorkspaceCredential } from '@/hooks/queries/credentials' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { EMPTY_BLOCK_SUBBLOCK_VALUES, useSubBlockStore } from '@/stores/workflows/subblock/store' /** * Evaluates reactive conditions for subblocks. Always calls the same hooks @@ -27,7 +27,8 @@ export function useReactiveConditions( useCallback( (state) => { if (!reactiveCond || !activeWorkflowId) return '' - const blockValues = state.workflowValues[activeWorkflowId]?.[blockId] ?? {} + const blockValues = + state.workflowValues[activeWorkflowId]?.[blockId] ?? EMPTY_BLOCK_SUBBLOCK_VALUES for (const field of reactiveCond.watchFields) { const val = resolveDependencyValue( field, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 8a7aebc3e7a..f1eeb350773 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -10,7 +10,7 @@ export interface ToolRuntimeSchemaEntry { } export const TOOL_RUNTIME_SCHEMAS: Record = { - agent: { + ['agent']: { parameters: { properties: { request: { @@ -23,7 +23,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - auth: { + ['auth']: { parameters: { properties: { request: { @@ -36,7 +36,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - check_deployment_status: { + ['check_deployment_status']: { parameters: { type: 'object', properties: { @@ -48,7 +48,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - complete_job: { + ['complete_job']: { parameters: { type: 'object', properties: { @@ -61,7 +61,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - context_write: { + ['context_write']: { parameters: { type: 'object', properties: { @@ -78,7 +78,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - crawl_website: { + ['crawl_website']: { parameters: { type: 'object', properties: { @@ -113,7 +113,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_file: { + ['create_file']: { parameters: { type: 'object', properties: { @@ -149,7 +149,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - create_folder: { + ['create_folder']: { parameters: { type: 'object', properties: { @@ -170,7 +170,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_job: { + ['create_job']: { parameters: { type: 'object', properties: { @@ -220,7 +220,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_workflow: { + ['create_workflow']: { parameters: { type: 'object', properties: { @@ -245,7 +245,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_workspace_mcp_server: { + ['create_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -266,7 +266,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - debug: { + ['debug']: { parameters: { properties: { context: { @@ -285,7 +285,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_file: { + ['delete_file']: { parameters: { type: 'object', properties: { @@ -314,7 +314,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - delete_folder: { + ['delete_folder']: { parameters: { type: 'object', properties: { @@ -330,7 +330,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_workflow: { + ['delete_workflow']: { parameters: { type: 'object', properties: { @@ -346,7 +346,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_workspace_mcp_server: { + ['delete_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -359,7 +359,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - deploy: { + ['deploy']: { parameters: { properties: { request: { @@ -373,7 +373,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - deploy_api: { + ['deploy_api']: { parameters: { type: 'object', properties: { @@ -447,7 +447,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_chat: { + ['deploy_chat']: { parameters: { type: 'object', properties: { @@ -595,7 +595,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_mcp: { + ['deploy_mcp']: { parameters: { type: 'object', properties: { @@ -711,7 +711,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['deploymentType', 'deploymentStatus'], }, }, - download_to_workspace_file: { + ['download_to_workspace_file']: { parameters: { type: 'object', properties: { @@ -730,7 +730,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - edit_content: { + ['edit_content']: { parameters: { type: 'object', properties: { @@ -762,7 +762,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - edit_workflow: { + ['edit_workflow']: { parameters: { type: 'object', properties: { @@ -801,13 +801,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - file: { + ['file']: { parameters: { type: 'object', }, resultSchema: undefined, }, - function_execute: { + ['function_execute']: { parameters: { type: 'object', properties: { @@ -868,7 +868,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_api_key: { + ['generate_api_key']: { parameters: { type: 'object', properties: { @@ -886,7 +886,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_image: { + ['generate_image']: { parameters: { type: 'object', properties: { @@ -923,7 +923,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_visualization: { + ['generate_visualization']: { parameters: { type: 'object', properties: { @@ -963,7 +963,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_block_outputs: { + ['get_block_outputs']: { parameters: { type: 'object', properties: { @@ -984,7 +984,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_block_upstream_references: { + ['get_block_upstream_references']: { parameters: { type: 'object', properties: { @@ -1006,7 +1006,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_deployed_workflow_state: { + ['get_deployed_workflow_state']: { parameters: { type: 'object', properties: { @@ -1019,7 +1019,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_deployment_version: { + ['get_deployment_version']: { parameters: { type: 'object', properties: { @@ -1036,7 +1036,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_execution_summary: { + ['get_execution_summary']: { parameters: { type: 'object', properties: { @@ -1063,7 +1063,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_job_logs: { + ['get_job_logs']: { parameters: { type: 'object', properties: { @@ -1088,7 +1088,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_page_contents: { + ['get_page_contents']: { parameters: { type: 'object', properties: { @@ -1116,14 +1116,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_platform_actions: { + ['get_platform_actions']: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - get_workflow_data: { + ['get_workflow_data']: { parameters: { type: 'object', properties: { @@ -1142,7 +1142,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_workflow_logs: { + ['get_workflow_logs']: { parameters: { type: 'object', properties: { @@ -1168,7 +1168,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - glob: { + ['glob']: { parameters: { type: 'object', properties: { @@ -1187,7 +1187,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - grep: { + ['grep']: { parameters: { type: 'object', properties: { @@ -1234,7 +1234,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - job: { + ['job']: { parameters: { properties: { request: { @@ -1247,7 +1247,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - knowledge: { + ['knowledge']: { parameters: { properties: { request: { @@ -1260,7 +1260,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - knowledge_base: { + ['knowledge_base']: { parameters: { type: 'object', properties: { @@ -1452,7 +1452,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - list_folders: { + ['list_folders']: { parameters: { type: 'object', properties: { @@ -1464,14 +1464,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_user_workspaces: { + ['list_user_workspaces']: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - list_workspace_mcp_servers: { + ['list_workspace_mcp_servers']: { parameters: { type: 'object', properties: { @@ -1483,7 +1483,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_credential: { + ['manage_credential']: { parameters: { type: 'object', properties: { @@ -1512,7 +1512,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_custom_tool: { + ['manage_custom_tool']: { parameters: { type: 'object', properties: { @@ -1591,7 +1591,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_job: { + ['manage_job']: { parameters: { type: 'object', properties: { @@ -1661,7 +1661,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_mcp_tool: { + ['manage_mcp_tool']: { parameters: { type: 'object', properties: { @@ -1712,7 +1712,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_skill: { + ['manage_skill']: { parameters: { type: 'object', properties: { @@ -1744,7 +1744,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - materialize_file: { + ['materialize_file']: { parameters: { type: 'object', properties: { @@ -1778,7 +1778,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_folder: { + ['move_folder']: { parameters: { type: 'object', properties: { @@ -1796,7 +1796,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_workflow: { + ['move_workflow']: { parameters: { type: 'object', properties: { @@ -1816,7 +1816,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - oauth_get_auth_link: { + ['oauth_get_auth_link']: { parameters: { type: 'object', properties: { @@ -1830,7 +1830,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - oauth_request_access: { + ['oauth_request_access']: { parameters: { type: 'object', properties: { @@ -1844,7 +1844,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - open_resource: { + ['open_resource']: { parameters: { type: 'object', properties: { @@ -1872,7 +1872,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - read: { + ['read']: { parameters: { type: 'object', properties: { @@ -1899,7 +1899,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - redeploy: { + ['redeploy']: { parameters: { type: 'object', properties: { @@ -1967,7 +1967,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - rename_file: { + ['rename_file']: { parameters: { type: 'object', properties: { @@ -2002,7 +2002,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - rename_workflow: { + ['rename_workflow']: { parameters: { type: 'object', properties: { @@ -2019,7 +2019,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - research: { + ['research']: { parameters: { properties: { topic: { @@ -2032,7 +2032,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - respond: { + ['respond']: { parameters: { additionalProperties: true, properties: { @@ -2055,7 +2055,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - restore_resource: { + ['restore_resource']: { parameters: { type: 'object', properties: { @@ -2073,7 +2073,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - revert_to_version: { + ['revert_to_version']: { parameters: { type: 'object', properties: { @@ -2090,7 +2090,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run: { + ['run']: { parameters: { properties: { context: { @@ -2107,7 +2107,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_block: { + ['run_block']: { parameters: { type: 'object', properties: { @@ -2139,7 +2139,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_from_block: { + ['run_from_block']: { parameters: { type: 'object', properties: { @@ -2171,7 +2171,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_workflow: { + ['run_workflow']: { parameters: { type: 'object', properties: { @@ -2199,7 +2199,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_workflow_until_block: { + ['run_workflow_until_block']: { parameters: { type: 'object', properties: { @@ -2231,7 +2231,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - scrape_page: { + ['scrape_page']: { parameters: { type: 'object', properties: { @@ -2252,7 +2252,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_documentation: { + ['search_documentation']: { parameters: { type: 'object', properties: { @@ -2269,7 +2269,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_library_docs: { + ['search_library_docs']: { parameters: { type: 'object', properties: { @@ -2290,7 +2290,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_online: { + ['search_online']: { parameters: { type: 'object', properties: { @@ -2331,7 +2331,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_patterns: { + ['search_patterns']: { parameters: { type: 'object', properties: { @@ -2353,7 +2353,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_block_enabled: { + ['set_block_enabled']: { parameters: { type: 'object', properties: { @@ -2375,7 +2375,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_environment_variables: { + ['set_environment_variables']: { parameters: { type: 'object', properties: { @@ -2409,7 +2409,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_global_workflow_variables: { + ['set_global_workflow_variables']: { parameters: { type: 'object', properties: { @@ -2447,7 +2447,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - superagent: { + ['superagent']: { parameters: { properties: { task: { @@ -2461,7 +2461,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - table: { + ['table']: { parameters: { properties: { request: { @@ -2474,7 +2474,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - tool_search_tool_regex: { + ['tool_search_tool_regex']: { parameters: { properties: { case_insensitive: { @@ -2495,7 +2495,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_job_history: { + ['update_job_history']: { parameters: { type: 'object', properties: { @@ -2513,7 +2513,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_workspace_mcp_server: { + ['update_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -2538,7 +2538,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - user_memory: { + ['user_memory']: { parameters: { type: 'object', properties: { @@ -2586,7 +2586,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - user_table: { + ['user_table']: { parameters: { type: 'object', properties: { @@ -2915,13 +2915,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - workflow: { + ['workflow']: { parameters: { type: 'object', }, resultSchema: undefined, }, - workspace_file: { + ['workspace_file']: { parameters: { type: 'object', properties: { diff --git a/apps/sim/stores/workflows/subblock/store.ts b/apps/sim/stores/workflows/subblock/store.ts index 8faea191997..7cb745b9fdd 100644 --- a/apps/sim/stores/workflows/subblock/store.ts +++ b/apps/sim/stores/workflows/subblock/store.ts @@ -18,6 +18,11 @@ const logger = createLogger('SubBlockStore') */ export const EMPTY_SUBBLOCK_VALUES: Record> = {} +/** + * Stable empty fallback for a single block's sub-block values. + */ +export const EMPTY_BLOCK_SUBBLOCK_VALUES: Record = {} + /** * SubBlockState stores values for all subblocks in workflows * diff --git a/package.json b/package.json index 12061f7cdc3..d1903ef8564 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "check:api-validation": "bun run scripts/check-api-validation-contracts.ts --check", "check:api-validation:strict": "bun run scripts/check-api-validation-contracts.ts --check --enforce-boundary-baseline", "check:realtime-prune": "bun run scripts/check-realtime-prune-graph.ts", + "check:zustand-v5": "bun run scripts/check-zustand-v5-selectors.ts", "mship-contracts:generate": "bun run scripts/sync-mothership-stream-contract.ts", "mship-contracts:check": "bun run scripts/sync-mothership-stream-contract.ts --check", "mship-tools:generate": "bun run scripts/sync-tool-catalog.ts", diff --git a/scripts/check-zustand-v5-selectors.ts b/scripts/check-zustand-v5-selectors.ts new file mode 100644 index 00000000000..21cc3b86fae --- /dev/null +++ b/scripts/check-zustand-v5-selectors.ts @@ -0,0 +1,371 @@ +#!/usr/bin/env bun +import { readdir, readFile } from 'node:fs/promises' +import path from 'node:path' + +const ROOT = path.resolve(import.meta.dir, '..') +const APP_DIR = path.join(ROOT, 'apps/sim') + +const SKIP_DIRS = new Set(['node_modules', '.next', '.turbo', 'coverage', 'dist', 'build']) + +const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']) +const STORE_HOOK_CALL_PATTERN = /\buse[A-Z][A-Za-z0-9_]*Store\s*\(/g +const SAFE_ANNOTATION = 'zustand-v5-safe:' +const UNSAFE_SELECTOR_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [ + { + pattern: /=>\s*\(\s*\{/, + reason: 'selector returns a fresh object literal; wrap it in useShallow', + }, + { + pattern: /\breturn\s+\{/, + reason: 'selector returns a fresh object literal; wrap it in useShallow', + }, + { + pattern: /=>\s*\[/, + reason: 'selector returns a fresh array literal; wrap it in useShallow', + }, + { + pattern: /\breturn\s+\[/, + reason: 'selector returns a fresh array literal; wrap it in useShallow', + }, + { + pattern: + /(?:=>|return)\s+Object\.(?:values|entries)\s*\([^)]*\)(?!\s*\.\s*(?:length|some|every)\b)/, + reason: + 'selector allocates a derived collection; use useStoreWithEqualityFn or memoize outside', + }, + { + pattern: /\bObject\.fromEntries\s*\(/, + reason: 'selector allocates a derived object; use useStoreWithEqualityFn or memoize outside', + }, + { + pattern: /\bObject\.keys\s*\([^)]*\)(?!\s*\.length\b)/, + reason: 'selector allocates Object.keys; return a primitive or use useShallow', + }, + { + pattern: /(?:=>|return)\s+[^;{}]*\.(?:map|filter|reduce)\s*\(/, + reason: 'selector allocates a derived value; use useStoreWithEqualityFn or memoize outside', + }, + { + pattern: /\bnew\s+(?:Set|Map)\s*\(/, + reason: + 'selector returns a fresh collection; use useStoreWithEqualityFn or a stable store reference', + }, + { + pattern: /\?\?\s*(?:\(\s*\)\s*=>|\{\s*\}|\[\s*\])/, + reason: 'selector uses an unstable fallback reference; move the fallback to module scope', + }, + { + pattern: /\|\|\s*(?:\(\s*\)\s*=>|\{\s*\}|\[\s*\])/, + reason: 'selector uses an unstable fallback reference; move the fallback to module scope', + }, +] + +interface Violation { + file: string + line: number + description: string + snippet: string +} + +async function walk(dir: string, results: string[] = []): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) continue + + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + await walk(full, results) + continue + } + + if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) { + results.push(full) + } + } + + return results +} + +function findMatchingParen(source: string, openIndex: number): number { + let depth = 0 + let quote: '"' | "'" | '`' | null = null + let escaped = false + let lineComment = false + let blockComment = false + + for (let index = openIndex; index < source.length; index++) { + const char = source[index] + const next = source[index + 1] + + if (lineComment) { + if (char === '\n') lineComment = false + continue + } + + if (blockComment) { + if (char === '*' && next === '/') { + blockComment = false + index++ + } + continue + } + + if (quote) { + if (escaped) { + escaped = false + continue + } + if (char === '\\') { + escaped = true + continue + } + if (char === quote) { + quote = null + } + continue + } + + if (char === '/' && next === '/') { + lineComment = true + index++ + continue + } + + if (char === '/' && next === '*') { + blockComment = true + index++ + continue + } + + if (char === '"' || char === "'" || char === '`') { + quote = char + continue + } + + if (char === '(') depth++ + if (char === ')') { + depth-- + if (depth === 0) return index + } + } + + return -1 +} + +function splitTopLevelArguments(args: string): string[] { + const result: string[] = [] + let start = 0 + let depth = 0 + let quote: '"' | "'" | '`' | null = null + let escaped = false + + for (let index = 0; index < args.length; index++) { + const char = args[index] + + if (quote) { + if (escaped) { + escaped = false + continue + } + if (char === '\\') { + escaped = true + continue + } + if (char === quote) quote = null + continue + } + + if (char === '"' || char === "'" || char === '`') { + quote = char + continue + } + + if (char === '(' || char === '[' || char === '{') depth++ + if (char === ')' || char === ']' || char === '}') depth-- + + if (char === ',' && depth === 0) { + result.push(args.slice(start, index).trim()) + start = index + 1 + } + } + + const finalArg = args.slice(start).trim() + if (finalArg) result.push(finalArg) + + return result +} + +function lineNumberAt(source: string, index: number): number { + let line = 1 + for (let i = 0; i < index; i++) { + if (source[i] === '\n') line++ + } + return line +} + +function hasSafeAnnotation(source: string, callStart: number): boolean { + const before = source.slice(0, callStart) + const lines = before.split('\n') + for (let i = lines.length - 1; i >= Math.max(0, lines.length - 4); i--) { + const trimmed = lines[i]?.trim() + if (!trimmed) continue + if (trimmed.includes(SAFE_ANNOTATION) && trimmed.split(SAFE_ANNOTATION)[1]?.trim()) { + return true + } + if (!trimmed.startsWith('//') && !trimmed.startsWith('*') && !trimmed.startsWith('/*')) { + return false + } + } + return false +} + +function oneLineSnippet(source: string, start: number, end: number): string { + return source.slice(start, end).replace(/\s+/g, ' ').trim().slice(0, 180) +} + +function auditFile(file: string, source: string): Violation[] { + const violations: Violation[] = [] + STORE_HOOK_CALL_PATTERN.lastIndex = 0 + + for ( + let match = STORE_HOOK_CALL_PATTERN.exec(source); + match; + match = STORE_HOOK_CALL_PATTERN.exec(source) + ) { + const callStart = match.index + const callee = match[0].replace(/\s*\($/, '') + if (callee === 'useSyncExternalStore') continue + + if (hasSafeAnnotation(source, callStart)) continue + + const openParenIndex = source.indexOf('(', callStart) + const closeParenIndex = findMatchingParen(source, openParenIndex) + if (closeParenIndex === -1) continue + + const args = splitTopLevelArguments(source.slice(openParenIndex + 1, closeParenIndex)) + const line = lineNumberAt(source, callStart) + const snippet = oneLineSnippet(source, callStart, closeParenIndex + 1) + + if (args.length === 0) { + violations.push({ + file, + line, + description: `${callee} subscribes to the entire store; select only the fields needed`, + snippet, + }) + continue + } + + if (args.length > 1) { + violations.push({ + file, + line, + description: `${callee} passes a second equality argument; Zustand v5 create() hooks ignore the v4 pattern. Use useShallow or useStoreWithEqualityFn.`, + snippet, + }) + continue + } + + const selector = args[0] + if (!selector || selector.startsWith('useShallow(')) continue + + for (const { pattern, reason } of UNSAFE_SELECTOR_PATTERNS) { + pattern.lastIndex = 0 + if (pattern.test(selector)) { + if (returnsPrimitiveDerivedValue(selector)) continue + if (usesReferenceFallbackOnlyInsideBlockBody(selector)) continue + violations.push({ + file, + line, + description: `${callee} ${reason}`, + snippet, + }) + break + } + } + } + + return violations +} + +function returnsPrimitiveDerivedValue(selector: string): boolean { + return ( + /\bObject\.(?:keys|values|entries)\s*\([^)]*\)\s*\.\s*(?:length|some|every)\b/.test(selector) || + /\bObject\.keys\s*\([^)]*\)\.length\b/.test(selector) || + /\.(?:map|filter)\s*\([^)]*\)\s*\.\s*(?:length|some|every|join)\b/.test(selector) + ) +} + +function usesReferenceFallbackOnlyInsideBlockBody(selector: string): boolean { + if (!/\)\s*=>\s*\{/.test(selector)) return false + + const returnExpressions = [...selector.matchAll(/\breturn\s+([^;\n}]+)/g)].map((match) => + match[1].trim() + ) + + return ( + returnExpressions.length > 0 && + returnExpressions.every((expression) => isPrimitiveReturnExpression(expression, selector)) + ) +} + +function isPrimitiveReturnExpression(expression: string, selector: string): boolean { + const normalized = expression + .trim() + .replace(/^\((.*)\)$/, '$1') + .trim() + + if (/^(?:true|false|null|undefined)\b/.test(normalized)) return true + if (/^(?:['"`]|\d)/.test(normalized)) return true + if (/^(?:!|typeof\b)/.test(normalized)) return true + if (/^(?:Boolean|Number|String)\s*\(/.test(normalized)) return true + if (/(?:===|!==|==|!=|>=|<=|>|<)/.test(normalized)) return true + if (/\.(?:length|some|every|includes|has)\s*(?:\(|$)/.test(normalized)) return true + + if (/^[A-Za-z_$][\w$]*$/.test(normalized)) { + return isIdentifierAssignedPrimitive(normalized, selector) + } + + return false +} + +function isIdentifierAssignedPrimitive(identifier: string, selector: string): boolean { + const declarationPattern = new RegExp(`\\b(?:const|let)\\s+${identifier}\\s*=\\s*([^;\\n]+)`) + const declaration = selector.match(declarationPattern) + if (!declaration) return false + + return isPrimitiveReturnExpression(declaration[1], selector) +} + +async function main() { + const files = await walk(APP_DIR) + const violations: Violation[] = [] + + for (const file of files) { + const source = await readFile(file, 'utf8') + const relativeFile = path.relative(ROOT, file) + violations.push(...auditFile(relativeFile, source)) + } + + if (violations.length === 0) { + console.log('✅ Zustand v5 selector audit OK') + return + } + + console.error('❌ Zustand v5 selector hazards found:') + console.error( + `Add useShallow/useStoreWithEqualityFn, split into primitive selectors, or document intentional exceptions with // ${SAFE_ANNOTATION} .` + ) + for (const violation of violations) { + console.error( + ` ${violation.file}:${violation.line} — ${violation.description}\n ${violation.snippet}` + ) + } + process.exit(1) +} + +void main().catch((error) => { + console.error('Zustand v5 selector audit failed:', error) + process.exit(1) +}) diff --git a/scripts/format-generated-source.ts b/scripts/format-generated-source.ts new file mode 100644 index 00000000000..538d3643489 --- /dev/null +++ b/scripts/format-generated-source.ts @@ -0,0 +1,19 @@ +import { spawnSync } from 'node:child_process' + +export function formatGeneratedSource(source: string, stdinFilePath: string, cwd: string): string { + const result = spawnSync('bunx', ['biome', 'format', '--stdin-file-path', stdinFilePath], { + cwd, + encoding: 'utf8', + input: source, + }) + + if (result.status !== 0) { + throw new Error( + `Failed to format generated source for ${stdinFilePath}:\n${ + result.stderr || result.stdout || 'unknown error' + }` + ) + } + + return result.stdout +} diff --git a/scripts/sync-mothership-stream-contract.ts b/scripts/sync-mothership-stream-contract.ts index 996e490548e..1e9641b0c83 100644 --- a/scripts/sync-mothership-stream-contract.ts +++ b/scripts/sync-mothership-stream-contract.ts @@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { compile } from 'json-schema-to-typescript' +import { formatGeneratedSource } from './format-generated-source' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(SCRIPT_DIR, '..') @@ -70,8 +71,16 @@ async function main() { }) const constants = generateRuntimeConstants(schema, types) - const rendered = constants ? `${types}\n${constants}\n` : types - const renderedSchemaModule = renderRuntimeSchemaModule(schema) + const rendered = formatGeneratedSource( + constants ? `${types}\n${constants}\n` : types, + OUTPUT_PATH, + ROOT + ) + const renderedSchemaModule = formatGeneratedSource( + renderRuntimeSchemaModule(schema), + RUNTIME_SCHEMA_OUTPUT_PATH, + ROOT + ) if (checkOnly) { const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null) diff --git a/scripts/sync-request-trace-contract.ts b/scripts/sync-request-trace-contract.ts index 2cf7f5c3f05..4813ed41527 100644 --- a/scripts/sync-request-trace-contract.ts +++ b/scripts/sync-request-trace-contract.ts @@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { compile } from 'json-schema-to-typescript' +import { formatGeneratedSource } from './format-generated-source' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(SCRIPT_DIR, '..') @@ -26,9 +27,7 @@ function generateRuntimeConstants(schema: Record): string { .map((v) => ` ${JSON.stringify(v)}: ${JSON.stringify(v)}`) .join(',\n') - lines.push( - `export const ${name} = {\n${entries},\n} as const;\n` - ) + lines.push(`export const ${name} = {\n${entries},\n} as const;\n`) } return lines.join('\n') @@ -37,19 +36,24 @@ function generateRuntimeConstants(schema: Record): string { async function main() { const checkOnly = process.argv.includes('--check') const inputPathArg = process.argv.find((arg) => arg.startsWith('--input=')) - const inputPath = inputPathArg ? resolve(ROOT, inputPathArg.slice('--input='.length)) : DEFAULT_CONTRACT_PATH + const inputPath = inputPathArg + ? resolve(ROOT, inputPathArg.slice('--input='.length)) + : DEFAULT_CONTRACT_PATH const raw = await readFile(inputPath, 'utf8') const schema = JSON.parse(raw) const types = await compile(schema, 'RequestTraceV1SimReport', { - bannerComment: - '// AUTO-GENERATED FILE. DO NOT EDIT.\n//', + bannerComment: '// AUTO-GENERATED FILE. DO NOT EDIT.\n//', unreachableDefinitions: true, - additionalProperties: false + additionalProperties: false, }) const constants = generateRuntimeConstants(schema) - const rendered = constants ? `${types}\n${constants}\n` : types + const rendered = formatGeneratedSource( + constants ? `${types}\n${constants}\n` : types, + OUTPUT_PATH, + ROOT + ) if (checkOnly) { const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null) diff --git a/scripts/sync-tool-catalog.ts b/scripts/sync-tool-catalog.ts index 5edf31d7826..5de6c62603b 100644 --- a/scripts/sync-tool-catalog.ts +++ b/scripts/sync-tool-catalog.ts @@ -1,6 +1,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { formatGeneratedSource } from './format-generated-source' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(SCRIPT_DIR, '..') @@ -208,7 +209,7 @@ async function main() { lines.push('};') lines.push('') - const rendered = lines.join('\n') + const rendered = formatGeneratedSource(lines.join('\n'), OUTPUT_PATH, ROOT) const runtimeSchemaRendered = renderRuntimeSchemaModule(catalog) if (checkOnly) { diff --git a/scripts/sync-trace-attribute-values-contract.ts b/scripts/sync-trace-attribute-values-contract.ts index 917c26c1764..762ba194a74 100644 --- a/scripts/sync-trace-attribute-values-contract.ts +++ b/scripts/sync-trace-attribute-values-contract.ts @@ -1,6 +1,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { formatGeneratedSource } from './format-generated-source' /** * Generate `apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts` @@ -27,12 +28,9 @@ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(SCRIPT_DIR, '..') const DEFAULT_CONTRACT_PATH = resolve( ROOT, - '../copilot/copilot/contracts/trace-attribute-values-v1.schema.json', -) -const OUTPUT_PATH = resolve( - ROOT, - 'apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts', + '../copilot/copilot/contracts/trace-attribute-values-v1.schema.json' ) +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts') interface ExtractedEnum { /** The Go type name — becomes the TS const + type name. */ @@ -66,13 +64,9 @@ function toValueIdent(value: string): string { if (parts.length === 0) { throw new Error(`Cannot derive identifier for enum value: ${value}`) } - const ident = parts - .map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()) - .join('') + const ident = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join('') if (/^[0-9]/.test(ident)) { - throw new Error( - `Derived identifier "${ident}" for value "${value}" starts with a digit`, - ) + throw new Error(`Derived identifier "${ident}" for value "${value}" starts with a digit`) } return ident } @@ -84,7 +78,7 @@ function renderEnum(e: ExtractedEnum): string { const prev = seen.get(ident) if (prev && prev !== v) { throw new Error( - `Enum ${e.name}: identifier collision — "${prev}" and "${v}" both map to "${ident}"`, + `Enum ${e.name}: identifier collision — "${prev}" and "${v}" both map to "${ident}"` ) } seen.set(ident, v) @@ -128,16 +122,16 @@ async function main() { const enums = extractEnums(schema) if (enums.length === 0) { throw new Error( - 'No enum $defs found in trace-attribute-values-v1.schema.json — did you add the Go type to TraceAttributeValuesV1AllDefs?', + 'No enum $defs found in trace-attribute-values-v1.schema.json — did you add the Go type to TraceAttributeValuesV1AllDefs?' ) } - const rendered = render(enums) + const rendered = formatGeneratedSource(render(enums), OUTPUT_PATH, ROOT) if (checkOnly) { const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null) if (existing !== rendered) { throw new Error( - 'Generated trace attribute values contract is stale. Run: bun run trace-attribute-values-contract:generate', + 'Generated trace attribute values contract is stale. Run: bun run trace-attribute-values-contract:generate' ) } console.log('Trace attribute values contract is up to date.') diff --git a/scripts/sync-trace-attributes-contract.ts b/scripts/sync-trace-attributes-contract.ts index 3f693781cd3..96d8f488570 100644 --- a/scripts/sync-trace-attributes-contract.ts +++ b/scripts/sync-trace-attributes-contract.ts @@ -1,6 +1,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { formatGeneratedSource } from './format-generated-source' /** * Generate `apps/sim/lib/copilot/generated/trace-attributes-v1.ts` @@ -32,12 +33,9 @@ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(SCRIPT_DIR, '..') const DEFAULT_CONTRACT_PATH = resolve( ROOT, - '../copilot/copilot/contracts/trace-attributes-v1.schema.json', -) -const OUTPUT_PATH = resolve( - ROOT, - 'apps/sim/lib/copilot/generated/trace-attributes-v1.ts', + '../copilot/copilot/contracts/trace-attributes-v1.schema.json' ) +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/trace-attributes-v1.ts') function extractAttrKeys(schema: Record): string[] { const defs = (schema.$defs ?? {}) as Record @@ -47,9 +45,7 @@ function extractAttrKeys(schema: Record): string[] { typeof nameDef !== 'object' || !Array.isArray((nameDef as Record).enum) ) { - throw new Error( - 'trace-attributes-v1.schema.json is missing $defs.TraceAttributesV1Name.enum', - ) + throw new Error('trace-attributes-v1.schema.json is missing $defs.TraceAttributesV1Name.enum') } const enumValues = (nameDef as Record).enum as unknown[] if (!enumValues.every((v) => typeof v === 'string')) { @@ -71,13 +67,9 @@ function toIdentifier(name: string): string { if (parts.length === 0) { throw new Error(`Cannot derive identifier for attribute key: ${name}`) } - const ident = parts - .map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()) - .join('') + const ident = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join('') if (/^[0-9]/.test(ident)) { - throw new Error( - `Derived identifier "${ident}" for attribute "${name}" starts with a digit`, - ) + throw new Error(`Derived identifier "${ident}" for attribute "${name}" starts with a digit`) } return ident } @@ -91,16 +83,12 @@ function render(attrKeys: string[]): string { for (const p of pairs) { const prev = seen.get(p.ident) if (prev && prev !== p.name) { - throw new Error( - `Identifier collision: "${prev}" and "${p.name}" both map to "${p.ident}"`, - ) + throw new Error(`Identifier collision: "${prev}" and "${p.name}" both map to "${p.ident}"`) } seen.set(p.ident, p.name) } - const constLines = pairs - .map((p) => ` ${p.ident}: ${JSON.stringify(p.name)},`) - .join('\n') + const constLines = pairs.map((p) => ` ${p.ident}: ${JSON.stringify(p.name)},`).join('\n') const arrayEntries = attrKeys.map((n) => ` ${JSON.stringify(n)},`).join('\n') return `// AUTO-GENERATED FILE. DO NOT EDIT. @@ -144,13 +132,13 @@ async function main() { const raw = await readFile(inputPath, 'utf8') const schema = JSON.parse(raw) const attrKeys = extractAttrKeys(schema) - const rendered = render(attrKeys) + const rendered = formatGeneratedSource(render(attrKeys), OUTPUT_PATH, ROOT) if (checkOnly) { const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null) if (existing !== rendered) { throw new Error( - 'Generated trace attributes contract is stale. Run: bun run trace-attributes-contract:generate', + 'Generated trace attributes contract is stale. Run: bun run trace-attributes-contract:generate' ) } console.log('Trace attributes contract is up to date.') diff --git a/scripts/sync-trace-events-contract.ts b/scripts/sync-trace-events-contract.ts index 7e858f4e2a6..8253fb59258 100644 --- a/scripts/sync-trace-events-contract.ts +++ b/scripts/sync-trace-events-contract.ts @@ -1,6 +1,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { formatGeneratedSource } from './format-generated-source' /** * Generate `apps/sim/lib/copilot/generated/trace-events-v1.ts` from @@ -17,12 +18,9 @@ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(SCRIPT_DIR, '..') const DEFAULT_CONTRACT_PATH = resolve( ROOT, - '../copilot/copilot/contracts/trace-events-v1.schema.json', -) -const OUTPUT_PATH = resolve( - ROOT, - 'apps/sim/lib/copilot/generated/trace-events-v1.ts', + '../copilot/copilot/contracts/trace-events-v1.schema.json' ) +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/trace-events-v1.ts') function extractEventNames(schema: Record): string[] { const defs = (schema.$defs ?? {}) as Record @@ -32,9 +30,7 @@ function extractEventNames(schema: Record): string[] { typeof nameDef !== 'object' || !Array.isArray((nameDef as Record).enum) ) { - throw new Error( - 'trace-events-v1.schema.json is missing $defs.TraceEventsV1Name.enum', - ) + throw new Error('trace-events-v1.schema.json is missing $defs.TraceEventsV1Name.enum') } const enumValues = (nameDef as Record).enum as unknown[] if (!enumValues.every((v) => typeof v === 'string')) { @@ -48,13 +44,9 @@ function toIdentifier(name: string): string { if (parts.length === 0) { throw new Error(`Cannot derive identifier for event name: ${name}`) } - const ident = parts - .map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()) - .join('') + const ident = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join('') if (/^[0-9]/.test(ident)) { - throw new Error( - `Derived identifier "${ident}" for event "${name}" starts with a digit`, - ) + throw new Error(`Derived identifier "${ident}" for event "${name}" starts with a digit`) } return ident } @@ -66,16 +58,12 @@ function render(eventNames: string[]): string { for (const p of pairs) { const prev = seen.get(p.ident) if (prev && prev !== p.name) { - throw new Error( - `Identifier collision: "${prev}" and "${p.name}" both map to "${p.ident}"`, - ) + throw new Error(`Identifier collision: "${prev}" and "${p.name}" both map to "${p.ident}"`) } seen.set(p.ident, p.name) } - const constLines = pairs - .map((p) => ` ${p.ident}: ${JSON.stringify(p.name)},`) - .join('\n') + const constLines = pairs.map((p) => ` ${p.ident}: ${JSON.stringify(p.name)},`).join('\n') const arrayEntries = eventNames.map((n) => ` ${JSON.stringify(n)},`).join('\n') return `// AUTO-GENERATED FILE. DO NOT EDIT. @@ -113,13 +101,13 @@ async function main() { const raw = await readFile(inputPath, 'utf8') const schema = JSON.parse(raw) const eventNames = extractEventNames(schema) - const rendered = render(eventNames) + const rendered = formatGeneratedSource(render(eventNames), OUTPUT_PATH, ROOT) if (checkOnly) { const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null) if (existing !== rendered) { throw new Error( - 'Generated trace events contract is stale. Run: bun run trace-events-contract:generate', + 'Generated trace events contract is stale. Run: bun run trace-events-contract:generate' ) } console.log('Trace events contract is up to date.') diff --git a/scripts/sync-trace-spans-contract.ts b/scripts/sync-trace-spans-contract.ts index b3495753f6c..374898df261 100644 --- a/scripts/sync-trace-spans-contract.ts +++ b/scripts/sync-trace-spans-contract.ts @@ -1,6 +1,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { formatGeneratedSource } from './format-generated-source' /** * Generate `apps/sim/lib/copilot/generated/trace-spans-v1.ts` from the @@ -22,12 +23,9 @@ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(SCRIPT_DIR, '..') const DEFAULT_CONTRACT_PATH = resolve( ROOT, - '../copilot/copilot/contracts/trace-spans-v1.schema.json', -) -const OUTPUT_PATH = resolve( - ROOT, - 'apps/sim/lib/copilot/generated/trace-spans-v1.ts', + '../copilot/copilot/contracts/trace-spans-v1.schema.json' ) +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/trace-spans-v1.ts') function extractSpanNames(schema: Record): string[] { const defs = (schema.$defs ?? {}) as Record @@ -37,9 +35,7 @@ function extractSpanNames(schema: Record): string[] { typeof nameDef !== 'object' || !Array.isArray((nameDef as Record).enum) ) { - throw new Error( - 'trace-spans-v1.schema.json is missing $defs.TraceSpansV1Name.enum', - ) + throw new Error('trace-spans-v1.schema.json is missing $defs.TraceSpansV1Name.enum') } const enumValues = (nameDef as Record).enum as unknown[] if (!enumValues.every((v) => typeof v === 'string')) { @@ -63,14 +59,10 @@ function toIdentifier(name: string): string { if (parts.length === 0) { throw new Error(`Cannot derive identifier for span name: ${name}`) } - const ident = parts - .map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()) - .join('') + const ident = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join('') // Safety: identifiers may not start with a digit. if (/^[0-9]/.test(ident)) { - throw new Error( - `Derived identifier "${ident}" for span "${name}" starts with a digit`, - ) + throw new Error(`Derived identifier "${ident}" for span "${name}" starts with a digit`) } return ident } @@ -85,16 +77,12 @@ function render(spanNames: string[]): string { for (const p of pairs) { const prev = seen.get(p.ident) if (prev && prev !== p.name) { - throw new Error( - `Identifier collision: "${prev}" and "${p.name}" both map to "${p.ident}"`, - ) + throw new Error(`Identifier collision: "${prev}" and "${p.name}" both map to "${p.ident}"`) } seen.set(p.ident, p.name) } - const constLines = pairs - .map((p) => ` ${p.ident}: ${JSON.stringify(p.name)},`) - .join('\n') + const constLines = pairs.map((p) => ` ${p.ident}: ${JSON.stringify(p.name)},`).join('\n') const arrayEntries = spanNames.map((n) => ` ${JSON.stringify(n)},`).join('\n') return `// AUTO-GENERATED FILE. DO NOT EDIT. @@ -131,13 +119,13 @@ async function main() { const raw = await readFile(inputPath, 'utf8') const schema = JSON.parse(raw) const spanNames = extractSpanNames(schema) - const rendered = render(spanNames) + const rendered = formatGeneratedSource(render(spanNames), OUTPUT_PATH, ROOT) if (checkOnly) { const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null) if (existing !== rendered) { throw new Error( - 'Generated trace spans contract is stale. Run: bun run trace-spans-contract:generate', + 'Generated trace spans contract is stale. Run: bun run trace-spans-contract:generate' ) } console.log('Trace spans contract is up to date.') From a1702556339b10f7a48dde119748d8fe68506c02 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 9 May 2026 16:16:08 -0700 Subject: [PATCH 2/3] fix(script): biome format wrap (#4541) --- scripts/sync-tool-catalog.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/sync-tool-catalog.ts b/scripts/sync-tool-catalog.ts index 5de6c62603b..285743741a6 100644 --- a/scripts/sync-tool-catalog.ts +++ b/scripts/sync-tool-catalog.ts @@ -210,7 +210,11 @@ async function main() { lines.push('') const rendered = formatGeneratedSource(lines.join('\n'), OUTPUT_PATH, ROOT) - const runtimeSchemaRendered = renderRuntimeSchemaModule(catalog) + const runtimeSchemaRendered = formatGeneratedSource( + renderRuntimeSchemaModule(catalog), + RUNTIME_SCHEMA_OUTPUT_PATH, + ROOT + ) if (checkOnly) { const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null) From 94f60e79b23243d7701df17cd9211202b6f4b5b3 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 9 May 2026 16:35:14 -0700 Subject: [PATCH 3/3] improvement(deps): remove unused remark deps (#4542) --- apps/sim/package.json | 2 -- bun.lock | 2 -- 2 files changed, 4 deletions(-) diff --git a/apps/sim/package.json b/apps/sim/package.json index 77619302061..321ad7c3acd 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -180,8 +180,6 @@ "rehype-slug": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "4.0.1", - "remark-parse": "11.0.0", - "remark-rehype": "11.1.2", "resend": "^4.1.2", "rss-parser": "3.13.0", "safe-regex2": "5.1.0", diff --git a/bun.lock b/bun.lock index 4a48daf8ee8..fe7fc784e8e 100644 --- a/bun.lock +++ b/bun.lock @@ -234,8 +234,6 @@ "rehype-slug": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "4.0.1", - "remark-parse": "11.0.0", - "remark-rehype": "11.1.2", "resend": "^4.1.2", "rss-parser": "3.13.0", "safe-regex2": "5.1.0",