Releases: kwy404/Voodoo.js
Release list
0.13.0
Changed
-
v-forno longer rediscovers a change it was already told about. A list is
one effect over a whole collection, so when the collection changes something
has to decide what that means fornrows. That decision used to cost the
same whatever had happened: every row's:keythrough the expression
interpreter, one allocated object per row, three hash structures the size of
the list, and a placement pass reading a DOM property per row. Reading the
rows through the reactive proxy also subscribed the list to allnindices
and to the key's property on allnitems, so every re-render removed the
effect from those dependency sets and added it back — about4nhash
operations before the reconciler had looked at anything.Three things replace it.
The mutation is the answer.
push,pop,shift,unshiftandsplice
now run against the raw array and record what they did. A list re-rendering
reads that record instead of comparing:rows.splice(5000, 1)on ten thousand
rows says one row left at index 5000, and nothing else is examined. A batch of
mutations in one tick composes into one range.A compiled key and a scan from both ends. When a new array arrives there is
no record, so the changed region is found by comparing keys inward from both
ends.:key="row.id"is compiled once to a property read rather than
interpreted per row. This is the floor: knowing that row 9,999 did not change
means looking at row 9,999, and no fingerprint avoids that, because computing
one means reading every key first.Nothing outside the region is touched. Not read, not written, not moved. A
reused row is only written to when its data actually differs — including the
case where the incoming value is a reactive proxy of the object already stored,
which[...state.rows]produces and which a plain identity test reports as
changed forever.edit before after shift the first off 10.000, in place 61.6 ms 0.096 ms 641x swap 2 rows of 10.000 9,585 ms 16.6 ms 576x unshift 1 onto 10.000, in place 56.0 ms 0.297 ms 189x splice 1 into the middle of 10.000, in place 48.6 ms 0.684 ms 71x splice out the middle of 10.000, in place 45.3 ms 0.853 ms 53x pop the last off 10.000, in place 37.5 ms 1.51 ms 25x push 1 onto 10.000, in place 34.2 ms 1.94 ms 17.6x re-assign an identical 10.000-row array 52.4 ms 9.57 ms 5.5x prepend 1 to 10.000, new array 38.6 ms 8.40 ms 4.6x remove the middle of 10.000, new array 41.7 ms 10.2 ms 4.1x insert 1 into the middle of 10.000, new array 42.5 ms 10.7 ms 4.0x append 1 to 10.000, new array 40.9 ms 11.6 ms 3.5x reverse 10.000 rows 3,989 ms 4,130 ms unchanged — see below create 10.000 rows 809 ms 809 ms unchanged — see below Median of up to 100 samples per case, jsdom, Node 24 on an i5-4440. Allocations
per reconciliation went from one object per row to none: 20,011 to 0 on a
ten-thousand-row list.Two of those rows are not algorithmic wins and are marked as such. Reversing a
list has to move all but one of its rows however the diff is computed, and
building one is the cost of inserting the nodes; in both, the clock is measuring
the DOM rather than the reconciler. The swap row is not an algorithm win either —
it is a bug the counters found: the old placement pass turned a two-row swap into
19,994 DOM moves, because one row that did not line up made every row after it
fail the same check.Full measurements, the counters behind them and how to reproduce any of it:
benchmarks/README.md. The algorithm and its
invariants:ARCHITECTURE.md. -
An array mutator fires one notification instead of one per element.
splice(5000, 1)on a ten-thousand-row array used to pass every element it
shuffled through thesettrap, producing roughly five thousandtrigger
calls — each allocating a Set and walking it — to describe a single removal.
The mutators run against the raw array now and notify once.reverseand
sortare instrumented on the same terms. -
A write to one array index reaches whoever iterates the array. This used to
work by accident:v-forread every element through the proxy and so
subscribed to allnindices. Now the list subscribes to the collection, and
triggerroutes an element write toITERATE_KEYexplicitly. -
:keyis read without subscribing to it. A key identifies a row; it is not
meant to be a value that changes underneath one. Reading keys untracked is what
removes the per-item dependency churn above. A list still re-renders on every
structural change and on any element write; what no longer happens is a
re-render triggered by mutating the property a key was computed from.
Fixed
- A NaN key rebuilt its row on every render.
NaN === NaNis false, so a
reconciler comparing keys with===alone throws that row away and builds it
again every time. Keys are compared with SameValueZero now.
Added
-
A reconciliation benchmark suite,
benchmarks/reconcile/: 24 scenarios
across both families — a new array assigned, and the reactive array mutated in
place — timed with the counters off and counted with them on, so a number that
moved can be explained rather than just reported. Plus a scaling sweep from
1.000 to 50.000 rows, a path probe that shows which route each edit actually
took, and a leak probe for teardown. -
Ten generated charts in both READMEs, drawn from the result JSON rather
than hand-edited: before/after, rows visited, cost against list size, which
path ran, DOM operations, framework comparison, size against speed, bundle
sizes, what ships in the box, and teardown memory. -
runtime/metrics.ts, counters for the reconciler. Off by default, behind
one property read, and deliberately not part of the public API — the benchmark
compiles its own entry point to reach them.
Read more in the CHANGELOG, or browse the documentation and the playground.
0.12.5
Fixed
-
v-ifandv-forleaked everything they had ever rendered. Both take
their element out of the document and keep it as the thing they clone from.
The cleanup for that element — the effect scoperunDirectiveregistered on
it — is keyed by that element, anddestroy()walks live children only. Once
detached, nothing ever reached it: the scope was never stopped, and it held
the template, every rendered block and every node inside them.Fifty mount-and-destroy cycles per size, heap forced between samples, retained
per cycle:rows before after 50 393.6 KB 11.1 KB 100 749.7 KB 6.5 KB 200 1498.4 KB 13.0 KB The old figure doubles with the list because it was proportional to what had
been rendered; the new one does not grow, and what remains is noise.It cost time as well as memory: the
v-iftoggle benchmark went from 5,683 ms
to 3,079 ms once the garbage stopped accumulating. The project's own memory
suite could not finish before this — it exhausted a 3.8 GB heap and took the
rest of the run down with it — and now completes all thirteen cases.The hypothesis was already written down in
benchmarks/memory/index.mjs, with
an A/B pair built to measure it. What was missing was routingdestroy()to
the detached templates, which it now does by recording them against the parent
they left.
Changed
-
The comparison numbers in both READMEs were re-measured, and the honest
reading changed with them. Create improved in absolute terms, 97.70 ms to
80.81 ms, while the position moved from third of seven to fourth, because the
other frameworks measured differently in the same run. Preact, Solid and
Voodoo now finish within 0.4 ms of each other on a thousand-row create, which
is a three-way tie a ranking column cannot express. Both tables say so. -
The teardown chart is generated from the measurement rather than hand-
edited, the way the framework comparison already was.
node --expose-gc scripts/measure-teardown.mjsbuilds both versions, measures
them, restores the source, and writes the JSON that
scripts/chart-teardown.mjsdraws.
Read the full changelog in CHANGELOG.md.
0.12.4
Fixed
-
The documentation taught a form of
stylethat does nothing. The README,
its Portuguese translation, the JSX guide and the module's own header all
showedstyle="{{ backgroundColor: color }}"marked as working. It is not: a
plainstyleattribute is never treated as an expression, because only
v-*,:and@attributes are read. The braces survive verbatim as a
nonsense CSS declaration and the element renders unstyled.Measured rather than argued: the quoted form leaves the attribute literal and
the element full width with no background;:style="{ ... }"resolves to
width: 60%; background: tomatoand renders. All four places now show the
binding, and say plainly that the earlier advice was wrong.
Added
-
The playground goes from 51 examples to 122, every one styled with
Tailwind and each verified rendering in a browser rather than assumed.53 are JSX, which is where the interesting cases live: nested maps, real
if / else if / else, callbacks with a block body and an early return,
ranges throughArray.from,{ const ... }blocks with nov-dataat all,
and tables more than once — loose text inside a<tbody>is foster-parented
out of the table by the HTML parser, and it still works, which is the hardest
thing the feature does.The rest are finished interfaces rather than fixtures: a sign-up form with
live validation, a command palette, a masked payment form with a live card
preview, a paginated fetch with a loading skeleton, an optimistic create that
shows its in-flight row, a cart backed by a store, undo and redo, a custom
directive, and a virtualised ten-thousand-row list.
Read the full changelog in CHANGELOG.md.
0.12.3
Fixed
-
0.12.2 shipped bundles that reported the wrong version.
V.versionsaid
0.12.1and the banner at the top of every file said it too, because the
release ran build → stamp instead of stamp → build, and stamping is what
rewrites the constant insrc/core.ts. Nothing failed: the tests passed, the
tag went up, the assets uploaded, and only a byte-level diff between two
builds gave it away.check-site-bundles.mjsnow compares the version baked into each bundle
againstpackage.jsonand fails when they disagree, so the ordering mistake
cannot reach a release again. It also normalises line endings before hashing,
which is why the 0.12.2 run went red on a tree where the bytes that matter
were identical.Anyone on 0.12.2 gets correct behaviour and a lying
V.version. Upgrading
fixes the label. -
The playground editor put your caret in the wrong place. The highlighted
layer and the textarea over it have to lay text out identically, character for
character, and two things broke that. Cascadia forms=>into one glyph where
the text is plain and cannot form it where the same two characters land in
different spans, so every arrow in the file shifted the rest of its line. And
comments were rendered in italic, whose glyphs are a different width from the
upright ones being typed. Ligatures are off in both layers now and comments
are upright, which is what Dark+ does anyway. -
Selected code in the playground turned into a blank blue bar. The
textarea's own text is transparent, so a solid selection colour painted over
the coloured text underneath it. The selection is translucent now. -
Forms inside a playground example did nothing. The preview frame is
sandboxed withoutallow-forms, and a sandbox without it blocks submission
before any handler runs — so<form @submit.prevent="...">never got the
chance to prevent anything, and the console said the frame was sandboxed.
allow-formsandallow-popupsare granted;allow-same-originis still
withheld, so the frame keeps an opaque origin and cannot reach the page.
Read the full changelog in CHANGELOG.md.
0.12.2
Fixed
-
The theme never applied on a page that starts the library itself.
theme.init()sat inside the deferred boot, after thedata-manualand
autoStartguard, so any page that callsV.start()on its own — this
project's own landing page among them — never ran it. The choice was stored
correctly andV.theme.currentread it back correctly, and nothing ever wrote
data-themeonto<html>.From the outside it looked like a dead button on every second press: click,
page goes light, reload, page is dark again, click,light→darkand the
already-dark page does not move. Reported as "the site never goes light, and I
cannot tell which theme is which".init()now runs unconditionally. It still writes nothing unless the visitor
actually picked a theme, so a page that merely included the script is left
alone exactly as before. -
Timers were unreachable from expressions.
useEffectshipped in 0.12.0
with cleanup as its headline feature and no way to register anything that
needs cleaning up: the documented example and the playground sample both
calledsetIntervaland both failed with"setInterval" was not found.setTimeout,setInterval, their clears and the animation-frame pair are
available now, each behind a guard that refuses the string form —
setTimeout('alert(1)')asks the browser to compile that text, which is
evalunder another name, and never compiling anything is the reason this
library works under a strict Content Security Policy. They resolve the global
when called rather than when the module loads, so fake timers and polyfills
are not bypassed. -
An accordion built from collapse toggles did nothing at all. Two handlers
ran for one click:v-collapse-togglelistens on the button,v-accordion
delegates from the container, and both calledtoggle()on the same panel.
The first opened it, the second closed it again on the way up. Nothing moved,
aria-expandednever changed, and there was no error to go on.The accordion now recognises a header that toggles itself and keeps only the
single-open rule. -
v-tablerendered every cell empty for positional rows. Cells were read
by column key alone, so a row given as an array found nothing. The components
page's own example passes[['Ada', 'Engineer']]against columns
['Name', 'Role']: the header row was right, the row count was right, and
every cell was blank. Both shapes work now.
Read the full changelog in CHANGELOG.md.
0.12.1
Fixed
-
The playground stopped following npm. It loaded the library from a pinned
CDN URL, so a release had to reach the registry before the playground could
show it, and jsDelivr's edge cache then added hours more. Every report of "the
playground is on the old version" traced to that one line, and the answer each
time was to publish and wait.It now loads the copy deployed beside it, resolved from its own script URL.
There is nothing left to keep in sync, on GitHub Pages or off a local checkout. -
A page could not show whether sound was muted.
$sound.mutedreads a
module variable andlocalStorage, both invisible to the Proxy, so
v-show="$sound.muted"rendered once and froze. With no way to display the
state, pressing the mute button looked like it did nothing — which is exactly
how it was reported.Reads now subscribe.
v-mutealso setsv-mute-onalongsidev-muted, and
the muted class finally has styling: it existed and nothing ever painted it, so
the button was identical either way. -
JSX had drifted to the bottom of the playground. The examples file is
appended to, and the group order was whatever editing history left behind, so
the one thing this library has that the others do not sat under nine other
groups. Order now follows the label map that declares the intent rather than
the order examples happen to be listed in. -
The version guard asked the wrong question. It checked whether the minor
line was on the CDN while the stamp writes the exact version. Once 0.12.0
was published, every later patch would have passed a check it should have
failed —0.12resolves, so the site would be pinned to a0.12.1that did
not exist yet and would serve a 404 for its own library. The guard exists to
prevent precisely that. -
Stamping straight after a release crashed.
git ls-fileslists what the
index knows, not what is on disk, and the release script writes
.release-notes.md, uses it and deletes it. That build artefact is no longer
tracked, and files missing from disk are skipped.
Read more in the CHANGELOG, or browse the documentation and the playground.
0.12.0
Added
-
React hooks, written into HTML attributes.
useState,useEffect,
useMemo,useRefanduseContext, reachable by bare name inside any
expression. They are a surface over primitives that already existed —effect,
computed,refandstore— so the whole set costs 70 bytes in the core
bundle. The point is not new machinery. It is that someone arriving from React
can write what they already know and have it mean the right thing here.<div v-data="{ count: useState(0) }"> <p>You clicked {count} times</p> <button @click="count++">click</button> </div>
Three differences from React, all deliberate:
The dependency array is optional. Reads are tracked through a Proxy, so an
effect with no array re-runs when something it actually read changes. The array
narrows that when you want to; it is not needed for correctness.No rule of hooks. Slots are keyed per element in call order within one
evaluation, andv-dataandv-initeach evaluate once per element. Calling a
hook inside a branch shifts nothing.No setter pair.
useStatereturns the value, not[value, setValue].
Reactive objects unwrap refs, socount++is the update and there is no
.valueanywhere in the markup.There is no
useReducer,useCallbackoruseLayoutEffect, and the reasons
are in the guide rather than left to be discovered. -
v-datacan read itself. A key can now use the keys written before it:<div v-data="{ n: useState(4), dobro: useMemo(() => n * 2) }">
Previously
v-datawas evaluated in the PARENT scope and handed over
afterwards, sodobrothere producedNaN—ndid not exist yet. It is now
filled one key at a time into the scope it is defining. Order matters, and only
backwards.A
v-datathat is not a plain object literal — a spread, a computed key, a call
returning an object — keeps the old behaviour, because there is no partial state
to expose midway through those.
Fixed
-
$theme.resolvedcontradicted the screen. It consulted only the stored
choice and the operating system, never thedata-themethe page was actually
wearing.apply()deliberately leaves an authored attribute alone, so a page
written as light, opened on a machine set to dark, displayed light while
reporting'dark'.This project's own documentation showed it: the theme page sat on a white
background with a live example inside it insisting "You are on the dark theme." -
The theme was not reactive, so text never followed it. Everything the theme
derives from is invisible to the Proxy —localStorage, a module variable, an
attribute, a media query — sov-show="$theme.resolved === 'dark'"rendered
once and then froze. Switching the theme appeared to need two clicks: the first
changed the theme, and only a later unrelated render made the text catch up.Reads now subscribe, and a
MutationObservercoversdata-themebeing written
by someone else, which is what a documentation shell pushing its theme into an
example frame does. -
The site was serving a different library from the one being tested.
site/*.min.jsare copies kept by hand and nothing checked them. All three had
drifted by a session's work, so the documentation, playground and landing page
ran an older build while every test passed against the new one. A feature could
be written, tested, committed and published and still be absent from the site.npm run check:sitenow compares them and CI fails on drift.
Read more in the CHANGELOG, or browse the documentation and the playground.
0.11.2: the comma operator, and the tag the docs hand out
Fixed
-
The comma operator works inside parentheses.
(a, b)evaluates both and
yields the last, and it did not parse. The top level already accepted,
between statements, so@click="a++, b++"worked while
@click="ok && (a++, b++)"failed withExpected ")" but found ",", a
distinction nobody would predict.It surfaced through this project's own playground: the todo example could not
add an item, becausedraft && (items.push({ text: draft }), draft = '')is
how you write "do these two things only if", and that is exactly the shape
that did not parse. The example had shipped using syntax the parser rejected.Verified in a browser, the whole todo: adding clears the field and updates the
count, the three filters select the right items, and the checkbox toggles with
the count following.Expressions the interpreter answers differently from JavaScript: still 0.
Valid JavaScript it refuses: 3 down to 2, and both remaining are regex
literals.
Changed
The rest of this release is what the documentation tells people to load, which
had been wrong in three separate ways at once.
-
Every CDN tag names the full build. Twenty-one of them named
voodoo.min.js, the essential build, which does not contain JSX. So the
installation page handed out a tag, the JSX page handed out an example, and
putting the two together produced a page printing its own source back with
nothing to explain why. The size cost is stated rather than hidden: the full
build is 132 KB gzipped against 84, and dropping.fullis one edit. -
The version is pinned exactly, not to the minor line.
voodoojs@0.11is
the same string for 0.11.0, 0.11.1 and everything after, so a reader could not
tell whether a fix had landed. Worse, jsDelivr caches a range at the edge for
twelve hours: 0.10.1 fixed the playground and the playground went on serving
0.10.0 until the cache was purged by hand, and 0.11.1 did it again. An exact
version is a different URL that no stale range can shadow, and it is live the
moment npm has it. -
The stamp finds files instead of being told about them. It worked from a
hand-written list of four, and a hand-written list is always missing
something. What it was missing, ten releases in:SECURITY.mdand four pages
underdocs/still saidvoodoojs@0.1.0, andscripts/components-page.mjs,
which generatessite/components.html, was frozen at0.5and regenerated
that page wrong every time it ran. It asks git for the tracked files now.
CHANGELOG.mdstays excluded, because rewriting the versions in old entries
would turn a record of what happened into a claim that never was. -
Three paragraphs that described the old pinning were left saying
0.4,0.4
and0.6, which stopped being merely stale and became false once the pin
changed.
Full notes: v0.11.2
Read more in the CHANGELOG, or browse the documentation and the playground.
0.11.1
Fixed
-
A
{ const ... }block above a table works. 0.11.0 taught JSX regions to
survive a table, and this is the case it still got wrong: the block and the
table's own expression end up in a single text node, because foster parenting
moves{rows.map(r => ( ))}out of thetbodyand the browser joins it onto
whatever text is already there.The block reader tested "starts with
{and ends with}", which that merged
node satisfies, so it swallowed the map along with the declarations and
neither one ran. It takes only the first balanced group now, respecting
strings, and leaves the rest of the node behind for the region pass. Both
forms are verified in a browser: the table withv-dataand the table with
the data in aconstblock. -
The README that ships to npm carries the right version. Stamping happens
during the release, before the version reaches the registry, so the pin guard
correctly refused to move it and the tarball went out pointing at the previous
line. The npm page for 0.11.0 told everyone to load 0.10, and every release
before it had the same fault.Files that ship in the tarball now pin unconditionally. The guard is right for
a page on GitHub Pages, which goes live immediately and must not name a
version the CDN cannot serve; it is wrong for a file that only becomes visible
by being published, where the version is guaranteed to exist by the time
anyone reads it.packages/cli/README.mdwas never stamped at all and is now.
Full notes: v0.11.1
Read more in the CHANGELOG, or browse the documentation and the playground.
0.11.0
A JSX region works inside a table. This was reported as broken, then written
off as impossible, and it was neither.
<table>
<tbody>
{rows.map(r => (
<tr>
<td>{r.name}</td>
<td>{r.score >= 60 ? <b>pass</b> : <b>fail</b>}</td>
</tr>
))}
</tbody>
</table>Added
-
Recovery from foster parenting. Loose text is not allowed inside
<table>
or<tbody>, so the HTML parser moves it out and keeps the elements in. The
text and its template land in different parents, which is why the sibling walk
found a balanced region with nothing in it and declined, leaving the raw
expression printed above the table.Nothing is lost, though, only moved, and moved predictably: the text keeps the
empty parentheses where the element used to be, and the element is in the
table alongside. Matching one against the other puts the expression back
together, and the rendered rows are anchored in thetbodywhere they belong
rather than beside the table, where a<tr>is dropped by the browser.The rule is deliberately narrow, because guessing would silently claim rows
somebody wrote by hand. The region must be balanced, contain no element of its
own, sit next to a table, and have exactly as many empty()groups as that
table hastbodyrows. Atheadrow is never taken.Both foster-parenting orders are accepted. The specification says the text is
inserted immediately before the table, which is what Chrome does; jsdom puts
it after and splits it into fragments. Neither is worth depending on. -
Three playground examples for it: the same table with
v-data, the same table
with the data declared in a{ const ... }block and no attribute anywhere,
and the same idea as a plain list. Seventeen JSX examples in total.
Known limitations
- A JSX region inside a
v-fortemplate does not render. The template is cloned
once per row during the walk, and regions are taken out of the page before it,
so each clone receives an anchor with nothing attached. Usev-ifon the
element, or a JSX region instead ofv-for, not both on the same subtree. - The nested-region-inside-a-recovered-row case is verified in a browser and
skipped in the unit suite, because jsdom's non-conforming foster parenting
cannot express it. Contorting the test to match jsdom would test jsdom.
Full notes: v0.11.0
Read more in the CHANGELOG, or browse the documentation and the playground.