diff --git a/.changeset/clever-emus-roll.md b/.changeset/clever-emus-roll.md
new file mode 100644
index 0000000000..5b9b2ee698
--- /dev/null
+++ b/.changeset/clever-emus-roll.md
@@ -0,0 +1,11 @@
+---
+'astro': minor
+---
+
+Adds a new, optional property `timeout` for the `client:idle` directive.
+
+This value allows you to specify a maximum time to wait, in milliseconds, before hydrating a UI framework component, even if the page is not yet done with its initial load. This means you can delay hydration for lower-priority UI elements with more control to ensure your element is interactive within a specified time frame.
+
+```astro
+
+```
diff --git a/packages/astro/e2e/client-idle-timeout.test.js b/packages/astro/e2e/client-idle-timeout.test.js
new file mode 100644
index 0000000000..034cfc8dca
--- /dev/null
+++ b/packages/astro/e2e/client-idle-timeout.test.js
@@ -0,0 +1,33 @@
+import { expect } from '@playwright/test';
+import { testFactory, waitForHydrate } from './test-utils.js';
+
+const test = testFactory({ root: './fixtures/client-idle-timeout/' });
+
+let devServer;
+
+test.beforeAll(async ({ astro }) => {
+ devServer = await astro.startDevServer();
+});
+
+test.afterAll(async () => {
+ await devServer.stop();
+});
+
+test.describe('Client idle timeout', () => {
+ test('React counter', async ({ astro, page }) => {
+ await page.goto(astro.resolveUrl('/'));
+
+ const counter = page.locator('#react-counter');
+ await expect(counter, 'component is visible').toBeVisible();
+
+ const count = counter.locator('pre');
+ await expect(count, 'initial count is 0').toHaveText('0');
+
+ await waitForHydrate(page, counter);
+
+ const inc = counter.locator('.increment');
+ await inc.click();
+
+ await expect(count, 'count incremented by 1').toHaveText('1');
+ });
+});
diff --git a/packages/astro/e2e/fixtures/client-idle-timeout/astro.config.mjs b/packages/astro/e2e/fixtures/client-idle-timeout/astro.config.mjs
new file mode 100644
index 0000000000..02dccb9780
--- /dev/null
+++ b/packages/astro/e2e/fixtures/client-idle-timeout/astro.config.mjs
@@ -0,0 +1,9 @@
+import react from '@astrojs/react';
+import { defineConfig } from 'astro/config';
+
+// https://astro.build/config
+export default defineConfig({
+ integrations: [
+ react(),
+ ],
+});
diff --git a/packages/astro/e2e/fixtures/client-idle-timeout/package.json b/packages/astro/e2e/fixtures/client-idle-timeout/package.json
new file mode 100644
index 0000000000..af4c416058
--- /dev/null
+++ b/packages/astro/e2e/fixtures/client-idle-timeout/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@e2e/client-idle-timeout",
+ "version": "0.0.0",
+ "private": true,
+ "devDependencies": {
+ "@astrojs/react": "workspace:*",
+ "astro": "workspace:*"
+ },
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ }
+}
diff --git a/packages/astro/e2e/fixtures/client-idle-timeout/src/components/Counter.jsx b/packages/astro/e2e/fixtures/client-idle-timeout/src/components/Counter.jsx
new file mode 100644
index 0000000000..9d2212b0ca
--- /dev/null
+++ b/packages/astro/e2e/fixtures/client-idle-timeout/src/components/Counter.jsx
@@ -0,0 +1,18 @@
+import React, { useState } from 'react';
+
+export default function Counter({ children, count: initialCount = 0, id }) {
+ const [count, setCount] = useState(initialCount);
+ const add = () => setCount((i) => i + 1);
+ const subtract = () => setCount((i) => i - 1);
+
+ return (
+ <>
+
+ {children}
+ >
+ );
+}
diff --git a/packages/astro/e2e/fixtures/client-idle-timeout/src/pages/index.astro b/packages/astro/e2e/fixtures/client-idle-timeout/src/pages/index.astro
new file mode 100644
index 0000000000..0045ca55cb
--- /dev/null
+++ b/packages/astro/e2e/fixtures/client-idle-timeout/src/pages/index.astro
@@ -0,0 +1,16 @@
+---
+import Counter from '../components/Counter.jsx';
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts
index 5e060ec990..19bbe48062 100644
--- a/packages/astro/src/@types/astro.ts
+++ b/packages/astro/src/@types/astro.ts
@@ -91,7 +91,7 @@ export type {
export interface AstroBuiltinProps {
'client:load'?: boolean;
- 'client:idle'?: boolean;
+ 'client:idle'?: IdleRequestOptions | boolean;
'client:media'?: string;
'client:visible'?: ClientVisibleOptions | boolean;
'client:only'?: boolean | string;
diff --git a/packages/astro/src/runtime/client/idle.ts b/packages/astro/src/runtime/client/idle.ts
index 990d5da6ef..1d630c4afe 100644
--- a/packages/astro/src/runtime/client/idle.ts
+++ b/packages/astro/src/runtime/client/idle.ts
@@ -1,14 +1,22 @@
import type { ClientDirective } from '../../@types/astro.js';
-const idleDirective: ClientDirective = (load) => {
+const idleDirective: ClientDirective = (load, options) => {
const cb = async () => {
const hydrate = await load();
await hydrate();
};
+
+ const rawOptions =
+ typeof options.value === 'object' ? (options.value as IdleRequestOptions) : undefined;
+
+ const idleOptions: IdleRequestOptions = {
+ timeout: rawOptions?.timeout,
+ };
+
if ('requestIdleCallback' in window) {
- (window as any).requestIdleCallback(cb);
+ (window as any).requestIdleCallback(cb, idleOptions);
} else {
- setTimeout(cb, 200);
+ setTimeout(cb, idleOptions.timeout || 200);
}
};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8b21136618..093d3615e7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1004,6 +1004,22 @@ importers:
specifier: ^3.4.38
version: 3.4.38(typescript@5.5.4)
+ packages/astro/e2e/fixtures/client-idle-timeout:
+ dependencies:
+ react:
+ specifier: ^18.3.1
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.3.1
+ version: 18.3.1(react@18.3.1)
+ devDependencies:
+ '@astrojs/react':
+ specifier: workspace:*
+ version: link:../../../../integrations/react
+ astro:
+ specifier: workspace:*
+ version: link:../../..
+
packages/astro/e2e/fixtures/client-only:
dependencies:
preact: