Fix page modals leaking between pages, redirect link gate and inline modal scripts (#3087)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-09-18 15:10:54 +02:00
1 parent 090591873a
commit bf576f10a4
8 files changed
+73 -45

No files matched your search

+6 -14
View File
@@ -2,7 +2,7 @@
// Validates internal links in the docs at the source level (no build needed): // Validates internal links in the docs at the source level (no build needed):
// - markdown links and `related:` frontmatter in docs/content/**/*.mdx // - markdown links and `related:` frontmatter in docs/content/**/*.mdx
// - menu/link urls in shared/data/docs.json // - menu/link urls in shared/data/docs.json
// - redirect destinations in docs/astro.config.mjs // - redirect destinations in docs/lib/redirects.ts
// - href string literals in docs .astro components // - href string literals in docs .astro components
// - docs paths linked from the demo site: <DocsLink path="…"> and getDocsUrl('…') // - docs paths linked from the demo site: <DocsLink path="…"> and getDocsUrl('…')
// - asset paths that actually render: src, poster and CSS url() in mdx and in // - asset paths that actually render: src, poster and CSS url() in mdx and in
@@ -20,6 +20,7 @@ import { join, dirname, relative } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { sync } from 'glob' import { sync } from 'glob'
import GithubSlugger from 'github-slugger' import GithubSlugger from 'github-slugger'
import { redirects } from '../docs/lib/redirects.js'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = join(__dirname, '..') const repoRoot = join(__dirname, '..')
@@ -53,19 +54,10 @@ for (const file of sync(join(pagesDir, '**', '*.ts'))) {
} }
// Redirect sources are valid link targets; their destinations must exist. // Redirect sources are valid link targets; their destinations must exist.
const astroConfig = readFileSync(join(repoRoot, 'docs', 'astro.config.mjs'), 'utf8') // The table lives in docs/lib/redirects.ts (shared with astro.config.mjs and
const redirectSources = new Set<string>() // the docs middleware), so it is imported rather than parsed.
const redirectDestinations: string[] = [] const redirectSources = new Set<string>(Object.keys(redirects))
// static entries: '/from': { status: 301, destination: '/to' } const redirectDestinations: string[] = Object.values(redirects).map((redirect) => redirect.destination)
for (const match of astroConfig.matchAll(/'(\/[^']+)':\s*\{\s*status:\s*\d+,\s*destination:\s*'(\/[^']+)'/g)) {
redirectSources.add(match[1] ?? '')
redirectDestinations.push(match[2] ?? '')
}
// generated entries: ['alerts', 'alert'], … mapped onto /ui/components/<slug>
for (const match of astroConfig.matchAll(/\['([a-z-]+)',\s*'([a-z-]+)'\]/g)) {
redirectSources.add(`/ui/components/${match[1]}`)
redirectDestinations.push(`/ui/components/${match[2]}`)
}
// Asset urls are served from these source directories (synced by copyAssets). // Asset urls are served from these source directories (synced by copyAssets).
const assetRoots: Record<string, string[]> = { const assetRoots: Record<string, string[]> = {
+5
View File
@@ -0,0 +1,5 @@
---
"@tabler/preview": patch
---
Fixed page modals leaking between pages and made the change-password and confirm-delete modal scripts plain inline JS.
+1 -1
View File
@@ -4,5 +4,5 @@
// Registration is synchronous (a promise) — see page-modals.ts. // Registration is synchronous (a promise) — see page-modals.ts.
import { addPageModal } from '@shared/lib/page-modals' import { addPageModal } from '@shared/lib/page-modals'
addPageModal(Astro.slots.render('default')) addPageModal(Astro.request, Astro.slots.render('default'))
--- ---
+2 -2
View File
@@ -1,10 +1,10 @@
--- ---
// Separate component so drainPageModals() executes only at this point of the // Separate component so drainPageModals(Astro.request) executes only at this point of the
// render stream (after the page content render has STARTED — registrations are // render stream (after the page content render has STARTED — registrations are
// synchronous, modal content may be a promise). // synchronous, modal content may be a promise).
import { drainPageModals } from '@shared/lib/page-modals' import { drainPageModals } from '@shared/lib/page-modals'
const modals = await Promise.all(drainPageModals()) const modals = await Promise.all(drainPageModals(Astro.request))
--- ---
{ {
@@ -30,14 +30,18 @@ import Strength from '@ui/Strength.astro'
</form> </form>
</div> </div>
<!-- BEGIN CHANGE PASSWORD --> <!-- BEGIN CHANGE PASSWORD -->
<script> <script is:inline>
// Password show/hide toggles are wired generically by InputGroup.astro (data-password-toggle). // Password show/hide toggles are wired generically by InputGroup.astro (data-password-toggle).
// The strength meter is wired by tabler.js from data-bs-strength. // The strength meter is wired by tabler.js from data-bs-strength.
const newPasswordInput = document.getElementById('password-new') as HTMLInputElement | null function initChangePassword() {
const confirmPasswordInput = document.getElementById('password-confirm') as HTMLInputElement | null const newPasswordInput = document.getElementById('password-new')
const matchError = document.getElementById('password-match-error') const confirmPasswordInput = document.getElementById('password-confirm')
const matchError = document.getElementById('password-match-error')
if (!newPasswordInput || !confirmPasswordInput || !matchError) {
return
}
if (newPasswordInput && confirmPasswordInput && matchError) {
const validateMatch = () => { const validateMatch = () => {
const newPassword = newPasswordInput.value const newPassword = newPasswordInput.value
const confirmPassword = confirmPasswordInput.value const confirmPassword = confirmPasswordInput.value
@@ -58,5 +62,7 @@ import Strength from '@ui/Strength.astro'
newPasswordInput.addEventListener('input', validateMatch) newPasswordInput.addEventListener('input', validateMatch)
confirmPasswordInput.addEventListener('input', validateMatch) confirmPasswordInput.addEventListener('input', validateMatch)
} }
document.readyState !== 'loading' ? initChangePassword() : document.addEventListener('DOMContentLoaded', initChangePassword, { once: true })
</script> </script>
<!-- END CHANGE PASSWORD --> <!-- END CHANGE PASSWORD -->
@@ -43,14 +43,18 @@ import ModalClose from './ModalClose.astro'
</div> </div>
</div> </div>
<!-- BEGIN CONFIRM DELETE --> <!-- BEGIN CONFIRM DELETE -->
<script> <script is:inline>
const checkbox = document.getElementById('confirm-delete-checkbox') as HTMLInputElement | null function initConfirmDelete() {
const deleteButton = document.getElementById('confirm-delete-button') as HTMLButtonElement | null const checkbox = document.getElementById('confirm-delete-checkbox')
const deleteButton = document.getElementById('confirm-delete-button')
if (checkbox && deleteButton) { if (checkbox && deleteButton) {
checkbox.addEventListener('change', function (this: HTMLInputElement) { checkbox.addEventListener('change', function () {
deleteButton.disabled = !this.checked deleteButton.disabled = !this.checked
}) })
}
} }
document.readyState !== 'loading' ? initConfirmDelete() : document.addEventListener('DOMContentLoaded', initConfirmDelete, { once: true })
</script> </script>
<!-- END CONFIRM DELETE --> <!-- END CONFIRM DELETE -->
+21 -8
View File
@@ -3,15 +3,28 @@ import { addPageModal, drainPageModals } from './page-modals'
describe('page-modals registry', () => { describe('page-modals registry', () => {
it('drains registrations in insertion order and empties the registry', () => { it('drains registrations in insertion order and empties the registry', () => {
addPageModal('<div>a</div>') const render = {}
addPageModal('<div>b</div>') addPageModal(render, '<div>a</div>')
expect(drainPageModals()).toEqual(['<div>a</div>', '<div>b</div>']) addPageModal(render, '<div>b</div>')
expect(drainPageModals()).toEqual([]) expect(drainPageModals(render)).toEqual(['<div>a</div>', '<div>b</div>'])
expect(drainPageModals(render)).toEqual([])
}) })
it('dedupes identical registrations (same modal declared twice renders once)', () => { it('passes promises through untouched', async () => {
addPageModal('<div>same</div>') const render = {}
addPageModal('<div>same</div>') const pending = Promise.resolve('<div>later</div>')
expect(drainPageModals()).toHaveLength(1) addPageModal(render, pending)
const drained = drainPageModals(render)
expect(drained[0]).toBe(pending)
await expect(drained[0]).resolves.toBe('<div>later</div>')
})
it('keeps the modals of one render away from another', () => {
const pageA = {}
const pageB = {}
addPageModal(pageA, '<div>a</div>')
addPageModal(pageB, '<div>b</div>')
expect(drainPageModals(pageB)).toEqual(['<div>b</div>'])
expect(drainPageModals(pageA)).toEqual(['<div>a</div>'])
}) })
}) })
+16 -8
View File
@@ -1,20 +1,28 @@
// Modals declared in page content render at the end of <body>, before the // Modals declared in page content render at the end of <body>, before the
// settings panel (via PageModals). // settings panel (via PageModals).
// //
// One registry per render, keyed by the request object (like page-scripts):
// Astro renders pages concurrently in the build and the dev server, so a
// module-level list would hand one page's modals to another.
//
// A string OR a promise of a string is registered: CaptureModal must register // A string OR a promise of a string is registered: CaptureModal must register
// itself SYNCHRONOUSLY in its frontmatter (before it first yields control via // itself SYNCHRONOUSLY in its frontmatter (before it first yields control via
// await), because Astro renders siblings concurrently and PageModals may drain // await), because Astro renders siblings concurrently and PageModals may drain
// the registry before the asynchronous render of the slot completes. // the registry before the asynchronous render of the slot completes.
const modals: Array<string | Promise<string>> = [] type Modal = string | Promise<string>
export function addPageModal(html: string | Promise<string>): void { const registries = new WeakMap<object, Modal[]>()
if (!modals.includes(html)) {
modals.push(html) export function addPageModal(render: object, html: Modal): void {
} // No dedup: every slots.render() is a new promise, so identical markup
// could never be told apart here anyway.
const modals = registries.get(render) ?? []
modals.push(html)
registries.set(render, modals)
} }
export function drainPageModals(): Array<string | Promise<string>> { export function drainPageModals(render: object): Modal[] {
const out = [...modals] const out = registries.get(render) ?? []
modals.length = 0 registries.delete(render)
return out return out
} }