fix(dashboard): mobile keyboard scroll lock, auto-reload hash, ChatView snap

- Add useMobileScrollLock hook using position:fixed body lock (Bootstrap/
  Headless UI/Stripe pattern) to prevent iOS Safari from shifting the
  document and visualViewport when an input inside a fixed-position modal
  is focused. Wire into 15 input-bearing modals plus ChatView, replacing
  ChatView's inline body-overflow effect.
- Widen computeBuildVersion in vite.config.ts to hash the entire app/ tree
  so the version-check poll actually notices rebuilds (FN-3333 follow-up;
  previously only main.tsx and package.json were hashed).
- ChatView: on input blur, suppress keyboard-aware sizing for 450ms while
  reserving mobile-nav-bar space, so the composer snaps to its final
  height in one move instead of crawling down with iOS's keyboard slide
  and then jumping again when the nav bar reappears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-03 19:01:10 -07:00
parent 5bfa6a37d8
commit 4bb3af220e
20 changed files with 345 additions and 24 deletions

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ToastType } from "../hooks/useToast";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import "./AddNodeModal.css";
export interface AddNodeInput {
@@ -59,6 +60,7 @@ function validateInput(input: AddNodeInput): FormErrors {
}
export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeModalProps) {
useMobileScrollLock(isOpen);
const [name, setName] = useState("");
const [type, setType] = useState<"local" | "remote">("local");
const [url, setUrl] = useState("");

View File

@@ -1,5 +1,6 @@
import { useState, useCallback, useEffect, useRef } from "react";
import type { AgentGenerationSpec } from "../api";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import {
startAgentGeneration,
generateAgentSpec,
@@ -37,6 +38,7 @@ export function AgentGenerationModal({
onGenerated,
projectId,
}: AgentGenerationModalProps) {
useMobileScrollLock(isOpen);
const [roleDescription, setRoleDescription] = useState("");
const [view, setView] = useState<ViewState>({ type: "input" });
const [error, setError] = useState<string | null>(null);

View File

@@ -2,6 +2,7 @@ import "./AgentImportModal.css";
import { useState, useRef, useCallback, useEffect } from "react";
import { Upload, FileText, CheckCircle, AlertTriangle, X, Loader2, FolderOpen, Globe, Search, RefreshCw } from "lucide-react";
import { fetchCompanies, type CompanyEntry } from "../api";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
export interface AgentImportModalProps {
isOpen: boolean;
@@ -126,6 +127,7 @@ function parseDirectoryAgentManifest(content: string): DirectoryAgentInput {
* Flow: Input → Preview parsed agents → Import → Show results
*/
export function AgentImportModal({ isOpen, onClose, onImported, projectId }: AgentImportModalProps) {
useMobileScrollLock(isOpen);
const [step, setStep] = useState<ModalStep>("input");
const [inputMethod, setInputMethod] = useState<InputMethod>("paste");
const [manifestContent, setManifestContent] = useState("");

View File

@@ -31,6 +31,7 @@ import { AgentMentionPopup } from "./AgentMentionPopup";
import { FileMentionPopup } from "./FileMentionPopup";
import { useFileMention } from "../hooks/useFileMention";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
export interface ChatViewProps {
projectId?: string;
@@ -784,14 +785,32 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
enabled: isMobile && !!activeSession,
});
const threadKeyboardStyle: CSSProperties =
keyboardOpen
? ({
// Suppresses keyboard-following styles for ~450ms after blur so the
// composer snaps to full height immediately instead of crawling down with
// iOS's keyboard-dismiss animation. See `handleInputBlur` below for where
// this is set, and QuickChatFAB's `suppressVvShrinkRef` for the same trick
// applied to the FAB panel.
const [suppressKeyboardStyle, setSuppressKeyboardStyle] = useState(false);
const suppressKeyboardStyleTimeoutRef = useRef<number | null>(null);
const threadKeyboardStyle: CSSProperties = keyboardOpen
? suppressKeyboardStyle
? // Suppress window: snap composer to the final post-keyboard height
// immediately, but reserve space for the MobileNavBar that is about
// to reappear once App-level keyboardOpen settles to false. Without
// the reservation the composer briefly extends below the nav bar
// position, then jumps up when the nav reappears.
({
"--keyboard-overlap":
"calc(var(--mobile-nav-height, 44px) + env(safe-area-inset-bottom, 0px))",
"--vv-offset-top": "0px",
} as CSSProperties)
: ({
"--keyboard-overlap": `${keyboardOverlap}px`,
"--vv-offset-top": `${viewportOffsetTop}px`,
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
} as CSSProperties)
: {};
: {};
const filteredSkills = useMemo(() => {
const normalizedFilter = skillFilter.trim().toLowerCase();
@@ -858,20 +877,9 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
}, [keyboardOverlap]);
// Lock body scroll on mobile while the keyboard is up so iOS can't shift
// the visual viewport (offsetTop > 0). Avoid forcing window.scrollTo(0, 0),
// which can jump the page when send briefly toggles focus.
useEffect(() => {
if (!isMobile || !keyboardOpen) return;
const html = document.documentElement;
const body = document.body;
const prev = { htmlOverflow: html.style.overflow, bodyOverflow: body.style.overflow };
html.style.overflow = "hidden";
body.style.overflow = "hidden";
return () => {
html.style.overflow = prev.htmlOverflow;
body.style.overflow = prev.bodyOverflow;
};
}, [isMobile, keyboardOpen]);
// the visual viewport (offsetTop > 0). Shared hook also restores
// window.scrollTo(0, 0) on cleanup to recover from any iOS drift.
useMobileScrollLock(isMobile && keyboardOpen);
// Close context menu on outside click
useEffect(() => {
@@ -1326,6 +1334,22 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
return;
}
// Pre-grow the composer ahead of iOS's keyboard-dismiss animation so it
// snaps to full height immediately instead of crawling down with the
// keyboard. The suppress window covers the iOS slide-out duration
// (~250ms with margin) during which visualViewport keeps reporting
// mid-dismiss heights that would otherwise drag the panel back down.
if (isMobile) {
setSuppressKeyboardStyle(true);
if (suppressKeyboardStyleTimeoutRef.current !== null) {
window.clearTimeout(suppressKeyboardStyleTimeoutRef.current);
}
suppressKeyboardStyleTimeoutRef.current = window.setTimeout(() => {
setSuppressKeyboardStyle(false);
suppressKeyboardStyleTimeoutRef.current = null;
}, 450);
}
if (hideSkillMenuTimeoutRef.current !== null) {
window.clearTimeout(hideSkillMenuTimeoutRef.current);
}
@@ -1339,9 +1363,16 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
fileMention.dismissMention();
hideSkillMenuTimeoutRef.current = null;
}, 120);
}, [fileMention, focusComposerInput]);
}, [fileMention, focusComposerInput, isMobile]);
const handleInputFocus = useCallback(() => {
// If the user re-focuses inside the post-blur suppress window, lift it
// immediately so the keyboard-aware sizing kicks back in.
if (suppressKeyboardStyleTimeoutRef.current !== null) {
window.clearTimeout(suppressKeyboardStyleTimeoutRef.current);
suppressKeyboardStyleTimeoutRef.current = null;
}
setSuppressKeyboardStyle(false);
if (hideSkillMenuTimeoutRef.current !== null) {
window.clearTimeout(hideSkillMenuTimeoutRef.current);
hideSkillMenuTimeoutRef.current = null;

View File

@@ -14,6 +14,7 @@ import {
} from "../api";
import { Loader2, RefreshCw, ArrowLeft, GitPullRequest, CircleDot } from "lucide-react";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
interface GitHubImportModalProps {
@@ -30,6 +31,7 @@ const MOBILE_BREAKPOINT = 640;
type TabType = "issues" | "pulls";
export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId }: GitHubImportModalProps) {
useMobileScrollLock(isOpen);
const [owner, setOwner] = useState("");
const [repo, setRepo] = useState("");
const [labels, setLabels] = useState("");

View File

@@ -29,6 +29,7 @@ import {
} from "../api";
import { MessageComposer } from "./MessageComposer";
import type { Agent } from "../api";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { subscribeSse } from "../sse-bus";
// ── Types ─────────────────────────────────────────────────────────────────
@@ -90,6 +91,7 @@ export function MailboxModal({
addToast,
agents = [],
}: MailboxModalProps) {
useMobileScrollLock(isOpen);
const [activeTab, setActiveTab] = useState<MailboxTab>("inbox");
const [inbox, setInbox] = useState<InboxResponse | null>(null);
const [outbox, setOutbox] = useState<OutboxResponse | null>(null);

View File

@@ -29,6 +29,7 @@ import {
import { ConversationHistory } from "./ConversationHistory";
import { useSessionLock } from "../hooks/useSessionLock";
import { useAiSessionSync } from "../hooks/useAiSessionSync";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { getSessionTabId } from "../utils/getSessionTabId";
interface MilestoneSliceInterviewModalProps {
@@ -73,6 +74,7 @@ export function MilestoneSliceInterviewModal({
projectId,
resumeSessionId,
}: MilestoneSliceInterviewModalProps) {
useMobileScrollLock(isOpen);
const [view, setView] = useState<ViewState>({ type: "initial" });
const [error, setError] = useState<string | null>(null);
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);

View File

@@ -49,6 +49,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
import { useSessionLock } from "../hooks/useSessionLock";
import { useAiSessionSync } from "../hooks/useAiSessionSync";
import { useConfirm } from "../hooks/useConfirm";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { getSessionTabId } from "../utils/getSessionTabId";
// Helper functions for model selection
@@ -107,6 +108,7 @@ export function MissionInterviewModal({
initialGoal: initialGoalProp,
resumeSessionId,
}: MissionInterviewModalProps) {
useMobileScrollLock(isOpen);
const [missionGoal, setMissionGoal] = useState("");
const [view, setView] = useState<ViewState>({ type: "initial" });
const [error, setError] = useState<string | null>(null);

View File

@@ -10,6 +10,7 @@ import { useSetupReadiness } from "../hooks/useSetupReadiness";
import { SetupWarningBanner } from "./SetupWarningBanner";
import { TaskForm, type PendingImage } from "./TaskForm";
import { useConfirm } from "../hooks/useConfirm";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useNodes } from "../hooks/useNodes";
interface NewTaskModalProps {
@@ -25,6 +26,7 @@ interface NewTaskModalProps {
export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast, onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) {
const { confirm } = useConfirm();
useMobileScrollLock(isOpen);
const [description, setDescription] = useState("");
const [dependencies, setDependencies] = useState<string[]>([]);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);

View File

@@ -44,6 +44,7 @@ import { useSessionLock } from "../hooks/useSessionLock";
import { useAiSessionSync } from "../hooks/useAiSessionSync";
import { useViewportMode } from "../hooks/useViewportMode";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { getSessionTabId } from "../utils/getSessionTabId";
interface PlanningModeModalProps {
@@ -189,6 +190,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } =
useMobileKeyboard({ enabled: viewportMode === "mobile" });
useMobileScrollLock(viewportMode === "mobile" && isOpen);
const modalKeyboardStyle: CSSProperties = keyboardOpen
? ({

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import type { Task, TaskPriority } from "@fusion/core";
import { fetchTasks } from "../api";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import type { ResearchRunDetail } from "../research-types";
import "./ResearchTaskActionModal.css";
@@ -17,6 +18,7 @@ interface ResearchTaskActionModalProps {
}
export function ResearchTaskActionModal({ open, mode, run, finding, projectId, onClose, onConfirm }: ResearchTaskActionModalProps) {
useMobileScrollLock(open);
const [attachExport, setAttachExport] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");

View File

@@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from "react";
import { getErrorMessage } from "@fusion/core";
import { fetchScripts, addScript, removeScript, type ScriptEntry } from "../api";
import type { ToastType } from "../hooks/useToast";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import {
X,
@@ -44,6 +45,7 @@ function truncateCommand(command: string, maxLength: number = 60): string {
}
export function ScriptsModal({ isOpen, onClose, addToast, projectId, onRunScript }: ScriptsModalProps) {
useMobileScrollLock(isOpen);
const [scripts, setScripts] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);

View File

@@ -39,6 +39,7 @@ import { CustomProvidersSection } from "./CustomProvidersSection";
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
import { appendTokenQuery } from "../auth";
import { useConfirm } from "../hooks/useConfirm";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useNodes } from "../hooks/useNodes";
import { NodeHealthDot } from "./NodeHealthDot";
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
@@ -351,6 +352,7 @@ export function SettingsModal({
onReopenOnboarding,
}: SettingsModalProps) {
const { confirm } = useConfirm();
useMobileScrollLock(true);
const modalRef = useRef<HTMLDivElement>(null);
const settingsContentRef = useRef<HTMLDivElement>(null);
useModalResizePersist(modalRef, true, "fusion:settings-modal-size");

View File

@@ -22,6 +22,7 @@ import { ConversationHistory } from "./ConversationHistory";
import { useSessionLock } from "../hooks/useSessionLock";
import { useAiSessionSync } from "../hooks/useAiSessionSync";
import { useConfirm } from "../hooks/useConfirm";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { getSessionTabId } from "../utils/getSessionTabId";
interface SubtaskBreakdownModalProps {
@@ -72,6 +73,7 @@ function hasDependencyCycle(subtasks: SubtaskItem[]): boolean {
}
export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onTasksCreated, parentTaskId, projectId, resumeSessionId }: SubtaskBreakdownModalProps) {
useMobileScrollLock(isOpen);
const [view, setView] = useState<ViewState>({ type: "initial" });
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([]);
const [conversationHistory, setConversationHistory] = useState<ConversationHistoryEntry[]>([]);

View File

@@ -2,6 +2,7 @@ import "./TaskDetailModal.css";
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft } from "lucide-react";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -2696,6 +2697,7 @@ export function TaskDetailContent({
export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
useModalResizePersist(modalRef, true, "task-detail-modal-size");
useMobileScrollLock(true);
const overlayDismissProps = useOverlayDismiss(onClose);
return (

View File

@@ -3,6 +3,7 @@ import { useEffect } from "react";
import { ListChecks, X } from "lucide-react";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useViewportMode } from "./Header";
import { TodoView } from "./TodoView";
@@ -21,6 +22,7 @@ export function TodoModal({ onClose, projectId, addToast, onPlanningMode }: Todo
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
enabled: isMobile,
});
useMobileScrollLock(isMobile);
const modalKeyboardStyle: React.CSSProperties =
keyboardOpen

View File

@@ -0,0 +1,102 @@
import { renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { _resetLockState, useMobileScrollLock } from "../useMobileScrollLock";
describe("useMobileScrollLock", () => {
let savedInnerWidth: number;
let savedMaxTouchPoints: number;
let savedOntouchstart: typeof window.ontouchstart;
let scrollSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
_resetLockState();
savedInnerWidth = window.innerWidth;
savedMaxTouchPoints = navigator.maxTouchPoints;
savedOntouchstart = window.ontouchstart;
document.documentElement.style.cssText = "";
document.body.style.cssText = "";
scrollSpy = vi.fn();
window.scrollTo = scrollSpy as unknown as typeof window.scrollTo;
Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true });
});
afterEach(() => {
Object.defineProperty(window, "innerWidth", { value: savedInnerWidth, writable: true, configurable: true });
Object.defineProperty(navigator, "maxTouchPoints", { value: savedMaxTouchPoints, configurable: true });
Object.defineProperty(window, "ontouchstart", { value: savedOntouchstart, writable: true, configurable: true });
document.documentElement.style.cssText = "";
document.body.style.cssText = "";
_resetLockState();
});
function makeMobile() {
(window as unknown as { ontouchstart: unknown }).ontouchstart = null;
Object.defineProperty(navigator, "maxTouchPoints", { value: 5, configurable: true });
Object.defineProperty(window, "innerWidth", { value: 375, writable: true, configurable: true });
}
function makeDesktop() {
delete (window as unknown as { ontouchstart?: unknown }).ontouchstart;
Object.defineProperty(navigator, "maxTouchPoints", { value: 0, configurable: true });
Object.defineProperty(window, "innerWidth", { value: 1280, writable: true, configurable: true });
}
it("pins body with position:fixed and overflow:hidden on mobile when enabled", () => {
makeMobile();
Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true });
renderHook(() => useMobileScrollLock(true));
expect(document.body.style.position).toBe("fixed");
expect(document.body.style.top).toBe("-120px");
expect(document.body.style.width).toBe("100%");
expect(document.body.style.overflow).toBe("hidden");
expect(document.documentElement.style.overflow).toBe("hidden");
});
it("does nothing on desktop", () => {
makeDesktop();
renderHook(() => useMobileScrollLock(true));
expect(document.body.style.position).toBe("");
expect(document.documentElement.style.overflow).toBe("");
expect(scrollSpy).not.toHaveBeenCalled();
});
it("restores prior styles and scroll position on cleanup", () => {
makeMobile();
document.body.style.position = "relative";
document.body.style.top = "5px";
Object.defineProperty(window, "scrollY", { value: 240, writable: true, configurable: true });
const { unmount } = renderHook(() => useMobileScrollLock(true));
unmount();
expect(document.body.style.position).toBe("relative");
expect(document.body.style.top).toBe("5px");
expect(document.body.style.overflow).toBe("");
expect(document.documentElement.style.overflow).toBe("");
expect(scrollSpy).toHaveBeenCalledWith(0, 240);
});
it("does not lock when disabled", () => {
makeMobile();
renderHook(() => useMobileScrollLock(false));
expect(document.body.style.position).toBe("");
expect(scrollSpy).not.toHaveBeenCalled();
});
it("reference-counts so an inner unmount does not release an outer lock", () => {
makeMobile();
Object.defineProperty(window, "scrollY", { value: 80, writable: true, configurable: true });
const outer = renderHook(() => useMobileScrollLock(true));
const inner = renderHook(() => useMobileScrollLock(true));
expect(document.body.style.position).toBe("fixed");
inner.unmount();
expect(document.body.style.position).toBe("fixed");
expect(scrollSpy).not.toHaveBeenCalled();
outer.unmount();
expect(document.body.style.position).toBe("");
expect(scrollSpy).toHaveBeenCalledWith(0, 80);
});
});

View File

@@ -0,0 +1,112 @@
import { useEffect } from "react";
function isMobileDevice(): boolean {
if (typeof window === "undefined") return false;
const hasTouchScreen =
"ontouchstart" in window || navigator.maxTouchPoints > 0;
const isNarrow = window.innerWidth <= 768;
return hasTouchScreen && isNarrow;
}
/**
* Reference-counted body scroll lock for fullscreen mobile overlays.
*
* Uses the `position: fixed; top: -scrollY` pattern (the same approach used
* by Bootstrap, Headless UI, and Stripe Elements) instead of just
* `overflow: hidden`. The reason: iOS Safari ignores `overflow: hidden` when
* an input inside a `position: fixed` overlay is focused — it scrolls the
* document to bring the caret above the soft keyboard, and after dismissal
* the document can be left scrolled with `visualViewport.offsetTop > 0`,
* shoving the underlying dashboard (header included) off the top of the
* screen with a matching gap at the bottom.
*
* Pinning `body` with `position: fixed` makes the document genuinely
* unscrollable, so iOS has nothing to do on focus and leaves the visible
* area aligned with the layout viewport.
*
* Reference counting matters because multiple overlays can be open at once
* (e.g. a confirm dialog over a TodoModal) — only the outermost lock should
* actually mutate styles, so an inner unmount doesn't release the lock for
* an outer overlay that is still open.
*/
let lockCount = 0;
let savedStyles: {
htmlOverflow: string;
bodyPosition: string;
bodyTop: string;
bodyLeft: string;
bodyRight: string;
bodyWidth: string;
bodyOverflow: string;
scrollY: number;
} | null = null;
function applyLock(): void {
if (typeof window === "undefined") return;
if (lockCount > 0) {
lockCount += 1;
return;
}
const html = document.documentElement;
const body = document.body;
savedStyles = {
htmlOverflow: html.style.overflow,
bodyPosition: body.style.position,
bodyTop: body.style.top,
bodyLeft: body.style.left,
bodyRight: body.style.right,
bodyWidth: body.style.width,
bodyOverflow: body.style.overflow,
scrollY: window.scrollY,
};
html.style.overflow = "hidden";
body.style.position = "fixed";
body.style.top = `-${savedStyles.scrollY}px`;
body.style.left = "0";
body.style.right = "0";
body.style.width = "100%";
body.style.overflow = "hidden";
lockCount = 1;
}
function releaseLock(): void {
if (typeof window === "undefined") return;
if (lockCount === 0) return;
lockCount -= 1;
if (lockCount > 0 || !savedStyles) return;
const html = document.documentElement;
const body = document.body;
const { htmlOverflow, bodyPosition, bodyTop, bodyLeft, bodyRight, bodyWidth, bodyOverflow, scrollY } = savedStyles;
html.style.overflow = htmlOverflow;
body.style.position = bodyPosition;
body.style.top = bodyTop;
body.style.left = bodyLeft;
body.style.right = bodyRight;
body.style.width = bodyWidth;
body.style.overflow = bodyOverflow;
savedStyles = null;
// Snap back to where the user was before the lock started. With the body
// un-fixed, this scroll actually applies (vs. being a no-op while the
// body was overflow:hidden).
window.scrollTo(0, scrollY);
}
/** Test-only: reset the module-level lock state. */
export function _resetLockState(): void {
lockCount = 0;
savedStyles = null;
}
/**
* Lock body scroll and pin position while a fullscreen mobile overlay is
* open. Recovers iOS visualViewport drift on cleanup. No-op on desktop.
*/
export function useMobileScrollLock(enabled: boolean): void {
useEffect(() => {
if (!enabled || !isMobileDevice()) return;
applyLock();
return () => {
releaseLock();
};
}, [enabled]);
}

View File

@@ -1,7 +1,7 @@
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import { resolve } from "node:path";
import { writeFileSync, readFileSync } from "node:fs";
import { writeFileSync, readFileSync, readdirSync, statSync } from "node:fs";
import { execSync } from "node:child_process";
import { createHash } from "node:crypto";
@@ -26,16 +26,53 @@ function computeBuildVersion(): string {
}
}
// Content hash of key source files — changes when source changes
const filesToHash = [resolve(__dirname, "app/main.tsx"), resolve(__dirname, "package.json")];
// Content hash of the entire app/ source tree + package.json. Hashing only
// a couple of entry files (the previous behavior) meant that edits to any
// other component or stylesheet produced an identical build version, so the
// dashboard's version-check poll never noticed the new bundle and the
// "reload available" prompt never fired (FN-3333 follow-up).
const hasher = createHash("sha1");
for (const f of filesToHash) {
const appDir = resolve(__dirname, "app");
// Collect, sort, then hash so the order is stable across platforms and runs.
const files: string[] = [];
const walk = (dir: string): void => {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
if (entry === "node_modules" || entry === "__tests__" || entry.startsWith(".")) continue;
const full = resolve(dir, entry);
let info: ReturnType<typeof statSync>;
try {
info = statSync(full);
} catch {
continue;
}
if (info.isDirectory()) {
walk(full);
} else if (info.isFile()) {
files.push(full);
}
}
};
walk(appDir);
files.sort();
for (const f of files) {
try {
hasher.update(f.slice(appDir.length));
hasher.update(readFileSync(f));
} catch {
// file may not exist during certain builds — skip
// file may have been deleted between readdir and read — skip
}
}
try {
hasher.update(readFileSync(resolve(__dirname, "package.json")));
} catch {
// ignore
}
const contentHash = hasher.digest("hex").slice(0, 8);
return `${prefix}-${contentHash}`;