Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add back Preact signals support #5015

Merged
merged 1 commit into from
Oct 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/itchy-tigers-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@astrojs/preact': minor
'astro': patch
---

Shared state in Preact components with signals

This makes it possible to share client state between Preact islands via signals.

For example, you can create a signals in an Astro component and then pass it to multiple islands:

```astro
---
// Component Imports
import Counter from '../components/Counter';
import { signal } from '@preact/signals';
const count = signal(0);
---

<Count count={count} />
<Count count={count} />
```
3 changes: 2 additions & 1 deletion examples/framework-preact/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"dependencies": {
"astro": "^1.4.6",
"preact": "^10.7.3",
"@astrojs/preact": "^1.1.1"
"@astrojs/preact": "^1.1.1",
"@preact/signals": "^1.1.0"
}
}
8 changes: 3 additions & 5 deletions examples/framework-preact/src/components/Counter.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { h, Fragment } from 'preact';
import { useState } from 'preact/hooks';
import './Counter.css';

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

return (
<>
Expand Down
12 changes: 10 additions & 2 deletions examples/framework-preact/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@
// Component Imports
import Counter from '../components/Counter';

import { signal } from '@preact/signals';

// Full Astro Component Syntax:
// https://docs.astro.build/core-concepts/astro-components/

const count = signal(0);
---

<html lang="en">
Expand All @@ -25,8 +29,12 @@ import Counter from '../components/Counter';
</head>
<body>
<main>
<Counter client:visible>
<h1>Hello, Preact!</h1>
<Counter count={count} client:visible>
<h1>Hello, Preact 1!</h1>
</Counter>

<Counter count={count} client:visible>
<h1>Hello, Preact 2!</h1>
</Counter>
</main>
</body>
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/runtime/server/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export async function generateHydrateScript(
// Attach renderer-provided attributes
if (attrs) {
for (const [key, value] of Object.entries(attrs)) {
island.props[key] = value;
island.props[key] = escapeHTML(value);
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/astro/test/fixtures/preact-component/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"dependencies": {
"@astrojs/preact": "workspace:*",
"astro": "workspace:*",
"preact": "^10.11.0"
"preact": "^10.11.0",
"@preact/signals": "1.1.1"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { h } from 'preact';

export default ({ count }) => {
return <div class="preact-signal">{ count }</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
import Signals from '../components/Signals';
import { signal } from '@preact/signals';
const count = signal(1);
---
<html>
<head>
<title>Testing</title>
</head>
<body>
<Signals client:load count={count} />
<Signals client:load count={count} />
</body>
</html>
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
Astro.response.headers.set('One-Two', 'three');
Astro.response.headers.set('Four-Five', 'six');
Astro.response.headers.set("Cache-Control", `max-age=0, s-maxage=86400`);
---
<html>
<head>
Expand Down
19 changes: 19 additions & 0 deletions packages/astro/test/preact-component.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as cheerio from 'cheerio';
import { loadFixture } from './test-utils.js';

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

before(async () => {
Expand Down Expand Up @@ -80,4 +81,22 @@ describe('Preact component', () => {
// test 1: preact/jsx-runtime is used for the component
expect(jsxRuntime).to.be.ok;
});

it('Can use shared signals between islands', async () => {
const html = await fixture.readFile('/signals/index.html');
const $ = cheerio.load(html);
expect($('.preact-signal')).to.have.a.lengthOf(2);

const sigs1Raw = $($('astro-island')[0]).attr('data-preact-signals');
const sigs2Raw = $($('astro-island')[1]).attr('data-preact-signals');

expect(sigs1Raw).to.not.be.undefined;
expect(sigs2Raw).to.not.be.undefined;

const sigs1 = JSON.parse(sigs1Raw);
const sigs2 = JSON.parse(sigs2Raw);

expect(sigs1.count).to.not.be.undefined;
expect(sigs1.count).to.equal(sigs2.count);
});
});
1 change: 1 addition & 0 deletions packages/astro/test/ssr-response.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@ describe('Using Astro.response in SSR', () => {
const headers = response.headers;
expect(headers.get('one-two')).to.equal('three');
expect(headers.get('four-five')).to.equal('six');
expect(headers.get('Cache-Control')).to.equal(`max-age=0, s-maxage=86400`);
});
});
14 changes: 0 additions & 14 deletions packages/integrations/preact/client.js

This file was deleted.

9 changes: 5 additions & 4 deletions packages/integrations/preact/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@
"homepage": "https://docs.astro.build/en/guides/integrations-guide/preact/",
"exports": {
".": "./dist/index.js",
"./client.js": "./client.js",
"./client-dev.js": "./client-dev.js",
"./server.js": "./server.js",
"./client.js": "./dist/client.js",
"./client-dev.js": "./dist/client-dev.js",
"./server.js": "./dist/server.js",
"./package.json": "./package.json"
},
"scripts": {
Expand All @@ -35,7 +35,8 @@
"@babel/core": ">=7.0.0-0 <8.0.0",
"@babel/plugin-transform-react-jsx": "^7.17.12",
"babel-plugin-module-resolver": "^4.1.0",
"preact-render-to-string": "^5.2.0"
"preact-render-to-string": "^5.2.4",
"@preact/signals": "^1.1.0"
},
"devDependencies": {
"astro": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @ts-ignore
import 'preact/debug';
import clientFn from './client.js';

Expand Down
33 changes: 33 additions & 0 deletions packages/integrations/preact/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { h, render } from 'preact';
import StaticHtml from './static-html.js';
import type { SignalLike } from './types';

const sharedSignalMap: Map<string, SignalLike> = new Map();

export default (element: HTMLElement) =>
async (
Component: any,
props: Record<string, any>,
{ default: children, ...slotted }: Record<string, any>
) => {
if (!element.hasAttribute('ssr')) return;
for (const [key, value] of Object.entries(slotted)) {
props[key] = h(StaticHtml, { value, name: key });
}
let signalsRaw = element.dataset.preactSignals;
if (signalsRaw) {
const { signal } = await import('@preact/signals');
let signals: Record<string, string> = JSON.parse(element.dataset.preactSignals as string);
for (const [propName, signalId] of Object.entries(signals)) {
if (!sharedSignalMap.has(signalId)) {
const signalValue = signal(props[propName]);
sharedSignalMap.set(signalId, signalValue);
}
props[propName] = sharedSignalMap.get(signalId);
}
}
render(
h(Component, props, children != null ? h(StaticHtml, { value: children }) : children),
element
);
};
32 changes: 32 additions & 0 deletions packages/integrations/preact/src/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { PropNameToSignalMap, RendererContext, SignalLike } from './types';

export type Context = {
id: string;
c: number;
signals: Map<SignalLike, string>;
propsToSignals: Map<Record<string, any>, PropNameToSignalMap>;
};

const contexts = new WeakMap<RendererContext['result'], Context>();

export function getContext(result: RendererContext['result']): Context {
if (contexts.has(result)) {
return contexts.get(result)!;
}
let ctx = {
c: 0,
get id() {
return 'p' + this.c.toString();
},
signals: new Map(),
propsToSignals: new Map(),
};
contexts.set(result, ctx);
return ctx;
}

export function incrementId(ctx: Context): string {
let id = ctx.id;
ctx.c++;
return id;
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { h, Component as BaseComponent } from 'preact';
import { Component as BaseComponent, h } from 'preact';
import render from 'preact-render-to-string';
import { getContext } from './context.js';
import { restoreSignalsOnProps, serializeSignals } from './signals.js';
import StaticHtml from './static-html.js';
import type { AstroPreactAttrs, RendererContext } from './types';

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

let originalConsoleError;
let originalConsoleError: typeof console.error;
let consoleFilterRefs = 0;

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

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

try {
try {
const { html } = renderToStaticMarkup(Component, props, children);
const { html } = renderToStaticMarkup.call(this, Component, props, children);
if (typeof html !== 'string') {
return false;
}
Expand All @@ -35,18 +38,35 @@ function check(Component, props, children) {
}
}

function renderToStaticMarkup(Component, props, { default: children, ...slotted }) {
const slots = {};
function renderToStaticMarkup(
this: RendererContext,
Component: any,
props: Record<string, any>,
{ default: children, ...slotted }: Record<string, any>
) {
const ctx = getContext(this.result);

const slots: Record<string, ReturnType<typeof h>> = {};
for (const [key, value] of Object.entries(slotted)) {
const name = slotName(key);
slots[name] = h(StaticHtml, { value, name });
}
// Note: create newProps to avoid mutating `props` before they are serialized

// Restore signals back onto props so that they will be passed as-is to components
let propsMap = restoreSignalsOnProps(ctx, props);

const newProps = { ...props, ...slots };

const attrs: AstroPreactAttrs = {};
serializeSignals(ctx, props, attrs, propsMap);

const html = render(
h(Component, newProps, children != null ? h(StaticHtml, { value: children }) : children)
);
return { html };
return {
attrs,
html,
};
}

/**
Expand Down Expand Up @@ -91,7 +111,7 @@ function finishUsingConsoleFilter() {
* Ignores known non-problematic errors while any code is using the console filter.
* Otherwise, simply forwards all arguments to the original function.
*/
function filteredConsoleError(msg, ...rest) {
function filteredConsoleError(msg: string, ...rest: any[]) {
if (consoleFilterRefs > 0 && typeof msg === 'string') {
// In `check`, we attempt to render JSX components through Preact.
// When attempting this on a React component, React may output
Expand Down
Loading