diff --git a/apps/sim/app/(landing)/components/footer/components/theme-toggle/theme-toggle.tsx b/apps/sim/app/(landing)/components/footer/components/theme-toggle/theme-toggle.tsx index ed9551b44d1..cbdadecb657 100644 --- a/apps/sim/app/(landing)/components/footer/components/theme-toggle/theme-toggle.tsx +++ b/apps/sim/app/(landing)/components/footer/components/theme-toggle/theme-toggle.tsx @@ -1,9 +1,10 @@ 'use client' -import { useId, useSyncExternalStore } from 'react' +import { useId } from 'react' import { cn } from '@sim/emcn' import { Moon, Sun } from '@sim/emcn/icons' import { useTheme } from 'next-themes' +import { useHydrated } from '@/hooks/use-hydrated' /** * The two themes on offer, in reading order. `system` is deliberately absent: @@ -33,21 +34,6 @@ const SEGMENT_TONE: Record = { dark: 'text-[var(--text-muted)] hover-hover:text-[var(--text-primary)] dark:bg-[var(--surface-active)] dark:text-[var(--text-primary)]', } -const subscribeToNothing = () => () => {} - -/** - * `true` once hydrated, `false` in server HTML and during hydration - the - * store-backed form React reconciles without a mismatch, unlike an effect that - * flips state after mount. - */ -function useHydrated(): boolean { - return useSyncExternalStore( - subscribeToNothing, - () => true, - () => false - ) -} - /** * Footer light/dark switch: a hairline pill holding a sun and a moon, with the * current theme's segment filled. Picking a segment writes the visitor's diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.tsx index b2739f6e80b..8c56ffe4d43 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useState, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useState } from 'react' import { toast } from '@sim/emcn' import { useQueryClient } from '@tanstack/react-query' import Link from 'next/link' @@ -48,6 +48,7 @@ import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId] import { mentionifyIntegrations } from '@/blocks/integration-matcher' import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats' import { getWorkspaceFilesQueryOptions } from '@/hooks/queries/workspace-files' +import { useHydrated } from '@/hooks/use-hydrated' import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' import { useOrganizationChatModeStore } from '@/stores/organization-chat-mode/store' import type { ChatContext } from '@/stores/panel' @@ -58,15 +59,11 @@ interface OrganizationHomeProps { requestMode?: ChatRequestMode } -const subscribeToClient = () => () => {} -const clientSnapshot = () => true -const serverSnapshot = () => false - /** Home chooses the conversation mode before its first message. */ export function OrganizationHome(props: OrganizationHomeProps) { const { organization, searchAccess, canBuild, mothershipAvailable } = useOrganizationContext() const { data: session } = useSession() - const isClient = useSyncExternalStore(subscribeToClient, clientSnapshot, serverSnapshot) + const isClient = useHydrated() const isRestoredChatEntry = useRestoredChatEntry({ chatId: props.chatId }) if (!mothershipAvailable || (!canBuild && !searchAccess.memberScoped)) return null /** Preferences are browser-persisted and keyed by user; never paint a guessed mode first. */ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx index 97b5d72f905..b7ae3876f54 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx @@ -3,7 +3,8 @@ */ import { act, type ReactNode } from 'react' import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock' -import { createRoot, type Root } from 'react-dom/client' +import { createRoot, hydrateRoot, type Root } from 'react-dom/client' +import { renderToString } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }) })) @@ -458,3 +459,70 @@ it('inserts a canonical built-in skill globally without inheriting a workspace', unmount() } }) + +it('keeps text typed into the server-rendered textarea before hydration', () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + let latest: ReturnType | undefined + function Editor({ revision }: { revision: number }) { + const editor = usePromptEditor({ workspaceId: 'ws-1' }) + latest = editor + return ( +