flux-md 0.20.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -156,7 +156,12 @@ use it from any binding.
156
156
  > append-only so parsing stays incremental. Re-transforming the *whole* string
157
157
  > each token (so earlier bytes change) forces `setContent` to reparse every tick
158
158
  > (O(n²)); that's what render-time overrides avoid. `setContent`'s reset path is
159
- > for the **once**-at-the-end reprocess swap, not per-token rewrites.
159
+ > for the **once**-at-the-end reprocess swap, not per-token rewrites. That swap
160
+ > is seamless: the current view stays on screen while the new string reparses —
161
+ > the document never blanks, scroll never moves, and blocks whose rendered
162
+ > content is unchanged keep their identity (and React keys), so only genuinely
163
+ > changed blocks re-render. (`setContent("")` is an explicit clear and resets
164
+ > immediately.)
160
165
 
161
166
  <details>
162
167
  <summary>Full manual control (caller-owned client)</summary>
@@ -552,9 +557,9 @@ class FluxClient {
552
557
  ): Promise<void>;
553
558
  finalize(): void; // mark stream complete
554
559
  setContent( // drive from a controlled full string
555
- full: string, // diffs vs last: prefix → append delta; else reset+reparse
556
- opts?: { done?: boolean }, // done:true finalize
557
- ): void;
560
+ full: string, // diffs vs last: prefix → append delta; else seamless
561
+ opts?: { done?: boolean }, // reset+reparse (view held, unchanged blocks keep identity)
562
+ ): void; // done:true → finalize
558
563
  reset(): void; // wipe and reuse
559
564
  destroy(): void; // free this stream's parser
560
565
  whenReady(): Promise<void>; // resolves once WASM loaded; rejects on init failure
package/dist/client.d.ts CHANGED
@@ -133,6 +133,10 @@ export declare class FluxClient {
133
133
  private rafHandle;
134
134
  private finalizePending;
135
135
  private epoch;
136
+ private staleSnapshot;
137
+ private staleTrimmed;
138
+ private idNamespace;
139
+ private mergeCache;
136
140
  private appendedBytes;
137
141
  private patchCount;
138
142
  private totalParseMicros;
@@ -221,7 +225,12 @@ export declare class FluxClient {
221
225
  * - **prefix-extension** (the streaming-growth case) → append only the new
222
226
  * suffix, so committed blocks stay put and only the active tail re-parses;
223
227
  * - **any other change** (e.g. a finished stream swapped for a re-processed
224
- * final string) → `reset()` + reparse the whole new string.
228
+ * final string) → reset + reparse the whole new string, keeping the
229
+ * current view on screen until the reparse lands: the document never
230
+ * blanks, scroll never moves, and blocks whose rendered content is
231
+ * unchanged keep their identity (and React keys) so only genuinely
232
+ * changed blocks re-render. An empty new string is an explicit clear and
233
+ * hard-resets immediately.
225
234
  *
226
235
  * This is the first-class bridge for UIs that hold a streaming message as a
227
236
  * single growing string prop (the common React shape) — no hand-rolled diff,
@@ -243,6 +252,27 @@ export declare class FluxClient {
243
252
  done?: boolean;
244
253
  }): void;
245
254
  reset(): void;
255
+ /**
256
+ * setContent's divergence reset: rebuild the parser exactly like {@link reset},
257
+ * but keep `preserve` (the currently displayed view) on screen while the new
258
+ * content reparses. No notify fires here — subscribers keep reading the same
259
+ * snapshot reference until the first new-generation patch merges over it, so
260
+ * the swap is seamless: no empty frame, no container collapse, no scroll
261
+ * clamp, and blocks whose content survives the reprocess never re-render.
262
+ */
263
+ private softReset;
264
+ /**
265
+ * Finish a preserved-view divergence swap by making the merged view THE
266
+ * store: adopted ids become the committed keys, the merged array becomes the
267
+ * snapshot, and every scrap of merge state drops. From here getSnapshot() is
268
+ * a plain field read again (zero steady-state overhead) and the superseded
269
+ * generation's blocks are garbage — only one document stays in memory.
270
+ * Runs on the terminal (final) patch, which commits everything — if anything
271
+ * is somehow still open, the lazy merge simply stays live instead (the
272
+ * incremental reuse keeps it linear).
273
+ */
274
+ private collapseStale;
275
+ private resetParser;
246
276
  destroy(): void;
247
277
  /**
248
278
  * Re-register with the pool after {@link destroy} so the client receives
@@ -253,6 +283,28 @@ export declare class FluxClient {
253
283
  reattach(): void;
254
284
  subscribe: (fn: () => void) => () => boolean;
255
285
  getSnapshot: () => Block[];
286
+ /**
287
+ * Positional merge of the preserved pre-divergence view over the rebuilding
288
+ * store (see {@link softReset}). Per position:
289
+ * - identical committed block (html + kind + open + speculative) → the OLD
290
+ * block object, so its id and reference survive the swap and the block
291
+ * never re-renders (blocksEqual / the DOM keyed reconcile hold);
292
+ * - changed committed block → the new block, its id offset into this
293
+ * generation's namespace (adopted old ids and raw new ids could collide);
294
+ * - still-open block over old content → the old block (never a shrinking
295
+ * partial where complete content was already on screen); past the old
296
+ * document's end the live tail streams in as-is;
297
+ * - position the reparse hasn't reached → the old block, until the terminal
298
+ * patch sets `staleTrimmed` and the view clamps to the new length.
299
+ *
300
+ * LINEARITY: committed blocks are reference-stable across patches (the store
301
+ * contract), so a base entry pointer-equal to the previous merge's reproduces
302
+ * its previous decision without re-running the O(html) equality compare. Each
303
+ * block is string-compared exactly once — when it first commits — keeping a
304
+ * long post-divergence stream linear instead of quadratic. Only the active
305
+ * tail (fresh references each patch, small by design) re-compares per patch.
306
+ */
307
+ private mergeStale;
256
308
  /**
257
309
  * Internal: a renderer with an `onRenderMetrics` hook calls this once per
258
310
  * actual React block render so `getMetrics().renderCount` aggregates churn.
package/dist/client.js CHANGED
@@ -162,6 +162,7 @@ class FluxPool {
162
162
  }
163
163
  }
164
164
  }
165
+ const ID_NAMESPACE_STRIDE = 4294967296;
165
166
  function poolCap() {
166
167
  const hc = typeof navigator !== "undefined" ? navigator.hardwareConcurrency : 0;
167
168
  return Math.min(hc || 4, 8);
@@ -213,6 +214,29 @@ class FluxClient {
213
214
  // echoed back on each patch; a patch whose epoch is older than this is a
214
215
  // pre-reset straggler and is dropped before it can repopulate the cleared store.
215
216
  epoch = 0;
217
+ // --- Preserved-view divergence swap (setContent's reset+reparse path) ---
218
+ // The displayed snapshot captured by softReset(). While set, getSnapshot()
219
+ // returns a positional merge of this view over the rebuilding store, so the
220
+ // document never blanks out during a divergence reparse and unchanged blocks
221
+ // keep their object identity AND id (React key / DOM node key) across the
222
+ // swap. It persists after the reparse completes — dropping it would revert
223
+ // the adopted ids and remount every block one notify later.
224
+ staleSnapshot = null;
225
+ // Set when the reparse's terminal (final) patch lands: the merge stops
226
+ // padding with the old document's tail, so a shorter replacement trims to the
227
+ // new length on that very notify.
228
+ staleTrimmed = false;
229
+ // Id offset applied to non-adopted new blocks in a merged view. A divergence
230
+ // reparse restarts core block ids at 0 (fresh parser), and streamed ids are
231
+ // chunk-dependent (tail reparses burn ids) — so a changed block's new id can
232
+ // collide with a retained old block's id in the same merged snapshot.
233
+ // Bumped by ID_NAMESPACE_STRIDE per generation, which keeps every merged id
234
+ // provably unique: adopted ids are all below the current namespace.
235
+ idNamespace = 0;
236
+ // getSnapshot() cache: (store.snapshot ref, trimmed) → merged array. Repeated
237
+ // reads between notifies must return the SAME reference — the
238
+ // useSyncExternalStore cached-snapshot contract.
239
+ mergeCache = null;
216
240
  // Perf
217
241
  appendedBytes = 0;
218
242
  patchCount = 0;
@@ -366,7 +390,12 @@ class FluxClient {
366
390
  * - **prefix-extension** (the streaming-growth case) → append only the new
367
391
  * suffix, so committed blocks stay put and only the active tail re-parses;
368
392
  * - **any other change** (e.g. a finished stream swapped for a re-processed
369
- * final string) → `reset()` + reparse the whole new string.
393
+ * final string) → reset + reparse the whole new string, keeping the
394
+ * current view on screen until the reparse lands: the document never
395
+ * blanks, scroll never moves, and blocks whose rendered content is
396
+ * unchanged keep their identity (and React keys) so only genuinely
397
+ * changed blocks re-render. An empty new string is an explicit clear and
398
+ * hard-resets immediately.
370
399
  *
371
400
  * This is the first-class bridge for UIs that hold a streaming message as a
372
401
  * single growing string prop (the common React shape) — no hand-rolled diff,
@@ -389,7 +418,9 @@ class FluxClient {
389
418
  if (!this.contentDone && content.startsWith(this.lastContent)) {
390
419
  this.append(content.slice(this.lastContent.length));
391
420
  } else {
392
- this.reset();
421
+ const displayed = this.getSnapshot();
422
+ if (content.length > 0 && displayed.length > 0) this.softReset(displayed);
423
+ else this.reset();
393
424
  this.append(content);
394
425
  }
395
426
  this.lastContent = content;
@@ -401,7 +432,57 @@ class FluxClient {
401
432
  }
402
433
  }
403
434
  reset() {
404
- const hadContent = this.store.snapshot.length > 0;
435
+ const hadContent = this.getSnapshot().length > 0;
436
+ this.staleSnapshot = null;
437
+ this.staleTrimmed = false;
438
+ this.mergeCache = null;
439
+ this.resetParser();
440
+ if (hadContent) this.emit(true);
441
+ }
442
+ /**
443
+ * setContent's divergence reset: rebuild the parser exactly like {@link reset},
444
+ * but keep `preserve` (the currently displayed view) on screen while the new
445
+ * content reparses. No notify fires here — subscribers keep reading the same
446
+ * snapshot reference until the first new-generation patch merges over it, so
447
+ * the swap is seamless: no empty frame, no container collapse, no scroll
448
+ * clamp, and blocks whose content survives the reprocess never re-render.
449
+ */
450
+ softReset(preserve) {
451
+ this.staleSnapshot = preserve;
452
+ this.staleTrimmed = false;
453
+ this.idNamespace += ID_NAMESPACE_STRIDE;
454
+ this.resetParser();
455
+ this.mergeCache = { base: this.store.snapshot, trimmed: false, view: preserve };
456
+ }
457
+ /**
458
+ * Finish a preserved-view divergence swap by making the merged view THE
459
+ * store: adopted ids become the committed keys, the merged array becomes the
460
+ * snapshot, and every scrap of merge state drops. From here getSnapshot() is
461
+ * a plain field read again (zero steady-state overhead) and the superseded
462
+ * generation's blocks are garbage — only one document stays in memory.
463
+ * Runs on the terminal (final) patch, which commits everything — if anything
464
+ * is somehow still open, the lazy merge simply stays live instead (the
465
+ * incremental reuse keeps it linear).
466
+ */
467
+ collapseStale() {
468
+ if (this.store.active.length > 0) return;
469
+ const view = this.getSnapshot();
470
+ const committed = /* @__PURE__ */ new Map();
471
+ const committedOrder = new Array(view.length);
472
+ for (let i = 0; i < view.length; i++) {
473
+ committedOrder[i] = view[i].id;
474
+ committed.set(view[i].id, view[i]);
475
+ }
476
+ this.store = { committed, committedOrder, active: [], snapshot: view };
477
+ this.staleSnapshot = null;
478
+ this.staleTrimmed = false;
479
+ this.mergeCache = null;
480
+ }
481
+ // Shared generation teardown: swap in an empty store, zero the metrics and
482
+ // the setContent baseline, invalidate any coalesced frame, bump the epoch,
483
+ // and tell the worker to drop the parser. View concerns (stale preservation,
484
+ // subscriber notify) belong to the callers — reset() and softReset().
485
+ resetParser() {
405
486
  this.store = emptyBlockStore();
406
487
  this.appendedBytes = 0;
407
488
  this.patchCount = 0;
@@ -417,7 +498,6 @@ class FluxClient {
417
498
  this.epoch += 1;
418
499
  const pw = this.ensureAcquired();
419
500
  this.pool.send(pw, { type: "reset", streamId: this.streamId, epoch: this.epoch });
420
- if (hadContent) this.emit(true);
421
501
  }
422
502
  destroy() {
423
503
  if (!this.attached) return;
@@ -449,7 +529,65 @@ class FluxClient {
449
529
  this.listeners.add(fn);
450
530
  return () => this.listeners.delete(fn);
451
531
  };
452
- getSnapshot = () => this.store.snapshot;
532
+ getSnapshot = () => {
533
+ const base = this.store.snapshot;
534
+ if (!this.staleSnapshot) return base;
535
+ const cache = this.mergeCache;
536
+ if (cache && cache.base === base && cache.trimmed === this.staleTrimmed) return cache.view;
537
+ const view = this.mergeStale(base, cache && cache.trimmed === this.staleTrimmed ? cache : null);
538
+ this.mergeCache = { base, trimmed: this.staleTrimmed, view };
539
+ return view;
540
+ };
541
+ /**
542
+ * Positional merge of the preserved pre-divergence view over the rebuilding
543
+ * store (see {@link softReset}). Per position:
544
+ * - identical committed block (html + kind + open + speculative) → the OLD
545
+ * block object, so its id and reference survive the swap and the block
546
+ * never re-renders (blocksEqual / the DOM keyed reconcile hold);
547
+ * - changed committed block → the new block, its id offset into this
548
+ * generation's namespace (adopted old ids and raw new ids could collide);
549
+ * - still-open block over old content → the old block (never a shrinking
550
+ * partial where complete content was already on screen); past the old
551
+ * document's end the live tail streams in as-is;
552
+ * - position the reparse hasn't reached → the old block, until the terminal
553
+ * patch sets `staleTrimmed` and the view clamps to the new length.
554
+ *
555
+ * LINEARITY: committed blocks are reference-stable across patches (the store
556
+ * contract), so a base entry pointer-equal to the previous merge's reproduces
557
+ * its previous decision without re-running the O(html) equality compare. Each
558
+ * block is string-compared exactly once — when it first commits — keeping a
559
+ * long post-divergence stream linear instead of quadratic. Only the active
560
+ * tail (fresh references each patch, small by design) re-compares per patch.
561
+ */
562
+ mergeStale(base, prev) {
563
+ const stale = this.staleSnapshot;
564
+ const len = this.staleTrimmed ? base.length : Math.max(base.length, stale.length);
565
+ const view = new Array(len);
566
+ for (let i = 0; i < len; i++) {
567
+ const nb = i < base.length ? base[i] : void 0;
568
+ if (nb !== void 0 && prev !== null && i < prev.base.length && prev.base[i] === nb) {
569
+ view[i] = prev.view[i];
570
+ continue;
571
+ }
572
+ const ob = i < stale.length ? stale[i] : void 0;
573
+ if (!nb) {
574
+ view[i] = ob;
575
+ continue;
576
+ }
577
+ if (ob) {
578
+ if (nb.open && !this.staleTrimmed) {
579
+ view[i] = ob;
580
+ continue;
581
+ }
582
+ if (nb.html === ob.html && nb.kind.type === ob.kind.type && nb.open === ob.open && nb.speculative === ob.speculative) {
583
+ view[i] = ob;
584
+ continue;
585
+ }
586
+ }
587
+ view[i] = { ...nb, id: this.idNamespace + nb.id };
588
+ }
589
+ return view;
590
+ }
453
591
  /**
454
592
  * Internal: a renderer with an `onRenderMetrics` hook calls this once per
455
593
  * actual React block render so `getMetrics().renderCount` aggregates churn.
@@ -497,7 +635,7 @@ class FluxClient {
497
635
  */
498
636
  outline() {
499
637
  const out = [];
500
- for (const b of this.store.snapshot) {
638
+ for (const b of this.getSnapshot()) {
501
639
  if (b.kind.type === "Heading") {
502
640
  const d = b.kind.data;
503
641
  const level = typeof d === "number" ? d : d?.level ?? 1;
@@ -515,7 +653,7 @@ class FluxClient {
515
653
  */
516
654
  toPlaintext() {
517
655
  const parts = [];
518
- for (const b of this.store.snapshot) {
656
+ for (const b of this.getSnapshot()) {
519
657
  const t = htmlToText(b.html);
520
658
  if (t) parts.push(t);
521
659
  }
@@ -535,6 +673,11 @@ class FluxClient {
535
673
  break;
536
674
  }
537
675
  applyPatch(this.store, patch);
676
+ if (msg.final === true && this.staleSnapshot) {
677
+ this.staleTrimmed = true;
678
+ this.mergeCache = null;
679
+ this.collapseStale();
680
+ }
538
681
  this.appendedBytes = msg.appendedBytes;
539
682
  this.totalParseMicros += msg.parseMicros;
540
683
  this.retainedBytes = msg.retainedBytes;
@@ -125,8 +125,9 @@ class WorkerCore {
125
125
  try {
126
126
  const parser = this.getOrCreate(streamId);
127
127
  if (buffered && buffered.length > 0) {
128
- parser.append(buffered);
128
+ const drained = parser.append(buffered);
129
129
  this.totalAppended.set(streamId, (this.totalAppended.get(streamId) ?? 0) + buffered.length);
130
+ this.emitPatch(streamId, drained, parser, 0, false);
130
131
  }
131
132
  const patch = parser.finalize();
132
133
  this.emitPatch(streamId, patch, parser, 0, true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flux-md",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "description": "Zero-dep streaming markdown for the browser. Rust→WASM core, Web Worker per stream, incremental parse with speculative closure.",
5
5
  "type": "module",
6
6
  "sideEffects": ["./dist/worker.js", "./dist/styles.css"],