rask-ui 0.28.3 → 0.29.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.
Files changed (65) hide show
  1. package/README.md +1 -1
  2. package/dist/component.d.ts +4 -3
  3. package/dist/component.d.ts.map +1 -1
  4. package/dist/component.js +37 -57
  5. package/dist/index.d.ts +0 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +0 -1
  8. package/dist/render.js +2 -2
  9. package/dist/scheduler.d.ts +2 -3
  10. package/dist/scheduler.d.ts.map +1 -1
  11. package/dist/scheduler.js +31 -104
  12. package/dist/tests/batch.test.js +202 -12
  13. package/dist/tests/createContext.test.js +50 -37
  14. package/dist/tests/error.test.js +25 -12
  15. package/dist/tests/renderCount.test.d.ts +2 -0
  16. package/dist/tests/renderCount.test.d.ts.map +1 -0
  17. package/dist/tests/renderCount.test.js +95 -0
  18. package/dist/tests/scopeEnforcement.test.d.ts +2 -0
  19. package/dist/tests/scopeEnforcement.test.d.ts.map +1 -0
  20. package/dist/tests/scopeEnforcement.test.js +157 -0
  21. package/dist/tests/useAction.test.d.ts +2 -0
  22. package/dist/tests/useAction.test.d.ts.map +1 -0
  23. package/dist/tests/useAction.test.js +132 -0
  24. package/dist/tests/useAsync.test.d.ts +2 -0
  25. package/dist/tests/useAsync.test.d.ts.map +1 -0
  26. package/dist/tests/useAsync.test.js +499 -0
  27. package/dist/tests/useDerived.test.d.ts +2 -0
  28. package/dist/tests/useDerived.test.d.ts.map +1 -0
  29. package/dist/tests/useDerived.test.js +407 -0
  30. package/dist/tests/useEffect.test.d.ts +2 -0
  31. package/dist/tests/useEffect.test.d.ts.map +1 -0
  32. package/dist/tests/useEffect.test.js +600 -0
  33. package/dist/tests/useLookup.test.d.ts +2 -0
  34. package/dist/tests/useLookup.test.d.ts.map +1 -0
  35. package/dist/tests/useLookup.test.js +299 -0
  36. package/dist/tests/useRef.test.d.ts +2 -0
  37. package/dist/tests/useRef.test.d.ts.map +1 -0
  38. package/dist/tests/useRef.test.js +189 -0
  39. package/dist/tests/useState.test.d.ts +2 -0
  40. package/dist/tests/useState.test.d.ts.map +1 -0
  41. package/dist/tests/useState.test.js +178 -0
  42. package/dist/tests/useSuspend.test.d.ts +2 -0
  43. package/dist/tests/useSuspend.test.d.ts.map +1 -0
  44. package/dist/tests/useSuspend.test.js +752 -0
  45. package/dist/tests/useView.test.d.ts +2 -0
  46. package/dist/tests/useView.test.d.ts.map +1 -0
  47. package/dist/tests/useView.test.js +305 -0
  48. package/dist/useAsync.d.ts.map +1 -1
  49. package/dist/useAsync.js +12 -11
  50. package/dist/useDerived.d.ts +1 -1
  51. package/dist/useDerived.d.ts.map +1 -1
  52. package/dist/useDerived.js +9 -63
  53. package/dist/useEffect.d.ts.map +1 -1
  54. package/dist/useEffect.js +4 -19
  55. package/dist/useLookup.d.ts.map +1 -1
  56. package/dist/useLookup.js +9 -14
  57. package/dist/useRef.d.ts.map +1 -1
  58. package/dist/useRef.js +4 -8
  59. package/dist/useRouter.d.ts.map +1 -1
  60. package/dist/useRouter.js +4 -8
  61. package/dist/useState.d.ts +0 -1
  62. package/dist/useState.d.ts.map +1 -1
  63. package/dist/useState.js +2 -100
  64. package/dist/useSuspend.d.ts.map +1 -1
  65. package/package.json +1 -1
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  <img src="https://raw.githubusercontent.com/christianalfoni/rask-ui/main/logo.png" alt="Logo" width="200">
5
5
  </p>
6
6
 
7
- A lightweight reactive component library that combines the simplicity of observable state management with the full power of a virtual DOM reconciler.
7
+ A lightweight reactive component library built on battle-tested technologies: **Inferno's** highly optimized reconciler and **MobX's** proven reactive state management.
8
8
 
9
9
  **[Visit rask-ui.io for full documentation](https://rask-ui.io)**
10
10
 
@@ -1,5 +1,5 @@
1
1
  import { VNode, Component, Props, InfernoNode } from "inferno";
2
- import { Observer, Signal } from "./observation";
2
+ import { IObservableValue } from "mobx";
3
3
  export declare function getCurrentComponent(): RaskComponent<any> | undefined;
4
4
  export declare function useMountEffect(cb: () => void): void;
5
5
  export declare function useCleanup(cb: () => void): void;
@@ -7,12 +7,13 @@ export type RaskStatelessFunctionComponent<P extends Props<any>> = (() => VNode)
7
7
  export type RaskStatefulFunctionComponent<P extends Props<any>> = (() => () => VNode) | ((props: P) => () => VNode);
8
8
  export declare class RaskComponent<P extends Props<any>> extends Component<P> {
9
9
  renderFn: RaskStatelessFunctionComponent<P>;
10
- propsSignals: Record<string, Signal>;
10
+ propsSignals: Record<string, IObservableValue<any>>;
11
11
  private reactiveProps;
12
12
  private isNotified;
13
13
  private isReconciling;
14
14
  private hasChangedComponent;
15
- observer: Observer;
15
+ private createReaction;
16
+ private reaction;
16
17
  isRendering: boolean;
17
18
  effects: Array<{
18
19
  isDirty: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"component.d.ts","sourceRoot":"","sources":["../src/component.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC/D,OAAO,EAAsB,QAAQ,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAMrE,wBAAgB,mBAAmB,mCAElC;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,IAAI,QAM5C;AAED,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,IAAI,QAMxC;AAED,MAAM,MAAM,8BAA8B,CAAC,CAAC,SAAS,KAAK,CAAC,GAAG,CAAC,IAC3D,CAAC,MAAM,KAAK,CAAC,GACb,CAAC,CAAC,KAAK,EAAE,CAAC,KAAK,KAAK,CAAC,CAAC;AAE1B,MAAM,MAAM,6BAA6B,CAAC,CAAC,SAAS,KAAK,CAAC,GAAG,CAAC,IAC1D,CAAC,MAAM,MAAM,KAAK,CAAC,GACnB,CAAC,CAAC,KAAK,EAAE,CAAC,KAAK,MAAM,KAAK,CAAC,CAAC;AAEhC,qBAAa,aAAa,CAAC,CAAC,SAAS,KAAK,CAAC,GAAG,CAAC,CAAE,SAAQ,SAAS,CAAC,CAAC,CAAC;IAC3D,QAAQ,EAAE,8BAA8B,CAAC,CAAC,CAAC,CAAC;IACpD,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAM;IAC1C,OAAO,CAAC,aAAa,CAAc;IAwBnC,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,mBAAmB,CAAQ;IAEnC,QAAQ,WAOL;IAEH,WAAW,UAAS;IACpB,OAAO,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,GAAG,EAAE,MAAM,IAAI,CAAA;KAAE,CAAC,CAAM;IAC3D,QAAQ,gBAAa;IACrB,UAAU,CAAC,OAAO,EAAE,OAAO;IAS3B,eAAe;IAMf,QAAQ,EAAE,KAAK,CAAC,MAAM,IAAI,CAAC,CAAM;IACjC,UAAU,EAAE,KAAK,CAAC,MAAM,IAAI,CAAC,CAAM;IAEnC,iBAAiB,IAAI,IAAI;IAGzB,oBAAoB,IAAI,IAAI;IAI5B,yBAAyB,CACvB,SAAS,EAAE,QAAQ,CAAC;QAAE,QAAQ,CAAC,EAAE,WAAW,CAAA;KAAE,GAAG,CAAC,CAAC,GAClD,IAAI;IAgBP,qBAAqB,IAAI,OAAO;IAMhC,MAAM;CAkDP"}
1
+ {"version":3,"file":"component.d.ts","sourceRoot":"","sources":["../src/component.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE/D,OAAO,EAAE,gBAAgB,EAAwB,MAAM,MAAM,CAAC;AAM9D,wBAAgB,mBAAmB,mCAElC;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,IAAI,QAM5C;AAED,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,IAAI,QAMxC;AAED,MAAM,MAAM,8BAA8B,CAAC,CAAC,SAAS,KAAK,CAAC,GAAG,CAAC,IAC3D,CAAC,MAAM,KAAK,CAAC,GACb,CAAC,CAAC,KAAK,EAAE,CAAC,KAAK,KAAK,CAAC,CAAC;AAE1B,MAAM,MAAM,6BAA6B,CAAC,CAAC,SAAS,KAAK,CAAC,GAAG,CAAC,IAC1D,CAAC,MAAM,MAAM,KAAK,CAAC,GACnB,CAAC,CAAC,KAAK,EAAE,CAAC,KAAK,MAAM,KAAK,CAAC,CAAC;AAEhC,qBAAa,aAAa,CAAC,CAAC,SAAS,KAAK,CAAC,GAAG,CAAC,CAAE,SAAQ,SAAS,CAAC,CAAC,CAAC;IAC3D,QAAQ,EAAE,8BAA8B,CAAC,CAAC,CAAC,CAAC;IACpD,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,GAAG,CAAC,CAAC,CAAM;IACzD,OAAO,CAAC,aAAa,CAAc;IAwBnC,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,mBAAmB,CAAQ;IACnC,OAAO,CAAC,cAAc;IAUtB,OAAO,CAAC,QAAQ,CAAmC;IAEnD,WAAW,UAAS;IACpB,OAAO,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,GAAG,EAAE,MAAM,IAAI,CAAA;KAAE,CAAC,CAAM;IAC3D,QAAQ,gBAAa;IACrB,UAAU,CAAC,OAAO,EAAE,OAAO;IAS3B,eAAe;IAMf,QAAQ,EAAE,KAAK,CAAC,MAAM,IAAI,CAAC,CAAM;IACjC,UAAU,EAAE,KAAK,CAAC,MAAM,IAAI,CAAC,CAAM;IAEnC,iBAAiB,IAAI,IAAI;IAGzB,oBAAoB,IAAI,IAAI;IAI5B,yBAAyB,CACvB,SAAS,EAAE,QAAQ,CAAC;QAAE,QAAQ,CAAC,EAAE,WAAW,CAAA;KAAE,GAAG,CAAC,CAAC,GAClD,IAAI;IAQP,qBAAqB,IAAI,OAAO;IAMhC,MAAM;CAoEP"}
package/dist/component.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { Component } from "inferno";
2
- import { getCurrentObserver, Observer, Signal } from "./observation";
3
- import { syncBatch } from "./batch";
4
2
  import { CatchErrorContext } from "./useCatchError";
3
+ import { Reaction, observable } from "mobx";
4
+ import { transaction } from "./scheduler";
5
+ import { assignState } from "./useState";
5
6
  let currentComponent;
6
7
  export function getCurrentComponent() {
7
8
  return currentComponent;
@@ -46,13 +47,16 @@ export class RaskComponent extends Component {
46
47
  isNotified = false;
47
48
  isReconciling = false;
48
49
  hasChangedComponent = true;
49
- observer = new Observer(() => {
50
- if (this.isReconciling) {
51
- this.isNotified = true;
52
- return;
53
- }
54
- this.forceUpdate();
55
- });
50
+ createReaction() {
51
+ return new Reaction("ComponentRender", () => {
52
+ if (this.isReconciling) {
53
+ this.isNotified = true;
54
+ return;
55
+ }
56
+ this.forceUpdate();
57
+ });
58
+ }
59
+ reaction = this.createReaction();
56
60
  // Flag to prevent props from tracking in render scope (We use props reconciliation)
57
61
  isRendering = false;
58
62
  effects = [];
@@ -80,17 +84,10 @@ export class RaskComponent extends Component {
80
84
  }
81
85
  componentWillReceiveProps(nextProps) {
82
86
  this.isReconciling = true;
83
- const prevProps = this.props;
84
- this.props = nextProps;
85
87
  this.hasChangedComponent =
86
- prevProps.__component !== this.props.__component;
87
- syncBatch(() => {
88
- for (const prop in this.propsSignals) {
89
- if (prevProps[prop] === nextProps[prop]) {
90
- continue;
91
- }
92
- this.propsSignals[prop].notify();
93
- }
88
+ nextProps.__component !== this.props.__component;
89
+ transaction(() => {
90
+ assignState(this.reactiveProps, nextProps);
94
91
  });
95
92
  }
96
93
  shouldComponentUpdate() {
@@ -101,17 +98,17 @@ export class RaskComponent extends Component {
101
98
  }
102
99
  render() {
103
100
  currentComponent = this;
104
- const stopObserving = this.observer.observe();
105
101
  try {
106
102
  if (this.hasChangedComponent) {
107
103
  this.hasChangedComponent = false;
108
104
  this.componentWillUnmount();
109
- this.reactiveProps = createReactiveProps(this);
105
+ this.reactiveProps = observable(this.props);
110
106
  const component = this.props.__component;
111
107
  const renderFn = component(this.reactiveProps);
112
108
  if (typeof renderFn === "function") {
113
109
  // Since we ran a setup function we need to clear any signals accessed
114
- this.observer.clearSignals();
110
+ this.reaction.dispose();
111
+ this.reaction = this.createReaction();
115
112
  this.renderFn = renderFn;
116
113
  }
117
114
  else {
@@ -121,7 +118,24 @@ export class RaskComponent extends Component {
121
118
  }
122
119
  let result = null;
123
120
  this.isRendering = true;
124
- result = this.renderFn(this.reactiveProps);
121
+ this.reaction.track(() => {
122
+ try {
123
+ result = this.renderFn(this.reactiveProps);
124
+ }
125
+ catch (error) {
126
+ try {
127
+ const notifyError = CatchErrorContext.use();
128
+ if (typeof notifyError !== "function") {
129
+ throw error;
130
+ }
131
+ notifyError(error);
132
+ return null;
133
+ }
134
+ catch {
135
+ throw error;
136
+ }
137
+ }
138
+ });
125
139
  this.isRendering = false;
126
140
  return result;
127
141
  }
@@ -139,41 +153,7 @@ export class RaskComponent extends Component {
139
153
  }
140
154
  }
141
155
  finally {
142
- stopObserving();
143
156
  currentComponent = undefined;
144
157
  }
145
158
  }
146
159
  }
147
- function createReactiveProps(comp) {
148
- const props = new Proxy({}, {
149
- ownKeys() {
150
- return Object.getOwnPropertyNames(comp.props);
151
- },
152
- getOwnPropertyDescriptor(_, prop) {
153
- return {
154
- configurable: true,
155
- enumerable: true,
156
- value: comp.props[prop],
157
- writable: false,
158
- };
159
- },
160
- get(_, prop) {
161
- // Skip known non-reactive props
162
- if (prop === "key" || prop === "ref") {
163
- return;
164
- }
165
- const observer = getCurrentObserver();
166
- if (observer) {
167
- // Lazy create signal only when accessed in reactive context
168
- let signal = comp.propsSignals[prop];
169
- if (!signal) {
170
- signal = new Signal();
171
- comp.propsSignals[prop] = signal;
172
- }
173
- observer.subscribeSignal(signal);
174
- }
175
- return comp.props[prop];
176
- },
177
- });
178
- return props;
179
- }
package/dist/index.d.ts CHANGED
@@ -11,7 +11,6 @@ export { useRef, assignRef, Ref } from "./useRef";
11
11
  export { useView } from "./useView";
12
12
  export { useEffect } from "./useEffect";
13
13
  export { useDerived, Derived } from "./useDerived";
14
- export { syncBatch } from "./batch";
15
14
  export { inspect } from "./inspect";
16
15
  export { Router, useRouter } from "./useRouter";
17
16
  export { useLookup } from "./useLookup";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,SAAS,CAAC;AAEjB,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAClC,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACnD,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAClD,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,SAAS,CAAC;AAEjB,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAClC,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACnD,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAClD,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -12,7 +12,6 @@ export { useRef, assignRef } from "./useRef";
12
12
  export { useView } from "./useView";
13
13
  export { useEffect } from "./useEffect";
14
14
  export { useDerived } from "./useDerived";
15
- export { syncBatch } from "./batch";
16
15
  export { inspect } from "./inspect";
17
16
  export { useRouter } from "./useRouter";
18
17
  export { useLookup } from "./useLookup";
package/dist/render.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { render as infernoRender } from "inferno";
2
- import { syncBatch } from "./batch";
2
+ import { transaction } from "./scheduler";
3
3
  /**
4
4
  * Renders a component with automatic event batching.
5
5
  * Temporarily patches document.addEventListener to wrap
@@ -42,7 +42,7 @@ export function render(...params) {
42
42
  !patchedEvents.has(type)) {
43
43
  patchedEvents.add(type);
44
44
  const wrappedListener = function (event) {
45
- syncBatch(() => {
45
+ transaction(() => {
46
46
  listener.call(this, event);
47
47
  });
48
48
  };
@@ -1,4 +1,3 @@
1
- export declare function markDirty(): void;
2
- export declare function enqueueUpdateFromSetter(): void;
3
- export declare function installGlobalBatching(target?: EventTarget): () => void;
1
+ export declare function transaction(cb: () => void): void;
2
+ export declare function autorun(cb: () => void): import("mobx").IReactionDisposer;
4
3
  //# sourceMappingURL=scheduler.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"scheduler.d.ts","sourceRoot":"","sources":["../src/scheduler.ts"],"names":[],"mappings":"AAIA,wBAAgB,SAAS,SAExB;AAeD,wBAAgB,uBAAuB,SAWtC;AAmED,wBAAgB,qBAAqB,CAAC,MAAM,GAAE,WAAoB,cAoBjE"}
1
+ {"version":3,"file":"scheduler.d.ts","sourceRoot":"","sources":["../src/scheduler.ts"],"names":[],"mappings":"AAQA,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,IAAI,QAKzC;AAED,wBAAgB,OAAO,CAAC,EAAE,EAAE,MAAM,IAAI,oCAOrC"}
package/dist/scheduler.js CHANGED
@@ -1,107 +1,34 @@
1
- let depth = 0; // batching scope nesting
2
- let dirty = false; // has any state update been enqueued?
3
- let scheduled = false; // async flush scheduled?
4
- export function markDirty() {
5
- dirty = true;
6
- }
7
- function performWork() {
8
- // TODO: call your Inferno render/commit once.
9
- // infernoRender(vnode, container);
10
- }
11
- function flushNow() {
12
- scheduled = false;
13
- if (!dirty)
14
- return;
15
- dirty = false;
16
- performWork();
17
- }
18
- // Called by setters after enqueueing their state change
19
- export function enqueueUpdateFromSetter() {
20
- dirty = true;
21
- if (depth > 0) {
22
- // We're inside a batched input event; we'll flush on exit (same frame).
23
- return;
24
- }
25
- if (!scheduled) {
26
- scheduled = true;
27
- queueMicrotask(flushNow); // one flush per task
28
- }
29
- }
30
- // Batch-scope control used by the global capture listeners
31
- function enter() {
32
- depth++;
33
- }
34
- function exit() {
35
- if (--depth === 0) {
36
- // End of the event propagation; commit now (before next paint).
37
- flushNow();
38
- }
1
+ import { configure, transaction as mobxTransaction, autorun as mobxAutorun, } from "mobx";
2
+ let isSync = false;
3
+ export function transaction(cb) {
4
+ isSync = true;
5
+ mobxTransaction(() => {
6
+ cb();
7
+ });
39
8
  }
40
- // eventBatching.ts
41
- const INTERACTIVE_EVENTS = [
42
- // Pointer + mouse
43
- "click",
44
- "dblclick",
45
- "contextmenu",
46
- "mousedown",
47
- "mouseup",
48
- "mousemove",
49
- "pointerdown",
50
- "pointerup",
51
- "pointermove",
52
- "touchstart",
53
- "touchmove",
54
- "touchend",
55
- "touchcancel",
56
- "dragstart",
57
- "drag",
58
- "dragend",
59
- "dragenter",
60
- "dragleave",
61
- "dragover",
62
- "drop",
63
- "wheel",
64
- // Keyboard
65
- "keydown",
66
- "keypress",
67
- "keyup",
68
- // Focus & input
69
- "focus",
70
- "blur",
71
- "focusin",
72
- "focusout",
73
- "input",
74
- "beforeinput",
75
- "change",
76
- "compositionstart",
77
- "compositionupdate",
78
- "compositionend",
79
- // Forms
80
- "submit",
81
- "reset",
82
- // Selection / clipboard
83
- "select",
84
- "selectionchange",
85
- "copy",
86
- "cut",
87
- "paste",
88
- ];
89
- export function installGlobalBatching(target = window) {
90
- const handlers = [];
91
- INTERACTIVE_EVENTS.forEach((type) => {
92
- const onCapture = () => {
93
- enter();
94
- // Close the scope after all handlers (capture→target→bubble) have run.
95
- queueMicrotask(exit);
96
- };
97
- target.addEventListener(type, onCapture, { capture: true });
98
- handlers.push([onCapture, { capture: true }]);
9
+ export function autorun(cb) {
10
+ isSync = true;
11
+ const disposer = mobxAutorun(() => {
12
+ cb();
99
13
  });
100
- // Return a disposer so you can remove on unmount
101
- return () => {
102
- INTERACTIVE_EVENTS.forEach((type, i) => {
103
- const [fn, opts] = handlers[i];
104
- target.removeEventListener(type, fn, opts);
105
- });
106
- };
14
+ return disposer;
107
15
  }
16
+ let hasQueuedFlush = false;
17
+ configure({
18
+ enforceActions: "never",
19
+ reactionScheduler: (f) => {
20
+ if (isSync) {
21
+ f(); // Flush immediately at the end the transaction
22
+ hasQueuedFlush = false;
23
+ isSync = false;
24
+ }
25
+ else if (!hasQueuedFlush) {
26
+ hasQueuedFlush = true;
27
+ queueMicrotask(() => {
28
+ isSync = true;
29
+ f();
30
+ hasQueuedFlush = false;
31
+ }); // Defer "loose" user changes
32
+ }
33
+ },
34
+ });
@@ -1,10 +1,10 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { syncBatch } from "../batch";
3
- import { createState } from "../createState";
3
+ import { useState } from "../useState";
4
4
  import { Observer } from "../observation";
5
5
  describe("syncBatch", () => {
6
6
  it("should batch multiple state changes into a single notification", () => {
7
- const state = createState({ count: 0, name: "Alice" });
7
+ const state = useState({ count: 0, name: "Alice" });
8
8
  let notifyCount = 0;
9
9
  const observer = new Observer(() => {
10
10
  notifyCount++;
@@ -26,7 +26,7 @@ describe("syncBatch", () => {
26
26
  observer.dispose();
27
27
  });
28
28
  it("should handle nested batches correctly", () => {
29
- const state = createState({ count: 0 });
29
+ const state = useState({ count: 0 });
30
30
  let notifyCount = 0;
31
31
  const observer = new Observer(() => {
32
32
  notifyCount++;
@@ -47,7 +47,7 @@ describe("syncBatch", () => {
47
47
  observer.dispose();
48
48
  });
49
49
  it("should handle multiple observers with syncBatch", () => {
50
- const state = createState({ count: 0 });
50
+ const state = useState({ count: 0 });
51
51
  let notifyCount1 = 0;
52
52
  let notifyCount2 = 0;
53
53
  const observer1 = new Observer(() => {
@@ -74,7 +74,7 @@ describe("syncBatch", () => {
74
74
  observer2.dispose();
75
75
  });
76
76
  it("should maintain correct state values after syncBatch", () => {
77
- const state = createState({
77
+ const state = useState({
78
78
  count: 0,
79
79
  name: "Alice",
80
80
  items: [1, 2, 3],
@@ -90,7 +90,7 @@ describe("syncBatch", () => {
90
90
  expect(state.items).toEqual([100, 2, 3, 4]);
91
91
  });
92
92
  it("should not flush if exception thrown within syncBatch", () => {
93
- const state = createState({ count: 0 });
93
+ const state = useState({ count: 0 });
94
94
  let notifyCount = 0;
95
95
  const observer = new Observer(() => {
96
96
  notifyCount++;
@@ -114,7 +114,7 @@ describe("syncBatch", () => {
114
114
  observer.dispose();
115
115
  });
116
116
  it("should deduplicate notifications for the same observer", () => {
117
- const state = createState({ count: 0, name: "Alice" });
117
+ const state = useState({ count: 0, name: "Alice" });
118
118
  let notifyCount = 0;
119
119
  const observer = new Observer(() => {
120
120
  notifyCount++;
@@ -135,7 +135,7 @@ describe("syncBatch", () => {
135
135
  });
136
136
  describe("queue (async batching)", () => {
137
137
  it("should queue updates and flush on microtask", async () => {
138
- const state = createState({ count: 0 });
138
+ const state = useState({ count: 0 });
139
139
  let notifyCount = 0;
140
140
  const observer = new Observer(() => {
141
141
  notifyCount++;
@@ -157,7 +157,7 @@ describe("queue (async batching)", () => {
157
157
  observer.dispose();
158
158
  });
159
159
  it("should batch multiple async updates into one notification", async () => {
160
- const state = createState({ count: 0, name: "Alice" });
160
+ const state = useState({ count: 0, name: "Alice" });
161
161
  let notifyCount = 0;
162
162
  const observer = new Observer(() => {
163
163
  notifyCount++;
@@ -175,7 +175,7 @@ describe("queue (async batching)", () => {
175
175
  observer.dispose();
176
176
  });
177
177
  it("should handle separate async batches", async () => {
178
- const state = createState({ count: 0 });
178
+ const state = useState({ count: 0 });
179
179
  let notifyCount = 0;
180
180
  const observer = new Observer(() => {
181
181
  notifyCount++;
@@ -196,7 +196,7 @@ describe("queue (async batching)", () => {
196
196
  });
197
197
  describe("syncBatch with nested async updates", () => {
198
198
  it("should handle syncBatch inside async context", async () => {
199
- const state = createState({ count: 0 });
199
+ const state = useState({ count: 0 });
200
200
  let notifyCount = 0;
201
201
  const observer = new Observer(() => {
202
202
  notifyCount++;
@@ -217,7 +217,7 @@ describe("syncBatch with nested async updates", () => {
217
217
  observer.dispose();
218
218
  });
219
219
  it("should handle async updates inside syncBatch callback", async () => {
220
- const state = createState({ count: 0 });
220
+ const state = useState({ count: 0 });
221
221
  let notifyCount = 0;
222
222
  const observer = new Observer(() => {
223
223
  notifyCount++;
@@ -242,3 +242,193 @@ describe("syncBatch with nested async updates", () => {
242
242
  observer.dispose();
243
243
  });
244
244
  });
245
+ describe("syncBatch with cascading updates", () => {
246
+ it("should handle cascading observer notifications within the same batch", () => {
247
+ const state = useState({ count: 0 });
248
+ const derived = useState({ doubled: 0 });
249
+ let stateNotifyCount = 0;
250
+ let derivedNotifyCount = 0;
251
+ let componentNotifyCount = 0;
252
+ // Observer 1: Watches state, updates derived (simulates useDerived)
253
+ const derivedObserver = new Observer(() => {
254
+ stateNotifyCount++;
255
+ // When state changes, update derived synchronously
256
+ derived.doubled = state.count * 2;
257
+ });
258
+ const dispose1 = derivedObserver.observe();
259
+ state.count; // Track state
260
+ dispose1();
261
+ // Observer 2: Watches derived (simulates component)
262
+ const componentObserver = new Observer(() => {
263
+ derivedNotifyCount++;
264
+ });
265
+ const dispose2 = componentObserver.observe();
266
+ derived.doubled; // Track derived
267
+ dispose2();
268
+ // Observer 3: Also watches derived (another component)
269
+ const component2Observer = new Observer(() => {
270
+ componentNotifyCount++;
271
+ });
272
+ const dispose3 = component2Observer.observe();
273
+ derived.doubled; // Track derived
274
+ dispose3();
275
+ // Make a change in a batch
276
+ syncBatch(() => {
277
+ state.count = 5;
278
+ });
279
+ // All observers should have been notified exactly once
280
+ expect(stateNotifyCount).toBe(1);
281
+ expect(derivedNotifyCount).toBe(1);
282
+ expect(componentNotifyCount).toBe(1);
283
+ expect(state.count).toBe(5);
284
+ expect(derived.doubled).toBe(10);
285
+ derivedObserver.dispose();
286
+ componentObserver.dispose();
287
+ component2Observer.dispose();
288
+ });
289
+ it("should handle multi-level cascading updates", () => {
290
+ const state = useState({ value: 0 });
291
+ const derived1 = useState({ level1: 0 });
292
+ const derived2 = useState({ level2: 0 });
293
+ const derived3 = useState({ level3: 0 });
294
+ const notifyCounts = [0, 0, 0, 0];
295
+ // Level 1: state -> derived1
296
+ const observer1 = new Observer(() => {
297
+ notifyCounts[0]++;
298
+ derived1.level1 = state.value + 1;
299
+ });
300
+ const dispose1 = observer1.observe();
301
+ state.value;
302
+ dispose1();
303
+ // Level 2: derived1 -> derived2
304
+ const observer2 = new Observer(() => {
305
+ notifyCounts[1]++;
306
+ derived2.level2 = derived1.level1 + 1;
307
+ });
308
+ const dispose2 = observer2.observe();
309
+ derived1.level1;
310
+ dispose2();
311
+ // Level 3: derived2 -> derived3
312
+ const observer3 = new Observer(() => {
313
+ notifyCounts[2]++;
314
+ derived3.level3 = derived2.level2 + 1;
315
+ });
316
+ const dispose3 = observer3.observe();
317
+ derived2.level2;
318
+ dispose3();
319
+ // Final observer: watches derived3
320
+ const observer4 = new Observer(() => {
321
+ notifyCounts[3]++;
322
+ });
323
+ const dispose4 = observer4.observe();
324
+ derived3.level3;
325
+ dispose4();
326
+ // Update state in a batch
327
+ syncBatch(() => {
328
+ state.value = 10;
329
+ });
330
+ // All levels should have cascaded and each observer notified exactly once
331
+ expect(notifyCounts).toEqual([1, 1, 1, 1]);
332
+ expect(state.value).toBe(10);
333
+ expect(derived1.level1).toBe(11);
334
+ expect(derived2.level2).toBe(12);
335
+ expect(derived3.level3).toBe(13);
336
+ observer1.dispose();
337
+ observer2.dispose();
338
+ observer3.dispose();
339
+ observer4.dispose();
340
+ });
341
+ it("should handle diamond dependency pattern", () => {
342
+ // Diamond: state -> [derived1, derived2] -> derived3
343
+ const state = useState({ value: 0 });
344
+ const derived1 = useState({ path1: 0 });
345
+ const derived2 = useState({ path2: 0 });
346
+ const derived3 = useState({ combined: 0 });
347
+ let derived3NotifyCount = 0;
348
+ // State -> derived1
349
+ const obs1 = new Observer(() => {
350
+ derived1.path1 = state.value * 2;
351
+ });
352
+ const d1 = obs1.observe();
353
+ state.value;
354
+ d1();
355
+ // State -> derived2
356
+ const obs2 = new Observer(() => {
357
+ derived2.path2 = state.value * 3;
358
+ });
359
+ const d2 = obs2.observe();
360
+ state.value;
361
+ d2();
362
+ // [derived1, derived2] -> derived3
363
+ const obs3 = new Observer(() => {
364
+ derived3.combined = derived1.path1 + derived2.path2;
365
+ });
366
+ const d3 = obs3.observe();
367
+ derived1.path1;
368
+ derived2.path2;
369
+ d3();
370
+ // Watch derived3
371
+ const obs4 = new Observer(() => {
372
+ derived3NotifyCount++;
373
+ });
374
+ const d4 = obs4.observe();
375
+ derived3.combined;
376
+ d4();
377
+ syncBatch(() => {
378
+ state.value = 5;
379
+ });
380
+ // derived3 should only be notified once despite two paths updating
381
+ expect(derived3NotifyCount).toBe(1);
382
+ expect(derived1.path1).toBe(10);
383
+ expect(derived2.path2).toBe(15);
384
+ expect(derived3.combined).toBe(25);
385
+ obs1.dispose();
386
+ obs2.dispose();
387
+ obs3.dispose();
388
+ obs4.dispose();
389
+ });
390
+ it("should not create infinite loops with circular dependencies", () => {
391
+ const state1 = useState({ value: 0 });
392
+ const state2 = useState({ value: 0 });
393
+ let notify1Count = 0;
394
+ let notify2Count = 0;
395
+ // Observer 1: watches state1, updates state2
396
+ const obs1 = new Observer(() => {
397
+ notify1Count++;
398
+ if (notify1Count > 10) {
399
+ throw new Error("Infinite loop detected");
400
+ }
401
+ // Only update if different to break the cycle
402
+ if (state2.value !== state1.value + 1) {
403
+ state2.value = state1.value + 1;
404
+ }
405
+ });
406
+ const d1 = obs1.observe();
407
+ state1.value;
408
+ d1();
409
+ // Observer 2: watches state2, updates state1
410
+ const obs2 = new Observer(() => {
411
+ notify2Count++;
412
+ if (notify2Count > 10) {
413
+ throw new Error("Infinite loop detected");
414
+ }
415
+ // Only update if different to break the cycle
416
+ if (state1.value !== state2.value - 1) {
417
+ state1.value = state2.value - 1;
418
+ }
419
+ });
420
+ const d2 = obs2.observe();
421
+ state2.value;
422
+ d2();
423
+ syncBatch(() => {
424
+ state1.value = 5;
425
+ });
426
+ // Should stabilize without infinite loop
427
+ expect(notify1Count).toBeLessThan(10);
428
+ expect(notify2Count).toBeLessThan(10);
429
+ expect(state1.value).toBe(5);
430
+ expect(state2.value).toBe(6);
431
+ obs1.dispose();
432
+ obs2.dispose();
433
+ });
434
+ });