Skip to content

Releases: kwy404/Voodoo.js

0.13.0

Choose a tag to compare

@kwy404 kwy404 released this 05 Sep 02:27

Changed

  • v-for no 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 for n rows. That decision used to cost the
    same whatever had happened: every row's :key through 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 all n indices
    and to the key's property on all n items, so every re-render removed the
    effect from those dependency sets and added it back — about 4n hash
    operations before the reconciler had looked at anything.

    Three things replace it.

    The mutation is the answer. push, pop, shift, unshift and splice
    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 the set trap, producing roughly five thousand trigger
    calls — each allocating a Set and walking it — to describe a single removal.
    The mutators run against the raw array now and notify once. reverse and
    sort are instrumented on the same terms.

  • A write to one array index reaches whoever iterates the array. This used to
    work by accident: v-for read every element through the proxy and so
    subscribed to all n indices. Now the list subscribes to the collection, and
    trigger routes an element write to ITERATE_KEY explicitly.

  • :key is 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 === NaN is 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

Choose a tag to compare

@kwy404 kwy404 released this 04 Sep 22:25

Fixed

  • v-if and v-for leaked 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 scope runDirective registered on
    it — is keyed by that element, and destroy() 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-if toggle 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 routing destroy() 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.mjs builds both versions, measures
    them, restores the source, and writes the JSON that
    scripts/chart-teardown.mjs draws.


Read the full changelog in CHANGELOG.md.

0.12.4

Choose a tag to compare

@kwy404 kwy404 released this 04 Sep 20:45

Fixed

  • The documentation taught a form of style that does nothing. The README,
    its Portuguese translation, the JSX guide and the module's own header all
    showed style="{{ backgroundColor: color }}" marked as working. It is not: a
    plain style attribute 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: tomato and 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 through Array.from, { const ... } blocks with no v-data at 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

Choose a tag to compare

@kwy404 kwy404 released this 04 Sep 20:35

Fixed

  • 0.12.2 shipped bundles that reported the wrong version. V.version said
    0.12.1 and 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 in src/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.mjs now compares the version baked into each bundle
    against package.json and 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 without allow-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-forms and allow-popups are granted; allow-same-origin is still
    withheld, so the frame keeps an opaque origin and cannot reach the page.


Read the full changelog in CHANGELOG.md.

0.12.2

Choose a tag to compare

@kwy404 kwy404 released this 04 Sep 20:21

Fixed

  • The theme never applied on a page that starts the library itself.
    theme.init() sat inside the deferred boot, after the data-manual and
    autoStart guard, so any page that calls V.start() on its own — this
    project's own landing page among them — never ran it. The choice was stored
    correctly and V.theme.current read it back correctly, and nothing ever wrote
    data-theme onto <html>.

    From the outside it looked like a dead button on every second press: click,
    page goes light, reload, page is dark again, click, lightdark and 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. useEffect shipped 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
    called setInterval and 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
    eval under 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-toggle listens on the button, v-accordion
    delegates from the container, and both called toggle() on the same panel.
    The first opened it, the second closed it again on the way up. Nothing moved,
    aria-expanded never 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-table rendered 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

Choose a tag to compare

@kwy404 kwy404 released this 04 Sep 19:42

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.muted reads a
    module variable and localStorage, 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-mute also sets v-mute-on alongside v-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.12 resolves, so the site would be pinned to a 0.12.1 that 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-files lists 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

Choose a tag to compare

@kwy404 kwy404 released this 04 Sep 19:30

Added

  • React hooks, written into HTML attributes. useState, useEffect,
    useMemo, useRef and useContext, reachable by bare name inside any
    expression. They are a surface over primitives that already existed — effect,
    computed, ref and store — 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, and v-data and v-init each evaluate once per element. Calling a
    hook inside a branch shifts nothing.

    No setter pair. useState returns the value, not [value, setValue].
    Reactive objects unwrap refs, so count++ is the update and there is no
    .value anywhere in the markup.

    There is no useReducer, useCallback or useLayoutEffect, and the reasons
    are in the guide rather than left to be discovered.

  • v-data can read itself. A key can now use the keys written before it:

    <div v-data="{ n: useState(4), dobro: useMemo(() => n * 2) }">

    Previously v-data was evaluated in the PARENT scope and handed over
    afterwards, so dobro there produced NaNn did 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-data that 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.resolved contradicted the screen. It consulted only the stored
    choice and the operating system, never the data-theme the 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 — so v-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 MutationObserver covers data-theme being 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.js are 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:site now 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

Choose a tag to compare

@kwy404 kwy404 released this 04 Sep 16:41

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 with Expected ")" but found ",", a
    distinction nobody would predict.

    It surfaced through this project's own playground: the todo example could not
    add an item, because draft && (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 .full is one edit.

  • The version is pinned exactly, not to the minor line. voodoojs@0.11 is
    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.md and four pages
    under docs/ still said voodoojs@0.1.0, and scripts/components-page.mjs,
    which generates site/components.html, was frozen at 0.5 and regenerated
    that page wrong every time it ran. It asks git for the tracked files now.
    CHANGELOG.md stays 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
    and 0.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

Choose a tag to compare

@kwy404 kwy404 released this 04 Sep 16:21

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 the tbody and 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 with v-data and the table with
    the data in a const block.

  • 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.md was 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

Choose a tag to compare

@kwy404 kwy404 released this 04 Sep 16:11

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 the tbody where 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 has tbody rows. A thead row 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-for template 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. Use v-if on the
    element, or a JSX region instead of v-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.