Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ <h4>Behavioural</h4>
title="Inject history"
>Inject history<sup>unknown</sup></a
>
<a
class="navigation bind"
href="/src/pat/bind/index.html"
title="Bind"
>Bind<sup>β</sup></a
>
<a
class="navigation switch"
href="/src/pat/switch/index.html"
Expand Down
5 changes: 5 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,9 @@ config.setupFilesAfterEnv.push(path.resolve(__dirname, "./src/setup-tests.js"));
config.moduleNameMapper["@patternslib/patternslib/(.*)"] =
path.resolve(__dirname) + "/$1";

// The official signal polyfill ships ESM and needs Babel in Jest.
config.transformIgnorePatterns = config.transformIgnorePatterns.map((pattern) =>
pattern.replace("preact/|", "preact/|signal-polyfill/|")
);

module.exports = config;
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"select2": "^3.5.1",
"showdown": "^2.1.0",
"showdown-prettify": "^1.3.0",
"signal-polyfill": "^0.2.2",
"slick-carousel": "git+https://github.com/kenwheeler/slick.git#d0716f19aa730006ee80ab026625fb1107816a97",
"spectrum-colorpicker": "^1.8.0",
"tippy.js": "^6.3.7"
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

139 changes: 139 additions & 0 deletions src/core/signals.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* Patternslib helpers backed by the TC39 Signals proposal's official polyfill.
* Computed values are lazy; effects run immediately, then in microtask batches.
*/
import { Signal } from "signal-polyfill";
import logging from "./logging";

const log = logging.getLogger("signals");
const pending_effects = new Set();
const MAX_RUNS_PER_EFFECT = 1000;
let flush_scheduled = false;
let batch_depth = 0;

function schedule_flush() {
if (flush_scheduled || batch_depth > 0 || !pending_effects.size) {
return;
}
flush_scheduled = true;
queueMicrotask(flush_effects);
}

function flush_effects() {
const run_counts = new Map();
try {
for (const eff of pending_effects) {
pending_effects.delete(eff);
const runs = (run_counts.get(eff) || 0) + 1;
if (runs > MAX_RUNS_PER_EFFECT) {
pending_effects.clear();
log.error(
"Aborting effect flush after too many iterations - likely a cyclic dependency."
);
return;
}
run_counts.set(eff, runs);
eff.run();
}
} finally {
flush_scheduled = false;
}
}

/** Create a writable value with tracked reads and untracked peek(). */
function signal(initial_value) {
const state = new Signal.State(initial_value);
return {
get value() {
return state.get();
},
set value(value) {
state.set(value);
},
peek() {
return untracked(() => state.get());
},
};
}

/**
* Create a lazy, cached derivation. Reads always return the current value.
* dispose() freezes the last evaluated value (undefined if never evaluated).
* Disposal is optional for unused computeds, which are not kept live by sources.
*/
function computed(fn) {
const active = new Signal.State(true);
let value;
const derived = new Signal.Computed(() => {
if (active.get()) {
value = fn();
}
return value;
});
return {
get value() {
return derived.get();
},
peek() {
return untracked(() => derived.get());
},
dispose() {
active.set(false);
fn = null;
// Drop source dependencies now, even if nobody reads again.
untracked(() => derived.get());
},
};
}

/** Run immediately and after dependency changes; return a dispose function. */
function effect(fn) {
let disposed = false;
const derived = new Signal.Computed(() => {
try {
fn();
} catch (e) {
log.error("Error while running effect.", e);
}
});
const eff = {
run() {
if (disposed) return;
// Re-arm before evaluation so writes can schedule other effects.
watcher.watch();
derived.get();
},
};
const watcher = new Signal.subtle.Watcher(() => {
// Watcher callbacks must not read or write signals.
pending_effects.add(eff);
schedule_flush();
});
watcher.watch(derived);
// Creating an effect inside another computation must not subscribe it.
untracked(() => eff.run());
return () => {
disposed = true;
watcher.unwatch(derived);
pending_effects.delete(eff);
};
}

/** Group synchronous writes, deferring effect scheduling until the end. */
function batch(fn) {
batch_depth++;
try {
return fn();
} finally {
batch_depth--;
schedule_flush();
}
}

/** Run without subscribing the surrounding computation to signal reads. */
function untracked(fn) {
return Signal.subtle.untrack(fn);
}

export { signal, computed, effect, batch, untracked };
export default { signal, computed, effect, batch, untracked };
68 changes: 68 additions & 0 deletions src/core/signals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Signals

Reactive values backed by the official [TC39 Signals polyfill](https://github.com/proposal-signals/signal-polyfill),
with automatic dependency tracking. Used by
[pat-bind](../pat/bind/documentation.md) to keep DOM elements in sync.

## Usage

```javascript
import {
signal,
computed,
effect,
batch,
} from "@patternslib/patternslib/src/core/signals";

const price = signal(10);
const quantity = signal(2);
const total = computed(() => price.value * quantity.value);
const dispose = effect(() => console.log(total.value)); // Logs 20 immediately.

batch(() => {
price.value = 12;
quantity.value = 3;
});

await Promise.resolve(); // The queued update logs 36 once.

// In a pattern, perform this cleanup in destroy().
dispose();
// Unused computed values need no disposal.
```

## API

- `signal(value)` creates a writable value. Read and assign through `.value`.
Assigning an equal value (`Object.is`) does not trigger updates.
- `computed(fn)` creates a lazy, cached, read-only derived value. Keep `fn`
free of side effects. Unused computeds need no disposal. For compatibility,
`.dispose()` freezes the last evaluated value and releases its dependencies;
disposing before the first read freezes `undefined`. Computation errors are
thrown on reads and cached until a dependency changes.
- `effect(fn)` runs immediately and tracks signals read synchronously by `fn`.
It runs again when its dependencies change. The returned function stops it.
- `batch(fn)` groups synchronous writes, deferring scheduling until it returns.
Synchronous writes are also batched automatically.
- `untracked(fn)` reads without collecting dependencies. Signals and computed
values also provide `.peek()` for a single untracked read.

Effects are batched in a microtask. Computed values evaluate only when read,
and both `.value` and `.peek()` return an up-to-date result immediately after
a write, including inside `batch()`. Computed reads update their dependencies
before returning, so consumers see consistent derived values.

Effects may write signals, but should not rely on repeatedly writing their own
dependencies to schedule themselves. Use computed values for derivations.

## Specifications

This module wraps `signal-polyfill`, the official implementation of the
[TC39 Signals proposal](https://github.com/tc39/proposal-signals).
`signal()` and `computed()` adapt `Signal.State` and `Signal.Computed` to the
Patternslib `.value` / `.peek()` API. `untracked()` delegates to
`Signal.subtle.untrack()`. Effects use `Signal.subtle.Watcher`; their microtask
scheduling and the `batch()` helper are Patternslib APIs.

Scheduling uses `queueMicrotask`, defined by the
[HTML Standard](https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#microtask-queuing).
Loading
Loading