From a2158b789b6ec0799058c4b3f3b013b9c6fc3d3e Mon Sep 17 00:00:00 2001 From: Matia Raspopovic Date: Wed, 12 Aug 2026 15:27:28 -0400 Subject: [PATCH] fix ios autofocus in drawer + introduce randomUUID fallback for local http serving --- .cursor/rules/mobile-drawer-menus.mdc | 14 ++++++++++- src/app/_components/app-sidebar.tsx | 3 ++- src/app/_components/command-menu.tsx | 3 ++- src/app/_components/document-breadcrumb.tsx | 13 ++++++++-- src/app/_components/mobile-drawer/index.ts | 1 + .../mobile-drawer-field-view.tsx | 25 +++++++++---------- .../mobile-drawer/use-mobile-drawer-stage.ts | 18 ++++++++++--- src/app/_components/mobile-drawer/utils.ts | 11 ++++++++ src/app/_components/welcome-client.tsx | 3 ++- src/app/documents/page.tsx | 3 ++- src/hooks/use-collaborative-doc-crdt.ts | 3 ++- src/lib/avatar-schema.ts | 3 ++- src/lib/utils.ts | 19 ++++++++++++++ 13 files changed, 94 insertions(+), 25 deletions(-) diff --git a/.cursor/rules/mobile-drawer-menus.mdc b/.cursor/rules/mobile-drawer-menus.mdc index eb4beea..4662d8f 100644 --- a/.cursor/rules/mobile-drawer-menus.mdc +++ b/.cursor/rules/mobile-drawer-menus.mdc @@ -32,7 +32,10 @@ Standalone single-field edit (no stack): `MobileFormDrawer`. - **Back** from a field screen: always dismiss via `MobileDrawerFieldView` (default) or `useMobileDrawerLeave()` so the previous view does not resize against a shifting visual viewport. - **Async save then navigate**: `dismissKeyboardOnDone={false}` on `MobileDrawerFieldView`, then `leave(onSaved)` after success (`useMobileDrawerLeave`). -- **Autofocus**: built into `MobileDrawerFieldView` (first `input`/`textarea`). Prefer that over manual focus + timeouts. +- **Autofocus / iOS keyboard**: Safari only opens the keyboard when focus runs in the **same user-gesture turn** as the tap. The kit handles this when you: + - Drill into a `keyboardView` via `stage.goToView` (flushSync + `MobileDrawerFieldView` layout-effect focus), or + - Open a controlled field drawer with `runWithMobileDrawerOpenSync(() => setOpen(true))` then `focusMobileDrawerInput(ref.current)` (see `document-breadcrumb.tsx`). + Do **not** rely on `useEffect`, `requestAnimationFrame`, or animation-complete handlers to open the keyboard. ## Do not @@ -49,6 +52,11 @@ mainMeasureRef.current ?? stageRef.current; // ❌ Navigate back while keyboard is up without wait/blur onBack={stage.returnToView("profile")}; + +// ❌ Deferred focus — focuses the input on device but does not open the keyboard +useEffect(() => { + requestAnimationFrame(() => inputRef.current?.focus()); +}, [open]); ``` ```tsx @@ -59,6 +67,10 @@ useMobileDrawerStage({ view, setView, mainView: "main", keyboardView: KEYBOARD_V stage.returnToView("profile")} onDone={…}> + +// ✅ Controlled single-field open (standalone drawer) +runWithMobileDrawerOpenSync(() => setOpen(true)); +focusMobileDrawerInput(inputRef.current); ``` ## Wiring checklist diff --git a/src/app/_components/app-sidebar.tsx b/src/app/_components/app-sidebar.tsx index 575d3bc..0389678 100644 --- a/src/app/_components/app-sidebar.tsx +++ b/src/app/_components/app-sidebar.tsx @@ -24,6 +24,7 @@ import { MobileActionGroup } from "./mobile-action-rows"; import { useCommandMenuStore } from "~/hooks/use-command-menu"; import { useUserProfile } from "~/hooks/use-user-profile"; import { markDocumentAsNew } from "~/hooks/use-new-document-flag"; +import { randomUUID } from "~/lib/utils"; interface AppSidebarProps extends React.ComponentProps { initialDocuments: { id: string; name: string }[]; @@ -86,7 +87,7 @@ export function AppSidebar({ initialDocuments, ...props }: AppSidebarProps) { // Instant document creation with optimistic sidebar update const handleCreateDocument = React.useCallback(() => { - const newId = crypto.randomUUID(); + const newId = randomUUID(); // Mark as new for the document page markDocumentAsNew(newId); diff --git a/src/app/_components/command-menu.tsx b/src/app/_components/command-menu.tsx index 8aa4b6b..e423871 100644 --- a/src/app/_components/command-menu.tsx +++ b/src/app/_components/command-menu.tsx @@ -17,6 +17,7 @@ import { DialogTitle } from "./dialog"; import { useCommandMenuStore } from "~/hooks/use-command-menu"; import { markDocumentAsNew } from "~/hooks/use-new-document-flag"; import { useTheme } from "next-themes"; +import { randomUUID } from "~/lib/utils"; export function CommandMenu() { const [search, setSearch] = useState(""); @@ -30,7 +31,7 @@ export function CommandMenu() { // Instant document creation - navigate immediately with a new UUID const handleCreateDocument = useCallback(() => { - const newId = crypto.randomUUID(); + const newId = randomUUID(); markDocumentAsNew(newId); router.push(`/documents/${newId}`); closeAll(); diff --git a/src/app/_components/document-breadcrumb.tsx b/src/app/_components/document-breadcrumb.tsx index 9017b14..3953b8c 100644 --- a/src/app/_components/document-breadcrumb.tsx +++ b/src/app/_components/document-breadcrumb.tsx @@ -15,7 +15,11 @@ import { PopoverTrigger, } from "~/app/_components/popover"; import { Input } from "~/app/_components/input"; -import { MobileFormDrawer } from "~/app/_components/mobile-drawer"; +import { + focusMobileDrawerInput, + MobileFormDrawer, + runWithMobileDrawerOpenSync, +} from "~/app/_components/mobile-drawer"; import { useIsMobile } from "~/hooks/use-mobile"; export function DocumentBreadcrumb() { @@ -156,7 +160,12 @@ export function DocumentBreadcrumb() { const openTitleEditor = React.useCallback(() => { setEditingName(document?.document?.name ?? "Untitled"); if (isMobile) { - setDrawerOpen(true); + runWithMobileDrawerOpenSync(() => { + setDrawerOpen(true); + }); + // Controlled open can't recover the gesture after the fact — focus here + // (FieldView layout-effect also runs during the sync mount). + focusMobileDrawerInput(titleInputRef.current); } else { setPopoverOpen(true); } diff --git a/src/app/_components/mobile-drawer/index.ts b/src/app/_components/mobile-drawer/index.ts index 84b11aa..2026303 100644 --- a/src/app/_components/mobile-drawer/index.ts +++ b/src/app/_components/mobile-drawer/index.ts @@ -26,5 +26,6 @@ export { applyMobileDrawerKeyboardInset, focusMobileDrawerInput, resetMobileDrawerKeyboardStyles, + runWithMobileDrawerOpenSync, waitForMobileDrawerKeyboardDismiss, } from "./utils"; diff --git a/src/app/_components/mobile-drawer/mobile-drawer-field-view.tsx b/src/app/_components/mobile-drawer/mobile-drawer-field-view.tsx index 3e7146e..72d9d35 100644 --- a/src/app/_components/mobile-drawer/mobile-drawer-field-view.tsx +++ b/src/app/_components/mobile-drawer/mobile-drawer-field-view.tsx @@ -1,7 +1,7 @@ "use client"; import { - useEffect, + useLayoutEffect, useRef, type FormEvent, type ReactNode, @@ -75,20 +75,19 @@ export function MobileDrawerFieldView({ const rootRef = useRef(null); const leave = useMobileDrawerLeave(); - useEffect(() => { + // useLayoutEffect (not useEffect/rAF): iOS Safari only opens the keyboard when + // focus runs in the same turn as the user gesture. Pair with flushSync when + // mounting this view (see goToView / runWithMobileDrawerOpenSync). + useLayoutEffect(() => { if (!autoFocus) return; - const frame = requestAnimationFrame(() => { - const input = rootRef.current?.querySelector("input, textarea"); - if ( - input instanceof HTMLInputElement || - input instanceof HTMLTextAreaElement - ) { - focusMobileDrawerInput(input); - } - }); - - return () => cancelAnimationFrame(frame); + const input = rootRef.current?.querySelector("input, textarea"); + if ( + input instanceof HTMLInputElement || + input instanceof HTMLTextAreaElement + ) { + focusMobileDrawerInput(input); + } }, [autoFocus]); const handleBack = () => { diff --git a/src/app/_components/mobile-drawer/use-mobile-drawer-stage.ts b/src/app/_components/mobile-drawer/use-mobile-drawer-stage.ts index 13f742a..50caf2a 100644 --- a/src/app/_components/mobile-drawer/use-mobile-drawer-stage.ts +++ b/src/app/_components/mobile-drawer/use-mobile-drawer-stage.ts @@ -7,6 +7,7 @@ import { useRef, useState, } from "react"; +import { flushSync } from "react-dom"; import { MOBILE_DRAWER_KEYBOARD_CLEARANCE_PX, @@ -75,10 +76,21 @@ export function useMobileDrawerStage({ const goToView = useCallback( (next: T, nextDirection: number) => { - setDirection(nextDirection); - setView(next); + const apply = () => { + setDirection(nextDirection); + setView(next); + }; + + // Keyboard fields must mount inside the tap so FieldView's layout-effect + // focus can open the iOS software keyboard. + if (isKeyboardView(next)) { + flushSync(apply); + return; + } + + apply(); }, - [setView], + [isKeyboardView, setView], ); const measureMainStage = useCallback(() => { diff --git a/src/app/_components/mobile-drawer/utils.ts b/src/app/_components/mobile-drawer/utils.ts index 4cbef04..50bb4dd 100644 --- a/src/app/_components/mobile-drawer/utils.ts +++ b/src/app/_components/mobile-drawer/utils.ts @@ -1,5 +1,16 @@ +import { flushSync } from "react-dom"; + import { MOBILE_DRAWER_KEYBOARD_SHELL_EXTRA_PX } from "./constants"; +/** + * Run state updates that mount a keyboard field inside the current user + * gesture. Required for iOS Safari to open the software keyboard — deferred + * focus (useEffect / rAF / animation complete) will not. + */ +export function runWithMobileDrawerOpenSync(update: () => void) { + flushSync(update); +} + /** Clear Vaul inline styles applied while the keyboard was open. */ export function resetMobileDrawerKeyboardStyles() { const drawer = document.querySelector("[data-vaul-drawer]"); diff --git a/src/app/_components/welcome-client.tsx b/src/app/_components/welcome-client.tsx index 3ffce28..016c629 100644 --- a/src/app/_components/welcome-client.tsx +++ b/src/app/_components/welcome-client.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/navigation"; import { Button } from "./ui/button"; import { useCallback } from "react"; import { markDocumentAsNew } from "~/hooks/use-new-document-flag"; +import { randomUUID } from "~/lib/utils"; interface WelcomeClientProps { userName: string; @@ -14,7 +15,7 @@ export function WelcomeClient({ userName }: WelcomeClientProps) { // Instant document creation - navigate immediately with a new UUID const handleGetStarted = useCallback(() => { - const newId = crypto.randomUUID(); + const newId = randomUUID(); markDocumentAsNew(newId); router.push(`/documents/${newId}`); }, [router]); diff --git a/src/app/documents/page.tsx b/src/app/documents/page.tsx index d76cab5..963a22a 100644 --- a/src/app/documents/page.tsx +++ b/src/app/documents/page.tsx @@ -6,13 +6,14 @@ import { useRouter } from "next/navigation"; import { MotionFade } from "~/app/_components/motion-fade"; import { useCallback } from "react"; import { markDocumentAsNew } from "~/hooks/use-new-document-flag"; +import { randomUUID } from "~/lib/utils"; export default function DocumentsPage() { const router = useRouter(); // Instant document creation - navigate immediately with a new UUID const handleCreateDocument = useCallback(() => { - const newId = crypto.randomUUID(); + const newId = randomUUID(); markDocumentAsNew(newId); router.push(`/documents/${newId}`); }, [router]); diff --git a/src/hooks/use-collaborative-doc-crdt.ts b/src/hooks/use-collaborative-doc-crdt.ts index 2381541..d2a115e 100644 --- a/src/hooks/use-collaborative-doc-crdt.ts +++ b/src/hooks/use-collaborative-doc-crdt.ts @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import * as Y from "yjs"; import { WebrtcProvider } from "y-webrtc"; import { api } from "~/trpc/react"; +import { randomUUID } from "~/lib/utils"; interface UseCollaborativeDocCrdtOptions { documentId: string; @@ -22,7 +23,7 @@ interface UseCollaborativeDocCrdtResult { /* -------------------------------- Helpers -------------------------------- */ function generateClientId(): string { - return crypto.randomUUID(); + return randomUUID(); } function base64ToUint8Array(base64: string): Uint8Array { diff --git a/src/lib/avatar-schema.ts b/src/lib/avatar-schema.ts index e01b194..65a03bb 100644 --- a/src/lib/avatar-schema.ts +++ b/src/lib/avatar-schema.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { randomUUID } from "~/lib/utils"; /** * Shared by the avatar picker, the upload helper, and the tRPC user router. @@ -55,7 +56,7 @@ export function validateAvatarFile(file: File): string | null { */ export function buildAvatarPath(userId: string, file: File): string { const extension = EXTENSION_BY_TYPE[file.type] ?? "png"; - return `${userId}/${crypto.randomUUID()}.${extension}`; + return `${userId}/${randomUUID()}.${extension}`; } /** diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391..96fdf5d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,22 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +/** + * UUID that works outside secure contexts (e.g. http://LAN-IP on a phone). + * `crypto.randomUUID` is HTTPS/localhost-only; `getRandomValues` is not. + */ +export function randomUUID(): string { + if (typeof globalThis.crypto?.randomUUID === "function") { + return globalThis.crypto.randomUUID() + } + + const bytes = new Uint8Array(16) + globalThis.crypto.getRandomValues(bytes) + // RFC 4122 version 4 + bytes[6] = (bytes[6]! & 0x0f) | 0x40 + bytes[8] = (bytes[8]! & 0x3f) | 0x80 + + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("") + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +}