epos-unit 1.14.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,31 +1,67 @@
1
1
  import * as mobx from 'mobx';
2
- import { Cls } from 'dropcap/types';
3
- import { Log } from 'dropcap/utils';
2
+ import { Cls, Obj, Arr } from 'dropcap/types';
3
+ import { createLog } from 'dropcap/utils';
4
4
 
5
5
  declare const _root_: unique symbol;
6
6
  declare const _parent_: unique symbol;
7
+ declare const _attached_: unique symbol;
7
8
  declare const _disposers_: unique symbol;
8
- type Descriptors = Record<string | symbol, PropertyDescriptor>;
9
+ declare const _ancestors_: unique symbol;
10
+ declare const _pendingAttachFns_: unique symbol;
11
+ type Node<T> = Unit<T> | Obj | Arr;
9
12
  declare class Unit<TRoot = unknown> {
10
- static [epos.symbols.stateModelStrict]: boolean;
11
- static [epos.symbols.stateModelVersioner]: unknown;
12
- [epos.symbols.stateModelInit]: () => void;
13
- [epos.symbols.stateModelDispose]: () => void;
14
- id: string;
13
+ /**
14
+ * Lifecycle method called when the unit is attached to the state tree.
15
+ */
16
+ [epos.state.ATTACH]: () => void;
17
+ /**
18
+ * Lifecycle method called when the unit is detached from the state tree.
19
+ */
20
+ [epos.state.DETACH]: () => void;
15
21
  '@': string;
16
- log: Log;
22
+ log: ReturnType<typeof createLog>;
17
23
  [':version']?: number;
18
- private [_root_];
19
- private [_parent_];
20
- private [_disposers_];
24
+ [_root_]?: TRoot;
25
+ [_parent_]?: Unit<TRoot> | null;
26
+ [_attached_]?: boolean;
27
+ [_disposers_]?: Set<() => void>;
28
+ [_ancestors_]?: Map<Cls, unknown>;
29
+ [_pendingAttachFns_]?: (() => void)[];
21
30
  constructor(parent: Unit<TRoot> | null);
22
- get $(): TRoot;
31
+ /**
32
+ * Gets the root unit of the current unit's tree.
33
+ * The result is cached for subsequent calls.
34
+ */
35
+ get $(): TRoot | (undefined & TRoot);
36
+ /**
37
+ * Finds the closest ancestor unit of a given type.
38
+ * The result is cached for subsequent calls.
39
+ */
23
40
  closest<T extends Unit>(Ancestor: Cls<T>): T | null;
41
+ /**
42
+ * A wrapper around MobX's `autorun` that automatically disposes
43
+ * the reaction when the unit is detached.
44
+ */
24
45
  autorun(...args: Parameters<typeof epos.libs.mobx.autorun>): mobx.IReactionDisposer;
46
+ /**
47
+ * A wrapper around MobX's `reaction` that automatically disposes
48
+ * the reaction when the unit is detached.
49
+ */
25
50
  reaction(...args: Parameters<typeof epos.libs.mobx.reaction>): mobx.IReactionDisposer;
51
+ /**
52
+ * A wrapper around `setTimeout` that automatically clears the timeout
53
+ * when the unit is detached.
54
+ */
26
55
  setTimeout(...args: Parameters<typeof self.setTimeout>): number;
56
+ /**
57
+ * A wrapper around `setInterval` that automatically clears the interval
58
+ * when the unit is detached.
59
+ */
27
60
  setInterval(...args: Parameters<typeof self.setInterval>): number;
28
- never(message?: string): void;
61
+ /**
62
+ * Creates an error for an unreachable code path.
63
+ */
64
+ never(message?: string): Error;
29
65
  }
30
66
 
31
- export { type Descriptors, Unit, _disposers_, _parent_, _root_ };
67
+ export { type Node, Unit, _ancestors_, _attached_, _disposers_, _parent_, _pendingAttachFns_, _root_ };
package/dist/epos-unit.js CHANGED
@@ -1,115 +1,178 @@
1
1
  // src/epos-unit.ts
2
- import { createLog } from "dropcap/utils";
2
+ import { createLog, is } from "dropcap/utils";
3
3
  import "epos";
4
- import { nanoid } from "nanoid";
5
4
  var _root_ = /* @__PURE__ */ Symbol("root");
6
5
  var _parent_ = /* @__PURE__ */ Symbol("parent");
6
+ var _attached_ = /* @__PURE__ */ Symbol("attached");
7
7
  var _disposers_ = /* @__PURE__ */ Symbol("disposers");
8
+ var _ancestors_ = /* @__PURE__ */ Symbol("ancestors");
9
+ var _pendingAttachFns_ = /* @__PURE__ */ Symbol("pendingAttachFns");
8
10
  var Unit = class {
9
- id = nanoid();
10
- static get [epos.symbols.stateModelStrict]() {
11
- return true;
12
- }
13
11
  constructor(parent) {
14
- Reflect.defineProperty(this, _parent_, { get: () => parent });
12
+ this[_parent_] = parent;
13
+ const versions = getVersions(this);
14
+ if (versions.length > 0) this[":version"] = versions.at(-1);
15
15
  }
16
- // ---------------------------------------------------------------------------
17
- // INIT
18
- // ---------------------------------------------------------------------------
19
- [epos.symbols.stateModelInit]() {
20
- const _this = this;
21
- const Unit2 = this.constructor;
22
- const descriptors = Object.getOwnPropertyDescriptors(Unit2.prototype);
23
- const keys = Reflect.ownKeys(descriptors);
24
- const disposers = /* @__PURE__ */ new Set();
25
- Reflect.defineProperty(this, _disposers_, { get: () => disposers });
26
- for (const key of keys) {
27
- if (key === "constructor") continue;
28
- const descriptor = descriptors[key];
29
- if (!descriptor) continue;
30
- if (descriptor.get || descriptor.set) continue;
31
- if (typeof descriptor.value !== "function") continue;
32
- _this[key] = descriptor.value.bind(this);
16
+ /**
17
+ * Lifecycle method called when the unit is attached to the state tree.
18
+ */
19
+ [epos.state.ATTACH]() {
20
+ setProperty(this, "log", createLog(this["@"]));
21
+ epos.state.transaction(() => {
22
+ const versioner = getVersioner(this);
23
+ const versions = getVersions(this);
24
+ for (const version of versions) {
25
+ if (is.number(this[":version"]) && this[":version"] >= version) continue;
26
+ const versionFn = versioner[version];
27
+ if (!is.function(versionFn)) continue;
28
+ versionFn.call(this, this);
29
+ this[":version"] = version;
30
+ }
31
+ });
32
+ for (const prototype of getPrototypes(this)) {
33
+ const descriptors = Object.getOwnPropertyDescriptors(prototype);
34
+ for (const [key, descriptor] of Object.entries(descriptors)) {
35
+ if (key === "constructor") continue;
36
+ if (this.hasOwnProperty(key)) continue;
37
+ if (descriptor.get || descriptor.set) continue;
38
+ if (!is.function(descriptor.value)) continue;
39
+ const fn = descriptor.value.bind(this);
40
+ if (key.endsWith("View")) {
41
+ let Component = epos.component(fn);
42
+ Component.displayName = `${this.constructor.name}.${key}`;
43
+ setProperty(this, key, Component);
44
+ } else {
45
+ setProperty(this, key, fn);
46
+ }
47
+ }
33
48
  }
34
- for (const key of keys) {
35
- if (typeof key === "symbol") continue;
36
- if (!key.endsWith("View")) continue;
37
- const descriptor = descriptors[key];
38
- if (!descriptor) continue;
39
- if (descriptor.get || descriptor.set) continue;
40
- if (typeof _this[key] !== "function") continue;
41
- _this[key] = epos.component(_this[key]);
42
- _this[key].displayName = `${this["@"]}.${key}`;
49
+ const stateDescriptor = Reflect.getOwnPropertyDescriptor(this.constructor.prototype, "state");
50
+ if (stateDescriptor && stateDescriptor.get) {
51
+ const state = stateDescriptor.get.call(this);
52
+ setProperty(this, "state", epos.libs.mobx.observable.object(state, {}, { deep: false }));
43
53
  }
44
- const log = createLog(this["@"]);
45
- Reflect.defineProperty(this, "log", { get: () => log });
46
- if (typeof _this.init === "function") _this.init();
47
- }
48
- // ---------------------------------------------------------------------------
49
- // DISPOSE
50
- // ---------------------------------------------------------------------------
51
- [epos.symbols.stateModelDispose]() {
52
- const _this = this;
53
- this[_disposers_].forEach((disposer) => disposer());
54
- this[_disposers_].clear();
55
- if (typeof _this.dispose === "function") _this.dispose();
54
+ const attach = Reflect.get(this, "attach");
55
+ if (is.function(attach)) {
56
+ const unattachedRoot = findUnattachedRoot(this);
57
+ if (!unattachedRoot) throw this.never();
58
+ ensureProperty(unattachedRoot, _pendingAttachFns_, () => []);
59
+ unattachedRoot[_pendingAttachFns_].push(() => attach());
60
+ }
61
+ if (this[_pendingAttachFns_]) {
62
+ this[_pendingAttachFns_].forEach((attach2) => attach2());
63
+ delete this[_pendingAttachFns_];
64
+ }
65
+ setProperty(this, _attached_, true);
56
66
  }
57
- // ---------------------------------------------------------------------------
58
- // VERSIONER
59
- // ---------------------------------------------------------------------------
60
- static get [epos.symbols.stateModelVersioner]() {
61
- if (!("versioner" in this)) return null;
62
- return this.versioner;
67
+ /**
68
+ * Lifecycle method called when the unit is detached from the state tree.
69
+ */
70
+ [epos.state.DETACH]() {
71
+ if (this[_disposers_]) {
72
+ this[_disposers_].forEach((disposer) => disposer());
73
+ this[_disposers_].clear();
74
+ }
75
+ const detach = Reflect.get(this, "detach");
76
+ if (is.function(detach)) detach();
77
+ delete this[_root_];
78
+ delete this[_attached_];
79
+ delete this[_ancestors_];
80
+ delete this[_disposers_];
63
81
  }
64
- // ---------------------------------------------------------------------------
65
- // ROOT
66
- // ---------------------------------------------------------------------------
82
+ /**
83
+ * Gets the root unit of the current unit's tree.
84
+ * The result is cached for subsequent calls.
85
+ */
67
86
  get $() {
68
- this[_root_] ??= findRoot(this);
87
+ ensureProperty(this, _root_, () => findRoot(this));
69
88
  return this[_root_];
70
89
  }
71
- // ---------------------------------------------------------------------------
72
- // METHODS
73
- // ---------------------------------------------------------------------------
90
+ /**
91
+ * Finds the closest ancestor unit of a given type.
92
+ * The result is cached for subsequent calls.
93
+ */
74
94
  closest(Ancestor) {
75
- let cursor = getParent(this);
95
+ ensureProperty(this, _ancestors_, () => /* @__PURE__ */ new Map());
96
+ if (this[_ancestors_].has(Ancestor)) return this[_ancestors_].get(Ancestor);
97
+ let cursor = this;
76
98
  while (cursor) {
77
- if (cursor instanceof Ancestor) return cursor;
99
+ if (cursor instanceof Ancestor) {
100
+ this[_ancestors_].set(Ancestor, cursor);
101
+ return cursor;
102
+ }
78
103
  cursor = getParent(cursor);
79
104
  }
80
105
  return null;
81
106
  }
107
+ /**
108
+ * A wrapper around MobX's `autorun` that automatically disposes
109
+ * the reaction when the unit is detached.
110
+ */
82
111
  autorun(...args) {
83
112
  const disposer = epos.libs.mobx.autorun(...args);
113
+ ensureProperty(this, _disposers_, () => /* @__PURE__ */ new Set());
84
114
  this[_disposers_].add(disposer);
85
115
  return disposer;
86
116
  }
117
+ /**
118
+ * A wrapper around MobX's `reaction` that automatically disposes
119
+ * the reaction when the unit is detached.
120
+ */
87
121
  reaction(...args) {
88
122
  const disposer = epos.libs.mobx.reaction(...args);
123
+ ensureProperty(this, _disposers_, () => /* @__PURE__ */ new Set());
89
124
  this[_disposers_].add(disposer);
90
125
  return disposer;
91
126
  }
127
+ /**
128
+ * A wrapper around `setTimeout` that automatically clears the timeout
129
+ * when the unit is detached.
130
+ */
92
131
  setTimeout(...args) {
93
132
  const id = self.setTimeout(...args);
133
+ ensureProperty(this, _disposers_, () => /* @__PURE__ */ new Set());
94
134
  this[_disposers_].add(() => self.clearTimeout(id));
95
135
  return id;
96
136
  }
137
+ /**
138
+ * A wrapper around `setInterval` that automatically clears the interval
139
+ * when the unit is detached.
140
+ */
97
141
  setInterval(...args) {
98
142
  const id = self.setInterval(...args);
143
+ ensureProperty(this, _disposers_, () => /* @__PURE__ */ new Set());
99
144
  this[_disposers_].add(() => self.clearInterval(id));
100
145
  return id;
101
146
  }
147
+ /**
148
+ * Creates an error for an unreachable code path.
149
+ */
102
150
  never(message = "This should never happen") {
103
- const error = new Error(`[${this["@"]}] ${message}`);
151
+ const details = message ? `: ${message}` : "";
152
+ const error = new Error(`[${this.constructor.name}] This should never happen${details}`);
104
153
  Error.captureStackTrace(error, this.never);
105
- throw error;
154
+ return error;
106
155
  }
107
156
  };
108
- function getParent(child) {
109
- return child[_parent_] ?? child[epos.symbols.stateParent];
157
+ function setProperty(object, key, value) {
158
+ Reflect.defineProperty(object, key, {
159
+ configurable: true,
160
+ get: () => value,
161
+ set: (v) => value = v
162
+ });
163
+ }
164
+ function ensureProperty(object, key, getInitialValue) {
165
+ if (key in object) return;
166
+ const value = getInitialValue();
167
+ Reflect.defineProperty(object, key, { configurable: true, get: () => value });
168
+ }
169
+ function getPrototypes(object) {
170
+ const prototype = Reflect.getPrototypeOf(object);
171
+ if (!prototype || prototype === Object.prototype) return [];
172
+ return [prototype, ...getPrototypes(prototype)];
110
173
  }
111
174
  function findRoot(unit) {
112
- let root = unit;
175
+ let root = null;
113
176
  let cursor = unit;
114
177
  while (cursor) {
115
178
  if (cursor instanceof Unit) root = cursor;
@@ -117,9 +180,36 @@ function findRoot(unit) {
117
180
  }
118
181
  return root;
119
182
  }
183
+ function findUnattachedRoot(unit) {
184
+ let unattachedRoot = null;
185
+ let cursor = unit;
186
+ while (cursor) {
187
+ if (cursor instanceof Unit && !cursor[_attached_]) unattachedRoot = cursor;
188
+ cursor = getParent(cursor);
189
+ }
190
+ return unattachedRoot;
191
+ }
192
+ function getParent(node) {
193
+ const parent = Reflect.get(node, _parent_) ?? Reflect.get(node, epos.state.PARENT) ?? null;
194
+ return parent;
195
+ }
196
+ function getVersioner(unit) {
197
+ const versioner = Reflect.get(unit.constructor, "versioner");
198
+ if (!is.object(versioner)) return {};
199
+ return versioner;
200
+ }
201
+ function getVersions(unit) {
202
+ const versioner = getVersioner(unit);
203
+ const numericKeys = Object.keys(versioner).filter((key) => is.numeric(key));
204
+ return numericKeys.map(Number).sort((v1, v2) => v1 - v2);
205
+ }
120
206
  export {
121
207
  Unit,
208
+ _ancestors_,
209
+ _attached_,
122
210
  _disposers_,
123
211
  _parent_,
212
+ _pendingAttachFns_,
124
213
  _root_
125
214
  };
215
+ //# sourceMappingURL=epos-unit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/epos-unit.ts"],"sourcesContent":["import type { Arr, Cls, Obj } from 'dropcap/types'\nimport { createLog, is } from 'dropcap/utils'\nimport 'epos'\n\nexport const _root_ = Symbol('root')\nexport const _parent_ = Symbol('parent')\nexport const _attached_ = Symbol('attached')\nexport const _disposers_ = Symbol('disposers')\nexport const _ancestors_ = Symbol('ancestors')\nexport const _pendingAttachFns_ = Symbol('pendingAttachFns')\n\nexport type Node<T> = Unit<T> | Obj | Arr\n\nexport class Unit<TRoot = unknown> {\n declare '@': string\n declare log: ReturnType<typeof createLog>;\n declare [':version']?: number;\n declare [_root_]?: TRoot;\n declare [_parent_]?: Unit<TRoot> | null; // Parent reference for a not-yet-attached units\n declare [_attached_]?: boolean;\n declare [_disposers_]?: Set<() => void>;\n declare [_ancestors_]?: Map<Cls, unknown>;\n declare [_pendingAttachFns_]?: (() => void)[]\n\n constructor(parent: Unit<TRoot> | null) {\n this[_parent_] = parent\n const versions = getVersions(this)\n if (versions.length > 0) this[':version'] = versions.at(-1)!\n }\n\n /**\n * Lifecycle method called when the unit is attached to the state tree.\n */\n [epos.state.ATTACH]() {\n // Setup logger\n setProperty(this, 'log', createLog(this['@']))\n\n // Apply versioner\n epos.state.transaction(() => {\n const versioner = getVersioner(this)\n const versions = getVersions(this)\n for (const version of versions) {\n if (is.number(this[':version']) && this[':version'] >= version) continue\n const versionFn = versioner[version]\n if (!is.function(versionFn)) continue\n versionFn.call(this, this)\n this[':version'] = version\n }\n })\n\n // Prepare methods:\n // - Create components for methods ending with `View`\n // - Bind all other methods to the unit instance\n for (const prototype of getPrototypes(this)) {\n const descriptors = Object.getOwnPropertyDescriptors(prototype)\n\n for (const [key, descriptor] of Object.entries(descriptors)) {\n if (key === 'constructor') continue\n if (this.hasOwnProperty(key)) continue\n\n if (descriptor.get || descriptor.set) continue\n if (!is.function(descriptor.value)) continue\n const fn = descriptor.value.bind(this)\n\n if (key.endsWith('View')) {\n let Component = epos.component(fn)\n Component.displayName = `${this.constructor.name}.${key}`\n setProperty(this, key, Component)\n } else {\n setProperty(this, key, fn)\n }\n }\n }\n\n // Setup state\n const stateDescriptor = Reflect.getOwnPropertyDescriptor(this.constructor.prototype, 'state')\n if (stateDescriptor && stateDescriptor.get) {\n const state = stateDescriptor.get.call(this)\n setProperty(this, 'state', epos.libs.mobx.observable.object(state, {}, { deep: false }))\n }\n\n // Process attach queue.\n // We do not execute `attach` methods immediately, but rather queue them\n // on the highest unattached ancestor. This way we ensure that `attach`\n // methods are called after all versioners have been applied in the entire subtree.\n const attach = Reflect.get(this, 'attach')\n if (is.function(attach)) {\n const unattachedRoot = findUnattachedRoot(this)\n if (!unattachedRoot) throw this.never()\n\n ensureProperty(unattachedRoot, _pendingAttachFns_, () => [])\n unattachedRoot[_pendingAttachFns_].push(() => attach())\n }\n\n if (this[_pendingAttachFns_]) {\n this[_pendingAttachFns_].forEach(attach => attach())\n delete this[_pendingAttachFns_]\n }\n\n // Mark as attached\n setProperty(this, _attached_, true)\n }\n\n /**\n * Lifecycle method called when the unit is detached from the state tree.\n */\n [epos.state.DETACH]() {\n // 1. Run disposers\n if (this[_disposers_]) {\n this[_disposers_].forEach(disposer => disposer())\n this[_disposers_].clear()\n }\n\n // 2. Call detach method\n const detach = Reflect.get(this, 'detach')\n if (is.function(detach)) detach()\n\n // 3. Clean up internal properties\n delete this[_root_]\n delete this[_attached_]\n delete this[_ancestors_]\n delete this[_disposers_]\n }\n\n /**\n * Gets the root unit of the current unit's tree.\n * The result is cached for subsequent calls.\n */\n get $() {\n ensureProperty(this, _root_, () => findRoot(this))\n return this[_root_]\n }\n\n /**\n * Finds the closest ancestor unit of a given type.\n * The result is cached for subsequent calls.\n */\n closest<T extends Unit>(Ancestor: Cls<T>) {\n // Has cached value? -> Return it\n ensureProperty(this, _ancestors_, () => new Map())\n if (this[_ancestors_].has(Ancestor)) return this[_ancestors_].get(Ancestor) as T\n\n // Find the closest ancestor and cache it\n let cursor: Node<TRoot> | null = this\n while (cursor) {\n if (cursor instanceof Ancestor) {\n this[_ancestors_].set(Ancestor, cursor)\n return cursor\n }\n cursor = getParent(cursor)\n }\n\n return null\n }\n\n /**\n * A wrapper around MobX's `autorun` that automatically disposes\n * the reaction when the unit is detached.\n */\n autorun(...args: Parameters<typeof epos.libs.mobx.autorun>) {\n const disposer = epos.libs.mobx.autorun(...args)\n ensureProperty(this, _disposers_, () => new Set())\n this[_disposers_].add(disposer)\n return disposer\n }\n\n /**\n * A wrapper around MobX's `reaction` that automatically disposes\n * the reaction when the unit is detached.\n */\n reaction(...args: Parameters<typeof epos.libs.mobx.reaction>) {\n const disposer = epos.libs.mobx.reaction(...args)\n ensureProperty(this, _disposers_, () => new Set())\n this[_disposers_].add(disposer)\n return disposer\n }\n\n /**\n * A wrapper around `setTimeout` that automatically clears the timeout\n * when the unit is detached.\n */\n setTimeout(...args: Parameters<typeof self.setTimeout>) {\n const id = self.setTimeout(...args)\n ensureProperty(this, _disposers_, () => new Set())\n this[_disposers_].add(() => self.clearTimeout(id))\n return id\n }\n\n /**\n * A wrapper around `setInterval` that automatically clears the interval\n * when the unit is detached.\n */\n setInterval(...args: Parameters<typeof self.setInterval>) {\n const id = self.setInterval(...args)\n ensureProperty(this, _disposers_, () => new Set())\n this[_disposers_].add(() => self.clearInterval(id))\n return id\n }\n\n /**\n * Creates an error for an unreachable code path.\n */\n never(message = 'This should never happen') {\n const details = message ? `: ${message}` : ''\n const error = new Error(`[${this.constructor.name}] This should never happen${details}`)\n Error.captureStackTrace(error, this.never)\n return error\n }\n}\n\n// ---------------------------------------------------------------------------\n// HELPERS\n// ---------------------------------------------------------------------------\n\n/**\n * Defines a configurable property on an object.\n */\nfunction setProperty(object: object, key: PropertyKey, value: unknown) {\n Reflect.defineProperty(object, key, {\n configurable: true,\n get: () => value,\n set: v => (value = v),\n })\n}\n\n/**\n * Ensures a property exists on an object, initializing it if it doesn't.\n */\nfunction ensureProperty<T extends object, K extends PropertyKey, V>(\n object: T,\n key: K,\n getInitialValue: () => V,\n): asserts object is T & { [key in K]: V } {\n if (key in object) return\n const value = getInitialValue()\n Reflect.defineProperty(object, key, { configurable: true, get: () => value })\n}\n\n/**\n * Gets all prototypes of an object up to `Object.prototype`.\n */\nfunction getPrototypes(object: object): object[] {\n const prototype = Reflect.getPrototypeOf(object)\n if (!prototype || prototype === Object.prototype) return []\n return [prototype, ...getPrototypes(prototype)]\n}\n\n/**\n * Finds the root `Unit` in the hierarchy for a given unit.\n */\nfunction findRoot<T>(unit: Unit<T>) {\n let root: Unit<T> | null = null\n let cursor: Node<T> | null = unit\n\n while (cursor) {\n if (cursor instanceof Unit) root = cursor\n cursor = getParent(cursor)\n }\n\n return root as T\n}\n\n/**\n * Finds the highest unattached `Unit` in the hierarchy for a given unit.\n */\nfunction findUnattachedRoot<T>(unit: Unit<T>) {\n let unattachedRoot: Unit<T> | null = null\n let cursor: Node<T> | null = unit\n\n while (cursor) {\n if (cursor instanceof Unit && !cursor[_attached_]) unattachedRoot = cursor\n cursor = getParent(cursor)\n }\n\n return unattachedRoot\n}\n\n/**\n * Gets the parent of a node, which can be a `Unit`, an object, or an array.\n */\nfunction getParent<T>(node: Node<T>) {\n const parent: Node<T> | null = Reflect.get(node, _parent_) ?? Reflect.get(node, epos.state.PARENT) ?? null\n return parent\n}\n\n/**\n * Gets the versioner object from a unit's constructor.\n */\nfunction getVersioner<T>(unit: Unit<T>) {\n const versioner: unknown = Reflect.get(unit.constructor, 'versioner')\n if (!is.object(versioner)) return {}\n return versioner\n}\n\n/**\n * Gets a sorted list of numeric version keys from a unit's versioner.\n */\nfunction getVersions<T>(unit: Unit<T>) {\n const versioner = getVersioner(unit)\n const numericKeys = Object.keys(versioner).filter(key => is.numeric(key))\n return numericKeys.map(Number).sort((v1, v2) => v1 - v2)\n}\n"],"mappings":"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","names":["attach"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "epos-unit",
3
- "version": "1.14.0",
3
+ "version": "1.16.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "author": "imkost",
@@ -20,8 +20,7 @@
20
20
  "src"
21
21
  ],
22
22
  "dependencies": {
23
- "dropcap": "^1.1.0",
24
- "epos": "^1.32.0",
25
- "nanoid": "^3.3.11"
23
+ "dropcap": "^1.4.0",
24
+ "epos": "^1.32.0"
26
25
  }
27
26
  }
package/src/epos-unit.ts CHANGED
@@ -1,150 +1,210 @@
1
- import type { Cls } from 'dropcap/types'
2
- import { createLog, Log } from 'dropcap/utils'
1
+ import type { Arr, Cls, Obj } from 'dropcap/types'
2
+ import { createLog, is } from 'dropcap/utils'
3
3
  import 'epos'
4
- import { nanoid } from 'nanoid'
5
- import type { FC } from 'react'
6
4
 
7
5
  export const _root_ = Symbol('root')
8
6
  export const _parent_ = Symbol('parent')
7
+ export const _attached_ = Symbol('attached')
9
8
  export const _disposers_ = Symbol('disposers')
10
- export type Descriptors = Record<string | symbol, PropertyDescriptor>
9
+ export const _ancestors_ = Symbol('ancestors')
10
+ export const _pendingAttachFns_ = Symbol('pendingAttachFns')
11
+
12
+ export type Node<T> = Unit<T> | Obj | Arr
11
13
 
12
14
  export class Unit<TRoot = unknown> {
13
- id = nanoid()
14
15
  declare '@': string
15
- declare log: Log;
16
- declare [':version']?: number
17
- declare private [_root_]: TRoot
18
- declare private [_parent_]: Unit<TRoot> | null // Parent reference for a not-yet-attached unit
19
- declare private [_disposers_]: Set<() => void>
20
-
21
- static get [epos.symbols.stateModelStrict]() {
22
- return true
23
- }
16
+ declare log: ReturnType<typeof createLog>;
17
+ declare [':version']?: number;
18
+ declare [_root_]?: TRoot;
19
+ declare [_parent_]?: Unit<TRoot> | null; // Parent reference for a not-yet-attached units
20
+ declare [_attached_]?: boolean;
21
+ declare [_disposers_]?: Set<() => void>;
22
+ declare [_ancestors_]?: Map<Cls, unknown>;
23
+ declare [_pendingAttachFns_]?: (() => void)[]
24
24
 
25
25
  constructor(parent: Unit<TRoot> | null) {
26
- // Define parent for not-yet-attached units
27
- Reflect.defineProperty(this, _parent_, { get: () => parent })
26
+ this[_parent_] = parent
27
+ const versions = getVersions(this)
28
+ if (versions.length > 0) this[':version'] = versions.at(-1)!
28
29
  }
29
30
 
30
- // ---------------------------------------------------------------------------
31
- // INIT
32
- // ---------------------------------------------------------------------------
33
-
34
- [epos.symbols.stateModelInit]() {
35
- const _this = this as any
36
- const Unit = this.constructor
37
- const descriptors: Descriptors = Object.getOwnPropertyDescriptors(Unit.prototype)
38
- const keys = Reflect.ownKeys(descriptors)
39
-
40
- // Setup disposers container
41
- const disposers = new Set<() => void>()
42
- Reflect.defineProperty(this, _disposers_, { get: () => disposers })
43
-
44
- // Bind all methods
45
- for (const key of keys) {
46
- if (key === 'constructor') continue
47
- const descriptor = descriptors[key]
48
- if (!descriptor) continue
49
- if (descriptor.get || descriptor.set) continue
50
- if (typeof descriptor.value !== 'function') continue
51
- _this[key] = descriptor.value.bind(this)
52
- }
31
+ /**
32
+ * Lifecycle method called when the unit is attached to the state tree.
33
+ */
34
+ [epos.state.ATTACH]() {
35
+ // Setup logger
36
+ setProperty(this, 'log', createLog(this['@']))
53
37
 
54
- // Create components out of `View` methods
55
- for (const key of keys) {
56
- if (typeof key === 'symbol') continue
57
- if (!key.endsWith('View')) continue
58
- const descriptor = descriptors[key]
59
- if (!descriptor) continue
60
- if (descriptor.get || descriptor.set) continue
61
- if (typeof _this[key] !== 'function') continue
62
- _this[key] = epos.component(_this[key] as FC)
63
- _this[key].displayName = `${this['@']}.${key}`
64
- }
38
+ // Apply versioner
39
+ epos.state.transaction(() => {
40
+ const versioner = getVersioner(this)
41
+ const versions = getVersions(this)
42
+ for (const version of versions) {
43
+ if (is.number(this[':version']) && this[':version'] >= version) continue
44
+ const versionFn = versioner[version]
45
+ if (!is.function(versionFn)) continue
46
+ versionFn.call(this, this)
47
+ this[':version'] = version
48
+ }
49
+ })
65
50
 
66
- // Define log method
67
- const log = createLog(this['@'])
68
- Reflect.defineProperty(this, 'log', { get: () => log })
51
+ // Prepare methods:
52
+ // - Create components for methods ending with `View`
53
+ // - Bind all other methods to the unit instance
54
+ for (const prototype of getPrototypes(this)) {
55
+ const descriptors = Object.getOwnPropertyDescriptors(prototype)
69
56
 
70
- // Call init method
71
- if (typeof _this.init === 'function') _this.init()
72
- }
57
+ for (const [key, descriptor] of Object.entries(descriptors)) {
58
+ if (key === 'constructor') continue
59
+ if (this.hasOwnProperty(key)) continue
73
60
 
74
- // ---------------------------------------------------------------------------
75
- // DISPOSE
76
- // ---------------------------------------------------------------------------
61
+ if (descriptor.get || descriptor.set) continue
62
+ if (!is.function(descriptor.value)) continue
63
+ const fn = descriptor.value.bind(this)
77
64
 
78
- [epos.symbols.stateModelDispose]() {
79
- const _this = this as any
65
+ if (key.endsWith('View')) {
66
+ let Component = epos.component(fn)
67
+ Component.displayName = `${this.constructor.name}.${key}`
68
+ setProperty(this, key, Component)
69
+ } else {
70
+ setProperty(this, key, fn)
71
+ }
72
+ }
73
+ }
80
74
 
81
- // Call disposers
82
- this[_disposers_].forEach(disposer => disposer())
83
- this[_disposers_].clear()
75
+ // Setup state
76
+ const stateDescriptor = Reflect.getOwnPropertyDescriptor(this.constructor.prototype, 'state')
77
+ if (stateDescriptor && stateDescriptor.get) {
78
+ const state = stateDescriptor.get.call(this)
79
+ setProperty(this, 'state', epos.libs.mobx.observable.object(state, {}, { deep: false }))
80
+ }
84
81
 
85
- // Call dispose method
86
- if (typeof _this.dispose === 'function') _this.dispose()
87
- }
82
+ // Process attach queue.
83
+ // We do not execute `attach` methods immediately, but rather queue them
84
+ // on the highest unattached ancestor. This way we ensure that `attach`
85
+ // methods are called after all versioners have been applied in the entire subtree.
86
+ const attach = Reflect.get(this, 'attach')
87
+ if (is.function(attach)) {
88
+ const unattachedRoot = findUnattachedRoot(this)
89
+ if (!unattachedRoot) throw this.never()
88
90
 
89
- // ---------------------------------------------------------------------------
90
- // VERSIONER
91
- // ---------------------------------------------------------------------------
91
+ ensureProperty(unattachedRoot, _pendingAttachFns_, () => [])
92
+ unattachedRoot[_pendingAttachFns_].push(() => attach())
93
+ }
92
94
 
93
- static get [epos.symbols.stateModelVersioner]() {
94
- if (!('versioner' in this)) return null
95
- return this.versioner
95
+ if (this[_pendingAttachFns_]) {
96
+ this[_pendingAttachFns_].forEach(attach => attach())
97
+ delete this[_pendingAttachFns_]
98
+ }
99
+
100
+ // Mark as attached
101
+ setProperty(this, _attached_, true)
96
102
  }
97
103
 
98
- // ---------------------------------------------------------------------------
99
- // ROOT
100
- // ---------------------------------------------------------------------------
104
+ /**
105
+ * Lifecycle method called when the unit is detached from the state tree.
106
+ */
107
+ [epos.state.DETACH]() {
108
+ // 1. Run disposers
109
+ if (this[_disposers_]) {
110
+ this[_disposers_].forEach(disposer => disposer())
111
+ this[_disposers_].clear()
112
+ }
113
+
114
+ // 2. Call detach method
115
+ const detach = Reflect.get(this, 'detach')
116
+ if (is.function(detach)) detach()
101
117
 
118
+ // 3. Clean up internal properties
119
+ delete this[_root_]
120
+ delete this[_attached_]
121
+ delete this[_ancestors_]
122
+ delete this[_disposers_]
123
+ }
124
+
125
+ /**
126
+ * Gets the root unit of the current unit's tree.
127
+ * The result is cached for subsequent calls.
128
+ */
102
129
  get $() {
103
- this[_root_] ??= findRoot(this) as TRoot
130
+ ensureProperty(this, _root_, () => findRoot(this))
104
131
  return this[_root_]
105
132
  }
106
133
 
107
- // ---------------------------------------------------------------------------
108
- // METHODS
109
- // ---------------------------------------------------------------------------
134
+ /**
135
+ * Finds the closest ancestor unit of a given type.
136
+ * The result is cached for subsequent calls.
137
+ */
138
+ closest<T extends Unit>(Ancestor: Cls<T>) {
139
+ // Has cached value? -> Return it
140
+ ensureProperty(this, _ancestors_, () => new Map())
141
+ if (this[_ancestors_].has(Ancestor)) return this[_ancestors_].get(Ancestor) as T
110
142
 
111
- closest<T extends Unit>(Ancestor: Cls<T>): T | null {
112
- let cursor: unknown = getParent(this)
143
+ // Find the closest ancestor and cache it
144
+ let cursor: Node<TRoot> | null = this
113
145
  while (cursor) {
114
- if (cursor instanceof Ancestor) return cursor
146
+ if (cursor instanceof Ancestor) {
147
+ this[_ancestors_].set(Ancestor, cursor)
148
+ return cursor
149
+ }
115
150
  cursor = getParent(cursor)
116
151
  }
152
+
117
153
  return null
118
154
  }
119
155
 
156
+ /**
157
+ * A wrapper around MobX's `autorun` that automatically disposes
158
+ * the reaction when the unit is detached.
159
+ */
120
160
  autorun(...args: Parameters<typeof epos.libs.mobx.autorun>) {
121
161
  const disposer = epos.libs.mobx.autorun(...args)
162
+ ensureProperty(this, _disposers_, () => new Set())
122
163
  this[_disposers_].add(disposer)
123
164
  return disposer
124
165
  }
125
166
 
167
+ /**
168
+ * A wrapper around MobX's `reaction` that automatically disposes
169
+ * the reaction when the unit is detached.
170
+ */
126
171
  reaction(...args: Parameters<typeof epos.libs.mobx.reaction>) {
127
172
  const disposer = epos.libs.mobx.reaction(...args)
173
+ ensureProperty(this, _disposers_, () => new Set())
128
174
  this[_disposers_].add(disposer)
129
175
  return disposer
130
176
  }
131
177
 
178
+ /**
179
+ * A wrapper around `setTimeout` that automatically clears the timeout
180
+ * when the unit is detached.
181
+ */
132
182
  setTimeout(...args: Parameters<typeof self.setTimeout>) {
133
183
  const id = self.setTimeout(...args)
184
+ ensureProperty(this, _disposers_, () => new Set())
134
185
  this[_disposers_].add(() => self.clearTimeout(id))
135
186
  return id
136
187
  }
137
188
 
189
+ /**
190
+ * A wrapper around `setInterval` that automatically clears the interval
191
+ * when the unit is detached.
192
+ */
138
193
  setInterval(...args: Parameters<typeof self.setInterval>) {
139
194
  const id = self.setInterval(...args)
195
+ ensureProperty(this, _disposers_, () => new Set())
140
196
  this[_disposers_].add(() => self.clearInterval(id))
141
197
  return id
142
198
  }
143
199
 
200
+ /**
201
+ * Creates an error for an unreachable code path.
202
+ */
144
203
  never(message = 'This should never happen') {
145
- const error = new Error(`[${this['@']}] ${message}`)
204
+ const details = message ? `: ${message}` : ''
205
+ const error = new Error(`[${this.constructor.name}] This should never happen${details}`)
146
206
  Error.captureStackTrace(error, this.never)
147
- throw error
207
+ return error
148
208
  }
149
209
  }
150
210
 
@@ -152,18 +212,91 @@ export class Unit<TRoot = unknown> {
152
212
  // HELPERS
153
213
  // ---------------------------------------------------------------------------
154
214
 
155
- function getParent(child: any) {
156
- return child[_parent_] ?? child[epos.symbols.stateParent]
215
+ /**
216
+ * Defines a configurable property on an object.
217
+ */
218
+ function setProperty(object: object, key: PropertyKey, value: unknown) {
219
+ Reflect.defineProperty(object, key, {
220
+ configurable: true,
221
+ get: () => value,
222
+ set: v => (value = v),
223
+ })
157
224
  }
158
225
 
159
- function findRoot(unit: Unit) {
160
- let root = unit
161
- let cursor: any = unit
226
+ /**
227
+ * Ensures a property exists on an object, initializing it if it doesn't.
228
+ */
229
+ function ensureProperty<T extends object, K extends PropertyKey, V>(
230
+ object: T,
231
+ key: K,
232
+ getInitialValue: () => V,
233
+ ): asserts object is T & { [key in K]: V } {
234
+ if (key in object) return
235
+ const value = getInitialValue()
236
+ Reflect.defineProperty(object, key, { configurable: true, get: () => value })
237
+ }
238
+
239
+ /**
240
+ * Gets all prototypes of an object up to `Object.prototype`.
241
+ */
242
+ function getPrototypes(object: object): object[] {
243
+ const prototype = Reflect.getPrototypeOf(object)
244
+ if (!prototype || prototype === Object.prototype) return []
245
+ return [prototype, ...getPrototypes(prototype)]
246
+ }
247
+
248
+ /**
249
+ * Finds the root `Unit` in the hierarchy for a given unit.
250
+ */
251
+ function findRoot<T>(unit: Unit<T>) {
252
+ let root: Unit<T> | null = null
253
+ let cursor: Node<T> | null = unit
162
254
 
163
255
  while (cursor) {
164
256
  if (cursor instanceof Unit) root = cursor
165
257
  cursor = getParent(cursor)
166
258
  }
167
259
 
168
- return root
260
+ return root as T
261
+ }
262
+
263
+ /**
264
+ * Finds the highest unattached `Unit` in the hierarchy for a given unit.
265
+ */
266
+ function findUnattachedRoot<T>(unit: Unit<T>) {
267
+ let unattachedRoot: Unit<T> | null = null
268
+ let cursor: Node<T> | null = unit
269
+
270
+ while (cursor) {
271
+ if (cursor instanceof Unit && !cursor[_attached_]) unattachedRoot = cursor
272
+ cursor = getParent(cursor)
273
+ }
274
+
275
+ return unattachedRoot
276
+ }
277
+
278
+ /**
279
+ * Gets the parent of a node, which can be a `Unit`, an object, or an array.
280
+ */
281
+ function getParent<T>(node: Node<T>) {
282
+ const parent: Node<T> | null = Reflect.get(node, _parent_) ?? Reflect.get(node, epos.state.PARENT) ?? null
283
+ return parent
284
+ }
285
+
286
+ /**
287
+ * Gets the versioner object from a unit's constructor.
288
+ */
289
+ function getVersioner<T>(unit: Unit<T>) {
290
+ const versioner: unknown = Reflect.get(unit.constructor, 'versioner')
291
+ if (!is.object(versioner)) return {}
292
+ return versioner
293
+ }
294
+
295
+ /**
296
+ * Gets a sorted list of numeric version keys from a unit's versioner.
297
+ */
298
+ function getVersions<T>(unit: Unit<T>) {
299
+ const versioner = getVersioner(unit)
300
+ const numericKeys = Object.keys(versioner).filter(key => is.numeric(key))
301
+ return numericKeys.map(Number).sort((v1, v2) => v1 - v2)
169
302
  }