This commit is contained in:
sebseb7
2026-09-22 11:38:20 +02:00
parent 264e8f839d
commit 0f9321ac34
10 changed files with 1865 additions and 5 deletions

View File

@@ -144,6 +144,8 @@ export class Automat {
#persist = false;
/** @type {Promise<object>} */
#ready = Promise.resolve();
/** @type {function|null} */
#loader = null;
/**
* @param {object} initialState Initial state snapshot.
@@ -151,11 +153,13 @@ export class Automat {
* @param {object} [options] Configuration options.
* @param {string} [options.name] Identifier for persistence and registry lookup.
* @param {boolean} [options.persist] true = IndexedDB (survives reload), false = window object (default).
* @param {function} [options.loader] Async loader function (automat) => Promise<object>.
*/
constructor(initialState = {}, callbacks = {}, options = {}) {
this.#callbacks = callbacks;
this.#name = options.name ?? null;
this.#persist = Boolean(options.persist);
this.#loader = typeof options.loader === 'function' ? options.loader : null;
if (this.#name && typeof window !== 'undefined') {
window.__AUTOMATS__ = window.__AUTOMATS__ || new Map();
@@ -203,6 +207,88 @@ export class Automat {
return undefined;
}
/**
* Creates a combined Automat derived from multiple upstream Automats.
*
* When accessed via `.getData()`:
* - If all upstream automats are already resolved (`status === 'success'`),
* it immediately returns the combined state in the constructor (instant resolution).
* - If any upstream automat is idle, it triggers `.getData()` on them concurrently,
* transitions itself to pending, and synchronizes automatically as each completes.
*
* @param {Automat[]} upstreamAutomats Array of upstream Automat instances.
* @param {function(...states: object[]): object} combiner Function that maps upstream states to combined state.
* @param {object} [options] Optional Automat options (name, persist).
* @returns {Automat}
*/
static combine(upstreamAutomats, combiner, options = {}) {
const computeInitial = () => {
const upstreamStates = upstreamAutomats.map((a) => a.state);
return combiner(...upstreamStates);
};
const combined = new Automat(
computeInitial(),
{
getData(opts = {}) {
const allResolved = upstreamAutomats.every(
(a) => a.state?.status === 'success'
);
if (allResolved && !opts.reload) {
const currentCombined = combiner(...upstreamAutomats.map((a) => a.state));
if (combined.state.status !== 'success') {
combined.setState({ ...currentCombined, status: 'success' });
}
return combined.state;
}
// Trigger loading on any uncompleted or reload-requested upstreams
upstreamAutomats.forEach((a) => {
if (typeof a.getData === 'function' && (a.state?.status === 'idle' || opts.reload)) {
a.getData(opts);
}
});
const checkAllResolved = upstreamAutomats.every(
(a) => a.state?.status === 'success'
);
if (checkAllResolved) {
const res = combiner(...upstreamAutomats.map((a) => a.state));
combined.setState({ ...res, status: 'success' });
return combined.state;
}
const partialCombined = combiner(...upstreamAutomats.map((a) => a.state));
combined.setState({ ...partialCombined, status: 'pending' });
return combined.state;
},
},
options
);
// Subscribe to all upstreams
upstreamAutomats.forEach((upstream) => {
combined.subscribeTo(upstream, () => {
const upstreamStates = upstreamAutomats.map((a) => a.state);
const allDone = upstreamStates.every((s) => s?.status === 'success');
const anyError = upstreamStates.find((s) => s?.status === 'error');
const combinedSlice = combiner(...upstreamStates);
if (anyError) {
return { ...combinedSlice, status: 'error', error: anyError.error };
}
if (allDone) {
return { ...combinedSlice, status: 'success', error: null };
}
return { ...combinedSlice, status: 'pending' };
});
});
return combined;
}
/**
* The registered name of the automat, or null if unnamed.
* @returns {string|null}
@@ -258,6 +344,73 @@ export class Automat {
return this.#state;
}
/**
* Reads state for constructor initialization, optionally triggering an action or loader.
*
* Lifecycle & Caching Semantics:
* 1. If loader or load action is configured and state is 'idle' (or reload is true):
* - Transitions state to { status: 'pending', error: null }.
* - Launches loader asynchronously in the background.
* - Synchronously returns the pending state for the first render.
* 2. When the async fetch finishes, automat.setState notifies subscribers, triggering
* a second render with completed data.
* 3. When called again from another component or after remounting with data already loaded
* (status === 'success'), returns the resolved state immediately in the constructor.
* No secondary render is needed!
*
* @param {object} [options]
* @param {boolean} [options.reload=false]
* @returns {object}
*/
getData(options = {}) {
const shouldReload = Boolean(options.reload);
const isIdle = !this.#state.status || this.#state.status === 'idle' || shouldReload;
if (this.#loader && isIdle) {
this.setState({ status: 'pending', error: null });
Promise.resolve(this.#loader(this))
.then((result) => {
if (result && typeof result === 'object') {
this.setState({ status: 'success', ...result, error: null });
} else {
this.setState({ status: 'success', error: null });
}
})
.catch((err) => {
this.setState({ status: 'error', error: err?.message || String(err) });
});
return this.#state;
}
if (this.#callbacks?.getData && typeof this.#callbacks.getData === 'function') {
return this.#callbacks.getData(options);
}
if (this.#callbacks?.load && typeof this.#callbacks.load === 'function' && isIdle) {
this.#callbacks.load(options);
return this.#state;
}
return this.#state;
}
/**
* Alias for getData(options).
* @param {object} [options]
* @returns {object}
*/
load(options) {
return this.getData(options);
}
/**
* Forces a reload via getData({ reload: true }).
* @returns {object}
*/
reload() {
return this.getData({ reload: true });
}
/**
* Shallow-merges `partial` into the current state and notifies all subscribers.
*