fix(drawing): zwei stille Wege in den Datenverlust

Feinschliff vor dem Ausrollen, beim Durchlesen mit fremdem Blick gefunden und
beide nachgestellt.

Ein Fehler beim Laden ergab ein leeres Zeichenbrett. fetch wirft bei 404 oder
403 nicht. Der Rumpf {"error":"file not found"} ist gültiges JSON, JSON.parse
gelang, elements war undefined – und die Zeichnung ging leer auf. Sie sah aus,
als wäre sie verloren. Wer dann speichert, macht es wahr. Gegen die laufende
Instanz nachgestellt, bevor ich es behoben habe.

Weiterzeichnen während des Speicherns ging verloren. Der Upload lief mit dem
Stand von Sekunde 0; die Fassungsnummer wurde hinterher aus aktuell.current
gelesen – und das war inzwischen ein anderer Stand. Die neuen Striche galten
als gespeichert, ohne je hochgegangen zu sein, und verschwanden beim Schliessen
ohne Rückfrage. Jetzt wird der Stand einmal festgehalten: Er geht hoch, seine
Nummer wird vermerkt, das Vorschaubild zeigt denselben.

Das Laden steht dafür jetzt in ladeZeichnung() statt im Effekt – der Weg hat
drei Fallen, und zwei enden in verlorener Arbeit; das gehört prüfbar. Vier neue
Tests, unter Mutation nachgewiesen: Ohne die Statusprüfung werden zwei rot.

Bewusst so gelassen: Leserecht fällt erst beim Speichern auf. FileItem trägt
kein Rechtefeld, der Text-Editor verhält sich seit jeher genauso, und das
Zeichenbrett bleibt offen – die Arbeit ist also nicht weg.

Geprüft: 57 Vitest-Tests, alle acht Selbsttest-Suiten grün, alle Go-Pakete
grün, tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-30 12:30:22 +02:00
1 parent cedcb1ba6f
commit f064eb62c7
3 files changed
+138 -38

No files matched your search

+41
View File
@@ -1685,6 +1685,47 @@ Excalidraw ist MIT. draw.io waere die Alternative (Apache-2.0), zielt aber auf
Flussdiagramme und UML statt auf freies Zeichnen – und bringt eine deutlich Flussdiagramme und UML statt auf freies Zeichnen – und bringt eine deutlich
schwerere Oberflaeche mit. schwerere Oberflaeche mit.
### Feinschliff: zwei stille Wege in den Datenverlust (30.09.2026)
Beim Durchlesen mit fremdem Blick gefunden, beide nachgestellt.
**Ein Fehler beim Laden ergab ein leeres Zeichenbrett.** `fetch` wirft bei 404
oder 403 **nicht**. Der Rumpf `{"error":"file not found"}` ist gueltiges JSON,
`JSON.parse` gelang, `elements` war `undefined` – und die Zeichnung ging leer
auf. Sie *sah aus*, als waere sie verloren. Wer dann speichert, macht es wahr.
Nachgestellt gegen die laufende Instanz:
GET /api/v1/files/<erfunden>/download?inline=1
Status: 404
Rumpf : {"error":"file not found"}
JSON.parse gelingt -> ['error'] d.elements -> None
**Weiterzeichnen waehrend des Speicherns gingen verloren.** Der Upload lief mit
dem Stand von Sekunde 0; die Fassungsnummer wurde hinterher aus
`aktuell.current` gelesen – und das war inzwischen ein anderer Stand. Die neuen
Striche galten als gespeichert, ohne je hochgegangen zu sein, und verschwanden
beim Schliessen ohne Rueckfrage.
Jetzt wird der Stand **einmal festgehalten**: Er geht hoch, seine Nummer wird
vermerkt, und das Vorschaubild zeigt denselben. Ist inzwischen weitergezeichnet
worden, steht „Unsaved" sofort wieder da – richtig so.
Das Laden steht dafuer jetzt in `ladeZeichnung()` statt im Effekt: Der Weg hat
drei Fallen, und zwei enden in verlorener Arbeit – das gehoert pruefbar.
- [x] Vier Tests fuer das Laden, unter Mutation nachgewiesen: Ohne die
Statuspruefung werden zwei davon rot.
### Was bewusst so bleibt
**Leserecht faellt erst beim Speichern auf.** `FileItem` traegt kein
Rechtefeld; die Oberflaeche kann vorher nicht unterscheiden. Wer eine geliehene
Zeichnung nur lesen darf, kann darin zeichnen und erfaehrt es beim Speichern –
mit dem Satz des Servers. Das Zeichenbrett bleibt dabei offen, die Arbeit ist
also nicht weg. Genauso verhaelt sich der Text-Editor seit jeher; es einzeln zu
loesen hiesse, einen Rechte-Endpunkt nur fuer diesen Fall zu bauen.
### Drei Nachbesserungen aus dem Testen (29.09.2026) ### Drei Nachbesserungen aus dem Testen (29.09.2026)
**„Close without saving?" kam, obwohl gerade gespeichert war.** Der Schalter **„Close without saving?" kam, obwohl gerade gespeichert war.** Der Schalter
+57 -38
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Excalidraw, exportToBlob, getSceneVersion, restore, serializeAsJSON } from '@excalidraw/excalidraw' import { Excalidraw, exportToBlob, getSceneVersion, restore, serializeAsJSON } from '@excalidraw/excalidraw'
import '@excalidraw/excalidraw/index.css' import '@excalidraw/excalidraw/index.css'
import { filesApi, type FileItem } from '../api/client' import { APIError, filesApi, type FileItem } from '../api/client'
import { useBackClose } from '../hooks/useBackClose' import { useBackClose } from '../hooks/useBackClose'
import { FileTypeIcon } from './icons' import { FileTypeIcon } from './icons'
import ConfirmDialog from './ConfirmDialog' import ConfirmDialog from './ConfirmDialog'
@@ -53,6 +53,39 @@ export function leereZeichnung(): string {
export const drawingExtension = '.excalidraw' export const drawingExtension = '.excalidraw'
/**
* Eine Zeichnung holen und in einen Zustand verwandeln, den das Zeichenbrett
* annimmt.
*
* Steht hier und nicht im Effekt, damit es sich prüfen lässt – der Weg hat
* drei Fallen, und zwei davon enden in verlorener Arbeit.
*/
export async function ladeZeichnung(url: string) {
const r = await fetch(url, { credentials: 'include' })
// **`fetch` wirft bei 404 oder 403 nicht.** Ohne diese Zeile käme der
// Fehlerrumpf `{"error":"file not found"}` weiter, `JSON.parse` gelänge –
// es ist gültiges JSON – und `elements` wäre `undefined`. Das Zeichenbrett
// ginge **leer** auf; es sähe aus, als sei die Zeichnung verloren. Wer dann
// speichert, macht es wahr.
if (!r.ok) throw new APIError(r.status, 'could not load the drawing')
const text = await r.text()
// Eine frisch angelegte Zeichnung ist leer – das ist kein Fehler.
const d = JSON.parse(text.trim() || leereZeichnung())
// `restore` säubert und ergänzt Fehlendes. Es ist der Weg, den Excalidraw
// dafür vorsieht – selbst zu prüfen hiesse, seine Feldliste nachzubauen und
// bei jeder Fassung nachzuziehen.
//
// **Eine Sache erledigt es allerdings nicht** (nachgemessen): Ein bereits
// verunglücktes `collaborators: {}` reicht es unverändert durch – die Map
// entsteht nur, wenn das Feld ganz fehlt. Neue Dateien haben es nicht mehr,
// weil `serializeAsJSON` es gar nicht erst schreibt; die paar aus der ersten
// Stunde wären sonst dauerhaft kaputt.
if (d?.appState) delete d.appState.collaborators
return restore(d, null, null)
}
interface Props { interface Props {
file: FileItem file: FileItem
onClose: () => void onClose: () => void
@@ -91,31 +124,8 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
useEffect(() => { useEffect(() => {
let lebt = true let lebt = true
fetch(filesApi.inline(file.id), { credentials: 'include' }) ladeZeichnung(filesApi.inline(file.id))
.then(r => r.text()) .then(szene => { if (lebt) setSzene(szene) })
.then(text => {
if (!lebt) return
// Eine frisch angelegte Zeichnung ist leer – das ist kein Fehler.
const d = JSON.parse(text.trim() || leereZeichnung())
// **Über `restore`, nicht roh hineinreichen.** Der `appState` enthält
// Dinge, die kein JSON überlebt – `collaborators` ist eine Map und
// kommt als `{}` zurück; Excalidraw ruft darauf `forEach` und bricht
// ab. Das Zeichenbrett blieb dann beim **zweiten** Öffnen leer, beim
// ersten nicht, weil da noch nichts gespeichert war.
//
// `restore` säubert und ergänzt Fehlendes. Es ist der Weg, den
// Excalidraw dafür vorsieht – selbst zu prüfen hiesse, seine
// Feldliste nachzubauen und bei jeder Fassung nachzuziehen.
//
// **Eine Sache erledigt `restore` allerdings nicht** (nachgemessen):
// Ein bereits verunglücktes `collaborators: {}` reicht es unverändert
// durch – die Map entsteht nur, wenn das Feld ganz fehlt. Neue
// Dateien haben es nicht mehr, weil `serializeAsJSON` es gar nicht
// erst schreibt. Die paar, die die erste Fassung angelegt hat, wären
// sonst dauerhaft kaputt. Also weg damit, bevor `restore` läuft.
if (d?.appState) delete d.appState.collaborators
setSzene(restore(d, null, null))
})
.catch(err => { if (lebt) setFehler(fehlertext(err, 'Could not open this drawing')) }) .catch(err => { if (lebt) setFehler(fehlertext(err, 'Could not open this drawing')) })
return () => { lebt = false } return () => { lebt = false }
}, [file.id]) }, [file.id])
@@ -158,21 +168,28 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
return return
} }
// **Einmal festhalten, was hochgeht.** Während der Upload läuft, kann
// weitergezeichnet werden – `aktuell.current` ist dann ein anderer
// Stand. Läse man die Fassungsnummer hinterher von dort, gälten die
// neuen Striche als gespeichert, obwohl sie nie hochgingen. Beim
// Schliessen wären sie lautlos weg.
const stand = aktuell.current
// Ebenfalls über das Paket: `serializeAsJSON` schreibt nur die Felder, // Ebenfalls über das Paket: `serializeAsJSON` schreibt nur die Felder,
// die eine Zeichnung ausmachen – nicht den Laufzeitzustand. // die eine Zeichnung ausmachen – nicht den Laufzeitzustand.
const inhalt = serializeAsJSON( const inhalt = serializeAsJSON(
aktuell.current.elements as never, stand.elements as never, stand.appState as never, stand.files as never, 'local')
aktuell.current.appState as never,
aktuell.current.files as never,
'local')
const blob = new File([inhalt], file.name, { type: 'application/json' }) const blob = new File([inhalt], file.name, { type: 'application/json' })
const neu = await filesApi.upload(blob, file.folder_id ?? undefined) const neu = await filesApi.upload(blob, file.folder_id ?? undefined)
basis.current = neu.version basis.current = neu.version
if (aktuell.current) gespeicherteFassung.current = getSceneVersion(aktuell.current.elements as never) gespeicherteFassung.current = getSceneVersion(stand.elements as never)
setGeaendert(false) // Ist inzwischen weitergezeichnet worden, steht „Unsaved" sofort wieder
// da – richtig so, es ist ja etwas offen.
const jetzt = aktuell.current ? getSceneVersion(aktuell.current.elements as never) : null
setGeaendert(jetzt !== null && jetzt !== gespeicherteFassung.current)
onSaved?.(neu) onSaved?.(neu)
toast.info('Drawing saved') toast.info('Drawing saved')
void vorschaubild(neu) void vorschaubild(neu, stand)
} catch (err) { } catch (err) {
toast.error(fehlertext(err, 'Could not save the drawing')) toast.error(fehlertext(err, 'Could not save the drawing'))
} finally { } finally {
@@ -192,13 +209,15 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
* scheitern zu lassen. Es bleibt deshalb auch stumm – die Zeichnung selbst * scheitern zu lassen. Es bleibt deshalb auch stumm – die Zeichnung selbst
* ist heil, und eine rote Meldung dafür wäre verwirrend. * ist heil, und eine rote Meldung dafür wäre verwirrend.
*/ */
async function vorschaubild(neu: FileItem) { async function vorschaubild(neu: FileItem, stand: NonNullable<typeof aktuell.current>) {
if (!aktuell.current) return
try { try {
const bild = await exportToBlob({ const bild = await exportToBlob({
elements: aktuell.current.elements as never, // Derselbe Stand, der hochgegangen ist – nicht der, der gerade auf
appState: { ...(aktuell.current.appState as object), exportBackground: true } as never, // dem Zeichenbrett liegt. Sonst zeigte die Kachel etwas, das so in
files: aktuell.current.files as never, // keiner Fassung steht.
elements: stand.elements as never,
appState: { ...(stand.appState as object), exportBackground: true } as never,
files: stand.files as never,
mimeType: 'image/png', mimeType: 'image/png',
exportPadding: 16, exportPadding: 16,
// Der Server verkleinert ohnehin auf Kachelgröße. Grösser hochzuladen // Der Server verkleinert ohnehin auf Kachelgröße. Grösser hochzuladen
+40
View File
@@ -12,6 +12,7 @@ HTMLCanvasElement.prototype.getContext = (() => ({
})) as never })) as never
const { restore, serializeAsJSON } = await import('@excalidraw/excalidraw') const { restore, serializeAsJSON } = await import('@excalidraw/excalidraw')
const { ladeZeichnung } = await import('./DrawingEditor')
/** /**
* Der Fehler, den dieser Test festhält, ist echt passiert (29.09.2026): * Der Fehler, den dieser Test festhält, ist echt passiert (29.09.2026):
@@ -96,4 +97,43 @@ describe('Zeichnungen: Speichern und wieder Öffnen', () => {
expect(leer.elements).toHaveLength(0) expect(leer.elements).toHaveLength(0)
expect(leer.appState.collaborators instanceof Map).toBe(true) expect(leer.appState.collaborators instanceof Map).toBe(true)
}) })
describe('Laden', () => {
function antwort(status: number, rumpf: string) {
vi.stubGlobal('fetch', async () => ({
ok: status >= 200 && status < 300,
status,
text: async () => rumpf,
}))
}
it('macht aus einem Fehlerrumpf KEINE leere Zeichnung', async () => {
// Das war der Fehler: `fetch` wirft bei 404 nicht, der Rumpf
// `{"error":"file not found"}` ist gueltiges JSON, `elements` waere
// undefined – und das Zeichenbrett ginge leer auf. Wer dann speichert,
// ueberschreibt die echte Zeichnung mit nichts.
antwort(404, '{"error":"file not found"}')
await expect(ladeZeichnung('/egal')).rejects.toBeTruthy()
})
it('auch 403 fuehrt nicht zu einem leeren Brett', async () => {
antwort(403, '{"error":"you only have read access to this file"}')
await expect(ladeZeichnung('/egal')).rejects.toBeTruthy()
})
it('eine leere Datei ist dagegen in Ordnung', async () => {
// Eine frisch angelegte Zeichnung hat noch keinen Inhalt.
antwort(200, '')
const szene = await ladeZeichnung('/egal')
expect(szene.elements).toHaveLength(0)
})
it('und eine echte Zeichnung kommt vollstaendig an', async () => {
antwort(200, serializeAsJSON(beispiel as never, { viewBackgroundColor: '#123456' } as never, {}, 'local'))
const szene = await ladeZeichnung('/egal')
expect(szene.elements).toHaveLength(1)
expect(szene.appState.viewBackgroundColor).toBe('#123456')
expect(szene.appState.collaborators instanceof Map).toBe(true)
})
})
}) })