@@ -0,0 +1,185 @@
1+import { newSpecPage } from '@stencil/core/testing';
2+3+import type { FrameworkDelegate } from '../../../interface';
4+import { Tab } from '../tab';
5+6+const mockDelegate = (attachViewToDom: FrameworkDelegate['attachViewToDom']): FrameworkDelegate => ({
7+ attachViewToDom,
8+removeViewFromDom: jest.fn().mockResolvedValue(undefined),
9+});
10+11+const createTab = async (html = '<ion-tab tab="home" component="ion-content"></ion-tab>') => {
12+const page = await newSpecPage({
13+components: [Tab],
14+ html,
15+});
16+17+return { page, tabEl: page.body.querySelector<HTMLIonTabElement>('ion-tab')! };
18+};
19+20+describe('ion-tab: lazy loading', () => {
21+it('should attach the component only once across multiple setActive calls', async () => {
22+const { tabEl } = await createTab();
23+const attachViewToDom = jest.fn().mockResolvedValue(document.createElement('div'));
24+tabEl.delegate = mockDelegate(attachViewToDom);
25+26+await tabEl.setActive();
27+await tabEl.setActive();
28+29+expect(attachViewToDom).toHaveBeenCalledTimes(1);
30+});
31+32+it('should share one attach attempt across concurrent setActive calls', async () => {
33+const { tabEl } = await createTab();
34+const attachViewToDom = jest.fn().mockResolvedValue(document.createElement('div'));
35+tabEl.delegate = mockDelegate(attachViewToDom);
36+37+await Promise.all([tabEl.setActive(), tabEl.setActive()]);
38+39+expect(attachViewToDom).toHaveBeenCalledTimes(1);
40+});
41+42+it('should make a later setActive wait for the in-flight attach', async () => {
43+const { tabEl } = await createTab();
44+let resolveAttach!: (el: HTMLElement) => void;
45+const attachViewToDom = jest.fn(() => new Promise<HTMLElement>((resolve) => (resolveAttach = resolve)));
46+tabEl.delegate = mockDelegate(attachViewToDom);
47+48+const first = tabEl.setActive();
49+const second = tabEl.setActive();
50+let secondSettled = false;
51+second.then(() => (secondSettled = true));
52+53+await new Promise((resolve) => setTimeout(resolve, 0));
54+55+expect(secondSettled).toBe(false);
56+57+resolveAttach(document.createElement('div'));
58+await Promise.all([first, second]);
59+60+expect(secondSettled).toBe(true);
61+expect(attachViewToDom).toHaveBeenCalledTimes(1);
62+});
63+64+it('should retry the attach after a failed first attempt', async () => {
65+const { tabEl } = await createTab();
66+const attachViewToDom = jest
67+.fn()
68+.mockRejectedValueOnce(new Error('attach failed'))
69+.mockResolvedValue(document.createElement('div'));
70+tabEl.delegate = mockDelegate(attachViewToDom);
71+72+await expect(tabEl.setActive()).rejects.toThrow('attach failed');
73+await expect(tabEl.setActive()).resolves.toBeUndefined();
74+75+expect(attachViewToDom).toHaveBeenCalledTimes(2);
76+});
77+78+it('should not activate the tab when the attach fails', async () => {
79+const { tabEl } = await createTab();
80+tabEl.delegate = mockDelegate(jest.fn().mockRejectedValue(new Error('attach failed')));
81+82+await expect(tabEl.setActive()).rejects.toThrow('attach failed');
83+84+expect(tabEl.active).toBe(false);
85+});
86+87+it('should activate the tab once a retried attach succeeds', async () => {
88+const { page, tabEl } = await createTab();
89+const attachViewToDom = jest
90+.fn()
91+.mockRejectedValueOnce(new Error('attach failed'))
92+.mockResolvedValue(document.createElement('div'));
93+tabEl.delegate = mockDelegate(attachViewToDom);
94+95+await expect(tabEl.setActive()).rejects.toThrow('attach failed');
96+await tabEl.setActive();
97+await page.waitForChanges();
98+99+expect(attachViewToDom).toHaveBeenCalledTimes(2);
100+expect(tabEl.active).toBe(true);
101+expect(tabEl.classList.contains('tab-hidden')).toBe(false);
102+});
103+104+it('should attach the component when the tab starts active', async () => {
105+const { page, tabEl } = await createTab('<ion-tab tab="home" component="ion-content" active="true"></ion-tab>');
106+107+await page.waitForChanges();
108+109+expect(tabEl.querySelector('ion-content.ion-page')).not.toBeNull();
110+expect(tabEl.classList.contains('tab-hidden')).toBe(false);
111+});
112+113+it('should not attach anything when no component is provided', async () => {
114+const { tabEl } = await createTab('<ion-tab tab="home"></ion-tab>');
115+const attachViewToDom = jest.fn().mockResolvedValue(document.createElement('div'));
116+tabEl.delegate = mockDelegate(attachViewToDom);
117+118+await expect(tabEl.setActive()).resolves.toBeUndefined();
119+120+expect(attachViewToDom).not.toHaveBeenCalled();
121+expect(tabEl.active).toBe(true);
122+});
123+124+describe('when the attach fails on the active watcher path', () => {
125+let consoleErrorSpy: jest.SpyInstance;
126+127+beforeEach(() => {
128+consoleErrorSpy = jest.spyOn(console, 'error');
129+// Suppress console.error output from polluting the test output
130+consoleErrorSpy.mockImplementation(() => {});
131+});
132+133+afterEach(() => {
134+consoleErrorSpy.mockRestore();
135+});
136+137+// Jest fails a test on an unhandled rejection, so the watcher swallowing the
138+// rejection is covered without an explicit unhandledRejection listener.
139+it('should log the error', async () => {
140+const { page, tabEl } = await createTab();
141+tabEl.delegate = mockDelegate(jest.fn().mockRejectedValue(new Error('attach failed')));
142+143+// ion-tabs activates a tab by setting `active`, it does not await setActive().
144+tabEl.active = true;
145+await page.waitForChanges();
146+// Give the rejected attach a turn to settle so the watcher can log it.
147+await new Promise((resolve) => setTimeout(resolve, 0));
148+149+expect(consoleErrorSpy).toHaveBeenCalledWith(
150+'[Ionic Error]: [ion-tab] - Exception in prepareLazyLoaded:',
151+expect.any(Error)
152+);
153+});
154+155+it('should retry on the next activation, as ion-tabs reactivates tabs', async () => {
156+const { page, tabEl } = await createTab();
157+const attachViewToDom = jest
158+.fn()
159+.mockRejectedValueOnce(new Error('attach failed'))
160+.mockImplementation(() => {
161+const el = document.createElement('div');
162+el.classList.add('attached-page');
163+tabEl.appendChild(el);
164+return Promise.resolve(el);
165+});
166+tabEl.delegate = mockDelegate(attachViewToDom);
167+168+tabEl.active = true;
169+await page.waitForChanges();
170+await new Promise((resolve) => setTimeout(resolve, 0));
171+172+expect(tabEl.querySelector('.attached-page')).toBeNull();
173+174+// ion-tabs deactivates the leaving tab, so returning to it is a false -> true change.
175+tabEl.active = false;
176+await page.waitForChanges();
177+tabEl.active = true;
178+await page.waitForChanges();
179+await new Promise((resolve) => setTimeout(resolve, 0));
180+181+expect(attachViewToDom).toHaveBeenCalledTimes(2);
182+expect(tabEl.querySelector('.attached-page')).not.toBeNull();
183+});
184+});
185+});