Compare commits

..

18 commits

Author SHA1 Message Date
7de0316038 Add editorconfig 2025-09-17 12:57:00 +02:00
92b78c3c0f
Add in-order script 2025-03-21 15:04:34 +01:00
350117ef64 Apply getter-transforms on change functions 2023-10-17 10:34:38 +02:00
f13bccf7a9
Fix two bugs in state.js 2023-09-20 21:20:27 +02:00
f1d3eeb9a2 Fix spelling in state page 2023-09-20 13:36:14 +02:00
1b286aff47 Reword intro of state.js page 2023-09-20 13:32:32 +02:00
6542047b7c Extract map of final values into change event
Cached on first use for performance and more DRY
2023-09-20 13:26:14 +02:00
d62136e180 Add example of storage state breaking identity 2023-09-20 12:58:53 +02:00
24f7cffa82 Add storage change propagation for same-window States
The browser's default storage event only triggers when a change happens
within another window, meaning that different StorageStates sharing the
same storage object would not be informed of each other's updates.
2023-09-20 12:53:40 +02:00
80b88ec647 Document StorageState and MapStorage objects 2023-09-20 11:44:02 +02:00
2c2adba2b6 Fix smaller problems in storage state 2023-09-20 11:24:02 +02:00
8c01bd83ca Move ObjectStorage into state.js as MapStorage 2023-09-20 11:23:06 +02:00
c9da7b116d
Fix syntax error in pqueue 2023-09-19 17:39:11 +02:00
939c564f03
Allow returning text in skooma bind method 2023-09-19 17:39:11 +02:00
8d9dc8ae7f Add some helpful comments to state.js 2023-09-19 16:58:43 +02:00
2e540dbc6f Add StorageState class 2023-09-19 15:38:23 +02:00
287aa98955 Add State library and deprecate speaker & listener 2023-09-18 14:58:40 +02:00
89d4084462 Further refactor code 2023-09-18 11:45:36 +02:00
14 changed files with 398 additions and 74 deletions

2
.editorconfig Normal file
View file

@ -0,0 +1,2 @@
[*]
indent_style = tab

View file

@ -6,9 +6,9 @@ export default Class => {
const props = []
Object.entries(attributes).forEach(([name, attribute]) => {
let htmlName = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()
const htmlName = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()
props.push(htmlName)
let prop = {}
const prop = {}
prop.get = typeof attribute.get == "function"
? function() { return attribute.get.call(this, this.getAttribute(htmlName)) }
@ -17,7 +17,7 @@ export default Class => {
prop.set = typeof attribute.set == "function"
? function(val) { return this.setAttribute(htmlName, attribute.set.call(this, val)) }
: attribute.set === false
? function(val) { throw(Error(`Attribute ${name} cannot be set`)) }
? function() { throw(Error(`Attribute ${name} cannot be set`)) }
: function(val) { this.setAttribute(htmlName, val) }
Object.defineProperty(proto, name, prop)
@ -33,9 +33,20 @@ export default Class => {
})
Class.prototype.attributeChangedCallback = function(name, oldValue, newValue) {
name = name.replaceAll(/-(.)/g, (a, b) => b.toUpperCase())
if (`${name}Changed` in this) this[`${name}Changed`](oldValue, newValue)
if (`changed` in this) this.changed(name, oldValue, newValue)
name = name.replaceAll(/-(.)/g, (_, b) => b.toUpperCase())
const get_transform = attributes[name]?.get
if (`${name}Changed` in this) {
if (typeof get_transform == "function")
return this[`${name}Changed`](get_transform(oldValue), get_transform(newValue))
else
return this[`${name}Changed`](oldValue, newValue)
}
if (`changed` in this) {
if (typeof get_transform == "function")
return this.changed(name, get_transform(oldValue), get_transform(newValue))
else
return this.changed(name, oldValue, newValue)
}
}
/* Enable batch-processing for dollar-methods */
@ -54,7 +65,7 @@ export default Class => {
queue.push(args)
Class.queues.set(this, queue)
}
};
}
}
}

View file

@ -66,36 +66,22 @@
</section>
<section>
<h2>Listener</h2>
<h2>State</h2>
<p>
Like a normal object, except you can register callbacks for property changes.
<a class="fancy" href="page/listener.html">Read More</a>
</p>
<p>
<h3 class="all-unset"><b>Code Sample</b>:</h3>
<code-block>
import listener from 'listener.js'
const user = listener({name: "John Doe"})
user.listen('name', value =&gt; console.warn(`User name has changed to ${value}`))
</code-block>
</p>
</section>
<section>
<h2>Speaker</h2>
<p>
Publish and subscribe to messages, relayed via micro-tasks.
<a class="fancy" href="page/speaker.html">Read More</a>
Utility class to monitor state changes on an object using a Proxy and events.
Changes are batched and processed in a microtask.
<a class="fancy" href="page/state.html">Read More</a>
<code-block>
const speaker = new Speaker()
speaker.listen((...args) =&gt; console.log(...args))
speaker.speak("First", "second", "third")
const state = new State()
speaker.meepChanged = value => console.log(`meep ${value}`)
state.proxy.meep = "Heat from fire"
state.proxy.meep = "Fire from heat"
state.proxy.meep = "moop"
// outputs: "meep moop"
</code-block>
No, this has nothing to do with playing audio.
No, this still has nothing to do with playing audio.
</p>
</section>

7
jsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"compilerOptions": {
"target": "es2020",
"allowJs": true,
"checkJs": true
}
}

View file

@ -9,6 +9,7 @@ Example:
*/
const registry = new WeakMap()
const listener = (target={}) => {
const callbacks = new Map()
const methods = Object.create(null)
@ -16,7 +17,7 @@ const listener = (target={}) => {
const callback = once
? (...args) => { this.forget(name, callback); return fn(...args) }
: fn
let set = callbacks.get(name) ?? new Set()
const set = callbacks.get(name) ?? new Set()
callbacks.set(name, set)
set.add(callback)
return this
@ -29,13 +30,13 @@ const listener = (target={}) => {
return callbacks.delete(name)
}
}
let proxy = new Proxy(target, {
const proxy = new Proxy(target, {
set: (target, prop, value) => {
if (callbacks.has(null)) callbacks.get(null).forEach(callback => callback(value, target[prop], prop))
if (callbacks.has(prop)) callbacks.get(prop).forEach(callback => callback(value, target[prop], prop))
return Reflect.set(target, prop, value)
},
get: (target, prop, value) => prop in methods
get: (target, prop, _value) => prop in methods
? methods[prop]
: target[prop]
})

View file

@ -297,6 +297,7 @@
Therefore one cannot use tagged template literals with <code>text</code>
as this would return a document fragment which cannot be replaced.
</div>
If the element is a string, it is turned into a text node before insertion.
</dd>
<dt>Update function</dt>
<code>

160
page/state.html Normal file
View file

@ -0,0 +1,160 @@
<link rel="stylesheet" href="style.css">
<script type="module" src="codeblock.js"></script>
<script type="module" src="scrollToTop.js"></script>
<scroll-to-top>
</scroll-to-top>
<a class="back" href="..">Module Index</a>
<h1 class="js module">state.js</h1>
<code-block>import {State} from 'state.js'</code-block>
<section>
<h2>Description</h2>
<p>
State objects are an event-oriented way of managing application state and reacting to changes.
It makes use of built-in features such as <b><code>Proxy</code></b> and <b><code>EventTarget</code></b>
to save code and give users less to remember.
</p>
<p>
By default, state changes are only queued up and processed in a microtask, allowing for batch-processing.<br>
This means that for repeated changes to the same property, the user can decide to process all the changes in one go or even discard everything but the last value of each property.<br>
That way one can easily avoid some types of unnecessary redraws, network requests, etc.
</p>
<p>
To make code more readable, the constructor defaults to adding an event listener that checks for a method called [prop]<code>Changed</code> in the state object, and calls it with the new value. This behaviour can be disabled by setting the <code>methods</code> option to false.
</p>
<p>
The state object also has a getter and setter pair for the `state` attribute, which simply accesses this same attribute on the proxy object.
This is simply a convenience feature to save some typing on single-variable states.
</p>
</section>
<section>
<h2>Options</h2>
<p>
<dl>
<dt><code>defer</code></dt>
<dd>Set to false to disable deferred change processing. This will emit a new event every time something changes, even if it's about to be changed again in the next line.</dd>
<dt><code>methods</code></dt>
<dd>Set to false to disable the default event listener that attempts to call a [prop]Changed method on the state object.</dd>
</dl>
</p>
</section>
<section>
<h2>Examples</h2>
<p>
A simple counter state that prints a new count to the console every time it gets updated.
</p>
<code-block>
const counter = new State({state: 0}, {defer: false})
counter.stateChanged = (count) =&gt; console.log(`new count: ${count}`)
counter.state += 1
counter.state += 1
counter.state += 1
</code-block>
<p>
This example uses an event listener instead to get notified of all property changes.
Collecting changes into a map is an easy way of de-duplicating their values and keeping only the last one.
</p>
<code-block>
const state = new State({}, {methods: false})
state.addEventListener("change", event =&gt; {
console.log(`There were ${event.changes.length} changes.`)
new Map(event.changes).forEach((value, prop) => {
console.log(`Final vaue of ${prop}: ${value}`)
})
})
state.proxy.foo = "foo 1"
state.proxy.foo = "foo 2"
state.proxy.bar = "bar 1"
state.proxy.foo = "foo 3"
state.proxy.bar = "bar 2"
// There were 5 changes.
// Final vaue of foo: foo 3
// Final vaue of bar: bar 2
</code-block>
</section>
<section>
<h2>Storage-backed states</h2>
<p>
The <code>StorageState</code> subclass of <code>State</code> implements the same API but is backed by a
<a href="https://developer.mozilla.org/en-US/docs/Web/API/Storage"><code>Storage</code></a> object.
</p>
<p>
By default, <code>StorageState</code> uses the
<code>window.localStorage</code> object, but you can change this by
passing the <code>storage</code> option in the constructor.
</p>
<p>
When using the <code>value</code> short-hand to have a different
<code>StorageState</code> for every individual value, these would
all override the same key in the storage. To remedy this, the
<code>key</code> option can be used to redirect reads and writes
of the <code>"value"</code> key to a different key in the storage.
</p>
<code-block>
const greeting = new StorageState({}, {
storage = sessionStorage,
key: 'greeting'
})
greeting.valueChanged = newValue =&gt;
console.log(`Greeting has changed: ${newValue}`)
greeting.value = "Hello, World!"
console.log(sessionStorage.value) // udnefined
console.log(sessionStorage.greeting) // "Hello, World!"
</code-block>
<p>
Using a storage object comes with the disadvantage that all values must
be stored in a serialised form, which won't work for all data and will
break identity. Specifically, all values are converted to JSON strings
for storage.
</p>
<code-block>
const plainState = new State({})
const storageState = new StorageState({})
const object = {}
plainState.value = object
storageState.value = object
console.log(plainState.value == object) // true
console.log(storageState.value == object) // false
</code-block>
</section>
<section>
<h2>Map-backed Storage</h2>
<p>
When a fallback option is needed, for example to support clients without
<code>localStorage</code> without breaking the page, the
<code>MapStorage</code> object implements the <code>Storage</code> API
and is backed by a plain JavaScript <code>Map</code> object.
</p>
</section>

View file

@ -67,7 +67,7 @@ export default (parallel = 1) => {
// Loop until there is nothing more to do:
// a) Running queue is full and nothing can be resolved
// b) Waiting queue is empty
const spin = () => { while pump() {} }
const spin = () => { while (pump()) {} }
return promise => {
waiting.push(promise)

View file

@ -24,13 +24,22 @@ support inheriting from other classes for extending builtin elements.
Generate CSS from JS objects. Yes, you can generate CSS from JSON now. Or from
YAML. Or from whatever you want, really.
## State
Combines both Listener and Speaker into one convenient class that batches and
defers changes by default.
## Listener
(Deprecated; use `State` instead)
A proxy object that fires a callback when certain (or all) properties are
changed.
## Speaker
(Deprecated; use `State` instead)
Simple messaging helper that uses microtasks by default.
## Debounce

View file

@ -10,10 +10,10 @@ or
html.ul([1, 2, 3, 4, 5].map(x => html.li(x)), {class: "numbers"})
*/
const keyToPropName = key => key.replace(/^[A-Z]/, a => "-"+a).replace(/[A-Z]/g, a => '-'+a.toLowerCase())
export const empty = Symbol("Explicit empty argument for Skooma")
const keyToPropName = key => key.replace(/^[A-Z]/, a => "-"+a).replace(/[A-Z]/g, a => '-'+a.toLowerCase())
const insertStyles = (rule, styles) => {
for (const [key, value] of Object.entries(styles))
if (typeof value == "undefined")
@ -81,9 +81,9 @@ const node = (name, args, options) => {
const custom = getCustom(args)
if ("nameFilter" in options) name = options.nameFilter(name)
if (options.xmlns)
element = document.createElementNS(options.xmlns, name, {is: custom})
element = document.createElementNS(options.xmlns, name, custom ?? {is: custom})
else
element = document.createElement(name, {is: custom})
element = document.createElement(name, custom ?? {is: custom})
parseArgs(element, null, args)
return element
}
@ -93,25 +93,34 @@ const nameSpacedProxy = (options={}) => new Proxy(Window, {
has: (_target, _prop) => true,
})
export const html = nameSpacedProxy({nameFilter: name => name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()})
export const svg = nameSpacedProxy({xmlns: "http://www.w3.org/2000/svg"})
// Other utility exports
export const bind = transform => {
let element
const inject = next => Object.defineProperty(next, 'current', {get: () => element})
const update = (...data) => {
const next = transform(...data)
if (next) {
if (typeof next == "string") {
element.innerText = next
return element
} else {
if (element) element.replaceWith(next)
element = inject(next)
return element
}
}
}
return update
}
export const handle = fn => event => { event.preventDefault(); return fn(event) }
export const html = nameSpacedProxy({nameFilter: name => name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()})
export const svg = nameSpacedProxy({xmlns: "http://www.w3.org/2000/svg"})
// Wraps an event handler in a function that calls preventDefault on the event
export const handle = fn => event => { fn(event); event.preventDefault() }
// Wraps a list of elements in a document fragment
export const fragment = (...elements) => {
const fragment = new DocumentFragment()
for (const element of elements)
@ -119,6 +128,9 @@ export const fragment = (...elements) => {
return fragment
}
// Turns a template literal into document fragment.
// Strings are returned as text nodes.
// Elements are inserted in between.
const textFromTemplate = (literals, items) => {
const fragment = new DocumentFragment()
for (const key in items) {

View file

@ -15,8 +15,8 @@ export class Speaker {
speak(...args) {
if (!this._scheduled.length) {
queueMicrotask(() => {
for (let args of this._scheduled) {
for (let callback of this._callbacks) {
for (const args of this._scheduled) {
for (const callback of this._callbacks) {
callback(...args)
}
this._retain = args
@ -28,7 +28,7 @@ export class Speaker {
}
forget(callback) {
this._callbacks.delete(callbacks)
this._callbacks.delete(callback)
}
silence() {
@ -38,7 +38,7 @@ export class Speaker {
export class ImmediateSpeaker extends Speaker {
speak(...args) {
for (let callback of this._callbacks) {
for (const callback of this._callbacks) {
callback(...args)
}
this._retain = args

156
state.js Normal file
View file

@ -0,0 +1,156 @@
export class ChangeEvent extends Event {
#final
constructor(...changes) {
super('change')
this.changes = changes
}
get final() {
if (!this.#final) {
this.#final = new Map(this.changes)
}
return this.#final
}
}
export class MapStorage extends Storage {
#map = new Map()
key(index) {
return [...this.#map.keys()][index]
}
getItem(keyName) {
if (this.#map.has(keyName))
return this.#map.get(keyName)
else
return null
}
setItem(keyName, keyValue) {
this.#map.set(keyName, String(keyValue))
}
removeItem(keyName) {
this.#map.delete(keyName)
}
clear() {
this.#map.clear()
}
}
export class State extends EventTarget {
#target
#options
#queue
constructor(target={}, options={}) {
super()
this.#options = options
this.#target = target
this.proxy = new Proxy(target, {
set: (_target, prop, value) => {
this.emit(prop, value)
this.set(prop, value)
return true
},
get: (_target, prop) => this.get(prop),
})
this.addEventListener
// Try running a "<name>Changed" method for every changed property
// Can be disabled to maybe squeeze out some performance
if (options.methods ?? true) {
this.addEventListener("change", ({final}) => {
final.forEach((value, prop) => {
if (`${prop}Changed` in this) this[`${prop}Changed`](value)
})
})
}
}
// When you only need one value, you can skip the proxy.
set value(value) { this.proxy.value = value }
get value() { return this.proxy.value }
// Anounces that a prop has changed
emit(prop, value) {
if (this.#options.defer ?? true) {
if (!this.#queue) {
this.#queue = []
queueMicrotask(() => {
this.dispatchEvent(new ChangeEvent(...this.#queue))
this.#queue = undefined
})
}
this.#queue.push([prop, value])
} else {
this.dispatchEvent(new ChangeEvent([prop, value]))
}
}
set(prop, value) {
this.#target[prop] = value
}
get(prop) {
return this.#target[prop]
}
}
export class StorageChangeEvent extends Event {
constructor(storage, key, value, targetState) {
super("storagechange")
this.storageArea = storage
this.key = key
this.newValue = value
this.targetState = targetState
}
}
export class StoredState extends State {
#storage
#valueKey
constructor(init, options={}) {
super({}, options)
this.#storage = options.storage ?? localStorage ?? new MapStorage()
this.#valueKey = options.key ?? 'value'
// Initialise storage from defaults
for (let [prop, value] of Object.entries(init)) {
if (prop === 'value') prop = this.#valueKey
if (this.#storage[prop] === undefined)
this.set(prop, value)
}
// Emit change events for any changed keys
for (let i=0; i<this.#storage.length; i++) {
const key = this.#storage.key(i)
const value = this.#storage[key]
if (value !== JSON.stringify(init[key]))
this.emit(key, value)
}
// Listen for changes from other windows
const handler = event => {
if (event.targetState !== this && event.storageArea == this.#storage) {
let prop = event.key
if (prop === this.#valueKey) prop = 'value'
this.emit(prop, JSON.parse(event.newValue))
}
}
addEventListener("storage", handler)
addEventListener("storagechange", handler)
}
set(prop, value) {
if (prop == "value") prop = this.#valueKey
const json = JSON.stringify(value)
dispatchEvent(new StorageChangeEvent(this.#storage, prop, json, this))
this.#storage[prop] = json
}
get(prop) {
if (prop == "value") prop = this.#valueKey
return JSON.parse(this.#storage[prop])
}
}
export default State

View file

@ -1,21 +0,0 @@
export class ObjectStorage {
#map = new Map()
key(index) {
return [...this.#map.keys()][index]
}
getItem(keyName) {
if (this.#map.has(keyName))
return this.#map.get(keyName)
else
return null
}
setItem(keyName, keyValue) {
this.#map.set(keyName, keyValue)
}
removeItem(keyName) {
this.#map.delete(keyName)
}
clear() {
this.#map.clear()
}
}

View file

@ -7,11 +7,11 @@ Example:
*/
export const template = (strings, ...args) => {
let buf = []
const buf = []
for (let i=0;i<strings.length;i++) {
buf.push(strings[i], args[i])
}
let template = document.createElement("template")
const template = document.createElement("template")
template.innerHTML = buf.join("")
return template.content
}