Using Threads EXTENSION

How to load the extension, write a definition, use the mirror on the page, and what crosses the boundary when. For what a thread is and why, start with Threads.

Loading it

Three classic script tags, the framework first, then the extension, then your definition file. The extension runs on every tier, nano and up.

<script defer src="/js/wildflower.min.js"></script>
<script defer src="/js/threads.wf.min.js"></script>
<script defer src="/js/orders.js"></script>

The definition file runs on both sides. On the page, wildflower.thread() registers the store as the mirror. In the worker, the same call becomes the real store. The worker loads the framework file it finds on the page, so there is nothing else to serve and nothing to bundle. Served from your own origin, it works under script-src 'self'; worker-src 'self'. Loaded from a CDN, the worker starts from a blob: URL that loads the extension, because a browser refuses a worker script from another origin, so worker-src has to allow blob:.

The definition

Same shape as a store: state, computed, methods at the top level. Two additions state where each part is kept.

// orders.js: runs on the page and in the worker
wildflower.thread('orders', {
    state: {
        rows: [],
        params: { query: '', region: '' }
    },
    // Reactive in the worker, never sent to the page.
    workerOnly: ['rows', 'filtered'],
    computed: {
        filtered() {
            var q = this.params.query.toLowerCase(), r = this.params.region;
            return this.rows.filter(function (row) {
                return (!r || row.region === r) && row.customer.toLowerCase().indexOf(q) !== -1;
            });
        },
        count()   { return this.filtered.length; },
        revenue() { return this.filtered.reduce(function (s, row) { return s + row.total; }, 0); },
        top()     { return this.filtered.slice().sort(function (a, b) { return b.total - a.total; }).slice(0, 50); }
    },
    async load(url) {
        const res = await fetch(url);
        this.rows = await res.json();
    }
});

workerOnly lists the state keys and computed properties that stay in the worker. Everything else in state is an input the page can write, and every other computed property is an output the worker pushes back when it changes. Here a keystroke sends params.query out and count, revenue and fifty rows come back.

Worker-only state is replaced, never mutated in place. The worker holds it as a plain array with no per-element tracking, and recomputes when the value is reassigned. Write this.rows = this.rows.concat(row), or a new array from a filter or a map. A push changes nothing the computeds can see, and the development build warns once (TH-109).

On the page

The mirror is a store named orders. Use it as you would any store.

<div data-component="dashboard">
    <input data-model="orders.params.query" placeholder="Filter">
    <p><span data-bind="$orders.count"></span> orders, <span data-bind="revenue"></span> total</p>
    <p data-show="$orders.pending">Computing&hellip;</p>
    <table><tbody data-list="$orders.top" data-key="id">
        <template><tr><td data-bind="customer"></td><td data-bind="total"></td></tr></template>
    </tbody></table>
</div>
wildflower.component('dashboard', {
    subscribe: ['orders'],
    computed: {
        revenue() { return '$' + this.stores.orders.revenue.toLocaleString(); }
    },
    watch: {
        'store:orders.count': function (n) { console.log(n + ' matching'); }
    },
    init() { this.stores.orders.load('/api/orders.json'); }
});

A write to an input field goes to the worker. A method call posts to the worker and returns a promise for its result. The store also carries three fields the runtime keeps: isLoading is true until the worker's first answer, pending counts calls and writes not yet acknowledged, and error holds the message of the last failure from the worker, cleared once the worker acknowledges the next message. These read the way a query store's isLoading and error read, so a spinner or an error banner written for one works for the other. One difference: a query refetches and turns isLoading back on each time, while a thread starts once, so its isLoading goes false at the first answer and stays there. A failing method call rejects with the Error itself, carrying name, stack and the thread's name, so catch the call when you want more than the message. settled() returns a promise that resolves once every write and call made so far has been acknowledged.

var orders = wildflower.getStore('orders');
orders.params.query = 'smith';     // the mirror updates now; the worker recomputes
await orders.settled();            // the answer has landed
orders.count;                      // the mirror: possibly one message behind
await orders.load('/api/2025.json');

What crosses, and when

Reads are of the mirror: the last values the worker pushed. A write updates the mirror at once, so your own next read sees it, and is sent with a sequence number. The worker applies writes in order. When several writes arrive while it is busy, it applies them together and recomputes once for the newest input, then acknowledges each one. A result from an older message never overwrites a newer local write to the same field.

A write is sent only when it changes the field's value. Writing the value the page already shows sends nothing, even while a method that will change that field is still running in the worker, so the method's result is the value that stays. If a field must end up at the value it shows now, wait for the method, or for settled(), before writing it.

Values cross by structured clone: plain objects, arrays, primitives and typed arrays. A function or a DOM node cannot cross. The write throws the browser's own DataCloneError and the development build tells you which field and what type (TH-101).

A Date, Map, Set or RegExp in state is held by reference and is reactive by identity, here as in any store: reassigning it notifies, mutating it in place does not. Structured clone carries all four, so they cross intact. A class instance crosses as a plain object, its fields without its methods, because that is what structured clone makes of one; convert it back in the definition, which runs on both sides.

An array mutator on the page sends the whole array. orders.rows.push(row) copies the mirror's array, applies the change and sends the result as one write, so a thousand-item array crosses in full for one added row. That is the cost of keeping the page's copy and the worker's in step through one ordered write. Two ways around it: call a method instead, so the change is made where the data is (orders.addRow(row)), or declare the collection workerOnly, which is the right answer for anything large.

Per-frame output

For a simulation, the output is a block of numbers every frame, and nothing on the page needs a subscription per element. An underscore-prefixed state key is the raw channel for that. It is non-reactive on both sides, sent whole whenever its identity changes, and read by pulling, the way a pool's tick reads its entities. A typed array in a raw field is transferred rather than cloned, so allocate a fresh one per tick.

wildflower.thread('sim', {
    state: { running: false, _positions: null },
    tick(dt) {
        if (!this.running) return;
        var out = new Float32Array(N * 2);
        // ... step the simulation, write x and y for each body ...
        this._positions = out;    // transferred; this side's copy is detached until the next tick
    }
});

// On the page, a pool's tick pulls the latest frame.
wildflower.component('view', {
    subscribe: ['sim'],
    pools: { bodies: { entity: { state: { tf: '' } } } },
    tick() {
        var pos = this.stores.sim._positions, i = 0;
        if (!pos) return;
        for (var b of this.pools.bodies) {
            b.tf = 'translate(' + pos[i] + 'px,' + pos[i + 1] + 'px)';
            i += 2;
        }
    }
});

tick(dt) in a thread definition runs in the worker on its own timer, about sixty times a second, with dt in milliseconds. It uses setTimeout, so it keeps running when the tab is hidden. It is a loop, not a method: the page cannot call it. A simulation is this shape: the state it steps never leaves the worker, and each tick writes a fresh buffer of results to an underscore-prefixed field, which is transferred to the page rather than copied.

Lifecycle

The worker is created when wildflower.thread() runs and ended by the store's own teardown. wildflower.unregister('orders') and wildflower.destroy() both terminate it. Calls in flight reject with an AbortError carrying code TH-104, and so does any call made after that.

Coming from another worker library

With Comlink you expose an object in the worker and wrap the worker on the page. Every access through that proxy is asynchronous, methods and properties alike, which its documentation puts as a rule of thumb: put await in front of it. threads.js works the same way through expose and spawn. A thread's methods are that idea unchanged: a definition method is called on the store and returns a promise.

What changes is reading. Move the exposed object's fields into state, mark the large ones workerOnly, and turn the getters you were calling into computed properties. The page then reads them as store fields, with no round trip and nothing to await, because the worker sends each new value as it changes.

// Comlink
const api = Comlink.wrap(new Worker('worker.js'));
await api.load(url);
el.textContent = await api.count();      // a round trip per read

// Threads
await orders.load(url);
el.textContent = orders.count;           // the mirror; no round trip

Vue and Solid

VueUse's useWebWorkerFn offloads one function, and useWebWorker wraps postMessage with the last message in a ref. For state that stays on the worker, subscribe the mirror's fields into a ref or a signal.

// Vue
const count = ref(orders.count);
orders.subscribe('count', v => { count.value = v; });

// Solid
const [count, setCount] = createSignal(orders.count);
orders.subscribe('count', setCount);

Solid's createSignaledWorker already connects signals to a worker: an input signal goes in, the function runs there, and an output setter receives the result. The signals are on the page and the worker holds a plain function. A thread puts the framework itself in the worker, which loads the tier file the page loaded and registers the definition as a real store, so computeds, watchers and lifecycle hooks behave there as they do on the page.

What a thread does not do

  • A per-call timeout. VueUse and the React hooks take a timeout and report a TIMEOUT_EXPIRED status, killing a worker that overruns. A thread has no timeout, because terminating its worker would throw away the state it holds, which is what the page is there to read. What it does instead is tell you: after five seconds with a message unanswered, the development build warns with TH-110, and pending says how many are waiting.
  • Worker pools. threads.js has a Pool, and Solid has createWorkerPool, both spreading calls across several workers. A pool suits calls, which are independent. A thread holds state, so several workers means splitting that state between them.
  • Callbacks across the boundary. Comlink's Comlink.proxy() sends a function as a proxy, so the worker can call back into the page. Functions cannot cross here (TH-101). For the case this usually serves, progress, write it to state: this.progress = 40 in the worker updates whatever binds to it.
  • Custom serialisation. Comlink's transferHandlers let you register canHandle, serialize and deserialize for a type structured clone refuses. A thread sends what structured clone sends, so a class instance arrives as a plain object. Convert it in the definition, which runs on both sides.
  • Streaming from one call. threads.js can return an observable that emits many values. A thread's method resolves once, and repeated output is a state field written repeatedly: the page sees every change. The per-frame channel is the high-rate version of the same thing.

Diagnostics

The extension has its own prefix, TH, printed beside the framework's WF codes. The development build carries the full message and a suggestion; the production build carries the code. Each code has an entry on the Error Codes page, under the Threads filter.

CodeMeaning
TH-101A value cannot cross the boundary. The message gives the field path and the type.
TH-102A page-side write to a computed or one of isLoading, pending, error. The worker owns it; the write is dropped.
TH-103The worker failed to start. For the inline route, or the extension loaded from another origin, under a strict Content Security Policy, the message tells you worker-src refused blob: and what to do instead.
TH-104Cancelled: the thread was terminated with calls in flight. The rejection's name is AbortError.
TH-105The definition is not usable. Warns when the thread can still run: a methods: block, a reserved name used for state, or a workerOnly entry that is neither a state key nor a computed. Throws when it cannot: the definition is not an object, or the inline route was handed state it cannot write as source.
TH-106The extension cannot find its own URL. Load it through a classic <script src>, or pass { url }.
TH-107No framework script for the worker to load. Pass { core } with the URL of a classic build.
TH-108The framework file loaded on a page with no framework instance. Put the framework script first.
TH-109A worker-only array was changed in place in the worker. Replace it instead.
TH-110A message has been unanswered for five seconds: a long method, or a worker that has stopped reading its queue.
TH-111The name is already a registered store. No thread is created and the existing store is returned; unregister() it first to replace it.

TypeScript

The package ships declarations that derive the page's view from the definition you write. A computed property's return type becomes the type of the field the page reads, a method returns a promise of its own return type, and a name listed in workerOnly is absent, so reaching for it is a compile error rather than undefined at runtime. Inside computeds and methods, this is the state, the computed values and the other methods.

The built files add wildflower.thread rather than exporting, so a page that loads them by script tag has no import to hang types on. Reference the global declarations, which add wildflower.thread to the framework's own surface:

/// <reference types="@wildflowerjs/threads/types.global" />

const orders = wildflower.thread('orders', {
    state: { rows: [] as Order[], params: { query: '' } },
    workerOnly: ['rows'],
    computed: {
        count(): number { return this.rows.length; }   // this.rows is Order[]
    },
    async load(url: string): Promise<number> {
        const res = await fetch(url);
        this.rows = await res.json();
        return this.rows.length;
    }
});

orders.count;                      // number
await orders.load('/api.json');    // Promise<number>
orders.rows;                       // compile error: worker-only, not on the page

Options

wildflower.thread(name, definition, options) accepts:

  • core: the framework file for the worker to load. The default is the framework <script src> found on the page.
  • def: the definition file's URL, for a wildflower.thread() call made somewhere other than that file's top level.
  • url: the extension's own URL, for a page that loaded it some way other than a classic script tag.
  • inline: true: serialise the definition into a Blob worker instead of loading it by URL, for playgrounds and one-file pages. A worker-src policy without blob: refuses it.