@vectojs/core 1.19.0 → 1.21.0

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.
@@ -17,6 +17,61 @@ import { type CoreWasmRuntime } from '../wasm/runtime';
17
17
  import { type HitModuleSource, type HitTestBackend } from '../wasm/hit-backend';
18
18
  import { type AnimModuleSource, type AnimBackend } from '../wasm/anim-backend';
19
19
  import { type ParticleModuleSource, type ParticleBackend } from '../wasm/particle-backend';
20
+ /**
21
+ * A timed phase of a frame.
22
+ *
23
+ * `render` is the ENCLOSING phase — it contains `transform`, `drawWalk` and
24
+ * `flush` — so it is reported without a share to avoid double-counting.
25
+ * `a11ySync` and `a11yOrder` run after `render` in the frame loop, so they are
26
+ * siblings of it, not children.
27
+ */
28
+ export type RenderPhase = 'render' | 'transform' | 'drawWalk' | 'flush' | 'a11ySync'
29
+ /**
30
+ * Time inside {@link Scene.syncContentGridProjection} materializing DOM
31
+ * carriers, nested inside `a11ySync`.
32
+ *
33
+ * Split out because `a11ySync` for a streaming code block measured 1661-1875 ms
34
+ * against a 210-671 ms render, and attributing that to grid materialization was
35
+ * an assumption. Nothing should be optimised here on the strength of the parent
36
+ * phase alone.
37
+ */
38
+ | 'gridMaterialize'
39
+ /**
40
+ * Whole of {@link Scene.syncContentProjection}, nested inside `a11ySync`.
41
+ *
42
+ * Measured at 99.8-99.9% of `a11ySync` for a streaming code block, so per-node
43
+ * a11y attribute and geometry work is not where that phase's cost lives.
44
+ */
45
+ | 'contentProjection'
46
+ /** Per-node a11y attribute/geometry work, excluding content projection and descendants. */
47
+ | 'a11yNodes'
48
+ /** Whole of `syncContentGridProjection`, of which `gridMaterialize` is one part. */
49
+ | 'gridSync'
50
+ /**
51
+ * Synchronous part of `scheduleContentGridCalibration` — building the probe DOM.
52
+ *
53
+ * The measurement itself is deferred to a rAF, but the probe is constructed
54
+ * here. Measured at 77-80% of `gridSync` on Chrome (3.7-4.5 ms per frame, i.e.
55
+ * the entire 240Hz budget) against about 1 ms on Firefox, making it the largest
56
+ * remaining cost of projecting a streaming code block once carrier reuse landed.
57
+ */
58
+ | 'gridCalibrateSchedule'
59
+ /** The `querySelectorAll` + per-cell scan inside calibration scheduling. */
60
+ | 'calibScan'
61
+ /** Probe DOM construction and insertion inside calibration scheduling. */
62
+ | 'calibProbeBuild' | 'a11yOrder'
63
+ /** Sum of every entity's own render(), nested inside drawWalk. */
64
+ | 'entityPaint';
65
+ export interface RenderPhaseEntry {
66
+ phase: RenderPhase;
67
+ totalMs: number;
68
+ calls: number;
69
+ avgMs: number;
70
+ /** Worst single sample — a spiky phase is a different problem from a slow one. */
71
+ maxMs: number;
72
+ /** Percent of the measured total, or `null` for the enclosing `render` phase. */
73
+ share: number | null;
74
+ }
20
75
  /**
21
76
  * Who marked the scene dirty, and why.
22
77
  *
@@ -230,6 +285,43 @@ export declare class Scene {
230
285
  renderMode: 'always' | 'onDemand';
231
286
  /** Cap on distinct recorded dirty reasons (see `recordDirtyReason`). */
232
287
  private static readonly MAX_DIRTY_REASONS;
288
+ private _phaseTiming;
289
+ private _phaseTotals;
290
+ /**
291
+ * Start or stop per-phase render timing.
292
+ *
293
+ * Off by default, and the probes compile to a single boolean test when off:
294
+ * these sit on the frame path, so the disabled cost has to be nothing. Enable,
295
+ * run the scene, then read {@link renderPhases}.
296
+ *
297
+ * Exists because a frame total cannot tell you where the time went. The
298
+ * markdown streaming benchmark put render at 85-99% of an append's cost, and
299
+ * there was no way to decompose that number further — which is exactly the
300
+ * position that led to two wrong optimisation guesses earlier
301
+ * (`CodeBlock` reuse, hit-grid fusion), both of which measured as no change.
302
+ */
303
+ setPhaseTiming(enabled: boolean): void;
304
+ /** Whether per-phase render timing is being recorded. */
305
+ get phaseTiming(): boolean;
306
+ /**
307
+ * Accumulate one phase sample.
308
+ *
309
+ * Totals rather than a per-frame log: the question is always "which phase owns
310
+ * the frame", and a log of thousands of samples answers it less directly while
311
+ * costing far more memory. `maxMs` is kept because a phase that is cheap on
312
+ * average but spikes is a different problem from one that is uniformly slow.
313
+ */
314
+ private _recordPhase;
315
+ /**
316
+ * Recorded phase timings, most expensive first, with each phase's share of the
317
+ * measured total.
318
+ *
319
+ * `share` is the number that matters: a phase at 4% cannot be worth optimising
320
+ * however inefficient it looks in isolation.
321
+ */
322
+ get renderPhases(): RenderPhaseEntry[];
323
+ /** Drop recorded phase timings, keeping timing enabled. */
324
+ clearRenderPhases(): void;
233
325
  private _dirtyTracking;
234
326
  private _dirtyReasons;
235
327
  private dirty;
@@ -300,6 +392,29 @@ export declare class Scene {
300
392
  private contentGridCalibrationFrames;
301
393
  /** Detached, untransformed font probes used by the cold calibration pass. */
302
394
  private contentGridCalibrationProbes;
395
+ /**
396
+ * Monotonic stamp identifying the conditions grid cells were calibrated under.
397
+ *
398
+ * Calibration measures the difference between the advance the canvas grid assigns
399
+ * a cluster and the width the browser lays it out at, then writes a per-cell
400
+ * `scaleX`. That result stays valid until the font or the page scale changes, and
401
+ * it lives on the cell element — so a cell carrying this stamp needs no further
402
+ * work.
403
+ *
404
+ * The scan that feeds calibration was O(cells) on every revision bump: for a
405
+ * streaming code block it re-derived a measurement key for every cell in the
406
+ * block each frame in order to produce only ~20 distinct keys, costing about
407
+ * 2.5 ms/frame after the `style.font` fix and still over half of `a11ySync`. Since
408
+ * carrier reuse (#244) leaves untouched lines — and therefore their calibrated
409
+ * transforms — in place, cells stamped with the current generation can simply be
410
+ * skipped, making the scan O(new cells) instead.
411
+ *
412
+ * A plain incrementing integer rather than the descriptive calibration key,
413
+ * because it goes into an attribute selector and must not need escaping.
414
+ */
415
+ private contentGridCalibrationGeneration;
416
+ /** The `(fontEpoch, pageScale)` pair the current generation corresponds to. */
417
+ private contentGridCalibrationStamp;
303
418
  /** Invalidates grid font calibration after browser font availability changes. */
304
419
  private contentFontEpoch;
305
420
  /** Cached Canvas-to-client scale for the current font/viewport epoch. */
@@ -742,6 +857,15 @@ export declare class Scene {
742
857
  */
743
858
  private setupGLContextRecovery;
744
859
  private endContentSelectionDrag;
860
+ /**
861
+ * Index of the carrier line currently holding a selection inside `el`, or
862
+ * `null`.
863
+ *
864
+ * Lets a partial re-materialization decide whether the user's selection is even
865
+ * affected. Checks the tracked anchor first (it survives a drag) and falls back
866
+ * to the live DOM selection.
867
+ */
868
+ private contentGridSelectionLine;
745
869
  private releaseContentSelectionForRebuild;
746
870
  /**
747
871
  * Rebuild a content-projection element's DOM (`rebuild`) while preserving a
@@ -778,7 +902,26 @@ export declare class Scene {
778
902
  * @example scene.add(new CircleEntity());
779
903
  */
780
904
  add(entity: Entity): this;
905
+ /**
906
+ * Reset per-grid calibration and bookkeeping before a (re)materialization.
907
+ *
908
+ * @param entityId - Owning entity, keyed into the calibration maps.
909
+ * @param el - The projection element.
910
+ * @param releaseSelection - Whether to drop a selection this element owns.
911
+ * Pass `false` when carrier lines are being reused: the selection's DOM nodes
912
+ * survive the pass, so tearing it down would wipe a user's selection on every
913
+ * streamed chunk — the exact bug `preserveContentSelectionAcrossRebuild`
914
+ * exists to prevent on the non-grid path.
915
+ */
781
916
  private clearContentGridState;
917
+ /**
918
+ * Drop any projected elements under `node` without touching the entity tree.
919
+ *
920
+ * Used when the walk reaches an invisible subtree: the entities stay put (a
921
+ * later `show()` re-projects them), but nothing under here may remain
922
+ * focusable or announced while hidden.
923
+ */
924
+ private pruneA11ySubtree;
782
925
  private removeA11yRecursively;
783
926
  /**
784
927
  * If `el` is about to be removed from the DOM while it holds browser focus,
@@ -867,6 +1010,26 @@ export declare class Scene {
867
1010
  get rootEntity(): Entity;
868
1011
  /** The overlay layer root (see {@link showOverlay}), read-only for tooling. */
869
1012
  get overlayRootEntity(): Entity;
1013
+ /**
1014
+ * Advance and render exactly one frame, synchronously.
1015
+ *
1016
+ * This renders UNCONDITIONALLY: it consults neither {@link renderMode} nor
1017
+ * {@link dirty}, and it does not apply the `always`-mode idle auto-throttle.
1018
+ * That is deliberate — a deterministic driver (video export, a test, a
1019
+ * fixed-step benchmark) asks for a frame because it wants that frame, not a
1020
+ * scheduler opinion about whether it is needed.
1021
+ *
1022
+ * The consequence is a measurement footgun worth stating explicitly: a
1023
+ * benchmark that drives frames through `step()` CANNOT observe frame skipping,
1024
+ * so `always` and `onDemand` produce byte-identical draw counts through this
1025
+ * path. An investigation into whether `onDemand` skips redundant repaints once
1026
+ * concluded "it does not" on exactly that basis; on the real rAF loop the same
1027
+ * workload rendered ~1.0 frames per content change. To measure anything about
1028
+ * scheduling, use {@link start} and let `requestAnimationFrame` drive.
1029
+ *
1030
+ * @param dt Seconds to advance. Not clamped by `MAX_FRAME_DT` — the caller
1031
+ * chooses the step, since determinism is the point.
1032
+ */
870
1033
  step(dt: number): void;
871
1034
  /**
872
1035
  * Mark the scene as needing a redraw on the next frame.
@@ -875,6 +1038,22 @@ export declare class Scene {
875
1038
  * entity state outside of {@link Entity.animate} so the change is rendered.
876
1039
  */
877
1040
  markDirty(source?: DirtySource): void;
1041
+ /**
1042
+ * Increments whenever the tree's shape changes: add, remove or reparent.
1043
+ *
1044
+ * Already maintained for the resident WASM transform store (see
1045
+ * {@link markStructureChanged}, called from `Entity.add`/`remove`), and exposed
1046
+ * here because a cache of the tree's shape — a DevTools tree model, a serialized
1047
+ * snapshot — is valid exactly as long as this value is unchanged. Comparing it is
1048
+ * O(1) against re-walking the tree, which is what it replaces: DevTools rebuilt
1049
+ * both trees on a fixed 500 ms interval, a constant cost proportional to entity
1050
+ * count, purely because it had no way to ask whether the shape had changed.
1051
+ *
1052
+ * Property changes do NOT bump it. Moving or restyling an entity leaves the
1053
+ * shape intact, so a consumer that also cares about values must read those
1054
+ * directly rather than rebuilding a tree.
1055
+ */
1056
+ get structureVersion(): number;
878
1057
  /**
879
1058
  * Record who marked the scene dirty and why.
880
1059
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectojs/core",
3
- "version": "1.19.0",
3
+ "version": "1.21.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },