Skip to content

perf(core)!: measure in one layout pass, drop the shape alias - #56

Merged
JaceThings merged 3 commits into
mainfrom
perf/lean-hot-paths
Aug 24, 2026
Merged

perf(core)!: measure in one layout pass, drop the shape alias#56
JaceThings merged 3 commits into
mainfrom
perf/lean-hot-paths

Conversation

@JaceThings

@JaceThings JaceThings commented Aug 23, 2026

Copy link
Copy Markdown
Owner

highlight() was measuring every range twice: once through computeAnchor to find the column, then again for the line rects. The anchor was only ever a fallback for columnBounds, which the caller already passes whenever there's an anchor host, so the second layout pass bought nothing in the common path.

Measurement is now a single pass per range, rangesToLineRects takes the anchor as optional, and computeAnchor stays exported for callers measuring a column themselves. Alongside it: style writes go through a per-element cache so a re-render resolving to the same declaration skips the DOM write, smoothEdge walks edge vertices directly instead of materialising two intermediate arrays per frame, and buildMarkGeometry shifts edges into mark space in place rather than mapping copies. The typeof x !== "undefined" guards scattered across targeting and the tiers collapse into one hasGlobal().

Breaking: shape and ShapeType are gone

ShapeType was export type ShapeType = MarkType, and shape? was an undocumented alias for markType? that mergeOptions already collapsed into markType and then deleted. It only ever gave one concept a second name. Callers pass markType and import MarkType. Nothing else in the public surface moved, and neither name appears anywhere in the README or docs. The changeset is a major, so the linked group takes all four packages to 2.0.0.

The rest: parse at the boundary, don't assert at the use

Same idea applied inward, across core, the wrappers, the site and the tests.

Core stopped indexing el.style through a CSSStyleDeclaration & Record<string, string> cast and calls setProperty with real CSS property names. That turned out to fix a latent lie rather than just a type: a camelCase el.style.maskPosition = v write only creates an expando in jsdom and never a real declaration, so four test assertions moved to getPropertyValue and check something real now. mergeRectsByLine returns real DOMRect instances instead of object literals wearing the type. Every walker.nextNode() as Text goes through one nextTextNode helper.

The website gained a single browser-env module that reads the environment once into a named capability record, so components branch on a domain value instead of probing globals inline. The playground's option paths are a real union derived from PlaygroundOptions with a value type per path, which removed the unknown that ran from set() all the way down through the option demos. The consumer smoke scripts share one export-contract parser instead of scattering typeof x === "function" across four files, and the react hook's test injects a runtime through the provider seam instead of mocking the core module.

Two behaviour changes worth naming. findSelectionAnchor now skips a positioned ancestor that isn't an HTMLElement instead of returning it as one; that's only reachable through a positioned SVG ancestor, where the old result couldn't host an overlay anyway. And the two mergeOptions tests that covered the shape alias were replaced with ones covering what the merge actually does now: the override wins, and an explicit undefined keeps the base.

Where to start

packages/core/src/render/highlight.ts and targeting/line-rects.ts carry the measure change and everything else follows from it. render/renderer.ts has the style cache and the setProperty switch, geometry/clip-path.ts the edge walk, and apps/website/src/playground/options-context.tsx the typed option paths. The comment strip in 643fb25 is mechanical and skimmable; the code inside it is not.

Verified

pnpm typecheck, pnpm test (241 pass), pnpm build, pnpm knip, pnpm size and pnpm consumer-smoke all green locally. Core sits at 14.8 kB brotlied against its 15 kB budget.

The type work was driven by a linter run locally and removed afterwards, so nothing new is vendored or added to the toolchain. It went from 278 findings to 3, all three being Vue's String as PropType<…> / Object as SlotsType<…> prop declarations, which are the documented way to declare Vue runtime props and can't be written without the assertion.

scripts/bench.mjs (pnpm bench) is new so the geometry hot paths can be timed instead of guessed at. It needs pnpm build first.

Times buildMarkGeometry, clipAtFront, buildEdge, and buildNoiseTile against the built core so hot-path changes can be measured rather than guessed at. Runs with `pnpm bench` after `pnpm build`.
Drops the running commentary across core, the framework wrappers, and the site, leaving comments only where they explain a why the code cannot.

Alongside it, the core render and measure paths get leaner:

- highlight() no longer computes an anchor before measuring, so each range is measured in one layout pass instead of two. rangesToLineRects takes the anchor as optional and falls back to the caller's columnBounds.
- Style writes route through a per-element cache, so a re-render that resolves to the same declaration skips the DOM write.
- smoothEdge walks edge vertices directly instead of materialising two intermediate arrays per frame, and buildMarkGeometry shifts edges into mark space in place.
- typeof guards collapse into a single hasGlobal() helper.
@JaceThings
JaceThings force-pushed the perf/lean-hot-paths branch from cb96a6e to 643fb25 Compare August 23, 2026 17:26
BREAKING CHANGE: `shape` and `ShapeType` are gone. Both were undocumented aliases for `markType` and `MarkType`, and `mergeOptions` already collapsed `shape` into `markType` and deleted it, so the alias only ever gave one concept a second name. Pass `markType`; import `MarkType`.

The rest is the same idea applied inward: values get parsed once where they enter, instead of being asserted into shape at each use.

Core no longer indexes `el.style` through a `CSSStyleDeclaration & Record<string, string>` cast; it calls `setProperty` with real CSS property names. That also fixes a latent lie, since a camelCase write only ever set an expando in jsdom and never a real declaration. `mergeRectsByLine` returns real `DOMRect` instances rather than object literals wearing the type. TreeWalker results go through one `nextTextNode` helper instead of `as Text` at every call site, and the remaining `typeof x !== "undefined"` probes collapse into `hasGlobal`.

The website gains a single `browser-env` module that reads the environment once into a named capability record, so components branch on a domain value instead of probing globals inline. The playground's option paths are now a real union derived from `PlaygroundOptions`, with a value type per path, which removes the `unknown` plumbing that ran from `set()` down through the option demos.

The consumer smoke scripts share one export-contract parser that validates the packed surface in a single place, and the react hook's test injects a runtime through the existing provider seam instead of mocking the core module.

`findSelectionAnchor` now skips a positioned ancestor that is not an `HTMLElement` instead of returning it as one. Only reachable through a positioned SVG ancestor, where the old result could not host an overlay anyway.
@JaceThings JaceThings changed the title perf(core): measure each range in one layout pass perf(core)!: measure in one layout pass, drop the shape alias Aug 23, 2026
@JaceThings

Copy link
Copy Markdown
Owner Author

Pushed 0bfd730. Two things changed since the last review pass: shape/ShapeType are removed, which makes this a major and retitles the PR, and the type-assertion cleanup described above landed across core, the wrappers, the site and the tests. The changeset went from patch to major.

@JaceThings
JaceThings merged commit b24e84e into main Aug 24, 2026
4 checks passed
@JaceThings
JaceThings deleted the perf/lean-hot-paths branch August 24, 2026 02:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant