Skip to content

Commit c2b3c34

Browse files
jouwdanclaude
andauthored
fix(composer): show the autosave copy, and drop a posted draft (#279)
* fix(composer): give the new-thread form the autosave copy it reads The new-thread page built its copy record without the six keys ComposerRecovery reads, so the island rendered them as themselves — "composer.autosave.saved" under the message box the moment a draft saved. The reply form's record carried them; the new-thread form's never had. Both records share one list now, and a test scans the composer components for the keys they read from the prop so a record cannot fall behind one again. Closes #275 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZGWFg83tyN4hSR3wsPLhF * fix(composer): drop a posted draft instead of offering it on the next thread Posting cleared the server draft but two paths put it back, so starting another thread in the forum offered the one just posted. A Server Action's redirect is a client-side navigation when scripting is on, so the browser's own recovery copy was never removed on the one path that mattered; and the blur that pressing Post fires raced an autosave in behind the action's own draft removal, recreating the server draft after it had gone. The browser copy is now removed when the composer unmounts mid-submit — what a successful post looks like from the browser — and a pointerdown on any submit button holds off the blur autosave until the next keystroke, so the post's draft removal is the last word. Typing after a failed submit clears the hold, so an attempt that errored still keeps its draft. Refs #275 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZGWFg83tyN4hSR3wsPLhF --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent edd740f commit c2b3c34

5 files changed

Lines changed: 132 additions & 7 deletions

File tree

apps/community/src/components/content/composer-recovery.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,13 @@ export function ComposerRecovery({
9090
const latest = useRef('')
9191
const saved = useRef('')
9292
const submitting = useRef(false)
93+
const held = useRef(false)
9394
const [recovery, setRecovery] = useState<Backup | null>(null)
9495
const [saveState, setSaveState] = useState<SaveState>('idle')
9596

9697
const persist = useCallback(async () => {
9798
const form = root.current?.closest('form')
98-
if (form === null || form === undefined) return
99+
if (form === null || form === undefined || held.current) return
99100
const payload = readPayload(form, { ...scope, title: '', message: '', prefixId: null })
100101
const next = signature(payload)
101102
latest.current = next
@@ -134,6 +135,8 @@ export function ComposerRecovery({
134135

135136
let timer: ReturnType<typeof setTimeout> | undefined
136137
const onInput = () => {
138+
submitting.current = false
139+
held.current = false
137140
const payload = readPayload(form, { ...scope, title: '', message: '', prefixId: null })
138141
latest.current = signature(payload)
139142
try {
@@ -146,6 +149,12 @@ export function ComposerRecovery({
146149
timer = setTimeout(() => void persist(), AUTOSAVE_DELAY_MS)
147150
}
148151
const onBlur = () => void persist()
152+
const onPointerDown = (event: Event) => {
153+
const target = event.target
154+
if (target instanceof Element && target.closest('button[type="submit"]') !== null) {
155+
held.current = true
156+
}
157+
}
149158
const onSubmit = (event: SubmitEvent) => {
150159
const submitter = event.submitter
151160
submitting.current =
@@ -163,19 +172,28 @@ export function ComposerRecovery({
163172

164173
form.addEventListener('input', onInput)
165174
form.addEventListener('blur', onBlur, true)
175+
form.addEventListener('pointerdown', onPointerDown, true)
166176
form.addEventListener('submit', onSubmit)
167177
window.addEventListener('pagehide', onPageHide)
168178
window.addEventListener('beforeunload', onBeforeUnload)
169179
return () => {
170180
clearTimeout(timer)
171181
form.removeEventListener('input', onInput)
172182
form.removeEventListener('blur', onBlur, true)
183+
form.removeEventListener('pointerdown', onPointerDown, true)
173184
form.removeEventListener('submit', onSubmit)
174185
window.removeEventListener('pagehide', onPageHide)
175186
window.removeEventListener('beforeunload', onBeforeUnload)
176187
}
177188
}, [persist, scope, serverUpdatedAt, storageKey])
178189

190+
useEffect(
191+
() => () => {
192+
if (submitting.current) localStorage.removeItem(storageKey)
193+
},
194+
[storageKey],
195+
)
196+
179197
function restore(): void {
180198
const form = root.current?.closest('form')
181199
if (form === null || form === undefined || recovery === null) return
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { readFileSync } from 'node:fs'
2+
3+
import { describe, expect, it } from 'vitest'
4+
5+
import { editPostFormCopy, newThreadFormCopy, replyFormCopy } from './content-copy'
6+
7+
function keysReadFromTheCopyProp(component: string): string[] {
8+
const source = readFileSync(
9+
new URL(`../components/content/${component}`, import.meta.url),
10+
'utf8',
11+
)
12+
const keys = new Set<string>()
13+
14+
for (const match of source.matchAll(/(?:format)?fromCopy\(\s*copy,\s*'([\w.-]+)'/g)) {
15+
const key = match[1]
16+
if (key !== undefined) keys.add(key)
17+
}
18+
19+
return [...keys].sort()
20+
}
21+
22+
const RECOVERY = keysReadFromTheCopyProp('composer-recovery.tsx')
23+
24+
const BUNDLES = [
25+
{ name: 'replyFormCopy', copy: replyFormCopy(), components: ['reply-form.tsx'] },
26+
{ name: 'newThreadFormCopy', copy: newThreadFormCopy(), components: ['new-thread-form.tsx'] },
27+
{ name: 'editPostFormCopy', copy: editPostFormCopy(), components: ['edit-post-form.tsx'] },
28+
] as const
29+
30+
describe('the copy a composer is handed', () => {
31+
it('finds the keys the autosave notice reads', () => {
32+
expect(RECOVERY).toContain('composer.autosave.saved')
33+
expect(RECOVERY.length).toBeGreaterThan(3)
34+
})
35+
36+
for (const bundle of BUNDLES) {
37+
const wanted = [
38+
...bundle.components.flatMap(keysReadFromTheCopyProp),
39+
...(bundle.name === 'editPostFormCopy' ? [] : RECOVERY),
40+
]
41+
42+
it.each(wanted)(`${bundle.name} carries %s`, (key) => {
43+
expect(Object.keys(bundle.copy)).toContain(key)
44+
})
45+
46+
it.each(wanted)(`${bundle.name} resolves %s to prose, not the key`, (key) => {
47+
expect(bundle.copy[key]).not.toBe(key)
48+
expect(bundle.copy[key] ?? '').not.toBe('')
49+
})
50+
}
51+
})

apps/community/src/view/content-copy.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@ import type { Translator } from '@meith/i18n'
33
import { copyFor, patternCopy, splitAround } from './copy'
44
import { untranslated } from './time'
55

6+
const RECOVERY_KEYS = [
7+
'composer.autosave.failed',
8+
'composer.autosave.saved',
9+
'composer.autosave.saving',
10+
'composer.recovery.available',
11+
'composer.recovery.discard',
12+
'composer.recovery.restore',
13+
] as const
14+
615
export function replyFormCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
716
return copyFor(
817
[
18+
...RECOVERY_KEYS,
919
'composer.draftSaved',
10-
'composer.autosave.failed',
11-
'composer.autosave.saved',
12-
'composer.autosave.saving',
13-
'composer.recovery.available',
14-
'composer.recovery.discard',
15-
'composer.recovery.restore',
1620
'composer.notifyReplies',
1721
'composer.reply.submit',
1822
'composer.reply.write',
@@ -44,6 +48,7 @@ export function newThreadFormCopy(
4448
return {
4549
...copyFor(
4650
[
51+
...RECOVERY_KEYS,
4752
'composer.draftSaved',
4853
'composer.notifyReplies',
4954
'composer.newThread.subject',

docs/guides/community/formatting.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,23 @@ the file still also appears in the attachment list once the post is
9191
saved. An id you did not upload yourself never resolves to anything in
9292
somebody else's post — typing one by hand does not work.
9393

94+
## Drafts
95+
96+
The composer saves what you are writing without being asked. A second or
97+
so after you stop typing, and again whenever a field loses focus, it
98+
sends the subject and the message to the board and says **Saved just
99+
now.** underneath. One draft is kept per forum for a thread you have not
100+
posted yet, and one per thread for a reply; reopening the composer fills
101+
it back in. **Save draft** does the same on demand.
102+
103+
A copy is also kept in the browser, and it is the one that survives a
104+
crashed tab or a lost connection. Reopen the composer and it offers to
105+
**restore** what it kept, or to **discard** it.
106+
107+
Posting clears both, so the next thread you start in that forum begins
108+
empty. With scripting off there is no autosave and no recovery offer;
109+
**Save draft** still works.
110+
94111
## Everything else stays server-rendered
95112

96113
None of the above needed a client-side markup renderer, a syntax-

e2e/composer-drafts.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { expect, test } from '@playwright/test'
2+
3+
import { signUp } from './support/session'
4+
5+
const BACKUP_KEY = 'meith:composer:new-thread:200'
6+
7+
test('posting a thread clears the browser copy, so the next one starts empty', async ({ page }) => {
8+
test.setTimeout(60_000)
9+
10+
await signUp(page, 'drafter')
11+
12+
await page.goto('/200-general/new')
13+
14+
const title = `A thread that autosaved ${Date.now()}`
15+
await page.getByLabel('Subject').fill(title)
16+
await page.getByLabel('Message').fill('This one was autosaved before it was posted.')
17+
18+
await expect(page.getByText('Saved just now.')).toBeVisible()
19+
await expect
20+
.poll(() => page.evaluate((key) => localStorage.getItem(key), BACKUP_KEY))
21+
.not.toBeNull()
22+
23+
await page.getByRole('button', { name: 'Post thread' }).click()
24+
await expect(page).toHaveURL(/\/thread\/\d+-/)
25+
26+
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), BACKUP_KEY)).toBeNull()
27+
28+
await page.goto('/200-general/new')
29+
30+
await expect(page.getByLabel('Subject')).toHaveValue('')
31+
await expect(
32+
page.getByText('A newer unsent version was recovered from this browser.'),
33+
).toHaveCount(0)
34+
})

0 commit comments

Comments
 (0)