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):
// - markdown links and `related:` frontmatter in docs/content/**/*.mdx
// - 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
// - 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
@@ -20,6 +20,7 @@ import { join, dirname, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { sync } from 'glob'
import GithubSlugger from 'github-slugger'
import { redirects } from '../docs/lib/redirects.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
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.
const astroConfig = readFileSync(join(repoRoot, 'docs', 'astro.config.mjs'), 'utf8')
const redirectSources = new Set<string>()
const redirectDestinations: string[] = []
// static entries: '/from': { status: 301, destination: '/to' }
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]}`)
}
// The table lives in docs/lib/redirects.ts (shared with astro.config.mjs and
// the docs middleware), so it is imported rather than parsed.
const redirectSources = new Set<string>(Object.keys(redirects))
const redirectDestinations: string[] = Object.values(redirects).map((redirect) => redirect.destination)
// Asset urls are served from these source directories (synced by copyAssets).
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.
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
// synchronous, modal content may be a promise).
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>
</div>
<!-- BEGIN CHANGE PASSWORD -->
<script>
<script is:inline>
// 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.
const newPasswordInput = document.getElementById('password-new') as HTMLInputElement | null
const confirmPasswordInput = document.getElementById('password-confirm') as HTMLInputElement | null
const matchError = document.getElementById('password-match-error')
function initChangePassword() {
const newPasswordInput = document.getElementById('password-new')
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 newPassword = newPasswordInput.value
const confirmPassword = confirmPasswordInput.value
@@ -58,5 +62,7 @@ import Strength from '@ui/Strength.astro'
newPasswordInput.addEventListener('input', validateMatch)
confirmPasswordInput.addEventListener('input', validateMatch)
}
document.readyState !== 'loading' ? initChangePassword() : document.addEventListener('DOMContentLoaded', initChangePassword, { once: true })
</script>
<!-- END CHANGE PASSWORD -->
@@ -43,14 +43,18 @@ import ModalClose from './ModalClose.astro'
</div>
</div>
<!-- BEGIN CONFIRM DELETE -->
<script>
const checkbox = document.getElementById('confirm-delete-checkbox') as HTMLInputElement | null
const deleteButton = document.getElementById('confirm-delete-button') as HTMLButtonElement | null
<script is:inline>
function initConfirmDelete() {
const checkbox = document.getElementById('confirm-delete-checkbox')
const deleteButton = document.getElementById('confirm-delete-button')
if (checkbox && deleteButton) {
checkbox.addEventListener('change', function (this: HTMLInputElement) {
deleteButton.disabled = !this.checked
})
if (checkbox && deleteButton) {
checkbox.addEventListener('change', function () {
deleteButton.disabled = !this.checked
})
}
}
document.readyState !== 'loading' ? initConfirmDelete() : document.addEventListener('DOMContentLoaded', initConfirmDelete, { once: true })
</script>
<!-- END CONFIRM DELETE -->
+21 -8
View File
@@ -3,15 +3,28 @@ import { addPageModal, drainPageModals } from './page-modals'
describe('page-modals registry', () => {
it('drains registrations in insertion order and empties the registry', () => {
addPageModal('<div>a</div>')
addPageModal('<div>b</div>')
expect(drainPageModals()).toEqual(['<div>a</div>', '<div>b</div>'])
expect(drainPageModals()).toEqual([])
const render = {}
addPageModal(render, '<div>a</div>')
addPageModal(render, '<div>b</div>')
expect(drainPageModals(render)).toEqual(['<div>a</div>', '<div>b</div>'])
expect(drainPageModals(render)).toEqual([])
})
it('dedupes identical registrations (same modal declared twice renders once)', () => {
addPageModal('<div>same</div>')
addPageModal('<div>same</div>')
expect(drainPageModals()).toHaveLength(1)
it('passes promises through untouched', async () => {
const render = {}
const pending = Promise.resolve('<div>later</div>')
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
// 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
// itself SYNCHRONOUSLY in its frontmatter (before it first yields control via
// await), because Astro renders siblings concurrently and PageModals may drain
// 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 {
if (!modals.includes(html)) {
modals.push(html)
}
const registries = new WeakMap<object, Modal[]>()
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>> {
const out = [...modals]
modals.length = 0
export function drainPageModals(render: object): Modal[] {
const out = registries.get(render) ?? []
registries.delete(render)
return out
}