
Hoe je een live rapport streamt zonder geflikker
Wanneer je meeting eindigt, vult GeekBye's samenvatting zich nu live in plaats van je naar een spinner te laten staren. Streaming-UI kalm laten aanvoelen in plaats van schokkerig vereiste dat we geflikker twee keer oplosten — één keer voor gestructureerde velden, één keer voor markdown — en de tweede fix was een renderer die we al bezaten.
Vóór deze release betekende het beëindigen van een GeekBye-meeting dat je naar een spinner keek. De app stuurde je hele transcript weg voor analyse, wachtte op de volledige samenvatting — score, kernpunten, actiepunten, alles — en dropte het dan in één keer op het scherm. Functioneel, maar het voelde traag, omdat je naar niets zat te staren terwijl het werkte.
GeekBye v1.6.13–v1.6.15 veranderde dat in een rapport dat live binnenstroomt, veld voor veld, terwijl het wordt gegenereerd. Het interessante deel is niet het streamen — het is alles wat we moesten doen om te voorkomen dat het streamen er schokkerig uitzag. Kalm streamen en hakkelig streamen zijn dezelfde feature met een heel verschillende uitvoering.
Geflikker, deel één: laat niet elke chunk React laten stuiptrekken
De samenvatting is niet één blob — het zijn gestructureerde velden (een totaalscore, kernpunten, actiepunten, enzovoort) die de backend één voor één uitzendt over server-sent events. De naïeve manier om dat te renderen is React-state bij te werken bij elke event zodra die binnenkomt.
Doe dat, en je krijgt geflikker. Elk binnenkomend veld triggert zijn eigen re-render; componenten die "leeg" toonden mounten, unmounten dan, en remounten als "geladen"; de layout schokt bij elk pakket. Het rapport zet zichzelf in elkaar voor je ogen op de slechtst mogelijke manier — zichtbaar, nerveus.
De fix is een tweedelige discipline. Ten eerste, accumuleer de binnenkomende velden in een ref — een muteerbare houder die geen renders triggert — en publiceer één keer per chunk naar React-state met een verse kopie, zodat de tree weloverwogen wordt bijgewerkt in plaats van bij elke micro-event. Ten tweede, render de hele tijd één stabiele component-tree, of het nu streamt of klaar is, met inline placeholders voor velden die nog niet zijn aangekomen:
// accumulate in a ref, publish once per chunk
partialRef.current = { ...partialRef.current, [field]: value }
setPartial({ ...partialRef.current })
// one tree, never swapped; missing fields show a placeholder in place
const report = isStreaming ? partial : saved
<Score>{report.overallScore ?? '…'}</Score>
De component die de score toont unmount nooit — het toont gewoon … totdat het getal landt, dan het getal. Niets stuiptrekt. En het volledig samengestelde object wordt aan het eind nog steeds gecachet naar de lokale database, zodat streaming-UI en duurzame opslag naast elkaar bestaan zonder te vechten.
Geflikker, deel twee: de renderer die we al bezaten
Het tweede geflikker is subtieler en leeft in de markdown zelf. Het rapport rendert rijke markdown — koppen, vet, lijsten, codeblokken. Maar markdown die midden in de stream aankomt is, op elk gegeven moment, half af. Een lijst met tot nu toe één bullet. Een code fence die is geopend maar niet gesloten. Een vet-marker zonder partner nog.
Een conventionele markdown-renderer herparseert de hele string bij elke token, en die half-afgemaakte staat parseert elke keer naar iets anders — dus de lijst flikkert, het codeblok flitst open en dicht, de layout springt terwijl tokens compleet worden. Het is dezelfde nerveuze assemblage als eerder, één laag dieper.
De fix was bijna gênant beschikbaar: we gebruikten al een streaming-bewuste markdown-renderer voor de live AI-chat. Een renderer gebouwd om incomplete tokens te tolereren — om half-afgemaakte markdown stabiel te renderen en het pas te laten settelen als de tokens compleet worden — in plaats van elke keer opnieuw vanaf nul te parseren. Het rapport hoefde alleen dezelfde te gebruiken. We hadden dit exacte probleem maanden eerder voor de chat opgelost; de "fix" voor het rapport was beseffen dat we de tool al hadden en die op een tweede oppervlak richten. Hergebruik won van herbouwen.
De bonus: rijk kopiëren is een klembord-formaatprobleem
Zodra het rapport er goed uitzag, wilden mensen het plakken — in een document, een e-mail — en de opmaak en de screenshots behouden. Het instinct is om het rapport te serialiseren naar een markdown-string en te hopen dat de bestemming het rendert. Dat doet het meestal niet.
Het echte antwoord is dat het klembord meerdere representaties tegelijk bevat. Dus schrijven we er twee: een HTML-versie met de opmaak intact en de screenshots ingebed als inline afbeeldingen, en een plaintext-fallback voor bestemmingen die geen HTML accepteren. Plak in een rijke editor en je krijgt het opgemaakte rapport met afbeeldingen; plak in een plattetekstvak en je krijgt schone tekst. "Rijk kopiëren" was nooit een serialisatieprobleem — het was een kies-het-juiste-klembord-formaat-probleem.
Dezelfde release gaf het rapport ook chat-achtige Me / Them-labels met de twee sprekers uitgelijnd aan tegenovergestelde kanten, zodat een transcript leest als het gesprek dat het was, en verplaatste je eigen screenshots naar hun eigen kant om te matchen. (Eén release in dit venster, v1.6.14, was een pure rebuild — geen verhaal daar, en het is eerlijk om dat te zeggen.)
Drie dingen die streaming-UI ons leerde
- Buffer gestreamde velden, publiceer één keer per chunk. Elke server-sent event zijn eigen state-update laten aansturen is het geflikker. Een ref-accumulator plus één publish per chunk, in een tree die nooit unmount, verandert nerveuze assemblage in kalm invullen.
- Alles wat streamt heeft een streaming-bewuste renderer nodig. Een renderer die de hele string per token herparseert zal flikkeren op half-afgemaakte markdown. Gebruik er een gebouwd voor incomplete input — en als je er al een hebt voor een ander oppervlak, hergebruik die voordat je een tweede bouwt.
- Rijk kopiëren gaat over klembord-formaten, niet serialisatie. Schrijf
text/htmlmet ingebedde afbeeldingen en eentext/plain-fallback. Het klembord is ontworpen om beide te dragen; gebruik het.
Dit is het vijfde hoofdstuk van het betrouwbaarheid-en-afwerking-verhaal dat GeekBye v2 wordt. Voor het vorige hoofdstuk, zie de oordopjes-bug (v1.6.12); voor waar hetzelfde server-sent-events-idee later een heel fallback-transport werd, live transcriptie wanneer de firewall WebSockets blokkeert (v2.0.8); en voor de hele boog, de anatomie van software tot in de perfectie uitbrengen.