Skip to content

Commit

Permalink
Implement custom client directives (#7074)
Browse files Browse the repository at this point in the history
Co-authored-by: Sarah Rainsberger <[email protected]>
  • Loading branch information
bluwy and sarah11918 authored May 17, 2023
1 parent 3a7f6ae commit 73ec6f6
Show file tree
Hide file tree
Showing 43 changed files with 765 additions and 86 deletions.
51 changes: 51 additions & 0 deletions .changeset/tall-eyes-vanish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
'astro': minor
---

Integrations can add new `client:` directives through the `astro:config:setup` hook's `addClientDirective()` API. To enable this API, the user needs to set `experimental.customClientDirectives` to `true` in their config.

```js
import { defineConfig } from 'astro/config';
import onClickDirective from 'astro-click-directive';

export default defineConfig({
integrations: [onClickDirective()],
experimental: {
customClientDirectives: true
}
});
```

```js
export default function onClickDirective() {
return {
hooks: {
'astro:config:setup': ({ addClientDirective }) => {
addClientDirective({
name: 'click',
entrypoint: 'astro-click-directive/click.js'
});
},
}
}
}
```

```astro
<Counter client:click />
```

The client directive file (e.g. `astro-click-directive/click.js`) should export a function of type `ClientDirective`:

```ts
import type { ClientDirective } from 'astro'

const clickDirective: ClientDirective = (load, opts, el) => {
window.addEventListener('click', async () => {
const hydrate = await load()
await hydrate()
}, { once: true })
}

export default clickDirective
```
3 changes: 2 additions & 1 deletion packages/astro/astro-jsx.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ declare namespace astroHTML.JSX {
children: {};
}

interface IntrinsicAttributes extends AstroBuiltinProps, AstroBuiltinAttributes {
interface IntrinsicAttributes extends AstroBuiltinProps, AstroBuiltinAttributes, AstroClientDirectives {
slot?: string;
children?: Children;
}

type AstroBuiltinProps = import('./dist/@types/astro.js').AstroBuiltinProps;
type AstroClientDirectives = import('./dist/@types/astro.js').AstroClientDirectives;
type AstroBuiltinAttributes = import('./dist/@types/astro.js').AstroBuiltinAttributes;
type AstroDefineVarsAttribute = import('./dist/@types/astro.js').AstroDefineVarsAttribute;
type AstroScriptAttributes = import('./dist/@types/astro.js').AstroScriptAttributes &
Expand Down
92 changes: 92 additions & 0 deletions packages/astro/e2e/custom-client-directives.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { expect } from '@playwright/test';
import { testFactory, waitForHydrate } from './test-utils.js';
import testAdapter from '../test/test-adapter.js';

const test = testFactory({
root: './fixtures/custom-client-directives/',
});

test.describe('Custom Client Directives - dev', () => {
let devServer;

test.beforeAll(async ({ astro }) => {
devServer = await astro.startDevServer();
});

test.afterAll(async () => {
await devServer.stop();
});

testClientDirectivesShared();
});

test.describe('Custom Client Directives - build static', () => {
let previewServer;

test.beforeAll(async ({ astro }) => {
await astro.build();
previewServer = await astro.preview();
});

test.afterAll(async () => {
await previewServer.stop();
});

testClientDirectivesShared();
});

test.describe('Custom Client Directives - build server', () => {
let previewServer;

test.beforeAll(async ({ astro }) => {
await astro.build({
adapter: testAdapter(),
});
previewServer = await astro.preview();
});

test.afterAll(async () => {
await previewServer.stop();
});

testClientDirectivesShared();
});

function testClientDirectivesShared() {
test('client:click should work', async ({ astro, page }) => {
await page.goto(astro.resolveUrl('/'));

const incrementBtn = page.locator('#client-click .increment');
const counterValue = page.locator('#client-click pre');

await expect(counterValue).toHaveText('0');

// Component only hydrates on first click
await Promise.all([waitForHydrate(page, counterValue), incrementBtn.click()]);

// Since first click only triggers hydration, this should stay 0
await expect(counterValue).toHaveText('0');
await incrementBtn.click();
// Hydrated, this should be 1
await expect(counterValue).toHaveText('1');
});

test('client:password should work', async ({ astro, page }) => {
await page.goto(astro.resolveUrl('/'));

const incrementBtn = page.locator('#client-password .increment');
const counterValue = page.locator('#client-password pre');

await expect(counterValue).toHaveText('0');
await incrementBtn.click();
// Not hydrated, so this should stay 0
await expect(counterValue).toHaveText('0');

// Type super cool password to activate password!
await Promise.all([waitForHydrate(page, counterValue), page.keyboard.type('hunter2')]);

await incrementBtn.click();
// Hydrated, this should be 1
await expect(counterValue).toHaveText('1');
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { defineConfig } from 'astro/config';
import react from "@astrojs/react";
import { fileURLToPath } from 'url';

export default defineConfig({
integrations: [astroClientClickDirective(), astroClientPasswordDirective(), react()],
experimental: {
customClientDirectives: true
}
});

function astroClientClickDirective() {
return {
name: 'astro-client-click',
hooks: {
'astro:config:setup': (opts) => {
opts.addClientDirective({
name: 'click',
entrypoint: fileURLToPath(new URL('./client-click.js', import.meta.url))
});
}
}
};
}

function astroClientPasswordDirective() {
return {
name: 'astro-client-click',
hooks: {
'astro:config:setup': (opts) => {
opts.addClientDirective({
name: 'password',
entrypoint: fileURLToPath(new URL('./client-password.js', import.meta.url))
});
}
}
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Hydrate on first click on the window
export default (load) => {
window.addEventListener('click', async () => {
const hydrate = await load()
await hydrate()
}, { once: true })
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Hydrate when the user types the correct password
export default (load, options) => {
const password = options.value
let consecutiveMatch = 0

const handleKeydown = async (e) => {
if (e.key === password[consecutiveMatch]) {
consecutiveMatch++
} else {
consecutiveMatch = 0
}

if (consecutiveMatch === password.length) {
window.removeEventListener('keydown', handleKeydown)
const hydrate = await load()
await hydrate()
}
}

window.addEventListener('keydown', handleKeydown)
}
11 changes: 11 additions & 0 deletions packages/astro/e2e/fixtures/custom-client-directives/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "@test/custom-client-directives",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/react": "workspace:*",
"astro": "workspace:*",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
declare module 'astro' {
interface AstroClientDirectives {
'client:click'?: boolean
'client:password'?: string
}
}

// Make d.ts a module to similate common packaging setups where the entry `index.d.ts` would augment the types
export {}
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div id={id} className="counter">
<button className="decrement" onClick={subtract}>-</button>
<pre>{count}</pre>
<button className="increment" onClick={add}>+</button>
</div>
<div className="counter-message">{children}</div>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
import Counter from '../components/Counter.jsx';
---

<html>
<body>
<Counter id="client-click" client:click>client:click</Counter>
<Counter id="client-password" client:password="hunter2">client:password</Counter>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"compilerOptions": {
// preserveSymlinks set to true so the augmented `declare module 'astro'` works.
// This is only needed because we link Astro locally.
"preserveSymlinks": true
},
"include": ["./src/**/*"]
}
1 change: 0 additions & 1 deletion packages/astro/e2e/test-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ export async function getErrorOverlayContent(page) {
}

/**
* @param {import('@playwright/test').Locator} el
* @returns {Promise<string>}
*/
export async function getColor(el) {
Expand Down
1 change: 1 addition & 0 deletions packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
"devalue": "^4.2.0",
"diff": "^5.1.0",
"es-module-lexer": "^1.1.0",
"esbuild": "^0.17.18",
"estree-walker": "3.0.0",
"execa": "^6.1.0",
"fast-glob": "^3.2.11",
Expand Down
Loading

0 comments on commit 73ec6f6

Please sign in to comment.