Skip to content

Fix iOS Safari viewport shrinking in dashboard - #81

Open
willianpm wants to merge 2 commits into
masterfrom
fix/ios-safari-viewport-shrinking
Open

Fix iOS Safari viewport shrinking in dashboard#81
willianpm wants to merge 2 commits into
masterfrom
fix/ios-safari-viewport-shrinking

Conversation

@willianpm

@willianpm willianpm commented May 19, 2026

Copy link
Copy Markdown
Owner

Summary

  • Fix iOS Safari viewport shrinking by driving layout height from a visualViewport-backed CSS variable.
  • Apply the dynamic height var to the dashboard shell and poll edit dialog max-height.
  • Add unit tests for the viewport height hook.

Root Cause

On iOS Safari, the visual viewport stays reduced after the virtual keyboard closes when the app uses a fixed-height scroll container (h-dvh) inside body with overflow hidden. The dynamic viewport unit does not reliably re-expand, so the container keeps the smaller height and the page appears permanently shrunk.

Fix

  • Add useViewportHeight hook to set --app-dvh from visualViewport.height (fallback to window.innerHeight) and update it on resize/orientation/focus events.
  • Replace 100dvh-based sizing in the dashboard layout and poll edit dialog with var(--app-dvh, 100dvh).

Tests

  • npm run lint
  • npm test
  • npm run test:dashboard
  • npm --prefix dashboard/frontend test
  • npm run dashboard:frontend:build

Type Safety

No dedicated typecheck script exists for this repo.

Regression Considerations

  • Layout height now depends on a CSS variable updated by visualViewport, which could affect rare scroll/fixed edge cases.
  • Dialog max-height now uses the same var; verify on desktop and Android.

Summary by CodeRabbit

  • New Features

    • App now tracks real viewport height and offset to make layouts respond more accurately to viewport changes (improved mobile behavior).
  • Bug Fixes

    • Modals, dashboard layout and sidebar sizing now use the tracked viewport values so dialog heights and scrolling behave consistently as the viewport changes.
  • Tests

    • Added tests to verify viewport height and offset are applied and cleaned up correctly.

Review Change Stack

@willianpm willianpm linked an issue May 19, 2026 that may be closed by this pull request
@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

The PR adds a useViewportHeight hook that computes and syncs --app-dvh and --app-vv-offset-top CSS variables (from visualViewport or window), clamps scroll roots, includes tests, and wires the hook into App, DashboardLayout, and PollDrafts to replace hardcoded dvh-based sizing.

Changes

Viewport Height Management

Layer / File(s) Summary
Viewport height hook implementation
dashboard/frontend/src/app/lib/useViewportHeight.ts
Adds getViewportHeight() and getViewportOffsetTop() helpers, setViewportHeightVar() and setViewportOffsetVar() to validate/round and write --app-dvh/--app-vv-offset-top, and useViewportHeight() hook that schedules coalesced updates (RAF + 250ms), registers window/visualViewport listeners, clamps [data-viewport-scroll-root] scrollTop, and cleans up listeners/timers on unmount.
Hook behavior tests
dashboard/frontend/src/app/lib/useViewportHeight.test.tsx
New tests render a component invoking useViewportHeight, simulate window.visualViewport, reset state after each test, and assert --app-dvh is set for visualViewport-present and visualViewport-missing (fallback to innerHeight) cases.
App root initialization
dashboard/frontend/src/app/App.tsx
Imports and invokes useViewportHeight inside App so viewport CSS variables are initialized at application root; no other rendering or exported API changes.
Layout and modal CSS variable integration
dashboard/frontend/src/app/components/DashboardLayout.tsx, dashboard/frontend/src/app/pages/PollDrafts.tsx
DashboardLayout replaces dvh classes with h-[var(--app-dvh,100dvh)] and uses --app-dvh-based min-h/h calculations for desktop layout; PollDrafts modal DialogContent and inner wrapper use calc(var(--app-dvh, 100dvh) - 2rem) for max-height.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Poem

🐰 I measured the sky with a hop and a grin,
variables set where heights used to have been,
RAF coalesced, offsets in tow,
modals and layout now ebb and now flow,
a rabbit's small patch of responsive glow.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the primary fix—addressing iOS Safari viewport shrinking—which is the main objective and root cause addressed by adding the useViewportHeight hook and updating height-based styling throughout the dashboard.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ios-safari-viewport-shrinking

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

dashboard/frontend/src/app/components/DashboardLayout.tsx

Parsing error: 'import' and 'export' may appear only with 'sourceType: module'

dashboard/frontend/src/app/lib/useViewportHeight.ts

Parsing error: 'import' and 'export' may appear only with 'sourceType: module'


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@willianpm willianpm self-assigned this May 19, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
dashboard/frontend/src/app/lib/useViewportHeight.test.tsx (2)

47-56: ⚡ Quick win

Assert listener cleanup by unmounting the component.

This test currently verifies registration but not teardown. Add an unmount() assertion so cleanup regressions are caught.

Proposed diff
-    render(<TestComponent />);
+    const { unmount } = render(<TestComponent />);
@@
     expect(addEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
     expect(addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
     expect(removeEventListener).not.toHaveBeenCalled();
+
+    unmount();
+    expect(removeEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
+    expect(removeEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dashboard/frontend/src/app/lib/useViewportHeight.test.tsx` around lines 47 -
56, The test registers viewport listeners but never unmounts the TestComponent
to assert cleanup; after render(<TestComponent />) and the existing waitFor
assertion, call the returned unmount() from render (e.g., const { unmount } =
render(...)) and then assert that removeEventListener was called for both
'resize' and 'scroll' (use
expect(removeEventListener).toHaveBeenCalledWith('resize', expect.any(Function))
and similarly for 'scroll') and/or that removeEventListener was called the
expected number of times instead of leaving only the registration checks in the
useViewportHeight test.

25-29: ⚡ Quick win

Restore window.innerHeight after overriding it in tests.

Line 61 mutates a global descriptor, but cleanup does not restore it. This can cause cross-test leakage as the suite grows.

Proposed diff
 describe('useViewportHeight', () => {
+  const originalInnerHeight = Object.getOwnPropertyDescriptor(window, 'innerHeight');
+
   afterEach(() => {
     document.documentElement.style.removeProperty('--app-dvh');
     setVisualViewport(undefined);
+    if (originalInnerHeight) {
+      Object.defineProperty(window, 'innerHeight', originalInnerHeight);
+    }
   });

Also applies to: 61-64

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dashboard/frontend/src/app/lib/useViewportHeight.test.tsx` around lines 25 -
29, The test mutates the global window.innerHeight descriptor but the afterEach
cleanup only removes --app-dvh and calls setVisualViewport(undefined); capture
the original window.innerHeight descriptor before overriding (e.g., const
originalInnerHeight = Object.getOwnPropertyDescriptor(window, 'innerHeight'))
and restore it in the afterEach along with the existing cleanup (use
Object.defineProperty(window, 'innerHeight', originalInnerHeight) if the
descriptor exists); update the test file where you override window.innerHeight
(the setup logic around the innerHeight override) to save the descriptor so
afterEach can restore it to prevent cross-test leakage.
dashboard/frontend/src/app/lib/useViewportHeight.ts (1)

23-29: ⚡ Quick win

Avoid redundant CSS var writes on high-frequency events.

Line 43 triggers immediate updates for every visualViewport scroll/resize event, and Line 28 writes the CSS variable even when unchanged. Guarding duplicate writes will reduce style churn on iOS keyboard animations and scroll bursts.

Proposed diff
 function setViewportHeightVar(height: number) {
   if (typeof document === 'undefined') {
     return;
   }

   const nextHeight = Math.round(height);
   if (!Number.isFinite(nextHeight) || nextHeight <= 0) {
     return;
   }

-  document.documentElement.style.setProperty(VIEWPORT_VAR, `${nextHeight}px`);
+  const nextValue = `${nextHeight}px`;
+  if (document.documentElement.style.getPropertyValue(VIEWPORT_VAR) === nextValue) {
+    return;
+  }
+  document.documentElement.style.setProperty(VIEWPORT_VAR, nextValue);
 }

Also applies to: 40-56, 67-69

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dashboard/frontend/src/app/lib/useViewportHeight.ts` around lines 23 - 29,
The update routine in useViewportHeight.ts is writing the CSS var on every
visualViewport event even when unchanged; change the handler (the code that
computes nextHeight and calls document.documentElement.style.setProperty with
VIEWPORT_VAR) to skip setting the property if the numeric value equals the
last-applied value (store a module-level or closure-scoped lastHeight) or
compare against
getComputedStyle(document.documentElement).getPropertyValue(VIEWPORT_VAR) before
calling setProperty, and also throttle/debounce the visualViewport listener if
needed for bursty events; apply the same guard pattern to the other update
points mentioned (the blocks around lines 40-56 and 67-69).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@dashboard/frontend/src/app/lib/useViewportHeight.test.tsx`:
- Around line 47-56: The test registers viewport listeners but never unmounts
the TestComponent to assert cleanup; after render(<TestComponent />) and the
existing waitFor assertion, call the returned unmount() from render (e.g., const
{ unmount } = render(...)) and then assert that removeEventListener was called
for both 'resize' and 'scroll' (use
expect(removeEventListener).toHaveBeenCalledWith('resize', expect.any(Function))
and similarly for 'scroll') and/or that removeEventListener was called the
expected number of times instead of leaving only the registration checks in the
useViewportHeight test.
- Around line 25-29: The test mutates the global window.innerHeight descriptor
but the afterEach cleanup only removes --app-dvh and calls
setVisualViewport(undefined); capture the original window.innerHeight descriptor
before overriding (e.g., const originalInnerHeight =
Object.getOwnPropertyDescriptor(window, 'innerHeight')) and restore it in the
afterEach along with the existing cleanup (use Object.defineProperty(window,
'innerHeight', originalInnerHeight) if the descriptor exists); update the test
file where you override window.innerHeight (the setup logic around the
innerHeight override) to save the descriptor so afterEach can restore it to
prevent cross-test leakage.

In `@dashboard/frontend/src/app/lib/useViewportHeight.ts`:
- Around line 23-29: The update routine in useViewportHeight.ts is writing the
CSS var on every visualViewport event even when unchanged; change the handler
(the code that computes nextHeight and calls
document.documentElement.style.setProperty with VIEWPORT_VAR) to skip setting
the property if the numeric value equals the last-applied value (store a
module-level or closure-scoped lastHeight) or compare against
getComputedStyle(document.documentElement).getPropertyValue(VIEWPORT_VAR) before
calling setProperty, and also throttle/debounce the visualViewport listener if
needed for bursty events; apply the same guard pattern to the other update
points mentioned (the blocks around lines 40-56 and 67-69).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3220c78a-e9bf-4d7c-a97e-da4c91d4a8ed

📥 Commits

Reviewing files that changed from the base of the PR and between 9b449a3 and 7b22bc0.

📒 Files selected for processing (5)
  • dashboard/frontend/src/app/App.tsx
  • dashboard/frontend/src/app/components/DashboardLayout.tsx
  • dashboard/frontend/src/app/lib/useViewportHeight.test.tsx
  • dashboard/frontend/src/app/lib/useViewportHeight.ts
  • dashboard/frontend/src/app/pages/PollDrafts.tsx

Add --app-vv-offset-top CSS var and update useViewportHeight to set offset and clamp the dashboard s
croll root.

Prevents leftover black bar and content jumping on iOS Safari when the virtual keyboard opens/closes
.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
dashboard/frontend/src/app/components/DashboardLayout.tsx (1)

52-87: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Mobile header does not respect viewport offset.

The mobile header uses fixed top-0, which positions it at the top of the viewport regardless of the parent container's --app-vv-offset-top offset. On iOS Safari, when the visualViewport has a non-zero offset (e.g., virtual keyboard open, status bar area), the header will be positioned at viewport top: 0 while the root content starts at top: var(--app-vv-offset-top), creating a misalignment.

🔧 Proposed fix to align mobile header with viewport offset
-      <div className="lg:hidden fixed top-0 left-0 right-0 z-50 bg-[`#5865F2`] dark:bg-[`#4752C4`] text-white p-4 flex items-center justify-between">
+      <div className="lg:hidden fixed top-[var(--app-vv-offset-top,0px)] left-0 right-0 z-50 bg-[`#5865F2`] dark:bg-[`#4752C4`] text-white p-4 flex items-center justify-between">
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dashboard/frontend/src/app/components/DashboardLayout.tsx` around lines 52 -
87, The mobile header div in DashboardLayout.tsx (the element with className
"lg:hidden fixed top-0 left-0 right-0 z-50 ...") is fixed to top:0 and ignores
the app visual-viewport offset; change it to use the CSS variable for the
viewport offset so it aligns with the root content by replacing the hard-coded
top:0 with an inline style or class that sets top: 'var(--app-vv-offset-top)'
(e.g., add style={{ top: 'var(--app-vv-offset-top)' }} while keeping left-0 and
right-0 and the existing classes), ensuring the header respects the
visualViewport offset on iOS Safari.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@dashboard/frontend/src/app/components/DashboardLayout.tsx`:
- Around line 52-87: The mobile header div in DashboardLayout.tsx (the element
with className "lg:hidden fixed top-0 left-0 right-0 z-50 ...") is fixed to
top:0 and ignores the app visual-viewport offset; change it to use the CSS
variable for the viewport offset so it aligns with the root content by replacing
the hard-coded top:0 with an inline style or class that sets top:
'var(--app-vv-offset-top)' (e.g., add style={{ top: 'var(--app-vv-offset-top)'
}} while keeping left-0 and right-0 and the existing classes), ensuring the
header respects the visualViewport offset on iOS Safari.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ea84c8ed-0399-43be-a702-aac978fe03d1

📥 Commits

Reviewing files that changed from the base of the PR and between 7b22bc0 and 9ed5951.

📒 Files selected for processing (2)
  • dashboard/frontend/src/app/components/DashboardLayout.tsx
  • dashboard/frontend/src/app/lib/useViewportHeight.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS Safari viewport shrinking after keyboard close on poll form

1 participant