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

feat: support passing signals as island props #757

Merged
merged 1 commit into from
May 31, 2023
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
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
13 changes: 12 additions & 1 deletion src/runtime/deserializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,22 @@

export const KEY = "_f";

export function deserialize(str: string): unknown {
interface Signal<T> {
peek(): T;
value: T;
}

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);
});