UNPKG

refui

Version:

The JavaScript framework that refuels your UI projects, across web, native, and embedded

1,127 lines (989 loc) 21 kB
/* Copyright Yukino Song, SudoMaker Ltd. * * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import { removeFromArr } from 'refui/utils' import { isProduction } from 'refui/constants' let sigID = 0 let ticking = false let currentEffect = null let currentDisposers = null let currentResolve = null let currentTick = null let contextValid = true let signalQueue = [] let effectQueue = [] // Scheduler part function scheduleSignal(signalEffects) { if (signalEffects.length <= 2) { return } return signalQueue.push(signalEffects) } function scheduleEffect(effects) { if (effects.length <= 2) { return } return effectQueue.push(effects) } // effectStore: [id, delCount, ...effects] function flushRunQueue(queue) { const queueLength = queue.length for (let i = 0; i < queueLength; i++) { const effects = queue[i] const effectEnd = effects.length for (let j = 2; j < effectEnd; j++) { const effect = effects[j][0] if (!effect) { continue } effect.__refui_scheduled = Math.max(1, effect.__refui_scheduled + 1) effect.__refui_pending = false } } for (let i = 0; i < queueLength; i++) { const effects = queue[i] const effectEnd = effects.length for (let j = 2; j < effectEnd; j++) { const effect = effects[j][0] if (effect) { if (--effect.__refui_scheduled > 0) { effect.__refui_pending = true } else if (effect.__refui_scheduled === 0) { effect.__refui_pending = false effect() } } } } } function sortQueue(a, b) { return a[0] - b[0] } function flushQueues() { if (signalQueue.length || effectQueue.length) { while (signalQueue.length) { const _ = signalQueue signalQueue = [] if (_.length > 1) { _.sort(sortQueue) } flushRunQueue(_) } while (effectQueue.length) { const _ = effectQueue effectQueue = [] flushRunQueue(_) } return Promise.resolve().then(flushQueues) } } function tickHandler(resolve) { currentResolve = resolve } function resetTick() { ticking = false currentTick = new Promise(tickHandler).then(flushQueues) currentTick.finally(resetTick) } function _tick() { currentResolve() return currentTick } function tick() { if (!ticking) { ticking = true currentResolve() currentTick = currentTick.finally(_tick) } return currentTick } function nextTick(cb, ...args) { if (args.length) { cb = cb.bind(null, ...args) } return tick().finally(cb) } // Signal part function pure(cb) { cb._pure = true return cb } function isPure(cb) { return !!cb._pure } function _dispose_raw() { const count = this.length for (let i = 0; i < count; i++) this[i](true) this.length = 0 } function _dispose_with_callback(dispose_raw, batch) { this(batch) dispose_raw(batch) } function _dispose_with_upstream(prevDisposers, batch) { if (!batch) { removeFromArr(prevDisposers, this) } this(batch) } function createDisposer(disposers, prevDisposers, cleanup) { let _cleanup = _dispose_raw.bind(disposers) if (cleanup) { _cleanup = _dispose_with_callback.bind(cleanup, _cleanup) } if (prevDisposers) { _cleanup = _dispose_with_upstream.bind(_cleanup, prevDisposers) prevDisposers.push(_cleanup) } return _cleanup } function collectDisposers(disposers, fn, cleanup) { const prevDisposers = currentDisposers const _dispose = createDisposer(disposers, prevDisposers, cleanup) currentDisposers = disposers try { fn() } finally { currentDisposers = prevDisposers } return _dispose } function _onDispose(cb) { const disposers = currentDisposers function cleanup(batch) { if (!batch) { removeFromArr(disposers, cleanup) } cb(batch) } disposers.push(cleanup) return cleanup } function onDispose(cb) { if (currentDisposers) { if (!isProduction && typeof cb !== 'function') { throw new TypeError(`Callback must be a function but got ${Object.prototype.toString.call(cb)}`) } return _onDispose(cb) } return cb } function useEffect(effect, ...args) { let cleanup = null let cancelled = false const _dispose = watch(function() { cleanup?.() cleanup = effect(...args) }) const cancelEffect = function() { if (cancelled) { return } cancelled = true cleanup?.() _dispose() } onDispose(cancelEffect) return cancelEffect } const _invalidatedState = { disposers: null, effect: null, valid: false } function _invalidateFrozenState() { Object.assign(this, _invalidatedState) } function _frozen({ disposers, effect, valid }, ...args) { const prevDisposers = currentDisposers const prevEffect = currentEffect const prevContextValid = contextValid currentDisposers = disposers currentEffect = effect contextValid = valid try { return this(...args) } finally { currentDisposers = prevDisposers currentEffect = prevEffect contextValid = prevContextValid } } function freeze( fn, state = { disposers: currentDisposers, effect: currentEffect, valid: contextValid } ) { if (currentDisposers) { _onDispose(_invalidateFrozenState.bind(state)) } return _frozen.bind(fn, state) } const untrack = freeze(function(fn, ...args) { return fn(...args) }) function vacuumEffectStore() { let delCount = this[1] if (!delCount) { return } const effectEnd = this.length if (delCount === effectEnd - 2) { this.length = 2 this[1] = 0 return } let i = 2 for (; i < effectEnd; i++) { if (!this[i][0]) { delCount -= 1 break } } let cursor = i i += 1 for (; i < effectEnd && delCount > 0; i++) { if (this[i][0]) { this[cursor] = this[i] cursor += 1 } else { delCount -= 1 } } this.splice(cursor, i - cursor) this[1] = 0 } function scheduleVacuum(effects) { if (effects[1] === 2) { nextTick(vacuumEffectStore.bind(effects)) } effects[1] += 1 } const Signal = class { constructor(value, compute) { if (!isProduction && new.target !== Signal) { throw new Error('Signal must not be extended!') } // effectStore: [id, delCount, ...effects] // eslint-disable-next-line no-plusplus const id = sigID++ const userEffects = [id, 0] const signalEffects = [id, 0] const disposeCtx = currentDisposers const internals = { id, value, compute, disposeCtx, userEffects, signalEffects } Object.defineProperty(this, '_', { value: internals, writable: false, enumerable: false, configurable: false }) if (compute) { watch(pure(this.set.bind(this, value))) } else if (isSignal(value)) { value.connect(pure(this.set.bind(this, value))) } } static ensure(val) { if (isSignal(val)) { return val } return signal(val) } static ensureAll(...vals) { return vals.map(this.ensure) } get value() { return this.get() } set value(val) { this.set(val) } get connected() { const { userEffects, signalEffects } = this._ return !!(userEffects.length || signalEffects.length) } touch() { this.connect(currentEffect) } get() { this.connect(currentEffect) return this._.value } set(val) { const { compute, value } = this._ const newVal = read(val) val = compute ? peek(compute(newVal)) : newVal if (value !== val) { this._.value = val this.trigger() } } peek() { return this._.value } poke(val) { this._.value = val } trigger() { const { userEffects, signalEffects } = this._ scheduleSignal(signalEffects) scheduleEffect(userEffects) tick() } refresh() { const { compute, value } = this._ if (compute) { const val = peek(compute(value)) if (value !== val) { this._.value = val this.trigger() } } } connect(effect, runImmediate = true) { if (!effect) { return } const { userEffects, signalEffects, disposeCtx } = this._ const effects = isPure(effect) ? signalEffects : userEffects if (contextValid) { const container = [effect] effects.push(container) if (currentDisposers && currentDisposers !== disposeCtx) { _onDispose(function() { container[0] = null if (!--effect.__refui_scheduled && effect.__refui_pending) { effect.__refui_pending = false effect() } scheduleVacuum(effects) }) } if (!Object.hasOwn(effect, '__refui_scheduled')) { Object.defineProperties(effect, { __refui_scheduled: { value: 0, writable: true }, __refui_pending: { value: false, writable: true } }) } } if (runImmediate && currentEffect !== effect) { effect() } } hasValue() { const val = this.get() return val !== undefined && val !== null } inverse() { return signal(this, function(i) { return !i }) } nullishThen(val) { return signal(this, function(i) { const _val = read(val) return (i === undefined || i === null) ? _val : i }) } choose(trueVal, falseVal) { return signal(this, function(i) { const _trueVal = read(trueVal) const _falseVal = read(falseVal) return i ? trueVal : falseVal }) } select(options) { return signal(this, function (i) { const _options = read(options) return Reflect.get(_options, i) }) } and(val) { return signal(this, function(i) { const _val = read(val) return i && _val }) } andNot(val) { return signal(this, function(i) { const _val = read(val) return i && !_val }) } andOr(andVal, orVal) { return signal(this, function(i) { const _andVal = read(andVal) const _orVal = read(orVal) return i && _andVal || orVal }) } inverseAnd(val) { return signal(this, function(i) { const _val = read(val) return !i && _val }) } inverseAndNot(val) { return signal(this, function(i) { const _val = read(val) return !i && !_val }) } inverseAndOr(andVal, orVal) { return signal(this, function(i) { const _andVal = read(andVal) const _orVal = read(orVal) return !i && _andVal || orVal }) } or(val) { return signal(this, function(i) { const _val = read(val) return i || _val }) } orNot(val) { return signal(this, function(i) { const _val = read(val) return i || !_val }) } inverseOr(val) { return signal(this, function(i) { const _val = read(val) return !i || _val }) } inverseOrNot(val) { return signal(this, function(i) { const _val = read(val) return !i || !_val }) } eq(val) { return signal(this, function(i) { return i === read(val) }) } neq(val) { return signal(this, function(i) { return i !== read(val) }) } gt(val) { return signal(this, function(i) { return i > read(val) }) } lt(val) { return signal(this, function(i) { return i < read(val) }) } gte(val) { return signal(this, function(i) { return i >= read(val) }) } lte(val) { return signal(this, function(i) { return i <= read(val) }) } toJSON() { return this.get() } *[Symbol.iterator]() { yield* this.get() } [Symbol.toPrimitive](hint) { const val = this.get() switch (hint) { case 'string': return String(val) case 'number': return Number(val) default: if (Object(val) !== val) { return val } return !!val } } } function signal(value, compute) { return new Signal(value, compute) } Object.defineProperties(signal, { ensure: { value: Signal.ensure.bind(Signal), enumerable: true }, ensureAll: { value: Signal.ensureAll.bind(Signal), enumerable: true } }) function isSignal(val) { return val && val.constructor === Signal } function watch(effect) { const prevEffect = currentEffect currentEffect = effect const _dispose = collectDisposers([], effect) currentEffect = prevEffect return _dispose } function peek(val) { while (isSignal(val)) { val = val.peek() } return val } function poke(val, newVal) { if (isSignal(val)) { return val.poke(newVal) } return newVal } function touch(...vals) { const valCount = vals.length for (let i = 0; i < valCount; i++) { if (isSignal(vals[i])) { vals[i].touch() } } } function read(val) { if (isSignal(val)) { val = peek(val.get()) } return val } function readAll(...vals) { return vals.map(read) } function _write(val, newVal) { if (typeof newVal === 'function') { newVal = newVal(peek(val)) } val.value = newVal return peek(val) } function write(val, newVal) { if (isSignal(val)) { return _write(val, newVal) } if (typeof newVal === 'function') { return newVal(val) } return newVal } function listen(vals, cb) { const valCount = vals.length for (let i = 0; i < valCount; i++) { if (isSignal(vals[i])) { vals[i].connect(cb) } } } function computed(fn) { return signal(null, fn) } function _merged(vals) { return this(...readAll(...vals)) } function merge(vals, handler) { return computed(_merged.bind(handler, vals)) } function tpl(raw, ...exprs) { if (!Array.isArray(raw)) { raw = [raw] } raw = { raw } return signal(null, function() { return String.raw(raw, ...exprs) }) } function dummyDeferrer(cb) { let cancelled = false nextTick(function() { if (cancelled) return cb() }) return function() { cancelled = true } } function createDefer(deferrer = dummyDeferrer) { return function(fn, onAbort) { const deferredSignal = signal() const commit = Signal.prototype.set.bind(deferredSignal) let dispose = null let cleanup = null function callback() { if (!dispose) return cleanup = fn(commit) } let run = function() { callback() run = function() { if (!dispose) return cleanup?.() cleanup = deferrer(callback) } } const frozenWatch = freeze(watch) dispose = deferrer(function() { if (!dispose) return dispose = frozenWatch(function() { run() }) }) function handleAbort() { if (cleanup) { cleanup() cleanup = null } } onDispose(function() { handleAbort() if (dispose) { dispose() dispose = null } }) onAbort?.(handleAbort) return deferredSignal } } const deferred = createDefer() function createSchedule(deferrer, onAbort) { const _deferred = createDefer(deferrer) const [onFlush, triggerFlush] = useAction() let pending = 0 let cancelFlush = null function _flush() { if (cancelFlush) { cancelFlush = null triggerFlush() } } const flush = nextTick.bind(null, _flush) function scheduleFlush() { pending = Math.max(0, pending - 1) if (!pending && !cancelFlush) { cancelFlush = deferrer(flush) } } function scheduled(fn) { let _commit = null let _val = null let _valChanged = false const wrappedFn = (function() { if (isSignal(fn)) { return function(commit) { pending += 1 _commit = commit _val = fn.value nextTick(scheduleFlush) return scheduleFlush } } else { let _cleanup = null function wrappedCommit(val) { if (_val === val) { return } _valChanged = true _val = val scheduleFlush() } function wrappedCleanup() { _cleanup?.() scheduleFlush() } return function(commit) { pending += 1 _commit = commit _cleanup = fn(wrappedCommit) return wrappedCleanup } } })() onAbort?.(function() { _commit = null }) onFlush(function() { if (_valChanged && _commit) { _commit(_val) _commit = null _valChanged = false } }) return _deferred(wrappedFn, onAbort) } onAbort?.(function() { cancelFlush?.() cancelFlush = null }) return scheduled } function connect(sigs, effect, runImmediate = true) { const sigCount = sigs.length for (let i = 0; i < sigCount; i++) { sigs[i].connect(effect, false) } if (runImmediate) { const prevEffect = currentEffect currentEffect = effect try { effect() } finally { currentEffect = prevEffect } } } function bind(handler, val) { if (isSignal(val)) { val.connect(function() { handler(val.peek()) }) } else if (typeof val === 'function') { watch(function() { handler(val()) }) } else { handler(val) } } function useAction(val, compute) { val = signal(val, compute) function onAction(cb) { val.connect(function() { cb(val.peek()) }, false) } function trigger(newVal) { val.value = newVal val.trigger() } return [onAction, trigger, val.touch.bind(val)] } function derive(sig, key, compute) { if (isSignal(sig)) { const derivedSig = signal(null, compute) let disposer = null const _dispose = function() { disposer?.() } sig.connect(pure(function() { _dispose() const newVal = peek(sig) if (!newVal) { return } untrack(function() { disposer = watch(function() { derivedSig.value = read(newVal[key]) }) }) })) onDispose(_dispose) return derivedSig } else { return signal(sig[key], compute) } } function extract(sig, ...extractions) { if (!extractions.length) { extractions = Object.keys(peek(sig)) } return extractions.reduce(function(mapped, i) { mapped[i] = signal(sig, function(val) { return val && peek(val[i]) }) return mapped }, {}) } function derivedExtract(sig, ...extractions) { if (!extractions.length) { extractions = Object.keys(peek(sig)) } return extractions.reduce(function(mapped, i) { mapped[i] = derive(sig, i) return mapped }, {}) } function makeReactive(obj) { return Object.defineProperties({}, Object.entries(obj).reduce(function(descriptors, [key, value]) { if (isSignal(value)) { descriptors[key] = { get: value.get.bind(value), set: value.set.bind(value), enumerable: true, configurable: false } } else { descriptors[key] = { value, enumerable: true } } return descriptors }, {})) } function onCondition(sig, compute) { let currentVal = null let conditionMap = new Map() let conditionValMap = new Map() sig.connect( pure(function() { const newVal = peek(sig) if (currentVal !== newVal) { const prevMatchSet = conditionMap.get(currentVal) const newMatchSet = conditionMap.get(newVal) currentVal = newVal if (prevMatchSet) { const size = prevMatchSet.length for (let i = 0; i < size; i++) prevMatchSet[i].value = false } if (newMatchSet) { const size = newMatchSet.length for (let i = 0; i < size; i++) newMatchSet[i].value = true } } }) ) if (currentDisposers) { _onDispose(function() { conditionMap = new Map() conditionValMap = new Map() }) } function match(condition) { let currentCondition = peek(condition) let matchSet = conditionMap.get(currentCondition) if (isSignal(condition)) { let matchSig = conditionValMap.get(condition) if (!matchSig) { matchSig = signal(currentCondition === currentVal, compute) conditionValMap.set(condition, matchSig) condition.connect(function() { currentCondition = peek(condition) if (matchSet) { removeFromArr(matchSet, matchSig) } matchSet = conditionMap.get(currentCondition) if (!matchSet) { matchSet = [] conditionMap.set(currentCondition, matchSet) } matchSet.push(matchSig) matchSig.value = currentCondition === currentVal }) if (currentDisposers) { _onDispose(function() { conditionValMap.delete(condition) if (matchSet.length === 1) conditionMap.delete(currentCondition) else removeFromArr(matchSet, matchSig) }) } } return matchSig } else { if (!matchSet) { matchSet = [] conditionMap.set(currentCondition, matchSet) } let matchSig = conditionValMap.get(currentCondition) if (!matchSig) { matchSig = signal(currentCondition === currentVal, compute) conditionValMap.set(currentCondition, matchSig) matchSet.push(matchSig) if (currentDisposers) { _onDispose(function() { conditionValMap.delete(currentCondition) if (matchSet.length === 1) { conditionMap.delete(currentCondition) } else { removeFromArr(matchSet, matchSig) } }) } } return matchSig } } return match } resetTick() export { Signal, signal, isSignal, computed, createDefer, createSchedule, deferred, connect, bind, useAction, derive, extract, derivedExtract, makeReactive, tpl, watch, peek, poke, touch, read, readAll, merge, write, listen, scheduleEffect as schedule, tick, nextTick, collectDisposers, onCondition, onDispose, useEffect, untrack, freeze, contextValid }