baremetal.js 1.0.1 → 1.2.2

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/docs/api.md CHANGED
@@ -7,41 +7,25 @@ BareMetal exports its core engine as a global configuration object. Here are the
7
7
  ### `BareMetal.init(config)`
8
8
  Initializes the engine, sets up the Router, and binds global configurations.
9
9
 
10
- **Parameters:**
11
- - `config.debug` *(boolean)*: Enable verbose console logs (default: `false`).
12
- - `config.keepAliveSameModules` *(boolean)*: If true, modules shared between routes are not destroyed during navigation (default: `true`).
13
- - `config.autoWrap` *(boolean)*: If true, modules that do not export a `mount` function are automatically wrapped in a dynamic Blob URL to prevent syntax errors (default: `true`).
14
- - `config.transition` *(object)*: Configuration for the native page transition API.
15
- - `config.transition.enabled` *(boolean)*: Enables the protected transition module.
16
- - `config.transition.module` *(string)*: Optional path to a custom transition module (defaults to `/src/transition.js`).
17
- - `config.transition.simulatedDelay` *(number)*: Optional artificial delay in milliseconds (useful for testing animations locally).
18
- - `config.transition.useViewTransitions` *(boolean)*: Enables the native View Transitions API for smooth cross-fades during navigation (default: `false`).
19
- - `config.offline` *(object)*: Configuration for offline Progressive Web App (PWA) support.
20
- - `config.offline.enabled` *(boolean)*: Enables Service Worker registration (default: `false`).
21
- - `config.offline.version` *(string)*: Version identifier for the offline cache (e.g., `'1.0.0'`). Bump this to push updates to users.
22
- - `config.offline.installType` *(string)*: `'auto'` (installs silently), `'consent'` (shows a popup), or `'custom'` (triggered manually via `BareMetal.offline.install()`).
23
- - `config.offline.delay` *(number)*: Milliseconds to wait before auto-install or showing the consent popup.
24
- - `config.offline.showInfo` *(boolean)*: If true, displays themed toast notifications at the bottom left during installation/updates.
25
- - `config.offline.assets` *(string[])*: An array of URLs to strictly pre-cache upon installation.
10
+ **Configuration Reference:**
11
+
12
+ | Option | Type | Default | Description |
13
+ |--------|------|---------|-------------|
14
+ | `debug` | boolean | `false` | Enable verbose console logs. |
15
+ | `keepAliveSameModules` | boolean | `true` | Prevent destruction of modules shared between routes. |
16
+ | `autoWrap` | boolean | `true` | Automatically wrap modules that do not export a `mount` function. |
17
+ | `hoverPrefetch` | boolean | `false` | Enable 0ms latency pre-fetching on link hover. |
18
+ | `persistState` | boolean | `false` | Automatically serialize `stateManager` to `sessionStorage` for F5 resilience. |
19
+ | `virtualizeDom` | boolean | `false` | Inject the `Virtualizer` helper into module mount contexts. |
20
+ | `showErrorNotification` | boolean | `false` | Enable floating error boundaries on navigation failures. |
21
+ | `transition.enabled` | boolean | `false` | Enable the protected transition module. |
22
+ | `transition.module` | string | `null` | Path to a custom transition module (defaults to `/src/transition.js`). |
23
+ | `transition.simulatedDelay` | number | `0` | Artificial delay (ms) for testing transitions. |
24
+ | `transition.useViewTransitions` | boolean | `false` | Enables the native View Transitions API for smooth cross-fades during navigation. |
26
25
 
27
26
  ---
28
27
 
29
- ## 2. Offline Manager
30
-
31
- Accessible via `BareMetal.offline` when enabled.
32
-
33
- ### `offline.install()`
34
- Manually triggers the background download of all resources specified in `config.offline.assets`.
35
-
36
- ### `offline.update()`
37
- Triggers a background fetch to update the cached assets based on the current `config.offline.version`.
38
-
39
- ### `offline.remove()`
40
- Unregisters the Service Worker and deletes all offline caches.
41
-
42
- ---
43
-
44
- ## 3. Router Navigation
28
+ ## 2. Router Navigation
45
29
 
46
30
  ### `BareMetal.router.reload()`
47
31
  Executes a hard reload of the current SPA page by calling `window.location.reload()`. This is useful when you want to force the browser to wipe the entire SPA state and fetch fresh assets.
@@ -78,7 +62,7 @@ Listens for a Pub/Sub event.
78
62
 
79
63
  ---
80
64
 
81
- ## 5. Writing Custom Page Transitions
65
+ ## 4. Writing Custom Page Transitions
82
66
 
83
67
  You can replace the default progress bar and fade overlay by pointing `config.transition.module` to your own JavaScript file. A custom transition is just a standard BareMetal module that listens to routing events.
84
68
 
@@ -128,5 +112,3 @@ export function mount({ state }) {
128
112
  };
129
113
  }
130
114
  ```
131
-
132
-
package/package.json CHANGED
@@ -1,11 +1,22 @@
1
1
  {
2
2
  "name": "baremetal.js",
3
- "version": "1.0.1",
3
+ "version": "1.2.2",
4
4
  "description": "A lightweight, dependency-free Vanilla JavaScript SPA engine prioritizing extreme performance, native browser features, and explicit lifecycle management.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
7
+ "files": [
8
+ "src",
9
+ "dist",
10
+ "LICENSE",
11
+ "CHANGELOG.md",
12
+ "docs/api.md",
13
+ "README.md",
14
+ "SECURITY.md"
15
+ ],
7
16
  "scripts": {
8
- "test": "echo \"No tests written yet.\" && exit 0"
17
+ "build": "rollup -c",
18
+ "test": "vitest run",
19
+ "test:watch": "vitest"
9
20
  },
10
21
  "repository": {
11
22
  "type": "git",
@@ -20,10 +31,16 @@
20
31
  "baremetal",
21
32
  "performance"
22
33
  ],
23
- "author": "Divyansh",
34
+ "author": "dkydivyansh",
24
35
  "license": "GPL-3.0",
25
36
  "bugs": {
26
37
  "url": "https://github.com/dkydivyansh/BareMetal.js/issues"
27
38
  },
28
- "homepage": "https://BareMetal.dkydivyansh.com"
29
- }
39
+ "homepage": "https://BareMetal.dkydivyansh.com",
40
+ "devDependencies": {
41
+ "@rollup/plugin-terser": "^1.0.0",
42
+ "jsdom": "^29.1.1",
43
+ "rollup": "^4.62.0",
44
+ "vitest": "^4.1.9"
45
+ }
46
+ }
package/src/index.js CHANGED
@@ -1,12 +1,14 @@
1
1
  import { stateManager } from './state.js';
2
2
  import { Loader, loader } from './loader.js';
3
3
  import { Router } from './router.js';
4
+ import { virtualize } from './virtualize.js';
4
5
 
5
6
  const BareMetal = {
6
7
  state: stateManager,
7
- events: stateManager, // Aliased for backward compatibility with Pub/Sub mental model
8
+ events: stateManager,
8
9
  loader: loader,
9
10
  router: Router,
11
+ virtualize: virtualize,
10
12
 
11
13
  init(config = {}) {
12
14
  if (config.debug !== undefined) Loader.setConfig({ debug: config.debug });
@@ -16,7 +18,16 @@ const BareMetal = {
16
18
  if (config.hoverPrefetch !== undefined) Loader.setConfig({ hoverPrefetch: config.hoverPrefetch });
17
19
  if (config.showErrorNotification !== undefined) Loader.setConfig({ showErrorNotification: config.showErrorNotification });
18
20
 
19
- // Initialize Router
21
+ if (config.persistState !== undefined) {
22
+ Loader.setConfig({ persistState: config.persistState });
23
+ if (config.persistState) {
24
+ window.__baremetal_persist_state = true;
25
+ stateManager.initPersistence();
26
+ }
27
+ }
28
+
29
+ if (config.virtualizeDom !== undefined) Loader.setConfig({ virtualizeDom: config.virtualizeDom });
30
+
20
31
  Router.init();
21
32
  Loader.log("Initialized BareMetal Engine with config:", config);
22
33
  }
package/src/loader.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { stateManager } from './state.js';
2
2
 
3
3
  export const Loader = {
4
- activeModules: {}, // { key: { path: "./dash.js", module: exportedModule } }
5
- config: { keepAliveSameModules: true, debug: false, autoWrap: true, hoverPrefetch: false, showErrorNotification: false, transition: { enabled: false, simulatedDelay: 0, module: null, useViewTransitions: false } },
6
-
4
+ activeModules: {},
5
+ config: { keepAliveSameModules: true, debug: false, autoWrap: true, hoverPrefetch: false, showErrorNotification: false, persistState: false, virtualizeDom: false, transition: { enabled: false, simulatedDelay: 0, module: null, useViewTransitions: false } },
6
+
7
7
  setConfig(globalConfig) {
8
8
  this.config = { ...this.config, ...globalConfig };
9
9
  },
@@ -12,11 +12,8 @@ export const Loader = {
12
12
  if (this.config.debug) console.log('[BareMetal Loader]', ...args);
13
13
  },
14
14
 
15
- /**
16
- * Prepares the transition by destroying obsolete modules and identifying new ones.
17
- */
18
15
  async prepare(newConfig) {
19
- // Auto-inject transition module if enabled
16
+
20
17
  if (this.config.transition && this.config.transition.enabled) {
21
18
  const transitionPath = this.config.transition.module || '/src/transition.js';
22
19
  newConfig['__baremetal_transition'] = transitionPath;
@@ -28,17 +25,17 @@ export const Loader = {
28
25
 
29
26
  for (const [key, mod] of Object.entries(this.activeModules)) {
30
27
  const isImmortal = key === '__baremetal_transition';
31
- if ((this.config.keepAliveSameModules || isImmortal) && newConfig[key] === mod.path) {
32
- // Keep alive
28
+ const newPath = typeof newConfig[key] === 'string' ? newConfig[key] : (newConfig[key] ? newConfig[key].path : null);
29
+ if ((this.config.keepAliveSameModules || isImmortal) && newPath === mod.path) {
30
+
33
31
  modulesToKeep[key] = mod;
34
32
  this.log(`Keep-Alive: ${key} (${mod.path})`);
35
33
  } else {
36
- // Mark for destruction
34
+
37
35
  modulesToDestroy.push(mod);
38
36
  }
39
37
  }
40
38
 
41
- // Destroy obsolete modules
42
39
  for (const mod of modulesToDestroy) {
43
40
  this.log(`Destroying module: ${mod.path}`);
44
41
  if (mod.module && typeof mod.module.destroy === 'function') {
@@ -50,83 +47,108 @@ export const Loader = {
50
47
  }
51
48
  }
52
49
 
53
- // Determine what's new
54
50
  for (const [key, path] of Object.entries(newConfig)) {
55
51
  if (!modulesToKeep[key]) {
56
52
  modulesToLoad[key] = path;
57
53
  }
58
54
  }
59
55
 
60
- // Temporarily update active modules to only the kept ones
61
56
  this.activeModules = modulesToKeep;
62
57
 
63
58
  return modulesToLoad;
64
59
  },
65
60
 
66
- /**
67
- * Loads the prepared modules. Called by Router after DOM swap.
68
- */
69
61
  async loadPrepared(modulesToLoad) {
70
62
  const total = Object.keys(modulesToLoad).length;
71
63
  let loaded = 0;
72
64
 
73
- const loadPromises = Object.entries(modulesToLoad).map(async ([key, path]) => {
74
- this.log(`Importing module: ${path}`);
75
- try {
76
- const resolvedPath = new URL(path, document.baseURI).href;
77
- const loadPath = this.config.debug ? `${resolvedPath}?t=${Date.now()}` : resolvedPath;
78
-
79
- let module;
80
-
81
- if (this.config.autoWrap) {
82
- // Fetch source to check if it needs auto-wrapping
83
- const response = await fetch(loadPath);
84
- if (!response.ok) throw new Error(`Failed to fetch ${loadPath}`);
85
- const sourceText = await response.text();
86
-
87
- const hasMount = /export\s+(function|const|let|var)\s+mount\b/.test(sourceText) || /export\s+\{.*?\bmount\b.*?\}/.test(sourceText);
88
-
89
- if (!hasMount) {
90
- console.warn(`[BareMetal] WARNING: Module ${path} does not explicitly export a mount() function. Auto-wrapping it...`);
91
-
92
- const wrappedSource = `
93
- export async function mount(context) {
94
- const { state } = context;
95
- ${sourceText}
96
- }
97
- `;
98
-
99
- const blob = new Blob([wrappedSource], { type: 'application/javascript' });
100
- const blobUrl = URL.createObjectURL(blob);
101
- module = await import(blobUrl);
102
- URL.revokeObjectURL(blobUrl);
65
+ const loadPromises = Object.entries(modulesToLoad).map(async ([key, modDef]) => {
66
+ const path = typeof modDef === 'string' ? modDef : modDef.path;
67
+ const lazySelector = typeof modDef === 'string' ? null : modDef.lazy;
68
+
69
+ this.log(`Preparing module: ${path}`);
70
+
71
+ const doImport = async () => {
72
+ try {
73
+ const resolvedPath = new URL(path, document.baseURI).href;
74
+ const loadPath = this.config.debug ? `${resolvedPath}?t=${Date.now()}` : resolvedPath;
75
+
76
+ let module;
77
+
78
+ if (this.config.autoWrap) {
79
+ const response = await fetch(loadPath);
80
+ if (!response.ok) throw new Error(`Failed to fetch ${loadPath}`);
81
+ const sourceText = await response.text();
82
+
83
+ const hasMount = /export\s+(function|const|let|var)\s+mount\b/.test(sourceText) || /export\s+\{.*?\bmount\b.*?\}/.test(sourceText);
84
+
85
+ if (!hasMount) {
86
+ console.warn(`[BareMetal] WARNING: Module ${path} does not explicitly export a mount() function. Auto-wrapping it...`);
87
+ const wrappedSource = `export async function mount(context) { const { state } = context; ${sourceText} }`;
88
+ const blob = new Blob([wrappedSource], { type: 'application/javascript' });
89
+ const blobUrl = URL.createObjectURL(blob);
90
+ module = await import(blobUrl);
91
+ URL.revokeObjectURL(blobUrl);
92
+ } else {
93
+ module = await import(loadPath);
94
+ }
103
95
  } else {
104
96
  module = await import(loadPath);
105
97
  }
106
- } else {
107
- // Auto-wrapping disabled, load natively
108
- module = await import(loadPath);
98
+
99
+ if (typeof module.mount === 'function') {
100
+ this.log(`Mounting module: ${path}`);
101
+
102
+ const context = { state: stateManager };
103
+ if (this.config.virtualizeDom) {
104
+ const { virtualize } = await import('./virtualize.js');
105
+ context.virtualize = virtualize;
106
+ }
107
+
108
+ const instance = await module.mount(context);
109
+
110
+ this.activeModules[key] = {
111
+ path: path,
112
+ module: instance ? { destroy: instance.destroy } : module
113
+ };
114
+ } else {
115
+ console.error(`[BareMetal] Module ${path} failed to provide a mount function even after wrapping.`);
116
+ }
117
+ } catch (err) {
118
+ console.error(`Failed to load module: ${path}`, err);
109
119
  }
110
-
111
- if (typeof module.mount === 'function') {
112
- this.log(`Mounting module: ${path}`);
113
- const instance = await module.mount({ state: stateManager });
114
-
115
- this.activeModules[key] = {
116
- path: path,
117
- module: instance ? { destroy: instance.destroy } : module
118
- };
120
+ };
121
+
122
+ if (lazySelector) {
123
+ const element = document.querySelector(lazySelector);
124
+ if (element && window.IntersectionObserver) {
125
+ this.log(`Deferred loading of module ${path} until ${lazySelector} is visible`);
126
+ const observer = new IntersectionObserver((entries) => {
127
+ if (entries[0].isIntersecting) {
128
+ observer.disconnect();
129
+ doImport();
130
+ }
131
+ });
132
+ observer.observe(element);
133
+
134
+ loaded++;
135
+ if (total > 0) {
136
+ const progress = 50 + (loaded / total) * 50;
137
+ stateManager.publish('ROUTE_PROGRESS', { url: window.location.pathname, progress });
138
+ }
139
+ return Promise.resolve();
119
140
  } else {
120
- console.error(`[BareMetal] Module ${path} failed to provide a mount function even after wrapping.`);
121
- }
122
- } catch (err) {
123
- console.error(`Failed to load module: ${path}`, err);
124
- } finally {
125
- loaded++;
126
- if (total > 0) {
127
- const progress = 50 + (loaded / total) * 50;
128
- stateManager.publish('ROUTE_PROGRESS', { url: window.location.pathname, progress });
141
+
142
+ await doImport();
129
143
  }
144
+ } else {
145
+ await doImport();
146
+ }
147
+
148
+ loaded++;
149
+ if (total > 0) {
150
+ const progress = 50 + (loaded / total) * 50;
151
+ stateManager.publish('ROUTE_PROGRESS', { url: window.location.pathname, progress });
130
152
  }
131
153
  });
132
154
 
@@ -134,16 +156,12 @@ export const Loader = {
134
156
  stateManager.publish('ROUTE_END', { url: window.location.pathname });
135
157
  },
136
158
 
137
- /**
138
- * Legacy standalone load (used on first page load directly)
139
- */
140
159
  async load(config) {
141
160
  const modulesToLoad = await this.prepare(config);
142
161
  await this.loadPrepared(modulesToLoad);
143
162
  }
144
163
  };
145
164
 
146
- // The user-facing API
147
165
  export function loader(config) {
148
166
  return Loader.load(config);
149
167
  }
package/src/router.js CHANGED
@@ -5,33 +5,30 @@ export const Router = {
5
5
  htmlCache: {},
6
6
  scrollMemory: {},
7
7
  historyStack: [],
8
+ currentAbortController: null,
8
9
 
9
10
  init() {
10
11
  if ('scrollRestoration' in history) {
11
12
  history.scrollRestoration = 'manual';
12
13
  }
13
14
  window.addEventListener('popstate', this.handleRoute.bind(this));
14
-
15
- // Intercept all link clicks
15
+
16
16
  document.body.addEventListener('click', e => {
17
- // Find closest anchor tag
17
+
18
18
  const anchor = e.target.closest('a');
19
19
  if (!anchor) return;
20
20
 
21
- // Ignore external domains, target="_blank", noreferrer, or downloads
22
21
  if (
23
- anchor.origin !== window.location.origin ||
22
+ anchor.origin !== window.location.origin ||
24
23
  anchor.target === '_blank' ||
25
24
  (anchor.rel && anchor.rel.includes('noreferrer')) ||
26
25
  anchor.hasAttribute('download')
27
26
  ) {
28
- return; // Let browser handle it naturally
27
+ return;
29
28
  }
30
29
 
31
- // Intercept same-origin internal links
32
30
  e.preventDefault();
33
-
34
- // Save current state before navigating away
31
+
35
32
  this.historyStack.push(window.location.pathname);
36
33
  this.scrollMemory[window.location.pathname] = window.scrollY;
37
34
 
@@ -39,18 +36,17 @@ export const Router = {
39
36
  this.handleRoute();
40
37
  });
41
38
 
42
- // Hover Pre-fetching
43
39
  document.body.addEventListener('mouseover', e => {
44
40
  if (!Loader.config.hoverPrefetch) return;
45
41
  const anchor = e.target.closest('a');
46
42
  if (!anchor) return;
47
43
  if (
48
- anchor.origin === window.location.origin &&
49
- anchor.target !== '_blank' &&
44
+ anchor.origin === window.location.origin &&
45
+ anchor.target !== '_blank' &&
50
46
  !anchor.hasAttribute('download') &&
51
47
  !this.htmlCache[anchor.href]
52
48
  ) {
53
- this.htmlCache[anchor.href] = 'fetching'; // Prevent duplicate fetches
49
+ this.htmlCache[anchor.href] = 'fetching';
54
50
  fetch(anchor.href)
55
51
  .then(res => {
56
52
  if (res.ok) return res.text();
@@ -63,13 +59,13 @@ export const Router = {
63
59
  },
64
60
 
65
61
  back() {
66
- // Custom programmatic back button
62
+
67
63
  if (this.historyStack.length > 0) {
68
64
  const prevUrl = this.historyStack.pop();
69
65
  history.pushState(null, '', prevUrl);
70
66
  this.handleRoute();
71
67
  } else {
72
- history.back(); // Fallback to browser's native back
68
+ history.back();
73
69
  }
74
70
  },
75
71
 
@@ -77,7 +73,14 @@ export const Router = {
77
73
  window.location.reload();
78
74
  },
79
75
 
80
- async handleRoute() {
76
+ async handleRoute(e) {
77
+
78
+ if (this.currentAbortController) {
79
+ this.currentAbortController.abort();
80
+ }
81
+ this.currentAbortController = new AbortController();
82
+ const signal = this.currentAbortController.signal;
83
+
81
84
  const url = window.location.pathname;
82
85
  try {
83
86
  Loader.log(`Navigating to ${url}`);
@@ -92,31 +95,30 @@ export const Router = {
92
95
 
93
96
  let htmlText;
94
97
  const fullUrl = new URL(url, document.baseURI).href;
95
-
98
+
96
99
  if (Loader.config.hoverPrefetch && this.htmlCache[fullUrl] && this.htmlCache[fullUrl] !== 'fetching') {
97
100
  htmlText = this.htmlCache[fullUrl];
98
101
  Loader.log(`Used pre-fetched cache for ${url}`);
99
102
  } else {
100
- const response = await fetch(url);
103
+ const response = await fetch(url, { signal });
101
104
  if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
102
105
  htmlText = await response.text();
103
106
  }
104
-
107
+
105
108
  stateManager.publish('ROUTE_PROGRESS', { url, progress: 50 });
106
109
 
107
110
  const parser = new DOMParser();
108
111
  const doc = parser.parseFromString(htmlText, 'text/html');
109
-
110
- // 1. Extract Config
112
+
111
113
  let config = null;
112
114
  const scriptTags = doc.querySelectorAll('script');
113
115
  for (const script of scriptTags) {
114
116
  if (script.textContent.includes('loader(')) {
115
- // Extract the JSON-like object from loader({...})
117
+
116
118
  const match = script.textContent.match(/loader\s*\(\s*(\{[\s\S]*?\})\s*\)/);
117
119
  if (match && match[1]) {
118
120
  try {
119
- // Using Function to safely parse object string that might not be strict JSON
121
+
120
122
  config = new Function('return ' + match[1])();
121
123
  } catch (e) {
122
124
  console.error("Failed to parse loader config in new page", e);
@@ -125,61 +127,53 @@ export const Router = {
125
127
  }
126
128
  }
127
129
 
128
- // If no BareMetal loader config is found, fallback to standard navigation
129
130
  if (!config) {
130
131
  Loader.log(`No BareMetal config found on ${url}. Falling back to native navigation.`);
131
132
  window.location.assign(url);
132
133
  return;
133
134
  }
134
135
 
135
- // 2. Prepare transition: identify kept vs destroyed modules
136
136
  const modulesToLoad = await Loader.prepare(config);
137
137
 
138
- // 3. Swap DOM and Head
139
138
  document.title = doc.title;
140
-
141
- // Swap Head Styles (prevent CSS leak)
139
+
142
140
  const oldStyles = document.head.querySelectorAll('link[data-baremetal="style"], style[data-baremetal="style"]');
143
141
  oldStyles.forEach(s => s.remove());
144
142
  const newStyles = doc.head.querySelectorAll('link[data-baremetal="style"], style[data-baremetal="style"]');
145
143
  newStyles.forEach(s => document.head.appendChild(s.cloneNode(true)));
146
144
 
147
- // User Protected Elements (data-baremetal-preserve)
148
145
  const preservedNodes = [];
149
146
  const persistentElements = document.querySelectorAll('[data-baremetal-preserve]');
150
-
147
+
151
148
  persistentElements.forEach(el => {
152
149
  if (!el.id) return;
153
150
  if (doc.getElementById(el.id)) {
154
- // Synchronously detach the live node
151
+
155
152
  const placeholder = document.createElement('div');
156
153
  el.parentNode.replaceChild(placeholder, el);
157
154
  preservedNodes.push(el);
158
155
  }
159
156
  });
160
157
 
161
- // Engine Protected Elements (Immortal)
162
158
  const transitionRoot = document.getElementById('baremetal-transition-root');
163
159
  if (transitionRoot) {
164
160
  transitionRoot.parentNode.removeChild(transitionRoot);
165
161
  }
166
162
 
167
- // The actual synchronous DOM swap and restoration
168
163
  const executeDOMSwap = () => {
169
164
  document.body.innerHTML = doc.body.innerHTML;
170
165
 
171
- // Restore User Protected Elements into their exact new positions
172
166
  preservedNodes.forEach(el => {
173
167
  const newEl = document.getElementById(el.id);
174
168
  if (newEl) {
175
- // Sync attributes from the new HTML node so classes/styles update
169
+
176
170
  Array.from(el.attributes).forEach(attr => {
177
171
  if (attr.name !== 'id' && attr.name !== 'data-baremetal-preserve') el.removeAttribute(attr.name);
178
172
  });
179
173
  Array.from(newEl.attributes).forEach(attr => {
180
174
  if (attr.name !== 'id') el.setAttribute(attr.name, attr.value);
181
175
  });
182
-
176
+
183
177
  newEl.parentNode.replaceChild(el, newEl);
184
178
  }
185
179
  });
@@ -188,7 +182,6 @@ export const Router = {
188
182
  document.body.appendChild(transitionRoot);
189
183
  }
190
184
 
191
- // Notify keep-alive modules that the DOM has been swapped so they can re-bind UI elements
192
185
  stateManager.publish('DOM_SWAPPED', null);
193
186
  };
194
187
 
@@ -205,21 +198,23 @@ export const Router = {
205
198
  doSwap();
206
199
  }
207
200
 
208
- // 4. Mount new modules (this emits ROUTE_END)
209
201
  await Loader.loadPrepared(modulesToLoad);
210
202
 
211
203
  } catch (err) {
204
+ if (err.name === 'AbortError') {
205
+ Loader.log(`Aborted fetch for ${url} due to new navigation.`);
206
+ return;
207
+ }
212
208
  console.error("Routing error:", err);
213
209
  stateManager.publish('ROUTE_ERROR', { url, error: err.message });
214
-
210
+
215
211
  if (Loader.config.showErrorNotification) {
216
- // Revert URL bar since we are aborting
212
+
217
213
  if (this.historyStack.length > 0) {
218
214
  const prev = this.historyStack.pop();
219
215
  history.replaceState(null, '', prev);
220
216
  }
221
217
 
222
- // Show floating notification
223
218
  const notif = document.createElement('div');
224
219
  notif.style.position = 'fixed';
225
220
  notif.style.bottom = '20px';
@@ -233,16 +228,15 @@ export const Router = {
233
228
  notif.style.fontFamily = 'sans-serif';
234
229
  notif.style.transition = 'opacity 0.3s ease';
235
230
  notif.innerHTML = `<strong>Navigation Failed:</strong> ${err.message}`;
236
-
231
+
237
232
  document.body.appendChild(notif);
238
-
233
+
239
234
  setTimeout(() => {
240
235
  notif.style.opacity = '0';
241
236
  setTimeout(() => notif.remove(), 300);
242
237
  }, 4000);
243
238
  } else {
244
- // Formal redirect to let server handle the error
245
- // FIRST: Undo the pushState so we don't leave a phantom history entry that breaks the Back button!
239
+
246
240
  if (this.historyStack.length > 0) {
247
241
  const prev = this.historyStack.pop();
248
242
  history.replaceState(null, '', prev);