Skip to content

Commit

Permalink
feat: support passing signals as island props
Browse files Browse the repository at this point in the history
This commit adds support for passing signals to islands in component
props. This is useful for passing around mutable state between islands.
The signal's value needs to be serializable for this to work.
  • Loading branch information
lucacasonato committed May 31, 2023
1 parent a6852fa commit 79388b5
Show file tree
Hide file tree
Showing 11 changed files with 133 additions and 13 deletions.
4 changes: 3 additions & 1 deletion demo/import_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"$fresh/": "../",
"preact": "https://esm.sh/preact@10.13.1",
"preact/": "https://esm.sh/preact@10.13.1/",
"preact-render-to-string": "https://esm.sh/*preact-render-to-string@5.2.6"
"preact-render-to-string": "https://esm.sh/*preact-render-to-string@5.2.6",
"@preact/signals": "https://esm.sh/*@preact/signals@1.1.3",
"@preact/signals-core": "https://esm.sh/@preact/signals-core@1.2.3"
}
}
11 changes: 5 additions & 6 deletions demo/islands/Counter.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import { useState } from "preact/hooks";
import { Signal } from "@preact/signals";
import { IS_BROWSER } from "$fresh/runtime.ts";

interface CounterProps {
start: number;
count: Signal<number>;
}

export default function Counter(props: CounterProps) {
const [count, setCount] = useState(props.start);
export default function Counter({ count }: CounterProps) {
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count - 1)} disabled={!IS_BROWSER}>
<button onClick={() => count.value -= 1} disabled={!IS_BROWSER}>
-1
</button>
<button onClick={() => setCount(count + 1)} disabled={!IS_BROWSER}>
<button onClick={() => count.value += 1} disabled={!IS_BROWSER}>
+1
</button>
</div>
Expand Down
6 changes: 5 additions & 1 deletion demo/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { useSignal } from "@preact/signals";
import Counter from "../islands/Counter.tsx";

export default function Home() {
const count = useSignal(3);
return (
<div>
<p>
Welcome to Fresh. Try to update this message in the ./routes/index.tsx
file, and refresh.
</p>
<Counter start={3} />
<Counter count={count} />
<Counter count={count} />
<Counter count={count} />
</div>
);
}
1 change: 1 addition & 0 deletions docs/concepts/islands.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Fresh can serialize the following types of values:
`null`, and `bigint`s are not supported)
- Plain objects with string keys and serializable values
- Arrays containing serializable values
- Preact Signals (if the inner value is serializable)

Circular references are supported. If an object or signal is referenced multiple
times, it is only serialized once and the references are restored upon
Expand Down
10 changes: 9 additions & 1 deletion src/runtime/deserializer.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
// Run `deno run -A npm:esbuild --minify src/runtime/deserializer.ts` to minify
// this file. It is embedded into src/server/deserializer_code.ts.

import type { Signal } from "@preact/signals";

export const KEY = "_f";

export function deserialize(str: string): unknown {
export function deserialize(
str: string,
signal?: <T>(a: T) => Signal<T>,
): unknown {
function reviver(this: unknown, _key: string, value: unknown): unknown {
if (typeof value === "object" && value && KEY in value) {
// deno-lint-ignore no-explicit-any
const v: any = value;
if (v[KEY] === "s") {
return signal!(v.v);
}
if (v[KEY] === "l") {
const val = v.v;
val[KEY] = v.k;
Expand Down
1 change: 1 addition & 0 deletions src/runtime/entrypoints/signals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { signal } from "@preact/signals";
6 changes: 6 additions & 0 deletions src/server/__snapshots__/serializer_test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export const snapshot = {};

snapshot[`serializer - primitives & plain objects 1`] = `'{"v":{"a":1,"b":"2","c":true,"d":null,"f":[1,2,3],"g":{"a":1,"b":2,"c":3}}}'`;

snapshot[`serializer - signals 1`] = `'{"v":{"a":1,"b":{"_f":"s","v":2}}}'`;

snapshot[`serializer - magic key 1`] = `'{"v":{"_f":"l","k":"f","v":{"a":1}}}'`;

snapshot[`serializer - circular reference objects 1`] = `'{"v":{"a":1,"b":0},"r":[[[],["b"]]]}'`;
Expand All @@ -12,4 +14,8 @@ snapshot[`serializer - circular reference array 1`] = `'{"v":[1,2,3,0],"r":[[[],

snapshot[`serializer - multiple reference 1`] = `'{"v":{"a":1,"b":{"c":2},"d":0},"r":[[["b"],["d"]]]}'`;

snapshot[`serializer - multiple reference signals 1`] = `'{"v":{"inner":{"_f":"l","k":"x","v":{"x":1,"y":0}},"a":{"_f":"s","v":0},"b":{"c":0}},"r":[[["inner"],["inner",null,"y"],["a","value"]],[["a"],["b","c"]]]}'`;

snapshot[`serializer - multiple reference in magic key 1`] = `'{"v":{"literal":{"_f":"l","k":"x","v":{"inner":{"foo":"bar"}}},"inner":0},"r":[[["literal",null,"inner"],["inner"]]]}'`;

snapshot[`serializer - multiple reference in signal 1`] = `'{"v":{"s":{"_f":"s","v":{"foo":"bar"}},"inner":0},"r":[[["s","value"],["inner"]]]}'`;
7 changes: 7 additions & 0 deletions src/server/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ export class Bundler {
deserializer: import.meta.resolve(`${entrypointBase}/deserializer.ts`),
};

try {
import.meta.resolve("@preact/signals");
entryPoints.signals = import.meta.resolve(`${entrypointBase}/signals.ts`);
} catch {
// @preact/signals is not in the import map
}

for (const island of this.#islands) {
entryPoints[`island-${island.id}`] = island.url;
}
Expand Down
10 changes: 9 additions & 1 deletion src/server/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,10 +257,18 @@ export async function render<Data>(
const url = addImport("/deserializer.js");
script += `import { deserialize } from "${url}";`;
}
if (res.hasSignals) {
const url = addImport("/signals.js");
script += `import { signal } from "${url}";`;
}
script += `const ST = document.getElementById("__FRSH_STATE").textContent;`;
script += `const STATE = `;
if (res.requiresDeserializer) {
script += `deserialize(ST);`;
if (res.hasSignals) {
script += `deserialize(ST, signal);`;
} else {
script += `deserialize(ST);`;
}
} else {
script += `JSON.parse(ST).v;`;
}
Expand Down
33 changes: 30 additions & 3 deletions src/server/serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* - `string`
* - `array`
* - `object` (no prototypes)
* - `Signal` from `@preact/signals`
*
* Circular references are supported and objects with the same reference are
* serialized only once.
Expand All @@ -22,10 +23,29 @@ interface SerializeResult {
/** If the deserializer is required to deserialize this string. If this is
* `false` the serialized string can be deserialized with `JSON.parse`. */
requiresDeserializer: boolean;
/** If the serialization contains serialized signals. If this is `true` the
* deserializer must be passed a factory functions for signals. */
hasSignals: boolean;
}

interface Signal {
peek(): unknown;
value: unknown;
}

// deno-lint-ignore no-explicit-any
function isSignal(x: any): x is Signal {
return (
x !== null &&
typeof x === "object" &&
typeof x.peek === "function" &&
"value" in x
);
}

export function serialize(data: unknown): SerializeResult {
let requiresDeserializer = false;
let hasSignals = false;
const seen = new Map<unknown, (string | null)[]>();
const references = new Map<(string | null)[], (string | null)[][]>();

Expand Down Expand Up @@ -63,7 +83,8 @@ export function serialize(data: unknown): SerializeResult {
// these cases, we have to change the contents of the key stack to match the
// deserialized object.
if (typeof this === "object" && this !== null && KEY in this) {
if (this[KEY] === "l" && key === "v") key = null;
if (this[KEY] === "s" && key === "v") key = "value"; // signals
if (this[KEY] === "l" && key === "v") key = null; // literals (magic key object)
}

if (this !== toSerialize) {
Expand Down Expand Up @@ -91,7 +112,13 @@ export function serialize(data: unknown): SerializeResult {
}
}

if (typeof value === "object" && value && KEY in value) {
if (isSignal(value)) {
requiresDeserializer = true;
hasSignals = true;
const res = { [KEY]: "s", v: value.peek() };
parentStack.push(res);
return res;
} else if (typeof value === "object" && value && KEY in value) {
requiresDeserializer = true;
// deno-lint-ignore no-explicit-any
const v: any = { ...value };
Expand All @@ -107,5 +134,5 @@ export function serialize(data: unknown): SerializeResult {
}

const serialized = JSON.stringify(toSerialize, replacer);
return { serialized, requiresDeserializer };
return { serialized, requiresDeserializer, hasSignals };
}
57 changes: 57 additions & 0 deletions src/server/serializer_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { serialize } from "./serializer.ts";
import { assert, assertEquals, assertSnapshot } from "../../tests/deps.ts";
import { deserialize, KEY } from "../runtime/deserializer.ts";
import { signal } from "@preact/signals";

Deno.test("serializer - primitives & plain objects", async (t) => {
const data = {
Expand All @@ -15,15 +16,33 @@ Deno.test("serializer - primitives & plain objects", async (t) => {
};
const res = serialize(data);
assert(!res.requiresDeserializer);
assert(!res.hasSignals);
await assertSnapshot(t, res.serialized);
const deserialized = deserialize(res.serialized);
assertEquals(deserialized, data);
});

Deno.test("serializer - signals", async (t) => {
const data = {
a: 1,
b: signal(2),
};
const res = serialize(data);
assert(res.requiresDeserializer);
assert(res.hasSignals);
await assertSnapshot(t, res.serialized);
const deserialized: any = deserialize(res.serialized, signal);
assertEquals(typeof deserialized, "object");
assertEquals(deserialized.a, 1);
assertEquals(deserialized.b.value, 2);
assertEquals(deserialized.b.peek(), 2);
});

Deno.test("serializer - magic key", async (t) => {
const data = { [KEY]: "f", a: 1 };
const res = serialize(data);
assert(res.requiresDeserializer);
assert(!res.hasSignals);
await assertSnapshot(t, res.serialized);
const deserialized = deserialize(res.serialized);
assertEquals(deserialized, data);
Expand All @@ -34,6 +53,7 @@ Deno.test("serializer - circular reference objects", async (t) => {
data.b = data;
const res = serialize(data);
assert(res.requiresDeserializer);
assert(!res.hasSignals);
await assertSnapshot(t, res.serialized);
const deserialized = deserialize(res.serialized);
assertEquals(deserialized, data);
Expand All @@ -44,6 +64,7 @@ Deno.test("serializer - circular reference nested objects", async (t) => {
data.b.d = data;
const res = serialize(data);
assert(res.requiresDeserializer);
assert(!res.hasSignals);
await assertSnapshot(t, res.serialized);
const deserialized = deserialize(res.serialized);
assertEquals(deserialized, data);
Expand All @@ -54,6 +75,7 @@ Deno.test("serializer - circular reference array", async (t) => {
data.push(data);
const res = serialize(data);
assert(res.requiresDeserializer);
assert(!res.hasSignals);
await assertSnapshot(t, res.serialized);
const deserialized: any = deserialize(res.serialized);
assertEquals(deserialized, data);
Expand All @@ -65,18 +87,53 @@ Deno.test("serializer - multiple reference", async (t) => {
data.d = data.b;
const res = serialize(data);
assert(res.requiresDeserializer);
assert(!res.hasSignals);
await assertSnapshot(t, res.serialized);
const deserialized = deserialize(res.serialized);
assertEquals(deserialized, data);
});

Deno.test("serializer - multiple reference signals", async (t) => {
const inner: any = { [KEY]: "x", x: 1 };
inner.y = inner;
const s = signal(inner);
const data = { inner, a: s, b: { c: s } };
const res = serialize(data);
assert(res.requiresDeserializer);
assert(res.hasSignals);
await assertSnapshot(t, res.serialized);
const deserialized: any = deserialize(res.serialized, signal);
assertEquals(deserialized.a.value, inner);
assertEquals(deserialized.a.peek(), inner);
assertEquals(deserialized.b.c.value, inner);
assertEquals(deserialized.b.c.peek(), inner);
deserialized.a.value = 2;
assertEquals(deserialized.a.value, 2);
assertEquals(deserialized.b.c.value, 2);
});

Deno.test("serializer - multiple reference in magic key", async (t) => {
const inner = { foo: "bar" };
const literal: any = { [KEY]: "x", inner };
const data = { literal, inner };
const res = serialize(data);
assert(res.requiresDeserializer);
assert(!res.hasSignals);
await assertSnapshot(t, res.serialized);
const deserialized: any = deserialize(res.serialized);
assertEquals(deserialized, data);
});

Deno.test("serializer - multiple reference in signal", async (t) => {
const inner = { foo: "bar" };
const s = signal(inner);
const data = { s, inner };
const res = serialize(data);
assert(res.requiresDeserializer);
assert(res.hasSignals);
await assertSnapshot(t, res.serialized);
const deserialized: any = deserialize(res.serialized, signal);
assertEquals(deserialized.s.value, inner);
assertEquals(deserialized.s.peek(), inner);
assertEquals(deserialized.inner, inner);
});

0 comments on commit 79388b5

Please sign in to comment.