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 +9 -4
- package/dist/client.d.ts +53 -1
- package/dist/client.js +150 -7
- package/dist/worker-core.js +2 -1
- package/package.json +1 -1
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
|
|
556
|
-
opts?: { done?: boolean }, //
|
|
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) →
|
|
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) →
|
|
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.
|
|
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.
|
|
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 = () =>
|
|
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.
|
|
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.
|
|
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;
|
package/dist/worker-core.js
CHANGED
|
@@ -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.
|
|
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"],
|