diff --git a/demo/import_map.json b/demo/import_map.json index 75474c39fd9..adf1ff4a013 100644 --- a/demo/import_map.json +++ b/demo/import_map.json @@ -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" } } diff --git a/demo/islands/Counter.tsx b/demo/islands/Counter.tsx index 8f78ff43d29..9a325f396b1 100644 --- a/demo/islands/Counter.tsx +++ b/demo/islands/Counter.tsx @@ -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; } -export default function Counter(props: CounterProps) { - const [count, setCount] = useState(props.start); +export default function Counter({ count }: CounterProps) { return (

{count}

- -
diff --git a/demo/routes/index.tsx b/demo/routes/index.tsx index 46a70ce1f57..5b2d109eb2a 100644 --- a/demo/routes/index.tsx +++ b/demo/routes/index.tsx @@ -1,13 +1,17 @@ +import { useSignal } from "@preact/signals"; import Counter from "../islands/Counter.tsx"; export default function Home() { + const count = useSignal(3); return (

Welcome to Fresh. Try to update this message in the ./routes/index.tsx file, and refresh.

- + + +
); } diff --git a/docs/concepts/islands.md b/docs/concepts/islands.md index c9fa8109f12..76a53e0537e 100644 --- a/docs/concepts/islands.md +++ b/docs/concepts/islands.md @@ -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 diff --git a/src/runtime/deserializer.ts b/src/runtime/deserializer.ts index 8ad9f125818..54c814c1543 100644 --- a/src/runtime/deserializer.ts +++ b/src/runtime/deserializer.ts @@ -3,11 +3,22 @@ export const KEY = "_f"; -export function deserialize(str: string): unknown { +interface Signal { + peek(): T; + value: T; +} + +export function deserialize( + str: string, + signal?: (a: T) => Signal, +): 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; diff --git a/src/runtime/entrypoints/signals.ts b/src/runtime/entrypoints/signals.ts new file mode 100644 index 00000000000..0c33f9daa6e --- /dev/null +++ b/src/runtime/entrypoints/signals.ts @@ -0,0 +1 @@ +export { signal } from "@preact/signals"; diff --git a/src/server/__snapshots__/serializer_test.ts.snap b/src/server/__snapshots__/serializer_test.ts.snap index ef0ca2b8002..c1d16cc08c7 100644 --- a/src/server/__snapshots__/serializer_test.ts.snap +++ b/src/server/__snapshots__/serializer_test.ts.snap @@ -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"]]]}'`; @@ -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"]]]}'`; diff --git a/src/server/bundle.ts b/src/server/bundle.ts index 9e096cdeb3a..c11177b6b5a 100644 --- a/src/server/bundle.ts +++ b/src/server/bundle.ts @@ -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; } diff --git a/src/server/render.ts b/src/server/render.ts index f46ec3eeaca..1f12baa3329 100644 --- a/src/server/render.ts +++ b/src/server/render.ts @@ -257,10 +257,18 @@ export async function render( 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;`; } diff --git a/src/server/serializer.ts b/src/server/serializer.ts index 6e7058b0c77..e8beefbcb60 100644 --- a/src/server/serializer.ts +++ b/src/server/serializer.ts @@ -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. @@ -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(); const references = new Map<(string | null)[], (string | null)[][]>(); @@ -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) { @@ -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 }; @@ -107,5 +134,5 @@ export function serialize(data: unknown): SerializeResult { } const serialized = JSON.stringify(toSerialize, replacer); - return { serialized, requiresDeserializer }; + return { serialized, requiresDeserializer, hasSignals }; } diff --git a/src/server/serializer_test.ts b/src/server/serializer_test.ts index bf735e6d2d4..0b0f8630557 100644 --- a/src/server/serializer_test.ts +++ b/src/server/serializer_test.ts @@ -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 = { @@ -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); @@ -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); @@ -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); @@ -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); @@ -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); +});