„Das Schreiben des neuen Codes war am Ende der einfache Teil“, heißt es im Erfahrungsbericht über den Umbau von Maple. Die Zahlen dahinter: 287.000 Zeilen React-Code, 131 Routen, drei Tage. Aufwendig war nicht der neue Code, sondern der Nachweis, dass die Anwendung danach gleich bleibt.
Bei einer Migration dieser Größe schreibt das Modell den neuen Code; der Prüfstand vergleicht beide Anwendungen Bild für Bild, Klick für Klick und Request für Request. Darin steckte die Arbeit: rund 30.000 Zeilen Prüf-Harness gegen drei Tage Portierung. Der Autor beschreibt eine Frontend-Migration, die erst mit aktuellen Sprachmodellen in dieser Form möglich wurde, und nennt auch die Grenzen.
Warum Maple React verlassen hat
Die React-App lief. Teuer war jede Änderung. Daten auf einer Seite kamen aus Atom-Hooks, jeder mit eigenem Cache-Schlüssel und eigener Lebensdauer, verteilt auf rund 520 Hook-Aufrufe in 208 Dateien. Wer wissen wollte, was passiert, wenn jemand die Organisation wechselt, während eine Abfrage läuft, musste jede Komponente der Seite lesen und die Renderreihenfolge im Kopf nachbauen. Der Zustand lebte an vielen Orten gleichzeitig.
Im August stürzte die Fehlerseite in der Produktion mit React-Fehler #301 ab. Ein Helfer erzeugte bei jedem Render ein neues Wrapper-Atom, ein Hook verglich es über Identität und setzte Zustand während des Renderns. Beide Vorgänge lösten sich gegenseitig aus. Der Fehler war an einem Tag behoben. Nichts hinderte den nächsten daran, genauso geschrieben zu werden – kein Linter, kein Test, keine Code-Review, weil das Muster beim Schreiben harmlos aussieht. Dazu kamen zwei Randbedingungen: Der Rest von Maple ist auf Effect gebaut, und ein wachsender Teil des Frontends wird von Agenten geschrieben, die dieselben Hook-Fehler machen wie Menschen.
Was Foldkit anders macht – und was es kostet
Foldkit ist die Elm-Architektur auf Basis von Effect. Der gesamte Zustand liegt in einem Model, und genau eine Funktion darf ihn ändern. Ein Klick, eine Antwort, ein Timer – alles kommt als Message mit einem Namen in der Vergangenheitsform an, etwa ClickedRow oder SucceededLoadLogs. Die Funktion update nimmt Model und Message und gibt das nächste Model zurück, dazu Commands. Commands sind der einzige Code, der mit dem Netzwerk sprechen darf; was sie zurückbringen, kommt als weitere Message herein.
Für alle, die Elm oder Redux kennen, ist das nichts Neues. Der Unterschied liegt in der Konsequenz: keine Hooks, kein Component-State. Die Frage nach dem Organisationswechsel hat damit eine Antwort, und diese Antwort ist ein einzelner Fall in update. Foldkits Scene-Tests führen update in Node ohne DOM aus, sodass sich dieser Fall direkt prüfen lässt – für automatisierte Verhaltens- und Accessibility-Tests ein Vorteil, weil kein Browser simuliert werden muss.
Weil Maple von Observability lebt, war ein Nebeneffekt willkommen: Foldkit führt jeden Command in einem Effect-Span aus, der nach ihm benannt ist. Die Frontend-Anwendung spricht damit OpenTelemetry wie das Backend. Ein kleiner Adapter im Effect-SDK gruppiert diese Spans unter dem Seitenaufruf oder der Navigation, die sie ausgelöst hat, verwirft Commands ohne echte Arbeit – ein Fokuswechsel, ein Scroll – und protokolliert update, view und DOM-Patches, die über ihr Budget laufen. Der HTTP-Client sendet traceparent, sodass ein Trace vom Klick im Browser bis zur Warehouse-Abfrage durchläuft.
Kostenlos war das nicht. Foldkit kann nicht nach Route splitten, deshalb wächst das Start-Bundle von 703 KB auf geschätzt 1,43 bis 1,58 MB gzip. Wer eine React App nach Foldkit portieren will, sollte diesen Punkt vorher durchrechnen, nicht nachher entdecken.
Die Definition von „fertig“
Der fertige Diff berührte 3.393 Dateien. Das kann niemand lesen. Und niemand kann 131 Routen in zwei Themes, auf Desktop und Mobil, in jedem leeren, ladenden und fehlerhaften Zustand durchklicken und danach ehrlich sagen, alles sehe gleich aus. Also hat das Team vor der ersten Zeile Portierung festgelegt, wann ein Screen als erledigt gilt: wenn jedes Szenario seiner Route identical meldet – oder pass mit null perzeptuellen Pixeln –, wenn null Verhaltensabweichungen, keine Konsolenfehler und keine nicht gemockten Endpunkte auftreten. Das Ziel einer Frontend-Migration ist damit nicht eine funktionierende App, sondern eine nachweisbar gleiche.
Dazu kam eine zweite Regel, mindestens genauso wichtig: Ein Agent darf niemals die Baseline, ein Szenario oder eine Toleranz anfassen, um einen Diff verschwinden zu lassen. Der Fehler wird immer in der Portierung behoben, und die Baseline hängt an einem festen Commit, damit sie nicht wandert. Ohne diese Regel ist jede pixelgenaue Prüfung wertlos, weil sie sich innerhalb von zwei Tagen selbst weichspült.
Der Prüfstand: wie @maple/ui-parity Gleichheit beweist
Der Prüfstand heißt @maple/ui-parity, ist als Paket öffentlich und baut beide Anwendungen, treibt sie mit Playwright und vergleicht die Ergebnisse. Beide Builds sind Produktions-Builds von derselben Origin, und beide sprechen mit einem gemeinsamen Fixture-Backend. Das ist kein Ordner mit JSON-Mocks: Es implementiert die echten API-Gruppen aus @maple/domain, Warehouse-Abfragen laufen über die echte Query-Engine in chDB, einem eingebetteten ClickHouse. Baut die Portierung eine etwas andere Abfrage, kommt ein anderes Diagramm heraus – und ein anderes Request-Log. Jede Anfrage, die das Backend nicht kennt, lässt den Screen scheitern.
Jede Aufnahme muss deterministisch sein, sonst bedeutet ein Diff nichts. Die Zeit ist auf das now des Datensatzes eingefroren, Math.random ist geseedet, der Browser läuft in UTC bei DPR 1, Animationen stehen in ihrem Endzustand, Caret und Scrollbar sind unsichtbar. Bevor ein Lauf zählt, vergleicht parity selfcheck die alte App mit sich selbst und muss null Unterschiede finden. Die Gegenprobe gab es auch: eine einzelne Klasse in packages/ui geändert, und der Fehler wurde gemeldet.
Ein Screenshot entsteht erst, wenn die Seite zur Ruhe gekommen ist. Der Prüfstand wartet auf Fonts und Bilder, dann 400 Millisekunden ohne DOM-Änderung, dann zwei Frames ohne Änderung am Canvas-Pixel-Hash, weil Diagramme auf Canvas gezeichnet werden. Danach werden Screenshots aufgenommen, bis zwei in Folge byte-identisch sind. Jedes Paar wird anschließend vierfach verglichen: über Pixel, strikt und perzeptuell; über Struktur, wobei jeder Text und jedes Bedienelement mit Position und berechneten Styles erfasst und über Text oder zugängliche Rolle und Namen zugeordnet wird, mit zusammengefassten Meldungen statt neun Einzel-Diffs, wenn eine falsche Klasse neun Buttons trifft; über das Request-Log, pro Endpunkt und ohne Reihenfolge, weil parallele Anfragen sich überholen – eine Seite, die richtig aussieht, aber doppelt lädt, fällt durch; und über ARIA-Snapshots, um die Barrierefreiheit nach der React-Migration zu prüfen, standardmäßig nur als Bericht, mit --strict-a11y als Fehler.
Szenarien werden einmal geschrieben und nutzen ausschließlich zugängliche Locators wie getByRole und getByText, sodass dasselbe Skript beide Anwendungen steuert. Findet ein Schritt einen Button in React, aber nicht in Foldkit, fehlt dem Foldkit-Button schlicht sein Label. Mehr als 50 Szenariodateien decken jede Route in hell und dunkel ab, dazu Mobil für responsive Seiten – über 2.600 Varianten pro Lauf, inklusive Lade- und Fehlerzuständen, für die ein Szenario jeden Endpunkt verzögern oder mit einem 502 beantworten kann. Der Prüfstand ist damit keine Sammlung von Screenshot-Tests, sondern ein Vertrag.
Wie Claude in drei Tagen portierte
Am Anfang standen acht kleine Spikes. Jeder beantwortete eine Frage, die das Projekt hätte kippen können, und endete in einer kurzen Entscheidungsnotiz. Ob Foldkit einen der Screens identisch rendern könne – der Login traf auf Anhieb pixelgenau. Was der Verlust des Route-Splittings kostet. Ob die Diagramme, die Log-Tabelle, die Service-Map, die Datenschicht und die Overlays tragen. Der letzte Spike erzeugte die Seitenvorlage, die später jeder Agent kopierte. Die Log-Tabelle mit 2.000 Zeilen traf alle zwölf Varianten und war außerdem schneller: 26 Prozent weniger Scroll-Script-Zeit, elf Prozent weniger Task-Zeit, elf Prozent kürzere Zeit bis zu den ersten Zeilen, acht Prozent weniger JS-Heap nach der Garbage Collection.
Jeder Portierungs-Agent bekam dasselbe Briefing, und die wichtigste Regel darin war das Verbot, die Baseline zu manipulieren. Bis zu vier Agenten arbeiteten gleichzeitig, jeder an einem Bereich der Anwendung in einem eigenen Git-Worktree mit eigenen Ports für den Prüfstand. Ein separater Integrator-Agent übernahm das Mergen und ließ danach die Parität erneut laufen. Über die drei Tage kamen knapp 40 Agenten zum Einsatz; zweimal stoppte ein Nutzungslimit alle gleichzeitig, und jeder setzte aus seinem Worktree wieder an. Mehrmals liefen so viele Prüfläufe parallel, dass die Load Average der Maschine über 40 stieg, weshalb gegen Ende in Batches gemergt wurde. Früh bauten zwei Agenten dieselben UI-Primitives unabhängig voneinander – danach hatte jedes Primitive genau einen Besitzer.
Der Prüfstand fand Dinge, die kein Reviewer je gesehen hätte. Im Overlay-Spike war ein Toast-Label in Foldkit ein einzelner Textknoten, in React drei – allein das verschob 13 Pixel. Die Grenze zeigte sich an anderer Stelle: Die Command-Palette hatte kein Szenario, und dass sie nie portiert worden war, fiel erst in der zweiten Nacht auf. Ein Prüfstand prüft nur, was die Szenarien abdecken.
Was trotzdem durchrutschte – und was das für die Praxis bedeutet
Weil die neue App exakt dieselben Requests stellen musste wie die alte, tauchten dabei ein paar alte Fehler als Diffs auf: eine Infrastruktur-Ansicht, die eine Abfrage mit leerem Workload-Namen schickte, bevor der Workload geladen war; ein Reconnect-Button, der nichts Sichtbares tat, weil die App weiter die alten Collections las; zwei Dashboard-Editoren, die in abgedeckten Situationen abstürzten. Pixel und Requests fingen nicht alles. Das erste Review des gemergten Codes fand rund 40 Defekte, die zwischen zwei Screenshots passieren – Messages, die die falsche Seiteninstanz erreichen, Schreibvorgänge ohne In-Flight-Guard, Grenzfälle der Pagination. Ein abschließendes Review mit sechs Agenten fand rund 140 weitere, vier davon ernst, in Checkout und Einstellungen. Der erste echte Deploy brachte zutage, was die Fixtures ausblenden: Clerk-Komponenten und Produktanalytik. An vier Stellen entschied sich das Team bewusst für ein anderes Verhalten als React, etwa eine Chat-Nachricht zu verweigern, solange der Verlauf nicht geladen ist.
Der letzte Lauf vor dem Cutover: 2.682 Varianten, davon 2.188 identisch mit null Pixeln und null Verhaltensabweichungen, 485 innerhalb der perzeptuellen Toleranz – Unterschiede nur im Anti-Aliasing – und neun Fehlschläge, von denen einer auch in der alten App scheitert, einer nach dem Lauf behoben wurde und vier unerklärte Request-Log-Abweichungen sind. In der Vorschau mit echtem Backend machte Foldkit 389 Requests, React 393, bei einem p50 von 133 zu 121 Millisekunden. Rund 700 Commits auf dem Migrationszweig, über 2.000 TypeScript-Dateien in der neuen App, mehr als 1.200 Scene- und Unit-Tests ohne DOM.
Für eine React-Migration verschiebt sich die Arbeit. Der Engpass ist nicht der neue Code, sondern der Nachweis gleicher Ergebnisse, bevor jemand die alte Anwendung löscht. 30.000 Zeilen Prüfstand gegen 287.000 Zeilen React – dieses Verhältnis zählt. Dazu kommt eine Einschränkung: Was kein Szenario hat, gilt als nicht geprüft, egal wie gut der portierte Code aussieht. Die Command-Palette zeigt das. Wer diesen Aufwand scheut, bekommt keine schlechtere Portierung, sondern eine unbewiesene. Merken wird er es, wenn die Nutzer es merken.
Quelle: maple.dev
