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 30, 2023
1 parent 5b1a8a4 commit 59d1a54
Show file tree
Hide file tree
Showing 7 changed files with 109 additions and 5 deletions.
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"]]]}'`;
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
29 changes: 26 additions & 3 deletions src/server/serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
* - `string`
* - `array`
* - `object` (no prototypes)
* - `Signal` from `@preact/signals`
*
* Circular references are supported and objects with the same reference are
* serialized only once.
*
* The corresponding deserializer is in `src/runtime/deserializer.ts`.
*/
import type { Signal } from "@preact/signals";
import { KEY } from "../runtime/deserializer.ts";

interface SerializeResult {
Expand All @@ -22,10 +24,24 @@ 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;
}

// 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 +79,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 +108,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 +130,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 59d1a54

Please sign in to comment.