Skip to content

Commit 7375dd6

Browse files
authored
fix(content): fullscreen offset is computed correctly with tab bar (#28245)
Issue number: resolves #21130 --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying. --> IonContent sets `--offset-top` and `--offset-bottom` variables to allow the content to scroll under headers, footers, and tab bars. This is essential to creating the translucency effect on these components. IonContent does this by computing its offsetHeight and offsetTop coordinates which take into account the dimensions of headers, footers, and tab bars. Occasionally, this code will run before the IonTabBar has been hydrated which means that the offset will be wrong because the IonTabBar will have a dimension of 0x0 prior to hydration. This impacts Ionic Angular devs who are using the lazy loaded build of Ionic. React and Vue devs are not impacted because they are using the dist-custom-elements build of Ionic which does not have hydration. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> - IonContent will re-run the offset computation code whenever the `ionTabBarLoaded` event is emitted. This event is emitted at most once per IonTabBar instance. ## Does this introduce a breaking change? - [ ] Yes - [x] No <!-- If this introduces a breaking change, please describe the impact and migration path for existing applications below. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> Dev build: `7.4.2-dev.11695831341.191bdf12` Note: I did not write a test since this is fixing a race condition. I wasn't able to find a non-flaky way of testing this. You can test this in an Ionic Angular Tabs starter application with the dev build. The `--offset-bottom` variable on `ion-content` should be large enough such that the content will scroll under the tab bar. The translucency effect won't work just yet, but that is being fixed in #28246.
1 parent 1167a93 commit 7375dd6

File tree

3 files changed

+67
-5
lines changed

3 files changed

+67
-5
lines changed

core/src/components.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6976,6 +6976,7 @@ declare namespace LocalJSX {
69766976
*/
69776977
"mode"?: "ios" | "md";
69786978
"onIonTabBarChanged"?: (event: IonTabBarCustomEvent<TabBarChangedEventDetail>) => void;
6979+
"onIonTabBarLoaded"?: (event: IonTabBarCustomEvent<void>) => void;
69796980
/**
69806981
* The selected tab component
69816982
*/

core/src/components/content/content.tsx

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ComponentInterface, EventEmitter } from '@stencil/core';
22
import { Build, Component, Element, Event, Host, Listen, Method, Prop, forceUpdate, h, readTask } from '@stencil/core';
3-
import { componentOnReady } from '@utils/helpers';
3+
import { componentOnReady, hasLazyBuild } from '@utils/helpers';
44
import { isPlatform } from '@utils/platform';
55
import { isRTL } from '@utils/rtl';
66
import { createColorClasses, hostContext } from '@utils/theme';
@@ -34,6 +34,9 @@ export class Content implements ComponentInterface {
3434
private isMainContent = true;
3535
private resizeTimeout: ReturnType<typeof setTimeout> | null = null;
3636

37+
private tabsElement: HTMLElement | null = null;
38+
private tabsLoadCallback?: () => void;
39+
3740
// Detail is used in a hot loop in the scroll event, by allocating it here
3841
// V8 will be able to inline any read/write to it since it's a monomorphic class.
3942
// https://mrale.ph/blog/2015/01/11/whats-up-with-monomorphism.html
@@ -115,15 +118,61 @@ export class Content implements ComponentInterface {
115118

116119
connectedCallback() {
117120
this.isMainContent = this.el.closest('ion-menu, ion-popover, ion-modal') === null;
121+
122+
/**
123+
* The fullscreen content offsets need to be
124+
* computed after the tab bar has loaded. Since
125+
* lazy evaluation means components are not hydrated
126+
* at the same time, we need to wait for the ionTabBarLoaded
127+
* event to fire. This does not impact dist-custom-elements
128+
* because there is no hydration there.
129+
*/
130+
if (hasLazyBuild(this.el)) {
131+
/**
132+
* We need to cache the reference to the tabs.
133+
* If just the content is unmounted then we won't
134+
* be able to query for the closest tabs on disconnectedCallback
135+
* since the content has been removed from the DOM tree.
136+
*/
137+
const closestTabs = (this.tabsElement = this.el.closest('ion-tabs'));
138+
if (closestTabs !== null) {
139+
/**
140+
* When adding and removing the event listener
141+
* we need to make sure we pass the same function reference
142+
* otherwise the event listener will not be removed properly.
143+
* We can't only pass `this.resize` because "this" in the function
144+
* context becomes a reference to IonTabs instead of IonContent.
145+
*
146+
* Additionally, we listen for ionTabBarLoaded on the IonTabs
147+
* instance rather than the IonTabBar instance. It's possible for
148+
* a tab bar to be conditionally rendered/mounted. Since ionTabBarLoaded
149+
* bubbles, we can catch any instances of child tab bars loading by listening
150+
* on IonTabs.
151+
*/
152+
this.tabsLoadCallback = () => this.resize();
153+
closestTabs.addEventListener('ionTabBarLoaded', this.tabsLoadCallback);
154+
}
155+
}
118156
}
119157

120158
disconnectedCallback() {
121159
this.onScrollEnd();
122-
}
123160

124-
@Listen('appload', { target: 'window' })
125-
onAppLoad() {
126-
this.resize();
161+
if (hasLazyBuild(this.el)) {
162+
/**
163+
* The event listener and tabs caches need to
164+
* be cleared otherwise this will create a memory
165+
* leak where the IonTabs instance can never be
166+
* garbage collected.
167+
*/
168+
const { tabsElement, tabsLoadCallback } = this;
169+
if (tabsElement !== null && tabsLoadCallback !== undefined) {
170+
tabsElement.removeEventListener('ionTabBarLoaded', tabsLoadCallback);
171+
}
172+
173+
this.tabsElement = null;
174+
this.tabsLoadCallback = undefined;
175+
}
127176
}
128177

129178
/**

core/src/components/tab-bar/tab-bar.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ export class TabBar implements ComponentInterface {
5757
/** @internal */
5858
@Event() ionTabBarChanged!: EventEmitter<TabBarChangedEventDetail>;
5959

60+
/**
61+
* @internal
62+
* This event is used in IonContent to correctly
63+
* calculate the fullscreen content offsets
64+
* when IonTabBar is used.
65+
*/
66+
@Event() ionTabBarLoaded!: EventEmitter<void>;
67+
6068
componentWillLoad() {
6169
this.selectedTabChanged();
6270
}
@@ -82,6 +90,10 @@ export class TabBar implements ComponentInterface {
8290
}
8391
}
8492

93+
componentDidLoad() {
94+
this.ionTabBarLoaded.emit();
95+
}
96+
8597
render() {
8698
const { color, translucent, keyboardVisible } = this;
8799
const mode = getIonMode(this);

0 commit comments

Comments
 (0)