From c42b02751fe2aa97a06ca249ce2723a4dd699680 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 10 Oct 2026 07:20:52 -0700 Subject: [PATCH 1/3] fix(chat): keep composer drafts across hydration - Adopt text typed into the server-rendered composer before hydration; the dropped input event left state empty and the next render wiped the text - Restore a saved draft after hydration instead of in the initial render, which mismatched the server HTML and regenerated the composer on every reload with a draft - Share one useHydrated hook across the composer, organization home, and theme toggle - Mobile E2E: wait for the draft to overflow its editor instead of measuring once, and check a saved draft returns after a reload without a hydration error --- .../components/theme-toggle/theme-toggle.tsx | 18 +-------- .../home/organization-home.tsx | 9 ++--- .../prompt-editor/use-prompt-editor.test.tsx | 38 ++++++++++++++++++- .../prompt-editor/use-prompt-editor.ts | 9 +++++ .../home/components/user-input/user-input.tsx | 6 ++- apps/sim/hooks/use-hydrated.ts | 13 +++++++ apps/sim/scripts/test-mobile-e2e.ts | 27 ++++++++++++- 7 files changed, 95 insertions(+), 25 deletions(-) create mode 100644 apps/sim/hooks/use-hydrated.ts 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..1a71d1f650a 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,38 @@ 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 ( +