diff --git a/.eslintrc.js b/.eslintrc.js index dbcf534..97109d7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -32,6 +32,7 @@ module.exports = { "@typescript-eslint/no-empty-function": 0, "@typescript-eslint/no-explicit-any": 0, "@typescript-eslint/no-unused-vars": ["error", { "varsIgnorePattern": "^_+$" }], + "@typescript-eslint/no-use-before-define": 0, "@typescript-eslint/no-inferrable-types": 0, "@typescript-eslint/no-use-before-define": 0, "no-constant-condition": 0, diff --git a/package-lock.json b/package-lock.json index b82869b..096388d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@pubpub/prosemirror-reactive", - "version": "0.1.10", + "version": "0.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -551,18 +551,18 @@ "dev": true }, "@types/prosemirror-model": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@types/prosemirror-model/-/prosemirror-model-1.7.2.tgz", - "integrity": "sha512-2l+yXvidg3AUHN07mO4Jd8Q84fo6ksFsy7LHUurLYrZ74uTahBp2fzcO49AKZMzww2EulXJ40Kl/OFaQ/7A1fw==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@types/prosemirror-model/-/prosemirror-model-1.13.2.tgz", + "integrity": "sha512-a2rDB0aZ+7aIP7uBqQq1wLb4Hg4qqEvpkCqvhsgT/gG8IWC0peCAZfQ24sgTco0qSJLeDgIbtPeU6mgr869/kg==", "dev": true, "requires": { "@types/orderedmap": "*" } }, "@types/prosemirror-state": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@types/prosemirror-state/-/prosemirror-state-1.2.5.tgz", - "integrity": "sha512-a5DxAifiF6vmdSJ5jsDMkpykUgUJUy+T5Q5hCjFOKJ4cfd3m3q1lsFKr7Bc4r91Qb7rfqyiKCMDnASS8LIHrKw==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/prosemirror-state/-/prosemirror-state-1.2.7.tgz", + "integrity": "sha512-clJf5uw3/XQnBJtl2RqYXoLMGBySnLYl43xtDvFfQZKkLnnYcM1SDU8dcz7lWjl2Dm+H98RpLOl44pp7DYT+wA==", "dev": true, "requires": { "@types/prosemirror-model": "*", @@ -580,9 +580,9 @@ } }, "@types/prosemirror-view": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@types/prosemirror-view/-/prosemirror-view-1.15.0.tgz", - "integrity": "sha512-OBIAiVInYS0cr4txLZEVYs1t1aFKaAZvogFDgkZfLwa+uda+LNPSs6m4tNLU/KXoFu9iK9CPOpiYTlDQPEnU6g==", + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/@types/prosemirror-view/-/prosemirror-view-1.19.1.tgz", + "integrity": "sha512-fyQ4NVxAdfISWrE2qT8cpZdosXoH/1JuVYMBs9CdaXPbvi/8R2L2tkkcMRM314piKrO8nfYH5OBZKzP2Ax3jtA==", "dev": true, "requires": { "@types/prosemirror-model": "*", diff --git a/package.json b/package.json index f7ba74f..1c0600c 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { "name": "@pubpub/prosemirror-reactive", - "version": "0.1.10", + "version": "0.2.0", "description": "Use reactive, stateful nodes in Prosemirror", "main": "dist/index.js", "devDependencies": { "@types/jest": "^24.0.18", "@types/node": "^12.7.4", - "@types/prosemirror-model": "^1.7.2", - "@types/prosemirror-state": "^1.2.5", - "@types/prosemirror-view": "^1.15.0", + "@types/prosemirror-model": "^1.13.2", + "@types/prosemirror-state": "^1.2.7", + "@types/prosemirror-view": "^1.19.1", "@typescript-eslint/eslint-plugin": "^2.19.2", "@typescript-eslint/parser": "^2.19.2", "eslint": "^6.0.1", diff --git a/src/examples/schemas.ts b/src/examples/schemas.ts index 8ce469e..b96d7a5 100644 --- a/src/examples/schemas.ts +++ b/src/examples/schemas.ts @@ -1,6 +1,8 @@ -import { ReactiveNodeSpec } from "../store/types"; import { Schema } from "prosemirror-model"; +import { ReactiveNodeSpec } from "../store/types"; +import { useDeferredNode, useState, useEffect, useTransactionState } from ".."; + const doc: ReactiveNodeSpec = { content: "block+", }; @@ -70,7 +72,6 @@ export const feedMe: ReactiveNodeSpec = { reactiveAttrs: { report: function({ attrs }) { - const { useDeferredNode } = this; return useDeferredNode(attrs.wantsToEatId, food => `yum, ${food.attrs.color}!`); }, }, @@ -83,7 +84,6 @@ export const feedMeMore: ReactiveNodeSpec = { reactiveAttrs: { report: function({ attrs }) { - const { useDeferredNode } = this; return useDeferredNode( attrs.wantsToEatIds, (first, second) => `yum, ${first.attrs.color} and ${second.attrs.color}!` @@ -109,7 +109,6 @@ export const boxOpener: ReactiveNodeSpec = { return node.attrs.value; }, boxValue: function(node) { - const { useDeferredNode } = this; return useDeferredNode(node.attrs.lookForBoxId, box => box.attrs.value); }, }, @@ -132,7 +131,6 @@ export const sheepCounter: ReactiveNodeSpec = { reactiveAttrs: { report: function(node) { - const { useState, useEffect } = this; const [sheepCount, setSheepCount] = useState(0); useEffect(() => { @@ -155,7 +153,7 @@ export const sheepNamer: ReactiveNodeSpec = { }, reactiveAttrs: { report: function(node) { - return this.useDeferredNode(node.attrs.sheepId, sheepNode => { + return useDeferredNode(node.attrs.sheepId, sheepNode => { if (sheepNode) { return `My sheep is named ${sheepNode.attrs.name}`; } @@ -168,7 +166,6 @@ export const sheepNamer: ReactiveNodeSpec = { export const statefulSheepNamer: ReactiveNodeSpec = { reactiveAttrs: { report: function() { - const { useState, useEffect, useDeferredNode } = this; const [sheepIndex, setSheepIndex] = useState(0); useEffect(() => { @@ -195,7 +192,7 @@ export const counter: ReactiveNodeSpec = { }, reactiveAttrs: { count: function() { - const counterState = this.useTransactionState(["counter"], { count: 0 }); + const counterState = useTransactionState(["counter"], { count: 0 }); counterState.count++; return counterState.count; }, diff --git a/src/globalHooks.ts b/src/globalHooks.ts new file mode 100644 index 0000000..d8b6afb --- /dev/null +++ b/src/globalHooks.ts @@ -0,0 +1,21 @@ +import { UseEffect, UseRef, UseState } from "./store/attrStore"; +import { Hooks, UseDeferredNode, UseReactiveMap } from "./store/types"; + +let currentHooks: null | Hooks = null; + +export const setCurrentHooks = (hooks: Hooks) => { + currentHooks = hooks; +}; + +export const useDeferredNode: UseDeferredNode = (nodeIds, callback) => + currentHooks.useDeferredNode(nodeIds, callback); + +export const useDocumentState: UseReactiveMap = (path, initialState) => + currentHooks.useDocumentState(path, initialState); + +export const useTransactionState: UseReactiveMap = (path, initialState) => + currentHooks.useTransactionState(path, initialState); + +export const useState: UseState = initialValue => currentHooks.useState(initialValue); +export const useEffect: UseEffect = (fn, dependencies) => currentHooks.useEffect(fn, dependencies); +export const useRef: UseRef = initialValue => currentHooks.useRef(initialValue); diff --git a/src/index.ts b/src/index.ts index d4a285e..ef3ebc8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,11 @@ export * from "./plugin"; export { getReactedDoc } from "./doc"; export { addTemporaryIdsToDoc } from "./util"; +export { + useState, + useEffect, + useRef, + useDeferredNode, + useTransactionState, + useDocumentState, +} from "./globalHooks"; diff --git a/src/store/__tests__/attrStore.test.ts b/src/store/__tests__/attrStore.test.ts index aa96d0f..d06b7aa 100644 --- a/src/store/__tests__/attrStore.test.ts +++ b/src/store/__tests__/attrStore.test.ts @@ -1,6 +1,7 @@ /* global it, expect, jest */ import { Node } from "prosemirror-model"; +import { useState, useEffect, useRef, useDocumentState } from "../.."; import { createSchema, greeter } from "../../examples/schemas"; import { AttrStore } from "../attrStore"; @@ -10,8 +11,8 @@ const schema = createSchema({ greeter }); const testNode = Node.fromJSON(schema, { type: "greeter", attrs: { name: "world" } }); -const createTestStore = (fn, globalHooks = {}, onInvalidate?) => - new AttrStore("anything", fn, globalHooks, onInvalidate); +const createTestStore = (fn, documentHooks = {}, onInvalidate?) => + new AttrStore("anything", fn, documentHooks as any, onInvalidate); it("runs an attr that can access the current Node value", () => { const attr = createTestStore(function stateCell(node) { @@ -24,17 +25,16 @@ it("runs an attr that can access the current Node value", () => { it("runs an attr that can access the current global hooks", () => { const attr = createTestStore( function stateCell() { - return this.use3(); + return useDocumentState(["hey"]); }, - { use3: () => 3 } + { useDocumentState: ([val]) => `${val}? yep.` } ); const result = attr.run(testNode); - expect(result).toEqual(3); + expect(result).toEqual("hey? yep."); }); it("runs an attr with a useState call", () => { const attr = createTestStore(function stateCell() { - const { useState } = this; const [someState] = useState("hello!"); return someState; }); @@ -44,7 +44,6 @@ it("runs an attr with a useState call", () => { it("runs an attr with a useRef call", () => { const attr = createTestStore(function stateCell() { - const { useRef } = this; const count = useRef(-1); count.current += 1; return count.current; @@ -58,7 +57,6 @@ it("runs an attr with a useRef call", () => { it("runs an attr with a useState and a useEffect call", () => { const attr = createTestStore(function stateCell() { - const { useState, useEffect } = this; const [count, setCount] = useState(0); // In a DocumentStore this would trigger an infinite loop @@ -75,7 +73,6 @@ it("runs an attr with a useState and a useEffect call", () => { it("runs another attr that uses both useState and useEffect", () => { const attr = createTestStore(function() { - const { useState, useEffect } = this; const [count, setCount] = useState(37); useEffect(() => { @@ -98,7 +95,6 @@ it("re-runs a useEffect hook only when its dependencies change", () => { let b = 5; const attr = createTestStore(function() { - const { useEffect } = this; useEffect(() => { callbackFn(); return teardownFn; @@ -136,7 +132,6 @@ it("tears down a useEffect hook as expected", () => { let useFirstTeardown = true; const attr = createTestStore(function() { - const { useEffect } = this; useEffect(() => { return useFirstTeardown ? firstTeardown : secondTeardown; }); @@ -164,7 +159,6 @@ it("invalidates when its state changes", () => { const attr = createTestStore( function() { - const { useEffect, useState } = this; const [ready, setReady] = useState(false); useEffect(() => { @@ -187,7 +181,6 @@ it("does not invalidate when setState is called, but does not change the state", const attr = createTestStore( function() { - const { useEffect, useState } = this; const [ready, setReady] = useState(false); useEffect(() => { @@ -210,7 +203,6 @@ it("does not invalidate after it has been destroyed", () => { const attr = createTestStore( function() { - const { useEffect, useState } = this; const [ready, setReady] = useState(false); useEffect(() => { diff --git a/src/store/attrStore.ts b/src/store/attrStore.ts deleted file mode 100644 index ea60d72..0000000 --- a/src/store/attrStore.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { Node } from "prosemirror-model"; - -import { ReactiveAttrFn, ReactiveAttrUpdateResult, Hooks } from "./types"; -import { warn, throwError } from "./util"; - -type UninitializedCell = []; -type Cell = UninitializedCell | [number, any[]]; -type State = [T, StateUpdater]; -type Effect = [any[], () => any]; -type Ref = { current: any }; - -type StateUpdater = (arg: T | ((curr: T) => T)) => void; -type EffectCallback = () => () => any; -type OnInvalidate = (attr: string) => void; - -const noopInvalidate = () => { - warn( - "An invalidate function was not provided to an attrStore, meaning state updates will not propagate to the document." - ); -}; - -const getDependenciesChanged = (from: any[], to: any[]) => { - if (!to) { - return true; - } - if (from === to) { - return false; - } else if (from.length !== to.length) { - return true; - } - for (let i = 0; i < from.length; i++) { - if (from[i] !== to[i]) { - return true; - } - } - return false; -}; - -const hookFactories = { - useState: (store: WeakAttrStore) => { - return function useState(this: State, initialValue: T): [T, StateUpdater] { - if (this) { - return this; - } - const contents: [T, StateUpdater] = [ - typeof initialValue === "function" ? initialValue() : initialValue, - arg => { - const currentValue = contents[0]; - if (typeof arg === "function") { - contents[0] = (arg as Function)(currentValue); - } else { - contents[0] = arg; - } - if (currentValue !== contents[0]) { - store.invalidate(); - } - }, - ]; - return contents; - }; - }, - useEffect: (store: WeakAttrStore) => { - return function useEffect(this: Effect, callback: EffectCallback, dependencies?: any[]) { - if (this) { - const [previousDependencies, previousTeardown] = this; - const shouldRerun = getDependenciesChanged(previousDependencies, dependencies); - if (shouldRerun) { - store.registerRunCallback(() => { - if (previousTeardown) { - previousTeardown(); - store.unregisterDestroyCallback(previousTeardown); - } - const teardown = callback(); - if (teardown) { - store.registerDestroyCallback(teardown); - } - this[0] = dependencies; - this[1] = teardown; - }); - } - return this; - } - const contents = []; - store.registerRunCallback(() => { - const teardown = callback(); - if (teardown) { - store.registerDestroyCallback(teardown); - } - contents[0] = dependencies; - contents[1] = teardown; - }); - return contents; - }; - }, - useRef: () => { - return function useRef(this: Ref, initialValue: any) { - if (this) { - return this; - } - return { current: initialValue }; - }; - }, -}; - -const hookEntries: [string, Function][] = Object.entries(hookFactories); -const hookIds: Record = {}; - -hookEntries.forEach(([name], index) => { - hookIds[name] = index; -}); - -const bindHook = (store: WeakAttrStore, hookName: string, hookFactory: Function) => { - const hook = hookFactory(store); - return (...args) => { - const cell = store.getCurrentCell(); - let result; - if (cell && cell.length === 2) { - // Cell has already been created - const [foundHookId, contents] = cell; - if (foundHookId !== hookIds[hookName]) { - throwError("Hooks called out of order."); - } - result = hook.call(contents, ...args); - cell[1] = result; - } else { - // Cell is newly created. - result = hook.call(null, ...args); - cell[0] = hookIds[hookName]; - cell[1] = result; - } - store.incrementPointer(); - return result; - }; -}; - -const getBoundHooks = (store: WeakAttrStore) => { - const boundHooks: Record = {}; - for (let i = 0; i < hookEntries.length; i++) { - const [hookName, hookFn] = hookEntries[i]; - boundHooks[hookName] = bindHook(store, hookName, hookFn); - } - return boundHooks; -}; - -/** - * Holds a weak reference to an AttrStore so that long-lived callbacks floating around in hooks-land - * don't accidentally retain a reference to this store when its parent node has disappeared and it - * could otherwise be garbage collected. - */ -class WeakAttrStore { - private attrStore: AttrStore; - - constructor(attrStore: AttrStore) { - this.attrStore = attrStore; - } - - registerRunCallback(callback: Function) { - return this.attrStore && this.attrStore.registerRunCallback(callback); - } - - registerDestroyCallback(callback: Function) { - return this.attrStore && this.attrStore.registerDestroyCallback(callback); - } - - unregisterDestroyCallback(callback: Function) { - return this.attrStore && this.attrStore.unregisterDestroyCallback(callback); - } - - getCurrentCell() { - return this.attrStore && this.attrStore.getCurrentCell(); - } - - incrementPointer() { - return this.attrStore && this.attrStore.incrementPointer(); - } - - invalidate() { - return this.attrStore && this.attrStore.invalidate(); - } - - destroy() { - this.attrStore = null; - } -} - -/** - * Stores state associated with a single attribute on a single Node instance in the document. - * An AttrStore works a little like a React component with hooks -- it holds a reference to a - * function, and allows that function to be run over and over again, while holding onto stateful - * context that it shares with that function via a set of "hooks" bound to `this` within the - * function body. However, it does not store any state about the return result of the function -- - * that responsibility is left to the `NodeStore` which instantiates this class. - * - * Most of the methods of this class are public because they are designed to be accessed from - * hooks bound to the instance at runtime -- they are not actually intended to be used by callers. - */ -export class AttrStore { - private attr: string; - private onInvalidate: OnInvalidate; - private fn: ReactiveAttrFn; - private cells: Cell[] = []; - private cellPointer = 0; - private hooks: Hooks; - private destroyCallbacks: Set = new Set(); - private runCallbacks: Function[]; - private weakSelf: WeakAttrStore; - - /** - * @param attr The name of the attribute represented - * @param fn The function which will be run to determine the attribute's value - * @param globalHooks A set of globally-scoped hooks to provide to `fn` - * @param onInvalidate Callback for when a hook (cough cough, `useState`) - * invalidates this store's value - */ - constructor(attr: string, fn: ReactiveAttrFn, globalHooks: Hooks, onInvalidate: OnInvalidate) { - this.attr = attr; - this.fn = fn; - this.onInvalidate = onInvalidate || noopInvalidate; - this.weakSelf = new WeakAttrStore(this); - this.hooks = { ...getBoundHooks(this.weakSelf), ...globalHooks }; - } - - invalidate() { - this.onInvalidate(this.attr); - } - - incrementPointer() { - this.cellPointer++; - } - - getCurrentCell() { - if (!this.cells[this.cellPointer]) { - this.cells[this.cellPointer] = []; - } - return this.cells[this.cellPointer]; - } - - registerRunCallback(callback: Function) { - this.runCallbacks.push(callback); - } - - registerDestroyCallback(callback: Function) { - this.destroyCallbacks.add(callback); - } - - unregisterDestroyCallback(callback: Function) { - this.destroyCallbacks.delete(callback); - } - - destroy() { - this.weakSelf.destroy(); - [...this.destroyCallbacks].forEach(callback => callback()); - } - - /** - * Given a `Node` instance, run `this.fn` on the node and return whatever we got. - * @param node - * @throws if hooks were called conditionally or out of order. - */ - run(node: Node): ReactiveAttrUpdateResult { - this.runCallbacks = []; - this.cellPointer = 0; - const result = this.fn.call(this.hooks, node); - if (this.cellPointer !== this.cells.length) { - throwError("Hooks called conditionally"); - } - this.runCallbacks.forEach(callback => callback()); - return result; - } -} diff --git a/src/store/attrStore/attrStore.ts b/src/store/attrStore/attrStore.ts new file mode 100644 index 0000000..f8ebce6 --- /dev/null +++ b/src/store/attrStore/attrStore.ts @@ -0,0 +1,107 @@ +import { Node } from "prosemirror-model"; + +import { setCurrentHooks } from "../../globalHooks"; +import { Hooks, DocumentHooks, ReactiveAttrFn, ReactiveAttrUpdateResult } from "../types"; +import { warn, throwError } from "../util"; + +import { bindHooksToStore } from "./hooks"; +import { Cell, OnInvalidate } from "./types"; +import { WeakAttrStore } from "./weakAttrStore"; + +const noopInvalidate = () => { + warn( + "An invalidate function was not provided to an attrStore, meaning state updates will not propagate to the document." + ); +}; + +/** + * Stores state associated with a single attribute on a single Node instance in the document. + * An AttrStore works a little like a React component with hooks -- it holds a reference to a + * function, and allows that function to be run over and over again, while holding onto stateful + * context that it shares with that function via a set of "hooks" bound to `this` within the + * function body. However, it does not store any state about the return result of the function -- + * that responsibility is left to the `NodeStore` which instantiates this class. + * + * Most of the methods of this class are public because they are designed to be accessed from + * hooks bound to the instance at runtime -- they are not actually intended to be used by callers. + */ +export class AttrStore { + private attr: string; + private onInvalidate: OnInvalidate; + private fn: ReactiveAttrFn; + private cells: Cell[] = []; + private cellPointer = 0; + private hooks: Hooks; + private destroyCallbacks: Set = new Set(); + private runCallbacks: Function[]; + private weakSelf: WeakAttrStore; + + /** + * @param attr The name of the attribute represented + * @param fn The function which will be run to determine the attribute's value + * @param documentHooks A set of document-scoped hooks to provide to `fn` + * @param onInvalidate Callback for when a hook (cough cough, `useState`) + * invalidates this store's value + */ + constructor( + attr: string, + fn: ReactiveAttrFn, + documentHooks: DocumentHooks, + onInvalidate: OnInvalidate + ) { + this.attr = attr; + this.fn = fn; + this.onInvalidate = onInvalidate || noopInvalidate; + this.weakSelf = new WeakAttrStore(this); + this.hooks = { ...bindHooksToStore(this.weakSelf), ...documentHooks }; + } + + invalidate() { + this.onInvalidate(this.attr); + } + + incrementPointer() { + this.cellPointer++; + } + + getCurrentCell() { + if (!this.cells[this.cellPointer]) { + this.cells[this.cellPointer] = []; + } + return this.cells[this.cellPointer]; + } + + registerRunCallback(callback: Function) { + this.runCallbacks.push(callback); + } + + registerDestroyCallback(callback: Function) { + this.destroyCallbacks.add(callback); + } + + unregisterDestroyCallback(callback: Function) { + this.destroyCallbacks.delete(callback); + } + + destroy() { + this.weakSelf.destroy(); + [...this.destroyCallbacks].forEach(callback => callback()); + } + + /** + * Given a `Node` instance, run `this.fn` on the node and return whatever we got. + * @param node + * @throws if hooks were called conditionally or out of order. + */ + run(node: Node): ReactiveAttrUpdateResult { + this.runCallbacks = []; + this.cellPointer = 0; + setCurrentHooks(this.hooks); + const result = this.fn(node); + if (this.cellPointer !== this.cells.length) { + throwError("Hooks called conditionally"); + } + this.runCallbacks.forEach(callback => callback()); + return result; + } +} diff --git a/src/store/attrStore/hooks.ts b/src/store/attrStore/hooks.ts new file mode 100644 index 0000000..acf857f --- /dev/null +++ b/src/store/attrStore/hooks.ts @@ -0,0 +1,129 @@ +import { throwError } from "../util"; + +import { WeakAttrStore } from "./weakAttrStore"; +import { AttrHooks, Effect, EffectCallback, Ref, State, StateUpdater } from "./types"; + +const getDependenciesChanged = (from: any[], to: any[]) => { + if (!to) { + return true; + } + if (from === to) { + return false; + } else if (from.length !== to.length) { + return true; + } + for (let i = 0; i < from.length; i++) { + if (from[i] !== to[i]) { + return true; + } + } + return false; +}; + +const bindHook = (store: WeakAttrStore, hookName: string, hookFactory: Function) => { + const hook = hookFactory(store); + return (...args) => { + const cell = store.getCurrentCell(); + let result; + if (cell && cell.length === 2) { + // Cell has already been created + const [foundHookId, contents] = cell; + if (foundHookId !== hookIds[hookName]) { + throwError("Hooks called out of order."); + } + result = hook.call(contents, ...args); + cell[1] = result; + } else { + // Cell is newly created. + result = hook.call(null, ...args); + cell[0] = hookIds[hookName]; + cell[1] = result; + } + store.incrementPointer(); + return result; + }; +}; + +export const bindHooksToStore = (store: WeakAttrStore) => { + const boundHooks: Partial = {}; + for (let i = 0; i < hookEntries.length; i++) { + const [hookName, hookFn] = hookEntries[i]; + boundHooks[hookName] = bindHook(store, hookName, hookFn); + } + return boundHooks as AttrHooks; +}; + +const hookFactories = { + useState: (store: WeakAttrStore) => { + return function useState(this: State, initialValue: T): [T, StateUpdater] { + if (this) { + return this; + } + const contents: [T, StateUpdater] = [ + typeof initialValue === "function" ? initialValue() : initialValue, + arg => { + const currentValue = contents[0]; + if (typeof arg === "function") { + contents[0] = (arg as Function)(currentValue); + } else { + contents[0] = arg; + } + if (currentValue !== contents[0]) { + store.invalidate(); + } + }, + ]; + return contents; + }; + }, + useEffect: (store: WeakAttrStore) => { + return function useEffect(this: Effect, callback: EffectCallback, dependencies?: any[]) { + if (this) { + const [previousDependencies, previousTeardown] = this; + const shouldRerun = getDependenciesChanged(previousDependencies, dependencies); + if (shouldRerun) { + store.registerRunCallback(() => { + if (previousTeardown) { + previousTeardown(); + store.unregisterDestroyCallback(previousTeardown); + } + const teardown = callback(); + if (teardown) { + store.registerDestroyCallback(teardown); + } + this[0] = dependencies; + this[1] = teardown; + }); + } + return this; + } + const contents = []; + store.registerRunCallback(() => { + const teardown = callback(); + if (teardown) { + store.registerDestroyCallback(teardown); + } + contents[0] = dependencies; + contents[1] = teardown; + }); + return contents; + }; + }, + useRef: () => { + return function useRef(this: Ref, initialValue: any) { + if (this) { + return this; + } + return { current: initialValue }; + }; + }, +}; + +export type HookFactories = typeof hookFactories; + +const hookEntries: [string, Function][] = Object.entries(hookFactories); +const hookIds: Record = {}; + +hookEntries.forEach(([name], index) => { + hookIds[name] = index; +}); diff --git a/src/store/attrStore/index.ts b/src/store/attrStore/index.ts new file mode 100644 index 0000000..792dfdd --- /dev/null +++ b/src/store/attrStore/index.ts @@ -0,0 +1,2 @@ +export { AttrStore } from "./attrStore"; +export { AttrHooks, UseState, UseEffect, UseRef } from "./types"; diff --git a/src/store/attrStore/types.ts b/src/store/attrStore/types.ts new file mode 100644 index 0000000..a0f2e89 --- /dev/null +++ b/src/store/attrStore/types.ts @@ -0,0 +1,19 @@ +export type UninitializedCell = []; +export type Cell = UninitializedCell | [number, any[]]; +export type State = [T, StateUpdater]; +export type Effect = [any[], () => any]; +export type Ref = { current: undefined | T }; + +export type StateUpdater = (arg: T | ((curr: T) => T)) => void; +export type EffectCallback = () => () => any; +export type OnInvalidate = (attr: string) => void; + +export type UseState = (initialValue: T) => State; +export type UseEffect = (fn: () => any, dependencies?: any[]) => unknown; +export type UseRef = (initialValue?: T) => Ref; + +export type AttrHooks = { + useState: UseState; + useEffect: UseEffect; + useRef: UseRef; +}; diff --git a/src/store/attrStore/weakAttrStore.ts b/src/store/attrStore/weakAttrStore.ts new file mode 100644 index 0000000..705f575 --- /dev/null +++ b/src/store/attrStore/weakAttrStore.ts @@ -0,0 +1,42 @@ +import { AttrStore } from "./attrStore"; + +/** + * Holds a weak reference to an AttrStore so that long-lived callbacks floating around in hooks-land + * don't accidentally retain a reference to this store when its parent node has disappeared and it + * could otherwise be garbage collected. + */ +export class WeakAttrStore { + private attrStore: AttrStore; + + constructor(attrStore: AttrStore) { + this.attrStore = attrStore; + } + + registerRunCallback(callback: Function) { + return this.attrStore && this.attrStore.registerRunCallback(callback); + } + + registerDestroyCallback(callback: Function) { + return this.attrStore && this.attrStore.registerDestroyCallback(callback); + } + + unregisterDestroyCallback(callback: Function) { + return this.attrStore && this.attrStore.unregisterDestroyCallback(callback); + } + + getCurrentCell() { + return this.attrStore && this.attrStore.getCurrentCell(); + } + + incrementPointer() { + return this.attrStore && this.attrStore.incrementPointer(); + } + + invalidate() { + return this.attrStore && this.attrStore.invalidate(); + } + + destroy() { + this.attrStore = null; + } +} diff --git a/src/store/documentStore.ts b/src/store/documentStore.ts index dddc359..2240620 100644 --- a/src/store/documentStore.ts +++ b/src/store/documentStore.ts @@ -7,8 +7,8 @@ import { ReactiveAttrsDefinition, NodeId, ReactiveNodeUpdate, - Hooks, AttrKey, + DocumentHooks, } from "./types"; import { ReactiveMap } from "./reactiveMap"; import { NodeStore } from "./nodeStore"; @@ -36,7 +36,7 @@ export class DocumentStore { private idAttrKey: AttrKey; private invalidateNode: InvalidateNode; - private hooks = { + private hooks: DocumentHooks = { useDocumentState: (...args) => this.documentState.get(...args), useTransactionState: (...args) => this.transactionState.get(...args), useDeferredNode: (nodeIds, callback) => @@ -139,7 +139,7 @@ export class DocumentStore { /** * Get the hooks provided by the reactive store. */ - getHooks(): Hooks { + getHooks(): DocumentHooks { return this.hooks; } diff --git a/src/store/reactiveMap.ts b/src/store/reactiveMap.ts index 2762084..5622ebe 100644 --- a/src/store/reactiveMap.ts +++ b/src/store/reactiveMap.ts @@ -1,9 +1,6 @@ type State = Record; type Key = string | symbol; -type UpdateStateCallback = (s: State) => State; -type UpdateState = (arg: State | UpdateStateCallback) => void; - export class ReactiveMap { state: Record; private childMaps: Map; diff --git a/src/store/types.ts b/src/store/types.ts index ba13eee..072d5cb 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -1,6 +1,8 @@ import { Node, NodeSpec } from "prosemirror-model"; +import { AttrHooks } from "./attrStore"; import { DeferredResult } from "./deferredResult"; +import { ReactiveMap } from "./reactiveMap"; export type NodeId = string; export type AttrKey = string; @@ -9,14 +11,26 @@ export type ReactiveChangeDispatcher = (nodeId: NodeId) => any; export type ReactiveAttrsDefinition = Record; export type ReactiveAttrValue = number | string; -export type ReactiveAttrFn = (node: Node) => ReactiveAttrValue; +export type ReactiveAttrFn = (node: Node) => ReactiveAttrValue | DeferredResult; export type ReactiveNodeUpdate = [boolean, Node]; export type ReactiveAttrUpdateResult = ReactiveAttrValue | DeferredResult; export type ReactiveNodeUpdateResult = ReactiveNodeUpdate | DeferredResult; -export type Hooks = Record; +export type UseReactiveMap = ReactiveMap["get"]; +export type UseDeferredNode = ( + nodeIds: string | string[], + callback: (...nodes: Node[]) => T +) => DeferredResult; + +export type DocumentHooks = { + useDocumentState: UseReactiveMap; + useTransactionState: UseReactiveMap; + useDeferredNode: UseDeferredNode; +}; + +export type Hooks = DocumentHooks & AttrHooks; export interface ReactiveNodeSpec extends NodeSpec { reactive?: true; diff --git a/src/store/util.ts b/src/store/util.ts index 9a0abbb..638ca8a 100644 --- a/src/store/util.ts +++ b/src/store/util.ts @@ -10,12 +10,6 @@ export const throwError = (message: string) => { throw new Error(`${prefix} ${message}`); }; -const isValidReactiveFn = fn => { - // Arrow functions have no prototype, and we need non-arrows so we can bind `this` to them. - // eslint-disable-next-line no-prototype-builtins - return typeof fn === "function" && fn.hasOwnProperty("prototype"); -}; - export const assertCanCreateReactiveNodeStore = ( nodeType: string, spec: ReactiveNodeSpec, @@ -26,14 +20,6 @@ export const assertCanCreateReactiveNodeStore = ( if (hasIdAttr) { const hasReactiveAttrs = reactiveAttrs && Object.keys(reactiveAttrs).length > 0; if (hasReactiveAttrs) { - const attrWithWrongType = Object.entries(reactiveAttrs).find( - entry => !isValidReactiveFn(entry[1]) - ); - if (attrWithWrongType) { - throwError( - `Reactive attr ${attrWithWrongType} on ${nodeType} must be a (non-arrow) function` - ); - } const reactiveAttrShadowingAttr = Object.keys(reactiveAttrs).find( attr => !!attrs[attr] );