Skip to content

Commit 9b5b301

Browse files
committed
feat(react): refactor useQuantaStore with cached snapshots & update dependency
1 parent b844d50 commit 9b5b301

File tree

10 files changed

+857
-696
lines changed

10 files changed

+857
-696
lines changed

.changeset/dark-zoos-bet.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@quantajs/react': major
3+
'@quantajs/core': major
4+
---
5+
6+
feat(react): refactor useQuantaStore with cached snapshots
7+
8+
\- Add store-level Set<subscribers> + notifyAll in createStore
9+
10+
\- Wire flattenStore.set trap → trigger + notifyAll
11+
12+
\- Generic Dependency<S> with snapshot-aware notify()
13+
14+
\- Update StoreSubscriber to (snapshot?: S) => void
15+
16+
\- Rewrite @quantajs/react useQuantaStore:
17+
18+
&nbsp; • useRef cache + fresh flat snapshot on every core mutation
19+
20+
&nbsp; • stable actions/getters (no re-bind loops)
21+
22+
&nbsp; • selector support for fine-grained re-renders
23+
24+
&nbsp; • SSR-safe server snapshot
25+
26+
&nbsp; • full error isolation (warn, never crash on bad subscriber)
27+
28+
\- Fix React re-render staleness: UI now updates instantly
29+
30+
\- Eliminate infinite-loop warning (cached snapshot is stable until dirty)Please enter a summary for your changes.
31+
32+
An empty message aborts the editor.

package.json

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,24 @@
1313
"version:update": "pnpm changeset version && pnpm install"
1414
},
1515
"devDependencies": {
16-
"@changesets/cli": "^2.29.5",
17-
"@eslint/eslintrc": "^3.3.0",
18-
"@eslint/js": "^9.35.0",
19-
"@types/node": "^24.4.0",
20-
"@typescript-eslint/eslint-plugin": "^8.35.0",
21-
"@typescript-eslint/parser": "^8.35.0",
22-
"eslint": "^9.35.0",
16+
"@changesets/cli": "^2.29.7",
17+
"@eslint/eslintrc": "^3.3.1",
18+
"@eslint/js": "^9.39.1",
19+
"@types/node": "^24.10.0",
20+
"@typescript-eslint/eslint-plugin": "^8.46.3",
21+
"@typescript-eslint/parser": "^8.46.3",
22+
"eslint": "^9.39.1",
2323
"eslint-config-prettier": "^10.1.8",
2424
"eslint-plugin-import": "^2.32.0",
2525
"eslint-plugin-prettier": "^5.5.4",
26-
"eslint-plugin-unused-imports": "^4.1.3",
26+
"eslint-plugin-unused-imports": "^4.3.0",
2727
"prettier": "^3.6.2",
28-
"rimraf": "^6.0.1",
29-
"terser": "^5.43.1",
30-
"typescript": "^5.8.3",
31-
"vite": "^6.3.6",
28+
"rimraf": "^6.1.0",
29+
"terser": "^5.44.1",
30+
"typescript": "^5.9.3",
31+
"vite": "^7.1.12",
3232
"vite-plugin-banner": "^0.8.1",
33-
"vite-plugin-dts": "^4.0.3",
33+
"vite-plugin-dts": "^4.5.4",
3434
"vite-plugin-eslint": "^1.8.1"
3535
},
3636
"workspaces": [

packages/core/src/core/create-store.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,21 @@ const createStore = <
8989
}
9090
}
9191

92-
// Define Actions
92+
// Store-level subscribers for broad "onAnyChange" (framework-safe)
93+
const subscribers = new Set<StoreSubscriber>();
94+
95+
// Define core store object
9396
const store = {
9497
state,
9598
getters,
9699
actions: {} as A,
97100
subscribe: (callback: StoreSubscriber) => {
98101
try {
102+
subscribers.add(callback);
99103
dependency.depend(callback);
100104
return () => {
101105
try {
106+
subscribers.delete(callback);
102107
dependency.remove(callback);
103108
} catch (error) {
104109
logger.error(
@@ -113,6 +118,24 @@ const createStore = <
113118
throw error;
114119
}
115120
},
121+
notifyAll: () => {
122+
try {
123+
const snapshot = store.state; // Fresh ref for subs
124+
subscribers.forEach((cb) => {
125+
try {
126+
cb(snapshot);
127+
} catch (e) {
128+
logger.warn(
129+
`Store: Subscriber callback failed for "${name}": ${e instanceof Error ? e.message : String(e)}`,
130+
);
131+
}
132+
});
133+
} catch (error) {
134+
logger.error(
135+
`Store: notifyAll failed for "${name}": ${error instanceof Error ? error.message : String(error)}`,
136+
);
137+
}
138+
},
116139
$reset: () => {
117140
try {
118141
const initial = initialStateMap.get(store);
Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1+
import { StoreSubscriber } from 'type/store-types';
12
import { logger } from '../services/logger-service';
23

3-
export class Dependency {
4+
export class Dependency<S = any> {
45
[x: string]: any;
5-
private subscribers: Set<Function>;
6+
private subscribers: Set<StoreSubscriber<S>>;
67

78
constructor() {
89
this.subscribers = new Set();
910
}
1011

11-
depend(callback: Function | null) {
12+
depend(callback: StoreSubscriber<S> | null) {
1213
try {
1314
if (callback) {
1415
this.subscribers.add(callback);
@@ -17,38 +18,44 @@ export class Dependency {
1718
logger.error(
1819
`Dependency: Failed to add dependency: ${error instanceof Error ? error.message : String(error)}`,
1920
);
20-
throw error;
21+
// throw error;
2122
}
2223
}
2324

24-
notify() {
25+
notify(snapshot?: S): void {
2526
try {
26-
this.subscribers.forEach((subscriber) => {
27+
const activeSubs = new Set(this.subscribers);
28+
activeSubs.forEach((subscriber) => {
2729
try {
28-
subscriber();
30+
if (snapshot !== undefined) {
31+
subscriber(snapshot); // Pass fresh state if provided (framework opt-in)
32+
} else {
33+
subscriber(); // Legacy no-arg compat
34+
}
2935
} catch (error) {
30-
logger.error(
31-
`Dependency: Failed to notify subscriber: ${error instanceof Error ? error.message : String(error)}`,
36+
logger.warn(
37+
// Warn only—isolated: Don't break other subs
38+
`Dependency: Subscriber callback failed: ${error instanceof Error ? error.message : String(error)}`,
3239
);
33-
throw error;
40+
// Continue chain
3441
}
3542
});
3643
} catch (error) {
3744
logger.error(
3845
`Dependency: Failed to notify subscribers: ${error instanceof Error ? error.message : String(error)}`,
3946
);
40-
throw error;
47+
// throw error;
4148
}
4249
}
4350

44-
remove(callback: Function) {
51+
remove(callback: StoreSubscriber<S>) {
4552
try {
4653
this.subscribers.delete(callback);
4754
} catch (error) {
4855
logger.error(
4956
`Dependency: Failed to remove dependency: ${error instanceof Error ? error.message : String(error)}`,
5057
);
51-
throw error;
58+
// throw error;
5259
}
5360
}
5461

@@ -59,11 +66,11 @@ export class Dependency {
5966
logger.error(
6067
`Dependency: Failed to clear dependencies: ${error instanceof Error ? error.message : String(error)}`,
6168
);
62-
throw error;
69+
// throw error;
6370
}
6471
}
6572

6673
get getSubscribers() {
67-
return this.subscribers;
74+
return new Set(this.subscribers);
6875
}
6976
}

packages/core/src/core/effect.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import { Dependency } from './dependency';
22
import { logger } from '../services/logger-service';
3+
import { StoreSubscriber } from '../type/store-types';
34

45
const targetMap = new WeakMap<object, Record<string | symbol, Dependency>>();
5-
let activeEffect: Function | null = null;
6+
let activeEffect: StoreSubscriber | null = null;
67

78
let isBatching = false;
8-
const effectQueue = new Set<Function>();
9-
const effectStack: Function[] = [];
9+
const effectQueue = new Set<StoreSubscriber>();
10+
const effectStack: StoreSubscriber[] = [];
1011

1112
// Start and process batched effects
12-
export function batchEffects(fn: Function) {
13+
export function batchEffects(fn: StoreSubscriber) {
1314
try {
1415
isBatching = true;
1516
fn();
@@ -134,7 +135,7 @@ export function track(target: object, prop: string | symbol) {
134135
}
135136

136137
// Reactive effect to handle reactivity with comprehensive error handling
137-
export function reactiveEffect(effect: Function) {
138+
export function reactiveEffect(effect: StoreSubscriber) {
138139
const wrappedEffect = () => {
139140
if (effectStack.includes(effect)) {
140141
const errorMessage = `Circular dependency detected: Effect "${

packages/core/src/type/store-types.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ export type ActionDefinition<S extends object, G extends object, A> = {
1010
[key in keyof A]: (this: StoreInstance<S, G, A>, ...args: any[]) => any;
1111
} & ThisType<S & { [K in keyof G]: G[K] } & A>;
1212

13-
export type StoreSubscriber = () => void;
13+
export type StoreSubscriber<S = any> = (snapshot?: S) => void;
1414

1515
export interface Store<S, G, A> {
1616
state: S;
1717
getters: G;
1818
actions: A;
19-
subscribe: (callback: StoreSubscriber) => () => void;
19+
subscribe: (callback: StoreSubscriber<S>) => () => void;
2020
$reset: () => void;
2121
$persist?: PersistenceManager;
2222
}
@@ -30,6 +30,9 @@ export type StoreInstance<
3030
G extends Record<string, any>,
3131
A,
3232
> = S & { [K in keyof G]: G[K] } & A & {
33+
state: S;
34+
getters: G;
35+
actions: A;
3336
subscribe: (callback: StoreSubscriber) => () => void;
3437
$reset: () => void;
3538
$persist?: PersistenceManager;

packages/core/src/utils/flattenStore.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { StoreInstance } from '../type/store-types';
1+
import { StoreInstance, StoreSubscriber } from '../type/store-types';
22
import { logger } from '../services/logger-service';
3+
import { trigger } from '../core/effect';
34

45
export const flattenStore = <
56
S extends object,
@@ -9,6 +10,8 @@ export const flattenStore = <
910
state: S;
1011
getters: G;
1112
actions: A;
13+
subscribe?: (cb: StoreSubscriber) => () => void;
14+
notifyAll?: () => void;
1215
}): StoreInstance<S, G, A> => {
1316
try {
1417
const flattenedProxy = new Proxy(store, {
@@ -48,13 +51,17 @@ export const flattenStore = <
4851
},
4952
set(target, prop: string, value, receiver) {
5053
try {
51-
// If the property exists in state, update it there
52-
if (prop in target.state) {
53-
return Reflect.set(target.state, prop, value);
54+
const wasInState = prop in target.state;
55+
const result = wasInState
56+
? Reflect.set(target.state, prop, value) // Mutate reactive state
57+
: Reflect.set(target, prop, value, receiver); // Fallback
58+
if (result && wasInState) {
59+
// Trigger core reactivity (per-key)
60+
trigger(target.state, prop);
61+
// Broad notify via notifyAll (global subs for frameworks)
62+
target.notifyAll?.();
5463
}
55-
56-
// Otherwise, fallback to setting the property on the target
57-
return Reflect.set(target, prop, value, receiver);
64+
return result;
5865
} catch (error) {
5966
logger.error(
6067
`FlattenStore: Failed to set property "${prop}": ${error instanceof Error ? error.message : String(error)}`,

packages/core/tsconfig.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
"target": "ES2020",
55
"lib": ["ES2020", "DOM", "DOM.Iterable"],
66
"moduleResolution": "bundler",
7-
"allowImportingTsExtensions": true,
87
"noFallthroughCasesInSwitch": true,
98
"composite": true,
109
"declaration": true,

0 commit comments

Comments
 (0)