pulse-js-framework 1.4.9 → 1.5.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/cli/analyze.js +8 -7
- package/cli/build.js +14 -13
- package/cli/dev.js +28 -7
- package/cli/format.js +13 -12
- package/cli/index.js +20 -1
- package/cli/lint.js +8 -7
- package/cli/release.js +493 -0
- package/compiler/parser.js +41 -20
- package/compiler/transformer/constants.js +54 -0
- package/compiler/transformer/export.js +33 -0
- package/compiler/transformer/expressions.js +273 -0
- package/compiler/transformer/imports.js +101 -0
- package/compiler/transformer/index.js +319 -0
- package/compiler/transformer/router.js +95 -0
- package/compiler/transformer/state.js +118 -0
- package/compiler/transformer/store.js +97 -0
- package/compiler/transformer/style.js +130 -0
- package/compiler/transformer/view.js +428 -0
- package/compiler/transformer.js +17 -1310
- package/core/errors.js +300 -0
- package/package.json +8 -5
- package/runtime/dom.js +61 -10
- package/runtime/lru-cache.js +145 -0
- package/runtime/native.js +6 -1
- package/runtime/pulse.js +46 -2
- package/runtime/router.js +4 -1
- package/runtime/store.js +35 -1
- package/runtime/utils.js +348 -0
- package/types/index.d.ts +19 -0
- package/types/lru-cache.d.ts +118 -0
- package/types/utils.d.ts +255 -0
package/core/errors.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse Error Classes - Structured error handling for the framework
|
|
3
|
+
* @module pulse-js-framework/core/errors
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Base error class for all Pulse framework errors.
|
|
8
|
+
* Provides source location tracking and code snippet formatting.
|
|
9
|
+
*/
|
|
10
|
+
export class PulseError extends Error {
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} message - Error message
|
|
13
|
+
* @param {Object} [options={}] - Error options
|
|
14
|
+
* @param {number} [options.line] - Line number where error occurred
|
|
15
|
+
* @param {number} [options.column] - Column number where error occurred
|
|
16
|
+
* @param {string} [options.file] - Source file path
|
|
17
|
+
* @param {string} [options.code] - Error code for documentation lookup
|
|
18
|
+
* @param {string} [options.source] - Original source code
|
|
19
|
+
* @param {string} [options.suggestion] - Helpful suggestion to fix the error
|
|
20
|
+
*/
|
|
21
|
+
constructor(message, options = {}) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'PulseError';
|
|
24
|
+
this.line = options.line ?? null;
|
|
25
|
+
this.column = options.column ?? null;
|
|
26
|
+
this.file = options.file ?? null;
|
|
27
|
+
this.code = options.code ?? 'PULSE_ERROR';
|
|
28
|
+
this.source = options.source ?? null;
|
|
29
|
+
this.suggestion = options.suggestion ?? null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Format the error with a code snippet showing context
|
|
34
|
+
* @param {string} [source] - Source code to use (overrides this.source)
|
|
35
|
+
* @param {number} [contextLines=2] - Number of lines to show before/after error
|
|
36
|
+
* @returns {string} Formatted error with code snippet
|
|
37
|
+
*/
|
|
38
|
+
formatWithSnippet(source = this.source, contextLines = 2) {
|
|
39
|
+
if (!this.line || !source) {
|
|
40
|
+
return this.toString();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const lines = source.split('\n');
|
|
44
|
+
const start = Math.max(0, this.line - 1 - contextLines);
|
|
45
|
+
const end = Math.min(lines.length, this.line + contextLines);
|
|
46
|
+
const gutterWidth = String(end).length;
|
|
47
|
+
|
|
48
|
+
let output = `\n${this.name}: ${this.message}\n`;
|
|
49
|
+
output += ` at ${this.file || '<anonymous>'}:${this.line}:${this.column || 1}\n\n`;
|
|
50
|
+
|
|
51
|
+
for (let i = start; i < end; i++) {
|
|
52
|
+
const lineNum = i + 1;
|
|
53
|
+
const isErrorLine = lineNum === this.line;
|
|
54
|
+
const prefix = isErrorLine ? '>' : ' ';
|
|
55
|
+
const gutter = String(lineNum).padStart(gutterWidth);
|
|
56
|
+
output += `${prefix} ${gutter} | ${lines[i]}\n`;
|
|
57
|
+
|
|
58
|
+
if (isErrorLine && this.column) {
|
|
59
|
+
const padding = ' '.repeat(gutterWidth + 3 + Math.max(0, this.column - 1));
|
|
60
|
+
output += ` ${padding}^\n`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (this.suggestion) {
|
|
65
|
+
output += `\n Suggestion: ${this.suggestion}\n`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (this.code && this.code !== 'PULSE_ERROR') {
|
|
69
|
+
output += `\n Error code: ${this.code}\n`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return output;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Convert to plain object for JSON serialization
|
|
77
|
+
* @returns {Object} Plain object representation
|
|
78
|
+
*/
|
|
79
|
+
toJSON() {
|
|
80
|
+
return {
|
|
81
|
+
name: this.name,
|
|
82
|
+
message: this.message,
|
|
83
|
+
line: this.line,
|
|
84
|
+
column: this.column,
|
|
85
|
+
file: this.file,
|
|
86
|
+
code: this.code,
|
|
87
|
+
suggestion: this.suggestion
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ============================================================================
|
|
93
|
+
// Compiler Errors
|
|
94
|
+
// ============================================================================
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Base class for all compilation errors
|
|
98
|
+
*/
|
|
99
|
+
export class CompileError extends PulseError {
|
|
100
|
+
constructor(message, options = {}) {
|
|
101
|
+
super(message, { code: 'COMPILE_ERROR', ...options });
|
|
102
|
+
this.name = 'CompileError';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Error during lexical analysis (tokenization)
|
|
108
|
+
*/
|
|
109
|
+
export class LexerError extends CompileError {
|
|
110
|
+
constructor(message, options = {}) {
|
|
111
|
+
super(message, { code: 'LEXER_ERROR', ...options });
|
|
112
|
+
this.name = 'LexerError';
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Error during parsing (AST construction)
|
|
118
|
+
*/
|
|
119
|
+
export class ParserError extends CompileError {
|
|
120
|
+
/**
|
|
121
|
+
* @param {string} message - Error message
|
|
122
|
+
* @param {Object} [options={}] - Error options
|
|
123
|
+
* @param {Object} [options.token] - The problematic token
|
|
124
|
+
*/
|
|
125
|
+
constructor(message, options = {}) {
|
|
126
|
+
super(message, { code: 'PARSER_ERROR', ...options });
|
|
127
|
+
this.name = 'ParserError';
|
|
128
|
+
this.token = options.token ?? null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Error during code transformation/generation
|
|
134
|
+
*/
|
|
135
|
+
export class TransformError extends CompileError {
|
|
136
|
+
constructor(message, options = {}) {
|
|
137
|
+
super(message, { code: 'TRANSFORM_ERROR', ...options });
|
|
138
|
+
this.name = 'TransformError';
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ============================================================================
|
|
143
|
+
// Runtime Errors
|
|
144
|
+
// ============================================================================
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Base class for all runtime errors
|
|
148
|
+
*/
|
|
149
|
+
export class RuntimeError extends PulseError {
|
|
150
|
+
constructor(message, options = {}) {
|
|
151
|
+
super(message, { code: 'RUNTIME_ERROR', ...options });
|
|
152
|
+
this.name = 'RuntimeError';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Error in the reactivity system (effects, computed values)
|
|
158
|
+
*/
|
|
159
|
+
export class ReactivityError extends RuntimeError {
|
|
160
|
+
constructor(message, options = {}) {
|
|
161
|
+
super(message, { code: 'REACTIVITY_ERROR', ...options });
|
|
162
|
+
this.name = 'ReactivityError';
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Error in DOM operations
|
|
168
|
+
*/
|
|
169
|
+
export class DOMError extends RuntimeError {
|
|
170
|
+
constructor(message, options = {}) {
|
|
171
|
+
super(message, { code: 'DOM_ERROR', ...options });
|
|
172
|
+
this.name = 'DOMError';
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Error in store operations
|
|
178
|
+
*/
|
|
179
|
+
export class StoreError extends RuntimeError {
|
|
180
|
+
constructor(message, options = {}) {
|
|
181
|
+
super(message, { code: 'STORE_ERROR', ...options });
|
|
182
|
+
this.name = 'StoreError';
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Error in router operations
|
|
188
|
+
*/
|
|
189
|
+
export class RouterError extends RuntimeError {
|
|
190
|
+
constructor(message, options = {}) {
|
|
191
|
+
super(message, { code: 'ROUTER_ERROR', ...options });
|
|
192
|
+
this.name = 'RouterError';
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ============================================================================
|
|
197
|
+
// CLI Errors
|
|
198
|
+
// ============================================================================
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Base class for CLI errors
|
|
202
|
+
*/
|
|
203
|
+
export class CLIError extends PulseError {
|
|
204
|
+
constructor(message, options = {}) {
|
|
205
|
+
super(message, { code: 'CLI_ERROR', ...options });
|
|
206
|
+
this.name = 'CLIError';
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Configuration file error
|
|
212
|
+
*/
|
|
213
|
+
export class ConfigError extends CLIError {
|
|
214
|
+
constructor(message, options = {}) {
|
|
215
|
+
super(message, { code: 'CONFIG_ERROR', ...options });
|
|
216
|
+
this.name = 'ConfigError';
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ============================================================================
|
|
221
|
+
// Helper Functions
|
|
222
|
+
// ============================================================================
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Common error suggestions based on error patterns
|
|
226
|
+
*/
|
|
227
|
+
export const SUGGESTIONS = {
|
|
228
|
+
'undefined-variable': (name) =>
|
|
229
|
+
`Did you forget to declare '${name}' in the state block or import it?`,
|
|
230
|
+
'duplicate-declaration': (name) =>
|
|
231
|
+
`Remove the duplicate declaration of '${name}'`,
|
|
232
|
+
'unexpected-token': (expected, got) =>
|
|
233
|
+
`Expected ${expected} but got '${got}'. Check for missing braces, quotes, or semicolons.`,
|
|
234
|
+
'missing-closing-brace': () =>
|
|
235
|
+
`Add the missing closing brace '}'`,
|
|
236
|
+
'missing-closing-paren': () =>
|
|
237
|
+
`Add the missing closing parenthesis ')'`,
|
|
238
|
+
'invalid-directive': (name) =>
|
|
239
|
+
`'${name}' is not a valid directive. Valid directives: @if, @each, @click, @link, @outlet`,
|
|
240
|
+
'empty-block': (blockName) =>
|
|
241
|
+
`The ${blockName} block is empty. Add content or remove the block.`
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Format an error with source code context
|
|
246
|
+
* @param {Error} error - The error to format
|
|
247
|
+
* @param {string} [source] - Source code for context
|
|
248
|
+
* @returns {string} Formatted error string
|
|
249
|
+
*/
|
|
250
|
+
export function formatError(error, source) {
|
|
251
|
+
if (error instanceof PulseError) {
|
|
252
|
+
return error.formatWithSnippet(source);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Handle plain Error objects with line/column properties
|
|
256
|
+
if (error.line && source) {
|
|
257
|
+
const pulseError = new PulseError(error.message, {
|
|
258
|
+
line: error.line,
|
|
259
|
+
column: error.column,
|
|
260
|
+
file: error.file
|
|
261
|
+
});
|
|
262
|
+
return pulseError.formatWithSnippet(source);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return error.stack || error.message;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Create a parser error from a token
|
|
270
|
+
* @param {string} message - Error message
|
|
271
|
+
* @param {Object} token - Token object with line/column info
|
|
272
|
+
* @param {Object} [options={}] - Additional options
|
|
273
|
+
* @returns {ParserError} The created error
|
|
274
|
+
*/
|
|
275
|
+
export function createParserError(message, token, options = {}) {
|
|
276
|
+
return new ParserError(message, {
|
|
277
|
+
line: token?.line || 1,
|
|
278
|
+
column: token?.column || 1,
|
|
279
|
+
token,
|
|
280
|
+
...options
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export default {
|
|
285
|
+
PulseError,
|
|
286
|
+
CompileError,
|
|
287
|
+
LexerError,
|
|
288
|
+
ParserError,
|
|
289
|
+
TransformError,
|
|
290
|
+
RuntimeError,
|
|
291
|
+
ReactivityError,
|
|
292
|
+
DOMError,
|
|
293
|
+
StoreError,
|
|
294
|
+
RouterError,
|
|
295
|
+
CLIError,
|
|
296
|
+
ConfigError,
|
|
297
|
+
SUGGESTIONS,
|
|
298
|
+
formatError,
|
|
299
|
+
createParserError
|
|
300
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pulse-js-framework",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
"types": "./types/hmr.d.ts",
|
|
46
46
|
"default": "./runtime/hmr.js"
|
|
47
47
|
},
|
|
48
|
+
"./runtime/lru-cache": "./runtime/lru-cache.js",
|
|
49
|
+
"./runtime/utils": "./runtime/utils.js",
|
|
48
50
|
"./compiler": {
|
|
49
51
|
"types": "./types/index.d.ts",
|
|
50
52
|
"default": "./compiler/index.js"
|
|
@@ -52,6 +54,7 @@
|
|
|
52
54
|
"./compiler/lexer": "./compiler/lexer.js",
|
|
53
55
|
"./compiler/parser": "./compiler/parser.js",
|
|
54
56
|
"./compiler/transformer": "./compiler/transformer.js",
|
|
57
|
+
"./core/errors": "./core/errors.js",
|
|
55
58
|
"./vite": {
|
|
56
59
|
"types": "./types/index.d.ts",
|
|
57
60
|
"default": "./loader/vite-plugin.js"
|
|
@@ -62,6 +65,7 @@
|
|
|
62
65
|
"files": [
|
|
63
66
|
"index.js",
|
|
64
67
|
"cli/",
|
|
68
|
+
"core/",
|
|
65
69
|
"runtime/",
|
|
66
70
|
"compiler/",
|
|
67
71
|
"loader/",
|
|
@@ -83,7 +87,8 @@
|
|
|
83
87
|
"test:format": "node test/format.test.js",
|
|
84
88
|
"test:analyze": "node test/analyze.test.js",
|
|
85
89
|
"build:netlify": "node scripts/build-netlify.js",
|
|
86
|
-
"version": "node scripts/sync-version.js"
|
|
90
|
+
"version": "node scripts/sync-version.js",
|
|
91
|
+
"docs": "node cli/index.js dev docs"
|
|
87
92
|
},
|
|
88
93
|
"keywords": [
|
|
89
94
|
"framework",
|
|
@@ -113,7 +118,5 @@
|
|
|
113
118
|
"node": ">=18.0.0"
|
|
114
119
|
},
|
|
115
120
|
"dependencies": {},
|
|
116
|
-
"devDependencies": {
|
|
117
|
-
"linkedom": "^0.16.8"
|
|
118
|
-
}
|
|
121
|
+
"devDependencies": {}
|
|
119
122
|
}
|
package/runtime/dom.js
CHANGED
|
@@ -7,12 +7,26 @@
|
|
|
7
7
|
|
|
8
8
|
import { effect, pulse, batch, onCleanup } from './pulse.js';
|
|
9
9
|
import { loggers } from './logger.js';
|
|
10
|
+
import { LRUCache } from './lru-cache.js';
|
|
10
11
|
|
|
11
12
|
const log = loggers.dom;
|
|
12
13
|
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// SELECTOR CACHE
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// LRU (Least Recently Used) cache for parseSelector results.
|
|
18
|
+
//
|
|
19
|
+
// Why LRU instead of Map?
|
|
20
|
+
// - Apps typically reuse the same selectors (e.g., 'div.container', 'button.primary')
|
|
21
|
+
// - Without eviction, memory grows unbounded in long-running apps
|
|
22
|
+
// - LRU keeps frequently-used selectors hot while evicting rare ones
|
|
23
|
+
//
|
|
24
|
+
// Capacity: 500 selectors (chosen based on typical SPA component count)
|
|
25
|
+
// - Most apps use 50-200 unique selectors
|
|
26
|
+
// - 500 provides headroom for dynamic selectors without excessive memory
|
|
27
|
+
//
|
|
28
|
+
// Cache hit returns a shallow copy to prevent mutation of cached config
|
|
29
|
+
const selectorCache = new LRUCache(500);
|
|
16
30
|
|
|
17
31
|
// Lifecycle tracking
|
|
18
32
|
let mountCallbacks = [];
|
|
@@ -113,12 +127,7 @@ export function parseSelector(selector) {
|
|
|
113
127
|
config.attrs[key] = value;
|
|
114
128
|
}
|
|
115
129
|
|
|
116
|
-
// Cache the result (
|
|
117
|
-
if (selectorCache.size >= SELECTOR_CACHE_MAX) {
|
|
118
|
-
// Remove oldest entry (first key)
|
|
119
|
-
const firstKey = selectorCache.keys().next().value;
|
|
120
|
-
selectorCache.delete(firstKey);
|
|
121
|
-
}
|
|
130
|
+
// Cache the result (LRU cache handles eviction automatically)
|
|
122
131
|
selectorCache.set(selector, config);
|
|
123
132
|
|
|
124
133
|
// Return a copy
|
|
@@ -311,6 +320,37 @@ export function on(element, event, handler, options) {
|
|
|
311
320
|
|
|
312
321
|
/**
|
|
313
322
|
* Create a reactive list with efficient keyed diffing
|
|
323
|
+
*
|
|
324
|
+
* LIST DIFFING ALGORITHM:
|
|
325
|
+
* -----------------------
|
|
326
|
+
* This uses a keyed reconciliation strategy to minimize DOM operations:
|
|
327
|
+
*
|
|
328
|
+
* 1. KEY EXTRACTION: Each item gets a unique key via keyFn (defaults to index)
|
|
329
|
+
* Good keys: item.id, item.uuid (stable across re-renders)
|
|
330
|
+
* Bad keys: array index (causes unnecessary re-renders on reorder)
|
|
331
|
+
*
|
|
332
|
+
* 2. RECONCILIATION PHASES:
|
|
333
|
+
* a) Build a map of new items by key
|
|
334
|
+
* b) For existing keys: reuse the DOM nodes (no re-creation)
|
|
335
|
+
* c) For removed keys: remove DOM nodes and run cleanup
|
|
336
|
+
* d) For new keys: create DOM nodes via template function
|
|
337
|
+
*
|
|
338
|
+
* 3. REORDERING: Uses a single forward pass:
|
|
339
|
+
* - Tracks previous node position with prevNode cursor
|
|
340
|
+
* - Only moves nodes that are out of position
|
|
341
|
+
* - Nodes already in correct position are skipped (no DOM operation)
|
|
342
|
+
*
|
|
343
|
+
* 4. BOUNDARY MARKERS: Uses comment nodes to track list boundaries:
|
|
344
|
+
* - startMarker: Insertion point for first item
|
|
345
|
+
* - endMarker: End boundary (not currently used but reserved)
|
|
346
|
+
*
|
|
347
|
+
* COMPLEXITY: O(n) for reconciliation + O(m) DOM moves where m <= n
|
|
348
|
+
* Best case (no changes): O(n) comparisons, 0 DOM operations
|
|
349
|
+
*
|
|
350
|
+
* @param {Function|Pulse} getItems - Items source (reactive)
|
|
351
|
+
* @param {Function} template - (item, index) => Node | Node[]
|
|
352
|
+
* @param {Function} keyFn - (item, index) => key (default: index)
|
|
353
|
+
* @returns {DocumentFragment} Container fragment with reactive list
|
|
314
354
|
*/
|
|
315
355
|
export function list(getItems, template, keyFn = (item, i) => i) {
|
|
316
356
|
const container = document.createDocumentFragment();
|
|
@@ -506,13 +546,24 @@ export function model(element, pulseValue) {
|
|
|
506
546
|
|
|
507
547
|
/**
|
|
508
548
|
* Mount an element to a target
|
|
549
|
+
* @param {string|HTMLElement} target - CSS selector or DOM element
|
|
550
|
+
* @param {Node} element - Element to mount
|
|
551
|
+
* @returns {Function} Unmount function
|
|
552
|
+
* @throws {Error} If target element is not found
|
|
509
553
|
*/
|
|
510
554
|
export function mount(target, element) {
|
|
555
|
+
const originalTarget = target;
|
|
511
556
|
if (typeof target === 'string') {
|
|
512
557
|
target = document.querySelector(target);
|
|
513
558
|
}
|
|
514
559
|
if (!target) {
|
|
515
|
-
|
|
560
|
+
const selector = typeof originalTarget === 'string' ? originalTarget : '(element)';
|
|
561
|
+
throw new Error(
|
|
562
|
+
`[Pulse] Mount target not found: "${selector}". ` +
|
|
563
|
+
`Ensure the element exists in the DOM before mounting. ` +
|
|
564
|
+
`Tip: Use document.addEventListener('DOMContentLoaded', () => mount(...)) ` +
|
|
565
|
+
`or place your script at the end of <body>.`
|
|
566
|
+
);
|
|
516
567
|
}
|
|
517
568
|
target.appendChild(element);
|
|
518
569
|
return () => {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LRU (Least Recently Used) Cache
|
|
3
|
+
* @module pulse-js-framework/runtime/lru-cache
|
|
4
|
+
*
|
|
5
|
+
* A simple LRU cache implementation using Map's insertion order.
|
|
6
|
+
* When capacity is reached, the least recently used item is evicted.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* LRU Cache implementation
|
|
11
|
+
* @template K, V
|
|
12
|
+
*/
|
|
13
|
+
export class LRUCache {
|
|
14
|
+
/** @type {number} */
|
|
15
|
+
#capacity;
|
|
16
|
+
|
|
17
|
+
/** @type {Map<K, V>} */
|
|
18
|
+
#cache = new Map();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create an LRU cache
|
|
22
|
+
* @param {number} capacity - Maximum number of items to store
|
|
23
|
+
*/
|
|
24
|
+
constructor(capacity) {
|
|
25
|
+
if (capacity <= 0) {
|
|
26
|
+
throw new Error('LRU cache capacity must be greater than 0');
|
|
27
|
+
}
|
|
28
|
+
this.#capacity = capacity;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get an item from the cache
|
|
33
|
+
* Accessing an item moves it to the "most recently used" position
|
|
34
|
+
* @param {K} key - Cache key
|
|
35
|
+
* @returns {V|undefined} The cached value or undefined if not found
|
|
36
|
+
*/
|
|
37
|
+
get(key) {
|
|
38
|
+
if (!this.#cache.has(key)) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Move to end (most recently used) by re-inserting
|
|
43
|
+
const value = this.#cache.get(key);
|
|
44
|
+
this.#cache.delete(key);
|
|
45
|
+
this.#cache.set(key, value);
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Set an item in the cache
|
|
51
|
+
* If the cache is at capacity, evicts the least recently used item
|
|
52
|
+
* @param {K} key - Cache key
|
|
53
|
+
* @param {V} value - Value to store
|
|
54
|
+
* @returns {LRUCache} this (for chaining)
|
|
55
|
+
*/
|
|
56
|
+
set(key, value) {
|
|
57
|
+
// If key exists, delete first to update position
|
|
58
|
+
if (this.#cache.has(key)) {
|
|
59
|
+
this.#cache.delete(key);
|
|
60
|
+
} else if (this.#cache.size >= this.#capacity) {
|
|
61
|
+
// Remove oldest (first item in Map)
|
|
62
|
+
const oldest = this.#cache.keys().next().value;
|
|
63
|
+
this.#cache.delete(oldest);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this.#cache.set(key, value);
|
|
67
|
+
return this;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check if a key exists in the cache
|
|
72
|
+
* Note: This does NOT update the item's position
|
|
73
|
+
* @param {K} key - Cache key
|
|
74
|
+
* @returns {boolean} True if key exists
|
|
75
|
+
*/
|
|
76
|
+
has(key) {
|
|
77
|
+
return this.#cache.has(key);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Delete an item from the cache
|
|
82
|
+
* @param {K} key - Cache key
|
|
83
|
+
* @returns {boolean} True if item was deleted
|
|
84
|
+
*/
|
|
85
|
+
delete(key) {
|
|
86
|
+
return this.#cache.delete(key);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Clear all items from the cache
|
|
91
|
+
*/
|
|
92
|
+
clear() {
|
|
93
|
+
this.#cache.clear();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get the current number of items in the cache
|
|
98
|
+
* @returns {number} Current size
|
|
99
|
+
*/
|
|
100
|
+
get size() {
|
|
101
|
+
return this.#cache.size;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get the maximum capacity of the cache
|
|
106
|
+
* @returns {number} Maximum capacity
|
|
107
|
+
*/
|
|
108
|
+
get capacity() {
|
|
109
|
+
return this.#capacity;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get all keys in the cache (oldest to newest)
|
|
114
|
+
* @returns {IterableIterator<K>} Iterator of keys
|
|
115
|
+
*/
|
|
116
|
+
keys() {
|
|
117
|
+
return this.#cache.keys();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get all values in the cache (oldest to newest)
|
|
122
|
+
* @returns {IterableIterator<V>} Iterator of values
|
|
123
|
+
*/
|
|
124
|
+
values() {
|
|
125
|
+
return this.#cache.values();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get all entries in the cache (oldest to newest)
|
|
130
|
+
* @returns {IterableIterator<[K, V]>} Iterator of [key, value] pairs
|
|
131
|
+
*/
|
|
132
|
+
entries() {
|
|
133
|
+
return this.#cache.entries();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Iterate over all entries
|
|
138
|
+
* @param {function(V, K, LRUCache): void} callback - Called for each entry
|
|
139
|
+
*/
|
|
140
|
+
forEach(callback) {
|
|
141
|
+
this.#cache.forEach((value, key) => callback(value, key, this));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export default LRUCache;
|
package/runtime/native.js
CHANGED
|
@@ -21,7 +21,12 @@ export function isNativeAvailable() {
|
|
|
21
21
|
*/
|
|
22
22
|
export function getNative() {
|
|
23
23
|
if (!isNativeAvailable()) {
|
|
24
|
-
throw new Error(
|
|
24
|
+
throw new Error(
|
|
25
|
+
'[Pulse Native] PulseMobile bridge is not available. ' +
|
|
26
|
+
'This API only works in a Pulse native mobile app. ' +
|
|
27
|
+
'For web, use isNativeAvailable() to check before calling native APIs, ' +
|
|
28
|
+
'or use getPlatform() to detect the current environment.'
|
|
29
|
+
);
|
|
25
30
|
}
|
|
26
31
|
return window.PulseMobile;
|
|
27
32
|
}
|
package/runtime/pulse.js
CHANGED
|
@@ -22,6 +22,43 @@ import { loggers } from './logger.js';
|
|
|
22
22
|
|
|
23
23
|
const log = loggers.pulse;
|
|
24
24
|
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// REACTIVE DEPENDENCY TRACKING ALGORITHM
|
|
27
|
+
// =============================================================================
|
|
28
|
+
//
|
|
29
|
+
// This module implements a push-based reactive system using automatic dependency
|
|
30
|
+
// tracking. Here's how it works:
|
|
31
|
+
//
|
|
32
|
+
// 1. READING (Dependency Collection)
|
|
33
|
+
// - When an effect runs, `context.currentEffect` points to it
|
|
34
|
+
// - When a pulse's get() is called, it checks if there's a currentEffect
|
|
35
|
+
// - If so, it adds the effect to its subscribers and vice versa
|
|
36
|
+
// - This creates a bidirectional link: pulse knows who depends on it,
|
|
37
|
+
// effect knows what it depends on
|
|
38
|
+
//
|
|
39
|
+
// 2. WRITING (Change Notification)
|
|
40
|
+
// - When a pulse's set() is called with a new value, it notifies subscribers
|
|
41
|
+
// - Each subscriber (effect) is either run immediately or queued for batching
|
|
42
|
+
// - Effects re-run, clearing old dependencies and collecting new ones
|
|
43
|
+
//
|
|
44
|
+
// 3. BATCHING (Performance Optimization)
|
|
45
|
+
// - batch() increments batchDepth and delays effect execution
|
|
46
|
+
// - Effects are queued in pendingEffects instead of running immediately
|
|
47
|
+
// - When batch completes, flushEffects() runs all queued effects
|
|
48
|
+
// - Nested batches are handled by only flushing at depth 0
|
|
49
|
+
//
|
|
50
|
+
// 4. COMPUTED VALUES (Derived State)
|
|
51
|
+
// - Computed pulses wrap a function and cache its result
|
|
52
|
+
// - They use the same dependency tracking to know when to recompute
|
|
53
|
+
// - Lazy computed values only recompute when read (pull-based optimization)
|
|
54
|
+
//
|
|
55
|
+
// 5. CLEANUP (Memory Management)
|
|
56
|
+
// - Effects can return a cleanup function called before re-run or disposal
|
|
57
|
+
// - On re-run, old dependencies are cleared before collecting new ones
|
|
58
|
+
// - This prevents memory leaks and stale subscriptions
|
|
59
|
+
//
|
|
60
|
+
// =============================================================================
|
|
61
|
+
|
|
25
62
|
/**
|
|
26
63
|
* @typedef {Object} ReactiveContext
|
|
27
64
|
* @property {EffectFn|null} currentEffect - Currently executing effect for dependency tracking
|
|
@@ -509,11 +546,18 @@ export function computed(fn, options = {}) {
|
|
|
509
546
|
|
|
510
547
|
// Override set to make it read-only
|
|
511
548
|
p.set = () => {
|
|
512
|
-
throw new Error(
|
|
549
|
+
throw new Error(
|
|
550
|
+
'[Pulse] Cannot set a computed value directly. ' +
|
|
551
|
+
'Computed values are derived from other pulses and update automatically. ' +
|
|
552
|
+
'Modify the source pulse(s) instead.'
|
|
553
|
+
);
|
|
513
554
|
};
|
|
514
555
|
|
|
515
556
|
p.update = () => {
|
|
516
|
-
throw new Error(
|
|
557
|
+
throw new Error(
|
|
558
|
+
'[Pulse] Cannot update a computed value directly. ' +
|
|
559
|
+
'Computed values are read-only. Modify the source pulse(s) instead.'
|
|
560
|
+
);
|
|
517
561
|
};
|
|
518
562
|
|
|
519
563
|
// Add dispose method
|
package/runtime/router.js
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
|
|
16
16
|
import { pulse, effect, batch } from './pulse.js';
|
|
17
17
|
import { el } from './dom.js';
|
|
18
|
+
import { loggers } from './logger.js';
|
|
19
|
+
|
|
20
|
+
const log = loggers.router;
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
23
|
* Lazy load helper for route components
|
|
@@ -126,7 +129,7 @@ export function lazy(importFn, options = {}) {
|
|
|
126
129
|
if (ErrorComponent) {
|
|
127
130
|
container.replaceChildren(ErrorComponent(err));
|
|
128
131
|
} else {
|
|
129
|
-
|
|
132
|
+
log.error('Lazy load error:', err);
|
|
130
133
|
container.replaceChildren(
|
|
131
134
|
el('div.lazy-error', `Failed to load component: ${err.message}`)
|
|
132
135
|
);
|