diff --git a/core/src/components/refresher/refresher.tsx b/core/src/components/refresher/refresher.tsx index ecd27fd0965..153f4cc6f76 100644 --- a/core/src/components/refresher/refresher.tsx +++ b/core/src/components/refresher/refresher.tsx @@ -512,11 +512,19 @@ export class Refresher implements ComponentInterface { this.checkNativeRefresher(); } - async connectedCallback() { + /** + * Validate the slot attribute before rendering, while the host still reflects the + * developer's original markup. `connectedCallback` is too early: in the custom + * elements build it runs during insertion, before frameworks such as React assign + * the slot. + */ + componentWillLoad() { if (this.el.getAttribute('slot') !== 'fixed') { printIonError('[ion-refresher] - Make sure you use: '); - return; } + } + + async connectedCallback() { const contentEl = this.el.closest(ION_CONTENT_ELEMENT_SELECTOR); if (!contentEl) { printIonContentErrorMsg(this.el); diff --git a/core/src/components/refresher/test/slot-validation/refresher.e2e.ts b/core/src/components/refresher/test/slot-validation/refresher.e2e.ts new file mode 100644 index 00000000000..952c2e4125a --- /dev/null +++ b/core/src/components/refresher/test/slot-validation/refresher.e2e.ts @@ -0,0 +1,96 @@ +import { expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { configs, dragElementByYAxis, test } from '@utils/test/playwright'; + +const collectConsoleErrors = (page: Page) => { + const logs: string[] = []; + + page.on('console', (msg) => { + if (msg.type() === 'error') { + logs.push(msg.text()); + } + }); + + return logs; +}; + +const SLOT_ERROR = '[Ionic Error]: [ion-refresher] - Make sure you use: '; + +/** + * Rendering puts `slot="fixed"` on the host, so a refresher whose markup omits the + * slot still ends up in the right place and has to work. Frameworks that assign the + * slot after inserting the element start out the same way. + * + * This behavior does not vary across directions. + */ +configs({ directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('refresher: slot validation'), () => { + test('should still set up the pull-to-refresh gesture when the slot is missing', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31376', + }); + + await page.setContent( + ` + + + + + +
+
+ `, + config + ); + /** + * Gesture setup runs behind a dynamic import, so dragging straight after + * setContent can land before the refresher is listening. + */ + await page.locator('ion-refresher.hydrated').waitFor({ state: 'attached' }); + + const ionRefresh = await page.spyOnEvent('ionRefresh'); + + await dragElementByYAxis(page.locator('body'), page, 320); + + await expect.poll(() => ionRefresh.events.length).toBe(1); + }); + + test('should report an error telling the developer to add the slot', async ({ page }) => { + const logs = collectConsoleErrors(page); + + await page.setContent( + ` + + + + + + `, + config + ); + await page.locator('ion-refresher.hydrated').waitFor({ state: 'attached' }); + + expect(logs.length).toBe(1); + expect(logs[0]).toContain(SLOT_ERROR); + }); + + test('should not report an error when the slot is set', async ({ page }) => { + const logs = collectConsoleErrors(page); + + await page.setContent( + ` + + + + + + `, + config + ); + await page.locator('ion-refresher.hydrated').waitFor({ state: 'attached' }); + + expect(logs).toEqual([]); + }); + }); +});