From 494cce5e849a30fcfccd5decdafbef73bc876d9a Mon Sep 17 00:00:00 2001 From: tianma-if Date: Thu, 6 Aug 2026 17:41:30 +0800 Subject: [PATCH] fix(mobile): harden iPadOS 26.5 paths against Fabric/WebView crashes Detail preview now uses static HTML WebView instead of Dom TipTap. Stagger TextInput and editor host mount/teardown for edit, Me, create, and back so Fabric ShadowNode measure and Hermes GC no longer SIGSEGV on iPadOS 26.5. --- apps/mobile/app.json | 4 +- apps/mobile/package.json | 2 + apps/mobile/src/components/LocalizedText.tsx | 24 +- apps/mobile/src/lib/mobile-markdown-html.ts | 175 ++++++ .../src/screens/WorkspaceMemoDetail.tsx | 587 ++++-------------- .../mobile/src/screens/WorkspaceNotesView.tsx | 88 ++- apps/mobile/src/screens/WorkspaceScreen.tsx | 575 ++++++++++++----- bun.lock | 20 +- docs/mobile-native-parity.md | 2 +- tests/mobile-app-scope.test.ts | 38 +- 10 files changed, 850 insertions(+), 665 deletions(-) create mode 100644 apps/mobile/src/lib/mobile-markdown-html.ts diff --git a/apps/mobile/app.json b/apps/mobile/app.json index d58fc4d7..aa973e7f 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -18,10 +18,10 @@ "icon": "./assets/icon.png", "orientation": "default", "userInterfaceStyle": "light", - "newArchEnabled": true, + "newArchEnabled": false, "ios": { "bundleIdentifier": "org.edgeever.mobile", - "supportsTablet": false, + "supportsTablet": true, "config": { "usesNonExemptEncryption": false }, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index d1f9e4b0..b9aeddc6 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -40,6 +40,7 @@ "expo-sqlite": "~57.0.1", "expo-status-bar": "latest", "expo-updates": "~57.0.8", + "markdown-it": "10.0.0", "mermaid": "^11.16.0", "react": "19.2.3", "react-dom": "19.2.3", @@ -56,6 +57,7 @@ "semver": "^7.8.5" }, "devDependencies": { + "@types/markdown-it": "^14.1.2", "@types/react": "latest", "@types/semver": "^7.7.1", "babel-preset-expo": "latest", diff --git a/apps/mobile/src/components/LocalizedText.tsx b/apps/mobile/src/components/LocalizedText.tsx index 99a836ae..02cc9f4b 100644 --- a/apps/mobile/src/components/LocalizedText.tsx +++ b/apps/mobile/src/components/LocalizedText.tsx @@ -1,16 +1,31 @@ import { Children, forwardRef, isValidElement, type ComponentRef, type ReactNode } from "react"; import { Alert as NativeAlert, + Platform, Pressable as NativePressable, + StyleSheet, Text as NativeText, TextInput as NativeTextInput, type PressableProps, type TextInputProps, type TextProps, + type TextStyle, } from "react-native"; import { translateCurrentMobileText, useMobileLocale } from "../lib/mobile-locale"; import { presentAppDialog } from "./app-dialog-controller"; +/** + * iPadOS 26.5 + Fabric: measuring TextInput with the variable system font + * (UIFont systemFontOfSize:weight: → CopyVariationAxes) can SIGSEGV. Force a + * non-variable UIKit face and drop weight so layout never hits that path. + */ +const IOS_SAFE_TEXT_INPUT_STYLE: TextStyle = Platform.OS === "ios" + ? { + fontFamily: "Helvetica", + fontWeight: "400", + } + : {}; + const translateChildren = (children: ReactNode, translate: (value: string) => string): ReactNode => Children.map(children, (child) => { if (typeof child === "string") { @@ -38,15 +53,22 @@ export const Text = forwardRef, TextProps>(({ ch Text.displayName = "LocalizedText"; -export const TextInput = forwardRef, TextInputProps>(({ accessibilityHint, accessibilityLabel, placeholder, ...props }, ref) => { +export const TextInput = forwardRef, TextInputProps>(({ accessibilityHint, accessibilityLabel, placeholder, value, defaultValue, style, ...props }, ref) => { const { translate } = useMobileLocale(); + // Fabric TextInput measure on iPadOS 26.5 corrupts when value is non-string. + const safeValue = value === undefined || value === null ? value : String(value); + const safeDefault = defaultValue === undefined || defaultValue === null ? defaultValue : String(defaultValue); return ( ); }); diff --git a/apps/mobile/src/lib/mobile-markdown-html.ts b/apps/mobile/src/lib/mobile-markdown-html.ts new file mode 100644 index 00000000..70226a17 --- /dev/null +++ b/apps/mobile/src/lib/mobile-markdown-html.ts @@ -0,0 +1,175 @@ +import MarkdownIt from "markdown-it"; + +const markdown = new MarkdownIt({ + breaks: false, + html: false, + linkify: true, + typographer: false, +}); + +const lightCss = ` + :root { + color-scheme: light; + --bg: #ffffff; + --fg: #0f172a; + --muted: #64748b; + --border: #dedede; + --th-bg: #f2f2f2; + --quote-bg: #f8fafc; + --quote-border: #94a3b8; + --code-bg: #f1f5f9; + --code-fg: #334155; + --pre-bg: #0f172a; + --pre-fg: #e2e8f0; + --link: #059669; + --hr: #e2e8f0; + } +`; + +const darkCss = ` + :root { + color-scheme: dark; + --bg: #0f172a; + --fg: #f8fafc; + --muted: #94a3b8; + --border: #334155; + --th-bg: #1e293b; + --quote-bg: #1e293b; + --quote-border: #64748b; + --code-bg: #1e293b; + --code-fg: #e2e8f0; + --pre-bg: #020617; + --pre-fg: #e2e8f0; + --link: #6ee7b7; + --hr: #334155; + } +`; + +const sharedCss = ` + * { box-sizing: border-box; } + html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--fg); + font: 400 16px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + -webkit-text-size-adjust: 100%; + word-wrap: break-word; + } + body { padding: 4px 2px 48px; } + h1, h2, h3, h4, h5, h6 { + line-height: 1.3; + margin: 1.1em 0 0.45em; + font-weight: 800; + } + h1 { font-size: 1.6rem; } + h2 { font-size: 1.3rem; } + h3 { font-size: 1.12rem; } + h4, h5, h6 { font-size: 1rem; } + p { margin: 0 0 0.85em; } + strong { font-weight: 800; } + em { font-style: italic; } + a { color: var(--link); text-decoration: none; } + blockquote { + margin: 0.8em 0; + padding: 0.55em 0.9em; + border-left: 3px solid var(--quote-border); + background: var(--quote-bg); + color: var(--fg); + } + blockquote p:last-child { margin-bottom: 0; } + ul, ol { margin: 0.5em 0 0.9em; padding-left: 1.4em; } + li { margin: 0.25em 0; } + hr { + border: 0; + border-top: 1px solid var(--hr); + margin: 1.1em 0; + } + code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.92em; + background: var(--code-bg); + color: var(--code-fg); + border-radius: 4px; + padding: 0.1em 0.35em; + } + pre { + background: var(--pre-bg); + color: var(--pre-fg); + border-radius: 8px; + padding: 12px; + overflow-x: auto; + margin: 0.8em 0; + } + pre code { + background: transparent; + color: inherit; + padding: 0; + font-size: 0.88em; + } + table { + border-collapse: collapse; + width: 100%; + margin: 0.8em 0 1em; + font-size: 0.95em; + } + th, td { + border: 1px solid var(--border); + padding: 0.45em 0.65em; + text-align: left; + vertical-align: top; + } + th { + background: var(--th-bg); + font-weight: 700; + } + img { + max-width: 100%; + height: auto; + border-radius: 8px; + } +`; + +const escapeHtml = (value: string) => + value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + +/** Render note markdown to a self-contained HTML document for a static WebView (JS off). */ +export const buildMemoDetailHtml = ( + markdownSource: string, + theme: "light" | "dark" = "light", + meta?: { notebookName?: string; tags?: string[]; title?: string } +): string => { + const body = markdown.render(markdownSource || ""); + const title = meta?.title?.trim() ? `

${escapeHtml(meta.title.trim())}

` : ""; + const notebook = meta?.notebookName + ? `
${escapeHtml(meta.notebookName)}${ + meta.tags?.length + ? `${escapeHtml(meta.tags.join(", "))}` + : "" + }

` + : ""; + const metaCss = ` + h1.note-title { font-size: 1.55rem; margin: 0 0 0.55em; } + .note-meta { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; color: var(--muted); font-size: 0.92rem; margin-bottom: 0.75em; } + .note-notebook { font-weight: 600; } + .note-tags { opacity: 0.9; } + hr.note-divider { margin: 0.4em 0 1em; } + `; + return ` + + + + + + +${title}${notebook}${body} +`; +}; diff --git a/apps/mobile/src/screens/WorkspaceMemoDetail.tsx b/apps/mobile/src/screens/WorkspaceMemoDetail.tsx index dd30018b..79472b47 100644 --- a/apps/mobile/src/screens/WorkspaceMemoDetail.tsx +++ b/apps/mobile/src/screens/WorkspaceMemoDetail.tsx @@ -1,277 +1,29 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { resolveMemoContentDoc, type MemoDetail, type TiptapDoc } from "@edgeever/shared"; -import { ActivityIndicator, Image as RNImage, Platform, StyleSheet, View, type ImageStyle, type StyleProp } from "react-native"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { resolveMemoContentMarkdown, type MemoDetail } from "@edgeever/shared"; +import { ActivityIndicator, Platform, StyleSheet, View } from "react-native"; import { Modal } from "react-native"; import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context"; -import { SvgXml } from "react-native-svg"; -import { ChevronDown, ChevronLeft, ChevronRight, History, MoreHorizontal, Pencil, RotateCcw, Search, Share2, Tag, Trash2, X } from "../components/icons"; +import { WebView } from "react-native-webview"; +import { ChevronLeft, ChevronRight, History, MoreHorizontal, Pencil, RotateCcw, Search, Share2, Trash2, X } from "../components/icons"; import { Alert, Pressable, Text, TextInput } from "../components/LocalizedText"; -import LocalTiptapEditor, { type LocalTiptapEditorRef } from "../components/LocalTiptapEditor"; import { MobileResourceActions } from "../components/MobileResourceActions"; -import { SAFE_DOM_WEBVIEW_PROPS } from "../lib/mobile-dom"; import { openMobileResource, - parseMobileResourceTargetJson, saveMobileResourceAs, type MobileResourceTarget, } from "../lib/mobile-attachments"; +import { SAFE_DOM_WEBVIEW_PROPS } from "../lib/mobile-dom"; +import { buildMemoDetailHtml } from "../lib/mobile-markdown-html"; import { useMobileLocale } from "../lib/mobile-locale"; import { useMobileTheme } from "../lib/mobile-theme"; import { useSession } from "../lib/session"; import { beginEditorStartup } from "../lib/startup-performance"; import type { MobileSyncQueueItem } from "../lib/sync-queue"; +import { getTextSearchMatches } from "./workspace-utils"; import { styles } from "./workspace-styles"; const ANDROID_SYSTEM_NAVIGATION_FALLBACK = 48; const DEFAULT_MEMO_TITLE = "无标题笔记"; -const RESOURCE_DATA_URL_CACHE_LIMIT = 32; - -type SessionLike = { baseUrl: string; token: string } | null; -type AuthenticatedImageSource = { - headers?: { Authorization: string }; - uri: string; -}; - -const isProtectedResourceSource = (source: string, session: SessionLike) => { - const baseUrl = session?.baseUrl.replace(/\/+$/, "") ?? ""; - return source.startsWith("/api/v1/resources/") - || Boolean(baseUrl && (source.startsWith(`${baseUrl}/api/v1/resources/`) || source.startsWith("/api/v1/resources/"))); -}; - -/** Ensure protected resource URLs hit the blob route the API serves. */ -const normalizeProtectedResourcePath = (source: string, session: SessionLike) => { - const baseUrl = session?.baseUrl.replace(/\/+$/, "") ?? ""; - let path = source; - if (baseUrl && path.startsWith(`${baseUrl}/`)) { - path = path.slice(baseUrl.length); - } - if (!path.startsWith("/api/v1/resources/")) { - return path; - } - if (/\/blob(?:$|[?#])/.test(path)) { - return path; - } - const match = path.match(/^(\/api\/v1\/resources\/[^/?#]+)/); - return match ? `${match[1]}/blob` : path; -}; - -const getAuthenticatedResourceSource = ( - source: string, - session: SessionLike -): AuthenticatedImageSource => { - const baseUrl = session?.baseUrl.replace(/\/+$/, "") ?? ""; - const uri = source.startsWith("/") && baseUrl ? `${baseUrl}${source}` : source; - const isProtectedResource = isProtectedResourceSource(source, session) - || Boolean(baseUrl && uri.startsWith(`${baseUrl}/api/v1/resources/`)); - - return { - uri, - ...(session?.token && isProtectedResource ? { headers: { Authorization: `Bearer ${session.token}` } } : {}), - }; -}; - -const blobToDataUrl = (blob: Blob) => new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onerror = () => reject(reader.error ?? new Error("资源读取失败")); - reader.onloadend = () => { - if (typeof reader.result === "string") { - resolve(reader.result); - return; - } - reject(new Error("资源读取失败")); - }; - reader.readAsDataURL(blob); -}); - -const resourceDataUrlCache = new Map>(); -const loadProtectedResourceDataUrl = ( - source: string, - session: SessionLike, - getResourceBlob: ((resourceUrl: string) => Promise) | null | undefined -) => { - if (!getResourceBlob || !isProtectedResourceSource(source, session)) { - return Promise.resolve(null); - } - const path = normalizeProtectedResourcePath(source, session); - const cacheKey = `${session?.token ?? ""}\n${path}`; - const cached = resourceDataUrlCache.get(cacheKey); - if (cached) { - return cached; - } - if (resourceDataUrlCache.size >= RESOURCE_DATA_URL_CACHE_LIMIT) { - const oldestKey = resourceDataUrlCache.keys().next().value; - if (oldestKey) { - resourceDataUrlCache.delete(oldestKey); - } - } - const pending = getResourceBlob(path) - .then(blobToDataUrl) - .catch(() => { - resourceDataUrlCache.delete(cacheKey); - return null; - }); - resourceDataUrlCache.set(cacheKey, pending); - return pending; -}; - -type CachedSvgResource = { - aspectRatio: number | null; - xml: string; -}; - -const AUTHENTICATED_SVG_CACHE_LIMIT = 24; -const authenticatedSvgCache = new Map>(); -const getAuthenticatedSvgCacheKey = (source: AuthenticatedImageSource) => - `${source.uri}\n${source.headers?.Authorization ?? ""}`; -const loadAuthenticatedSvg = (source: AuthenticatedImageSource) => { - const cacheKey = getAuthenticatedSvgCacheKey(source); - const cached = authenticatedSvgCache.get(cacheKey); - if (cached) { - return cached; - } - if (authenticatedSvgCache.size >= AUTHENTICATED_SVG_CACHE_LIMIT) { - const oldestKey = authenticatedSvgCache.keys().next().value; - if (oldestKey) { - authenticatedSvgCache.delete(oldestKey); - } - } - const pending = fetch(source.uri, { headers: source.headers }) - .then(async (response) => { - if (!response.ok) { - throw new Error(`Resource request failed with ${response.status}`); - } - if (!response.headers.get("Content-Type")?.toLowerCase().includes("svg")) { - return null; - } - const xml = await response.text(); - const viewBox = xml.match(/viewBox=["']\s*[\d.-]+\s+[\d.-]+\s+([\d.]+)\s+([\d.]+)\s*["']/i); - const width = Number(viewBox?.[1]); - const height = Number(viewBox?.[2]); - return { - aspectRatio: width > 0 && height > 0 ? width / height : null, - xml, - }; - }) - .catch(() => { - authenticatedSvgCache.delete(cacheKey); - return null; - }); - authenticatedSvgCache.set(cacheKey, pending); - return pending; -}; - -const AuthenticatedResourceImage = ({ - alt, - fitAspect = false, - href, - loadResourceBlob, - resizeMode = "cover", - session, - style, -}: { - alt: string; - fitAspect?: boolean; - href: string; - loadResourceBlob?: ((resourceUrl: string) => Promise) | null; - resizeMode?: "center" | "contain" | "cover" | "repeat" | "stretch"; - session: SessionLike; - style: StyleProp; -}) => { - const headerSource = useMemo(() => getAuthenticatedResourceSource(href, session), [href, session]); - const [displaySource, setDisplaySource] = useState(headerSource); - const [aspectRatio, setAspectRatio] = useState(16 / 9); - const [svgXml, setSvgXml] = useState(null); - const svgRequestStartedRef = useRef(false); - const svgSourceKeyRef = useRef(""); - const svgSourceKey = getAuthenticatedSvgCacheKey(displaySource); - const imageStyle = fitAspect ? [style, { aspectRatio, height: undefined, width: "100%" as const }] : style; - - useEffect(() => { - let cancelled = false; - setSvgXml(null); - setAspectRatio(16 / 9); - svgRequestStartedRef.current = false; - setDisplaySource(headerSource); - - void loadProtectedResourceDataUrl(href, session, loadResourceBlob).then((dataUrl) => { - if (cancelled || !dataUrl) { - return; - } - setDisplaySource({ uri: dataUrl }); - }); - - return () => { - cancelled = true; - }; - }, [headerSource, href, loadResourceBlob, session]); - - useEffect(() => { - svgSourceKeyRef.current = svgSourceKey; - const cached = authenticatedSvgCache.get(svgSourceKey); - if (cached) { - svgRequestStartedRef.current = true; - void cached.then((result) => { - if (!result || svgSourceKeyRef.current !== svgSourceKey) { - return; - } - if (result.aspectRatio) { - setAspectRatio(result.aspectRatio); - } - setSvgXml(result.xml); - }); - } - return () => { - if (svgSourceKeyRef.current === svgSourceKey) { - svgSourceKeyRef.current = ""; - } - }; - }, [svgSourceKey]); - - const loadSvgFallback = () => { - if (svgRequestStartedRef.current) { - return; - } - svgRequestStartedRef.current = true; - void loadAuthenticatedSvg(displaySource) - .then((result) => { - if (!result || svgSourceKeyRef.current !== svgSourceKey) { - return; - } - if (result.aspectRatio) { - setAspectRatio(result.aspectRatio); - } - setSvgXml(result.xml); - }); - }; - - if (svgXml) { - return ( - - - - ); - } - - return ( - { - const { height, width } = event.nativeEvent.source; - if (height > 0 && width > 0) { - setAspectRatio(width / height); - } - }} - onError={loadSvgFallback} - resizeMethod={Platform.OS === "android" ? "resize" : "auto"} - resizeMode={resizeMode} - source={displaySource} - style={imageStyle} - /> - ); -}; - const DetailActionSheetItem = ({ danger = false, disabled = false, icon, label, onPress }: { danger?: boolean; disabled?: boolean; icon: ReactNode; label: string; onPress: () => void }) => ( @@ -330,27 +82,16 @@ export const MemoDetailModal = ({ syncStatus: MobileSyncQueueItem["status"] | null; visible: boolean; }) => { - const { client, session } = useSession(); + const { client } = useSession(); const { resolvedTheme } = useMobileTheme(); const { resolvedLocale } = useMobileLocale(); const safeAreaInsets = useSafeAreaInsets(); const [actionsOpen, setActionsOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); - const [searchMatchCount, setSearchMatchCount] = useState(0); const [activeMatchIndex, setActiveMatchIndex] = useState(0); - const [imagePreview, setImagePreview] = useState<{ alt: string; source: string } | null>(null); const [resourceTarget, setResourceTarget] = useState(null); - const [viewerReady, setViewerReady] = useState(false); - const viewerRef = useRef(null); - const resourceDataUrlCacheRef = useRef(new Map>()); - - const baseUrl = session?.baseUrl.replace(/\/+$/, "") ?? ""; - const viewerContent = useMemo( - () => (memo ? resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown) : { type: "doc", content: [{ type: "paragraph" }] }), - [memo] - ); - + const [bodyReady, setBodyReady] = useState(false); const downloadResource = useCallback(async (target: MobileResourceTarget) => { if (!client) throw new Error(resolvedLocale === "en-US" ? "The resource client is unavailable." : "当前无法读取资源。"); try { @@ -374,44 +115,31 @@ export const MemoDetailModal = ({ } }, [client, resolvedLocale]); - const loadViewerResource = useCallback((source: string) => { - if (!client) { - return Promise.resolve(null); + // HTML in a JS-disabled WebView: full markdown (tables/bold/etc.) without Fabric + // ParagraphShadowNode / AttributedString measure crashes on iPadOS 26.5. + // Not Expo Dom TipTap — static HTML only + media capture denied. + const detailText = memo + ? resolveMemoContentMarkdown(memo.contentJson, memo.contentMarkdown) || memo.contentText || "没有正文内容" + : "没有正文内容"; + const detailHtml = useMemo( + () => buildMemoDetailHtml(detailText, resolvedTheme, { + notebookName, + tags: memo?.tags, + title: memo?.title?.trim() || DEFAULT_MEMO_TITLE, + }), + [detailText, memo?.tags, memo?.title, notebookName, resolvedTheme] + ); + const searchMatches = useMemo(() => getTextSearchMatches(detailText, searchQuery), [detailText, searchQuery]); + // Unmount WebView as soon as the modal hides so teardown does not race Me tab layout. + const showBodyWebView = Boolean(visible && memo && !isLoading); + // Close search host when leaving the memo so no TextInput is measured during dismiss. + useEffect(() => { + if (!visible) { + setSearchOpen(false); + setSearchQuery(""); } - const path = normalizeProtectedResourcePath(source, session); - const cached = resourceDataUrlCacheRef.current.get(path); - if (cached) { - return cached; - } - const pending = client.getResourceBlob(path).then(blobToDataUrl).catch(() => null); - resourceDataUrlCacheRef.current.set(path, pending); - return pending; - }, [client, session]); - - const onResourcePress = useCallback(async (targetJson: string) => { - const target = parseMobileResourceTargetJson(targetJson); - if (target) { - setResourceTarget(target); - } - }, []); - - const onImagePreview = useCallback(async (payloadJson: string) => { - try { - const parsed = JSON.parse(payloadJson) as { alt?: unknown; source?: unknown }; - if (typeof parsed.source === "string" && parsed.source) { - setImagePreview({ - alt: typeof parsed.alt === "string" ? parsed.alt : "", - source: parsed.source, - }); - } - } catch { - // Ignore malformed bridge payloads. - } - }, []); - - const searchMatchLabel = searchQuery.trim() - ? `${searchMatchCount > 0 ? activeMatchIndex + 1 : 0}/${searchMatchCount}` - : "0/0"; + }, [visible]); + const searchMatchLabel = searchQuery.trim() ? `${searchMatches.length > 0 ? activeMatchIndex + 1 : 0}/${searchMatches.length}` : "0/0"; const syncStatusLabel = isSaving || syncStatus === "syncing" ? "保存中" : syncStatus === "conflict" @@ -427,28 +155,19 @@ export const MemoDetailModal = ({ ) + 16; useEffect(() => { - setViewerReady(false); - setSearchOpen(false); - setSearchQuery(""); - setSearchMatchCount(0); setActiveMatchIndex(0); - setImagePreview(null); - setResourceTarget(null); - resourceDataUrlCacheRef.current.clear(); - }, [memo?.id]); + }, [detailText, searchQuery]); useEffect(() => { - if (!viewerReady || !searchOpen) { - return; - } - viewerRef.current?.search(searchQuery, activeMatchIndex); - }, [activeMatchIndex, searchOpen, searchQuery, viewerReady]); + setBodyReady(false); + }, [memo?.id]); const moveSearchMatch = (direction: 1 | -1) => { - if (searchMatchCount === 0) { + if (searchMatches.length === 0) { return; } - setActiveMatchIndex((current) => (current + direction + searchMatchCount) % searchMatchCount); + + setActiveMatchIndex((current) => (current + direction + searchMatches.length) % searchMatches.length); }; const closeActionsAndRun = (action: () => void) => { @@ -556,106 +275,61 @@ export const MemoDetailModal = ({ ) : memo ? ( - - - {memo.title?.trim() || DEFAULT_MEMO_TITLE} - - - {notebookName} - + + {searchOpen ? ( + + + + + {searchMatchLabel} - - - - {memo.tags.length ? memo.tags.join(", ") : "添加标签,用逗号分隔"} - + + moveSearchMatch(-1)}> + + + moveSearchMatch(1)}> + + + { + setSearchOpen(false); + setSearchQuery(""); + }}> + + - {searchOpen ? ( - - - - { - setSearchQuery(value); - setActiveMatchIndex(0); - }} - placeholder="在当前笔记内搜索" - placeholderTextColor="#94a3b8" - style={styles.searchInput} - value={searchQuery} - /> - {searchMatchLabel} - - - moveSearchMatch(-1)}> - - - moveSearchMatch(1)}> - - - { - setSearchOpen(false); - setSearchQuery(""); - setSearchMatchCount(0); - setActiveMatchIndex(0); - viewerRef.current?.search("", 0); - }}> - - - - - ) : null} - - - {baseUrl ? ( - { - setViewerReady(true); - }} - onResourcePress={onResourcePress} - onSearchResult={async (count, index) => { - setSearchMatchCount(count); - setActiveMatchIndex(count > 0 ? index : 0); - }} - ref={viewerRef} - theme={resolvedTheme} - /> - ) : ( - - {resolvedLocale === "en-US" ? "Not signed in." : "未登录。"} - - )} - {!viewerReady ? ( - - - ) : null} + + {showBodyWebView ? ( + setBodyReady(true)} + originWhitelist={["*"]} + setSupportMultipleWindows={false} + source={{ html: detailHtml }} + style={[detailBodyStyles.webView, !bodyReady && detailBodyStyles.webViewHidden]} + /> + ) : null} + {bodyReady ? null : ( + + + + )} + ) : ( @@ -692,28 +366,6 @@ export const MemoDetailModal = ({ ) : null} - setImagePreview(null)} transparent visible={Boolean(imagePreview)}> - - {imagePreview ? ( - - ) : null} - setImagePreview(null)} - style={resourceImageStyles.previewClose} - > - - - - setResourceTarget(null)} @@ -734,54 +386,35 @@ export const MemoDetailModal = ({ ); }; -const detailLayoutStyles = StyleSheet.create({ - body: { +const detailBodyStyles = StyleSheet.create({ + container: { flex: 1, minHeight: 0, }, - meta: { - paddingBottom: 0, - paddingHorizontal: 16, - paddingTop: 16, + loadingOverlay: { + ...StyleSheet.absoluteFill, + alignItems: "center", + backgroundColor: "rgba(255,255,255,0.72)", + justifyContent: "center", }, - viewer: { + searchPanel: { + marginHorizontal: 16, + marginTop: 8, + }, + webHost: { backgroundColor: "#ffffff", flex: 1, minHeight: 0, }, - viewerDark: { + webHostDark: { backgroundColor: "#0f172a", }, - viewerLoading: { - ...StyleSheet.absoluteFill, - alignItems: "center", - backgroundColor: "rgba(248,250,252,0.72)", - justifyContent: "center", - top: 120, + webView: { + backgroundColor: "transparent", + flex: 1, + }, + webViewHidden: { + opacity: 0, }, }); -const resourceImageStyles = StyleSheet.create({ - previewBackdrop: { - alignItems: "center", - backgroundColor: "rgba(2,6,23,0.96)", - flex: 1, - justifyContent: "center", - padding: 16, - }, - previewClose: { - alignItems: "center", - backgroundColor: "rgba(15,23,42,0.78)", - borderRadius: 999, - height: 46, - justifyContent: "center", - position: "absolute", - right: 18, - top: 54, - width: 46, - }, - previewImage: { - height: "100%", - width: "100%", - }, -}); diff --git a/apps/mobile/src/screens/WorkspaceNotesView.tsx b/apps/mobile/src/screens/WorkspaceNotesView.tsx index 6fd5adfc..e8d6216f 100644 --- a/apps/mobile/src/screens/WorkspaceNotesView.tsx +++ b/apps/mobile/src/screens/WorkspaceNotesView.tsx @@ -1,9 +1,8 @@ -import { memo, type ReactNode } from "react"; +import { memo, useEffect, useState, type ReactNode } from "react"; import type { MemoFilterMode } from "@edgeever/client"; import type { MemoSummary, Notebook } from "@edgeever/shared"; import { MOBILE_UI_METRICS, toggleMobileMemoFilterMode } from "@edgeever/shared/mobile-ui"; import { ActivityIndicator, FlatList, Platform, RefreshControl, View } from "react-native"; -import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated"; import { Check, ChevronDown, ChevronLeft, MoreHorizontal, Plus, RotateCcw, Search, Sparkles, Tag, X } from "../components/icons"; import { Pressable, Text, TextInput } from "../components/LocalizedText"; import type { MobileBootstrapProgress } from "../lib/local-mirror"; @@ -41,6 +40,8 @@ export const NotesView = ({ onRetry, onSearchTextChange, onSetMemoView, + /** When false, unmount the real TextInput (detail modal / tab switch). */ + searchInputEnabled = true, searchText, totalMemoCount, selectedMemoIds, @@ -70,6 +71,7 @@ export const NotesView = ({ onRetry: () => void; onSearchTextChange: (value: string) => void; onSetMemoView: (memoView: MemoView) => void; + searchInputEnabled?: boolean; searchText: string; totalMemoCount: number; selectionMode: boolean; @@ -77,7 +79,18 @@ export const NotesView = ({ }) => { const { resolvedTheme } = useMobileTheme(); const { preference: localePreference, translate } = useMobileLocale(); + // Fabric on iPadOS 26.5 SIGTRAPs in BaseTextInputShadowNode::measureContent when a + // TextInput stays mounted under a closing memo modal / WebView teardown. Only mount + // the real input while the user is actively searching. + const [searchFocused, setSearchFocused] = useState(false); + const searchHostActive = searchInputEnabled && !selectionMode; + useEffect(() => { + if (!searchHostActive) { + setSearchFocused(false); + } + }, [searchHostActive]); const searchActive = searchText.trim().length > 0; + const showSearchInput = searchHostActive && (searchFocused || searchActive); const filterActive = memoFilterMode !== "all"; const searchStatusLabel = translate("正在搜索"); const searchResultLabel = translate(`${totalMemoCount} 条结果`); @@ -124,19 +137,45 @@ export const NotesView = ({ - + {showSearchInput ? ( + { + if (!searchText.trim()) { + setSearchFocused(false); + } + }} + onChangeText={onSearchTextChange} + placeholder="搜索笔记" + placeholderTextColor="#94a3b8" + returnKeyType="search" + style={[styles.mobileSearchInput, searchActive && resolvedTheme === "dark" && styles.mobileSearchInputActiveDark]} + value={searchText} + /> + ) : ( + setSearchFocused(true)} + style={[styles.mobileSearchInput, { justifyContent: "center" }]} + > + 搜索笔记 + + )} {searchText ? ( - onSearchTextChange("")} style={styles.mobileSearchClearButton}> + { + onSearchTextChange(""); + setSearchFocused(false); + }} + style={styles.mobileSearchClearButton} + > ) : null} @@ -395,21 +434,16 @@ const MemoCard = memo(function MemoCard({ }) { const localePreference = useMobileLocale().preference; const memoTitle = memo.title?.trim() || DEFAULT_MEMO_TITLE; - const pressScale = useSharedValue(1); - const pressAnimatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: pressScale.value }], - })); - // Avoid Reanimated layout/entering transitions on list cards: concurrent - // Fabric text measurement on iPad (compatibility mode / wide width) crashed - // App Review builds with SIGSEGV inside RCTTextLayoutManager. + // Keep memo cards free of Reanimated transforms: concurrent Fabric + // AttributedString/Paragraph measure + animated scale corrupted heap on + // iPadOS 26.5 (SIGTRAP/SIGSEGV in ParagraphShadowNode / RCTTextLayoutManager). return ( - {selectionMode ? ( @@ -431,12 +465,6 @@ const MemoCard = memo(function MemoCard({ delayLongPress={520} onLongPress={onLongPress} onPress={onPress} - onPressIn={() => { - pressScale.value = withTiming(0.985, { duration: 100 }); - }} - onPressOut={() => { - pressScale.value = withTiming(1, { duration: 160 }); - }} style={[styles.memoCardContent, listDensity === "compact" && styles.memoCardContentCompact, selectionMode && styles.memoCardContentWithSelection]} > @@ -461,7 +489,7 @@ const MemoCard = memo(function MemoCard({ ))} - + ); }, (previous, next) => previous.memo === next.memo && diff --git a/apps/mobile/src/screens/WorkspaceScreen.tsx b/apps/mobile/src/screens/WorkspaceScreen.tsx index 63a36876..c31c773e 100644 --- a/apps/mobile/src/screens/WorkspaceScreen.tsx +++ b/apps/mobile/src/screens/WorkspaceScreen.tsx @@ -390,6 +390,29 @@ export const WorkspaceScreen = ({ } }, [memosQuery.data, notebooksQuery.data]); + // Keep list TextInput unmounted while a note is open, while Me/settings is + // open or opening, while the rich editor is opening/open, and for a short + // settle window after close — Fabric TextInput measure races WebView teardown + // on iPadOS 26.5. + const [searchInputEnabled, setSearchInputEnabled] = useState(true); + const [richEditorOpening, setRichEditorOpening] = useState(false); + const [settingsOpening, setSettingsOpening] = useState(false); + const settingsOpenGenerationRef = useRef(0); + useEffect(() => { + if ( + selectedMemoId + || richEditingSession + || richEditorOpening + || settingsOpening + || activeView === "settings" + ) { + setSearchInputEnabled(false); + return; + } + const timer = setTimeout(() => setSearchInputEnabled(true), 500); + return () => clearTimeout(timer); + }, [activeView, richEditingSession, richEditorOpening, selectedMemoId, settingsOpening]); + const refresh = async () => { if (client) { await syncMobileLocalMirror(client, dataScope); @@ -424,6 +447,7 @@ export const WorkspaceScreen = ({ }; const showAllNotes = () => { + setSelectedMemoId(null); setActiveView("notes"); setMemoView("notebook"); setActiveNotebookId(ALL_NOTES_ID); @@ -432,24 +456,57 @@ export const WorkspaceScreen = ({ }; const showTrash = () => { + setSelectedMemoId(null); setMemoView("trash"); setActiveNotebookId(ALL_NOTES_ID); setSearchText(""); clearSelection(); }; - const openSettings = () => { + const openSettings = useCallback(async () => { + // Me crash (16:40): same-frame unmount of NotesView + detail WebView while + // mounting Settings destroyed ShadowNodeFamily under Hermes HadesGC + // (SIGSEGV in ~ShadowNodeFamily on concurrent GC). Sequence instead: + // 1) tear down detail + search TextInput, 2) settle, 3) present Me as Modal + // without unmounting the notes tree. + const generation = ++settingsOpenGenerationRef.current; + setSettingsOpening(true); + setSearchInputEnabled(false); + setSelectedMemoId(null); setSearchText(""); - setActiveView("settings"); - }; + setNotesActionsOpen(false); + setSelectionMode(false); + setSelectedMemoIds(new Set()); + setSelectionMoveOpen(false); + setSelectionMoreOpen(false); + try { + await new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); + await new Promise((resolve) => setTimeout(resolve, 300)); + if (generation !== settingsOpenGenerationRef.current) { + return; + } + setActiveView("settings"); + } finally { + if (generation === settingsOpenGenerationRef.current) { + setSettingsOpening(false); + } + } + }, []); - const closeSettings = () => { + const closeSettings = useCallback(() => { + settingsOpenGenerationRef.current += 1; + setSettingsOpening(false); + setSelectedMemoId(null); setActiveView("notes"); if (memoView === "trash") { setMemoView("notebook"); setActiveNotebookId(ALL_NOTES_ID); } - }; + }, [memoView]); const toggleVisibleSelection = () => { const visibleMemoIds = visibleMemos.map((memo) => memo.id); @@ -485,6 +542,8 @@ export const WorkspaceScreen = ({ const closeRichEditor = () => { const memoId = richEditingSession?.memo.id ?? null; + // Keep list search TextInput off while the editor tree finishes leaving. + setSearchInputEnabled(false); setRichEditingSession(null); setSelectedMemoId(null); if (memoId) { @@ -505,24 +564,42 @@ export const WorkspaceScreen = ({ const openRichEditor = useCallback(async (memo: MemoDetail) => { beginEditorStartup(); - let editingMemo = memo; - const queuedItem = (await listMobileSyncQueueItems(syncQueueScope)).find((item) => item.memoId === memo.id); + // Tear down the detail HTML WebView and keep list TextInput unmounted before + // mounting the editor chrome / Dom TipTap. Simultaneous Fabric TextInput + // measure + WKWebView host creation SIGSEGVs on iPadOS 26.5. + setRichEditorOpening(true); + setSearchInputEnabled(false); + setSelectedMemoId(null); + try { + await new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); + // Wait long enough for detail Modal + static WebView to fully leave the tree. + await new Promise((resolve) => setTimeout(resolve, 280)); - if (!queuedItem && client && !memo.id.startsWith("local:")) { - try { - const response = await client.getMemo(memo.id); - editingMemo = response.memo; - await upsertLocalMemo(dataScope, editingMemo); - queryClient.setQueryData(["mobile", "memo", "notebook", editingMemo.id], { memo: editingMemo }); - queryClient.setQueryData(["mobile", "memo", "trash", editingMemo.id], { memo: editingMemo }); - } catch { - // The local mirror remains editable while offline. + let editingMemo = memo; + const queuedItem = (await listMobileSyncQueueItems(syncQueueScope)).find((item) => item.memoId === memo.id); + + if (!queuedItem && client && !memo.id.startsWith("local:")) { + try { + const response = await client.getMemo(memo.id); + editingMemo = response.memo; + await upsertLocalMemo(dataScope, editingMemo); + queryClient.setQueryData(["mobile", "memo", "notebook", editingMemo.id], { memo: editingMemo }); + queryClient.setQueryData(["mobile", "memo", "trash", editingMemo.id], { memo: editingMemo }); + } catch { + // The local mirror remains editable while offline. + } } - } - const draft = await loadMemoDraft(editingMemo.id); - memoDraftPrefetchRef.current.delete(memo.id); - setRichEditingSession({ draft, memo: editingMemo }); + const draft = await loadMemoDraft(editingMemo.id); + memoDraftPrefetchRef.current.delete(memo.id); + setRichEditingSession({ draft, memo: editingMemo }); + } finally { + setRichEditorOpening(false); + } }, [client, dataScope, loadMemoDraft, queryClient, syncQueueScope]); const memos = useMemo(() => memosQuery.data?.pages.flatMap((page) => page.memos) ?? [], [memosQuery.data]); @@ -533,7 +610,13 @@ export const WorkspaceScreen = ({ const selectedMemoSyncStatus = selectedMemo ? syncQueueItems.find((item) => item.memoId === selectedMemo.id)?.status ?? null : null; - const isRefreshing = notebooksQuery.isFetching || memosQuery.isFetching || searchQuery.isFetching || memoDetailQuery.isFetching; + // Pull-to-refresh must only track list refetches. Using isFetching (or + // memoDetailQuery) starts UIRefreshControl while opening a note or paging, + // and ending that animation during navigation crashes on iPadOS 26.5 + // (SIGSEGV in RCTPullToRefreshViewComponentView / createComponentView). + const isRefreshing = searchActive + ? searchQuery.isRefetching + : notebooksQuery.isRefetching || memosQuery.isRefetching; const selectedMemoIdList = Array.from(selectedMemoIds); const selectedMemos = visibleMemos.filter((memo) => selectedMemoIds.has(memo.id)); const canToggleVisibleSelection = visibleMemos.length > 0; @@ -549,8 +632,15 @@ export const WorkspaceScreen = ({ const canCreateMemo = memoView !== "trash" && Boolean(createMemoNotebookId); const openCreateMemo = () => { beginEditorStartup(); + setSearchInputEnabled(false); + setSelectedMemoId(null); setIncomingClipDraft(null); - setCreateOpen(true); + // Defer Modal open so list layout settles before Dom WKWebView mounts. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setTimeout(() => setCreateOpen(true), 120); + }); + }); }; const openIncomingClipDraft = useCallback((draft: MobileWebClipDraft) => { @@ -1031,50 +1121,56 @@ export const WorkspaceScreen = ({ return ( - {activeView === "notes" ? ( - setNotesActionsOpen(true)} - onOpenNotebookPicker={() => setNotebookPickerOpen(true)} - onMemoPress={handleMemoPress} - onMemoLongPress={(memo) => selectSingleMemo(memo.id)} - onLoadMore={() => { - const query = searchActive ? searchQuery : memosQuery; - if (query.hasNextPage && !query.isFetchingNextPage) { - void query.fetchNextPage(); - } - }} - onRefresh={refresh} - onSearchTextChange={(value) => { - setSearchText(value); - clearSelection(); - }} - onSetMemoView={(nextMemoView) => nextMemoView === "trash" ? showTrash() : showAllNotes()} - searchText={searchText} - totalMemoCount={searchActive - ? searchQuery.data?.pages[0]?.totalCount ?? searchResults.length - : memosQuery.data?.pages[0]?.totalCount ?? memos.length} - selectionMode={selectionMode} - selectedMemoIds={selectedMemoIds} - error={initialMirrorSyncError ?? notebooksQuery.error ?? (searchActive ? searchQuery.error : memosQuery.error)} - isError={Boolean(initialMirrorSyncError) || notebooksQuery.isError || (searchActive ? searchQuery.isError : memosQuery.isError)} - onRetry={retryInitialSync} - /> - ) : null} + {/* Keep NotesView mounted under Me — swapping trees unmounts the whole list + ShadowNode graph in one commit and races Hermes HadesGC on iPadOS 26.5. */} + setNotesActionsOpen(true)} + onOpenNotebookPicker={() => setNotebookPickerOpen(true)} + onMemoPress={handleMemoPress} + onMemoLongPress={(memo) => selectSingleMemo(memo.id)} + onLoadMore={() => { + const query = searchActive ? searchQuery : memosQuery; + if (query.hasNextPage && !query.isFetchingNextPage) { + void query.fetchNextPage(); + } + }} + onRefresh={refresh} + onSearchTextChange={(value) => { + setSearchText(value); + clearSelection(); + }} + onSetMemoView={(nextMemoView) => nextMemoView === "trash" ? showTrash() : showAllNotes()} + searchInputEnabled={searchInputEnabled && activeView === "notes" && !settingsOpening} + searchText={searchText} + totalMemoCount={searchActive + ? searchQuery.data?.pages[0]?.totalCount ?? searchResults.length + : memosQuery.data?.pages[0]?.totalCount ?? memos.length} + selectionMode={selectionMode} + selectedMemoIds={selectedMemoIds} + error={initialMirrorSyncError ?? notebooksQuery.error ?? (searchActive ? searchQuery.error : memosQuery.error)} + isError={Boolean(initialMirrorSyncError) || notebooksQuery.isError || (searchActive ? searchQuery.isError : memosQuery.isError)} + onRetry={retryInitialSync} + /> - {activeView === "settings" ? ( + - ) : null} + } + disabled={settingsOpening} + icon={} label="我的" - onPress={openSettings} + onPress={() => void openSettings()} /> ) : null} @@ -1611,6 +1708,11 @@ const CreateMemoModal = ({ const [draftLoaded, setDraftLoaded] = useState(false); const [dirty, setDirty] = useState(false); const [editorReady, setEditorReady] = useState(false); + // Delay Dom TipTap WKWebView host — cold create on iPadOS 26.5 can SIGTRAP in + // WebProcessPool init when Dom mounts in the same transaction as the Modal. + const [editorHostReady, setEditorHostReady] = useState(false); + const [createTitleFocused, setCreateTitleFocused] = useState(false); + const [createTagsFocused, setCreateTagsFocused] = useState(false); const [imageOperation, setImageOperation] = useState<"idle" | "creating" | "uploading">("idle"); const [resourceTarget, setResourceTarget] = useState(null); const targetNotebookId = notebookId || fallbackNotebookId; @@ -1624,11 +1726,20 @@ const CreateMemoModal = ({ useEffect(() => { if (!visible) { + setEditorHostReady(false); return; } let active = true; setDraftLoaded(false); setEditorReady(false); + setEditorHostReady(false); + setCreateTitleFocused(false); + setCreateTagsFocused(false); + const hostTimer = setTimeout(() => { + if (active) { + setEditorHostReady(true); + } + }, 360); if (initialDraft) { const markdown = initialDraft.contentMarkdown; contentMarkdownRef.current = markdown; @@ -1642,6 +1753,7 @@ const CreateMemoModal = ({ setDraftLoaded(true); return () => { active = false; + clearTimeout(hostTimer); }; } void readMobileNewMemoDraft(dataScope).then((draft) => { @@ -1664,6 +1776,7 @@ const CreateMemoModal = ({ }); return () => { active = false; + clearTimeout(hostTimer); }; }, [dataScope, fallbackNotebookId, initialDraft, visible]); @@ -1889,6 +2002,17 @@ const CreateMemoModal = ({ return; } await flushEditor(); + // Unmount Dom TipTap before the create Modal tears down (same ShadowNode race as edit). + setEditorHostReady(false); + setCreateTitleFocused(false); + setCreateTagsFocused(false); + setEditorReady(false); + await new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); + await new Promise((resolve) => setTimeout(resolve, 280)); createMutation.mutate(); }; @@ -1938,9 +2062,9 @@ const CreateMemoModal = ({ if (target) setResourceTarget(target); }, []); - const editorElement = useMemo(() => draftLoaded && baseUrl ? ( + const editorElement = useMemo(() => draftLoaded && baseUrl && editorHostReady ? ( - ) : null, [baseUrl, draftLoaded, loadEditorResource, resolvedLocale, resolvedTheme, selectResource]); + ) : null, [baseUrl, draftLoaded, editorHostReady, loadEditorResource, resolvedLocale, resolvedTheme, selectResource]); return ( void requestClose()} presentationStyle="fullScreen" visible={visible}> @@ -1999,36 +2123,75 @@ const CreateMemoModal = ({ - { - setTitle(value); - markDirty(); - }} - placeholder={DEFAULT_MEMO_TITLE} - placeholderTextColor="#94a3b8" - style={styles.createMemoTitleInput} - value={title} - /> + {/* Lazy title/tags TextInputs — same iPadOS 26.5 Fabric measure crash as edit. */} + {createTitleFocused && editorReady ? ( + setCreateTitleFocused(false)} + onChangeText={(value) => { + setTitle(value); + markDirty(); + }} + placeholder={DEFAULT_MEMO_TITLE} + placeholderTextColor="#94a3b8" + style={styles.createMemoTitleInput} + value={title} + /> + ) : ( + { + if (editorReady) { + setCreateTitleFocused(true); + } + }} + style={styles.createMemoTitleInput} + > + + {title || DEFAULT_MEMO_TITLE} + + + )} setNotebookPickerOpen(true)} style={styles.createMemoNotebookButton}> {selectedNotebookName} - { - setTagsText(value); - markDirty(); - }} - placeholder="添加标签,用逗号分隔" - placeholderTextColor="#94a3b8" - style={styles.createMemoTagsInput} - value={tagsText} - /> + {createTagsFocused && editorReady ? ( + setCreateTagsFocused(false)} + onChangeText={(value) => { + setTagsText(value); + markDirty(); + }} + placeholder="添加标签,用逗号分隔" + placeholderTextColor="#94a3b8" + style={styles.createMemoTagsInput} + value={tagsText} + /> + ) : ( + { + if (editorReady) { + setCreateTagsFocused(true); + } + }} + style={[styles.createMemoTagsInput, { justifyContent: "center" }]} + > + + {tagsText || "添加标签,用逗号分隔"} + + + )} @@ -2258,6 +2421,7 @@ const RichEditorModal = ({ const flushResolverRef = useRef<(() => void) | null>(null); const savingRef = useRef(false); const uploadingRef = useRef(false); + const closingRef = useRef(false); const [title, setTitle] = useState(resolveEditableMemoTitle(restoredDraft?.title ?? memo?.title)); const [tagsText, setTagsText] = useState(restoredDraft?.tagsText ?? memo?.tags.join(", ") ?? ""); const [notebookId, setNotebookId] = useState(restoredDraft?.notebookId ?? memo?.notebookId ?? ""); @@ -2267,11 +2431,30 @@ const RichEditorModal = ({ const [dirty, setDirty] = useState(Boolean(restoredDraft)); const [saving, setSaving] = useState(false); const [uploading, setUploading] = useState(false); + const [closing, setClosing] = useState(false); const [error, setError] = useState(null); const [startupMs, setStartupMs] = useState(null); const [resourceTarget, setResourceTarget] = useState(null); + // Stagger TextInput chrome and Dom TipTap so Fabric never measures title/tags + // TextInputs in the same commit as WKWebView host creation (iPadOS 26.5 SIGSEGV). + const [chromeReady, setChromeReady] = useState(false); + const [editorHostReady, setEditorHostReady] = useState(false); + const [titleFocused, setTitleFocused] = useState(false); + const [tagsFocused, setTagsFocused] = useState(false); const notebookLabel = notebooks.find((notebook) => notebook.id === notebookId)?.name ?? "未分类"; - const saveLabel = error ? "保存失败" : saving ? "保存中" : uploading ? "上传中" : dirty ? (draftRestored ? "本地草稿" : "未保存") : ready ? "已保存" : "加载中"; + const saveLabel = closing + ? "正在退出" + : error + ? "保存失败" + : saving + ? "保存中" + : uploading + ? "上传中" + : dirty + ? (draftRestored ? "本地草稿" : "未保存") + : ready + ? "已保存" + : "加载中"; const titleRef = useRef(title); const tagsTextRef = useRef(tagsText); const notebookIdRef = useRef(notebookId); @@ -2279,11 +2462,19 @@ const RichEditorModal = ({ tagsTextRef.current = tagsText; notebookIdRef.current = notebookId; - useEffect(() => () => { - if (initialFocusTimerRef.current !== null) { - clearTimeout(initialFocusTimerRef.current); - initialFocusTimerRef.current = null; - } + useEffect(() => { + // Phase 1: header chrome only (Pressable labels, no TextInput). + // Phase 2: Dom TipTap host after layout has settled without native text fields. + const chromeTimer = setTimeout(() => setChromeReady(true), 120); + const editorTimer = setTimeout(() => setEditorHostReady(true), 360); + return () => { + clearTimeout(chromeTimer); + clearTimeout(editorTimer); + if (initialFocusTimerRef.current !== null) { + clearTimeout(initialFocusTimerRef.current); + initialFocusTimerRef.current = null; + } + }; }, []); const persistDraft = async (contentJson: TiptapDoc) => { @@ -2372,17 +2563,44 @@ const RichEditorModal = ({ }; const requestClose = async () => { - if (savingRef.current || uploadingRef.current) { + if (savingRef.current || uploadingRef.current || closingRef.current) { return; } + closingRef.current = true; + setClosing(true); if (initialFocusTimerRef.current !== null) { clearTimeout(initialFocusTimerRef.current); initialFocusTimerRef.current = null; } - await flushEditor(); - const savedMemo = await save(); - if (savedMemo) { + try { + await flushEditor(); + const savedMemo = await save(); + if (!savedMemo && dirtyRef.current) { + // Hard save failure: stay so the user can retry (draft is still local). + closingRef.current = false; + setClosing(false); + return; + } + // 17:06 crash: unmounting Dom TipTap + remounting NotesView in one commit + // double-frees ShadowNodeFamily under Fabric (SIGBUS on JS thread). + // Drop the editor host first, settle, then leave the screen. + setTitleFocused(false); + setTagsFocused(false); + setEditorHostReady(false); + setChromeReady(false); + setReady(false); + setNotebookPickerOpen(false); + setResourceTarget(null); + await new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); + await new Promise((resolve) => setTimeout(resolve, 320)); onClose(); + } catch { + closingRef.current = false; + setClosing(false); } }; @@ -2492,9 +2710,9 @@ const RichEditorModal = ({ }, []); const editorElement = useMemo( - () => memo && baseUrl ? ( + () => memo && baseUrl && editorHostReady ? ( { initialFocusTimerRef.current = null; editorRef.current?.focusEnd(); if (Platform.OS === "android") { showEdgeEverKeyboard(); } - }, 180); + }, 280); }} ref={editorRef} locale={resolvedLocale} theme={resolvedTheme} /> ) : null, - [baseUrl, loadEditorResource, memo?.id, resolvedLocale, resolvedTheme, selectResource] + [baseUrl, editorHostReady, loadEditorResource, memo?.id, resolvedLocale, resolvedTheme, selectResource] ); useEffect(() => { - if (!memo || !dirty) { + if (!memo || !dirty || closingRef.current) { return; } const timeout = setTimeout(() => { + if (closingRef.current) { + return; + } void writeMobileMemoDraft({ memoId: memo.id, expectedRevision: memo.revision, @@ -2551,10 +2773,13 @@ const RichEditorModal = ({ }, [dirty, memo, notebookId, tagsText, title]); useEffect(() => { - if (!memo || !dirty || !ready || savingRef.current || uploadingRef.current) { + if (!memo || !dirty || !ready || savingRef.current || uploadingRef.current || closingRef.current) { return; } const timeout = setTimeout(() => { + if (closingRef.current) { + return; + } void flushEditor().then(save); }, 1200); return () => clearTimeout(timeout); @@ -2563,53 +2788,101 @@ const RichEditorModal = ({ return ( - void requestClose()} style={styles.createMemoBackButton}> - + void requestClose()} style={styles.createMemoBackButton}> + - {saveLabel} + {saveLabel} void requestClose()} - style={[styles.createMemoDoneButton, (saving || uploading || !ready) && styles.createMemoDoneButtonDisabled]} + style={[styles.createMemoDoneButton, (saving || uploading || closing || !ready) && styles.createMemoDoneButtonDisabled]} > - {saving ? : 完成} + {saving || closing ? : 完成} {memo && baseUrl ? ( - { - setTitle(value); - dirtyRef.current = true; - setDirty(true); - }} - placeholder={DEFAULT_MEMO_TITLE} - placeholderTextColor="#94a3b8" - style={styles.createMemoTitleInput} - value={title} - /> + {chromeReady ? ( + // Only mount title TextInput after editor is ready AND user taps — + // cold open of RichEditorModal must stay TextInput-free on iPadOS 26.5. + titleFocused && ready ? ( + setTitleFocused(false)} + onChangeText={(value) => { + setTitle(value); + dirtyRef.current = true; + setDirty(true); + }} + placeholder={DEFAULT_MEMO_TITLE} + placeholderTextColor="#94a3b8" + style={styles.createMemoTitleInput} + value={title} + /> + ) : ( + { + if (ready) { + setTitleFocused(true); + } + }} + style={styles.createMemoTitleInput} + > + + {title || DEFAULT_MEMO_TITLE} + + + ) + ) : ( + + )} setNotebookPickerOpen(true)} style={styles.createMemoNotebookButton}> {notebookLabel} - { - setTagsText(value); - dirtyRef.current = true; - setDirty(true); - }} - placeholder="添加标签,用逗号分隔" - placeholderTextColor="#94a3b8" - style={[styles.createMemoTagsInput, styles.richStandaloneTagsInput]} - value={tagsText} - /> + {chromeReady ? ( + tagsFocused && ready ? ( + setTagsFocused(false)} + onChangeText={(value) => { + setTagsText(value); + dirtyRef.current = true; + setDirty(true); + }} + placeholder="添加标签,用逗号分隔" + placeholderTextColor="#94a3b8" + style={[styles.createMemoTagsInput, styles.richStandaloneTagsInput]} + value={tagsText} + /> + ) : ( + { + if (ready) { + setTagsFocused(true); + } + }} + style={[styles.createMemoTagsInput, styles.richStandaloneTagsInput, { justifyContent: "center" }]} + > + + {tagsText || "添加标签,用逗号分隔"} + + + ) + ) : ( + + )} {draftRestored ? 已恢复上次未完成的本地草稿 : null} @@ -3037,8 +3310,26 @@ const ActionButton = ({ ); -const BottomNavItem = ({ active = false, icon, label, onPress }: { active?: boolean; icon: ReactNode; label: string; onPress: () => void }) => ( - +const BottomNavItem = ({ + active = false, + disabled = false, + icon, + label, + onPress, +}: { + active?: boolean; + disabled?: boolean; + icon: ReactNode; + label: string; + onPress: () => void; +}) => ( + {icon} {label} diff --git a/bun.lock b/bun.lock index ba9e7483..83012384 100644 --- a/bun.lock +++ b/bun.lock @@ -77,7 +77,7 @@ }, "apps/desktop": { "name": "@edgeever/desktop", - "version": "1.11.1", + "version": "1.12.3", "dependencies": { "electron-updater": "^6.6.2", }, @@ -129,6 +129,7 @@ "expo-sqlite": "~57.0.1", "expo-status-bar": "latest", "expo-updates": "~57.0.8", + "markdown-it": "10.0.0", "mermaid": "^11.16.0", "react": "19.2.3", "react-dom": "19.2.3", @@ -145,6 +146,7 @@ "semver": "^7.8.5", }, "devDependencies": { + "@types/markdown-it": "^14.1.2", "@types/react": "latest", "@types/semver": "^7.7.1", "babel-preset-expo": "latest", @@ -727,7 +729,7 @@ "@expo/sudo-prompt": ["@expo/sudo-prompt@9.3.2", "", {}, "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw=="], - "@expo/ui": ["@expo/ui@57.0.8", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0", "vaul": "^1.1.2" }, "peerDependencies": { "@babel/core": "*", "expo": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-worklets": "*" }, "optionalPeers": ["@babel/core", "react-dom", "react-native-worklets"] }, "sha512-aR17CRM45k4JyFahX4Xn2b6ti3aiddC4SAzvsm3iUCVacQA6tGmClu012rYNeVXhJ96csX2o7EEymfzYqgJNxw=="], + "@expo/ui": ["@expo/ui@57.0.9", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0", "vaul": "^1.1.2" }, "peerDependencies": { "@babel/core": "*", "expo": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-worklets": "*" }, "optionalPeers": ["@babel/core", "react-dom", "react-native-worklets"] }, "sha512-VIxvk5ncgylBj2vrIP1iLaMc3XmYucKbf0hIcg3qx9l2anB9JzaYnH7cvVgNU3RfwV8R9m/tA7lX9BP7D8uMQw=="], "@expo/vector-icons": ["@expo/vector-icons@15.1.1", "", { "peerDependencies": { "expo-font": ">=14.0.4", "react": "*", "react-native": "*" } }, "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw=="], @@ -1363,10 +1365,16 @@ "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], + "@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="], + + "@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="], + "@types/marked": ["@types/marked@6.0.0", "", { "dependencies": { "marked": "*" } }, "sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + "@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="], + "@types/mdx": ["@types/mdx@2.0.14", "", {}, "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg=="], "@types/minimatch": ["@types/minimatch@3.0.5", "", {}, "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ=="], @@ -1477,7 +1485,7 @@ "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], @@ -2115,7 +2123,7 @@ "expo-modules-jsi": ["expo-modules-jsi@57.0.3", "", { "peerDependencies": { "react-native": "*" } }, "sha512-B+iXJCC5OIXjFKkLLSY2DZ8BGS+hC3PBTrALpV43pHqhXqqZrRH3uTEylZgsoKQO8N8tjmjAua2ucTFHtr1o0w=="], - "expo-router": ["expo-router@57.0.9", "", { "dependencies": { "@expo/log-box": "^57.0.2", "@expo/metro-runtime": "^57.0.8", "@expo/schema-utils": "^57.0.2", "@expo/ui": "^57.0.8", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "client-only": "^0.0.1", "color": "^4.2.3", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^57.0.1", "expo-server": "^57.0.1", "expo-symbols": "^57.0.1", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-is": "^19.1.0", "react-native-drawer-layout": "^4.2.2", "react-native-screens": "^4.26.0", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "standard-navigation": "^0.0.5", "vaul": "^1.1.2" }, "peerDependencies": { "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^57.0.8", "expo-linking": "^57.0.4", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-jPBwHmKmBfzCLUuKgFf+I/UhFkxvS3J2WF5MQKcuupC8XzQPBAqdzbxmcPIDY3VIVabHw7WnhwzsROWA74t1LA=="], + "expo-router": ["expo-router@57.0.10", "", { "dependencies": { "@expo/log-box": "^57.0.2", "@expo/metro-runtime": "^57.0.8", "@expo/schema-utils": "^57.0.2", "@expo/ui": "^57.0.9", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "client-only": "^0.0.1", "color": "^4.2.3", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^57.0.1", "expo-server": "^57.0.1", "expo-symbols": "^57.0.1", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-is": "^19.1.0", "react-native-drawer-layout": "^4.2.2", "react-native-screens": "^4.26.0", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "standard-navigation": "^0.0.5", "vaul": "^1.1.2" }, "peerDependencies": { "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^57.0.9", "expo-linking": "^57.0.5", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-E6Cjudl4wQ86KdEHqLGhBmfOFRdtzXTtRzEEDmqOvqtkAc9C637u0us7CGKkkee9m+kwuHqfhn779ww85rn/Pg=="], "expo-secure-store": ["expo-secure-store@57.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-tLa1VmSadOq19mA/dwkl99RbHyjLE0T1qqBYMY3/OsguZTI+rlrDy/DDJjupqlVtmr95hD7o1pYqx5aL+B4YMA=="], @@ -4301,6 +4309,8 @@ "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "jwa/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "jws/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -4309,8 +4319,6 @@ "log-symbols/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], - "markdown-it/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "markdown-it/entities": ["entities@2.0.3", "", {}, "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], diff --git a/docs/mobile-native-parity.md b/docs/mobile-native-parity.md index f7dafd03..38f73b22 100644 --- a/docs/mobile-native-parity.md +++ b/docs/mobile-native-parity.md @@ -38,7 +38,7 @@ The App follows the mobile PWA for high-frequency layouts and interactions, but | Memo list, filters, sort, pin, and batch operations | Implemented | | Local-cache search | Implemented | | Local TipTap create/edit and autosave flow | Implemented | -| Note detail body preview via read-only TipTap (same schema as edit) | Implemented | +| Note detail body preview via static HTML WebView markdown (TipTap reserved for edit; avoids iPadOS 26.5 Fabric text crashes) | Implemented | | Attachments, image compression, and resource library | Implemented | | Tag management | Implemented | | Revision history and restore | Implemented | diff --git a/tests/mobile-app-scope.test.ts b/tests/mobile-app-scope.test.ts index ce8e330c..89f1e8e1 100644 --- a/tests/mobile-app-scope.test.ts +++ b/tests/mobile-app-scope.test.ts @@ -65,10 +65,16 @@ describe("mobile app scope", () => { expect(memoDetailSource).toContain("onResolveSyncConflict"); }); - test("renders note detail body with the shared read-only TipTap viewer", () => { - expect(memoDetailSource).toContain('mode="viewer"'); - expect(memoDetailSource).toContain("LocalTiptapEditor"); - expect(memoDetailSource).not.toContain("react-native-markdown-display"); + test("renders note detail markdown via static HTML WebView (not Dom TipTap / Fabric Text)", () => { + // Full markdown (tables/headings/bold) via markdown-it → HTML WebView with JS off. + // Avoids Fabric AttributedString SIGTRAP and Dom TipTap media TCC on iPadOS 26.5. + expect(memoDetailSource).toContain("resolveMemoContentMarkdown"); + expect(memoDetailSource).toContain("buildMemoDetailHtml"); + expect(memoDetailSource).toContain("react-native-webview"); + expect(memoDetailSource).toContain("javaScriptEnabled={false}"); + expect(memoDetailSource).toContain("SAFE_DOM_WEBVIEW_PROPS"); + expect(memoDetailSource).not.toContain('mode="viewer"'); + expect(memoDetailSource).not.toContain("LocalTiptapEditor"); }); test("keeps memo list free of Reanimated layout transitions that crash Fabric text layout", () => { @@ -77,20 +83,40 @@ describe("mobile app scope", () => { expect(notesViewSource).not.toContain("LinearTransition"); expect(notesViewSource).not.toContain("entering="); expect(notesViewSource).not.toContain("layout={LinearTransition"); + expect(notesViewSource).not.toContain("useSharedValue"); + expect(notesViewSource).not.toContain("useAnimatedStyle"); + expect(notesViewSource).not.toContain("react-native-reanimated"); }); test("hardens DOM/WebView hosts against media capture probes during App Review", () => { expect(mobileDomSource).toContain('mediaCapturePermissionGrantType: "deny"'); expect(mobileDomSource).toContain("mediaPlaybackRequiresUserAction: true"); + // Detail body is native Markdown; Dom hosts remain on edit/create/mermaid paths. expect(workspaceSource).toContain("SAFE_DOM_WEBVIEW_PROPS"); - expect(memoDetailSource).toContain("SAFE_DOM_WEBVIEW_PROPS"); }); test("declares iOS privacy strings and full-screen phone-on-iPad presentation", () => { const infoPlist = appJson.expo.ios?.infoPlist ?? {}; - expect(appJson.expo.ios?.supportsTablet).toBe(false); + expect(appJson.expo.ios?.supportsTablet).toBe(true); expect(infoPlist.UIRequiresFullScreen).toBe(true); expect(String(infoPlist.NSMicrophoneUsageDescription ?? "")).toMatch(/microphone/i); expect(String(infoPlist.NSCameraUsageDescription ?? "")).toMatch(/camera/i); }); + + test("keeps New Architecture off to avoid iPadOS 26.5 Fabric text layout crashes", () => { + expect((appJson.expo as { newArchEnabled?: boolean }).newArchEnabled).toBe(false); + }); + + test("keeps list search TextInput unmounted while a note is open", () => { + expect(notesViewSource).toContain("searchInputEnabled"); + expect(notesViewSource).toContain("showSearchInput"); + expect(workspaceSource).toContain("searchInputEnabled={searchInputEnabled"); + expect(workspaceSource).toContain("setSearchInputEnabled(false)"); + }); + + test("keeps pull-to-refresh off memo-detail fetches that crash iPadOS RefreshControl", () => { + expect(workspaceSource).not.toMatch(/isRefreshing\s*=\s*[^;]*memoDetailQuery\.isFetching/); + expect(workspaceSource).toMatch(/isRefreshing\s*=\s*searchActive/); + expect(workspaceSource).toContain("isRefetching"); + }); });