Repository navigation
Fix the deep review issues #143-#180, and the SSR rendering gap - #182
Merged
Merged
Conversation
withTitleChild now omits the <title> when the mark has a tip, as applyChannelStyles does upstream; a native tooltip would otherwise compete with the tip on hover. channelStyleProps no longer writes href onto the shape element, since withHrefWrap already wraps it in an <a>. Arrow, circle and tick markers get stroke-dasharray="none" so they don't inherit a dashed line's pattern. Regenerates the 28 affected snapshots; title and href counts now match the upstream outputs exactly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The snapshots in test/output are Replot's own, so a green suite shows only that Replot's output has not changed. `yarn parity` canonicalises each snapshot and its counterpart in an Observable Plot checkout (attribute order, generated ids and class names, numeric precision, whitespace) and reports what differs per plot by tag and attribute, with --show for a unified diff and --allow/--fail for CI. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Groundwork for fixing the React pointer layer, which today only works for an unfaceted mark with explicit x and y and one consumer per plot. No behaviour changes yet: nothing is wired into <Plot>, so rendering is unaffected and no snapshot moves. Re-syncs src/interactions/pointer.js to upstream's current text (it was the older pre-pool version) and lifts three pure functions out of its closure: pointerOffsets, pointerAnchors and pointerSearch. Upstream's own closure calls them, so the React path and the imperative one share an implementation and a future re-sync stays a small diff. pointerSearch keeps upstream's arithmetic exactly, including the NaN exclusion of a datum missing a coordinate. Adds a React-free selection store keyed by the registration record itself rather than by aria-label, so two tips or a crosshair's several same-labelled sub-marks can no longer collide, and arbitration is per group rather than winner-take-all. Adds a test harness that dispatches hovers in SVG user space and can model a CSS-scaled plot. Freezes today's 307 differing plots in parity-allow.json as a ratchet, so later steps can be checked for parity regressions automatically. The store's tests are mutation-checked: each behaviour has a single-edit mutant that the suite is confirmed to kill. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
<Replot> now owns one pointer store, and every rendered (mark, facet) slot registers a record in it. A slot renders only the datum its record has been awarded and reads it through useSyncExternalStore, so a pointer move re-renders the slots whose selection changed and nothing else; the mark tree is untouched. The store remembers WHERE THE POINTER IS rather than which datum was selected: when the registry changes it re-runs the ordinary hit test at that position against the new index, data and anchors, so transforms, derived rows, reorders, insertions, deletions and filters need no special case. Previous revisions validated the retained index instead — first against the index's membership, then the data array's reference, then the datum's identity — and each proxy for "still the same datum" broke a legitimate case: groupX and binX synthesise new datum objects on every recompute, and a controlled plot rebuilds its rows on the very render the selection caused. The value contract is upstream's: the focused datum is assigned to the plot's root element (the <figure> when there is one, else the <svg>), and a bubbling input event fires whenever it changes — one report per change, not one per consumer — so viewof and addEventListener behave as they do in Observable Plot. The onValue prop follows the same rule. The hit test reports a miss exactly as upstream does, as maxRadius squared rather than Infinity, so a miss can win its group and blank every mark in it. An earlier revision dropped misses out of the arbitration and so showed a datum wherever upstream shows nothing. Removes usePointer, findNearest, formatTip and their types from the public surface. A pointer consumer is declared the way Observable Plot declares one, as a mark's tip option or a <Tip>/<Crosshair> component. Four of the new tests fail until the next commit, which reports the clearing value when the record holding the selection unmounts (#143). Co-Authored-By: Claude Code <noreply@anthropic.com>
settle()'s third step was documented but never written. A record that unmounted was cleared — so the tip went with its mark — while nothing was dispatched for it, leaving figure.value, the input event and the onValue prop holding a datum the plot no longer drew, and leaving the store's duplicate-value guard (lastValue) holding it too, so the next genuine selection of that same datum was swallowed as well. The report is made at the plot level, and after the survivors have been re-resolved: if nothing is showing while something is still reported, the plot reports the clearing value. It cannot be left to the per-record clearing dispatches, because those reproduce upstream's faceted suppression quirk (pointer.js:172; see suppressesClearingDispatch) and a suppressed clearing would leave the guard holding a vanished datum for good. Closes #143. Co-Authored-By: Claude Code <noreply@anthropic.com>
The tip consumers' DOM changed shape: the facets of one tip mark render as the children of a single promoted <g aria-label="tip">, the form upstream's render walker produces, rather than as one labelled group per facet. Everything else in the .svg baselines is that promotion moving attributes and cell transforms with it. The two viewof plots (pointer.tsx's pointerViewof and arrow.tsx's arrowTestPointer) now render the figure-and-textarea shape upstream's own pointer tests use, which has no SVG form, so their baselines move from .svg to .html. pointerRenderCompose comes off parity-allow.json: it matches the Observable Plot snapshot now rather than differing in an accepted way. Co-Authored-By: Claude Code <noreply@anthropic.com>
The Unreleased section listed only the onInput change as breaking. The hook and the helpers that went with it are gone from replot/react as well — usePointer, findNearest, PointerState, UsePointerOptions and formatTip — so they are listed as breaking too, along with what a caller declares instead. The sentence describing how a selection survives a recompute stated the rule the rewrite deliberately rejected, that the datum at the retained index decides whether the selection stands. What survives is the pointer position, so it now says that the tip follows the datum drawn under it and is dropped only when nothing is drawn there. PLAN.md's file tree and its interactions step still described a usePointer hook and a usePointer.ts module, neither of which exists; they now name the store and the context that replaced them. Co-Authored-By: Claude Code <noreply@anthropic.com>
Upstream decides the transform from the presence of the rotate channel, not from the resolved angle (vector.js:106-114, image.js:99-100, text.js:110-114): a zero angle is still written, and the vector anchor translate is written for every anchor but start, even for a zero length. Replot tested the values for truthiness, so a rotate channel with a zero value, and a zero-length vector, silently lost their (identity) transform. Three plot baselines change, each purely by the expected rotate(0) clause: caltrainDirection and vectorField gain rotate(0) on their zero-angle vectors, and kittenVariableRotate gains rotate(0) plus its transform-origin. Co-Authored-By: Claude Code <noreply@anthropic.com>
…eRendering The raster mark parsed every pixel's colour with d3-color, which cannot express a wide-gamut colour space, and it built the image-rendering attribute as a hyphenated key, which React rejects on an SVG element (and warns about on the console). Parse each colour through a 1x1 canvas instead, as upstream's raster.js does: getColorConverter fills one pixel, reads it back, caches every colour it has seen and returns an all-zero sentinel for a null colour, so a raster of one colour costs a single parse per render. The converter is cached on the mark, the render canvas is created in the mark's colorSpace, and colorSpace is now an option, lowercased onto the mark and defaulting to srgb, all matching upstream. Colour syntax the CSS parser knows but d3-color does not, such as oklch, survives as a result. The image element is rewritten to use React's camelCase imageRendering prop, and now takes the channel title as its own child rather than a sibling, which closes raster's part of #157. Regenerates rasterMixedOpacity, whose image data now matches upstream's pixel for pixel: 0 of its 160 pixels differ, where 36.25% differed by as much as 11 per channel before. Fixes #164 Co-Authored-By: Claude Code <noreply@anthropic.com>
Both marks emitted the channel-driven <title> as a sibling of the path, line or rect, so the tooltip attached to the enclosing group rather than to the shape: hovering a contour band showed the group's first title, or none at all, and a frame title, the frame having no group, landed directly on the <svg> and described the whole plot. Both now pass withTitleChild(...) as the element's child, as vector and line already did, and contour gives each band its own title rather than one for the group. Upstream reaches the same result through applyChannelStyles, which appends the title to the selected element. Raster's title moved with the raster rework in the previous commit. Fixes #157 Co-Authored-By: Claude Code <noreply@anthropic.com>
Upstream's area mark has a second stroke-only path (aria-label "area-line", area.js:96-152) selected through the line option, so that a translucent fill can be combined with a crisp edge without a second mark. Replot never ported it: `line` was not an option, so <AreaY line /> silently rendered a plain filled area, and test/plots/aapl-close.tsx worked around it with a separate <LineY>. AreaLine extends Area, taking the line mark's defaults (fill-opacity 0.3, stroke currentColor, stroke-width 1.5) and its markers via the shared markers() options. Its renderJSX walks the same groups as Area and emits two paths per group: the area without a stroke, and the topline without a fill, both inside one group so the direct and channel styles apply to the pair. Marker defs are hoisted into a single <defs>, as in Line.renderJSX. The aaplClose test plot goes back to the upstream form and its baseline now matches upstream's SVG byte for byte; the plot no longer needs its parity-allow.json entry. Co-Authored-By: Claude Code <noreply@anthropic.com>
…158) A marker option can be a custom function, but nothing ever called it: the name-only resolver fell through to "invalid marker", so a documented MarkerFunction was never drawn. markerToJSX now calls it with the stroke color and the render context and converts the element it returns back into React elements. Marker ids were built from the marker name and colour alone, so two marks drawing the same marker shared one def. That is not merely untidy: a def resolves currentColor — and any CSS variable — against its own ancestors rather than against the path that references it, so the second mark drew the first mark's colours. Ids are now minted per mark: markers() gives each mark a scope, which travels with the resolved marker, and the id for a (scope, marker, colour) triple is remembered so that a re-render — an interactive mark re-renders on every pointer move — does not remount its defs. The scope travels on the resolved marker rather than on the mark because the callers that build marker defs (rule, tick, line, link) dedupe them by id inside renderJSX, so the ids have to be stable within a render. Regenerates the 21 snapshots that carry markers. The only difference is the id form — plot-marker-N rather than plot-m-name-hash — and the def counts are unchanged; the preceding scheme could not have made them unique per mark. Co-Authored-By: Claude Code <noreply@anthropic.com>
linearRegression drew its confidence band without the title its line carries. Upstream applies the grouped channel styles — the title among them — to both paths, so the band gets withTitleChild too. hexgrid rendered its grid path with the mark's direct styles only, dropping every channel style, title and href. The grid is a single path over a singleton datum, so its channel values are read at index 0, which is what upstream's .datum(0) amounts to. tick's _transform methods went with the imperative d3-selection renderer; that path no longer exists, so the methods and the applyTransform import they were the last users of are gone. cell and bar still carry their copies. geo's options parameter was typed any, which hid the centroid(options) reassignment; it is a GeoOptions. No snapshots move: these paths are styled and titled through channels that no committed snapshot exercises. Co-Authored-By: Claude Code <noreply@anthropic.com>
Two defects in the difference mark, both in how it uses the clip renderJSX it hands its two areas. The render option was left in the options spread, so it reached all three sub-marks. Upstream destructures it and composes it onto the two areas only — composeRender(render, clipDifference(k, …)) (difference.js:38,60,77) puts the user's transform outside the clip, and the line never sees it. In Replot the option was inert on the areas (an own renderJSX suppresses the imperative bridge) and live on the line, so a difference mark with a render transform transformed the wrong mark. The areas now take the transform as an own render, over a mark that inherits the clipped one, so the bridge runs with the clipped area as `next` and `this` still the area. The clip ids were allocated inside the render closure, and the JSX paths re-render a mark whenever React re-renders the plot without recomputing it, so every pass rewrote the clipPath ids and the clip-path refs. They are now allocated once per facet and group and reused, which is what the imperative render got for free by running once per mark. A per-facet key is needed because each facet renders the areas with its own index and its own defs. Three tests come with the change: the transform runs for both areas and not the line, the clip ids differ per facet, and they hold still across a React re-render. The arrowTestDifferenceY baseline is regenerated because the new test file consumes clip ids earlier in the run, which removed a duplicate id from that snapshot: its difference clip had been colliding with the plot's frame clip, so two elements shared plot-clip-1. That collision itself is untouched here — difference clip ids come from style.js's global counter while the clip registry allocates its own plot-clip-N per render — and it still needs the registry to own the allocation. Co-Authored-By: Claude Code <noreply@anthropic.com>
`.claude/settings.local.json`, `.claude/worktrees/` and `.playwright-cli/` hold personal settings, agent worktrees and Playwright output. None of them belong in the repository, and a worktree tree can run to several gigabytes, so a single `git add -A` was enough to commit the lot. Nothing is tracked under either directory, so no negation is needed to keep a tracked file visible. Fixes #173 Co-Authored-By: Claude Code <noreply@anthropic.com>
The area and marker branches were written independently: the area test asserted the old marker id shape while the marker registry moved to document-unique ids. Assert the three refs name the def that was actually rendered, which is the property that matters and survives an id scheme change. Co-Authored-By: Claude Code <noreply@anthropic.com>
The c8 `include` glob matched only `.js`, so `yarn test:coverage` reported on the 47 plain JavaScript sources and skipped every TypeScript one, 97 `.ts` and `.tsx` files where the React layer and the newer marks live. It now covers `.js`, `.ts` and `.tsx`. React hook rule violations were invisible: nothing configured eslint-plugin-react-hooks. The plugin's flat recommended set now applies to `src/react`, with `rules-of-hooks` as an error. The three React Compiler rules (`immutability`, `refs`, `set-state-in-effect`) are reported as warnings rather than errors: they flag the mark registry's design, where marks register during render and record their resolved options on the mark instance so `<Plot>` sees every mark before its compute effect runs. The config records that reasoning, and the existing findings stay visible instead of being silenced. `rollup.config.js` and the four `@rollup/plugin-*` devDependencies are gone. The bundle is built by vite (`vite.bundle.config.ts`), `@mdx-js/rollup` is a vite plugin with its own dependency, and nothing else referenced rollup or those plugins. Vite still depends on rollup itself. Fixes #179 Co-Authored-By: Claude Code <noreply@anthropic.com>
Nothing exercised the package the way a consumer meets it, so an entry point that stopped resolving would only be noticed after publishing. `scripts/smoke-test.mjs` packs the package with `npm pack`, installs the tarball into a throwaway project, and then imports both entry points: through a bundler, through `tsc` as a TypeScript consumer, and through the UMD bundle in plain Node with a `d3` global, which is what a `<script src>` page provides. It also checks that every path the package declares exists in the tarball. `yarn test:smoke` runs it, and `yarn test` now includes it. The existence check failed on the state it was written against, and both causes were real: `main`, `module` and the default export condition pointed at `src/index.js`, which does not exist (they resolved only because the TypeScript toolchain substitutes the `.js` extension for `.ts`), and `jsdelivr`, `unpkg` and the `umd` condition pointed at `dist/plot.umd.min.js` while the bundle is built as `dist/replot.umd.min.js`. Fixes #174 Co-Authored-By: Claude Code <noreply@anthropic.com>
The workflow pinned Node 22. It now runs a matrix over the Node lines that are still maintained, 22 and 24, with `fail-fast: false`, so a failure on one line does not hide the result on the other. The issue proposed 18, 20 and 22; 18 and 20 reached end of life before this change, so testing them would only add failures that no one can act on. `yarn test:smoke` joins the steps, so every pull request packs the package, installs the tarball into a throwaway project and imports both entry points. It builds the published artifacts itself, as the step above it does. Co-Authored-By: Claude Code <noreply@anthropic.com>
CHANGELOG-2021.md through CHANGELOG-2024.md, and everything from 0.6.17 down in CHANGELOG.md, are Observable's release notes rather than Replot's. The 172 images under img/ existed only to serve them, so they go too; img/localhost.png stays, since CONTRIBUTING.md links to it. Replot's own entries stay: the Unreleased section, with its pointer notes and breaking changes, and the changelog's own title. Co-Authored-By: Claude Code <noreply@anthropic.com>
A titled dot rendered as <g><circle/><title/></g>. Upstream appends the title to the selected element (style.js applyTitle), so the dot itself is the element that carries it and no wrapper is emitted. The item key now follows the datum index, which is the index the title channel is looked up by, rather than the item's position in the list. Baselines: the 19 fixtures whose dots carry titles. Co-Authored-By: Claude Code <noreply@anthropic.com>
…at (#160, #161, #162) A format that returns null for one datum no longer throws: every datum is formatted first and the nulls dropped, as upstream does (tip.js:132-134). The visibility decision counts the showing items, so a tip whose data all format to null is empty rather than hidden. The box is no longer styled by the channels that feed it: the channels choose the content of the tip, not its look (upstream tip.js:136-137), so a per-datum fill cannot leak onto the box. The box is measured where a layout engine exists. TipItem renders the item once from an estimate and again from the text's getBBox in a layout effect, which re-runs upstream's fit (tip.js:222-243): the box is oriented to stay inside the frame, the lines shift to the measured left edge (tip.js:238), and a faceted tip is fitted to its own cell. The estimate remains what jsdom, the server and a static snapshot see, so those outputs keep a stable, meaningful box. Also from upstream: the corner anchors use m/2 in getPath and getTextTranslate; contours joins the ignored channels; and a channel whose values are all literal is dropped whatever its default scale, so an explicit symbol no longer appears as a tip line. Co-Authored-By: Claude Code <noreply@anthropic.com>
docs/ carried 68 .md files alongside their .mdx twins, inherited from the VitePress era. The docs build reads only .mdx: app.tsx globs ./**/*.mdx, and the link checker crawls .mdx and maps each .md link target onto its .mdx twin. The .md files were the twins: every page had both, and nothing read the .md copies. scripts/convert-docs.ts still globs docs/**/*.md, so it now matches nothing. It is left in place rather than deleted here. Co-Authored-By: Claude Code <noreply@anthropic.com>
boxX/boxY fill and the tree transform's stroke were fixed light-only greys, so a dark-mode page drew them at the wrong contrast. Both now answer the colour scheme with light-dark(), as upstream's do (box.js:19/45, tree.js:68). Baselines: the 9 fixtures that use the box or tree defaults. Co-Authored-By: Claude Code <noreply@anthropic.com>
yarn run mocha --conditions=mocha --parallel --watch test/marks/bar-test.js fails with ERR_MODULE_NOT_FOUND for src/index.js: the tests import "replot", which package.json maps to src/index.js, and that file exists only as the TypeScript source src/index.ts. Plain Mocha cannot resolve it, which is why test:mocha runs tsx. Route the documented command through tsx the same way, drop the --conditions=mocha flag (no "mocha" condition exists in the exports map) and the --parallel flag (pointless for one file), and keep the timezone that test:mocha sets, since date-dependent tests need it. Co-Authored-By: Claude Code <noreply@anthropic.com>
…#178) The three predicates destructured their argument, so asking about options that were never given threw. They now read through optional chaining, as upstream's do, and a missing object reads as "none of these channels". Co-Authored-By: Claude Code <noreply@anthropic.com>
The projection is not a scale in the registry; it lives on the rendering context, so exposeScales has to reach for it there (upstream scales.js:535). For that to mean anything the projection has to be the point-wise object plot.scale hands out, so createProjection returns stream/apply/invert and a projection implementation passed as an object is wrapped in the same shape (upstream projection.js:117-125). The frame transforms carry the inverse that invert needs. Tests ported from upstream (test/scales/scales-test.js:2313-2421): the projection is undefined when unused, a named one applies and inverts back to the same pixels, a projection implementation and a mercator object both work, and a projection round-trips into a second plot. Co-Authored-By: Claude Code <noreply@anthropic.com>
This was referenced Sep 24, 2026
Closed
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the September deep review: all 38 issues (#143–#180), plus #181, which was filed while this work was in progress.
Nothing was pushed while the work was done, so this is the first reviewable view of it. It is one branch because the fixes share a small number of hot files (notably
src/react/Replot.tsxandsrc/react/useMark.ts, which 14 issues converge on), and parallel work would only have produced conflicts.The branch now contains
origin/main, so the 0.1.0-alpha.1 packaging work is merged in and this PR merges as a fast-forward.By area
Pointer interaction (#143, #144). The selection store reports the clearing value when the mark showing a tip unmounts, instead of holding the vanished datum and swallowing the next selection of it. The removed
usePointerexports are documented as breaking.Marks (#156–#166).
AreaY linedraws a topline; custom marker functions draw, with defs scoped to the mark that uses them; a channel-driven<title>is a child of the element it describes;differencecomposes its render onto the areas and stops churning clip ids; raster parses colours through the canvas once instead of per pixel and honourscolorSpace; zero-valuedrotateand zero-length vectors are emitted rather than silently dropped.Tips (#159–#162, #178). The box is measured and the anchor flips to fit the frame; per-datum channel styles no longer tint it, which is what had put two plots on the parity allow list for the wrong reason; a
formatreturning null no longer throws on hover.React core (#145, #146, #148–#151). Marks and scales register from layout effects, so StrictMode and registration order are correct by construction rather than by accident;
computePlotfailures reach an enclosing error boundary; an unserialisable prop no longer keys a plot at random; an accessor that closes over React state recomputes the plot.Rendering (#152, #153, #168, #177, #180). No direct DOM manipulation from React, and
svg.scaleis the lookup function the imperative API exposes rather than the raw scales object; the svg shell is shared between the React and static paths; warnings drain after the marks render, so a render-time warning belongs to the plot that raised it; a faceted mark announces itself once rather than once per facet.Server rendering (#147).
renderToStringreturns the whole plot instead of an empty host. A hydrating client redraws it, because marks register from an effect, and README and the docs now say that rather than claiming SSR works unchanged.Packaging (#154, #174, #175). The
replot/reactentry moved out ofindex.tsx; themain,moduleandumdconditions and the bundle filename are fixed;reactandreact-domare required peers, asplot()andlegend()statically importreact-dom/server; a pack-and-install smoke test now imports both entry points through a bundler, TypeScript and the UMD bundle as part ofyarn test.Parity and tests (#169–#171). The parity comparison now runs in CI against a pinned upstream checkout, and
parity-allow.jsonrecords each plot's expected divergence as a machine-checked signature rather than a bare name, so a listed plot that diverges further fails the build. StrictMode coverage and the null-data assertions were replaced with ones that can actually fail.Docs and tooling (#172, #173, #179). The inherited Observable Plot changelogs, images and docs twins are gone; coverage measures the TypeScript sources; the React hooks rules are enabled.
Reconciling with origin/main
Merging the release line in hit 15 real conflicts, and three of them were disagreements rather than overlaps. The merge commit records each decision:
@dave-hillier/replot, the compileddist/esmbuild, its entry points and its release workflow, with this branch's entry rename applied on top.plot()stays inplot.tswith the rest of the fixes rather than moving back to the base'splotDom.tssplit.renderTransform.tskeeps the base'sjsxToDom(it builds DOM directly rather than importingreact-dom/server, the fix for Published package breaks the Vite dev server: CJS deps reach the browser unconverted #140) with this branch'suserRenderOfrestored — without it, a transform composed underpointer()never ran.Legend.tsxkeeps the base'sLegendPropsunion andresolveLegendElement(LegendProps is missing the plot-scopedscaleprop, so <Legend scale="color"> fails to typecheck #136) with this branch's render-phase registration, since effect-based registration never runs on a server.src/react/api.d.tswas a hand-written re-export that the entry rename turned into a self-reference, and it overwrote the generated declaration indist, so a TypeScript consumer saw no exports fromreplot/react. It is deleted.Verification
One CI finding
The Node matrix this work added turned up a snapshot that is not byte-stable across V8 lines.
projectionDomainRatioNCrenders the same polygon from a different starting vertex on Node 24 than on Node 22, because d3-geo's clipping is sensitive to the floating-point behaviour of the V8 the tests run on; the geometry is identical, only the ring's start moves. Reproduced locally on 24.21.0, byte-identical to what CI produced, and it is the only plot affected.The snapshots are therefore checked on the primary line only, and the other lines run every test except the snapshot harness, so a Node-specific breakage in the library still shows up.
🤖 Generated with Claude Code