From 0bbbc0764596192e2aebfbc44ebdeb78c2e3c1c3 Mon Sep 17 00:00:00 2001 From: markm39 Date: Tue, 13 Oct 2026 07:28:54 -0500 Subject: [PATCH 1/2] fix(editor): smooth writing during autosave and pan/zoom (1.3.5) --- app.json | 4 +- ios/OpenNotes.xcodeproj/project.pbxproj | 8 +- ios/OpenNotes/Info.plist | 4 +- .../NotebookInteractionUITests.swift | 19 ++++ ios/Podfile.lock | 4 +- package.json | 2 +- scripts/autosaveQueue.test.mjs | 88 +++++++++++++++ src/hooks/autosaveQueue.ts | 86 ++++++++++++++ src/hooks/useAutosave.ts | 105 ++++-------------- src/hooks/useViewportTransform.ts | 20 ++-- 10 files changed, 233 insertions(+), 107 deletions(-) create mode 100644 scripts/autosaveQueue.test.mjs create mode 100644 src/hooks/autosaveQueue.ts diff --git a/app.json b/app.json index 80150c3..329e50e 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "OpenNotes", "slug": "open-notes", - "version": "1.3.4", + "version": "1.3.5", "scheme": "opennotes", "orientation": "default", "icon": "./assets/icon.png", @@ -16,7 +16,7 @@ "ios": { "bundleIdentifier": "com.builderpro.opennotes", "icon": "./assets/icon.png", - "buildNumber": "17", + "buildNumber": "18", "supportsTablet": true, "usesIcloudStorage": true, "entitlements": { diff --git a/ios/OpenNotes.xcodeproj/project.pbxproj b/ios/OpenNotes.xcodeproj/project.pbxproj index 993b72b..fff93d8 100644 --- a/ios/OpenNotes.xcodeproj/project.pbxproj +++ b/ios/OpenNotes.xcodeproj/project.pbxproj @@ -454,7 +454,7 @@ CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_TEAM = U2CPXQV7AJ; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -467,7 +467,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.4; + MARKETING_VERSION = 1.3.5; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -493,7 +493,7 @@ CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_TEAM = U2CPXQV7AJ; INFOPLIST_FILE = OpenNotes/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -501,7 +501,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.4; + MARKETING_VERSION = 1.3.5; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/ios/OpenNotes/Info.plist b/ios/OpenNotes/Info.plist index 513a139..0213f89 100644 --- a/ios/OpenNotes/Info.plist +++ b/ios/OpenNotes/Info.plist @@ -34,7 +34,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.3.4 + 1.3.5 CFBundleSignature ???? CFBundleURLTypes @@ -54,7 +54,7 @@ CFBundleVersion - 17 + 18 ITSAppUsesNonExemptEncryption LSMinimumSystemVersion diff --git a/ios/OpenNotesUITests/NotebookInteractionUITests.swift b/ios/OpenNotesUITests/NotebookInteractionUITests.swift index 625f45b..9cd56df 100644 --- a/ios/OpenNotesUITests/NotebookInteractionUITests.swift +++ b/ios/OpenNotesUITests/NotebookInteractionUITests.swift @@ -412,6 +412,25 @@ final class NotebookInteractionUITests: XCTestCase { } } + // Each pause outlasts the 350 ms autosave debounce, so every stroke after the + // first is drawn while the dense 100-page document is being captured. + func testDenseWritingDuringAutosavePerformance() throws { + let app = prepare() + let viewport = element(app, "ink-viewport") + tap(app, "Enable finger drawing") + try measureInteractions(app) { + for row in 0..<6 { + let y = 0.3 + Double(row) * 0.08 + let start = viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.25, dy: y)) + let end = viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.75, dy: y + 0.02)) + start.press(forDuration: 0.05, thenDragTo: end, withVelocity: .default, thenHoldForDuration: 0) + Thread.sleep(forTimeInterval: 0.4) + } + for _ in 0..<6 { tap(app, "Undo") } + } + assertDenseInkVisible(app, context: "Writing during autosave complete") + } + func testDenseScrollAndZoomPerformance() throws { let app = prepare() let viewport = element(app, "ink-viewport") diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 22086ed..6640214 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -259,7 +259,7 @@ PODS: - hermes-engine (0.81.5): - hermes-engine/Pre-built (= 0.81.5) - hermes-engine/Pre-built (0.81.5) - - MathNotesMobileInk (0.3.8): + - MathNotesMobileInk (0.3.9): - React-Core - react-native-skia - RCTDeprecation (0.81.5) @@ -2552,7 +2552,7 @@ SPEC CHECKSUMS: EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734 FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 - MathNotesMobileInk: 84fb87a4ff80b818202db8b5fbb7ac8bbdcd419c + MathNotesMobileInk: aba86894baa524180e07594ec138906806630991 RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990 RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043 RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c diff --git a/package.json b/package.json index 05429a7..e5341ce 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "android": "expo run:android", "test:lifecycle": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/reviewPromptService.test.mjs", "test:catalog": "node --experimental-strip-types --test scripts/catalogStore.test.mjs", - "test": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/reviewPromptService.test.mjs scripts/catalogStore.test.mjs scripts/backupEngine.test.mjs scripts/exportService.test.mjs scripts/overlayTextInsertion.test.mjs scripts/appDocumentUri.test.mjs scripts/crashReport.test.mjs scripts/continuousAssets.test.mjs scripts/overlayHistory.test.mjs scripts/overlayGeometry.test.mjs scripts/nativeCallback.test.mjs", + "test": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/reviewPromptService.test.mjs scripts/catalogStore.test.mjs scripts/backupEngine.test.mjs scripts/exportService.test.mjs scripts/overlayTextInsertion.test.mjs scripts/appDocumentUri.test.mjs scripts/crashReport.test.mjs scripts/continuousAssets.test.mjs scripts/overlayHistory.test.mjs scripts/overlayGeometry.test.mjs scripts/nativeCallback.test.mjs scripts/autosaveQueue.test.mjs", "test:backup": "node --experimental-strip-types --test scripts/backupEngine.test.mjs", "typecheck": "tsc --noEmit -p tsconfig.json" }, diff --git a/scripts/autosaveQueue.test.mjs b/scripts/autosaveQueue.test.mjs new file mode 100644 index 0000000..b77f1b4 --- /dev/null +++ b/scripts/autosaveQueue.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {createAutosaveQueue} from '../src/hooks/autosaveQueue.ts'; + +const wait = ms => new Promise(done => setTimeout(done, ms)); +const superseded = error => error.message === 'Document changed during capture'; +const harness = (save) => { + const statuses = []; + const queue = createAutosaveQueue({save, onStatus: s => statuses.push(s), isEnabled: () => true, debounceMs: 5, + isSuperseded: superseded}); + return {queue, statuses}; +}; + +test('a change during a save waits for it instead of writing concurrently', async () => { + const order = []; + let release; + let calls = 0; + const {queue} = harness(async () => { + const call = ++calls; + order.push(`start ${call}`); + if (call === 1) await new Promise(done => {release = done;}); + order.push(`end ${call}`); + }); + queue.schedule(); + await wait(15); + queue.schedule(); + await wait(15); + assert.deepEqual(order, ['start 1']); + release(); + await queue.flushNow(); + assert.deepEqual(order, ['start 1', 'end 1', 'start 2', 'end 2']); +}); + +test('a save invalidated by newer ink reports pending, then saves the newer ink', async () => { + let calls = 0; + let queueRef; + const {queue, statuses} = harness(async () => { + if (++calls === 1) { + queueRef.schedule(); + throw new Error('Document changed during capture'); + } + }); + queueRef = queue; + queue.schedule(); + await wait(15); + assert.equal(statuses.includes('error'), false); + await wait(15); + await queue.flushNow(); + assert.equal(calls, 2); + assert.equal(statuses.at(-1), 'saved'); +}); + +test('a failure with no newer change is reported and retried on flush', async () => { + let calls = 0; + const {queue, statuses} = harness(async () => { + if (++calls === 1) throw new Error('disk full'); + }); + queue.schedule(); + await wait(15); + assert.equal(statuses.at(-1), 'error'); + assert.equal(await queue.flushNow(), true); + assert.equal(calls, 2); + assert.equal(statuses.at(-1), 'saved'); +}); + +test('a real failure is reported even while newer ink is pending', async () => { + let calls = 0; + let queueRef; + const {queue, statuses} = harness(async () => { + if (++calls === 1) { + queueRef.schedule(); + throw new Error('disk full'); + } + }); + queueRef = queue; + queue.schedule(); + await wait(15); + assert.ok(statuses.includes('error')); + await queue.flushNow(); + assert.equal(statuses.at(-1), 'saved'); +}); + +test('flush without changes does not save', async () => { + let calls = 0; + const {queue} = harness(async () => { calls++; }); + assert.equal(await queue.flushNow(), true); + assert.equal(calls, 0); +}); diff --git a/src/hooks/autosaveQueue.ts b/src/hooks/autosaveQueue.ts new file mode 100644 index 0000000..7da672f --- /dev/null +++ b/src/hooks/autosaveQueue.ts @@ -0,0 +1,86 @@ +export type AutosaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error'; + +export interface AutosaveQueueOptions { + save: () => Promise; + onStatus: (status: AutosaveStatus) => void; + isEnabled: () => boolean; + debounceMs: number; + onError?: (error: unknown) => void; + /** True when newer edits invalidated the save; their own schedule retries it. */ + isSuperseded?: (error: unknown) => boolean; +} + +/** + * Debounced saves that never overlap. A save captures the document first and + * writes it last, so two concurrent saves could land out of order and let an + * older capture overwrite newer ink on disk. + */ +export function createAutosaveQueue({ save, onStatus, isEnabled, debounceMs, onError, isSuperseded }: AutosaveQueueOptions) { + let timer: ReturnType | null = null; + let inFlight: Promise | null = null; + let dirty = false; + + const clearTimer = () => { + if (timer) clearTimeout(timer); + timer = null; + }; + + const runSave = async (): Promise => { + if (!isEnabled()) return true; + // Cleared before the save so changes made while it runs mark dirty again. + dirty = false; + onStatus('saving'); + try { + await save(); + onStatus(dirty ? 'pending' : 'saved'); + return true; + } catch (error) { + dirty = true; + if (isSuperseded?.(error)) { + onStatus('pending'); + return false; + } + onError?.(error); + onStatus('error'); + return false; + } + }; + + const start = (): Promise => { + const previous = inFlight; + const promise = (async () => { + if (previous) await previous; + if (!dirty) return true; + return runSave(); + })(); + inFlight = promise; + void promise.finally(() => { + if (inFlight === promise) inFlight = null; + }); + return promise; + }; + + return { + schedule() { + if (!isEnabled()) return; + dirty = true; + onStatus('pending'); + clearTimer(); + timer = setTimeout(() => { + timer = null; + void start(); + }, debounceMs); + }, + flushNow(): Promise { + clearTimer(); + return start(); + }, + cancelPending() { + clearTimer(); + onStatus('idle'); + }, + dispose() { + clearTimer(); + }, + }; +} diff --git a/src/hooks/useAutosave.ts b/src/hooks/useAutosave.ts index 89efe70..433c08c 100644 --- a/src/hooks/useAutosave.ts +++ b/src/hooks/useAutosave.ts @@ -1,9 +1,11 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { AppState } from 'react-native'; +import { isDocumentCaptureSuperseded } from '@mathnotes/mobile-ink'; +import { createAutosaveQueue, type AutosaveStatus } from './autosaveQueue'; -const DEFAULT_DEBOUNCE_MS = 350; +export type { AutosaveStatus } from './autosaveQueue'; -export type AutosaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error'; +const DEFAULT_DEBOUNCE_MS = 350; export interface UseAutosaveOptions { onSave: () => Promise; @@ -18,79 +20,21 @@ export function useAutosave({ debounceMs = DEFAULT_DEBOUNCE_MS, enabled = true, }: UseAutosaveOptions) { - const timerRef = useRef | null>(null); - const inFlightRef = useRef | null>(null); - const dirtyRef = useRef(false); const onSaveRef = useRef(onSave); const onStatusRef = useRef(onStatusChange); const enabledRef = useRef(enabled); - - useEffect(() => { - onSaveRef.current = onSave; - }, [onSave]); - - useEffect(() => { - onStatusRef.current = onStatusChange; - }, [onStatusChange]); - - useEffect(() => { - enabledRef.current = enabled; - }, [enabled]); - - const runSave = useCallback(async (): Promise => { - if (!enabledRef.current) return true; - // Cleared before the save so changes made while it runs mark dirty again. - dirtyRef.current = false; - onStatusRef.current?.('saving'); - try { - await onSaveRef.current(); - onStatusRef.current?.('saved'); - return true; - } catch (error) { - dirtyRef.current = true; - if (__DEV__) console.warn('[useAutosave] save failed', error); - onStatusRef.current?.('error'); - return false; - } - }, []); - - const flushNow = useCallback(async (): Promise => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - if (inFlightRef.current) { - await inFlightRef.current; - } - if (!dirtyRef.current) return true; - const promise = runSave(); - inFlightRef.current = promise; - return promise.finally(() => { - if (inFlightRef.current === promise) inFlightRef.current = null; - }); - }, [runSave]); - - const cancelPending = useCallback(() => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - onStatusRef.current?.('idle'); - }, []); - - const schedule = useCallback(() => { - if (!enabledRef.current) return; - dirtyRef.current = true; - onStatusRef.current?.('pending'); - if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => { - timerRef.current = null; - const promise = runSave().finally(() => { - if (inFlightRef.current === promise) inFlightRef.current = null; - }); - inFlightRef.current = promise; - }, debounceMs); - }, [debounceMs, runSave]); + onSaveRef.current = onSave; + onStatusRef.current = onStatusChange; + enabledRef.current = enabled; + + const [queue] = useState(() => createAutosaveQueue({ + save: () => onSaveRef.current(), + onStatus: status => onStatusRef.current?.(status), + isEnabled: () => enabledRef.current, + debounceMs, + onError: error => { if (__DEV__) console.warn('[useAutosave] save failed', error); }, + isSuperseded: isDocumentCaptureSuperseded, + })); // Flush the moment the app leaves the foreground: JS timers do not fire in // the background, so a pending debounced save would otherwise sit unsaved @@ -98,20 +42,13 @@ export function useAutosave({ useEffect(() => { const subscription = AppState.addEventListener('change', (state) => { if (state === 'background' || state === 'inactive') { - void flushNow(); + void queue.flushNow(); } }); return () => subscription.remove(); - }, [flushNow]); + }, [queue]); - useEffect(() => { - return () => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - }; - }, []); + useEffect(() => () => queue.dispose(), [queue]); - return { schedule, flushNow, cancelPending }; + return { schedule: queue.schedule, flushNow: queue.flushNow, cancelPending: queue.cancelPending }; } diff --git a/src/hooks/useViewportTransform.ts b/src/hooks/useViewportTransform.ts index 720a093..19c3bb4 100644 --- a/src/hooks/useViewportTransform.ts +++ b/src/hooks/useViewportTransform.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useSyncExternalStore } from 'react'; +import { useCallback, useRef } from 'react'; import type { InfiniteInkViewportTransform } from '@mathnotes/mobile-ink'; export interface ViewportTransformStore { @@ -29,21 +29,17 @@ export function createViewportTransformStore(): ViewportTransformStore { return { transform: current, onTransformChange, subscribe, getSnapshot }; } -export function useViewportTransformStore(): { - store: ViewportTransformStore; - transform: InfiniteInkViewportTransform | null; -} { +/** + * The store receives every viewport frame. It is deliberately not subscribed + * here: the editor screen would otherwise re-render ~60 times a second during + * pan and zoom. Consumers that need frames subscribe to the store directly. + */ +export function useViewportTransformStore(): { store: ViewportTransformStore } { const storeRef = useRef(null); if (!storeRef.current) { storeRef.current = createViewportTransformStore(); } - const store = storeRef.current; - const transform = useSyncExternalStore( - store.subscribe, - store.getSnapshot, - () => null, - ); - return { store, transform }; + return { store: storeRef.current }; } export interface PageRect { From d243697902a80159937e29951e3fd7730960bbce Mon Sep 17 00:00:00 2001 From: markm39 Date: Tue, 13 Oct 2026 11:02:05 -0500 Subject: [PATCH 2/2] chore(deps): pin published mobile ink 0.3.9 --- package-lock.json | 34 +++++++++++++++++++++++++++------- package.json | 2 +- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index d22ef72..4782ebe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@expo/vector-icons": "^15.0.3", - "@mathnotes/mobile-ink": "0.3.8", + "@mathnotes/mobile-ink": "0.3.9", "@react-native-async-storage/async-storage": "2.2.0", "@shopify/react-native-skia": "2.2.12", "expo": "54.0.34", @@ -2565,9 +2565,9 @@ } }, "node_modules/@mathnotes/mobile-ink": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@mathnotes/mobile-ink/-/mobile-ink-0.3.8.tgz", - "integrity": "sha512-cmeSgqHb8TLoJnqbzg6KkCWV75zBM7W+AVT2K2kjNnldXgeJY8FKmpAzAQqTbvAA0gpMI33hchBPqtLly6O0Rg==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mathnotes/mobile-ink/-/mobile-ink-0.3.9.tgz", + "integrity": "sha512-pDPKs/tBIgjGyAkveBui2jGXkc4KLWl1gOrNT4VWaicrmV2Yw6NOEIxWHHtUXJVKO1Tm+/VWvsmHTjlmSS9uyA==", "license": "Apache-2.0", "peerDependencies": { "@shopify/react-native-skia": ">=2.0.0", @@ -3526,7 +3526,7 @@ "version": "19.1.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -4546,7 +4546,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -7697,6 +7697,26 @@ } } }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-dom/node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT", + "peer": true + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -8921,7 +8941,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index e5341ce..32d981c 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "@expo/vector-icons": "^15.0.3", - "@mathnotes/mobile-ink": "0.3.8", + "@mathnotes/mobile-ink": "0.3.9", "@react-native-async-storage/async-storage": "2.2.0", "@shopify/react-native-skia": "2.2.12", "expo": "54.0.34",