fix: 7 bugs and 6 minors from a code review#48
Merged
Conversation
Nine fixes, each with a regression test that fails against the prior code. Crashes and lost state - parseColor resolved a color() space off a plain object, so `color(constructor …)` / `color(__proto__ …)` passed the unknown-space guard as an inherited member and reached the conversion maths as a non-space — a TypeError out of set() / fromJSON() / a persisted restore. Null-prototype, like the plot whitelist and the presets bag. - applySnapshot and applySet ran their entries unguarded, so one throwing control abandoned every later value AND the notify with it, leaving the panel silently half-restored. Isolate per entry, the same way createControl / metaFor / notify / applyConditionals already do. Stuck and leaked state - The header drag took no pointer capture below the 4px threshold, so a release just off the header stranded dragId — and the next plain hover then lifted the panel into a drag with nothing pressed. Bail on buttons === 0, as the slider, the interval and dragGesture all do. - The monitor poll and the FPS rAF loop deliberately idle through the "built but not appended yet" window, so a panel destroyed before it ever connected never stopped them. Controls now hand back an optional destroy() that the panel adopts into its cleanups. - Undo history grew without bound; cap it at 200 steps. Unreachable behaviour - Double-click-to-reset on a slider was shadowed by its own click-to-type: once an 800ms hover armed editing, click #1 swapped the readout for the inline input and the dblclick landed there instead. Catch it on the input too — commit, close, then reset. - reset() in the lazy window routed through the toolbar button, which is disabled until assemble(), so it vanished while set/setMany/fromJSON queued. It now queues with them, replaying in call order. - opts.filter with toolbar:false mounted a filter input nothing could reveal; refuse it with a warning instead. - Markup mode keyed its copy by label (same-labelled hosts overwrote each other) and echoed object-valued controls onto data-value as the literal "[object Object]" — the very attribute its parsers read back. Key by data-key when present, and serialise objects in the comma form. Also: null-prototype THEME_ALIASES / MODE_INTERP / INTERP_MODE (same class as the colour bug, currently harmless); cap stepPrecision at toFixed's 100-digit ceiling, so a sub-1e-100 step no longer throws the control away; route the plot's "y =" label through txt() rather than el()'s innerHTML arm; npm audit fix for the dev-only esbuild and undici advisories (lockfile only, nothing ships).
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.
A review of the whole kit — a full read of
src/(~2,900 lines) plusbuild.mjs,site/, the tests and CI — then a second pass over my own findings. 7 confirmed findings and 6 minors, all fixed here. Tests go from 39 to 48.Each new test was run against the source with the fix stashed, and kept only once it failed there with the symptom described. That run also hung the test runner for two minutes, which is itself the proof for finding 4 — the leaked interval keeps Node's event loop alive.
Crashes and lost state
1.
parseColorread acolor()space off a plain object (colour.ts)color(constructor …)andcolor(__proto__ …)return an inherited member. It's truthy, so it passes theif (!space)guard and reaches the conversion maths as something that isn't a space:Every other lookup table in the kit already guards this —
TYPED_METAandDATA_VALUEusehasOwn, the plot whitelist and presets bag are null-prototype, andexpr.test.mjsassertscompileExpr("constructor(1)") === null. This one was missed. It's now null-prototype, so an unknown space falls to the existing default. No pollution and no code execution — just a crash where the kit should degrade.2.
applySnapshotandapplySetdidn't isolate per entry (core.ts)One control that throws stopped the whole restore, and skipped the notify with it:
The panel ends up half-restored with nothing told about it, and that partial state can be saved back over the good snapshot. Each entry is now wrapped, the same way
createControl,metaFor,notifyandapplyConditionalsalready are. This is the more useful of the two fixes: it holds even if some other control throws later.Stuck and leaked state
3. Header drag left
dragIdset, so the panel followed an unpressed cursor (core.ts)No pointer is captured below the 4px drag threshold. So a release just off the header — a little drift onto
.tw-body, or the page scrolling out from under a held button — never reachesendDrag. The next ordinary hover then crosses the threshold against the old origin:Clicking once clears it. The slider, the interval and
dragGestureall handle this withe.buttons === 0; the header didn't. That's the fix.4. Monitor and FPS loops outlived
destroy()on a panel that never mounted (monitor.ts)Both loops idle through the "built but not appended yet" window on purpose, so they never see an unmount to stop on — and
destroy()had no way to reach controls at all. The same happens for a panel mounted into a detached subtree.Controls can now return a
destroy(), which the panel adds to its cleanups.5. Undo history had no cap
A deep clone of every value, committed every 350ms of editing, with nothing dropping off the end. Capped at 200 steps.
Things you couldn't reach
6. Double-click-to-reset on a slider was blocked by click-to-type (
core.ts)The readout is both the only reset target (
.tw-slider-labelispointer-events: none) and the trigger for the 800ms hover editor. Resting the pointer there is what you'd do before double-clicking, so click #1 swapped the readout for the inline input and thedblclickwent to the input instead:The docs describe both on the same target: "hover the value and click to type (double-click resets)". Checked with real mouse input in Chrome; click-to-type still commits as before.
7.
reset()during the lazy window did nothingIt went through
resetBtn.click(), andHTMLElement.click()does nothing on a disabled control — the toolbar is disabled untilassemble().set,setManyandfromJSONall queued;resetdidn't. It now queues with them and replays in call order.8.
opts.filterwithtoolbar: falsebuilt a filter you couldn't openThe search button lives in the toolbar, so nothing could set
.is-searching. It now warns and skips instead.Minors
THEME_ALIASES,MODE_INTERPandINTERP_MODEmade null-prototype — same shape as finding 1, harmless today.stepPrecisioncapped attoFixed's 100-digit limit. A step below 1e-100 threw aRangeErrorthat dropped the whole control.data-valueas"[object Object]"— the attribute its own parsers read back. It now usesdata-keywhen present (suffixing real duplicates) and writes objects in the comma form, so a point round-trips.y =label moved fromel()'s innerHTML argument totxt().npm audit fixfor the esbuild and undici advisories. Both are dev-only; lockfile changes only, nothing ships.What I checked and found fine
No XSS sink — every
innerHTMLandinsertAdjacentHTMLcall takes a static icon constant, and host data goes throughtxt()/textContent. No prototype-pollution route via the schema,set(), dotted paths or presets, including a preset named__proto__. The plot evaluator's whitelist and length cap hold. CI is in good shape: actions pinned by SHA, least-privilegepermissions, nopull_request_target, and tag/version verification before publish. The light-twin drift check inbuild.mjsis still a good invariant.The second pass also changed my own conclusions. It moved the emphasis on finding 1 (the crash was the trigger, the missing isolation was the real problem), corrected my description of finding 3 (I first wrote it as "permanently undraggable", which is wrong), moved three findings down to minors, and dropped one I couldn't reproduce.
One thing to look at
Finding 3's fix changes how dragging behaves: a header press that ends without a proper release now ends the drag rather than leaving it open. That matches the other drag surfaces, but it's the only change here that affects an interaction rather than a failure path.
Verification
npm testgreen (48/48), build clean, sizes unchanged at ~19 KB split and ~34 KB single. CI green on Linux/Node 22. Interaction fixes checked with real mouse input in Chrome; docs site re-checked on the numbers, colour/gradient, monitors and markup pages with no console errors.