diff --git a/.build/check-docs-links.ts b/.build/check-docs-links.ts index 5504ca395..9d092c99f 100644 --- a/.build/check-docs-links.ts +++ b/.build/check-docs-links.ts @@ -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: 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() -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/ -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(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 = { diff --git a/.changeset/page-modals-per-request.md b/.changeset/page-modals-per-request.md new file mode 100644 index 000000000..dd6f6fe04 --- /dev/null +++ b/.changeset/page-modals-per-request.md @@ -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. diff --git a/shared/components/CaptureModal.astro b/shared/components/CaptureModal.astro index d6ce83014..fd586b7a2 100644 --- a/shared/components/CaptureModal.astro +++ b/shared/components/CaptureModal.astro @@ -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')) --- diff --git a/shared/components/PageModals.astro b/shared/components/PageModals.astro index ce55c5eba..1b5d047f1 100644 --- a/shared/components/PageModals.astro +++ b/shared/components/PageModals.astro @@ -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)) --- { diff --git a/shared/components/modals/ChangePasswordModalContent.astro b/shared/components/modals/ChangePasswordModalContent.astro index 233aeee7d..40a87d403 100644 --- a/shared/components/modals/ChangePasswordModalContent.astro +++ b/shared/components/modals/ChangePasswordModalContent.astro @@ -30,14 +30,18 @@ import Strength from '@ui/Strength.astro' - diff --git a/shared/components/modals/ConfirmDeleteModalContent.astro b/shared/components/modals/ConfirmDeleteModalContent.astro index 3fdd16c17..6c9af1753 100644 --- a/shared/components/modals/ConfirmDeleteModalContent.astro +++ b/shared/components/modals/ConfirmDeleteModalContent.astro @@ -43,14 +43,18 @@ import ModalClose from './ModalClose.astro' - diff --git a/shared/lib/page-modals.test.ts b/shared/lib/page-modals.test.ts index 7ac0e76be..f2815e0b4 100644 --- a/shared/lib/page-modals.test.ts +++ b/shared/lib/page-modals.test.ts @@ -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('
a
') - addPageModal('
b
') - expect(drainPageModals()).toEqual(['
a
', '
b
']) - expect(drainPageModals()).toEqual([]) + const render = {} + addPageModal(render, '
a
') + addPageModal(render, '
b
') + expect(drainPageModals(render)).toEqual(['
a
', '
b
']) + expect(drainPageModals(render)).toEqual([]) }) - it('dedupes identical registrations (same modal declared twice renders once)', () => { - addPageModal('
same
') - addPageModal('
same
') - expect(drainPageModals()).toHaveLength(1) + it('passes promises through untouched', async () => { + const render = {} + const pending = Promise.resolve('
later
') + addPageModal(render, pending) + const drained = drainPageModals(render) + expect(drained[0]).toBe(pending) + await expect(drained[0]).resolves.toBe('
later
') + }) + + it('keeps the modals of one render away from another', () => { + const pageA = {} + const pageB = {} + addPageModal(pageA, '
a
') + addPageModal(pageB, '
b
') + expect(drainPageModals(pageB)).toEqual(['
b
']) + expect(drainPageModals(pageA)).toEqual(['
a
']) }) }) diff --git a/shared/lib/page-modals.ts b/shared/lib/page-modals.ts index 007240294..c1cd86104 100644 --- a/shared/lib/page-modals.ts +++ b/shared/lib/page-modals.ts @@ -1,20 +1,28 @@ // Modals declared in page content render at the end of , 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> = [] +type Modal = string | Promise -export function addPageModal(html: string | Promise): void { - if (!modals.includes(html)) { - modals.push(html) - } +const registries = new WeakMap() + +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> { - const out = [...modals] - modals.length = 0 +export function drainPageModals(render: object): Modal[] { + const out = registries.get(render) ?? [] + registries.delete(render) return out }