@hypen-space/core 0.2.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.
- package/dist/chunk-5va59f7m.js +22 -0
- package/dist/chunk-5va59f7m.js.map +9 -0
- package/dist/engine.d.ts +101 -0
- package/dist/events.d.ts +78 -0
- package/dist/index.browser.d.ts +13 -0
- package/dist/index.d.ts +33 -0
- package/dist/remote/index.d.ts +6 -0
- package/dist/router.d.ts +93 -0
- package/dist/src/app.js +160 -0
- package/dist/src/app.js.map +10 -0
- package/dist/src/context.js +114 -0
- package/dist/src/context.js.map +10 -0
- package/dist/src/engine.browser.js +130 -0
- package/dist/src/engine.browser.js.map +10 -0
- package/dist/src/engine.js +101 -0
- package/dist/src/engine.js.map +10 -0
- package/dist/src/events.js +72 -0
- package/dist/src/events.js.map +10 -0
- package/dist/src/index.browser.js +51 -0
- package/dist/src/index.browser.js.map +9 -0
- package/dist/src/index.js +55 -0
- package/dist/src/index.js.map +9 -0
- package/dist/src/remote/client.js +176 -0
- package/dist/src/remote/client.js.map +10 -0
- package/dist/src/remote/index.js +9 -0
- package/dist/src/remote/index.js.map +9 -0
- package/dist/src/remote/types.js +2 -0
- package/dist/src/remote/types.js.map +9 -0
- package/dist/src/renderer.js +58 -0
- package/dist/src/renderer.js.map +10 -0
- package/dist/src/router.js +189 -0
- package/dist/src/router.js.map +10 -0
- package/dist/src/state.js +226 -0
- package/dist/src/state.js.map +10 -0
- package/dist/state.d.ts +30 -0
- package/package.json +124 -0
- package/src/app.ts +330 -0
- package/src/context.ts +201 -0
- package/src/engine.browser.ts +245 -0
- package/src/engine.ts +208 -0
- package/src/events.ts +126 -0
- package/src/index.browser.ts +104 -0
- package/src/index.ts +126 -0
- package/src/remote/client.ts +274 -0
- package/src/remote/index.ts +17 -0
- package/src/remote/types.ts +51 -0
- package/src/renderer.ts +102 -0
- package/src/router.ts +311 -0
- package/src/state.ts +363 -0
package/src/router.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hypen Router - Declarative routing system
|
|
3
|
+
* Integrated with Hypen's reactive state management
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createObservableState, getStateSnapshot } from "./state.js";
|
|
7
|
+
|
|
8
|
+
export type RouteMatch = {
|
|
9
|
+
params: Record<string, string>;
|
|
10
|
+
query: Record<string, string>;
|
|
11
|
+
path: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type RouteState = {
|
|
15
|
+
currentPath: string;
|
|
16
|
+
params: Record<string, string>;
|
|
17
|
+
query: Record<string, string>;
|
|
18
|
+
previousPath: string | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type RouteChangeCallback = (route: RouteState) => void;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Hypen Router - Manages application routing with pattern matching
|
|
25
|
+
*/
|
|
26
|
+
export class HypenRouter {
|
|
27
|
+
private state: RouteState;
|
|
28
|
+
private subscribers = new Set<RouteChangeCallback>();
|
|
29
|
+
private isInitialized = false;
|
|
30
|
+
|
|
31
|
+
constructor() {
|
|
32
|
+
// Create observable state for reactivity
|
|
33
|
+
this.state = createObservableState<RouteState>(
|
|
34
|
+
{
|
|
35
|
+
currentPath: "/",
|
|
36
|
+
params: {},
|
|
37
|
+
query: {},
|
|
38
|
+
previousPath: null,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
onChange: () => {
|
|
42
|
+
this.notifySubscribers();
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Initialize from browser if available
|
|
48
|
+
if (typeof window !== "undefined") {
|
|
49
|
+
this.initializeBrowserSync();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Initialize browser history sync
|
|
55
|
+
*/
|
|
56
|
+
private initializeBrowserSync() {
|
|
57
|
+
// Get initial path from hash or pathname
|
|
58
|
+
const initialPath = this.getPathFromBrowser();
|
|
59
|
+
this.state.currentPath = initialPath;
|
|
60
|
+
this.state.params = {};
|
|
61
|
+
this.state.query = this.parseQuery();
|
|
62
|
+
|
|
63
|
+
// Listen for browser back/forward
|
|
64
|
+
window.addEventListener("popstate", () => {
|
|
65
|
+
const newPath = this.getPathFromBrowser();
|
|
66
|
+
this.updatePath(newPath, false); // Don't push to history again
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Listen for hash changes
|
|
70
|
+
window.addEventListener("hashchange", () => {
|
|
71
|
+
const newPath = this.getPathFromBrowser();
|
|
72
|
+
this.updatePath(newPath, false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
this.isInitialized = true;
|
|
76
|
+
console.log("Router initialized at:", initialPath);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get path from browser URL (supports both hash and pathname)
|
|
81
|
+
*/
|
|
82
|
+
private getPathFromBrowser(): string {
|
|
83
|
+
if (typeof window === "undefined") return "/";
|
|
84
|
+
|
|
85
|
+
// Prefer hash-based routing for simplicity
|
|
86
|
+
const hash = window.location.hash.slice(1);
|
|
87
|
+
if (hash) return hash;
|
|
88
|
+
|
|
89
|
+
// Fallback to pathname
|
|
90
|
+
return window.location.pathname;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse query string from URL
|
|
95
|
+
*/
|
|
96
|
+
private parseQuery(): Record<string, string> {
|
|
97
|
+
if (typeof window === "undefined") return {};
|
|
98
|
+
|
|
99
|
+
const query: Record<string, string> = {};
|
|
100
|
+
const searchParams = new URLSearchParams(window.location.search);
|
|
101
|
+
|
|
102
|
+
searchParams.forEach((value, key) => {
|
|
103
|
+
query[key] = value;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return query;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Navigate to a new path
|
|
111
|
+
*/
|
|
112
|
+
push(path: string) {
|
|
113
|
+
console.log("Router.push:", path);
|
|
114
|
+
this.updatePath(path, true);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Replace current path without adding to history
|
|
119
|
+
*/
|
|
120
|
+
replace(path: string) {
|
|
121
|
+
console.log("Router.replace:", path);
|
|
122
|
+
this.updatePath(path, true, true);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Go back in history
|
|
127
|
+
*/
|
|
128
|
+
back() {
|
|
129
|
+
console.log("Router.back");
|
|
130
|
+
if (typeof window !== "undefined") {
|
|
131
|
+
window.history.back();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Go forward in history
|
|
137
|
+
*/
|
|
138
|
+
forward() {
|
|
139
|
+
console.log("Router.forward");
|
|
140
|
+
if (typeof window !== "undefined") {
|
|
141
|
+
window.history.forward();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Update the current path
|
|
147
|
+
*/
|
|
148
|
+
private updatePath(
|
|
149
|
+
path: string,
|
|
150
|
+
updateBrowser: boolean,
|
|
151
|
+
replace: boolean = false
|
|
152
|
+
) {
|
|
153
|
+
const oldPath = this.state.currentPath;
|
|
154
|
+
this.state.previousPath = oldPath;
|
|
155
|
+
this.state.currentPath = path;
|
|
156
|
+
this.state.query = this.parseQuery();
|
|
157
|
+
|
|
158
|
+
// Update browser URL if needed
|
|
159
|
+
if (updateBrowser && typeof window !== "undefined") {
|
|
160
|
+
const url = "#" + path;
|
|
161
|
+
if (replace) {
|
|
162
|
+
window.history.replaceState(null, "", url);
|
|
163
|
+
} else {
|
|
164
|
+
window.history.pushState(null, "", url);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Manually trigger hashchange event
|
|
168
|
+
const hashChangeEvent = new HashChangeEvent("hashchange", {
|
|
169
|
+
oldURL: window.location.href.replace(window.location.hash, "#" + oldPath),
|
|
170
|
+
newURL: window.location.href,
|
|
171
|
+
});
|
|
172
|
+
window.dispatchEvent(hashChangeEvent);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Get current path
|
|
178
|
+
*/
|
|
179
|
+
getCurrentPath(): string {
|
|
180
|
+
return this.state.currentPath;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Get current route params
|
|
185
|
+
*/
|
|
186
|
+
getParams(): Record<string, string> {
|
|
187
|
+
return { ...this.state.params };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get current query params
|
|
192
|
+
*/
|
|
193
|
+
getQuery(): Record<string, string> {
|
|
194
|
+
return { ...this.state.query };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get full route state snapshot
|
|
199
|
+
*/
|
|
200
|
+
getState(): RouteState {
|
|
201
|
+
return getStateSnapshot(this.state);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Match a pattern against a path
|
|
206
|
+
*/
|
|
207
|
+
matchPath(pattern: string, path: string): RouteMatch | null {
|
|
208
|
+
// Handle invalid inputs gracefully
|
|
209
|
+
if (!pattern || typeof pattern !== 'string') {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
if (!path || typeof path !== 'string') {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Exact match
|
|
217
|
+
if (pattern === path) {
|
|
218
|
+
return {
|
|
219
|
+
params: {},
|
|
220
|
+
query: this.state.query,
|
|
221
|
+
path,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Wildcard match: /dashboard/* matches /dashboard/anything
|
|
226
|
+
if (pattern.endsWith("/*")) {
|
|
227
|
+
const prefix = pattern.slice(0, -2);
|
|
228
|
+
if (path === prefix || path.startsWith(prefix + "/")) {
|
|
229
|
+
return {
|
|
230
|
+
params: {},
|
|
231
|
+
query: this.state.query,
|
|
232
|
+
path,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Parameter match: /users/:id matches /users/123
|
|
239
|
+
const paramNames: string[] = [];
|
|
240
|
+
const regexPattern = pattern
|
|
241
|
+
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
|
|
242
|
+
paramNames.push(name);
|
|
243
|
+
return "([^/]+)";
|
|
244
|
+
})
|
|
245
|
+
.replace(/\*/g, ".*");
|
|
246
|
+
|
|
247
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
248
|
+
const match = path.match(regex);
|
|
249
|
+
|
|
250
|
+
if (!match) return null;
|
|
251
|
+
|
|
252
|
+
// Extract params
|
|
253
|
+
const params: Record<string, string> = {};
|
|
254
|
+
paramNames.forEach((name, i) => {
|
|
255
|
+
const value = match[i + 1];
|
|
256
|
+
if (value !== undefined) {
|
|
257
|
+
params[name] = decodeURIComponent(value);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
params,
|
|
263
|
+
query: this.state.query,
|
|
264
|
+
path,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Subscribe to route changes
|
|
270
|
+
*/
|
|
271
|
+
onNavigate(callback: RouteChangeCallback): () => void {
|
|
272
|
+
this.subscribers.add(callback);
|
|
273
|
+
|
|
274
|
+
// Call immediately with current state
|
|
275
|
+
callback(this.getState());
|
|
276
|
+
|
|
277
|
+
// Return unsubscribe function
|
|
278
|
+
return () => {
|
|
279
|
+
this.subscribers.delete(callback);
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Notify all subscribers of route change
|
|
285
|
+
*/
|
|
286
|
+
private notifySubscribers() {
|
|
287
|
+
const routeState = this.getState();
|
|
288
|
+
this.subscribers.forEach((callback) => {
|
|
289
|
+
callback(routeState);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Check if a path matches the current route
|
|
295
|
+
*/
|
|
296
|
+
isActive(pattern: string): boolean {
|
|
297
|
+
return this.matchPath(pattern, this.state.currentPath) !== null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Get a URL with query params
|
|
302
|
+
*/
|
|
303
|
+
buildUrl(path: string, query?: Record<string, string>): string {
|
|
304
|
+
if (!query || Object.keys(query).length === 0) {
|
|
305
|
+
return path;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const queryString = new URLSearchParams(query).toString();
|
|
309
|
+
return `${path}?${queryString}`;
|
|
310
|
+
}
|
|
311
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State management with observer pattern and diffing
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type StatePath = string; // e.g., "user.name", "items.0.title"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Represents a change in state with full path information
|
|
9
|
+
*/
|
|
10
|
+
export interface StateChange {
|
|
11
|
+
paths: StatePath[];
|
|
12
|
+
newValues: Record<StatePath, any>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Options for state observer
|
|
17
|
+
*/
|
|
18
|
+
export interface StateObserverOptions {
|
|
19
|
+
onChange: (change: StateChange) => void;
|
|
20
|
+
pathPrefix?: string; // For nested observers
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Deep clone an object, handling circular references
|
|
25
|
+
*/
|
|
26
|
+
function deepClone<T>(obj: T): T {
|
|
27
|
+
// Handle primitives and null
|
|
28
|
+
if (obj === null || typeof obj !== 'object') {
|
|
29
|
+
return obj;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Use a WeakMap to track visited objects and handle circular references
|
|
33
|
+
const visited = new WeakMap();
|
|
34
|
+
|
|
35
|
+
function cloneInternal(value: any): any {
|
|
36
|
+
// Handle primitives and null
|
|
37
|
+
if (value === null || typeof value !== 'object') {
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check if we've already cloned this object (circular reference)
|
|
42
|
+
if (visited.has(value)) {
|
|
43
|
+
return visited.get(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Handle Date
|
|
47
|
+
if (value instanceof Date) {
|
|
48
|
+
return new Date(value.getTime());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Handle RegExp
|
|
52
|
+
if (value instanceof RegExp) {
|
|
53
|
+
return new RegExp(value.source, value.flags);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Handle Map
|
|
57
|
+
if (value instanceof Map) {
|
|
58
|
+
const mapClone = new Map();
|
|
59
|
+
visited.set(value, mapClone);
|
|
60
|
+
for (const [k, v] of value.entries()) {
|
|
61
|
+
mapClone.set(cloneInternal(k), cloneInternal(v));
|
|
62
|
+
}
|
|
63
|
+
return mapClone;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Handle Set
|
|
67
|
+
if (value instanceof Set) {
|
|
68
|
+
const setClone = new Set();
|
|
69
|
+
visited.set(value, setClone);
|
|
70
|
+
for (const item of value.values()) {
|
|
71
|
+
setClone.add(cloneInternal(item));
|
|
72
|
+
}
|
|
73
|
+
return setClone;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Handle WeakMap/WeakSet - cannot be cloned, return as-is
|
|
77
|
+
if (value instanceof WeakMap || value instanceof WeakSet) {
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Handle Array
|
|
82
|
+
if (Array.isArray(value)) {
|
|
83
|
+
const arrClone: any[] = [];
|
|
84
|
+
visited.set(value, arrClone);
|
|
85
|
+
for (let i = 0; i < value.length; i++) {
|
|
86
|
+
arrClone[i] = cloneInternal(value[i]);
|
|
87
|
+
}
|
|
88
|
+
return arrClone;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Handle plain objects
|
|
92
|
+
const objClone: any = {};
|
|
93
|
+
visited.set(value, objClone);
|
|
94
|
+
for (const key in value) {
|
|
95
|
+
if (value.hasOwnProperty(key)) {
|
|
96
|
+
objClone[key] = cloneInternal(value[key]);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return objClone;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return cloneInternal(obj);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Compare two values and detect changes with full paths
|
|
107
|
+
*/
|
|
108
|
+
function diffState(
|
|
109
|
+
oldState: any,
|
|
110
|
+
newState: any,
|
|
111
|
+
basePath: string = ""
|
|
112
|
+
): StateChange {
|
|
113
|
+
const paths: StatePath[] = [];
|
|
114
|
+
const newValues: Record<StatePath, any> = {};
|
|
115
|
+
|
|
116
|
+
function diff(oldVal: any, newVal: any, path: string) {
|
|
117
|
+
// Handle null/undefined
|
|
118
|
+
if (oldVal === newVal) return;
|
|
119
|
+
|
|
120
|
+
// Different types or primitives
|
|
121
|
+
if (
|
|
122
|
+
typeof oldVal !== "object" ||
|
|
123
|
+
typeof newVal !== "object" ||
|
|
124
|
+
oldVal === null ||
|
|
125
|
+
newVal === null
|
|
126
|
+
) {
|
|
127
|
+
if (oldVal !== newVal) {
|
|
128
|
+
paths.push(path);
|
|
129
|
+
newValues[path] = newVal;
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Arrays
|
|
135
|
+
if (Array.isArray(oldVal) || Array.isArray(newVal)) {
|
|
136
|
+
if (
|
|
137
|
+
!Array.isArray(oldVal) ||
|
|
138
|
+
!Array.isArray(newVal) ||
|
|
139
|
+
oldVal.length !== newVal.length
|
|
140
|
+
) {
|
|
141
|
+
paths.push(path);
|
|
142
|
+
newValues[path] = newVal;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (let i = 0; i < newVal.length; i++) {
|
|
147
|
+
const itemPath = path ? `${path}.${i}` : `${i}`;
|
|
148
|
+
diff(oldVal[i], newVal[i], itemPath);
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Objects
|
|
154
|
+
const oldKeys = new Set(Object.keys(oldVal));
|
|
155
|
+
const newKeys = new Set(Object.keys(newVal));
|
|
156
|
+
|
|
157
|
+
// Check for added or changed keys
|
|
158
|
+
for (const key of newKeys) {
|
|
159
|
+
const propPath = path ? `${path}.${key}` : key;
|
|
160
|
+
if (!oldKeys.has(key)) {
|
|
161
|
+
// New property
|
|
162
|
+
paths.push(propPath);
|
|
163
|
+
newValues[propPath] = newVal[key];
|
|
164
|
+
} else {
|
|
165
|
+
// Existing property, recurse
|
|
166
|
+
diff(oldVal[key], newVal[key], propPath);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Check for removed keys
|
|
171
|
+
for (const key of oldKeys) {
|
|
172
|
+
if (!newKeys.has(key)) {
|
|
173
|
+
const propPath = path ? `${path}.${key}` : key;
|
|
174
|
+
paths.push(propPath);
|
|
175
|
+
newValues[propPath] = undefined;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
diff(oldState, newState, basePath);
|
|
181
|
+
return { paths, newValues };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Create an observable state object that tracks changes
|
|
186
|
+
*/
|
|
187
|
+
export function createObservableState<T extends object>(
|
|
188
|
+
initialState: T,
|
|
189
|
+
options?: StateObserverOptions
|
|
190
|
+
): T {
|
|
191
|
+
// Use default options if not provided
|
|
192
|
+
const opts: StateObserverOptions = options || { onChange: () => {} };
|
|
193
|
+
|
|
194
|
+
// Detect and reject primitive wrapper objects (Number, String, Boolean)
|
|
195
|
+
if (
|
|
196
|
+
initialState instanceof Number ||
|
|
197
|
+
initialState instanceof String ||
|
|
198
|
+
initialState instanceof Boolean
|
|
199
|
+
) {
|
|
200
|
+
throw new TypeError(
|
|
201
|
+
"Cannot create observable state from primitive wrapper objects (Number, String, Boolean). " +
|
|
202
|
+
"Use plain primitives or regular objects instead."
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Keep a snapshot of the last known state
|
|
207
|
+
let lastSnapshot = deepClone(initialState);
|
|
208
|
+
const pathPrefix = opts.pathPrefix || "";
|
|
209
|
+
|
|
210
|
+
// Track if we're in a batch update
|
|
211
|
+
let batchDepth = 0;
|
|
212
|
+
let pendingChange: StateChange | null = null;
|
|
213
|
+
|
|
214
|
+
function notifyChange() {
|
|
215
|
+
if (batchDepth > 0) return;
|
|
216
|
+
|
|
217
|
+
// Compare current state with last snapshot
|
|
218
|
+
const change = diffState(lastSnapshot, state, pathPrefix);
|
|
219
|
+
|
|
220
|
+
if (change.paths.length > 0) {
|
|
221
|
+
// Update snapshot
|
|
222
|
+
lastSnapshot = deepClone(state);
|
|
223
|
+
|
|
224
|
+
// Merge with pending changes if any
|
|
225
|
+
if (pendingChange) {
|
|
226
|
+
change.paths.push(...pendingChange.paths);
|
|
227
|
+
Object.assign(change.newValues, pendingChange.newValues);
|
|
228
|
+
pendingChange = null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Notify
|
|
232
|
+
opts.onChange(change);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Track if we have a pending microtask notification
|
|
237
|
+
let notificationPending = false;
|
|
238
|
+
|
|
239
|
+
function scheduleBatch() {
|
|
240
|
+
if (batchDepth === 0) {
|
|
241
|
+
// If not in a batch, schedule notification in next microtask to coalesce rapid changes
|
|
242
|
+
if (!notificationPending) {
|
|
243
|
+
notificationPending = true;
|
|
244
|
+
queueMicrotask(() => {
|
|
245
|
+
notificationPending = false;
|
|
246
|
+
if (batchDepth === 0) {
|
|
247
|
+
notifyChange();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Cache proxies to handle circular references
|
|
255
|
+
const proxyCache = new WeakMap<any, any>();
|
|
256
|
+
|
|
257
|
+
function createProxy(target: any, basePath: string): any {
|
|
258
|
+
// Return cached proxy if it exists (handles circular references)
|
|
259
|
+
if (proxyCache.has(target)) {
|
|
260
|
+
return proxyCache.get(target);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const proxy = new Proxy(target, {
|
|
264
|
+
get(obj, prop) {
|
|
265
|
+
const value = obj[prop];
|
|
266
|
+
|
|
267
|
+
// Expose batch control methods
|
|
268
|
+
if (prop === "__beginBatch") {
|
|
269
|
+
return () => {
|
|
270
|
+
batchDepth++;
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
if (prop === "__endBatch") {
|
|
274
|
+
return () => {
|
|
275
|
+
batchDepth--;
|
|
276
|
+
if (batchDepth === 0) {
|
|
277
|
+
notifyChange();
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (prop === "__getSnapshot") {
|
|
282
|
+
return () => deepClone(obj);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Return proxied nested objects/arrays, but NOT special types
|
|
286
|
+
if (value && typeof value === "object") {
|
|
287
|
+
// Check for special object types that should not be proxied
|
|
288
|
+
if (
|
|
289
|
+
value instanceof Date ||
|
|
290
|
+
value instanceof RegExp ||
|
|
291
|
+
value instanceof Map ||
|
|
292
|
+
value instanceof Set ||
|
|
293
|
+
value instanceof WeakMap ||
|
|
294
|
+
value instanceof WeakSet
|
|
295
|
+
) {
|
|
296
|
+
return value;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Proxy regular objects and arrays
|
|
300
|
+
return createProxy(value, basePath ? `${basePath}.${String(prop)}` : String(prop));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return value;
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
set(obj, prop, value) {
|
|
307
|
+
const oldValue = obj[prop];
|
|
308
|
+
|
|
309
|
+
// Set the new value
|
|
310
|
+
obj[prop] = value;
|
|
311
|
+
|
|
312
|
+
if (oldValue !== value) {
|
|
313
|
+
scheduleBatch();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return true;
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
deleteProperty(obj, prop) {
|
|
320
|
+
if (prop in obj) {
|
|
321
|
+
delete obj[prop];
|
|
322
|
+
scheduleBatch();
|
|
323
|
+
}
|
|
324
|
+
return true;
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Cache the proxy before returning
|
|
329
|
+
proxyCache.set(target, proxy);
|
|
330
|
+
return proxy;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const state = createProxy(initialState, pathPrefix);
|
|
334
|
+
return state as T;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Helper to batch multiple state updates
|
|
339
|
+
*/
|
|
340
|
+
export function batchStateUpdates<T>(state: T, fn: () => void): void {
|
|
341
|
+
const s = state as any;
|
|
342
|
+
if (s.__beginBatch && s.__endBatch) {
|
|
343
|
+
s.__beginBatch();
|
|
344
|
+
try {
|
|
345
|
+
fn();
|
|
346
|
+
} finally {
|
|
347
|
+
s.__endBatch();
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
fn();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Get a snapshot of the current state
|
|
356
|
+
*/
|
|
357
|
+
export function getStateSnapshot<T>(state: T): T {
|
|
358
|
+
const s = state as any;
|
|
359
|
+
if (s.__getSnapshot) {
|
|
360
|
+
return s.__getSnapshot();
|
|
361
|
+
}
|
|
362
|
+
return deepClone(state);
|
|
363
|
+
}
|