Portal / Dialog / SSR Audit Baseline
Status: Baseline audit — not React 19 runtime certification, not SSR certification. The findings below are point-in-time observations of @smolitux/core overlay components, intended to make ExportReviewDialog (planned in @smolitux/data-governance, see Modularium/smolitux-ui PR12.12) buildable on a known foundation.
1. Summary
This document audits the Dialog-/overlay-adjacent components in @smolitux/core for:
- import-time SSR safety (no module-scope browser API access)
- portal behavior (
createPortal, container, SSR guards) - a11y baseline (role / aria-modal / aria-labelledby / focus / Escape)
- React 18 vs. React 19 readiness (peer-compatible only — runtime smoke is still pending)
- prerequisites a future
ExportReviewDialogconsumer can rely on
It does not:
- introduce
ExportReviewDialog - ship a React 19 runtime certification
- declare the package "SSR-ready"
- rewrite, redesign, or restructure any overlay component
The accompanying jest smoke test sits at packages/@smolitux/core/src/components/Modal/__tests__/portal-ssr-smoke.test.tsx and verifies that all eight overlay component modules can be imported in a jsdom environment without throwing — a cheap proxy for "no module-scope browser API use".
2. Scope
| Component | Package path | Portal use | Import-time browser API risk | Render smoke (existing) | A11y baseline | Status | Notes |
|---|---|---|---|---|---|---|---|
Dialog | packages/@smolitux/core/src/components/Dialog/Dialog.tsx | none — renders inline | none | Dialog/__tests__/Dialog.test.tsx, Dialog.a11y.test.tsx | role="dialog" / role="alertdialog" (line 410), aria-modal="true", aria-labelledby, aria-describedby, Escape via document.addEventListener inside useEffect, focus trap with firstElement/lastElement | pass | Inline render trades z-index/overlay flexibility for SSR safety. Primary candidate for ExportReviewDialog. |
Modal | packages/@smolitux/core/src/components/Modal/Modal.tsx | createPortal(modalContent, document.body) (line 667) — guarded by if (usePortal && typeof document !== 'undefined') | none at module scope; document access only inside render guard or effects | Modal.test.tsx, Modal/__tests__/Modal.test.tsx, Modal.a11y.test.tsx, Modal.int.test.tsx (mocks createPortal) | aria-modal="true", aria-labelledby, aria-describedby, Escape via doc listener (effect), focus trap built on querySelectorAll('[tabindex]:not([tabindex="-1"]), …') | pass_with_notes | Portal already SSR-guarded; Modal.a11y adds process.env.NODE_ENV !== 'test' guard so jest renders Modal inline. |
Drawer | packages/@smolitux/core/src/components/Drawer/Drawer.tsx | none | none | Drawer.test.tsx (asserts Escape on window) | role="dialog", aria-modal="true", aria-describedby; focus handling via useDrawerLogic hook | pass_with_notes | Suitable for side-panel detail surfaces in OceanData; not a candidate for ExportReviewDialog per consumption brief. |
Tooltip | packages/@smolitux/core/src/components/Tooltip/Tooltip.tsx | none | none — window.innerWidth / window.innerHeight only inside resize effect (line 106) | Tooltip.test.tsx, Tooltip.a11y.test.tsx | role="tooltip", aria-describedby toggled by visibility | pass | |
Toast | packages/@smolitux/core/src/components/Toast/Toast.tsx + ToastProvider.tsx | none in inspected files | none | Toast.test.tsx, Toast.a11y.test.tsx, ToastProvider.test.tsx | role="alert", aria-labelledby, aria-describedby | pass | If ToastProvider introduces a portal in a later refactor, this audit's portal row needs revisiting. |
Dropdown | packages/@smolitux/core/src/components/Dropdown/Dropdown.tsx | none in main file | none — document.getElementById and document.addEventListener inside effects only | Dropdown.a11y.test.tsx | role="menu", role="menuitem" | pass | |
Popover | packages/@smolitux/core/src/components/Popover/Popover.tsx | none in main file | none — window.pageYOffset, window.innerWidth, document.addEventListener only inside effects | Popover.test.tsx, Popover.a11y.test.tsx | role="tooltip", aria-labelledby, aria-describedby, Escape handler in effect | pass | |
Menu | packages/@smolitux/core/src/components/Menu/Menu.tsx + MenuDropdown.tsx | ReactDOM.createPortal(dropdown, portalTo) (MenuDropdown line 334) — caller-supplied container | none — caller passes portalTo; no document.body hardcode | Menu.test.tsx, Menu.a11y.test.tsx, MenuDropdown.test.tsx, MenuItem.test.tsx, Menu.snapshot.test.tsx | role="menu", role="menuitem", keyboard navigation in Menu.a11y.tsx (lines 230 onward) | pass | Portal opt-in via prop is the cleanest pattern in this set — no global guard needed because the caller decides the container. |
Status legend: pass (no documented risk for the audited dimension), pass_with_notes (acceptable but with caveats below), risk, blocked, not_found, not_audited.
3. Import-time SSR Safety
Module-scope scan (awk walking brace depth, looking for top-level window. / document. / navigator.) across all eight component directories returned zero hits. Every browser API reference lives inside a function body — useEffect, event handler, callback, or render-time guarded branch.
Categorised findings:
document.addEventListener/document.removeEventListener(Dialog, Modal, Modal.a11y, Dropdown, Popover) — all wrapped inuseEffect, returning a cleanup that callsremoveEventListener. Safe under React StrictMode and SSR (effects do not run on the server).document.activeElementreads (Dialog 155/229/232, Modal 276/348) — inside effects. Safe.document.body.style.overflow(Dialog 186/188/192) — inside effect, restored on unmount via cleanup. Safe.document.bodyportal target (Modal line 667, Modal.a11y line 1078) — inside the render function but guarded bytypeof document !== 'undefined'. Modal.a11y additionally guardsprocess.env.NODE_ENV !== 'test'. Safe.document.getElementById(Dropdown line 96) — insideonKeyDownhandler. Safe.document.documentElement.scrollTop/scrollLeft(Popover lines 191–192) — insideuseEffect-driven positioning callback. Safe.window.innerWidth/innerHeight(Tooltip 106–107, Popover 195–196) — inside resize callback registered viauseEffect. Safe.window.addEventListener('resize', …)(Tooltip 160, Popover 351) — insideuseEffectwith cleanup. Safe.
Conclusion: the eight components are import-safe for environments where document / window are undefined at module evaluation time (Node SSR, isolated build pipelines, edge runtimes). They are not crash-free for SSR render: any component that internally calls document.body (Modal portal path) or queries document.activeElement (Dialog focus restoration) will fail if it actually mounts in a Node environment without DOM. Modal already short-circuits this via the typeof document !== 'undefined' guard; Dialog renders inline and so the issue does not arise.
The companion jest smoke test (Modal/__tests__/portal-ssr-smoke.test.tsx) covers the import-evaluation path for all eight modules.
4. Portal Behavior
| Component | createPortal | Container | SSR guard | Caller override |
|---|---|---|---|---|
Dialog | not used | n/a (inline render) | inline = always safe | n/a |
Modal | react-dom's createPortal (Modal.tsx:667) | document.body (hardcoded) | usePortal && typeof document !== 'undefined' (Modal.tsx:666) | usePortal: false falls back to inline |
Modal.a11y | same | same | additionally gated by NODE_ENV !== 'test' (Modal.a11y.tsx:1077) | same |
Drawer | not used | n/a | inline = safe | n/a |
Tooltip | not used | n/a | inline = safe | n/a |
Toast | not observed in Toast.tsx / ToastProvider.tsx | n/a | n/a | n/a |
Dropdown | not used in Dropdown.tsx | n/a | inline = safe | n/a |
Popover | not used in Popover.tsx | n/a | inline = safe | n/a |
Menu (MenuDropdown) | ReactDOM.createPortal(dropdown, portalTo) (MenuDropdown.tsx:334) | caller-supplied via portalTo prop | conditional on prop being set | yes — caller-controlled |
Two patterns are present and both are acceptable:
- Self-portalling with SSR guard (Modal). Trade-off: callers cannot redirect the portal target. Acceptable for a top-level overlay primitive that always wants to escape ancestor stacking contexts.
- Caller-supplied container (MenuDropdown's
portalTo). Trade-off: more flexible, but caller carries the SSR responsibility. Best for scoped overlays where the parent already manages a target div.
For ExportReviewDialog the recommended primitive is Dialog (no portal at all) unless a portal is strictly required for stacking. If a portal becomes required, Modal is the next choice because its SSR guard is already in place.
5. Accessibility Baseline
| Component | role | aria-modal | label/desc | Escape | Focus management | Notes |
|---|---|---|---|---|---|---|
Dialog | dialog / alertdialog | yes | aria-labelledby + aria-describedby | yes (effect) | focus trap (firstElement/lastElement), restores previousFocus on unmount | Strongest a11y story in the set. |
Modal | dialog (inferred from aria-modal) | yes | aria-labelledby + aria-describedby | yes (effect) | focus trap, initialFocusRef prop, focusable toggle in Modal.a11y | |
Drawer | dialog | yes | aria-describedby | yes (test fires on window) | via useDrawerLogic hook | |
Tooltip | tooltip | n/a | aria-describedby toggled by visibility | n/a | n/a (non-modal) | |
Toast | alert (Toast.tsx:358) | n/a | aria-labelledby + aria-describedby | n/a | n/a | |
Dropdown | menu / menuitem | n/a (non-modal) | none in main file | n/a | keyboard via aria-activedescendant-style id resolution (Dropdown.tsx:96) | |
Popover | tooltip (line 531), aria-labelledby/describedby | n/a (non-modal) | yes | yes (effect) | n/a (non-modal) | |
Menu | menu / menuitem | n/a | aria-describedby (Menu.a11y.tsx:280) | partial via keyboard nav | keyboard navigation in Menu.a11y |
Known gaps surfaced during the audit (intentionally not fixed in this PR):
DialogandModalhave separate base +.a11ycompanions. The base files carry their own role/aria/Escape/focus implementation; the.a11ycompanion adds polish (longer focusable-element selectors, Escape edge cases). Consumers that pickDialog(notDialog.A11y) still get a usable baseline. Documenting the split here so a futureExportReviewDialogchoice is informed.Drawer.test.tsxfiresEscapeonwindow, notdocument. The implementation listens ondocument(typical), so the test runs but the wiring is non-canonical. Worth confirming in a follow-up.Menu.a11y.tsxcarries the keyboard navigation; the plainMenu.tsxdoes not. PickMenu.A11yifExportReviewDialogever needs a menu inside it.
6. React 18 / React 19 Notes
- The Smolitux-UI test baseline currently runs on React 18. No React 19 runtime smoke has been executed in this audit.
- Per-package peer ranges declare
^17.0.0 || ^18.0.0 || ^19.0.0(see PR #576), which is peer-compatibility only. - The portal behavior patterns observed here (caller-supplied container,
typeof documentguard) are React-19-friendly: they do not depend on legacy quirks of React 17/18 that React 19 changed (e.g. event-system root attachment). createPortalitself is unchanged across React 17 → 19; the migration risk is in event delegation and concurrent-mode batching, neither of which the audited components depend on at module scope.
For runtime React 19 acceptance, the following are still required and explicitly out of scope for this PR:
- consumer-app smoke under React 19 (e.g. Vite + Modal/Dialog open/close → focus + Escape semantics)
- Storybook builder migration if Storybook 8/9 + React 19 is targeted
- overlay interaction smoke under React 19's more aggressive concurrent rendering
- per-component fix sweep if any of the above surface regressions
These belong to dedicated follow-up PRs (see §9).
7. ExportReviewDialog Readiness
The consumption strategy OceanData/docs/architecture/SMOLITUX_UI_CONSUMPTION_STRATEGY.md §7 already lists the OceanData decision: Dialog for confirmation/review flows. This audit confirms that decision is implementable today:
Recommended primitive: Dialog.
Rationale:
- No portal — eliminates SSR portal-target risk entirely.
- Strongest a11y story in the set (
role="dialog"/"alertdialog",aria-modal,aria-labelledby,aria-describedby, Escape, focus trap, focus restoration). - No reliance on
document.bodyoutside effects. - Test coverage exists and runs in jsdom.
Fallback primitive: Modal. Use only if a portal is required (e.g. a parent ancestor uses overflow: hidden and would clip the dialog). The SSR guard is already in place.
Not recommended for ExportReviewDialog: Drawer. Drawer's metaphor is "side-panel detail", not "confirmation/review". OceanData's consumption brief (§7) already excludes it.
Gates required before opening the ExportReviewDialog PR:
- ✅
role/aria-modal/ focus / Escape audited and present (this PR). - ✅ No import-time
documentaccess on the chosen primitive (this PR — Dialog: clean, Modal: guarded). - ✅ Portal behavior documented (this PR — §4).
- ✅ jsdom render smoke green (this PR — eight components import without crashing; per-component render tests in
__tests__/already exist and pass). - ⚠️ Runtime React 19 smoke (deferred).
- ⚠️ SSR-render smoke under Next.js or equivalent (deferred — only relevant if OceanData picks SSR; current
web/scaffold is Vite + CSR).
ExportReviewDialog may proceed as a separate PR (PR12.12) targeting @smolitux/data-governance, on top of Dialog. The deferred gates (5–6) do not block PR12.12 because the OceanData consumer ships CSR-only today.
8. Findings
Confirmed-safe findings (no action required):
- Zero module-scope browser API access across all eight overlay components.
- Modal's
createPortalcall is SSR-guarded withtypeof document !== 'undefined'(Modal.tsx:666) and additionally withNODE_ENV !== 'test'in the.a11yvariant. MenuDropdownportal is caller-controlled via theportalToprop — no hardcoded global container.- Dialog's a11y baseline is the strongest of the set: role + aria-modal + label + desc + focus trap + Escape + focus restoration, all in pure
useEffect-time code. - Tooltip / Popover positioning uses
window.innerWidth/innerHeight/pageYOffsetexclusively inside resize-bound effects; SSR-safe.
Risks observed (not fixed in this PR — flagged for follow-up):
- Modal's portal target is hardcoded to
document.body. Acceptable today, but aportalTo-style prop (mirroringMenuDropdown) would make Modal more composable when an OceanData app eventually ships a custom theming/scaling root. Follow-up only — no action in this PR. Drawer.test.tsxfiresEscapeonwindow, notdocument, while the implementation listens ondocument. The test passes today (jsdom forwards the event), but the wiring is non-canonical and could mask a regression if listener registration moves. Follow-up note, not a blocker.Dialoghas both a base and an.a11ycompanion with diverging implementations. Future contributors must choose deliberately. The barrel exposes both asDialogandDialog.A11y. Documentation in this audit is the action; no code change.
Open risks (require runtime evidence before declaring "ready"):
- React 19 runtime behaviour of overlay components is not certified (peer-compatible only).
- SSR-render-time behaviour of Modal in a Node environment with
usePortal=truehas not been smoke-tested against Next.js or equivalent.
9. Follow-up PRs
| Tag | Repo | Scope |
|---|---|---|
| PR12.10a | Modularium/smolitux-ui | Optional small fixes if a follow-up audit finds local SSR gaps. None blocking as of this PR. |
| PR12.12 | Modularium/smolitux-ui (@smolitux/data-governance) | ExportReviewDialog built on Dialog. Unblocked by this audit. |
| React 19 runtime smoke | Modularium/smolitux-ui | Consumer-app smoke (Vite + 5 overlay components) under React 19. |
| Storybook overlay regression | Modularium/smolitux-ui | Storybook builder review for React 19 + overlay open/close. |
Modal portalTo prop | Modularium/smolitux-ui (low priority) | Mirror MenuDropdown's caller-controlled container pattern in Modal. |
Drawer Escape listener target | Modularium/smolitux-ui (low priority) | Align test target (window) with implementation target (document). |
10. Non-goals
- No
ExportReviewDialogin this PR. - No OceanData app code changes.
- No React 19 certification — peer-compatible only; runtime verification still pending.
- No full overlay rewrite.
- No new dependency.
- No breaking changes.
- No Storybook migration.
- No CI changes.
- No
@smolitux/oceandataor@smolitux/data-governancecomponent changes.
11. Related Documents
SMOLITUX_UI_CONSUMPTION_STRATEGY.md— OceanData consumption brief (R18 baseline + Dialog primitive choice).SMOLITUX_UI_FOUNDATION_READINESS.md§4.1 — Dialog/Modal/Drawer convention.react-19-compatibility.md— repo-level React 19 status (peer-compatible; runtime pending).- Smoke test:
packages/@smolitux/core/src/components/Modal/__tests__/portal-ssr-smoke.test.tsx.