From f3c915e4e9e758ecceb88f02c62b8e2b005d9b9d Mon Sep 17 00:00:00 2001 From: Amp Date: Fri, 30 Oct 2026 12:52:18 +0000 Subject: [PATCH 1/2] Keep search text typed before the page finishes loading Before hydration the search input accepted typing, then React reset it to its empty initial state. The input is now read-only until a mount effect marks it ready. CI also runs the iPhone (WebKit) project, which reproduces the bug reliably. Amp-Thread-ID: https://ampcode.com/threads/T-01a18b16-89b6-7301-9d0c-e6ff7f681d32 Co-authored-by: Lada Kesseler --- .github/workflows/deploy.yml | 6 +++--- website/app/components/SearchBar.tsx | 6 ++++++ website/tests/e2e/smoke.spec.ts | 13 +++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ddfea55..df12563 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -50,13 +50,13 @@ jobs: run: npm test - name: Install Playwright browsers - run: npx playwright install --with-deps chromium + run: npx playwright install --with-deps chromium webkit - name: Build the Cloudflare site for E2E tests run: npm run build:cloudflare - - name: Run E2E tests on desktop - run: npx playwright test --project=desktop-chrome + - name: Run E2E tests on desktop and iPhone + run: npx playwright test --project=desktop-chrome --project=iphone build: name: Build Static Site diff --git a/website/app/components/SearchBar.tsx b/website/app/components/SearchBar.tsx index aad6b9a..3ba99f8 100644 --- a/website/app/components/SearchBar.tsx +++ b/website/app/components/SearchBar.tsx @@ -22,6 +22,7 @@ const resultOptionId = (resultsListId: string, index: number) => `${resultsListI export default function SearchBar({ patterns, onSelect }: SearchBarProps) { const [searchQuery, setSearchQuery] = useState('') + const [isReadyForTyping, setIsReadyForTyping] = useState(false) const [isOpen, setIsOpen] = useState(false) const [selectedIndex, setSelectedIndex] = useState(-1) const [filteredPatterns, setFilteredPatterns] = useState({ @@ -37,6 +38,10 @@ export default function SearchBar({ patterns, onSelect }: SearchBarProps) { const categoryOrder: PatternCategory[] = ['obstacles', 'anti-patterns', 'patterns'] + useEffect(() => { + setIsReadyForTyping(true) + }, []) + useEffect(() => { if (searchQuery.trim() === '') { setFilteredPatterns({ @@ -174,6 +179,7 @@ export default function SearchBar({ patterns, onSelect }: SearchBarProps) { className={styles.searchInput} placeholder="Search patterns..." value={searchQuery} + readOnly={!isReadyForTyping} onChange={e => setSearchQuery(e.target.value)} onKeyDown={handleKeyDown} onBlur={() => setIsOpen(false)} diff --git a/website/tests/e2e/smoke.spec.ts b/website/tests/e2e/smoke.spec.ts index c20d273..9fd4884 100644 --- a/website/tests/e2e/smoke.spec.ts +++ b/website/tests/e2e/smoke.spec.ts @@ -117,6 +117,19 @@ test.describe('Search Functionality', () => { await expect(searchResults(page).getByRole('option').first()).toBeVisible(); }); + test('text typed while the page is still loading is kept', async ({ page }) => { + await page.route('**/_next/static/chunks/**', async route => { + await new Promise(resolve => setTimeout(resolve, 2000)); + await route.continue(); + }); + await page.goto('/', { waitUntil: 'commit' }); + + await searchBox(page).fill('active'); + + await expect(searchResults(page).getByRole('option', { name: 'Active Partner' })).toBeVisible(); + await expect(searchBox(page)).toHaveValue('active'); + }); + test('clicking a search result navigates to correct pattern page', async ({ page }) => { await page.goto('/'); From bd482df5f7a8f567c613eb0e65e4d690a763ce8b Mon Sep 17 00:00:00 2001 From: Amp Date: Fri, 30 Oct 2026 14:29:08 +0000 Subject: [PATCH 2/2] Adopt text typed before hydration instead of blocking it The input stays uncontrolled until hydrated, then the mount effect copies what the reader already typed into state. The early-typing test now types with the keyboard during the delayed load, since fill() waits for an editable input and hid the dropped keystrokes under the readOnly approach. Amp-Thread-ID: https://ampcode.com/threads/T-01a18b16-89b6-7301-9d0c-e6ff7f681d32 Co-authored-by: Lada Kesseler --- website/app/components/SearchBar.tsx | 8 ++++---- website/tests/e2e/smoke.spec.ts | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/website/app/components/SearchBar.tsx b/website/app/components/SearchBar.tsx index 3ba99f8..13f02c4 100644 --- a/website/app/components/SearchBar.tsx +++ b/website/app/components/SearchBar.tsx @@ -22,7 +22,7 @@ const resultOptionId = (resultsListId: string, index: number) => `${resultsListI export default function SearchBar({ patterns, onSelect }: SearchBarProps) { const [searchQuery, setSearchQuery] = useState('') - const [isReadyForTyping, setIsReadyForTyping] = useState(false) + const [isHydrated, setIsHydrated] = useState(false) const [isOpen, setIsOpen] = useState(false) const [selectedIndex, setSelectedIndex] = useState(-1) const [filteredPatterns, setFilteredPatterns] = useState({ @@ -39,7 +39,8 @@ export default function SearchBar({ patterns, onSelect }: SearchBarProps) { const categoryOrder: PatternCategory[] = ['obstacles', 'anti-patterns', 'patterns'] useEffect(() => { - setIsReadyForTyping(true) + setSearchQuery(inputRef.current?.value ?? '') + setIsHydrated(true) }, []) useEffect(() => { @@ -178,8 +179,7 @@ export default function SearchBar({ patterns, onSelect }: SearchBarProps) { aria-activedescendant={activeOptionId} className={styles.searchInput} placeholder="Search patterns..." - value={searchQuery} - readOnly={!isReadyForTyping} + value={isHydrated ? searchQuery : undefined} onChange={e => setSearchQuery(e.target.value)} onKeyDown={handleKeyDown} onBlur={() => setIsOpen(false)} diff --git a/website/tests/e2e/smoke.spec.ts b/website/tests/e2e/smoke.spec.ts index 9fd4884..042ee81 100644 --- a/website/tests/e2e/smoke.spec.ts +++ b/website/tests/e2e/smoke.spec.ts @@ -124,7 +124,8 @@ test.describe('Search Functionality', () => { }); await page.goto('/', { waitUntil: 'commit' }); - await searchBox(page).fill('active'); + await searchBox(page).focus(); + await page.keyboard.type('active'); await expect(searchResults(page).getByRole('option', { name: 'Active Partner' })).toBeVisible(); await expect(searchBox(page)).toHaveValue('active');