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 };