Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
2 changes: 1 addition & 1 deletion mobius.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
16 changes: 7 additions & 9 deletions src/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {} }
Expand Down Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion test/autosave-deps-stable.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {} }
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -111,6 +113,7 @@ function installMobius() {
}
return {
store,
documentPaths,
seed(path, val) { store.set(path, val) },
get docEffectRuns() { return docEffectRuns },
}
Expand Down Expand Up @@ -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' }))
Expand Down
16 changes: 16 additions & 0 deletions test/runtime-integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading