From 22381e9f07917bc765a3fc1041b510567167a365 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 03:31:09 +0000 Subject: [PATCH 1/4] Keep account table search focused after the first keystroke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RecordTableSearch was a Remix-controlled field, so the first character wrote `?q=` into the URL, restored the previous empty value, and revealed WebKit's search cancel control — all of which dropped focus. Keep the input uncontrolled and hide the cancel chrome. Co-authored-by: Kent C. Dodds --- .../client/routes/record-table.node.test.ts | 21 +++++ .../worker/client/routes/record-table.tsx | 92 ++++++++++++++----- 2 files changed, 92 insertions(+), 21 deletions(-) diff --git a/packages/worker/client/routes/record-table.node.test.ts b/packages/worker/client/routes/record-table.node.test.ts index fde2cfd8ea..7c4b12e0f5 100644 --- a/packages/worker/client/routes/record-table.node.test.ts +++ b/packages/worker/client/routes/record-table.node.test.ts @@ -3,6 +3,7 @@ import { renderToString } from 'remix/ui/server' import { expect, test } from 'vitest' import { RecordTable, + RecordTableSearch, type RecordTableColumn, } from '#client/routes/record-table.tsx' @@ -133,6 +134,26 @@ test('record table keeps container drops, row links, and expand/pane selection c expect(overflowHtml).toContain('overflow: clip') }) +test('record table search stays uncontrolled so the first keystroke cannot remount it', async () => { + const html = await renderToString( + jsx(RecordTableSearch, { + label: 'Search packages', + placeholder: 'Search by name, id, description, or tag', + value: '', + onInput() {}, + }), + ) + + expect(html).toContain('type="search"') + expect(html).toContain('aria-label="Search packages"') + // Passing `value` makes Remix restore the previous query on `input` and + // drop focus. `defaultValue` serializes as the HTML value attribute, so + // the client contract is "no controlled value prop" — pinned here by the + // WebKit cancel-button rules that otherwise appear on the first character. + expect(html).toContain('::-webkit-search-cancel-button') + expect(html).toContain('display: none') +}) + test('record table empty and busy states keep toolbar layout stable', async () => { const emptyHtml = await renderToString( jsx(RecordTable, { diff --git a/packages/worker/client/routes/record-table.tsx b/packages/worker/client/routes/record-table.tsx index d18ddd1bcb..f45117b2db 100644 --- a/packages/worker/client/routes/record-table.tsx +++ b/packages/worker/client/routes/record-table.tsx @@ -1,4 +1,4 @@ -import { css, type Handle } from 'remix/ui' +import { css, ref, type Handle } from 'remix/ui' import { shouldRouterHandleClick } from '#client/client-router.tsx' import { on } from '#client/event-mixin.ts' import { @@ -315,6 +315,34 @@ const footerCss = { * they cost 48px, so the label becomes the control's accessible name. */ +/** + * Live filter fields write the query into the URL on every keystroke. A + * Remix-controlled `value` lets the first character schedule a restore of the + * previous (empty) query, and WebKit's search cancel control appears on that + * same keystroke — either one drops focus and the reader has to click back in. + * The field stays uncontrolled; URL/back-button updates land only while it is + * not focused. + */ +const searchCancelHiddenCss = { + '&::-webkit-search-decoration': { + WebkitAppearance: 'none', + appearance: 'none', + }, + '&::-webkit-search-cancel-button': { + WebkitAppearance: 'none', + appearance: 'none', + display: 'none', + }, + '&::-webkit-search-results-button': { + WebkitAppearance: 'none', + appearance: 'none', + }, + '&::-webkit-search-results-decoration': { + WebkitAppearance: 'none', + appearance: 'none', + }, +} as const + export function RecordTableSearch( handle: Handle<{ label: string @@ -323,26 +351,48 @@ export function RecordTableSearch( onInput: (value: string) => void }>, ) { - return () => ( - - handle.props.onInput((event.currentTarget as HTMLInputElement).value), - ), - css({ - ...getAuthInputCss(), - flex: '1 1 12rem', - minWidth: '7rem', - width: 'auto', - }), - ]} - /> - ) + let input: HTMLInputElement | null = null + const initialValue = handle.props.value + let lastExternalValue = initialValue + + return () => { + const nextValue = handle.props.value + if (nextValue !== lastExternalValue) { + lastExternalValue = nextValue + if (input && document.activeElement !== input) { + input.value = nextValue + } + } + return ( + { + input = node as HTMLInputElement + signal.addEventListener('abort', () => { + if (input === node) input = null + }) + }), + on('input', (event) => + handle.props.onInput( + (event.currentTarget as HTMLInputElement).value, + ), + ), + css({ + ...getAuthInputCss(), + flex: '1 1 12rem', + minWidth: '7rem', + width: 'auto', + ...searchCancelHiddenCss, + }), + ]} + /> + ) + } } export function RecordTableSelect( From 9438b23bf736c92274e73c36fa385d7c79780b6e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 03:36:17 +0000 Subject: [PATCH 2/4] Apply deferred search URL updates when the field blurs An external `q` change while the input is focused was marked synced and then never written. Hold it as pending and apply on blur so back-button updates still land. Co-authored-by: Kent C. Dodds --- .../worker/client/routes/record-table.tsx | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/worker/client/routes/record-table.tsx b/packages/worker/client/routes/record-table.tsx index f45117b2db..44e4d36218 100644 --- a/packages/worker/client/routes/record-table.tsx +++ b/packages/worker/client/routes/record-table.tsx @@ -320,8 +320,8 @@ const footerCss = { * Remix-controlled `value` lets the first character schedule a restore of the * previous (empty) query, and WebKit's search cancel control appears on that * same keystroke — either one drops focus and the reader has to click back in. - * The field stays uncontrolled; URL/back-button updates land only while it is - * not focused. + * The field stays uncontrolled. URL/back-button updates apply immediately + * when it is not focused, and on blur if they arrived while it was. */ const searchCancelHiddenCss = { '&::-webkit-search-decoration': { @@ -354,13 +354,21 @@ export function RecordTableSearch( let input: HTMLInputElement | null = null const initialValue = handle.props.value let lastExternalValue = initialValue + let pendingExternalValue: string | null = null + + function applyExternalValue(nextValue: string) { + if (input) input.value = nextValue + lastExternalValue = nextValue + pendingExternalValue = null + } return () => { const nextValue = handle.props.value if (nextValue !== lastExternalValue) { - lastExternalValue = nextValue - if (input && document.activeElement !== input) { - input.value = nextValue + if (input && document.activeElement === input) { + pendingExternalValue = nextValue + } else { + applyExternalValue(nextValue) } } return ( @@ -382,6 +390,11 @@ export function RecordTableSearch( (event.currentTarget as HTMLInputElement).value, ), ), + on('blur', () => { + if (pendingExternalValue !== null) { + applyExternalValue(pendingExternalValue) + } + }), css({ ...getAuthInputCss(), flex: '1 1 12rem', From 8c246718fa47be263eaaf4e12b75e5dbc3c84242 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 03:41:35 +0000 Subject: [PATCH 3/4] Drop stale pending search text when the query returns Typing now records the field as the last applied value so clearing or backing to the same q cannot leave a pending string for blur to restore. Co-authored-by: Kent C. Dodds --- .../client/routes/record-table-search-sync.ts | 52 ++++++++++++++++++ .../client/routes/record-table.node.test.ts | 53 +++++++++++++++++++ .../worker/client/routes/record-table.tsx | 48 ++++++++++------- 3 files changed, 134 insertions(+), 19 deletions(-) create mode 100644 packages/worker/client/routes/record-table-search-sync.ts diff --git a/packages/worker/client/routes/record-table-search-sync.ts b/packages/worker/client/routes/record-table-search-sync.ts new file mode 100644 index 0000000000..5a4db2803c --- /dev/null +++ b/packages/worker/client/routes/record-table-search-sync.ts @@ -0,0 +1,52 @@ +export type RecordTableSearchSync = { + lastExternalValue: string + pendingExternalValue: string | null +} + +/** + * The reader just typed this string, so the coming URL update is theirs. + * Record it as the last applied value and drop any deferred external string + * so blur cannot write stale text back into the field. + */ +export function acknowledgeRecordTableSearchInput( + value: string, +): RecordTableSearchSync { + return { lastExternalValue: value, pendingExternalValue: null } +} + +/** + * Reconcile an incoming `value` prop (URL / back-button) with the last + * applied or typed string. Focused changes wait for blur; a return to the + * last applied value clears a stale pending string instead of keeping it. + */ +export function reconcileRecordTableSearchExternalValue( + state: RecordTableSearchSync, + nextValue: string, + focused: boolean, +): { state: RecordTableSearchSync; applyValue: string | null } { + if (nextValue === state.lastExternalValue) { + return { + state: { + lastExternalValue: state.lastExternalValue, + pendingExternalValue: null, + }, + applyValue: null, + } + } + if (focused) { + return { + state: { + lastExternalValue: state.lastExternalValue, + pendingExternalValue: nextValue, + }, + applyValue: null, + } + } + return { + state: { + lastExternalValue: nextValue, + pendingExternalValue: null, + }, + applyValue: nextValue, + } +} diff --git a/packages/worker/client/routes/record-table.node.test.ts b/packages/worker/client/routes/record-table.node.test.ts index 7c4b12e0f5..a16a7700c6 100644 --- a/packages/worker/client/routes/record-table.node.test.ts +++ b/packages/worker/client/routes/record-table.node.test.ts @@ -6,6 +6,10 @@ import { RecordTableSearch, type RecordTableColumn, } from '#client/routes/record-table.tsx' +import { + acknowledgeRecordTableSearchInput, + reconcileRecordTableSearchExternalValue, +} from '#client/routes/record-table-search-sync.ts' const columns: Array = [ { key: 'name', label: 'Name', primary: true }, @@ -154,6 +158,55 @@ test('record table search stays uncontrolled so the first keystroke cannot remou expect(html).toContain('display: none') }) +test('record table search defers focused URL updates and drops a stale pending string', () => { + const empty = { lastExternalValue: '', pendingExternalValue: null } + + // A keystroke is user-driven: the coming URL update must not become + // pending, or blur would overwrite whatever the reader typed next. + const typed = acknowledgeRecordTableSearchInput('ab') + expect( + reconcileRecordTableSearchExternalValue(typed, 'ab', true), + ).toEqual({ + state: { lastExternalValue: 'ab', pendingExternalValue: null }, + applyValue: null, + }) + + // Clearing the field returns `q` to the last applied empty string. + // Without acknowledging the keystroke, pending would stay "ab" and + // blur would write that stale text back. + const cleared = acknowledgeRecordTableSearchInput('') + expect( + reconcileRecordTableSearchExternalValue(cleared, '', true), + ).toEqual({ + state: empty, + applyValue: null, + }) + expect( + reconcileRecordTableSearchExternalValue( + { lastExternalValue: '', pendingExternalValue: 'ab' }, + '', + true, + ), + ).toEqual({ + state: empty, + applyValue: null, + }) + + // Back-button while focused defers until blur; unfocused applies now. + expect( + reconcileRecordTableSearchExternalValue(typed, '', true), + ).toEqual({ + state: { lastExternalValue: 'ab', pendingExternalValue: '' }, + applyValue: null, + }) + expect( + reconcileRecordTableSearchExternalValue(typed, '', false), + ).toEqual({ + state: empty, + applyValue: '', + }) +}) + test('record table empty and busy states keep toolbar layout stable', async () => { const emptyHtml = await renderToString( jsx(RecordTable, { diff --git a/packages/worker/client/routes/record-table.tsx b/packages/worker/client/routes/record-table.tsx index 44e4d36218..3e6ea708f1 100644 --- a/packages/worker/client/routes/record-table.tsx +++ b/packages/worker/client/routes/record-table.tsx @@ -14,6 +14,11 @@ import { getSurfaceCardCss, hoverMq, } from '#universal/styles/style-primitives.ts' +import { + acknowledgeRecordTableSearchInput, + reconcileRecordTableSearchExternalValue, + type RecordTableSearchSync, +} from './record-table-search-sync.ts' /* * The account and admin list/detail screens, as one table. @@ -321,7 +326,10 @@ const footerCss = { * previous (empty) query, and WebKit's search cancel control appears on that * same keystroke — either one drops focus and the reader has to click back in. * The field stays uncontrolled. URL/back-button updates apply immediately - * when it is not focused, and on blur if they arrived while it was. + * when it is not focused, and on blur if they arrived while it was. A + * keystroke records the typed string as the last applied value so a later + * render that returns `q` to that value (clear, or back to the same query) + * cannot leave a stale pending string for blur to write back. */ const searchCancelHiddenCss = { '&::-webkit-search-decoration': { @@ -353,24 +361,26 @@ export function RecordTableSearch( ) { let input: HTMLInputElement | null = null const initialValue = handle.props.value - let lastExternalValue = initialValue - let pendingExternalValue: string | null = null + let sync: RecordTableSearchSync = { + lastExternalValue: initialValue, + pendingExternalValue: null, + } function applyExternalValue(nextValue: string) { if (input) input.value = nextValue - lastExternalValue = nextValue - pendingExternalValue = null + sync = acknowledgeRecordTableSearchInput(nextValue) } return () => { const nextValue = handle.props.value - if (nextValue !== lastExternalValue) { - if (input && document.activeElement === input) { - pendingExternalValue = nextValue - } else { - applyExternalValue(nextValue) - } - } + const focused = Boolean(input && document.activeElement === input) + const reconciled = reconcileRecordTableSearchExternalValue( + sync, + nextValue, + focused, + ) + sync = reconciled.state + if (reconciled.applyValue !== null) applyExternalValue(reconciled.applyValue) return ( - handle.props.onInput( - (event.currentTarget as HTMLInputElement).value, - ), - ), + on('input', (event) => { + const value = (event.currentTarget as HTMLInputElement).value + sync = acknowledgeRecordTableSearchInput(value) + handle.props.onInput(value) + }), on('blur', () => { - if (pendingExternalValue !== null) { - applyExternalValue(pendingExternalValue) + if (sync.pendingExternalValue !== null) { + applyExternalValue(sync.pendingExternalValue) } }), css({ From 1929e1a534bc780dc036aa17add0fd549a31c444 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 03:49:08 +0000 Subject: [PATCH 4/4] Format record-table search sync files Co-authored-by: Kent C. Dodds --- .../client/routes/record-table.node.test.ts | 16 ++++------------ packages/worker/client/routes/record-table.tsx | 3 ++- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/worker/client/routes/record-table.node.test.ts b/packages/worker/client/routes/record-table.node.test.ts index a16a7700c6..65810027f8 100644 --- a/packages/worker/client/routes/record-table.node.test.ts +++ b/packages/worker/client/routes/record-table.node.test.ts @@ -164,9 +164,7 @@ test('record table search defers focused URL updates and drops a stale pending s // A keystroke is user-driven: the coming URL update must not become // pending, or blur would overwrite whatever the reader typed next. const typed = acknowledgeRecordTableSearchInput('ab') - expect( - reconcileRecordTableSearchExternalValue(typed, 'ab', true), - ).toEqual({ + expect(reconcileRecordTableSearchExternalValue(typed, 'ab', true)).toEqual({ state: { lastExternalValue: 'ab', pendingExternalValue: null }, applyValue: null, }) @@ -175,9 +173,7 @@ test('record table search defers focused URL updates and drops a stale pending s // Without acknowledging the keystroke, pending would stay "ab" and // blur would write that stale text back. const cleared = acknowledgeRecordTableSearchInput('') - expect( - reconcileRecordTableSearchExternalValue(cleared, '', true), - ).toEqual({ + expect(reconcileRecordTableSearchExternalValue(cleared, '', true)).toEqual({ state: empty, applyValue: null, }) @@ -193,15 +189,11 @@ test('record table search defers focused URL updates and drops a stale pending s }) // Back-button while focused defers until blur; unfocused applies now. - expect( - reconcileRecordTableSearchExternalValue(typed, '', true), - ).toEqual({ + expect(reconcileRecordTableSearchExternalValue(typed, '', true)).toEqual({ state: { lastExternalValue: 'ab', pendingExternalValue: '' }, applyValue: null, }) - expect( - reconcileRecordTableSearchExternalValue(typed, '', false), - ).toEqual({ + expect(reconcileRecordTableSearchExternalValue(typed, '', false)).toEqual({ state: empty, applyValue: '', }) diff --git a/packages/worker/client/routes/record-table.tsx b/packages/worker/client/routes/record-table.tsx index 3e6ea708f1..41d5ddb06e 100644 --- a/packages/worker/client/routes/record-table.tsx +++ b/packages/worker/client/routes/record-table.tsx @@ -380,7 +380,8 @@ export function RecordTableSearch( focused, ) sync = reconciled.state - if (reconciled.applyValue !== null) applyExternalValue(reconciled.applyValue) + if (reconciled.applyValue !== null) + applyExternalValue(reconciled.applyValue) return (