diff --git a/.changeset/pre.json b/.changeset/pre.json index 6d6cf68..6e39fd2 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -4,5 +4,7 @@ "initialVersions": { "haunted": "5.0.0" }, - "changesets": [] + "changesets": [ + "chilly-ants-swim" + ] } diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c388286 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# haunted + +## 6.0.0-next.0 + +### Major Changes + +- f861a4b: Deprecates haunted.js and web.js bundles diff --git a/component.d.ts b/component.d.ts new file mode 100644 index 0000000..5d840f4 --- /dev/null +++ b/component.d.ts @@ -0,0 +1,20 @@ +import { GenericRenderer, RenderFunction } from './core'; +interface Renderer

extends GenericRenderer { + (this: Component

, host: Component

): unknown | void; + observedAttributes?: (keyof P)[]; +} +declare type Component

= HTMLElement & P; +declare type Constructor

= new (...args: unknown[]) => Component

; +interface Creator { +

(renderer: Renderer

): Constructor

; +

(renderer: Renderer

, options: Options

): Constructor

; +

(renderer: Renderer

, baseElement: Constructor<{}>, options: Omit, 'baseElement'>): Constructor

; +} +interface Options

{ + baseElement?: Constructor<{}>; + observedAttributes?: (keyof P)[]; + useShadowDOM?: boolean; + shadowRootInit?: ShadowRootInit; +} +declare function makeComponent(render: RenderFunction): Creator; +export { makeComponent, Component, Constructor as ComponentConstructor, Creator as ComponentCreator }; diff --git a/component.js b/component.js new file mode 100644 index 0000000..2884f55 --- /dev/null +++ b/component.js @@ -0,0 +1,106 @@ +import { BaseScheduler } from './scheduler'; +const toCamelCase = (val = '') => val.replace(/-+([a-z])?/g, (_, char) => char ? char.toUpperCase() : ''); +function makeComponent(render) { + class Scheduler extends BaseScheduler { + frag; + constructor(renderer, frag, host) { + super(renderer, (host || frag)); + this.frag = frag; + } + commit(result) { + render(result, this.frag); + } + } + function component(renderer, baseElementOrOptions, options) { + const BaseElement = (options || baseElementOrOptions || {}).baseElement || HTMLElement; + const { observedAttributes = [], useShadowDOM = true, shadowRootInit = {} } = options || baseElementOrOptions || {}; + class Element extends BaseElement { + _scheduler; + static get observedAttributes() { + return renderer.observedAttributes || observedAttributes || []; + } + constructor() { + super(); + if (useShadowDOM === false) { + this._scheduler = new Scheduler(renderer, this); + } + else { + this.attachShadow({ mode: 'open', ...shadowRootInit }); + this._scheduler = new Scheduler(renderer, this.shadowRoot, this); + } + } + connectedCallback() { + this._scheduler.update(); + } + disconnectedCallback() { + this._scheduler.teardown(); + } + attributeChangedCallback(name, oldValue, newValue) { + if (oldValue === newValue) { + return; + } + let val = newValue === '' ? true : newValue; + Reflect.set(this, toCamelCase(name), val); + } + } + ; + function reflectiveProp(initialValue) { + let value = initialValue; + let isSetup = false; + return Object.freeze({ + enumerable: true, + configurable: true, + get() { + return value; + }, + set(newValue) { + // Avoid scheduling update when prop value hasn't changed + if (isSetup && value === newValue) + return; + isSetup = true; + value = newValue; + if (this._scheduler) { + this._scheduler.update(); + } + } + }); + } + const proto = new Proxy(BaseElement.prototype, { + getPrototypeOf(target) { + return target; + }, + set(target, key, value, receiver) { + let desc; + if (key in target) { + desc = Object.getOwnPropertyDescriptor(target, key); + if (desc && desc.set) { + desc.set.call(receiver, value); + return true; + } + Reflect.set(target, key, value, receiver); + return true; + } + if (typeof key === 'symbol' || key[0] === '_') { + desc = { + enumerable: true, + configurable: true, + writable: true, + value + }; + } + else { + desc = reflectiveProp(value); + } + Object.defineProperty(receiver, key, desc); + if (desc.set) { + desc.set.call(receiver, value); + } + return true; + } + }); + Object.setPrototypeOf(Element.prototype, proto); + return Element; + } + return component; +} +export { makeComponent }; diff --git a/core.d.ts b/core.d.ts new file mode 100644 index 0000000..1c5cd2c --- /dev/null +++ b/core.d.ts @@ -0,0 +1,27 @@ +import { ComponentCreator } from './component'; +import { ContextCreator } from './create-context'; +import { ChildPart } from 'lit/html'; +declare type Component

= HTMLElement & P; +declare type ComponentOrVirtualComponent = T extends HTMLElement ? Component

: ChildPart; +declare type GenericRenderer = (this: ComponentOrVirtualComponent, ...args: any[]) => unknown | void; +declare type RenderFunction = (result: unknown, container: DocumentFragment | HTMLElement) => void; +interface Options { + render: RenderFunction; +} +declare function haunted({ render }: Options): { + component: ComponentCreator; + createContext: ContextCreator; +}; +export { haunted as default, Options, GenericRenderer, RenderFunction, ComponentOrVirtualComponent }; +export { useCallback } from './use-callback'; +export { useController } from './use-controller'; +export { useEffect } from './use-effect'; +export { useLayoutEffect } from './use-layout-effect'; +export { useState } from './use-state'; +export { useReducer } from './use-reducer'; +export { useMemo } from './use-memo'; +export { useContext } from './use-context'; +export { useRef } from './use-ref'; +export { hook, Hook } from './hook'; +export { BaseScheduler } from './scheduler'; +export { State } from './state'; diff --git a/core.js b/core.js new file mode 100644 index 0000000..088e24f --- /dev/null +++ b/core.js @@ -0,0 +1,20 @@ +import { makeComponent } from './component'; +import { makeContext } from './create-context'; +function haunted({ render }) { + const component = makeComponent(render); + const createContext = makeContext(component); + return { component, createContext }; +} +export { haunted as default }; +export { useCallback } from './use-callback'; +export { useController } from './use-controller'; +export { useEffect } from './use-effect'; +export { useLayoutEffect } from './use-layout-effect'; +export { useState } from './use-state'; +export { useReducer } from './use-reducer'; +export { useMemo } from './use-memo'; +export { useContext } from './use-context'; +export { useRef } from './use-ref'; +export { hook, Hook } from './hook'; +export { BaseScheduler } from './scheduler'; +export { State } from './state'; diff --git a/create-context.d.ts b/create-context.d.ts new file mode 100644 index 0000000..a37dc6b --- /dev/null +++ b/create-context.d.ts @@ -0,0 +1,20 @@ +import { ComponentConstructor, ComponentCreator } from './component'; +interface ConsumerProps { + render: (value: T) => unknown; +} +interface Creator { + (defaultValue: T): Context; +} +interface Context { + Provider: ComponentConstructor<{}>; + Consumer: ComponentConstructor>; + defaultValue: T; +} +interface ContextDetail { + Context: Context; + callback: (value: T) => void; + value: T; + unsubscribe?: (this: Context) => void; +} +declare function makeContext(component: ComponentCreator): Creator; +export { makeContext, Creator as ContextCreator, Context, ContextDetail }; diff --git a/create-context.js b/create-context.js new file mode 100644 index 0000000..47c8cde --- /dev/null +++ b/create-context.js @@ -0,0 +1,48 @@ +import { contextEvent } from './symbols'; +import { useContext } from './use-context'; +function makeContext(component) { + return (defaultValue) => { + const Context = { + Provider: class extends HTMLElement { + listeners; + _value; + constructor() { + super(); + this.listeners = new Set(); + this.addEventListener(contextEvent, this); + } + disconnectedCallback() { + this.removeEventListener(contextEvent, this); + } + handleEvent(event) { + const { detail } = event; + if (detail.Context === Context) { + detail.value = this.value; + detail.unsubscribe = this.unsubscribe.bind(this, detail.callback); + this.listeners.add(detail.callback); + event.stopPropagation(); + } + } + unsubscribe(callback) { + this.listeners.delete(callback); + } + set value(value) { + this._value = value; + for (let callback of this.listeners) { + callback(value); + } + } + get value() { + return this._value; + } + }, + Consumer: component(function ({ render }) { + const context = useContext(Context); + return render(context); + }), + defaultValue, + }; + return Context; + }; +} +export { makeContext }; diff --git a/create-effect.d.ts b/create-effect.d.ts new file mode 100644 index 0000000..b9c44a9 --- /dev/null +++ b/create-effect.d.ts @@ -0,0 +1,4 @@ +import { State, Callable } from './state'; +declare type Effect = (this: State) => void | VoidFunction | Promise; +declare function createEffect(setEffects: (state: State, cb: Callable) => void): (callback: Effect, values?: unknown[] | undefined) => void; +export { createEffect }; diff --git a/create-effect.js b/create-effect.js new file mode 100644 index 0000000..df474ed --- /dev/null +++ b/create-effect.js @@ -0,0 +1,36 @@ +import { Hook, hook } from './hook'; +function createEffect(setEffects) { + return hook(class extends Hook { + callback; + lastValues; + values; + _teardown; + constructor(id, state, ignored1, ignored2) { + super(id, state); + setEffects(state, this); + } + update(callback, values) { + this.callback = callback; + this.values = values; + } + call() { + if (!this.values || this.hasChanged()) { + this.run(); + } + this.lastValues = this.values; + } + run() { + this.teardown(); + this._teardown = this.callback.call(this.state); + } + teardown() { + if (typeof this._teardown === 'function') { + this._teardown(); + } + } + hasChanged() { + return !this.lastValues || this.values.some((value, i) => this.lastValues[i] !== value); + } + }); +} +export { createEffect }; diff --git a/haunted.d.ts b/haunted.d.ts new file mode 100644 index 0000000..9154692 --- /dev/null +++ b/haunted.d.ts @@ -0,0 +1,3 @@ +export { html, render, component, createContext, virtual } from './lit-haunted'; +export * from './core'; +export { default } from './core'; diff --git a/hook.d.ts b/hook.d.ts new file mode 100644 index 0000000..f4193a5 --- /dev/null +++ b/hook.d.ts @@ -0,0 +1,13 @@ +import { State } from './state'; +declare abstract class Hook

{ + id: number; + state: State; + constructor(id: number, state: State); + abstract update(...args: P): R; + teardown?(): void; +} +interface CustomHook

{ + new (id: number, state: State, ...args: P): Hook; +} +declare function hook

(Hook: CustomHook): (...args: P) => R; +export { hook, Hook }; diff --git a/hook.js b/hook.js new file mode 100644 index 0000000..f884ebb --- /dev/null +++ b/hook.js @@ -0,0 +1,24 @@ +import { current, notify } from './interface'; +import { hookSymbol } from './symbols'; +class Hook { + id; + state; + constructor(id, state) { + this.id = id; + this.state = state; + } +} +function use(Hook, ...args) { + let id = notify(); + let hooks = current[hookSymbol]; + let hook = hooks.get(id); + if (!hook) { + hook = new Hook(id, current, ...args); + hooks.set(id, hook); + } + return hook.update(...args); +} +function hook(Hook) { + return use.bind(null, Hook); +} +export { hook, Hook }; diff --git a/interface.d.ts b/interface.d.ts new file mode 100644 index 0000000..fc81263 --- /dev/null +++ b/interface.d.ts @@ -0,0 +1,6 @@ +import { State } from './state'; +declare let current: State | null; +declare function setCurrent(state: State): void; +declare function clear(): void; +declare function notify(): number; +export { clear, current, setCurrent, notify }; diff --git a/interface.js b/interface.js new file mode 100644 index 0000000..0d6b75f --- /dev/null +++ b/interface.js @@ -0,0 +1,13 @@ +let current; +let currentId = 0; +function setCurrent(state) { + current = state; +} +function clear() { + current = null; + currentId = 0; +} +function notify() { + return currentId++; +} +export { clear, current, setCurrent, notify }; diff --git a/lit-haunted.d.ts b/lit-haunted.d.ts new file mode 100644 index 0000000..cd1fe1f --- /dev/null +++ b/lit-haunted.d.ts @@ -0,0 +1,4 @@ +import { html, render } from 'lit'; +declare const component: import("./component").ComponentCreator, createContext: import("./create-context").ContextCreator; +declare const virtual: any; +export { component, createContext, virtual, html, render }; diff --git a/lit-haunted.js b/lit-haunted.js new file mode 100644 index 0000000..ff3729a --- /dev/null +++ b/lit-haunted.js @@ -0,0 +1,6 @@ +import { html, render } from 'lit'; +import haunted from './core'; +import { makeVirtual } from './virtual'; +const { component, createContext } = haunted({ render }); +const virtual = makeVirtual(); +export { component, createContext, virtual, html, render }; diff --git a/package.json b/package.json index b0a03eb..433e236 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "haunted", - "version": "5.0.0", + "version": "6.0.0-next.0", "description": "Hooks for web components", "main": "lib/haunted.js", "module": "lib/haunted.js", @@ -23,7 +23,11 @@ "type": "git", "url": "git+https://github.com/matthewp/haunted.git" }, - "keywords": ["haunted", "lit", "web-components"], + "keywords": [ + "haunted", + "lit", + "web-components" + ], "author": "", "license": "BSD-2-Clause", "bugs": { diff --git a/scheduler.d.ts b/scheduler.d.ts new file mode 100644 index 0000000..8317087 --- /dev/null +++ b/scheduler.d.ts @@ -0,0 +1,21 @@ +import { State } from './state'; +import { commitSymbol, phaseSymbol, updateSymbol, effectsSymbol, Phase, EffectsSymbols } from './symbols'; +import { GenericRenderer, ComponentOrVirtualComponent } from './core'; +import { ChildPart } from 'lit/html'; +declare abstract class BaseScheduler

, H extends ComponentOrVirtualComponent> { + renderer: R; + host: H; + state: State; + [phaseSymbol]: Phase | null; + _updateQueued: boolean; + constructor(renderer: R, host: H); + update(): void; + handlePhase(phase: typeof commitSymbol, arg: unknown): void; + handlePhase(phase: typeof updateSymbol): unknown; + handlePhase(phase: typeof effectsSymbol): void; + render(): unknown; + runEffects(phase: EffectsSymbols): void; + abstract commit(result: unknown): void; + teardown(): void; +} +export { BaseScheduler }; diff --git a/scheduler.js b/scheduler.js new file mode 100644 index 0000000..253dadb --- /dev/null +++ b/scheduler.js @@ -0,0 +1,73 @@ +import { State } from './state'; +import { commitSymbol, phaseSymbol, updateSymbol, effectsSymbol, layoutEffectsSymbol } from './symbols'; +const defer = Promise.resolve().then.bind(Promise.resolve()); +function runner() { + let tasks = []; + let id; + function runTasks() { + id = null; + let t = tasks; + tasks = []; + for (var i = 0, len = t.length; i < len; i++) { + t[i](); + } + } + return function (task) { + tasks.push(task); + if (id == null) { + id = defer(runTasks); + } + }; +} +const read = runner(); +const write = runner(); +class BaseScheduler { + renderer; + host; + state; + [phaseSymbol]; + _updateQueued; + constructor(renderer, host) { + this.renderer = renderer; + this.host = host; + this.state = new State(this.update.bind(this), host); + this[phaseSymbol] = null; + this._updateQueued = false; + } + update() { + if (this._updateQueued) + return; + read(() => { + let result = this.handlePhase(updateSymbol); + write(() => { + this.handlePhase(commitSymbol, result); + write(() => { + this.handlePhase(effectsSymbol); + }); + }); + this._updateQueued = false; + }); + this._updateQueued = true; + } + handlePhase(phase, arg) { + this[phaseSymbol] = phase; + switch (phase) { + case commitSymbol: + this.commit(arg); + this.runEffects(layoutEffectsSymbol); + return; + case updateSymbol: return this.render(); + case effectsSymbol: return this.runEffects(effectsSymbol); + } + } + render() { + return this.state.run(() => this.renderer.call(this.host, this.host)); + } + runEffects(phase) { + this.state._runEffects(phase); + } + teardown() { + this.state.teardown(); + } +} +export { BaseScheduler }; diff --git a/state.d.ts b/state.d.ts new file mode 100644 index 0000000..cbc0abf --- /dev/null +++ b/state.d.ts @@ -0,0 +1,20 @@ +import { Hook } from './hook'; +import { hookSymbol, effectsSymbol, layoutEffectsSymbol, EffectsSymbols } from './symbols'; +interface Callable { + call: (state: State) => void; +} +declare class State { + update: VoidFunction; + host: H; + virtual?: boolean; + [hookSymbol]: Map; + [effectsSymbol]: Callable[]; + [layoutEffectsSymbol]: Callable[]; + constructor(update: VoidFunction, host: H); + run(cb: () => T): T; + _runEffects(phase: EffectsSymbols): void; + runEffects(): void; + runLayoutEffects(): void; + teardown(): void; +} +export { State, Callable }; diff --git a/state.js b/state.js new file mode 100644 index 0000000..f722eda --- /dev/null +++ b/state.js @@ -0,0 +1,46 @@ +import { setCurrent, clear } from './interface'; +import { hookSymbol, effectsSymbol, layoutEffectsSymbol } from './symbols'; +class State { + update; + host; + virtual; + [hookSymbol]; + [effectsSymbol]; + [layoutEffectsSymbol]; + constructor(update, host) { + this.update = update; + this.host = host; + this[hookSymbol] = new Map(); + this[effectsSymbol] = []; + this[layoutEffectsSymbol] = []; + } + run(cb) { + setCurrent(this); + let res = cb(); + clear(); + return res; + } + _runEffects(phase) { + let effects = this[phase]; + setCurrent(this); + for (let effect of effects) { + effect.call(this); + } + clear(); + } + runEffects() { + this._runEffects(effectsSymbol); + } + runLayoutEffects() { + this._runEffects(layoutEffectsSymbol); + } + teardown() { + let hooks = this[hookSymbol]; + hooks.forEach(hook => { + if (typeof hook.teardown === 'function') { + hook.teardown(); + } + }); + } +} +export { State }; diff --git a/symbols.d.ts b/symbols.d.ts new file mode 100644 index 0000000..f403868 --- /dev/null +++ b/symbols.d.ts @@ -0,0 +1,10 @@ +declare const phaseSymbol: unique symbol; +declare const hookSymbol: unique symbol; +declare const updateSymbol: unique symbol; +declare const commitSymbol: unique symbol; +declare const effectsSymbol: unique symbol; +declare const layoutEffectsSymbol: unique symbol; +declare type EffectsSymbols = typeof effectsSymbol | typeof layoutEffectsSymbol; +declare type Phase = typeof updateSymbol | typeof commitSymbol | typeof effectsSymbol; +declare const contextEvent = "haunted.context"; +export { phaseSymbol, hookSymbol, updateSymbol, commitSymbol, effectsSymbol, layoutEffectsSymbol, contextEvent, Phase, EffectsSymbols, }; diff --git a/symbols.js b/symbols.js new file mode 100644 index 0000000..dc8b14f --- /dev/null +++ b/symbols.js @@ -0,0 +1,8 @@ +const phaseSymbol = Symbol('haunted.phase'); +const hookSymbol = Symbol('haunted.hook'); +const updateSymbol = Symbol('haunted.update'); +const commitSymbol = Symbol('haunted.commit'); +const effectsSymbol = Symbol('haunted.effects'); +const layoutEffectsSymbol = Symbol('haunted.layoutEffects'); +const contextEvent = 'haunted.context'; +export { phaseSymbol, hookSymbol, updateSymbol, commitSymbol, effectsSymbol, layoutEffectsSymbol, contextEvent, }; diff --git a/use-callback.d.ts b/use-callback.d.ts new file mode 100644 index 0000000..ae557f9 --- /dev/null +++ b/use-callback.d.ts @@ -0,0 +1,9 @@ +/** + * @function + * @template {Function} T + * @param {T} fn - callback to memoize + * @param {unknown[]} inputs - dependencies to callback memoization + * @return {T} + */ +declare const useCallback: (fn: T, inputs: unknown[]) => T; +export { useCallback }; diff --git a/use-callback.js b/use-callback.js new file mode 100644 index 0000000..b33d8ad --- /dev/null +++ b/use-callback.js @@ -0,0 +1,10 @@ +import { useMemo } from './use-memo'; +/** + * @function + * @template {Function} T + * @param {T} fn - callback to memoize + * @param {unknown[]} inputs - dependencies to callback memoization + * @return {T} + */ +const useCallback = (fn, inputs) => useMemo(() => fn, inputs); +export { useCallback }; diff --git a/use-context.d.ts b/use-context.d.ts new file mode 100644 index 0000000..c298e20 --- /dev/null +++ b/use-context.d.ts @@ -0,0 +1,9 @@ +import { Context } from './create-context'; +/** + * @function + * @template T + * @param {Context} context + * @return {T} + */ +declare const useContext: (Context: Context) => T; +export { useContext }; diff --git a/use-context.js b/use-context.js new file mode 100644 index 0000000..2c56b07 --- /dev/null +++ b/use-context.js @@ -0,0 +1,63 @@ +import { hook, Hook } from './hook'; +import { contextEvent } from './symbols'; +import { setEffects } from './use-effect'; +/** + * @function + * @template T + * @param {Context} context + * @return {T} + */ +const useContext = hook(class extends Hook { + Context; + value; + _ranEffect; + _unsubscribe; + constructor(id, state, _) { + super(id, state); + this._updater = this._updater.bind(this); + this._ranEffect = false; + this._unsubscribe = null; + setEffects(state, this); + } + update(Context) { + if (this.state.virtual) { + throw new Error('can\'t be used with virtual components'); + } + if (this.Context !== Context) { + this._subscribe(Context); + this.Context = Context; + } + return this.value; + } + call() { + if (!this._ranEffect) { + this._ranEffect = true; + if (this._unsubscribe) + this._unsubscribe(); + this._subscribe(this.Context); + this.state.update(); + } + } + _updater(value) { + this.value = value; + this.state.update(); + } + _subscribe(Context) { + const detail = { Context, callback: this._updater }; + this.state.host.dispatchEvent(new CustomEvent(contextEvent, { + detail, + bubbles: true, + cancelable: true, + composed: true, // to pass ShadowDOM boundaries + })); + const { unsubscribe = null, value } = detail; + this.value = unsubscribe ? value : Context.defaultValue; + this._unsubscribe = unsubscribe; + } + teardown() { + if (this._unsubscribe) { + this._unsubscribe(); + } + } +}); +export { useContext }; diff --git a/use-controller.d.ts b/use-controller.d.ts new file mode 100644 index 0000000..08a3fec --- /dev/null +++ b/use-controller.d.ts @@ -0,0 +1,18 @@ +/** + * @license + * Portions Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +import { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element'; +/** + * Creates and stores a stateful ReactiveController instance and provides it + * with a ReactiveControllerHost that drives the controller lifecycle. + * + * Use this hook to convert a ReactiveController into a Haunted hook. + * + * @param {(host: ReactiveControllerHost) => C} createController A function that creates a controller instance. + * This function is given a HauntedControllerHost to pass to the controller. The + * create function is only called once per component. + * @return {ReactiveController} the controller instance + */ +export declare function useController(createController: (host: ReactiveControllerHost) => C): C; diff --git a/use-controller.js b/use-controller.js new file mode 100644 index 0000000..e93f1e6 --- /dev/null +++ b/use-controller.js @@ -0,0 +1,94 @@ +/** + * @license + * Portions Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +import { useLayoutEffect } from './use-layout-effect'; +import { useState } from './use-state'; +const microtask = Promise.resolve(); +/** + * An implementation of ReactiveControllerHost that is driven by Haunted hooks + * and `useController()`. + */ +class HauntedControllerHost { + count; + kick; + _controllers = []; + _updatePending = true; + _updateCompletePromise; + _resolveUpdate; + constructor(count, kick) { + this.count = count; + this.kick = kick; + this._updateCompletePromise = new Promise(res => { + this._resolveUpdate = res; + }); + } + addController(controller) { + this._controllers.push(controller); + } + removeController(controller) { + // Note, if the indexOf is -1, the >>> will flip the sign which makes the + // splice do nothing. + this._controllers && this._controllers.splice(this._controllers.indexOf(controller) >>> 0, 1); + } + requestUpdate() { + if (!this._updatePending) { + this._updatePending = true; + microtask.then(() => this.kick(this.count + 1)); + } + } + get updateComplete() { + return this._updateCompletePromise; + } + connected() { + this._controllers.forEach(c => c.hostConnected && c.hostConnected()); + } + disconnected() { + this._controllers.forEach(c => c.hostDisconnected && c.hostDisconnected()); + } + update() { + this._controllers.forEach(c => c.hostUpdate && c.hostUpdate()); + } + updated() { + this._updatePending = false; + const resolve = this._resolveUpdate; + // Create a new updateComplete Promise for the next update, + // before resolving the current one. + this._updateCompletePromise = new Promise(res => { + this._resolveUpdate = res; + }); + this._controllers.forEach(c => c.hostUpdated && c.hostUpdated()); + resolve(this._updatePending); + } +} +/** + * Creates and stores a stateful ReactiveController instance and provides it + * with a ReactiveControllerHost that drives the controller lifecycle. + * + * Use this hook to convert a ReactiveController into a Haunted hook. + * + * @param {(host: ReactiveControllerHost) => C} createController A function that creates a controller instance. + * This function is given a HauntedControllerHost to pass to the controller. The + * create function is only called once per component. + * @return {ReactiveController} the controller instance + */ +export function useController(createController) { + const [count, kick] = useState(0); + const [host] = useState(() => { + const host = new HauntedControllerHost(count, kick); + const controller = createController(host); + host.primaryController = controller; + host.connected(); + return host; + }); + // We use useLayoutEffect because we need updated() called synchronously + // after rendering. + useLayoutEffect(() => host.updated()); + // Returning a cleanup function simulates hostDisconnected timing. An empty + // deps array tells Haunted to only call this once: on mount with the cleanup + // called on unmount. + useLayoutEffect(() => () => host.disconnected(), []); + host.update(); + return host.primaryController; +} diff --git a/use-effect.d.ts b/use-effect.d.ts new file mode 100644 index 0000000..27e8877 --- /dev/null +++ b/use-effect.d.ts @@ -0,0 +1,10 @@ +import { State, Callable } from './state'; +declare function setEffects(state: State, cb: Callable): void; +/** + * @function + * @param {() => void} effect - callback function that runs each time dependencies change + * @param {unknown[]} [dependencies] - list of dependencies to the effect + * @return {void} + */ +declare const useEffect: (callback: (this: State) => void | Promise | VoidFunction, values?: unknown[] | undefined) => void; +export { setEffects, useEffect }; diff --git a/use-effect.js b/use-effect.js new file mode 100644 index 0000000..535929b --- /dev/null +++ b/use-effect.js @@ -0,0 +1,13 @@ +import { effectsSymbol } from './symbols'; +import { createEffect } from './create-effect'; +function setEffects(state, cb) { + state[effectsSymbol].push(cb); +} +/** + * @function + * @param {() => void} effect - callback function that runs each time dependencies change + * @param {unknown[]} [dependencies] - list of dependencies to the effect + * @return {void} + */ +const useEffect = createEffect(setEffects); +export { setEffects, useEffect }; diff --git a/use-layout-effect.d.ts b/use-layout-effect.d.ts new file mode 100644 index 0000000..4c313f2 --- /dev/null +++ b/use-layout-effect.d.ts @@ -0,0 +1,9 @@ +import { State } from './state'; +/** + * @function + * @param {Effect} callback effecting callback + * @param {unknown[]} [values] dependencies to the effect + * @return {void} + */ +declare const useLayoutEffect: (callback: (this: State) => void | Promise | VoidFunction, values?: unknown[] | undefined) => void; +export { useLayoutEffect }; diff --git a/use-layout-effect.js b/use-layout-effect.js new file mode 100644 index 0000000..5c45cb4 --- /dev/null +++ b/use-layout-effect.js @@ -0,0 +1,13 @@ +import { layoutEffectsSymbol } from './symbols'; +import { createEffect } from './create-effect'; +function setLayoutEffects(state, cb) { + state[layoutEffectsSymbol].push(cb); +} +/** + * @function + * @param {Effect} callback effecting callback + * @param {unknown[]} [values] dependencies to the effect + * @return {void} + */ +const useLayoutEffect = createEffect(setLayoutEffects); +export { useLayoutEffect }; diff --git a/use-memo.d.ts b/use-memo.d.ts new file mode 100644 index 0000000..14e9dc0 --- /dev/null +++ b/use-memo.d.ts @@ -0,0 +1,9 @@ +/** + * @function + * @template T + * @param {() => T} fn function to memoize + * @param {unknown[]} values dependencies to the memoized computation + * @return {T} The next computed value + */ +declare const useMemo: (fn: () => T, values: unknown[]) => T; +export { useMemo }; diff --git a/use-memo.js b/use-memo.js new file mode 100644 index 0000000..890ab31 --- /dev/null +++ b/use-memo.js @@ -0,0 +1,28 @@ +import { hook, Hook } from './hook'; +/** + * @function + * @template T + * @param {() => T} fn function to memoize + * @param {unknown[]} values dependencies to the memoized computation + * @return {T} The next computed value + */ +const useMemo = hook(class extends Hook { + value; + values; + constructor(id, state, fn, values) { + super(id, state); + this.value = fn(); + this.values = values; + } + update(fn, values) { + if (this.hasChanged(values)) { + this.values = values; + this.value = fn(); + } + return this.value; + } + hasChanged(values = []) { + return values.some((value, i) => this.values[i] !== value); + } +}); +export { useMemo }; diff --git a/use-reducer.d.ts b/use-reducer.d.ts new file mode 100644 index 0000000..ef18ae9 --- /dev/null +++ b/use-reducer.d.ts @@ -0,0 +1,14 @@ +declare type Reducer = (state: S, action: A) => S; +/** + * Given a reducer function, initial state, and optional state initializer function, returns a tuple of state and dispatch function. + * @function + * @template S State + * @template I Initial State + * @template A Action + * @param {Reducer} reducer - reducer function to compute the next state given the previous state and the action + * @param {I} initialState - the initial state of the reducer + * @param {(init: I) => S} [init=undefined] - Optional initializer function, called on initialState if provided + * @return {readonly [S, (action: A) => void]} + */ +declare const useReducer: (_: Reducer, initialState: I, init?: ((_: I) => S) | undefined) => readonly [S, (action: A) => void]; +export { useReducer }; diff --git a/use-reducer.js b/use-reducer.js new file mode 100644 index 0000000..9981a2b --- /dev/null +++ b/use-reducer.js @@ -0,0 +1,30 @@ +import { hook, Hook } from './hook'; +/** + * Given a reducer function, initial state, and optional state initializer function, returns a tuple of state and dispatch function. + * @function + * @template S State + * @template I Initial State + * @template A Action + * @param {Reducer} reducer - reducer function to compute the next state given the previous state and the action + * @param {I} initialState - the initial state of the reducer + * @param {(init: I) => S} [init=undefined] - Optional initializer function, called on initialState if provided + * @return {readonly [S, (action: A) => void]} + */ +const useReducer = hook(class extends Hook { + reducer; + currentState; + constructor(id, state, _, initialState, init) { + super(id, state); + this.dispatch = this.dispatch.bind(this); + this.currentState = init !== undefined ? init(initialState) : initialState; + } + update(reducer) { + this.reducer = reducer; + return [this.currentState, this.dispatch]; + } + dispatch(action) { + this.currentState = this.reducer(this.currentState, action); + this.state.update(); + } +}); +export { useReducer }; diff --git a/use-ref.d.ts b/use-ref.d.ts new file mode 100644 index 0000000..2df8ec6 --- /dev/null +++ b/use-ref.d.ts @@ -0,0 +1,10 @@ +/** + * @function + * @template T + * @param {T} initialValue + * @return {{ current: T }} Ref + */ +declare const useRef: (initialValue: T) => { + current: T; +}; +export { useRef }; diff --git a/use-ref.js b/use-ref.js new file mode 100644 index 0000000..252227d --- /dev/null +++ b/use-ref.js @@ -0,0 +1,11 @@ +import { useMemo } from './use-memo'; +/** + * @function + * @template T + * @param {T} initialValue + * @return {{ current: T }} Ref + */ +const useRef = (initialValue) => useMemo(() => ({ + current: initialValue +}), []); +export { useRef }; diff --git a/use-state.d.ts b/use-state.d.ts new file mode 100644 index 0000000..6ce904e --- /dev/null +++ b/use-state.d.ts @@ -0,0 +1,10 @@ +declare type NewState = T | ((previousState?: T) => T); +declare type StateUpdater = (value: NewState) => void; +/** + * @function + * @template {*} T + * @param {T} [initialState] - Optional initial state + * @return {readonly [state: T, updaterFn: StateUpdater]} stateTuple - Tuple of current state and state updater function + */ +declare const useState: (initialValue?: T | undefined) => readonly [T extends (...args: any[]) => infer R ? R : T, StateUpdater infer S ? S : T>]; +export { useState }; diff --git a/use-state.js b/use-state.js new file mode 100644 index 0000000..99a5fff --- /dev/null +++ b/use-state.js @@ -0,0 +1,35 @@ +import { hook, Hook } from './hook'; +/** + * @function + * @template {*} T + * @param {T} [initialState] - Optional initial state + * @return {readonly [state: T, updaterFn: StateUpdater]} stateTuple - Tuple of current state and state updater function + */ +const useState = hook(class extends Hook { + args; + constructor(id, state, initialValue) { + super(id, state); + this.updater = this.updater.bind(this); + if (typeof initialValue === 'function') { + initialValue = initialValue(); + } + this.makeArgs(initialValue); + } + update() { + return this.args; + } + updater(value) { + if (typeof value === 'function') { + const updaterFn = value; + const [previousValue] = this.args; + value = updaterFn(previousValue); + } + this.makeArgs(value); + this.state.update(); + } + makeArgs(value) { + this.args = Object.freeze([value, this.updater]); + } +}); +; +export { useState }; diff --git a/virtual.d.ts b/virtual.d.ts new file mode 100644 index 0000000..9196e60 --- /dev/null +++ b/virtual.d.ts @@ -0,0 +1,7 @@ +import { ChildPart } from 'lit/directive.js'; +import { GenericRenderer } from './core'; +interface Renderer extends GenericRenderer { + (this: ChildPart, ...args: unknown[]): unknown | void; +} +declare function makeVirtual(): any; +export { makeVirtual, Renderer as VirtualRenderer }; diff --git a/virtual.js b/virtual.js new file mode 100644 index 0000000..b770638 --- /dev/null +++ b/virtual.js @@ -0,0 +1,79 @@ +import { directive } from 'lit/directive.js'; +import { noChange } from 'lit'; +import { AsyncDirective } from 'lit/async-directive.js'; +import { BaseScheduler } from './scheduler'; +const includes = Array.prototype.includes; +const partToScheduler = new WeakMap(); +const schedulerToPart = new WeakMap(); +class Scheduler extends BaseScheduler { + args; + setValue; + constructor(renderer, part, setValue) { + super(renderer, part); + this.state.virtual = true; + this.setValue = setValue; + } + render() { + return this.state.run(() => this.renderer.apply(this.host, this.args)); + } + commit(result) { + this.setValue(result); + } + teardown() { + super.teardown(); + let part = schedulerToPart.get(this); + partToScheduler.delete(part); + } +} +function makeVirtual() { + function virtual(renderer) { + class VirtualDirective extends AsyncDirective { + cont; + constructor(partInfo) { + super(partInfo); + this.cont = undefined; + } + update(part, args) { + this.cont = partToScheduler.get(part); + if (!this.cont || this.cont.renderer !== renderer) { + this.cont = new Scheduler(renderer, part, (r) => { this.setValue(r); }); + partToScheduler.set(part, this.cont); + schedulerToPart.set(this.cont, part); + teardownOnRemove(this.cont, part); + } + this.cont.args = args; + this.cont.update(); + return this.render(args); + } + render(args) { + return noChange; + } + } + return directive(VirtualDirective); + } + return virtual; +} +function teardownOnRemove(cont, part, node = part.startNode) { + let frag = node.parentNode; + let mo = new MutationObserver(mutations => { + for (let mutation of mutations) { + if (includes.call(mutation.removedNodes, node)) { + mo.disconnect(); + if (node.parentNode instanceof ShadowRoot) { + teardownOnRemove(cont, part); + } + else { + cont.teardown(); + } + break; + } + else if (includes.call(mutation.addedNodes, node.nextSibling)) { + mo.disconnect(); + teardownOnRemove(cont, part, node.nextSibling || undefined); + break; + } + } + }); + mo.observe(frag, { childList: true }); +} +export { makeVirtual };