flux-md 0.20.0 → 0.20.2

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;
@@ -154,6 +158,10 @@ export declare class FluxClient {
154
158
  * order, after the store updates) — for side effects like lazily
155
159
  * highlighting a finished code block or analytics. A committed block never
156
160
  * re-fires; the streaming tail does not (subscribe for live tail updates).
161
+ * NOTE: this is a PARSER-commit hook — the block carries the parser's raw
162
+ * id. During a setContent divergence swap the rendered view may show that
163
+ * block under a different id (an adopted old id, or a namespaced one), so
164
+ * correlate with rendered blocks via subscribe()+getSnapshot(), not this id.
157
165
  * @param options.coalesce opt-in (default `false`): collapse multiple
158
166
  * intra-frame patch notifications into ONE `requestAnimationFrame`-scheduled
159
167
  * flush to subscribers, so a React `useSyncExternalStore` consumer renders at
@@ -221,7 +229,12 @@ export declare class FluxClient {
221
229
  * - **prefix-extension** (the streaming-growth case) → append only the new
222
230
  * suffix, so committed blocks stay put and only the active tail re-parses;
223
231
  * - **any other change** (e.g. a finished stream swapped for a re-processed
224
- * final string) → `reset()` + reparse the whole new string.
232
+ * final string) → reset + reparse the whole new string, keeping the
233
+ * current view on screen until the reparse lands: the document never
234
+ * blanks, scroll never moves, and blocks whose rendered content is
235
+ * unchanged keep their identity (and React keys) so only genuinely
236
+ * changed blocks re-render. An empty new string is an explicit clear and
237
+ * hard-resets immediately.
225
238
  *
226
239
  * This is the first-class bridge for UIs that hold a streaming message as a
227
240
  * single growing string prop (the common React shape) — no hand-rolled diff,
@@ -243,6 +256,27 @@ export declare class FluxClient {
243
256
  done?: boolean;
244
257
  }): void;
245
258
  reset(): void;
259
+ /**
260
+ * setContent's divergence reset: rebuild the parser exactly like {@link reset},
261
+ * but keep `preserve` (the currently displayed view) on screen while the new
262
+ * content reparses. No notify fires here — subscribers keep reading the same
263
+ * snapshot reference until the first new-generation patch merges over it, so
264
+ * the swap is seamless: no empty frame, no container collapse, no scroll
265
+ * clamp, and blocks whose content survives the reprocess never re-render.
266
+ */
267
+ private softReset;
268
+ /**
269
+ * Finish a preserved-view divergence swap by making the merged view THE
270
+ * store: adopted ids become the committed keys, the merged array becomes the
271
+ * snapshot, and every scrap of merge state drops. From here getSnapshot() is
272
+ * a plain field read again (zero steady-state overhead) and the superseded
273
+ * generation's blocks are garbage — only one document stays in memory.
274
+ * Runs on the terminal (final) patch, which commits everything — if anything
275
+ * is somehow still open, the lazy merge simply stays live instead (the
276
+ * incremental reuse keeps it linear).
277
+ */
278
+ private collapseStale;
279
+ private resetParser;
246
280
  destroy(): void;
247
281
  /**
248
282
  * Re-register with the pool after {@link destroy} so the client receives
@@ -253,6 +287,28 @@ export declare class FluxClient {
253
287
  reattach(): void;
254
288
  subscribe: (fn: () => void) => () => boolean;
255
289
  getSnapshot: () => Block[];
290
+ /**
291
+ * Positional merge of the preserved pre-divergence view over the rebuilding
292
+ * store (see {@link softReset}). Per position:
293
+ * - identical committed block (html + kind + open + speculative) → the OLD
294
+ * block object, so its id and reference survive the swap and the block
295
+ * never re-renders (blocksEqual / the DOM keyed reconcile hold);
296
+ * - changed committed block → the new block, its id offset into this
297
+ * generation's namespace (adopted old ids and raw new ids could collide);
298
+ * - still-open block over old content → the old block (never a shrinking
299
+ * partial where complete content was already on screen); past the old
300
+ * document's end the live tail streams in as-is;
301
+ * - position the reparse hasn't reached → the old block, until the terminal
302
+ * patch sets `staleTrimmed` and the view clamps to the new length.
303
+ *
304
+ * LINEARITY: committed blocks are reference-stable across patches (the store
305
+ * contract), so a base entry pointer-equal to the previous merge's reproduces
306
+ * its previous decision without re-running the O(html) equality compare. Each
307
+ * block is string-compared exactly once — when it first commits — keeping a
308
+ * long post-divergence stream linear instead of quadratic. Only the active
309
+ * tail (fresh references each patch, small by design) re-compares per patch.
310
+ */
311
+ private mergeStale;
256
312
  /**
257
313
  * Internal: a renderer with an `onRenderMetrics` hook calls this once per
258
314
  * 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;
@@ -238,6 +262,10 @@ class FluxClient {
238
262
  * order, after the store updates) — for side effects like lazily
239
263
  * highlighting a finished code block or analytics. A committed block never
240
264
  * re-fires; the streaming tail does not (subscribe for live tail updates).
265
+ * NOTE: this is a PARSER-commit hook — the block carries the parser's raw
266
+ * id. During a setContent divergence swap the rendered view may show that
267
+ * block under a different id (an adopted old id, or a namespaced one), so
268
+ * correlate with rendered blocks via subscribe()+getSnapshot(), not this id.
241
269
  * @param options.coalesce opt-in (default `false`): collapse multiple
242
270
  * intra-frame patch notifications into ONE `requestAnimationFrame`-scheduled
243
271
  * flush to subscribers, so a React `useSyncExternalStore` consumer renders at
@@ -366,7 +394,12 @@ class FluxClient {
366
394
  * - **prefix-extension** (the streaming-growth case) → append only the new
367
395
  * suffix, so committed blocks stay put and only the active tail re-parses;
368
396
  * - **any other change** (e.g. a finished stream swapped for a re-processed
369
- * final string) → `reset()` + reparse the whole new string.
397
+ * final string) → reset + reparse the whole new string, keeping the
398
+ * current view on screen until the reparse lands: the document never
399
+ * blanks, scroll never moves, and blocks whose rendered content is
400
+ * unchanged keep their identity (and React keys) so only genuinely
401
+ * changed blocks re-render. An empty new string is an explicit clear and
402
+ * hard-resets immediately.
370
403
  *
371
404
  * This is the first-class bridge for UIs that hold a streaming message as a
372
405
  * single growing string prop (the common React shape) — no hand-rolled diff,
@@ -387,9 +420,14 @@ class FluxClient {
387
420
  setContent(content, opts) {
388
421
  if (content !== this.lastContent) {
389
422
  if (!this.contentDone && content.startsWith(this.lastContent)) {
423
+ if (this.lastContent === "" && content.length > 0 && this.getSnapshot().length > 0) {
424
+ this.softReset(this.getSnapshot());
425
+ }
390
426
  this.append(content.slice(this.lastContent.length));
391
427
  } else {
392
- this.reset();
428
+ const displayed = this.getSnapshot();
429
+ if (content.length > 0 && displayed.length > 0) this.softReset(displayed);
430
+ else this.reset();
393
431
  this.append(content);
394
432
  }
395
433
  this.lastContent = content;
@@ -401,7 +439,57 @@ class FluxClient {
401
439
  }
402
440
  }
403
441
  reset() {
404
- const hadContent = this.store.snapshot.length > 0;
442
+ const hadContent = this.getSnapshot().length > 0;
443
+ this.staleSnapshot = null;
444
+ this.staleTrimmed = false;
445
+ this.mergeCache = null;
446
+ this.resetParser();
447
+ if (hadContent) this.emit(true);
448
+ }
449
+ /**
450
+ * setContent's divergence reset: rebuild the parser exactly like {@link reset},
451
+ * but keep `preserve` (the currently displayed view) on screen while the new
452
+ * content reparses. No notify fires here — subscribers keep reading the same
453
+ * snapshot reference until the first new-generation patch merges over it, so
454
+ * the swap is seamless: no empty frame, no container collapse, no scroll
455
+ * clamp, and blocks whose content survives the reprocess never re-render.
456
+ */
457
+ softReset(preserve) {
458
+ this.staleSnapshot = preserve;
459
+ this.staleTrimmed = false;
460
+ this.idNamespace += ID_NAMESPACE_STRIDE;
461
+ this.resetParser();
462
+ this.mergeCache = { base: this.store.snapshot, trimmed: false, view: preserve };
463
+ }
464
+ /**
465
+ * Finish a preserved-view divergence swap by making the merged view THE
466
+ * store: adopted ids become the committed keys, the merged array becomes the
467
+ * snapshot, and every scrap of merge state drops. From here getSnapshot() is
468
+ * a plain field read again (zero steady-state overhead) and the superseded
469
+ * generation's blocks are garbage — only one document stays in memory.
470
+ * Runs on the terminal (final) patch, which commits everything — if anything
471
+ * is somehow still open, the lazy merge simply stays live instead (the
472
+ * incremental reuse keeps it linear).
473
+ */
474
+ collapseStale() {
475
+ if (this.store.active.length > 0) return;
476
+ const view = this.getSnapshot();
477
+ const committed = /* @__PURE__ */ new Map();
478
+ const committedOrder = new Array(view.length);
479
+ for (let i = 0; i < view.length; i++) {
480
+ committedOrder[i] = view[i].id;
481
+ committed.set(view[i].id, view[i]);
482
+ }
483
+ this.store = { committed, committedOrder, active: [], snapshot: view };
484
+ this.staleSnapshot = null;
485
+ this.staleTrimmed = false;
486
+ this.mergeCache = null;
487
+ }
488
+ // Shared generation teardown: swap in an empty store, zero the metrics and
489
+ // the setContent baseline, invalidate any coalesced frame, bump the epoch,
490
+ // and tell the worker to drop the parser. View concerns (stale preservation,
491
+ // subscriber notify) belong to the callers — reset() and softReset().
492
+ resetParser() {
405
493
  this.store = emptyBlockStore();
406
494
  this.appendedBytes = 0;
407
495
  this.patchCount = 0;
@@ -417,7 +505,6 @@ class FluxClient {
417
505
  this.epoch += 1;
418
506
  const pw = this.ensureAcquired();
419
507
  this.pool.send(pw, { type: "reset", streamId: this.streamId, epoch: this.epoch });
420
- if (hadContent) this.emit(true);
421
508
  }
422
509
  destroy() {
423
510
  if (!this.attached) return;
@@ -449,7 +536,65 @@ class FluxClient {
449
536
  this.listeners.add(fn);
450
537
  return () => this.listeners.delete(fn);
451
538
  };
452
- getSnapshot = () => this.store.snapshot;
539
+ getSnapshot = () => {
540
+ const base = this.store.snapshot;
541
+ if (!this.staleSnapshot) return base;
542
+ const cache = this.mergeCache;
543
+ if (cache && cache.base === base && cache.trimmed === this.staleTrimmed) return cache.view;
544
+ const view = this.mergeStale(base, cache && cache.trimmed === this.staleTrimmed ? cache : null);
545
+ this.mergeCache = { base, trimmed: this.staleTrimmed, view };
546
+ return view;
547
+ };
548
+ /**
549
+ * Positional merge of the preserved pre-divergence view over the rebuilding
550
+ * store (see {@link softReset}). Per position:
551
+ * - identical committed block (html + kind + open + speculative) → the OLD
552
+ * block object, so its id and reference survive the swap and the block
553
+ * never re-renders (blocksEqual / the DOM keyed reconcile hold);
554
+ * - changed committed block → the new block, its id offset into this
555
+ * generation's namespace (adopted old ids and raw new ids could collide);
556
+ * - still-open block over old content → the old block (never a shrinking
557
+ * partial where complete content was already on screen); past the old
558
+ * document's end the live tail streams in as-is;
559
+ * - position the reparse hasn't reached → the old block, until the terminal
560
+ * patch sets `staleTrimmed` and the view clamps to the new length.
561
+ *
562
+ * LINEARITY: committed blocks are reference-stable across patches (the store
563
+ * contract), so a base entry pointer-equal to the previous merge's reproduces
564
+ * its previous decision without re-running the O(html) equality compare. Each
565
+ * block is string-compared exactly once — when it first commits — keeping a
566
+ * long post-divergence stream linear instead of quadratic. Only the active
567
+ * tail (fresh references each patch, small by design) re-compares per patch.
568
+ */
569
+ mergeStale(base, prev) {
570
+ const stale = this.staleSnapshot;
571
+ const len = this.staleTrimmed ? base.length : Math.max(base.length, stale.length);
572
+ const view = new Array(len);
573
+ for (let i = 0; i < len; i++) {
574
+ const nb = i < base.length ? base[i] : void 0;
575
+ if (nb !== void 0 && prev !== null && i < prev.base.length && prev.base[i] === nb) {
576
+ view[i] = prev.view[i];
577
+ continue;
578
+ }
579
+ const ob = i < stale.length ? stale[i] : void 0;
580
+ if (!nb) {
581
+ view[i] = ob;
582
+ continue;
583
+ }
584
+ if (ob) {
585
+ if (nb.open && !this.staleTrimmed) {
586
+ view[i] = ob;
587
+ continue;
588
+ }
589
+ if (nb.html === ob.html && nb.kind.type === ob.kind.type && nb.open === ob.open && nb.speculative === ob.speculative) {
590
+ view[i] = ob;
591
+ continue;
592
+ }
593
+ }
594
+ view[i] = { ...nb, id: this.idNamespace + nb.id };
595
+ }
596
+ return view;
597
+ }
453
598
  /**
454
599
  * Internal: a renderer with an `onRenderMetrics` hook calls this once per
455
600
  * actual React block render so `getMetrics().renderCount` aggregates churn.
@@ -497,7 +642,7 @@ class FluxClient {
497
642
  */
498
643
  outline() {
499
644
  const out = [];
500
- for (const b of this.store.snapshot) {
645
+ for (const b of this.getSnapshot()) {
501
646
  if (b.kind.type === "Heading") {
502
647
  const d = b.kind.data;
503
648
  const level = typeof d === "number" ? d : d?.level ?? 1;
@@ -515,7 +660,7 @@ class FluxClient {
515
660
  */
516
661
  toPlaintext() {
517
662
  const parts = [];
518
- for (const b of this.store.snapshot) {
663
+ for (const b of this.getSnapshot()) {
519
664
  const t = htmlToText(b.html);
520
665
  if (t) parts.push(t);
521
666
  }
@@ -535,6 +680,11 @@ class FluxClient {
535
680
  break;
536
681
  }
537
682
  applyPatch(this.store, patch);
683
+ if (msg.final === true && this.staleSnapshot) {
684
+ this.staleTrimmed = true;
685
+ this.mergeCache = null;
686
+ this.collapseStale();
687
+ }
538
688
  this.appendedBytes = msg.appendedBytes;
539
689
  this.totalParseMicros += msg.parseMicros;
540
690
  this.retainedBytes = msg.retainedBytes;
Binary file
@@ -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.2",
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"],