@fynixorg/ui 1.0.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/README.md +36 -0
- package/package.json +63 -0
- package/router/router.d.ts +21 -0
- package/router/router.js +678 -0
- package/runtime.d.ts +83 -0
- package/types/global.d.ts +236 -0
- package/types/jsx.d.ts +669 -0
package/router/router.js
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fynix File-Based Router - PRODUCTION FIXED VERSION
|
|
3
|
+
* All Security & Memory Leak Issues Fixed
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mount } from "../runtime.js";
|
|
7
|
+
|
|
8
|
+
const MAX_CACHE_SIZE = 50;
|
|
9
|
+
const PROPS_NAMESPACE = '__fynixLinkProps__';
|
|
10
|
+
const MAX_LISTENERS = 100;
|
|
11
|
+
const ALLOWED_PROTOCOLS = ['http:', 'https:', ''];
|
|
12
|
+
|
|
13
|
+
// FIX 1: Singleton pattern to prevent multiple router instances
|
|
14
|
+
let routerInstance = null;
|
|
15
|
+
let isRouterInitialized = false;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Security: Improved HTML escaping to prevent XSS
|
|
19
|
+
*/
|
|
20
|
+
function escapeHTML(str) {
|
|
21
|
+
if (typeof str !== 'string') return '';
|
|
22
|
+
return str
|
|
23
|
+
.replace(/&/g, '&')
|
|
24
|
+
.replace(/</g, '<')
|
|
25
|
+
.replace(/>/g, '>')
|
|
26
|
+
.replace(/"/g, '"')
|
|
27
|
+
.replace(/'/g, ''')
|
|
28
|
+
.replace(/`/g, '`')
|
|
29
|
+
.replace(/\//g, '/');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Security: Validate URL to prevent open redirect
|
|
34
|
+
*/
|
|
35
|
+
function isValidURL(url) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = new URL(url, window.location.origin);
|
|
38
|
+
|
|
39
|
+
if (parsed.origin !== window.location.origin) {
|
|
40
|
+
console.warn('[Router] Security: Cross-origin navigation blocked');
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
|
|
45
|
+
console.warn('[Router] Security: Dangerous protocol blocked:', parsed.protocol);
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return true;
|
|
50
|
+
} catch (e) {
|
|
51
|
+
console.warn('[Router] Security: Invalid URL blocked');
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Security: Sanitize path to prevent directory traversal
|
|
58
|
+
*/
|
|
59
|
+
function sanitizePath(path) {
|
|
60
|
+
if (typeof path !== 'string') return '/';
|
|
61
|
+
|
|
62
|
+
// Decode URL encoding first to catch encoded traversal attempts like %2e%2e
|
|
63
|
+
try {
|
|
64
|
+
path = decodeURIComponent(path);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
// Invalid encoding, reject
|
|
67
|
+
console.warn('[Router] Invalid URL encoding in path');
|
|
68
|
+
return '/';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
path = path.replace(/\0/g, '');
|
|
72
|
+
path = path.replace(/\\/g, '/');
|
|
73
|
+
path = path.replace(/\/+/g, '/');
|
|
74
|
+
path = path.split('/').filter(part => part !== '..' && part !== '.').join('/');
|
|
75
|
+
|
|
76
|
+
if (!path.startsWith('/')) {
|
|
77
|
+
path = '/' + path;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (path.length > 1 && path.endsWith('/')) {
|
|
81
|
+
path = path.slice(0, -1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return path || '/';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Helper: Try multiple possible glob paths for file-based routing
|
|
89
|
+
*/
|
|
90
|
+
function tryGlobPaths() {
|
|
91
|
+
try {
|
|
92
|
+
// @ts-ignore - Vite glob API
|
|
93
|
+
const modules = import.meta.glob("/src/**/*.{ts,js,jsx,fnx}", { eager: true });
|
|
94
|
+
return modules || {};
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error('[Router] Failed to load modules:', error);
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Convert file path to route path
|
|
103
|
+
*/
|
|
104
|
+
function filePathToRoute(filePath) {
|
|
105
|
+
let route = filePath
|
|
106
|
+
.replace(/^.*\/src/, "")
|
|
107
|
+
.replace(/\.(js|jsx|fnx)$/, "")
|
|
108
|
+
.replace(/\/view$/, "")
|
|
109
|
+
.replace(/\/$/, "");
|
|
110
|
+
|
|
111
|
+
if (!route) route = "/";
|
|
112
|
+
route = route.replace(/\[([^\]]+)\]/g, ":$1");
|
|
113
|
+
return route;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Match a dynamic route pattern
|
|
118
|
+
*/
|
|
119
|
+
function matchDynamicRoute(path, dynamicRoutes) {
|
|
120
|
+
for (const route of dynamicRoutes) {
|
|
121
|
+
const match = path.match(route.regex);
|
|
122
|
+
if (match) {
|
|
123
|
+
const params = {};
|
|
124
|
+
route.params.forEach((param, i) => {
|
|
125
|
+
// FIX: Don't decode again - already decoded in sanitizePath
|
|
126
|
+
// Just escape the matched value
|
|
127
|
+
params[param] = escapeHTML(match[i + 1]);
|
|
128
|
+
});
|
|
129
|
+
return { component: route.component, params };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Deserialize plain props
|
|
137
|
+
*/
|
|
138
|
+
function deserializeProps(props) {
|
|
139
|
+
if (!props || typeof props !== 'object') return {};
|
|
140
|
+
|
|
141
|
+
const deserialized = {};
|
|
142
|
+
for (const [key, value] of Object.entries(props)) {
|
|
143
|
+
if (typeof key !== 'string' || key.startsWith('__')) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
deserialized[key] = value;
|
|
147
|
+
}
|
|
148
|
+
return deserialized;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Normalize path
|
|
153
|
+
*/
|
|
154
|
+
function normalizePath(path) {
|
|
155
|
+
return sanitizePath(path);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* FIX 2: Generate unique cache keys using crypto API when available
|
|
160
|
+
*/
|
|
161
|
+
function generateCacheKey() {
|
|
162
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
163
|
+
return crypto.randomUUID();
|
|
164
|
+
}
|
|
165
|
+
// Fallback with better uniqueness
|
|
166
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* @typedef {Object} FynixRouter
|
|
171
|
+
* @property {function(string=): void} mountRouter - Mount router to DOM element
|
|
172
|
+
* @property {function(string, Object=): void} navigate - Navigate to path with props
|
|
173
|
+
* @property {function(string, Object=): void} replace - Replace current path
|
|
174
|
+
* @property {function(): void} back - Navigate back
|
|
175
|
+
* @property {function(): void} cleanup - Cleanup router instance
|
|
176
|
+
* @property {Object} routes - Static routes map
|
|
177
|
+
* @property {Array} dynamicRoutes - Dynamic routes array
|
|
178
|
+
*/
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Fynix Router Factory
|
|
182
|
+
* @returns {FynixRouter}
|
|
183
|
+
*/
|
|
184
|
+
export default function createFynix() {
|
|
185
|
+
// FIX 3: Singleton pattern - return existing instance if already initialized
|
|
186
|
+
// Skip singleton check in dev mode (HMR) to allow hot reloading
|
|
187
|
+
const isDevMode = import.meta.hot !== undefined;
|
|
188
|
+
|
|
189
|
+
if (routerInstance && isRouterInitialized && !isDevMode) {
|
|
190
|
+
console.warn('[Router] Router already initialized, returning existing instance');
|
|
191
|
+
return routerInstance;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// In dev mode with HMR, cleanup old instance before creating new one
|
|
195
|
+
if (isDevMode && routerInstance) {
|
|
196
|
+
console.log('[Router] HMR: Cleaning up old router instance');
|
|
197
|
+
routerInstance.cleanup();
|
|
198
|
+
routerInstance = null;
|
|
199
|
+
isRouterInitialized = false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let rootSelector = "#app-root";
|
|
203
|
+
let currentPath = null;
|
|
204
|
+
let isDestroyed = false;
|
|
205
|
+
let listenerCount = 0;
|
|
206
|
+
|
|
207
|
+
const listeners = [];
|
|
208
|
+
|
|
209
|
+
if (!window[PROPS_NAMESPACE]) {
|
|
210
|
+
window[PROPS_NAMESPACE] = {};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Clear old cache in dev mode to prevent memory buildup
|
|
214
|
+
if (isDevMode && window.__fynixPropsCache) {
|
|
215
|
+
window.__fynixPropsCache.clear();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// @ts-ignore - Custom cache property
|
|
219
|
+
const __fynixPropsCache = window.__fynixPropsCache || new Map();
|
|
220
|
+
// @ts-ignore
|
|
221
|
+
window.__fynixPropsCache = __fynixPropsCache;
|
|
222
|
+
|
|
223
|
+
const modules = tryGlobPaths();
|
|
224
|
+
const routes = {};
|
|
225
|
+
const dynamicRoutes = [];
|
|
226
|
+
|
|
227
|
+
for (const [filePath, mod] of Object.entries(modules)) {
|
|
228
|
+
const routePath = filePathToRoute(filePath);
|
|
229
|
+
const component = mod.default || mod[Object.keys(mod)[0]] || Object.values(mod)[0];
|
|
230
|
+
|
|
231
|
+
if (!component) continue;
|
|
232
|
+
|
|
233
|
+
const hasDynamic = /:[^/]+/.test(routePath);
|
|
234
|
+
if (hasDynamic) {
|
|
235
|
+
dynamicRoutes.push({
|
|
236
|
+
pattern: routePath,
|
|
237
|
+
regex: new RegExp("^" + routePath.replace(/:[^/]+/g, "([^/]+)") + "$"),
|
|
238
|
+
component,
|
|
239
|
+
params: [...routePath.matchAll(/:([^/]+)/g)].map((m) => m[1]),
|
|
240
|
+
});
|
|
241
|
+
} else {
|
|
242
|
+
routes[routePath] = component;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Add cache management with LRU
|
|
248
|
+
*/
|
|
249
|
+
function addToCache(key, value) {
|
|
250
|
+
if (__fynixPropsCache.size >= MAX_CACHE_SIZE) {
|
|
251
|
+
const firstKey = __fynixPropsCache.keys().next().value;
|
|
252
|
+
const evicted = __fynixPropsCache.get(firstKey);
|
|
253
|
+
|
|
254
|
+
if (evicted && typeof evicted === 'object') {
|
|
255
|
+
Object.values(evicted).forEach(val => {
|
|
256
|
+
if (val && typeof val === 'object' && val.cleanup) {
|
|
257
|
+
try { val.cleanup(); } catch (e) {}
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
__fynixPropsCache.delete(firstKey);
|
|
263
|
+
}
|
|
264
|
+
__fynixPropsCache.set(key, value);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const MANAGED_META = [
|
|
268
|
+
{ key: "description", name: "description" },
|
|
269
|
+
{ key: "keywords", name: "keywords" },
|
|
270
|
+
{ key: "twitterCard", name: "twitter:card" },
|
|
271
|
+
{ key: "ogTitle", property: "og:title" },
|
|
272
|
+
{ key: "ogDescription", property: "og:description" },
|
|
273
|
+
{ key: "ogImage", property: "og:image" },
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Update document meta tags for SEO with XSS prevention
|
|
278
|
+
* @param {Object} meta - Meta object
|
|
279
|
+
*/
|
|
280
|
+
function updateMetaTags(meta = {}) {
|
|
281
|
+
if (!meta || typeof meta !== 'object') return;
|
|
282
|
+
|
|
283
|
+
if (meta.title && typeof meta.title === 'string') {
|
|
284
|
+
document.title = escapeHTML(meta.title);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
MANAGED_META.forEach(def => {
|
|
288
|
+
const value = meta[def.key];
|
|
289
|
+
|
|
290
|
+
const selector = def.name
|
|
291
|
+
? `meta[name="${def.name}"]`
|
|
292
|
+
: `meta[property="${def.property}"]`;
|
|
293
|
+
|
|
294
|
+
let el = document.querySelector(selector);
|
|
295
|
+
|
|
296
|
+
if (value == null) {
|
|
297
|
+
if (el) el.remove();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (typeof value !== 'string') return;
|
|
302
|
+
|
|
303
|
+
if (!el) {
|
|
304
|
+
el = document.createElement("meta");
|
|
305
|
+
if (def.name) el.setAttribute("name", def.name);
|
|
306
|
+
if (def.property) el.setAttribute("property", def.property);
|
|
307
|
+
document.head.appendChild(el);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
el.setAttribute("content", escapeHTML(value));
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// FIX 4: Debounce renderRoute to prevent race conditions
|
|
315
|
+
let renderTimeout = null;
|
|
316
|
+
const RENDER_DEBOUNCE = 10; // ms
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Core route rendering function
|
|
320
|
+
*/
|
|
321
|
+
function renderRoute() {
|
|
322
|
+
if (isDestroyed) return;
|
|
323
|
+
|
|
324
|
+
// FIX 5: Debounce to prevent race conditions
|
|
325
|
+
if (renderTimeout) {
|
|
326
|
+
clearTimeout(renderTimeout);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
renderTimeout = setTimeout(() => {
|
|
330
|
+
_renderRouteImmediate();
|
|
331
|
+
renderTimeout = null;
|
|
332
|
+
}, RENDER_DEBOUNCE);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function _renderRouteImmediate() {
|
|
336
|
+
if (isDestroyed) return;
|
|
337
|
+
|
|
338
|
+
const path = normalizePath(window.location.pathname);
|
|
339
|
+
let Page = routes[path];
|
|
340
|
+
let params = {};
|
|
341
|
+
let routeProps = {};
|
|
342
|
+
|
|
343
|
+
if (!Page) {
|
|
344
|
+
const match = matchDynamicRoute(path, dynamicRoutes);
|
|
345
|
+
if (match) {
|
|
346
|
+
Page = match.component;
|
|
347
|
+
params = match.params;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const root = document.querySelector(rootSelector);
|
|
352
|
+
if (!root) {
|
|
353
|
+
console.error("[Router] Root element not found:", rootSelector);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!Page) {
|
|
358
|
+
root.innerHTML = `<h2>404 Not Found</h2><p>Path: ${escapeHTML(path)}</p>`;
|
|
359
|
+
updateMetaTags({ title: "404 - Page Not Found" });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const state = window.history.state || {};
|
|
364
|
+
let passedProps = {};
|
|
365
|
+
|
|
366
|
+
if (state.__fynixCacheKey && __fynixPropsCache.has(state.__fynixCacheKey)) {
|
|
367
|
+
passedProps = __fynixPropsCache.get(state.__fynixCacheKey);
|
|
368
|
+
} else if (state.serializedProps) {
|
|
369
|
+
passedProps = deserializeProps(state.serializedProps);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (Page.props) {
|
|
373
|
+
routeProps = typeof Page.props === "function" ? Page.props() : Page.props;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (Page.meta) {
|
|
377
|
+
const meta = typeof Page.meta === "function" ? Page.meta(params) : Page.meta;
|
|
378
|
+
updateMetaTags(meta);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// @ts-ignore
|
|
382
|
+
window.__lastRouteProps = {
|
|
383
|
+
...routeProps,
|
|
384
|
+
...passedProps,
|
|
385
|
+
params,
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
mount(Page, rootSelector, false, window.__lastRouteProps);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
console.error("[Router] Mount failed:", err);
|
|
392
|
+
root.innerHTML = `<pre style="color:red;">Mount Error occurred</pre>`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
currentPath = path;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* SPA Navigation Helpers
|
|
400
|
+
*/
|
|
401
|
+
function navigate(path, props = {}) {
|
|
402
|
+
if (isDestroyed) return;
|
|
403
|
+
|
|
404
|
+
path = normalizePath(path);
|
|
405
|
+
|
|
406
|
+
if (!isValidURL(window.location.origin + path)) {
|
|
407
|
+
console.error('[Router] Invalid navigation URL');
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (path === currentPath) return;
|
|
412
|
+
|
|
413
|
+
const cacheKey = generateCacheKey();
|
|
414
|
+
addToCache(cacheKey, props);
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
window.history.pushState({ __fynixCacheKey: cacheKey }, "", path);
|
|
418
|
+
renderRoute();
|
|
419
|
+
} catch (err) {
|
|
420
|
+
console.error('[Router] Navigation failed:', err);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function replace(path, props = {}) {
|
|
425
|
+
if (isDestroyed) return;
|
|
426
|
+
|
|
427
|
+
path = normalizePath(path);
|
|
428
|
+
|
|
429
|
+
if (!isValidURL(window.location.origin + path)) {
|
|
430
|
+
console.error('[Router] Invalid replace URL');
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const cacheKey = generateCacheKey();
|
|
435
|
+
addToCache(cacheKey, props);
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
window.history.replaceState({ __fynixCacheKey: cacheKey }, "", path);
|
|
439
|
+
renderRoute();
|
|
440
|
+
} catch (err) {
|
|
441
|
+
console.error('[Router] Replace failed:', err);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function back() {
|
|
446
|
+
if (isDestroyed) return;
|
|
447
|
+
try {
|
|
448
|
+
window.history.back();
|
|
449
|
+
} catch (err) {
|
|
450
|
+
console.error('[Router] Back navigation failed:', err);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Mount the router to a DOM element
|
|
456
|
+
*/
|
|
457
|
+
function mountRouter(selector = "#app-root") {
|
|
458
|
+
if (isDestroyed) {
|
|
459
|
+
console.error("[Router] Cannot mount destroyed router");
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (typeof selector !== 'string' || selector.length === 0) {
|
|
464
|
+
console.error('[Router] Invalid selector');
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
rootSelector = selector;
|
|
469
|
+
renderRoute();
|
|
470
|
+
isRouterInitialized = true;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Link click delegation
|
|
475
|
+
*/
|
|
476
|
+
const clickHandler = (e) => {
|
|
477
|
+
if (isDestroyed) return;
|
|
478
|
+
|
|
479
|
+
const link = e.target.closest("a[data-fynix-link]");
|
|
480
|
+
if (!link) return;
|
|
481
|
+
|
|
482
|
+
const href = link.getAttribute('href');
|
|
483
|
+
if (!href) {
|
|
484
|
+
console.warn('[Router] Missing href attribute');
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// FIX: Build full URL for validation (handles relative URLs)
|
|
489
|
+
const fullUrl = new URL(link.href, window.location.origin).href;
|
|
490
|
+
if (!isValidURL(fullUrl)) {
|
|
491
|
+
console.warn('[Router] Invalid link href');
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
e.preventDefault();
|
|
496
|
+
|
|
497
|
+
const path = normalizePath(new URL(link.href, window.location.origin).pathname);
|
|
498
|
+
|
|
499
|
+
if (path === currentPath) return;
|
|
500
|
+
|
|
501
|
+
let props = {};
|
|
502
|
+
const propsKey = link.getAttribute("data-props-key");
|
|
503
|
+
|
|
504
|
+
if (propsKey && typeof propsKey === 'string' && !propsKey.startsWith('__')) {
|
|
505
|
+
if (window[PROPS_NAMESPACE]?.[propsKey]) {
|
|
506
|
+
props = window[PROPS_NAMESPACE][propsKey];
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const serializableProps = {};
|
|
511
|
+
for (const [k, v] of Object.entries(props)) {
|
|
512
|
+
if (typeof k !== 'string' || k.startsWith('__')) continue;
|
|
513
|
+
serializableProps[k] = v && (v._isNixState || v._isRestState) ? v.value : v;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const cacheKey = generateCacheKey();
|
|
517
|
+
addToCache(cacheKey, serializableProps);
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
window.history.pushState(
|
|
521
|
+
{ __fynixCacheKey: cacheKey, serializedProps: serializableProps },
|
|
522
|
+
"",
|
|
523
|
+
path
|
|
524
|
+
);
|
|
525
|
+
renderRoute();
|
|
526
|
+
} catch (err) {
|
|
527
|
+
console.error('[Router] Link navigation failed:', err);
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
// FIX 6: Only add listeners if not already added
|
|
532
|
+
if (listenerCount < MAX_LISTENERS && !isRouterInitialized) {
|
|
533
|
+
document.addEventListener("click", clickHandler);
|
|
534
|
+
listeners.push({ element: document, event: "click", handler: clickHandler });
|
|
535
|
+
listenerCount++;
|
|
536
|
+
|
|
537
|
+
window.addEventListener("popstate", renderRoute);
|
|
538
|
+
listeners.push({ element: window, event: "popstate", handler: renderRoute });
|
|
539
|
+
listenerCount++;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Cleanup function
|
|
544
|
+
*/
|
|
545
|
+
function cleanup() {
|
|
546
|
+
// FIX: Clear timeout FIRST to prevent pending renders
|
|
547
|
+
if (renderTimeout) {
|
|
548
|
+
clearTimeout(renderTimeout);
|
|
549
|
+
renderTimeout = null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// THEN mark as destroyed
|
|
553
|
+
isDestroyed = true;
|
|
554
|
+
|
|
555
|
+
// Remove all event listeners
|
|
556
|
+
listeners.forEach(({ element, event, handler }) => {
|
|
557
|
+
try {
|
|
558
|
+
element.removeEventListener(event, handler);
|
|
559
|
+
} catch (e) {
|
|
560
|
+
console.error('[Router] Cleanup error:', e);
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
listeners.length = 0;
|
|
564
|
+
listenerCount = 0;
|
|
565
|
+
|
|
566
|
+
// Clean up all cached props
|
|
567
|
+
__fynixPropsCache.forEach(props => {
|
|
568
|
+
if (props && typeof props === 'object') {
|
|
569
|
+
Object.values(props).forEach(val => {
|
|
570
|
+
if (val && typeof val === 'object' && val.cleanup) {
|
|
571
|
+
try { val.cleanup(); } catch (e) {}
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
__fynixPropsCache.clear();
|
|
577
|
+
|
|
578
|
+
// Clean up global namespace
|
|
579
|
+
if (window[PROPS_NAMESPACE]) {
|
|
580
|
+
Object.keys(window[PROPS_NAMESPACE]).forEach(key => {
|
|
581
|
+
delete window[PROPS_NAMESPACE][key];
|
|
582
|
+
});
|
|
583
|
+
delete window[PROPS_NAMESPACE];
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Clear last route props
|
|
587
|
+
// @ts-ignore
|
|
588
|
+
if (window.__lastRouteProps) {
|
|
589
|
+
// @ts-ignore
|
|
590
|
+
delete window.__lastRouteProps;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Reset singleton flags at the VERY end
|
|
594
|
+
isRouterInitialized = false;
|
|
595
|
+
routerInstance = null;
|
|
596
|
+
|
|
597
|
+
console.log("[Router] Cleanup complete");
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// @ts-ignore - Vite HMR API
|
|
601
|
+
if (import.meta.hot) {
|
|
602
|
+
// @ts-ignore
|
|
603
|
+
import.meta.hot.accept(() => {
|
|
604
|
+
console.log("[Router] HMR detected, re-rendering route...");
|
|
605
|
+
renderRoute();
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// @ts-ignore
|
|
609
|
+
import.meta.hot.dispose(() => {
|
|
610
|
+
console.log("[Router] HMR dispose, cleaning up...");
|
|
611
|
+
cleanup();
|
|
612
|
+
// Reset singleton flags for HMR
|
|
613
|
+
routerInstance = null;
|
|
614
|
+
isRouterInitialized = false;
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const router = {
|
|
619
|
+
mountRouter,
|
|
620
|
+
navigate,
|
|
621
|
+
replace,
|
|
622
|
+
back,
|
|
623
|
+
cleanup,
|
|
624
|
+
routes,
|
|
625
|
+
dynamicRoutes,
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
routerInstance = router;
|
|
629
|
+
return router;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Helper: Set props for links
|
|
634
|
+
*/
|
|
635
|
+
export function setLinkProps(key, props) {
|
|
636
|
+
if (typeof key !== 'string' || key.startsWith('__')) {
|
|
637
|
+
console.error('[Router] Invalid props key');
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (!props || typeof props !== 'object') {
|
|
642
|
+
console.error('[Router] Invalid props object');
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (!window[PROPS_NAMESPACE]) {
|
|
647
|
+
window[PROPS_NAMESPACE] = {};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (Object.keys(window[PROPS_NAMESPACE]).length >= MAX_CACHE_SIZE) {
|
|
651
|
+
console.warn('[Router] Props storage limit reached');
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
window[PROPS_NAMESPACE][key] = props;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Helper: Clear link props
|
|
660
|
+
*/
|
|
661
|
+
export function clearLinkProps(key) {
|
|
662
|
+
if (typeof key !== 'string') return;
|
|
663
|
+
|
|
664
|
+
if (window[PROPS_NAMESPACE]?.[key]) {
|
|
665
|
+
const props = window[PROPS_NAMESPACE][key];
|
|
666
|
+
if (props && typeof props === 'object') {
|
|
667
|
+
Object.values(props).forEach(val => {
|
|
668
|
+
if (val && typeof val === 'object' && val.cleanup) {
|
|
669
|
+
try { val.cleanup(); } catch (e) {}
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
delete window[PROPS_NAMESPACE][key];
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Named export for better IDE support
|
|
678
|
+
export { createFynix };
|