From 979eb132aa8818ee08a53119cb32d39049adb912 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 19 Aug 2026 15:14:54 -0700 Subject: [PATCH 1/2] fix(refresher): validate slot after frameworks assign it --- core/src/components/refresher/refresher.tsx | 12 +++- .../test/missing-slot/refresher.e2e.ts | 69 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 core/src/components/refresher/test/missing-slot/refresher.e2e.ts diff --git a/core/src/components/refresher/refresher.tsx b/core/src/components/refresher/refresher.tsx index ecd27fd0965..d98f0248c61 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() { + /** + * Checked here rather than in `connectedCallback`, which with the custom elements + * build runs while the element is being inserted, before frameworks like React + * assign the slot. Rendering puts `slot="fixed"` on the host, so this is also the + * last point where the developer's own markup is still visible. + */ + 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/missing-slot/refresher.e2e.ts b/core/src/components/refresher/test/missing-slot/refresher.e2e.ts new file mode 100644 index 00000000000..d40bd789b9c --- /dev/null +++ b/core/src/components/refresher/test/missing-slot/refresher.e2e.ts @@ -0,0 +1,69 @@ +import { expect } from '@playwright/test'; +import { configs, dragElementByYAxis, test } from '@utils/test/playwright'; + +/** + * 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: missing slot'), () => { + test('should still set up the pull-to-refresh gesture', 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: string[] = []; + + page.on('console', (msg) => { + if (msg.type() === 'error') { + logs.push(msg.text()); + } + }); + + await page.setContent( + ` + + + + + + `, + config + ); + await page.locator('ion-refresher.hydrated').waitFor({ state: 'attached' }); + + expect(logs.length).toBe(1); + expect(logs[0]).toContain('[Ionic Error]: [ion-refresher] - Make sure you use: '); + }); + }); +}); From dc88930f326de205a6a38c30d4a9f4ee87d88f18 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 20 Aug 2026 08:50:02 -0700 Subject: [PATCH 2/2] test(refresher): add slot-set case to slot validation --- core/src/components/refresher/refresher.tsx | 8 ++-- .../refresher.e2e.ts | 47 +++++++++++++++---- 2 files changed, 41 insertions(+), 14 deletions(-) rename core/src/components/refresher/test/{missing-slot => slot-validation}/refresher.e2e.ts (61%) diff --git a/core/src/components/refresher/refresher.tsx b/core/src/components/refresher/refresher.tsx index d98f0248c61..153f4cc6f76 100644 --- a/core/src/components/refresher/refresher.tsx +++ b/core/src/components/refresher/refresher.tsx @@ -513,10 +513,10 @@ export class Refresher implements ComponentInterface { } /** - * Checked here rather than in `connectedCallback`, which with the custom elements - * build runs while the element is being inserted, before frameworks like React - * assign the slot. Rendering puts `slot="fixed"` on the host, so this is also the - * last point where the developer's own markup is still visible. + * 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') { diff --git a/core/src/components/refresher/test/missing-slot/refresher.e2e.ts b/core/src/components/refresher/test/slot-validation/refresher.e2e.ts similarity index 61% rename from core/src/components/refresher/test/missing-slot/refresher.e2e.ts rename to core/src/components/refresher/test/slot-validation/refresher.e2e.ts index d40bd789b9c..952c2e4125a 100644 --- a/core/src/components/refresher/test/missing-slot/refresher.e2e.ts +++ b/core/src/components/refresher/test/slot-validation/refresher.e2e.ts @@ -1,6 +1,21 @@ 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 @@ -9,8 +24,8 @@ import { configs, dragElementByYAxis, test } from '@utils/test/playwright'; * This behavior does not vary across directions. */ configs({ directions: ['ltr'] }).forEach(({ title, config }) => { - test.describe(title('refresher: missing slot'), () => { - test('should still set up the pull-to-refresh gesture', async ({ page }, testInfo) => { + 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', @@ -42,13 +57,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => { }); test('should report an error telling the developer to add the slot', async ({ page }) => { - const logs: string[] = []; - - page.on('console', (msg) => { - if (msg.type() === 'error') { - logs.push(msg.text()); - } - }); + const logs = collectConsoleErrors(page); await page.setContent( ` @@ -63,7 +72,25 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => { await page.locator('ion-refresher.hydrated').waitFor({ state: 'attached' }); expect(logs.length).toBe(1); - expect(logs[0]).toContain('[Ionic Error]: [ion-refresher] - Make sure you use: '); + 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([]); }); }); });