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-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 05429a7..32d981c 100644
--- a/package.json
+++ b/package.json
@@ -19,13 +19,13 @@
"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"
},
"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",
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 {