From 5458aa1f662f3b1a621bc6ffeac213c0e87c7e33 Mon Sep 17 00:00:00 2001 From: Hamza Merzic Date: Tue, 21 Jul 2026 12:33:18 +0000 Subject: [PATCH] fix: stop probing a sentinel document --- index.jsx | 2 +- mobius.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src/app.jsx | 16 +++++++--------- test/autosave-deps-stable.test.js | 13 ++++++++++++- test/runtime-integration.test.js | 16 ++++++++++++++++ 7 files changed, 40 insertions(+), 15 deletions(-) diff --git a/index.jsx b/index.jsx index 8eae900..e564033 100644 --- a/index.jsx +++ b/index.jsx @@ -3742,7 +3742,7 @@ function App({ appId }) { const collection = useMemo3(() => makeNoteCollection({ onConflict }), [onConflict]); const openId = view.mode === "editor" ? view.id : null; const openNote = openId ? notes.find((n) => n.meta.id === openId && !n.placeholder) : null; - const openPath = openId ? openNote?.storagePath || notePath(openId) : "__notes_no_open__.json"; + const openPath = openId ? openNote?.storagePath || notePath(openId) : null; useEffect6(() => { openIdRef.current = openId; }, [openId]); diff --git a/mobius.json b/mobius.json index 88c6cd0..91a1f37 100644 --- a/mobius.json +++ b/mobius.json @@ -1,7 +1,7 @@ { "id": "notes", "name": "Notes", - "version": "1.2.29", + "version": "1.2.30", "description": "Markdown notes that render as you type, with checklists, pins, colors, search, and images.", "author": "mobius-os", "license": "MIT", diff --git a/package-lock.json b/package-lock.json index 2d75d53..ac230ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "app-notes", - "version": "1.2.29", + "version": "1.2.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "app-notes", - "version": "1.2.29", + "version": "1.2.30", "devDependencies": { "@codemirror/commands": "6.10.3", "@codemirror/lang-markdown": "6.5.0", diff --git a/package.json b/package.json index d44e5cb..637bdc5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "app-notes", - "version": "1.2.29", + "version": "1.2.30", "private": true, "type": "module", "description": "Markdown notes for Möbius — live-inline editor, offline, git-tracked.", diff --git a/src/app.jsx b/src/app.jsx index f32ad85..9d67f07 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -32,8 +32,8 @@ import EditorPanel from './ui/EditorPanel.jsx' import ConfirmModal from './ui/ConfirmModal.jsx' import { Icon } from './ui/icons.jsx' -// A no-op document handle: the value the open-note hook returns when no note is -// open (the sentinel path) or under the unit-test harness, where window.mobius +// A no-op document handle: the value the open-note hook returns under the +// unit-test harness, where window.mobius // is absent. Keeps a stable shape so callers can read .value/.status/.lastError // unconditionally. const NO_DOC = { value: null, status: 'idle', lastError: null, update: async () => {}, set: async () => {}, refresh: async () => {} } @@ -291,25 +291,23 @@ export default function App({ appId }) { // mode 'lww' (per-note files avoid same-path clobber; backend CAS isn't live). const openId = view.mode === 'editor' ? view.id : null const openNote = openId ? notes.find((n) => n.meta.id === openId && !n.placeholder) : null - const openPath = openId ? (openNote?.storagePath || notePath(openId)) : '__notes_no_open__.json' + const openPath = openId ? (openNote?.storagePath || notePath(openId)) : null // Keep the debounced-gc mirrors current (read in scheduleGc's setTimeout body). useEffect(() => { openIdRef.current = openId }, [openId]) useEffect(() => { notesRef.current = notes }, [notes]) useEffect(() => { draftRef.current = draft }, [draft]) useEffect(() => { failedSaveIdsRef.current = failedSaveIds }, [failedSaveIds]) const mergeNote = useMemo(() => makeMergeNote(onConflict), [onConflict]) - // The deployed runtime keys its refresh/subscription effects on these option - // references. Keeping both the callback and options object stable prevents an - // unrelated render from tearing down the subscription and re-reading the same - // document in a tight loop. + // A null path is the runtime's explicit idle document state: no read, + // subscription, or write. Keeping the live options stable also prevents an + // unrelated render from re-arming an open note's subscription. const openDocOptions = useMemo(() => ({ initial: null, identity: NOTE_DOC_IDENTITY, merge: mergeNote, mode: 'lww', }), [mergeNote]) - // Always called (stable hook position); inert when no note is open (the - // sentinel path is never written) or under the test harness (returns NO_DOC). + // Always called at a stable hook position; the test harness returns NO_DOC. const liveDoc = useDocument(openPath, openDocOptions) // useDocument returns a FRESH handle object every render, so depending on the whole // `liveDoc` in writeNote would rebuild writeNote → persist → the editor's flushSave diff --git a/test/autosave-deps-stable.test.js b/test/autosave-deps-stable.test.js index c95731c..61a288c 100644 --- a/test/autosave-deps-stable.test.js +++ b/test/autosave-deps-stable.test.js @@ -37,6 +37,7 @@ function installMobius() { const pathSubs = new Map() const docSubs = new Map() let docEffectRuns = 0 + const documentPaths = [] function notify(path) { const v = store.has(path) ? store.get(path) : null for (const cb of pathSubs.get(path) || []) { try { cb(v) } catch {} } @@ -73,10 +74,11 @@ function installMobius() { } function createUseDocument(React) { return function useDocument(path, opts) { + documentPaths.push(path) const [value, setValue] = React.useState(opts?.initial ?? null) const baseRef = React.useRef(null) React.useEffect(() => { - if (!path || path.startsWith('__notes_no_open__')) return undefined + if (!path) return undefined docEffectRuns++ let s = docSubs.get(path); if (!s) { s = new Set(); docSubs.set(path, s) } const cb = (v) => { baseRef.current = v; setValue(v) } @@ -111,6 +113,7 @@ function installMobius() { } return { store, + documentPaths, seed(path, val) { store.set(path, val) }, get docEffectRuns() { return docEffectRuns }, } @@ -168,6 +171,14 @@ const editorProps = () => { const n = safeFind((x) => x.props && Object.prototyp // its onQuery prop on the element to drive a search-query state change. const topBar = () => safeFind((n) => n.props && typeof n.props.onQuery === 'function') +test('grid mode uses the runtime idle-document contract instead of a sentinel file', async () => { + shim.mount(() => App({ appId: '1', token: 't' })) + await flush() + assert.equal(env.documentPaths.at(-1), null) + assert.equal(env.documentPaths.includes('__notes_no_open__.json'), false) + shim.unmount() +}) + test('onSave (persist) keeps a stable identity across an unrelated re-render (autosave deps do not churn)', async () => { env.seed(notePath('n1'), { meta: { id: 'n1', title: 'T', type: 'note', color: null, pinned: false, attachments: [], updated: '2026-01-01T00:00:00.000Z' }, body: 'hello' }) shim.mount(() => App({ appId: '1', token: 't' })) diff --git a/test/runtime-integration.test.js b/test/runtime-integration.test.js index 20ffef1..04154eb 100644 --- a/test/runtime-integration.test.js +++ b/test/runtime-integration.test.js @@ -104,6 +104,22 @@ async function renderDoc(storage, path, opts) { } } +test('runtime useDocument: a null path is idle and performs no storage work', { skip: !HAVE_RUNTIME ? 'platform runtime not present' : false }, async () => { + const calls = [] + const storage = { + async get() { calls.push('get'); return null }, + async getWithVersion() { calls.push('getWithVersion'); return { value: null, version: null } }, + async durableWrite() { calls.push('durableWrite'); return { durability: 'synced' } }, + subscribe() { calls.push('subscribe'); return () => {} }, + } + const doc = await renderDoc(storage, null, { initial: null }) + assert.equal(doc.get().status, 'idle') + assert.deepEqual(calls, []) + await assert.rejects(doc.get().update(() => ({ body: 'must not write' })), /document path/) + assert.deepEqual(calls, []) + doc.cleanup() +}) + test('runtime useDocument + app mergeNote: an edit on an existing note lands durably (loads base, writes through the real writer)', { skip: !HAVE_RUNTIME ? 'platform runtime not present' : false }, async () => { const { freshEnv, waitFor } = await import(HARNESS) const { server } = freshEnv()