Skip to content

Commit 5e46be5

Browse files
authored
Support shared signals in Preact islands (#4763)
* Support signals in Preact islands * Add a changeset * Only add signals if we need them * Refactor signal logic into its own module * Keep track of the signals used
1 parent baae1b3 commit 5e46be5

File tree

21 files changed

+272
-39
lines changed

21 files changed

+272
-39
lines changed

.changeset/itchy-tigers-help.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@astrojs/preact': minor
3+
'astro': patch
4+
---
5+
6+
Shared state in Preact components with signals
7+
8+
This makes it possible to share client state between Preact islands via signals.
9+
10+
For example, you can create a signals in an Astro component and then pass it to multiple islands:
11+
12+
```astro
13+
---
14+
// Component Imports
15+
import Counter from '../components/Counter';
16+
import { signal } from '@preact/signals';
17+
const count = signal(0);
18+
---
19+
20+
<Count count={count} />
21+
<Count count={count} />
22+
```

examples/framework-preact/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"dependencies": {
1414
"astro": "^1.2.8",
1515
"preact": "^10.7.3",
16-
"@astrojs/preact": "^1.1.0"
16+
"@astrojs/preact": "^1.1.0",
17+
"@preact/signals": "1.0.3"
1718
}
1819
}

examples/framework-preact/src/components/Counter.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import { h, Fragment } from 'preact';
2-
import { useState } from 'preact/hooks';
32
import './Counter.css';
43

5-
export default function Counter({ children }) {
6-
const [count, setCount] = useState(0);
7-
const add = () => setCount((i) => i + 1);
8-
const subtract = () => setCount((i) => i - 1);
4+
export default function Counter({ children, count }) {
5+
const add = () => count.value++
6+
const subtract = () => count.value--;
97

108
return (
119
<>

examples/framework-preact/src/pages/index.astro

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22
// Component Imports
33
import Counter from '../components/Counter';
44
5+
import { signal } from '@preact/signals';
6+
57
// Full Astro Component Syntax:
68
// https://docs.astro.build/core-concepts/astro-components/
9+
10+
const count = signal(0);
711
---
812

913
<html lang="en">
@@ -25,8 +29,12 @@ import Counter from '../components/Counter';
2529
</head>
2630
<body>
2731
<main>
28-
<Counter client:visible>
29-
<h1>Hello, Preact!</h1>
32+
<Counter count={count} client:visible>
33+
<h1>Hello, Preact 1!</h1>
34+
</Counter>
35+
36+
<Counter count={count} client:visible>
37+
<h1>Hello, Preact 2!</h1>
3038
</Counter>
3139
</main>
3240
</body>

packages/astro/src/runtime/server/hydration.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ export async function generateHydrateScript(
135135
// Attach renderer-provided attributes
136136
if (attrs) {
137137
for (const [key, value] of Object.entries(attrs)) {
138-
island.props[key] = value;
138+
island.props[key] = escapeHTML(value);
139139
}
140140
}
141141

packages/astro/test/fixtures/preact-component/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"private": true,
55
"dependencies": {
66
"@astrojs/preact": "workspace:*",
7-
"astro": "workspace:*"
7+
"astro": "workspace:*",
8+
"@preact/signals": "1.0.3",
9+
"preact": "^10.7.3"
810
}
911
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { h } from 'preact';
2+
3+
export default ({ count }) => {
4+
return <div class="preact-signal">{ count }</div>
5+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
import Signals from '../components/Signals';
3+
import { signal } from '@preact/signals';
4+
const count = signal(1);
5+
---
6+
<html>
7+
<head>
8+
<title>Testing</title>
9+
</head>
10+
<body>
11+
<Signals client:load count={count} />
12+
<Signals client:load count={count} />
13+
</body>
14+
</html>

packages/astro/test/fixtures/ssr-response/src/pages/some-header.astro

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
Astro.response.headers.set('One-Two', 'three');
33
Astro.response.headers.set('Four-Five', 'six');
4+
Astro.response.headers.set("Cache-Control", `max-age=0, s-maxage=86400`);
45
---
56
<html>
67
<head>

packages/astro/test/preact-component.test.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as cheerio from 'cheerio';
33
import { loadFixture } from './test-utils.js';
44

55
describe('Preact component', () => {
6+
/** @type {import('./test-utils').Fixture} */
67
let fixture;
78

89
before(async () => {
@@ -80,4 +81,23 @@ describe('Preact component', () => {
8081
// test 1: preact/jsx-runtime is used for the component
8182
expect(jsxRuntime).to.be.ok;
8283
});
84+
85+
it('Can use shared signals between islands', async () => {
86+
const html = await fixture.readFile('/signals/index.html');
87+
const $ = cheerio.load(html);
88+
expect($('.preact-signal')).to.have.a.lengthOf(2);
89+
90+
const sigs1Raw = $($('astro-island')[0]).attr('data-preact-signals');
91+
const sigs2Raw = $($('astro-island')[1]).attr('data-preact-signals');
92+
93+
expect(sigs1Raw).to.not.be.undefined;
94+
expect(sigs2Raw).to.not.be.undefined;
95+
96+
97+
const sigs1 = JSON.parse(sigs1Raw);
98+
const sigs2 = JSON.parse(sigs2Raw);
99+
100+
expect(sigs1.count).to.not.be.undefined;
101+
expect(sigs1.count).to.equal(sigs2.count);
102+
});
83103
});

packages/astro/test/ssr-response.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,6 @@ describe('Using Astro.response in SSR', () => {
3636
const headers = response.headers;
3737
expect(headers.get('one-two')).to.equal('three');
3838
expect(headers.get('four-five')).to.equal('six');
39+
expect(headers.get('Cache-Control')).to.equal(`max-age=0, s-maxage=86400`)
3940
});
4041
});

packages/integrations/preact/client.js

Lines changed: 0 additions & 14 deletions
This file was deleted.

packages/integrations/preact/package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@
2121
"homepage": "https://docs.astro.build/en/guides/integrations-guide/preact/",
2222
"exports": {
2323
".": "./dist/index.js",
24-
"./client.js": "./client.js",
25-
"./client-dev.js": "./client-dev.js",
26-
"./server.js": "./server.js",
24+
"./client.js": "./dist/client.js",
25+
"./client-dev.js": "./dist/client-dev.js",
26+
"./server.js": "./dist/server.js",
2727
"./package.json": "./package.json"
2828
},
2929
"scripts": {
@@ -35,7 +35,8 @@
3535
"@babel/core": ">=7.0.0-0 <8.0.0",
3636
"@babel/plugin-transform-react-jsx": "^7.17.12",
3737
"babel-plugin-module-resolver": "^4.1.0",
38-
"preact-render-to-string": "^5.2.0"
38+
"preact-render-to-string": "^5.2.4",
39+
"@preact/signals": "^1.1.0"
3940
},
4041
"devDependencies": {
4142
"astro": "workspace:*",

packages/integrations/preact/client-dev.js renamed to packages/integrations/preact/src/client-dev.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// @ts-ignore
12
import 'preact/debug';
23
import clientFn from './client.js';
34

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { SignalLike } from './types';
2+
import { h, render } from 'preact';
3+
import StaticHtml from './static-html.js';
4+
5+
const sharedSignalMap: Map<string, SignalLike> = new Map();
6+
7+
export default (element: HTMLElement) =>
8+
async (Component: any, props: Record<string, any>, { default: children, ...slotted }: Record<string, any>) => {
9+
if (!element.hasAttribute('ssr')) return;
10+
for (const [key, value] of Object.entries(slotted)) {
11+
props[key] = h(StaticHtml, { value, name: key });
12+
}
13+
let signalsRaw = element.dataset.preactSignals;
14+
if(signalsRaw) {
15+
const { signal } = await import('@preact/signals');
16+
let signals: Record<string, string> = JSON.parse(element.dataset.preactSignals as string);
17+
for(const [propName, signalId] of Object.entries(signals)) {
18+
if(!sharedSignalMap.has(signalId)) {
19+
const signalValue = signal(props[propName]);
20+
sharedSignalMap.set(signalId, signalValue);
21+
}
22+
props[propName] = sharedSignalMap.get(signalId);
23+
}
24+
}
25+
render(
26+
h(Component, props, children != null ? h(StaticHtml, { value: children }) : children),
27+
element
28+
);
29+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { RendererContext, SignalLike, PropNameToSignalMap } from './types';
2+
3+
export type Context = {
4+
id: string;
5+
c: number;
6+
signals: Map<SignalLike, string>;
7+
propsToSignals: Map<Record<string, any>, PropNameToSignalMap>;
8+
};
9+
10+
const contexts = new WeakMap<RendererContext['result'], Context>();
11+
12+
export function getContext(result: RendererContext['result']): Context {
13+
if (contexts.has(result)) {
14+
return contexts.get(result)!;
15+
}
16+
let ctx = {
17+
c: 0,
18+
get id() {
19+
return 'p' + this.c.toString();
20+
},
21+
signals: new Map(),
22+
propsToSignals: new Map()
23+
};
24+
contexts.set(result, ctx);
25+
return ctx;
26+
}
27+
28+
export function incrementId(ctx: Context): string {
29+
let id = ctx.id;
30+
ctx.c++;
31+
return id;
32+
}

packages/integrations/preact/server.js renamed to packages/integrations/preact/src/server.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1+
import type { AstroPreactAttrs, RendererContext } from './types';
12
import { h, Component as BaseComponent } from 'preact';
23
import render from 'preact-render-to-string';
34
import StaticHtml from './static-html.js';
5+
import { getContext } from './context.js';
6+
import { restoreSignalsOnProps, serializeSignals } from './signals.js';
47

5-
const slotName = (str) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
8+
const slotName = (str: string) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
69

7-
let originalConsoleError;
10+
let originalConsoleError: typeof console.error;
811
let consoleFilterRefs = 0;
912

10-
function check(Component, props, children) {
13+
function check(this: RendererContext, Component: any, props: Record<string, any>, children: any) {
1114
if (typeof Component !== 'function') return false;
1215

1316
if (Component.prototype != null && typeof Component.prototype.render === 'function') {
@@ -18,7 +21,7 @@ function check(Component, props, children) {
1821

1922
try {
2023
try {
21-
const { html } = renderToStaticMarkup(Component, props, children);
24+
const { html } = renderToStaticMarkup.call(this, Component, props, children);
2225
if (typeof html !== 'string') {
2326
return false;
2427
}
@@ -35,20 +38,33 @@ function check(Component, props, children) {
3538
}
3639
}
3740

38-
function renderToStaticMarkup(Component, props, { default: children, ...slotted }) {
39-
const slots = {};
41+
function renderToStaticMarkup(this: RendererContext, Component: any, props: Record<string, any>, { default: children, ...slotted }: Record<string, any>) {
42+
const ctx = getContext(this.result);
43+
44+
const slots: Record<string, ReturnType<typeof h>> = {};
4045
for (const [key, value] of Object.entries(slotted)) {
4146
const name = slotName(key);
4247
slots[name] = h(StaticHtml, { value, name });
4348
}
44-
// Note: create newProps to avoid mutating `props` before they are serialized
49+
50+
// Restore signals back onto props so that they will be passed as-is to components
51+
let propsMap = restoreSignalsOnProps(ctx, props);
52+
4553
const newProps = { ...props, ...slots };
54+
55+
const attrs: AstroPreactAttrs = {};
56+
serializeSignals(ctx, props, attrs, propsMap);
57+
4658
const html = render(
4759
h(Component, newProps, children != null ? h(StaticHtml, { value: children }) : children)
4860
);
49-
return { html };
61+
return {
62+
attrs,
63+
html
64+
};
5065
}
5166

67+
5268
/**
5369
* Reduces console noise by filtering known non-problematic errors.
5470
*
@@ -91,7 +107,7 @@ function finishUsingConsoleFilter() {
91107
* Ignores known non-problematic errors while any code is using the console filter.
92108
* Otherwise, simply forwards all arguments to the original function.
93109
*/
94-
function filteredConsoleError(msg, ...rest) {
110+
function filteredConsoleError(msg: string, ...rest: any[]) {
95111
if (consoleFilterRefs > 0 && typeof msg === 'string') {
96112
// In `check`, we attempt to render JSX components through Preact.
97113
// When attempting this on a React component, React may output
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { AstroPreactAttrs, PropNameToSignalMap, SignalLike } from './types';
2+
import type { Context } from './context';
3+
import { incrementId } from './context.js';
4+
5+
function isSignal(x: any): x is SignalLike {
6+
return x != null && typeof x === 'object' && typeof x.peek === 'function' && 'value' in x;
7+
}
8+
9+
export function restoreSignalsOnProps(ctx: Context, props: Record<string, any>) {
10+
// Restore signal props that were mutated for serialization
11+
let propMap: PropNameToSignalMap;
12+
if(ctx.propsToSignals.has(props)) {
13+
propMap = ctx.propsToSignals.get(props)!
14+
} else {
15+
propMap = new Map();
16+
ctx.propsToSignals.set(props, propMap);
17+
}
18+
for(const [key, signal] of propMap) {
19+
props[key] = signal;
20+
}
21+
return propMap;
22+
}
23+
24+
export function serializeSignals(ctx: Context, props: Record<string, any>, attrs: AstroPreactAttrs, map: PropNameToSignalMap){
25+
// Check for signals
26+
const signals: Record<string, string> = {};
27+
for(const [key, value] of Object.entries(props)) {
28+
if(isSignal(value)) {
29+
// Set the value to the current signal value
30+
// This mutates the props on purpose, so that it will be serialized correct.
31+
props[key] = value.peek();
32+
map.set(key, value);
33+
34+
let id: string;
35+
if(ctx.signals.has(value)) {
36+
id = ctx.signals.get(value)!;
37+
} else {
38+
id = incrementId(ctx);
39+
ctx.signals.set(value, id);
40+
}
41+
signals[key] = id;
42+
}
43+
}
44+
45+
if(Object.keys(signals).length) {
46+
attrs['data-preact-signals'] = JSON.stringify(signals);
47+
}
48+
}

0 commit comments

Comments
 (0)