diff --git a/CHANGELOG.md b/CHANGELOG.md index 882fb7b..4512e14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [v1.1.15] - 2026-02-27 + +### Added +- Add support for exported `styles` +- Add `AsyncDisposableStack` and disposal in `AegisNavigationEvent` + ## [v1.1.14] - 2026-02-08 ### Added diff --git a/http.config.js b/http.config.js index e0781a2..fce3edb 100644 --- a/http.config.js +++ b/http.config.js @@ -10,7 +10,7 @@ addScriptSrc( imports['@highlightjs/cdn-assets/'], ); addConnectSrc('https://api.github.com/users/', 'https://baconipsum.com/api/'); -addStyleSrc(imports['@shgysk8zer0/core-css/']); +addStyleSrc(imports['@shgysk8zer0/core-css/'], imports['@highlightjs/cdn-assets/es/styles/']); addImageSrc('https://avatars.githubusercontent.com/u/', 'https://images.unsplash.com/', 'blob:'); addTrustedTypePolicy('aegis-router#html', 'aegis-sanitizer#html', 'aegis-escape#html', 'lit-html', 'default'); lockCSP(); diff --git a/package-lock.json b/package-lock.json index 5563543..8885173 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aegisjsproject/router", - "version": "1.1.14", + "version": "1.1.15", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@aegisjsproject/router", - "version": "1.1.14", + "version": "1.1.15", "funding": [ { "type": "librepay", @@ -1205,6 +1205,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1341,6 +1342,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -1901,6 +1903,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -2719,7 +2722,8 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true + "dev": true, + "peer": true }, "acorn-jsx": { "version": "5.3.2", @@ -2816,6 +2820,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", "dev": true, + "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -3221,6 +3226,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, + "peer": true, "requires": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", diff --git a/package.json b/package.json index 6d6c228..3ef5f35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aegisjsproject/router", - "version": "1.1.14", + "version": "1.1.15", "description": "A simple but powerful router module", "keywords": [ "router", diff --git a/router.js b/router.js index a66a5d5..2cc75a7 100644 --- a/router.js +++ b/router.js @@ -46,6 +46,7 @@ const DEFAULT_REASONS = [EVENT_TYPES.back, EVENT_TYPES.forward, EVENT_TYPES.navi export class AegisNavigationEvent extends CustomEvent { #reason; #url; + #stack = new AsyncDisposableStack(); #controller = new AbortController(); #promises = []; #errors = []; @@ -63,6 +64,10 @@ export class AegisNavigationEvent extends CustomEvent { return this.#controller.signal.aborted; } + get disposed() { + return this.#stack.disposed; + } + get error() { switch(this.#errors.length) { case 0: @@ -84,6 +89,10 @@ export class AegisNavigationEvent extends CustomEvent { return this.#controller.signal; } + get stack() { + return this.#stack; + } + get url() { return this.#url; } @@ -99,10 +108,26 @@ export class AegisNavigationEvent extends CustomEvent { return result; } + adopt(obj, callback) { + return this.#stack.adopt(obj, callback); + } + abort(reason) { this.#controller.abort(reason); } + defer(callback) { + this.#stack.defer(callback); + } + + async disposeAsync() { + await this[Symbol.asyncDispose](); + } + + use(obj) { + return this.#stack.use(obj); + } + waitUntil(promiseOrCallback, { signal } = {}) { const { promise, resolve, reject } = Promise.withResolvers(); @@ -132,7 +157,8 @@ export class AegisNavigationEvent extends CustomEvent { } else if (! this.defaultPrevented && promiseOrCallback instanceof Function) { Promise.try(() => promiseOrCallback(this, { signal: signal instanceof AbortSignal ? AbortSignal.any([signal, this.#controller.signal]) : this.#controller.signal, - timestamp: performance.now() + timestamp: performance.now(), + stack: this.#stack, })).then(resolve, reject); } else if (! this.defaultPrevented && promiseOrCallback instanceof Promise) { promiseOrCallback.then(resolve, reject); @@ -143,6 +169,16 @@ export class AegisNavigationEvent extends CustomEvent { return 'NavigationEvent'; } + async [Symbol.asyncDispose]() { + if (! this.#controller.signal.aborted) { + this.#controller.abort(new DOMException('The stack of the event was disposed.', 'AbortError')); + } + + if (! this.#stack.disposed) { + await this.#stack.disposeAsync(); + } + } + static get defaultType() { return NAV_EVENT; } @@ -163,21 +199,36 @@ async function _popstateHandler(event) { detail: { newState: event.state, oldState: null, oldURL: new URL(location.href), method: 'GET', formData: null }, }); - EVENT_TARGET.dispatchEvent(navigate); - - if (! await navigate[NAV_CLOSE_SYMBOL]()) { - const old = history.scrollRestoration; - const [content] = await Promise.all([ - getModule(new URL(location.href)), - notifyStateChange(diff), - ]); - - history.scrollRestoration = 'auto'; - _updatePage(content); - history.scrollRestoration = old; + try { + EVENT_TARGET.dispatchEvent(navigate); + + if (! await navigate[NAV_CLOSE_SYMBOL]()) { + const old = history.scrollRestoration; + const [content] = await Promise.all([ + getModule(new URL(location.href)), + notifyStateChange(diff), + ]); + + history.scrollRestoration = 'auto'; + _updatePage(content); + history.scrollRestoration = old; + } + } finally { + requestAnimationFrame(navigate[Symbol.asyncDispose].bind(navigate)); } }; +function _addStyle(sheet) { + if (sheet instanceof CSSStyleSheet && ! document.adoptedStyleSheets.includes(sheet)) { + document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]; + } else if (Array.isArray(sheet) && sheet.length !== 0) { + document.adoptedStyleSheets = [ + ...document.adoptedStyleSheets, + ...sheet.filter(s => s instanceof CSSStyleSheet && ! document.adoptedStyleSheets.includes(s)) + ]; + } +} + function _createMeta(props = {}) { const meta = document.createElement('meta'); @@ -329,28 +380,41 @@ async function _interceptFormSubmit(event) { event.target.removeEventListener('submit', _interceptFormSubmit); } else if (event.isTrusted && event.target.action.startsWith(location.origin)) { event.preventDefault(); - const { method, action } = event.target; - const formData = new FormData(event.target); - + const { target, submitter } = event; + const { method, action } = target; + const formData = new FormData(target); const submit = new AegisNavigationEvent(NAV_EVENT, EVENT_TYPES.submit, { detail: { oldState: getStateObj(), oldURL: new URL(location.href), formData }, }); - EVENT_TARGET.dispatchEvent(submit); + try { + if (submitter instanceof HTMLButtonElement) { + submitter.disabled = true; + } + + + EVENT_TARGET.dispatchEvent(submit); - if (await submit[NAV_CLOSE_SYMBOL]()) { - return; - } else if (NO_BODY_METHODS.includes(method.toUpperCase())) { - const url = new URL(action); - const params = new URLSearchParams(formData); + if (await submit[NAV_CLOSE_SYMBOL]()) { + return; + } else if (NO_BODY_METHODS.includes(method.toUpperCase())) { + const url = new URL(action); + const params = new URLSearchParams(formData); - for (const [key, val] of params.entries()) { - url.searchParams.append(key, val); + for (const [key, val] of params.entries()) { + url.searchParams.append(key, val); + } + + await navigate(url, getStateObj(), { method }); + } else { + await navigate(action, getStateObj(), { method, formData }); + } + } finally { + if (submitter instanceof HTMLButtonElement) { + submitter.disabled = false; } - await navigate(url, getStateObj(), { method }); - } else { - await navigate(action, getStateObj(), { method, formData }); + requestAnimationFrame(submit[Symbol.asyncDispose].bind(submit)); } } } @@ -409,7 +473,8 @@ function _updatePage(content) { rootEl.textContent = content; } - EVENT_TARGET.dispatchEvent(new AegisNavigationEvent(NAV_EVENT, EVENT_TYPES.load, { cancelable: false })); + const ev = new AegisNavigationEvent(NAV_EVENT, EVENT_TYPES.load, { cancelable: false }); + Promise.try(() => EVENT_TARGET.dispatchEvent(ev)).finally(ev[Symbol.asyncDispose].bind(ev)); if (history.scrollRestoration === 'manual') { if (location.hash.length > 1) { @@ -478,6 +543,10 @@ async function _handleModule(moduleSrc, { ); } + if (typeof module.styles !== 'undefined') { + _addStyle(module.styles); + } + _handleMetadata(module, { state, matches, params, url, signal }); return new module.default({ @@ -491,6 +560,10 @@ async function _handleModule(moduleSrc, { ...args }); } else if (module.default instanceof Function) { + if (typeof module.styles !== 'undefined') { + _addStyle(module.styles); + } + _handleMetadata(module, { state, matches, params, url, signal }); return await module.default({ @@ -504,6 +577,10 @@ async function _handleModule(moduleSrc, { ...args }); } else if (module.default instanceof Node || module.default instanceof Error) { + if (typeof module.styles !== 'undefined') { + _addStyle(module.styles); + } + _handleMetadata(module, { state, matches, params, url, signal }); _updatePage(module.default); } else if (module.default instanceof URL && module.default.origin === location.origin) { @@ -822,12 +899,13 @@ export async function navigate(url, newState = getStateObj(), { return await navigate(url, newState, { signal, method, cache, referrerPolicy, integrity }); } else if (url.href !== location.href) { + const oldState = getStateObj(); + const navigate = new AegisNavigationEvent(NAV_EVENT, EVENT_TYPES.navigate, { + detail: { newState, oldState, oldURL: new URL(location.href), newURL: url, method, formData }, + }); + try { - const oldState = getStateObj(); const diff = diffState(newState, oldState); - const navigate = new AegisNavigationEvent(NAV_EVENT, EVENT_TYPES.navigate, { - detail: { newState, oldState, oldURL: new URL(location.href), newURL: url, method, formData }, - }); EVENT_TARGET.dispatchEvent(navigate); @@ -848,6 +926,8 @@ export async function navigate(url, newState = getStateObj(), { } catch(err) { back(); reportError(err); + } finally { + requestAnimationFrame(navigate[Symbol.asyncDispose].bind(navigate)); } } } @@ -864,7 +944,7 @@ export async function back({ signal } = {}) { history.back(); await whenNavigated({ signal, reasons: [EVENT_TYPES.load] }); } - }); + }).finally(event[Symbol.asyncDispose].bind(event)); } /** @@ -879,7 +959,7 @@ export async function forward({ signal } = {}) { history.forward(); await whenNavigated({ signal, reasons: [EVENT_TYPES.load] }); } - }); + }).finally(event[Symbol.asyncDispose].bind(event)); } /** @@ -896,7 +976,7 @@ export async function go(delta = 0, { signal } = {}) { history.go(delta); await whenNavigated({ signal, reasons: [EVENT_TYPES.load] }); } - }); + }).finally(event[Symbol.asyncDispose].bind(event)); } /** @@ -910,7 +990,7 @@ export function reload() { if (! prevented) { history.go(0); } - }); + }).finally(event[Symbol.asyncDispose].bind(event)); } /** diff --git a/test/views/img.js b/test/views/img.js index 424f07c..ec232b2 100644 --- a/test/views/img.js +++ b/test/views/img.js @@ -1,10 +1,12 @@ import { svg } from '@aegisjsproject/core/parsers/svg.js'; +import { css } from '@aegisjsproject/core/parsers/css.js'; const color = crypto.getRandomValues(new Uint8Array(3)).toHex(); +const svgClass = '_' + crypto.randomUUID(); export default ({ params: { fill = color, size = '96', radius = 1 } = {}, -}) => svg` +}) => svg` `; @@ -13,3 +15,12 @@ export const title = 'Random Image'; export const description = ({ params: { fill = color, size = '96', radius = 1 } = {}, } = {}) => `Random image (fill: #${fill}, size: ${size}, radius: ${radius})`; + +export const styles = css`.${svgClass} { + transform: none; + transition: transform 800ms ease-in-out; + + &:hover { + transform: scale(1.3) rotate(1turn); + } +}`;