baremetal.js 1.2.2 → 1.2.5

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/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.5] - 2026-07-01
9
+
10
+ ### Added
11
+ - Added `onCleanup` hook for safe module cleanup. Implement a cleanup registry that allows modules to register cleanup callbacks via `context.onCleanup()` instead of explicitly returning a destroy function.
12
+
13
+ ## [1.2.3] - 2026-06-19
14
+
15
+ ### Added
16
+ - Added comprehensive documentation pages for `BareMetal.init()` configuration options (`api-config.html`).
17
+
18
+ ### Changed
19
+ - Updated the documentation site to dog-food the live jsdelivr CDN for its BareMetal imports.
20
+
21
+ ### Fixed
22
+ - Fixed the router to correctly ignore anchor links and navigations that only modify the URL hash fragment (`#hash`), preventing unnecessary page reloads and pre-fetches.
23
+
8
24
  ## [1.2.0] - 2026-06-18
9
25
 
10
26
  ### Added
package/README.md CHANGED
@@ -26,12 +26,22 @@ Load heavy widgets only when they scroll into view! The Loader supports an `Inte
26
26
  Never lose state on a hard reload again. If enabled, BareMetal automatically serializes the `stateManager` to `sessionStorage` in real-time. If the user hits F5, the engine instantly hydrates the state and bounces right back to where they were!
27
27
 
28
28
  ### DOM Virtualization Helper
29
- Rendering 10,000 table rows? BareMetal exposes a `Virtualizer` class that recycles DOM nodes to render massive lists with zero jank. When enabled globally, the engine automatically injects the `virtualize` helper directly into your module's `mount` context!.
30
- - **Scroll Memory & Programmatic Back:** Maintains a persistent history stack, restoring your exact scroll depth instantly when navigating backward.
31
- - **Reactive State Management:** Includes a built-in publish/subscribe Signals pattern, preventing race conditions and keeping your UI synced.
32
- - **Custom Page Transitions:** Build and integrate your own loading animations and transition effects hooking into the routing lifecycle.
33
- - **Error Boundaries:** Handles navigation failures gracefully with configurable fallback UIs.
34
- - **Auto-Wrap Module Loader:** Can automatically wrap simple scripts into conformant lifecycle modules to prevent errors.
29
+ Rendering 10,000 table rows? BareMetal exposes a `Virtualizer` class that recycles DOM nodes to render massive lists with zero jank. When enabled globally, the engine automatically injects the `virtualize` helper directly into your module's `mount` context.
30
+
31
+ ### Scroll Memory & Programmatic Back
32
+ Maintains a persistent history stack, restoring your exact scroll depth instantly when navigating backward.
33
+
34
+ ### Reactive State Management
35
+ Includes a built-in publish/subscribe Signals pattern, preventing race conditions and keeping your UI synced.
36
+
37
+ ### Custom Page Transitions
38
+ Build and integrate your own loading animations and transition effects hooking into the routing lifecycle.
39
+
40
+ ### Error Boundaries
41
+ Handles navigation failures gracefully with configurable fallback UIs.
42
+
43
+ ### Auto-Wrap Module Loader
44
+ Can automatically wrap simple scripts into conformant lifecycle modules to prevent errors.
35
45
 
36
46
  ## Quick Start
37
47
 
package/dist/baremetal.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * baremetal.js v1.2.2
2
+ * baremetal.js v1.2.5
3
3
  * A lightweight, dependency-free Vanilla JavaScript SPA engine prioritizing extreme performance, native browser features, and explicit lifecycle management.
4
4
  * (c) 2026 dkydivyansh
5
5
  * Released under the GPL-3.0 License
@@ -194,11 +194,22 @@ const Loader = {
194
194
  context.virtualize = virtualize;
195
195
  }
196
196
 
197
+ // Safe Cleanup Registry
198
+ const cleanups = [];
199
+ context.onCleanup = (cb) => cleanups.push(cb);
200
+
197
201
  const instance = await module.mount(context);
198
202
 
203
+ const autoDestroy = () => {
204
+ cleanups.forEach(cb => { try { cb(); } catch(e) { console.error(e); } });
205
+ if (instance && typeof instance.destroy === 'function') {
206
+ try { instance.destroy(); } catch(e) { console.error(e); }
207
+ }
208
+ };
209
+
199
210
  this.activeModules[key] = {
200
211
  path: path,
201
- module: instance ? { destroy: instance.destroy } : module
212
+ module: { destroy: autoDestroy }
202
213
  };
203
214
  } else {
204
215
  console.error(`[BareMetal] Module ${path} failed to provide a mount function even after wrapping.`);
@@ -260,8 +271,10 @@ const Router = {
260
271
  scrollMemory: {},
261
272
  historyStack: [],
262
273
  currentAbortController: null,
274
+ lastPathAndSearch: '',
263
275
 
264
276
  init() {
277
+ this.lastPathAndSearch = window.location.pathname + window.location.search;
265
278
  if ('scrollRestoration' in history) {
266
279
  history.scrollRestoration = 'manual';
267
280
  }
@@ -276,7 +289,8 @@ const Router = {
276
289
  anchor.origin !== window.location.origin ||
277
290
  anchor.target === '_blank' ||
278
291
  (anchor.rel && anchor.rel.includes('noreferrer')) ||
279
- anchor.hasAttribute('download')
292
+ anchor.hasAttribute('download') ||
293
+ (anchor.getAttribute('href') || '').startsWith('#')
280
294
  ) {
281
295
  return;
282
296
  }
@@ -298,6 +312,7 @@ const Router = {
298
312
  anchor.origin === window.location.origin &&
299
313
  anchor.target !== '_blank' &&
300
314
  !anchor.hasAttribute('download') &&
315
+ !(anchor.getAttribute('href') || '').startsWith('#') &&
301
316
  !this.htmlCache[anchor.href]
302
317
  ) {
303
318
  this.htmlCache[anchor.href] = 'fetching';
@@ -328,6 +343,11 @@ const Router = {
328
343
  },
329
344
 
330
345
  async handleRoute(e) {
346
+ const currentPathAndSearch = window.location.pathname + window.location.search;
347
+ if (this.lastPathAndSearch === currentPathAndSearch) {
348
+ return; // Ignore navigations that only change the #hash
349
+ }
350
+ this.lastPathAndSearch = currentPathAndSearch;
331
351
 
332
352
  if (this.currentAbortController) {
333
353
  this.currentAbortController.abort();
@@ -355,6 +375,15 @@ const Router = {
355
375
  Loader.log(`Used pre-fetched cache for ${url}`);
356
376
  } else {
357
377
  const response = await fetch(url, { signal });
378
+
379
+ if (response.redirected) {
380
+ const newUrl = new URL(response.url);
381
+ Loader.log(`Redirect detected to ${newUrl.pathname}. Continuing SPA transition to new destination.`);
382
+ // Sync the URL bar and router state to the redirect destination
383
+ history.replaceState(null, '', newUrl.pathname + newUrl.search);
384
+ this.lastPathAndSearch = newUrl.pathname + newUrl.search;
385
+ }
386
+
358
387
  if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
359
388
  htmlText = await response.text();
360
389
  }
@@ -1 +1 @@
1
- const t=new class{constructor(){this.state={},this.listeners={},this.eventBus={}}initPersistence(){try{const t=sessionStorage.getItem("baremetal_state");if(t){const e=JSON.parse(t);for(const[t,i]of Object.entries(e))this.state[t]=i}}catch(t){console.warn("Failed to hydrate state",t)}}init(t,e){void 0===this.state[t]&&(this.state[t]=e)}subscribe(t,e){return this.listeners[t]||(this.listeners[t]=[]),this.listeners[t].push(e),void 0!==this.state[t]&&e(this.state[t]),()=>this.unsubscribe(t,e)}unsubscribe(t,e){this.listeners[t]&&(this.listeners[t]=this.listeners[t].filter(t=>t!==e))}update(t,e){if(this.state[t]=e,this.listeners[t]&&this.listeners[t].forEach(t=>t(e)),window.__baremetal_persist_state)try{sessionStorage.setItem("baremetal_state",JSON.stringify(this.state))}catch(t){}}get(t){return this.state[t]}on(t,e){return this.eventBus[t]||(this.eventBus[t]=[]),this.eventBus[t].push(e),()=>this.off(t,e)}off(t,e){this.eventBus[t]&&(this.eventBus[t]=this.eventBus[t].filter(t=>t!==e))}publish(t,e){this.eventBus[t]&&this.eventBus[t].forEach(t=>t(e))}},e={activeModules:{},config:{keepAliveSameModules:!0,debug:!1,autoWrap:!0,hoverPrefetch:!1,showErrorNotification:!1,persistState:!1,virtualizeDom:!1,transition:{enabled:!1,simulatedDelay:0,module:null,useViewTransitions:!1}},setConfig(t){this.config={...this.config,...t}},log(...t){this.config.debug&&console.log("[BareMetal Loader]",...t)},async prepare(t){if(this.config.transition&&this.config.transition.enabled){const e=this.config.transition.module||"/src/transition.js";t.__baremetal_transition=e}const e={},i=[],o={};for(const[o,s]of Object.entries(this.activeModules)){const n="__baremetal_transition"===o,r="string"==typeof t[o]?t[o]:t[o]?t[o].path:null;(this.config.keepAliveSameModules||n)&&r===s.path?(e[o]=s,this.log(`Keep-Alive: ${o} (${s.path})`)):i.push(s)}for(const t of i)if(this.log(`Destroying module: ${t.path}`),t.module&&"function"==typeof t.module.destroy)try{t.module.destroy()}catch(e){console.error(`Error destroying module ${t.path}`,e)}for(const[i,s]of Object.entries(t))e[i]||(o[i]=s);return this.activeModules=e,o},async loadPrepared(e){const i=Object.keys(e).length;let o=0;const s=Object.entries(e).map(async([e,s])=>{const n="string"==typeof s?s:s.path,a="string"==typeof s?null:s.lazy;this.log(`Preparing module: ${n}`);const l=async()=>{try{const i=new URL(n,document.baseURI).href,o=this.config.debug?`${i}?t=${Date.now()}`:i;let s;if(this.config.autoWrap){const t=await fetch(o);if(!t.ok)throw new Error(`Failed to fetch ${o}`);const e=await t.text();if(/export\s+(function|const|let|var)\s+mount\b/.test(e)||/export\s+\{.*?\bmount\b.*?\}/.test(e))s=await import(o);else{console.warn(`[BareMetal] WARNING: Module ${n} does not explicitly export a mount() function. Auto-wrapping it...`);const t=`export async function mount(context) { const { state } = context; ${e} }`,i=new Blob([t],{type:"application/javascript"}),o=URL.createObjectURL(i);s=await import(o),URL.revokeObjectURL(o)}}else s=await import(o);if("function"==typeof s.mount){this.log(`Mounting module: ${n}`);const i={state:t};if(this.config.virtualizeDom){const{virtualize:t}=await Promise.resolve().then(function(){return r});i.virtualize=t}const o=await s.mount(i);this.activeModules[e]={path:n,module:o?{destroy:o.destroy}:s}}else console.error(`[BareMetal] Module ${n} failed to provide a mount function even after wrapping.`)}catch(t){console.error(`Failed to load module: ${n}`,t)}};if(a){const e=document.querySelector(a);if(e&&window.IntersectionObserver){this.log(`Deferred loading of module ${n} until ${a} is visible`);const s=new IntersectionObserver(t=>{t[0].isIntersecting&&(s.disconnect(),l())});if(s.observe(e),o++,i>0){const e=50+o/i*50;t.publish("ROUTE_PROGRESS",{url:window.location.pathname,progress:e})}return Promise.resolve()}await l()}else await l();if(o++,i>0){const e=50+o/i*50;t.publish("ROUTE_PROGRESS",{url:window.location.pathname,progress:e})}});await Promise.all(s),t.publish("ROUTE_END",{url:window.location.pathname})},async load(t){const e=await this.prepare(t);await this.loadPrepared(e)}};function i(t){return e.load(t)}const o={htmlCache:{},scrollMemory:{},historyStack:[],currentAbortController:null,init(){"scrollRestoration"in history&&(history.scrollRestoration="manual"),window.addEventListener("popstate",this.handleRoute.bind(this)),document.body.addEventListener("click",t=>{const e=t.target.closest("a");e&&(e.origin!==window.location.origin||"_blank"===e.target||e.rel&&e.rel.includes("noreferrer")||e.hasAttribute("download")||(t.preventDefault(),this.historyStack.push(window.location.pathname),this.scrollMemory[window.location.pathname]=window.scrollY,history.pushState(null,"",e.href),this.handleRoute()))}),document.body.addEventListener("mouseover",t=>{if(!e.config.hoverPrefetch)return;const i=t.target.closest("a");i&&(i.origin!==window.location.origin||"_blank"===i.target||i.hasAttribute("download")||this.htmlCache[i.href]||(this.htmlCache[i.href]="fetching",fetch(i.href).then(t=>{if(t.ok)return t.text();throw new Error("Failed to prefetch")}).then(t=>this.htmlCache[i.href]=t).catch(()=>delete this.htmlCache[i.href])))})},back(){if(this.historyStack.length>0){const t=this.historyStack.pop();history.pushState(null,"",t),this.handleRoute()}else history.back()},reload(){window.location.reload()},async handleRoute(i){this.currentAbortController&&this.currentAbortController.abort(),this.currentAbortController=new AbortController;const o=this.currentAbortController.signal,s=window.location.pathname;try{let n;e.log(`Navigating to ${s}`),t.publish("ROUTE_START",{url:s}),e.config.transition&&e.config.transition.simulatedDelay&&(t.publish("ROUTE_PROGRESS",{url:s,progress:10}),await new Promise(t=>setTimeout(t,e.config.transition.simulatedDelay/2)),t.publish("ROUTE_PROGRESS",{url:s,progress:30}),await new Promise(t=>setTimeout(t,e.config.transition.simulatedDelay/2)));const r=new URL(s,document.baseURI).href;if(e.config.hoverPrefetch&&this.htmlCache[r]&&"fetching"!==this.htmlCache[r])n=this.htmlCache[r],e.log(`Used pre-fetched cache for ${s}`);else{const t=await fetch(s,{signal:o});if(!t.ok)throw new Error(`HTTP error! status: ${t.status}`);n=await t.text()}t.publish("ROUTE_PROGRESS",{url:s,progress:50});const a=(new DOMParser).parseFromString(n,"text/html");let l=null;const c=a.querySelectorAll("script");for(const t of c)if(t.textContent.includes("loader(")){const e=t.textContent.match(/loader\s*\(\s*(\{[\s\S]*?\})\s*\)/);if(e&&e[1])try{l=new Function("return "+e[1])()}catch(i){console.error("Failed to parse loader config in new page",i)}}if(!l)return e.log(`No BareMetal config found on ${s}. Falling back to native navigation.`),void window.location.assign(s);const h=await e.prepare(l);document.title=a.title;document.head.querySelectorAll('link[data-baremetal="style"], style[data-baremetal="style"]').forEach(t=>t.remove());a.head.querySelectorAll('link[data-baremetal="style"], style[data-baremetal="style"]').forEach(t=>document.head.appendChild(t.cloneNode(!0)));const d=[];document.querySelectorAll("[data-baremetal-preserve]").forEach(t=>{if(t.id&&a.getElementById(t.id)){const e=document.createElement("div");t.parentNode.replaceChild(e,t),d.push(t)}});const u=document.getElementById("baremetal-transition-root");u&&u.parentNode.removeChild(u);const p=()=>{document.body.innerHTML=a.body.innerHTML,d.forEach(t=>{const e=document.getElementById(t.id);e&&(Array.from(t.attributes).forEach(e=>{"id"!==e.name&&"data-baremetal-preserve"!==e.name&&t.removeAttribute(e.name)}),Array.from(e.attributes).forEach(e=>{"id"!==e.name&&t.setAttribute(e.name,e.value)}),e.parentNode.replaceChild(t,e))}),u&&document.body.appendChild(u),t.publish("DOM_SWAPPED",null)},f=()=>{p(),window.scrollTo(0,this.scrollMemory[s]||0)};e.config.transition&&e.config.transition.useViewTransitions&&document.startViewTransition?document.startViewTransition(()=>{f()}):f(),await e.loadPrepared(h)}catch(i){if("AbortError"===i.name)return void e.log(`Aborted fetch for ${s} due to new navigation.`);if(console.error("Routing error:",i),t.publish("ROUTE_ERROR",{url:s,error:i.message}),e.config.showErrorNotification){if(this.historyStack.length>0){const t=this.historyStack.pop();history.replaceState(null,"",t)}const t=document.createElement("div");t.style.position="fixed",t.style.bottom="20px",t.style.left="20px",t.style.background="#e74c3c",t.style.color="white",t.style.padding="15px 20px",t.style.borderRadius="8px",t.style.boxShadow="0 4px 12px rgba(0,0,0,0.15)",t.style.zIndex="999999",t.style.fontFamily="sans-serif",t.style.transition="opacity 0.3s ease",t.innerHTML=`<strong>Navigation Failed:</strong> ${i.message}`,document.body.appendChild(t),setTimeout(()=>{t.style.opacity="0",setTimeout(()=>t.remove(),300)},4e3)}else{if(this.historyStack.length>0){const t=this.historyStack.pop();history.replaceState(null,"",t)}window.location.assign(s)}}}};class s{constructor(t,e,i,o,s=20){this.container=document.getElementById(t),this.container&&(this.items=e,this.renderRow=i,this.itemHeight=o,this.visibleCount=s,this.totalHeight=this.items.length*this.itemHeight,this.container.style.overflowY="auto",this.container.style.position="relative",this.innerWrapper=document.createElement("div"),this.innerWrapper.style.height=`${this.totalHeight}px`,this.innerWrapper.style.position="relative",this.container.appendChild(this.innerWrapper),this.startIndex=0,this.onScroll=this.onScroll.bind(this),this.container.addEventListener("scroll",this.onScroll),this.render())}onScroll(){const t=this.container.scrollTop,e=Math.max(0,Math.floor(t/this.itemHeight)-2);e!==this.startIndex&&(this.startIndex=e,this.render())}render(){this.innerWrapper.innerHTML="";const t=Math.min(this.items.length-1,this.startIndex+this.visibleCount+4);for(let e=this.startIndex;e<=t;e++){const t=this.renderRow(this.items[e],e);t.style.position="absolute",t.style.top=e*this.itemHeight+"px",t.style.width="100%",this.innerWrapper.appendChild(t)}}destroy(){this.container&&(this.container.removeEventListener("scroll",this.onScroll),this.container.innerHTML="")}}const n=(t,e,i,o,n)=>new s(t,e,i,o,n);var r=Object.freeze({__proto__:null,Virtualizer:s,virtualize:n});const a={state:t,events:t,loader:i,router:o,virtualize:n,init(i={}){void 0!==i.debug&&e.setConfig({debug:i.debug}),void 0!==i.keepAliveSameModules&&e.setConfig({keepAliveSameModules:i.keepAliveSameModules}),void 0!==i.transition&&e.setConfig({transition:i.transition}),void 0!==i.autoWrap&&e.setConfig({autoWrap:i.autoWrap}),void 0!==i.hoverPrefetch&&e.setConfig({hoverPrefetch:i.hoverPrefetch}),void 0!==i.showErrorNotification&&e.setConfig({showErrorNotification:i.showErrorNotification}),void 0!==i.persistState&&(e.setConfig({persistState:i.persistState}),i.persistState&&(window.__baremetal_persist_state=!0,t.initPersistence())),void 0!==i.virtualizeDom&&e.setConfig({virtualizeDom:i.virtualizeDom}),o.init(),e.log("Initialized BareMetal Engine with config:",i)}};export{a as BareMetal,i as loader};
1
+ const t=new class{constructor(){this.state={},this.listeners={},this.eventBus={}}initPersistence(){try{const t=sessionStorage.getItem("baremetal_state");if(t){const e=JSON.parse(t);for(const[t,i]of Object.entries(e))this.state[t]=i}}catch(t){console.warn("Failed to hydrate state",t)}}init(t,e){void 0===this.state[t]&&(this.state[t]=e)}subscribe(t,e){return this.listeners[t]||(this.listeners[t]=[]),this.listeners[t].push(e),void 0!==this.state[t]&&e(this.state[t]),()=>this.unsubscribe(t,e)}unsubscribe(t,e){this.listeners[t]&&(this.listeners[t]=this.listeners[t].filter(t=>t!==e))}update(t,e){if(this.state[t]=e,this.listeners[t]&&this.listeners[t].forEach(t=>t(e)),window.__baremetal_persist_state)try{sessionStorage.setItem("baremetal_state",JSON.stringify(this.state))}catch(t){}}get(t){return this.state[t]}on(t,e){return this.eventBus[t]||(this.eventBus[t]=[]),this.eventBus[t].push(e),()=>this.off(t,e)}off(t,e){this.eventBus[t]&&(this.eventBus[t]=this.eventBus[t].filter(t=>t!==e))}publish(t,e){this.eventBus[t]&&this.eventBus[t].forEach(t=>t(e))}},e={activeModules:{},config:{keepAliveSameModules:!0,debug:!1,autoWrap:!0,hoverPrefetch:!1,showErrorNotification:!1,persistState:!1,virtualizeDom:!1,transition:{enabled:!1,simulatedDelay:0,module:null,useViewTransitions:!1}},setConfig(t){this.config={...this.config,...t}},log(...t){this.config.debug&&console.log("[BareMetal Loader]",...t)},async prepare(t){if(this.config.transition&&this.config.transition.enabled){const e=this.config.transition.module||"/src/transition.js";t.__baremetal_transition=e}const e={},i=[],o={};for(const[o,s]of Object.entries(this.activeModules)){const n="__baremetal_transition"===o,r="string"==typeof t[o]?t[o]:t[o]?t[o].path:null;(this.config.keepAliveSameModules||n)&&r===s.path?(e[o]=s,this.log(`Keep-Alive: ${o} (${s.path})`)):i.push(s)}for(const t of i)if(this.log(`Destroying module: ${t.path}`),t.module&&"function"==typeof t.module.destroy)try{t.module.destroy()}catch(e){console.error(`Error destroying module ${t.path}`,e)}for(const[i,s]of Object.entries(t))e[i]||(o[i]=s);return this.activeModules=e,o},async loadPrepared(e){const i=Object.keys(e).length;let o=0;const s=Object.entries(e).map(async([e,s])=>{const n="string"==typeof s?s:s.path,a="string"==typeof s?null:s.lazy;this.log(`Preparing module: ${n}`);const l=async()=>{try{const i=new URL(n,document.baseURI).href,o=this.config.debug?`${i}?t=${Date.now()}`:i;let s;if(this.config.autoWrap){const t=await fetch(o);if(!t.ok)throw new Error(`Failed to fetch ${o}`);const e=await t.text();if(/export\s+(function|const|let|var)\s+mount\b/.test(e)||/export\s+\{.*?\bmount\b.*?\}/.test(e))s=await import(o);else{console.warn(`[BareMetal] WARNING: Module ${n} does not explicitly export a mount() function. Auto-wrapping it...`);const t=`export async function mount(context) { const { state } = context; ${e} }`,i=new Blob([t],{type:"application/javascript"}),o=URL.createObjectURL(i);s=await import(o),URL.revokeObjectURL(o)}}else s=await import(o);if("function"==typeof s.mount){this.log(`Mounting module: ${n}`);const i={state:t};if(this.config.virtualizeDom){const{virtualize:t}=await Promise.resolve().then(function(){return r});i.virtualize=t}const o=[];i.onCleanup=t=>o.push(t);const a=await s.mount(i),l=()=>{if(o.forEach(t=>{try{t()}catch(t){console.error(t)}}),a&&"function"==typeof a.destroy)try{a.destroy()}catch(t){console.error(t)}};this.activeModules[e]={path:n,module:{destroy:l}}}else console.error(`[BareMetal] Module ${n} failed to provide a mount function even after wrapping.`)}catch(t){console.error(`Failed to load module: ${n}`,t)}};if(a){const e=document.querySelector(a);if(e&&window.IntersectionObserver){this.log(`Deferred loading of module ${n} until ${a} is visible`);const s=new IntersectionObserver(t=>{t[0].isIntersecting&&(s.disconnect(),l())});if(s.observe(e),o++,i>0){const e=50+o/i*50;t.publish("ROUTE_PROGRESS",{url:window.location.pathname,progress:e})}return Promise.resolve()}await l()}else await l();if(o++,i>0){const e=50+o/i*50;t.publish("ROUTE_PROGRESS",{url:window.location.pathname,progress:e})}});await Promise.all(s),t.publish("ROUTE_END",{url:window.location.pathname})},async load(t){const e=await this.prepare(t);await this.loadPrepared(e)}};function i(t){return e.load(t)}const o={htmlCache:{},scrollMemory:{},historyStack:[],currentAbortController:null,lastPathAndSearch:"",init(){this.lastPathAndSearch=window.location.pathname+window.location.search,"scrollRestoration"in history&&(history.scrollRestoration="manual"),window.addEventListener("popstate",this.handleRoute.bind(this)),document.body.addEventListener("click",t=>{const e=t.target.closest("a");e&&(e.origin!==window.location.origin||"_blank"===e.target||e.rel&&e.rel.includes("noreferrer")||e.hasAttribute("download")||(e.getAttribute("href")||"").startsWith("#")||(t.preventDefault(),this.historyStack.push(window.location.pathname),this.scrollMemory[window.location.pathname]=window.scrollY,history.pushState(null,"",e.href),this.handleRoute()))}),document.body.addEventListener("mouseover",t=>{if(!e.config.hoverPrefetch)return;const i=t.target.closest("a");i&&(i.origin!==window.location.origin||"_blank"===i.target||i.hasAttribute("download")||(i.getAttribute("href")||"").startsWith("#")||this.htmlCache[i.href]||(this.htmlCache[i.href]="fetching",fetch(i.href).then(t=>{if(t.ok)return t.text();throw new Error("Failed to prefetch")}).then(t=>this.htmlCache[i.href]=t).catch(()=>delete this.htmlCache[i.href])))})},back(){if(this.historyStack.length>0){const t=this.historyStack.pop();history.pushState(null,"",t),this.handleRoute()}else history.back()},reload(){window.location.reload()},async handleRoute(i){const o=window.location.pathname+window.location.search;if(this.lastPathAndSearch===o)return;this.lastPathAndSearch=o,this.currentAbortController&&this.currentAbortController.abort(),this.currentAbortController=new AbortController;const s=this.currentAbortController.signal,n=window.location.pathname;try{let o;e.log(`Navigating to ${n}`),t.publish("ROUTE_START",{url:n}),e.config.transition&&e.config.transition.simulatedDelay&&(t.publish("ROUTE_PROGRESS",{url:n,progress:10}),await new Promise(t=>setTimeout(t,e.config.transition.simulatedDelay/2)),t.publish("ROUTE_PROGRESS",{url:n,progress:30}),await new Promise(t=>setTimeout(t,e.config.transition.simulatedDelay/2)));const r=new URL(n,document.baseURI).href;if(e.config.hoverPrefetch&&this.htmlCache[r]&&"fetching"!==this.htmlCache[r])o=this.htmlCache[r],e.log(`Used pre-fetched cache for ${n}`);else{const t=await fetch(n,{signal:s});if(t.redirected){const i=new URL(t.url);e.log(`Redirect detected to ${i.pathname}. Continuing SPA transition to new destination.`),history.replaceState(null,"",i.pathname+i.search),this.lastPathAndSearch=i.pathname+i.search}if(!t.ok)throw new Error(`HTTP error! status: ${t.status}`);o=await t.text()}t.publish("ROUTE_PROGRESS",{url:n,progress:50});const a=(new DOMParser).parseFromString(o,"text/html");let l=null;const c=a.querySelectorAll("script");for(const t of c)if(t.textContent.includes("loader(")){const e=t.textContent.match(/loader\s*\(\s*(\{[\s\S]*?\})\s*\)/);if(e&&e[1])try{l=new Function("return "+e[1])()}catch(i){console.error("Failed to parse loader config in new page",i)}}if(!l)return e.log(`No BareMetal config found on ${n}. Falling back to native navigation.`),void window.location.assign(n);const h=await e.prepare(l);document.title=a.title;document.head.querySelectorAll('link[data-baremetal="style"], style[data-baremetal="style"]').forEach(t=>t.remove());a.head.querySelectorAll('link[data-baremetal="style"], style[data-baremetal="style"]').forEach(t=>document.head.appendChild(t.cloneNode(!0)));const d=[];document.querySelectorAll("[data-baremetal-preserve]").forEach(t=>{if(t.id&&a.getElementById(t.id)){const e=document.createElement("div");t.parentNode.replaceChild(e,t),d.push(t)}});const u=document.getElementById("baremetal-transition-root");u&&u.parentNode.removeChild(u);const p=()=>{document.body.innerHTML=a.body.innerHTML,d.forEach(t=>{const e=document.getElementById(t.id);e&&(Array.from(t.attributes).forEach(e=>{"id"!==e.name&&"data-baremetal-preserve"!==e.name&&t.removeAttribute(e.name)}),Array.from(e.attributes).forEach(e=>{"id"!==e.name&&t.setAttribute(e.name,e.value)}),e.parentNode.replaceChild(t,e))}),u&&document.body.appendChild(u),t.publish("DOM_SWAPPED",null)},f=()=>{p(),window.scrollTo(0,this.scrollMemory[n]||0)};e.config.transition&&e.config.transition.useViewTransitions&&document.startViewTransition?document.startViewTransition(()=>{f()}):f(),await e.loadPrepared(h)}catch(i){if("AbortError"===i.name)return void e.log(`Aborted fetch for ${n} due to new navigation.`);if(console.error("Routing error:",i),t.publish("ROUTE_ERROR",{url:n,error:i.message}),e.config.showErrorNotification){if(this.historyStack.length>0){const t=this.historyStack.pop();history.replaceState(null,"",t)}const t=document.createElement("div");t.style.position="fixed",t.style.bottom="20px",t.style.left="20px",t.style.background="#e74c3c",t.style.color="white",t.style.padding="15px 20px",t.style.borderRadius="8px",t.style.boxShadow="0 4px 12px rgba(0,0,0,0.15)",t.style.zIndex="999999",t.style.fontFamily="sans-serif",t.style.transition="opacity 0.3s ease",t.innerHTML=`<strong>Navigation Failed:</strong> ${i.message}`,document.body.appendChild(t),setTimeout(()=>{t.style.opacity="0",setTimeout(()=>t.remove(),300)},4e3)}else{if(this.historyStack.length>0){const t=this.historyStack.pop();history.replaceState(null,"",t)}window.location.assign(n)}}}};class s{constructor(t,e,i,o,s=20){this.container=document.getElementById(t),this.container&&(this.items=e,this.renderRow=i,this.itemHeight=o,this.visibleCount=s,this.totalHeight=this.items.length*this.itemHeight,this.container.style.overflowY="auto",this.container.style.position="relative",this.innerWrapper=document.createElement("div"),this.innerWrapper.style.height=`${this.totalHeight}px`,this.innerWrapper.style.position="relative",this.container.appendChild(this.innerWrapper),this.startIndex=0,this.onScroll=this.onScroll.bind(this),this.container.addEventListener("scroll",this.onScroll),this.render())}onScroll(){const t=this.container.scrollTop,e=Math.max(0,Math.floor(t/this.itemHeight)-2);e!==this.startIndex&&(this.startIndex=e,this.render())}render(){this.innerWrapper.innerHTML="";const t=Math.min(this.items.length-1,this.startIndex+this.visibleCount+4);for(let e=this.startIndex;e<=t;e++){const t=this.renderRow(this.items[e],e);t.style.position="absolute",t.style.top=e*this.itemHeight+"px",t.style.width="100%",this.innerWrapper.appendChild(t)}}destroy(){this.container&&(this.container.removeEventListener("scroll",this.onScroll),this.container.innerHTML="")}}const n=(t,e,i,o,n)=>new s(t,e,i,o,n);var r=Object.freeze({__proto__:null,Virtualizer:s,virtualize:n});const a={state:t,events:t,loader:i,router:o,virtualize:n,init(i={}){void 0!==i.debug&&e.setConfig({debug:i.debug}),void 0!==i.keepAliveSameModules&&e.setConfig({keepAliveSameModules:i.keepAliveSameModules}),void 0!==i.transition&&e.setConfig({transition:i.transition}),void 0!==i.autoWrap&&e.setConfig({autoWrap:i.autoWrap}),void 0!==i.hoverPrefetch&&e.setConfig({hoverPrefetch:i.hoverPrefetch}),void 0!==i.showErrorNotification&&e.setConfig({showErrorNotification:i.showErrorNotification}),void 0!==i.persistState&&(e.setConfig({persistState:i.persistState}),i.persistState&&(window.__baremetal_persist_state=!0,t.initPersistence())),void 0!==i.virtualizeDom&&e.setConfig({virtualizeDom:i.virtualizeDom}),o.init(),e.log("Initialized BareMetal Engine with config:",i)}};export{a as BareMetal,i as loader};
package/docs/api.md CHANGED
@@ -62,6 +62,38 @@ Listens for a Pub/Sub event.
62
62
 
63
63
  ---
64
64
 
65
+ ## 4. Module Auto-Cleanup & Sandboxing
66
+
67
+ BareMetal provides a safe cleanup registry during module `mount` to help manage memory and global pollution, reducing boilerplate.
68
+
69
+ ### `context.onCleanup(callback)`
70
+ Instead of explicitly returning a `destroy` function from your module, you can register cleanup tasks using `onCleanup`. The engine will automatically execute all registered callbacks when the module unmounts.
71
+
72
+ This is highly recommended for cleaning up:
73
+ 1. Global `window` function assignments.
74
+ 2. `window.addEventListener` / `document.addEventListener`.
75
+ 3. Local class instances (like third-party editors).
76
+
77
+ **Example:**
78
+ ```javascript
79
+ export function mount({ state, onCleanup }) {
80
+ const chart = new Chart(document.getElementById('chart'), config);
81
+
82
+ window.myCustomFunc = () => console.log('hello');
83
+
84
+ // Register manual cleanups
85
+ onCleanup(() => {
86
+ chart.destroy();
87
+ delete window.myCustomFunc;
88
+ });
89
+
90
+ // Alternatively, you can still return an explicit destroy function:
91
+ // return { destroy: () => { ... } };
92
+ }
93
+ ```
94
+
95
+ ---
96
+
65
97
  ## 4. Writing Custom Page Transitions
66
98
 
67
99
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baremetal.js",
3
- "version": "1.2.2",
3
+ "version": "1.2.5",
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",
package/src/loader.js CHANGED
@@ -105,11 +105,22 @@ export const Loader = {
105
105
  context.virtualize = virtualize;
106
106
  }
107
107
 
108
+ // Safe Cleanup Registry
109
+ const cleanups = [];
110
+ context.onCleanup = (cb) => cleanups.push(cb);
111
+
108
112
  const instance = await module.mount(context);
109
113
 
114
+ const autoDestroy = () => {
115
+ cleanups.forEach(cb => { try { cb(); } catch(e) { console.error(e); } });
116
+ if (instance && typeof instance.destroy === 'function') {
117
+ try { instance.destroy(); } catch(e) { console.error(e); }
118
+ }
119
+ };
120
+
110
121
  this.activeModules[key] = {
111
122
  path: path,
112
- module: instance ? { destroy: instance.destroy } : module
123
+ module: { destroy: autoDestroy }
113
124
  };
114
125
  } else {
115
126
  console.error(`[BareMetal] Module ${path} failed to provide a mount function even after wrapping.`);
package/src/router.js CHANGED
@@ -6,8 +6,10 @@ export const Router = {
6
6
  scrollMemory: {},
7
7
  historyStack: [],
8
8
  currentAbortController: null,
9
+ lastPathAndSearch: '',
9
10
 
10
11
  init() {
12
+ this.lastPathAndSearch = window.location.pathname + window.location.search;
11
13
  if ('scrollRestoration' in history) {
12
14
  history.scrollRestoration = 'manual';
13
15
  }
@@ -22,7 +24,8 @@ export const Router = {
22
24
  anchor.origin !== window.location.origin ||
23
25
  anchor.target === '_blank' ||
24
26
  (anchor.rel && anchor.rel.includes('noreferrer')) ||
25
- anchor.hasAttribute('download')
27
+ anchor.hasAttribute('download') ||
28
+ (anchor.getAttribute('href') || '').startsWith('#')
26
29
  ) {
27
30
  return;
28
31
  }
@@ -44,6 +47,7 @@ export const Router = {
44
47
  anchor.origin === window.location.origin &&
45
48
  anchor.target !== '_blank' &&
46
49
  !anchor.hasAttribute('download') &&
50
+ !(anchor.getAttribute('href') || '').startsWith('#') &&
47
51
  !this.htmlCache[anchor.href]
48
52
  ) {
49
53
  this.htmlCache[anchor.href] = 'fetching';
@@ -74,6 +78,11 @@ export const Router = {
74
78
  },
75
79
 
76
80
  async handleRoute(e) {
81
+ const currentPathAndSearch = window.location.pathname + window.location.search;
82
+ if (this.lastPathAndSearch === currentPathAndSearch) {
83
+ return; // Ignore navigations that only change the #hash
84
+ }
85
+ this.lastPathAndSearch = currentPathAndSearch;
77
86
 
78
87
  if (this.currentAbortController) {
79
88
  this.currentAbortController.abort();
@@ -101,6 +110,15 @@ export const Router = {
101
110
  Loader.log(`Used pre-fetched cache for ${url}`);
102
111
  } else {
103
112
  const response = await fetch(url, { signal });
113
+
114
+ if (response.redirected) {
115
+ const newUrl = new URL(response.url);
116
+ Loader.log(`Redirect detected to ${newUrl.pathname}. Continuing SPA transition to new destination.`);
117
+ // Sync the URL bar and router state to the redirect destination
118
+ history.replaceState(null, '', newUrl.pathname + newUrl.search);
119
+ this.lastPathAndSearch = newUrl.pathname + newUrl.search;
120
+ }
121
+
104
122
  if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
105
123
  htmlText = await response.text();
106
124
  }