Zum Hauptinhalt springen

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 ExportReviewDialog consumer 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

ComponentPackage pathPortal useImport-time browser API riskRender smoke (existing)A11y baselineStatusNotes
Dialogpackages/@smolitux/core/src/components/Dialog/Dialog.tsxnone — renders inlinenoneDialog/__tests__/Dialog.test.tsx, Dialog.a11y.test.tsxrole="dialog" / role="alertdialog" (line 410), aria-modal="true", aria-labelledby, aria-describedby, Escape via document.addEventListener inside useEffect, focus trap with firstElement/lastElementpassInline render trades z-index/overlay flexibility for SSR safety. Primary candidate for ExportReviewDialog.
Modalpackages/@smolitux/core/src/components/Modal/Modal.tsxcreatePortal(modalContent, document.body) (line 667) — guarded by if (usePortal && typeof document !== 'undefined')none at module scope; document access only inside render guard or effectsModal.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_notesPortal already SSR-guarded; Modal.a11y adds process.env.NODE_ENV !== 'test' guard so jest renders Modal inline.
Drawerpackages/@smolitux/core/src/components/Drawer/Drawer.tsxnonenoneDrawer.test.tsx (asserts Escape on window)role="dialog", aria-modal="true", aria-describedby; focus handling via useDrawerLogic hookpass_with_notesSuitable for side-panel detail surfaces in OceanData; not a candidate for ExportReviewDialog per consumption brief.
Tooltippackages/@smolitux/core/src/components/Tooltip/Tooltip.tsxnonenone — window.innerWidth / window.innerHeight only inside resize effect (line 106)Tooltip.test.tsx, Tooltip.a11y.test.tsxrole="tooltip", aria-describedby toggled by visibilitypass
Toastpackages/@smolitux/core/src/components/Toast/Toast.tsx + ToastProvider.tsxnone in inspected filesnoneToast.test.tsx, Toast.a11y.test.tsx, ToastProvider.test.tsxrole="alert", aria-labelledby, aria-describedbypassIf ToastProvider introduces a portal in a later refactor, this audit's portal row needs revisiting.
Dropdownpackages/@smolitux/core/src/components/Dropdown/Dropdown.tsxnone in main filenone — document.getElementById and document.addEventListener inside effects onlyDropdown.a11y.test.tsxrole="menu", role="menuitem"pass
Popoverpackages/@smolitux/core/src/components/Popover/Popover.tsxnone in main filenone — window.pageYOffset, window.innerWidth, document.addEventListener only inside effectsPopover.test.tsx, Popover.a11y.test.tsxrole="tooltip", aria-labelledby, aria-describedby, Escape handler in effectpass
Menupackages/@smolitux/core/src/components/Menu/Menu.tsx + MenuDropdown.tsxReactDOM.createPortal(dropdown, portalTo) (MenuDropdown line 334) — caller-supplied containernone — caller passes portalTo; no document.body hardcodeMenu.test.tsx, Menu.a11y.test.tsx, MenuDropdown.test.tsx, MenuItem.test.tsx, Menu.snapshot.test.tsxrole="menu", role="menuitem", keyboard navigation in Menu.a11y.tsx (lines 230 onward)passPortal 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 in useEffect, returning a cleanup that calls removeEventListener. Safe under React StrictMode and SSR (effects do not run on the server).
  • document.activeElement reads (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.body portal target (Modal line 667, Modal.a11y line 1078) — inside the render function but guarded by typeof document !== 'undefined'. Modal.a11y additionally guards process.env.NODE_ENV !== 'test'. Safe.
  • document.getElementById (Dropdown line 96) — inside onKeyDown handler. Safe.
  • document.documentElement.scrollTop / scrollLeft (Popover lines 191–192) — inside useEffect-driven positioning callback. Safe.
  • window.innerWidth / innerHeight (Tooltip 106–107, Popover 195–196) — inside resize callback registered via useEffect. Safe.
  • window.addEventListener('resize', …) (Tooltip 160, Popover 351) — inside useEffect with 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

ComponentcreatePortalContainerSSR guardCaller override
Dialognot usedn/a (inline render)inline = always safen/a
Modalreact-dom's createPortal (Modal.tsx:667)document.body (hardcoded)usePortal && typeof document !== 'undefined' (Modal.tsx:666)usePortal: false falls back to inline
Modal.a11ysamesameadditionally gated by NODE_ENV !== 'test' (Modal.a11y.tsx:1077)same
Drawernot usedn/ainline = safen/a
Tooltipnot usedn/ainline = safen/a
Toastnot observed in Toast.tsx / ToastProvider.tsxn/an/an/a
Dropdownnot used in Dropdown.tsxn/ainline = safen/a
Popovernot used in Popover.tsxn/ainline = safen/a
Menu (MenuDropdown)ReactDOM.createPortal(dropdown, portalTo) (MenuDropdown.tsx:334)caller-supplied via portalTo propconditional on prop being setyes — caller-controlled

Two patterns are present and both are acceptable:

  1. 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.
  2. 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

Componentrolearia-modallabel/descEscapeFocus managementNotes
Dialogdialog / alertdialogyesaria-labelledby + aria-describedbyyes (effect)focus trap (firstElement/lastElement), restores previousFocus on unmountStrongest a11y story in the set.
Modaldialog (inferred from aria-modal)yesaria-labelledby + aria-describedbyyes (effect)focus trap, initialFocusRef prop, focusable toggle in Modal.a11y
Drawerdialogyesaria-describedbyyes (test fires on window)via useDrawerLogic hook
Tooltiptooltipn/aaria-describedby toggled by visibilityn/an/a (non-modal)
Toastalert (Toast.tsx:358)n/aaria-labelledby + aria-describedbyn/an/a
Dropdownmenu / menuitemn/a (non-modal)none in main filen/akeyboard via aria-activedescendant-style id resolution (Dropdown.tsx:96)
Popovertooltip (line 531), aria-labelledby/describedbyn/a (non-modal)yesyes (effect)n/a (non-modal)
Menumenu / menuitemn/aaria-describedby (Menu.a11y.tsx:280)partial via keyboard navkeyboard navigation in Menu.a11y

Known gaps surfaced during the audit (intentionally not fixed in this PR):

  • Dialog and Modal have separate base + .a11y companions. The base files carry their own role/aria/Escape/focus implementation; the .a11y companion adds polish (longer focusable-element selectors, Escape edge cases). Consumers that pick Dialog (not Dialog.A11y) still get a usable baseline. Documenting the split here so a future ExportReviewDialog choice is informed.
  • Drawer.test.tsx fires Escape on window, not document. The implementation listens on document (typical), so the test runs but the wiring is non-canonical. Worth confirming in a follow-up.
  • Menu.a11y.tsx carries the keyboard navigation; the plain Menu.tsx does not. Pick Menu.A11y if ExportReviewDialog ever 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 document guard) 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).
  • createPortal itself 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.body outside 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:

  1. role / aria-modal / focus / Escape audited and present (this PR).
  2. ✅ No import-time document access on the chosen primitive (this PR — Dialog: clean, Modal: guarded).
  3. ✅ Portal behavior documented (this PR — §4).
  4. ✅ jsdom render smoke green (this PR — eight components import without crashing; per-component render tests in __tests__/ already exist and pass).
  5. ⚠️ Runtime React 19 smoke (deferred).
  6. ⚠️ 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 createPortal call is SSR-guarded with typeof document !== 'undefined' (Modal.tsx:666) and additionally with NODE_ENV !== 'test' in the .a11y variant.
  • MenuDropdown portal is caller-controlled via the portalTo prop — 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 / pageYOffset exclusively 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 a portalTo-style prop (mirroring MenuDropdown) 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.tsx fires Escape on window, not document, while the implementation listens on document. 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.
  • Dialog has both a base and an .a11y companion with diverging implementations. Future contributors must choose deliberately. The barrel exposes both as Dialog and Dialog.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=true has not been smoke-tested against Next.js or equivalent.

9. Follow-up PRs

TagRepoScope
PR12.10aModularium/smolitux-uiOptional small fixes if a follow-up audit finds local SSR gaps. None blocking as of this PR.
PR12.12Modularium/smolitux-ui (@smolitux/data-governance)ExportReviewDialog built on Dialog. Unblocked by this audit.
React 19 runtime smokeModularium/smolitux-uiConsumer-app smoke (Vite + 5 overlay components) under React 19.
Storybook overlay regressionModularium/smolitux-uiStorybook builder review for React 19 + overlay open/close.
Modal portalTo propModularium/smolitux-ui (low priority)Mirror MenuDropdown's caller-controlled container pattern in Modal.
Drawer Escape listener targetModularium/smolitux-ui (low priority)Align test target (window) with implementation target (document).

10. Non-goals

  • No ExportReviewDialog in 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/oceandata or @smolitux/data-governance component changes.