Skip to content

Commit c42b027

Browse files
committed
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
1 parent 071e7db commit c42b027

7 files changed

Lines changed: 95 additions & 25 deletions

File tree

‎apps/sim/app/(landing)/components/footer/components/theme-toggle/theme-toggle.tsx‎

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
'use client'
22

3-
import { useId, useSyncExternalStore } from 'react'
3+
import { useId } from 'react'
44
import { cn } from '@sim/emcn'
55
import { Moon, Sun } from '@sim/emcn/icons'
66
import { useTheme } from 'next-themes'
7+
import { useHydrated } from '@/hooks/use-hydrated'
78

89
/**
910
* The two themes on offer, in reading order. `system` is deliberately absent:
@@ -33,21 +34,6 @@ const SEGMENT_TONE: Record<ThemeOption, string> = {
3334
dark: 'text-[var(--text-muted)] hover-hover:text-[var(--text-primary)] dark:bg-[var(--surface-active)] dark:text-[var(--text-primary)]',
3435
}
3536

36-
const subscribeToNothing = () => () => {}
37-
38-
/**
39-
* `true` once hydrated, `false` in server HTML and during hydration - the
40-
* store-backed form React reconciles without a mismatch, unlike an effect that
41-
* flips state after mount.
42-
*/
43-
function useHydrated(): boolean {
44-
return useSyncExternalStore(
45-
subscribeToNothing,
46-
() => true,
47-
() => false
48-
)
49-
}
50-
5137
/**
5238
* Footer light/dark switch: a hairline pill holding a sun and a moon, with the
5339
* current theme's segment filled. Picking a segment writes the visitor's

‎apps/sim/app/o/[organizationId]/home/organization-home.tsx‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
3+
import { useCallback, useEffect, useState } from 'react'
44
import { toast } from '@sim/emcn'
55
import { useQueryClient } from '@tanstack/react-query'
66
import Link from 'next/link'
@@ -48,6 +48,7 @@ import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]
4848
import { mentionifyIntegrations } from '@/blocks/integration-matcher'
4949
import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
5050
import { getWorkspaceFilesQueryOptions } from '@/hooks/queries/workspace-files'
51+
import { useHydrated } from '@/hooks/use-hydrated'
5152
import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
5253
import { useOrganizationChatModeStore } from '@/stores/organization-chat-mode/store'
5354
import type { ChatContext } from '@/stores/panel'
@@ -58,15 +59,11 @@ interface OrganizationHomeProps {
5859
requestMode?: ChatRequestMode
5960
}
6061

61-
const subscribeToClient = () => () => {}
62-
const clientSnapshot = () => true
63-
const serverSnapshot = () => false
64-
6562
/** Home chooses the conversation mode before its first message. */
6663
export function OrganizationHome(props: OrganizationHomeProps) {
6764
const { organization, searchAccess, canBuild, mothershipAvailable } = useOrganizationContext()
6865
const { data: session } = useSession()
69-
const isClient = useSyncExternalStore(subscribeToClient, clientSnapshot, serverSnapshot)
66+
const isClient = useHydrated()
7067
const isRestoredChatEntry = useRestoredChatEntry({ chatId: props.chatId })
7168
if (!mothershipAvailable || (!canBuild && !searchAccess.memberScoped)) return null
7269
/** Preferences are browser-persisted and keyed by user; never paint a guessed mode first. */

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
*/
44
import { act, type ReactNode } from 'react'
55
import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock'
6-
import { createRoot, type Root } from 'react-dom/client'
6+
import { createRoot, hydrateRoot, type Root } from 'react-dom/client'
7+
import { renderToString } from 'react-dom/server'
78
import { describe, expect, it, vi } from 'vitest'
89

910
vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }) }))
@@ -458,3 +459,38 @@ it('inserts a canonical built-in skill globally without inheriting a workspace',
458459
unmount()
459460
}
460461
})
462+
463+
it('keeps text typed into the server-rendered textarea before hydration', () => {
464+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
465+
let latest: ReturnType<typeof usePromptEditor> | undefined
466+
function Editor({ revision }: { revision: number }) {
467+
const editor = usePromptEditor({ workspaceId: 'ws-1' })
468+
latest = editor
469+
return (
470+
<textarea
471+
ref={editor.textareaRef}
472+
value={editor.value}
473+
onChange={editor.handleInputChange}
474+
data-revision={revision}
475+
/>
476+
)
477+
}
478+
const container = document.createElement('div')
479+
container.innerHTML = renderToString(<Editor revision={0} />)
480+
document.body.appendChild(container)
481+
const textarea = container.querySelector('textarea')
482+
if (!textarea) throw new Error('The server must render the textarea')
483+
textarea.value = 'Typed while the page loaded'
484+
let root: Root | undefined
485+
try {
486+
act(() => {
487+
root = hydrateRoot(container, <Editor revision={0} />)
488+
})
489+
act(() => root?.render(<Editor revision={1} />))
490+
expect(latest?.getValue()).toBe('Typed while the page loaded')
491+
expect(textarea.value).toBe('Typed while the page loaded')
492+
} finally {
493+
act(() => root?.unmount())
494+
container.remove()
495+
}
496+
})

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,6 +1056,15 @@ export function usePromptEditor({
10561056
setValueState(textarea.value)
10571057
}, [])
10581058

1059+
// Keystrokes typed into the server-rendered textarea before hydration never
1060+
// reach React. Compared with the mounted value: `valueRef` leads the DOM
1061+
// while an edit made during mount is still uncommitted.
1062+
const mountedValueRef = useRef(initialValue)
1063+
useLayoutEffect(() => {
1064+
const textarea = textareaRef.current
1065+
if (textarea && textarea.value !== mountedValueRef.current) adoptDomValue(textarea)
1066+
}, [textareaRef, adoptDomValue])
1067+
10591068
// Selection one change ago, used to infer which edge of a range moved. Kept
10601069
// current by the `selectionchange` listener below — which fires on EVERY
10611070
// caret/selection change (typing, arrows, clicks, programmatic), unlike

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]
4444
import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
4545
import { mentionifyIntegrations } from '@/blocks/integration-matcher'
4646
import { useChatInputFocus } from '@/hooks/use-chat-input-focus'
47+
import { useHydrated } from '@/hooks/use-hydrated'
4748
import { isMobileViewport } from '@/hooks/use-is-mobile'
4849
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
4950
import { useVoiceInput } from '@/hooks/use-voice-input'
@@ -104,9 +105,11 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
104105
const { workspaceId } = useParams<{ workspaceId: string }>()
105106
const { navigateToSettings } = useSettingsNavigation()
106107
const { userId, onContextAdd, onContextRemove } = useChatSurface()
108+
// Server HTML cannot see the stored draft; hydration renders without it and the mount restore applies it.
109+
const canReadStoredDraft = useHydrated()
107110
const [initialValue] = useState(() => {
108111
if (defaultValue) return defaultValue
109-
if (!draftScopeKey) return ''
112+
if (!canReadStoredDraft || !draftScopeKey) return ''
110113
const text = useMothershipDraftsStore.getState().drafts[draftScopeKey]?.text
111114
return typeof text === 'string' ? text : ''
112115
})
@@ -201,6 +204,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
201204
if (restoredContexts) editor.setContexts(restoredContexts)
202205
if (restoredFiles) files.restoreAttachedFiles(restoredFiles)
203206
if (caretText !== null) {
207+
if (editor.getValue() === '') editor.setValue(caretText)
204208
const textarea = textareaRef.current
205209
if (textarea) {
206210
if (!isMobileViewport()) textarea.focus()

‎apps/sim/hooks/use-hydrated.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { useSyncExternalStore } from 'react'
2+
3+
const subscribeToNothing = () => () => {}
4+
const onClient = () => true
5+
const onServer = () => false
6+
7+
/**
8+
* `true` once hydrated, `false` in server HTML and during hydration: the store-backed form React
9+
* reconciles without a mismatch, unlike an effect that flips state after mount.
10+
*/
11+
export function useHydrated(): boolean {
12+
return useSyncExternalStore(subscribeToNothing, onClient, onServer)
13+
}

‎apps/sim/scripts/test-mobile-e2e.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,27 @@ async function exerciseViewport(
553553
}
554554

555555
if (viewport.name === 'desktop') {
556+
await check(`${prefix}/a saved draft returns after a reload`, page, async () => {
557+
const draft = 'A draft saved before reloading'
558+
const storedDrafts = () =>
559+
page.evaluate(() => localStorage.getItem('mothership-drafts:v1') ?? '')
560+
await visit(page, 'home')
561+
const composer = page.getByRole('textbox', { name: 'Message', exact: true })
562+
await composer.fill(draft)
563+
await expect.poll(storedDrafts).toContain(draft)
564+
const pageErrorCount = pageErrors.length
565+
const nativeErrorCount = nativeErrors.length
566+
await visit(page, 'home')
567+
await expect(composer).toHaveValue(draft)
568+
assert.deepEqual(
569+
[...pageErrors.slice(pageErrorCount), ...nativeErrors.slice(nativeErrorCount)],
570+
[],
571+
'Restoring a saved draft must hydrate cleanly'
572+
)
573+
await composer.fill('')
574+
await expect.poll(storedDrafts).not.toContain(draft)
575+
})
576+
556577
for (const width of [390, viewport.width]) {
557578
await check(
558579
`${prefix}/${width}px draft scroll boundaries preserve the surrounding page`,
@@ -591,8 +612,12 @@ async function exerciseViewport(
591612
try {
592613
await page.setViewportSize({ width, height: 520 })
593614
await composer.fill('A draft line\n'.repeat(30))
615+
await expect
616+
.poll(async () => (await readScroll().catch(() => null))?.max ?? 0, {
617+
message: 'The draft must overflow its editor',
618+
})
619+
.toBeGreaterThan(100)
594620
const editor = await readScroll()
595-
assert(editor.max > 100, 'The draft must overflow its editor')
596621
await page.mouse.move(editor.x, editor.y)
597622
await page.mouse.wheel(0, 10_000)
598623
await expect

0 commit comments

Comments
 (0)