baremetal.js 1.0.1 → 1.2.1

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.1",
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"
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
+ }
29
46
  }
package/src/index.js CHANGED
@@ -1,12 +1,21 @@
1
+ /**
2
+ * baremetal.js v1.2.1
3
+ * A lightweight, dependency-free Vanilla JavaScript SPA engine prioritizing extreme performance, native browser features, and explicit lifecycle management.
4
+ * (c) 2026 dkydivyansh
5
+ * Released under the GPL-3.0 License
6
+ */
7
+
1
8
  import { stateManager } from './state.js';
2
9
  import { Loader, loader } from './loader.js';
3
10
  import { Router } from './router.js';
11
+ import { virtualize } from './virtualize.js';
4
12
 
5
13
  const BareMetal = {
6
14
  state: stateManager,
7
- events: stateManager, // Aliased for backward compatibility with Pub/Sub mental model
15
+ events: stateManager,
8
16
  loader: loader,
9
17
  router: Router,
18
+ virtualize: virtualize,
10
19
 
11
20
  init(config = {}) {
12
21
  if (config.debug !== undefined) Loader.setConfig({ debug: config.debug });
@@ -16,7 +25,16 @@ const BareMetal = {
16
25
  if (config.hoverPrefetch !== undefined) Loader.setConfig({ hoverPrefetch: config.hoverPrefetch });
17
26
  if (config.showErrorNotification !== undefined) Loader.setConfig({ showErrorNotification: config.showErrorNotification });
18
27
 
19
- // Initialize Router
28
+ if (config.persistState !== undefined) {
29
+ Loader.setConfig({ persistState: config.persistState });
30
+ if (config.persistState) {
31
+ window.__baremetal_persist_state = true;
32
+ stateManager.initPersistence();
33
+ }
34
+ }
35
+
36
+ if (config.virtualizeDom !== undefined) Loader.setConfig({ virtualizeDom: config.virtualizeDom });
37
+
20
38
  Router.init();
21
39
  Loader.log("Initialized BareMetal Engine with config:", config);
22
40
  }
package/src/loader.js CHANGED
@@ -1,9 +1,16 @@
1
+ /**
2
+ * baremetal.js v1.2.1
3
+ * A lightweight, dependency-free Vanilla JavaScript SPA engine prioritizing extreme performance, native browser features, and explicit lifecycle management.
4
+ * (c) 2026 dkydivyansh
5
+ * Released under the GPL-3.0 License
6
+ */
7
+
1
8
  import { stateManager } from './state.js';
2
9
 
3
10
  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
-
11
+ activeModules: {},
12
+ config: { keepAliveSameModules: true, debug: false, autoWrap: true, hoverPrefetch: false, showErrorNotification: false, persistState: false, virtualizeDom: false, transition: { enabled: false, simulatedDelay: 0, module: null, useViewTransitions: false } },
13
+
7
14
  setConfig(globalConfig) {
8
15
  this.config = { ...this.config, ...globalConfig };
9
16
  },
@@ -12,11 +19,8 @@ export const Loader = {
12
19
  if (this.config.debug) console.log('[BareMetal Loader]', ...args);
13
20
  },
14
21
 
15
- /**
16
- * Prepares the transition by destroying obsolete modules and identifying new ones.
17
- */
18
22
  async prepare(newConfig) {
19
- // Auto-inject transition module if enabled
23
+
20
24
  if (this.config.transition && this.config.transition.enabled) {
21
25
  const transitionPath = this.config.transition.module || '/src/transition.js';
22
26
  newConfig['__baremetal_transition'] = transitionPath;
@@ -28,17 +32,17 @@ export const Loader = {
28
32
 
29
33
  for (const [key, mod] of Object.entries(this.activeModules)) {
30
34
  const isImmortal = key === '__baremetal_transition';
31
- if ((this.config.keepAliveSameModules || isImmortal) && newConfig[key] === mod.path) {
32
- // Keep alive
35
+ const newPath = typeof newConfig[key] === 'string' ? newConfig[key] : (newConfig[key] ? newConfig[key].path : null);
36
+ if ((this.config.keepAliveSameModules || isImmortal) && newPath === mod.path) {
37
+
33
38
  modulesToKeep[key] = mod;
34
39
  this.log(`Keep-Alive: ${key} (${mod.path})`);
35
40
  } else {
36
- // Mark for destruction
41
+
37
42
  modulesToDestroy.push(mod);
38
43
  }
39
44
  }
40
45
 
41
- // Destroy obsolete modules
42
46
  for (const mod of modulesToDestroy) {
43
47
  this.log(`Destroying module: ${mod.path}`);
44
48
  if (mod.module && typeof mod.module.destroy === 'function') {
@@ -50,83 +54,108 @@ export const Loader = {
50
54
  }
51
55
  }
52
56
 
53
- // Determine what's new
54
57
  for (const [key, path] of Object.entries(newConfig)) {
55
58
  if (!modulesToKeep[key]) {
56
59
  modulesToLoad[key] = path;
57
60
  }
58
61
  }
59
62
 
60
- // Temporarily update active modules to only the kept ones
61
63
  this.activeModules = modulesToKeep;
62
64
 
63
65
  return modulesToLoad;
64
66
  },
65
67
 
66
- /**
67
- * Loads the prepared modules. Called by Router after DOM swap.
68
- */
69
68
  async loadPrepared(modulesToLoad) {
70
69
  const total = Object.keys(modulesToLoad).length;
71
70
  let loaded = 0;
72
71
 
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);
72
+ const loadPromises = Object.entries(modulesToLoad).map(async ([key, modDef]) => {
73
+ const path = typeof modDef === 'string' ? modDef : modDef.path;
74
+ const lazySelector = typeof modDef === 'string' ? null : modDef.lazy;
75
+
76
+ this.log(`Preparing module: ${path}`);
77
+
78
+ const doImport = async () => {
79
+ try {
80
+ const resolvedPath = new URL(path, document.baseURI).href;
81
+ const loadPath = this.config.debug ? `${resolvedPath}?t=${Date.now()}` : resolvedPath;
82
+
83
+ let module;
84
+
85
+ if (this.config.autoWrap) {
86
+ const response = await fetch(loadPath);
87
+ if (!response.ok) throw new Error(`Failed to fetch ${loadPath}`);
88
+ const sourceText = await response.text();
89
+
90
+ const hasMount = /export\s+(function|const|let|var)\s+mount\b/.test(sourceText) || /export\s+\{.*?\bmount\b.*?\}/.test(sourceText);
91
+
92
+ if (!hasMount) {
93
+ console.warn(`[BareMetal] WARNING: Module ${path} does not explicitly export a mount() function. Auto-wrapping it...`);
94
+ const wrappedSource = `export async function mount(context) { const { state } = context; ${sourceText} }`;
95
+ const blob = new Blob([wrappedSource], { type: 'application/javascript' });
96
+ const blobUrl = URL.createObjectURL(blob);
97
+ module = await import(blobUrl);
98
+ URL.revokeObjectURL(blobUrl);
99
+ } else {
100
+ module = await import(loadPath);
101
+ }
103
102
  } else {
104
103
  module = await import(loadPath);
105
104
  }
106
- } else {
107
- // Auto-wrapping disabled, load natively
108
- module = await import(loadPath);
105
+
106
+ if (typeof module.mount === 'function') {
107
+ this.log(`Mounting module: ${path}`);
108
+
109
+ const context = { state: stateManager };
110
+ if (this.config.virtualizeDom) {
111
+ const { virtualize } = await import('./virtualize.js');
112
+ context.virtualize = virtualize;
113
+ }
114
+
115
+ const instance = await module.mount(context);
116
+
117
+ this.activeModules[key] = {
118
+ path: path,
119
+ module: instance ? { destroy: instance.destroy } : module
120
+ };
121
+ } else {
122
+ console.error(`[BareMetal] Module ${path} failed to provide a mount function even after wrapping.`);
123
+ }
124
+ } catch (err) {
125
+ console.error(`Failed to load module: ${path}`, err);
109
126
  }
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
- };
127
+ };
128
+
129
+ if (lazySelector) {
130
+ const element = document.querySelector(lazySelector);
131
+ if (element && window.IntersectionObserver) {
132
+ this.log(`Deferred loading of module ${path} until ${lazySelector} is visible`);
133
+ const observer = new IntersectionObserver((entries) => {
134
+ if (entries[0].isIntersecting) {
135
+ observer.disconnect();
136
+ doImport();
137
+ }
138
+ });
139
+ observer.observe(element);
140
+
141
+ loaded++;
142
+ if (total > 0) {
143
+ const progress = 50 + (loaded / total) * 50;
144
+ stateManager.publish('ROUTE_PROGRESS', { url: window.location.pathname, progress });
145
+ }
146
+ return Promise.resolve();
119
147
  } 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 });
148
+
149
+ await doImport();
129
150
  }
151
+ } else {
152
+ await doImport();
153
+ }
154
+
155
+ loaded++;
156
+ if (total > 0) {
157
+ const progress = 50 + (loaded / total) * 50;
158
+ stateManager.publish('ROUTE_PROGRESS', { url: window.location.pathname, progress });
130
159
  }
131
160
  });
132
161
 
@@ -134,16 +163,12 @@ export const Loader = {
134
163
  stateManager.publish('ROUTE_END', { url: window.location.pathname });
135
164
  },
136
165
 
137
- /**
138
- * Legacy standalone load (used on first page load directly)
139
- */
140
166
  async load(config) {
141
167
  const modulesToLoad = await this.prepare(config);
142
168
  await this.loadPrepared(modulesToLoad);
143
169
  }
144
170
  };
145
171
 
146
- // The user-facing API
147
172
  export function loader(config) {
148
173
  return Loader.load(config);
149
174
  }
package/src/router.js CHANGED
@@ -1,3 +1,10 @@
1
+ /**
2
+ * baremetal.js v1.2.1
3
+ * A lightweight, dependency-free Vanilla JavaScript SPA engine prioritizing extreme performance, native browser features, and explicit lifecycle management.
4
+ * (c) 2026 dkydivyansh
5
+ * Released under the GPL-3.0 License
6
+ */
7
+
1
8
  import { Loader } from './loader.js';
2
9
  import { stateManager } from './state.js';
3
10
 
@@ -5,33 +12,30 @@ export const Router = {
5
12
  htmlCache: {},
6
13
  scrollMemory: {},
7
14
  historyStack: [],
15
+ currentAbortController: null,
8
16
 
9
17
  init() {
10
18
  if ('scrollRestoration' in history) {
11
19
  history.scrollRestoration = 'manual';
12
20
  }
13
21
  window.addEventListener('popstate', this.handleRoute.bind(this));
14
-
15
- // Intercept all link clicks
22
+
16
23
  document.body.addEventListener('click', e => {
17
- // Find closest anchor tag
24
+
18
25
  const anchor = e.target.closest('a');
19
26
  if (!anchor) return;
20
27
 
21
- // Ignore external domains, target="_blank", noreferrer, or downloads
22
28
  if (
23
- anchor.origin !== window.location.origin ||
29
+ anchor.origin !== window.location.origin ||
24
30
  anchor.target === '_blank' ||
25
31
  (anchor.rel && anchor.rel.includes('noreferrer')) ||
26
32
  anchor.hasAttribute('download')
27
33
  ) {
28
- return; // Let browser handle it naturally
34
+ return;
29
35
  }
30
36
 
31
- // Intercept same-origin internal links
32
37
  e.preventDefault();
33
-
34
- // Save current state before navigating away
38
+
35
39
  this.historyStack.push(window.location.pathname);
36
40
  this.scrollMemory[window.location.pathname] = window.scrollY;
37
41
 
@@ -39,18 +43,17 @@ export const Router = {
39
43
  this.handleRoute();
40
44
  });
41
45
 
42
- // Hover Pre-fetching
43
46
  document.body.addEventListener('mouseover', e => {
44
47
  if (!Loader.config.hoverPrefetch) return;
45
48
  const anchor = e.target.closest('a');
46
49
  if (!anchor) return;
47
50
  if (
48
- anchor.origin === window.location.origin &&
49
- anchor.target !== '_blank' &&
51
+ anchor.origin === window.location.origin &&
52
+ anchor.target !== '_blank' &&
50
53
  !anchor.hasAttribute('download') &&
51
54
  !this.htmlCache[anchor.href]
52
55
  ) {
53
- this.htmlCache[anchor.href] = 'fetching'; // Prevent duplicate fetches
56
+ this.htmlCache[anchor.href] = 'fetching';
54
57
  fetch(anchor.href)
55
58
  .then(res => {
56
59
  if (res.ok) return res.text();
@@ -63,13 +66,13 @@ export const Router = {
63
66
  },
64
67
 
65
68
  back() {
66
- // Custom programmatic back button
69
+
67
70
  if (this.historyStack.length > 0) {
68
71
  const prevUrl = this.historyStack.pop();
69
72
  history.pushState(null, '', prevUrl);
70
73
  this.handleRoute();
71
74
  } else {
72
- history.back(); // Fallback to browser's native back
75
+ history.back();
73
76
  }
74
77
  },
75
78
 
@@ -77,7 +80,14 @@ export const Router = {
77
80
  window.location.reload();
78
81
  },
79
82
 
80
- async handleRoute() {
83
+ async handleRoute(e) {
84
+
85
+ if (this.currentAbortController) {
86
+ this.currentAbortController.abort();
87
+ }
88
+ this.currentAbortController = new AbortController();
89
+ const signal = this.currentAbortController.signal;
90
+
81
91
  const url = window.location.pathname;
82
92
  try {
83
93
  Loader.log(`Navigating to ${url}`);
@@ -92,31 +102,30 @@ export const Router = {
92
102
 
93
103
  let htmlText;
94
104
  const fullUrl = new URL(url, document.baseURI).href;
95
-
105
+
96
106
  if (Loader.config.hoverPrefetch && this.htmlCache[fullUrl] && this.htmlCache[fullUrl] !== 'fetching') {
97
107
  htmlText = this.htmlCache[fullUrl];
98
108
  Loader.log(`Used pre-fetched cache for ${url}`);
99
109
  } else {
100
- const response = await fetch(url);
110
+ const response = await fetch(url, { signal });
101
111
  if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
102
112
  htmlText = await response.text();
103
113
  }
104
-
114
+
105
115
  stateManager.publish('ROUTE_PROGRESS', { url, progress: 50 });
106
116
 
107
117
  const parser = new DOMParser();
108
118
  const doc = parser.parseFromString(htmlText, 'text/html');
109
-
110
- // 1. Extract Config
119
+
111
120
  let config = null;
112
121
  const scriptTags = doc.querySelectorAll('script');
113
122
  for (const script of scriptTags) {
114
123
  if (script.textContent.includes('loader(')) {
115
- // Extract the JSON-like object from loader({...})
124
+
116
125
  const match = script.textContent.match(/loader\s*\(\s*(\{[\s\S]*?\})\s*\)/);
117
126
  if (match && match[1]) {
118
127
  try {
119
- // Using Function to safely parse object string that might not be strict JSON
128
+
120
129
  config = new Function('return ' + match[1])();
121
130
  } catch (e) {
122
131
  console.error("Failed to parse loader config in new page", e);
@@ -125,61 +134,53 @@ export const Router = {
125
134
  }
126
135
  }
127
136
 
128
- // If no BareMetal loader config is found, fallback to standard navigation
129
137
  if (!config) {
130
138
  Loader.log(`No BareMetal config found on ${url}. Falling back to native navigation.`);
131
139
  window.location.assign(url);
132
140
  return;
133
141
  }
134
142
 
135
- // 2. Prepare transition: identify kept vs destroyed modules
136
143
  const modulesToLoad = await Loader.prepare(config);
137
144
 
138
- // 3. Swap DOM and Head
139
145
  document.title = doc.title;
140
-
141
- // Swap Head Styles (prevent CSS leak)
146
+
142
147
  const oldStyles = document.head.querySelectorAll('link[data-baremetal="style"], style[data-baremetal="style"]');
143
148
  oldStyles.forEach(s => s.remove());
144
149
  const newStyles = doc.head.querySelectorAll('link[data-baremetal="style"], style[data-baremetal="style"]');
145
150
  newStyles.forEach(s => document.head.appendChild(s.cloneNode(true)));
146
151
 
147
- // User Protected Elements (data-baremetal-preserve)
148
152
  const preservedNodes = [];
149
153
  const persistentElements = document.querySelectorAll('[data-baremetal-preserve]');
150
-
154
+
151
155
  persistentElements.forEach(el => {
152
156
  if (!el.id) return;
153
157
  if (doc.getElementById(el.id)) {
154
- // Synchronously detach the live node
158
+
155
159
  const placeholder = document.createElement('div');
156
160
  el.parentNode.replaceChild(placeholder, el);
157
161
  preservedNodes.push(el);
158
162
  }
159
163
  });
160
164
 
161
- // Engine Protected Elements (Immortal)
162
165
  const transitionRoot = document.getElementById('baremetal-transition-root');
163
166
  if (transitionRoot) {
164
167
  transitionRoot.parentNode.removeChild(transitionRoot);
165
168
  }
166
169
 
167
- // The actual synchronous DOM swap and restoration
168
170
  const executeDOMSwap = () => {
169
171
  document.body.innerHTML = doc.body.innerHTML;
170
172
 
171
- // Restore User Protected Elements into their exact new positions
172
173
  preservedNodes.forEach(el => {
173
174
  const newEl = document.getElementById(el.id);
174
175
  if (newEl) {
175
- // Sync attributes from the new HTML node so classes/styles update
176
+
176
177
  Array.from(el.attributes).forEach(attr => {
177
178
  if (attr.name !== 'id' && attr.name !== 'data-baremetal-preserve') el.removeAttribute(attr.name);
178
179
  });
179
180
  Array.from(newEl.attributes).forEach(attr => {
180
181
  if (attr.name !== 'id') el.setAttribute(attr.name, attr.value);
181
182
  });
182
-
183
+
183
184
  newEl.parentNode.replaceChild(el, newEl);
184
185
  }
185
186
  });
@@ -188,7 +189,6 @@ export const Router = {
188
189
  document.body.appendChild(transitionRoot);
189
190
  }
190
191
 
191
- // Notify keep-alive modules that the DOM has been swapped so they can re-bind UI elements
192
192
  stateManager.publish('DOM_SWAPPED', null);
193
193
  };
194
194
 
@@ -205,21 +205,23 @@ export const Router = {
205
205
  doSwap();
206
206
  }
207
207
 
208
- // 4. Mount new modules (this emits ROUTE_END)
209
208
  await Loader.loadPrepared(modulesToLoad);
210
209
 
211
210
  } catch (err) {
211
+ if (err.name === 'AbortError') {
212
+ Loader.log(`Aborted fetch for ${url} due to new navigation.`);
213
+ return;
214
+ }
212
215
  console.error("Routing error:", err);
213
216
  stateManager.publish('ROUTE_ERROR', { url, error: err.message });
214
-
217
+
215
218
  if (Loader.config.showErrorNotification) {
216
- // Revert URL bar since we are aborting
219
+
217
220
  if (this.historyStack.length > 0) {
218
221
  const prev = this.historyStack.pop();
219
222
  history.replaceState(null, '', prev);
220
223
  }
221
224
 
222
- // Show floating notification
223
225
  const notif = document.createElement('div');
224
226
  notif.style.position = 'fixed';
225
227
  notif.style.bottom = '20px';
@@ -233,16 +235,15 @@ export const Router = {
233
235
  notif.style.fontFamily = 'sans-serif';
234
236
  notif.style.transition = 'opacity 0.3s ease';
235
237
  notif.innerHTML = `<strong>Navigation Failed:</strong> ${err.message}`;
236
-
238
+
237
239
  document.body.appendChild(notif);
238
-
240
+
239
241
  setTimeout(() => {
240
242
  notif.style.opacity = '0';
241
243
  setTimeout(() => notif.remove(), 300);
242
244
  }, 4000);
243
245
  } 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!
246
+
246
247
  if (this.historyStack.length > 0) {
247
248
  const prev = this.historyStack.pop();
248
249
  history.replaceState(null, '', prev);