diff --git a/packages/tiny-react/examples/server/src/entry-browser.tsx b/packages/tiny-react/examples/server/src/entry-browser.tsx index 424f9eba..ec59a434 100644 --- a/packages/tiny-react/examples/server/src/entry-browser.tsx +++ b/packages/tiny-react/examples/server/src/entry-browser.tsx @@ -9,6 +9,7 @@ import { } from "@hiogawa/tiny-react"; import { tinyassert } from "@hiogawa/utils"; import { createReferenceMap } from "./integration/client-reference/runtime"; +import { jsonUnescapeSymbol } from "./integration/serialization"; async function main() { if (window.location.href.includes("__nojs")) { @@ -25,7 +26,7 @@ async function main() { url.searchParams.set("__serialize", ""); const res = await fetch(url); tinyassert(res.ok); - const result: SerializeResult = await res.json(); + const result: SerializeResult = jsonUnescapeSymbol(await res.text()); const newVnode = deserialize( result.data, await createReferenceMap(result.referenceIds) @@ -38,7 +39,9 @@ async function main() { } // hydrate with initial SNode - const initResult: SerializeResult = (globalThis as any).__serialized; + const initResult: SerializeResult = jsonUnescapeSymbol( + (globalThis as any).__serialized + ); const vnode = deserialize( initResult.data, await createReferenceMap(initResult.referenceIds) diff --git a/packages/tiny-react/examples/server/src/entry-server.tsx b/packages/tiny-react/examples/server/src/entry-server.tsx index f0abcc8c..c1c86861 100644 --- a/packages/tiny-react/examples/server/src/entry-server.tsx +++ b/packages/tiny-react/examples/server/src/entry-server.tsx @@ -6,6 +6,7 @@ import { } from "@hiogawa/tiny-react"; import type { ViteDevServer } from "vite"; import { createReferenceMap } from "./integration/client-reference/runtime"; +import { jsonEscapeSymbol } from "./integration/serialization"; import Layout from "./routes/layout"; export async function handler(request: Request) { @@ -15,7 +16,7 @@ export async function handler(request: Request) { // to CSR if (url.searchParams.has("__serialize")) { - return new Response(JSON.stringify(serialized), { + return new Response(jsonEscapeSymbol(serialized), { headers: { "content-type": "application/json", }, @@ -35,7 +36,7 @@ export async function handler(request: Request) { "", () => `` ); // dev only FOUC fix diff --git a/packages/tiny-react/examples/server/src/integration/serialization.ts b/packages/tiny-react/examples/server/src/integration/serialization.ts new file mode 100644 index 00000000..d858bf48 --- /dev/null +++ b/packages/tiny-react/examples/server/src/integration/serialization.ts @@ -0,0 +1,25 @@ +export function jsonEscapeSymbol(v: unknown) { + return JSON.stringify(v, function (_k, v) { + // escape collision + if (typeof v === "string" && v.startsWith("!")) { + return "!" + v; + } + // symbol + if (typeof v === "symbol" && typeof v.description === "string") { + return "!s:" + v.description; + } + return v; + }); +} + +export function jsonUnescapeSymbol(s: string) { + return JSON.parse(s, function (_k, v) { + if (typeof v === "string" && v.startsWith("!s:")) { + return Symbol.for(v.slice(3)); + } + if (typeof v === "string" && v.startsWith("!")) { + return v.slice(1); + } + return v; + }); +} diff --git a/packages/tiny-react/src/helper/hyperscript.test.tsx b/packages/tiny-react/src/helper/hyperscript.test.tsx index 7d355f9a..c2df8953 100644 --- a/packages/tiny-react/src/helper/hyperscript.test.tsx +++ b/packages/tiny-react/src/helper/hyperscript.test.tsx @@ -34,7 +34,7 @@ describe("hyperscript", () => { "value": "hello", }, "render": [Function], - "type": "custom", + "type": Symbol(tiny-react.custom), }, null, 0, @@ -42,7 +42,7 @@ describe("hyperscript", () => { "key": undefined, "props": {}, "render": [Function], - "type": "custom", + "type": Symbol(tiny-react.custom), }, undefined, { @@ -53,7 +53,7 @@ describe("hyperscript", () => { "className": "text-red", "ref": [Function], }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, { "key": undefined, @@ -61,7 +61,7 @@ describe("hyperscript", () => { "props": { "children": 0, }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, { "key": undefined, @@ -72,7 +72,7 @@ describe("hyperscript", () => { 1, ], }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, { "key": undefined, @@ -82,7 +82,7 @@ describe("hyperscript", () => { 0, ], }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, { "key": undefined, @@ -93,12 +93,12 @@ describe("hyperscript", () => { 1, ], }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, ], "className": "flex", }, - "type": "tag", + "type": Symbol(tiny-react.tag), } `); }); @@ -109,7 +109,7 @@ describe("hyperscript", () => { "key": undefined, "props": {}, "render": [Function], - "type": "custom", + "type": Symbol(tiny-react.custom), } `); expect(h(Fragment, {}, 1)).toMatchInlineSnapshot(` @@ -119,7 +119,7 @@ describe("hyperscript", () => { "children": 1, }, "render": [Function], - "type": "custom", + "type": Symbol(tiny-react.custom), } `); expect(h(Fragment, { children: 1 })).toMatchInlineSnapshot(` @@ -129,7 +129,7 @@ describe("hyperscript", () => { "children": 1, }, "render": [Function], - "type": "custom", + "type": Symbol(tiny-react.custom), } `); expect(h(Fragment, { children: [1] })).toMatchInlineSnapshot(` @@ -141,7 +141,7 @@ describe("hyperscript", () => { ], }, "render": [Function], - "type": "custom", + "type": Symbol(tiny-react.custom), } `); expect(h(Fragment, { children: 1 }, 2)).toMatchInlineSnapshot(` @@ -151,7 +151,7 @@ describe("hyperscript", () => { "children": 2, }, "render": [Function], - "type": "custom", + "type": Symbol(tiny-react.custom), } `); }); diff --git a/packages/tiny-react/src/helper/jsx-runtime.test.tsx b/packages/tiny-react/src/helper/jsx-runtime.test.tsx index a96ef19d..ac85d844 100644 --- a/packages/tiny-react/src/helper/jsx-runtime.test.tsx +++ b/packages/tiny-react/src/helper/jsx-runtime.test.tsx @@ -22,7 +22,7 @@ test("basic", () => { "children": "yay", "className": "hehe", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, { "key": undefined, @@ -35,17 +35,17 @@ test("basic", () => { "props": { "children": "ment", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, ], }, "render": [Function], - "type": "custom", + "type": Symbol(tiny-react.custom), }, ], "id": "hi", }, - "type": "tag", + "type": Symbol(tiny-react.tag), } `); }); diff --git a/packages/tiny-react/src/index.test.tsx b/packages/tiny-react/src/index.test.tsx index b2d42cac..056ccd24 100644 --- a/packages/tiny-react/src/index.test.tsx +++ b/packages/tiny-react/src/index.test.tsx @@ -49,19 +49,19 @@ describe(render, () => { "children": [ { "hnode": hello, - "type": "text", + "type": Symbol(tiny-react.text), "vnode": { "data": "hello", - "type": "text", + "type": Symbol(tiny-react.text), }, }, { "child": { "hnode": world, - "type": "text", + "type": Symbol(tiny-react.text), "vnode": { "data": "world", - "type": "text", + "type": Symbol(tiny-react.text), }, }, "hnode": { world , "listeners": Map {}, - "type": "tag", + "type": Symbol(tiny-react.tag), "vnode": { "key": undefined, "name": "span", @@ -78,7 +78,7 @@ describe(render, () => { "children": "world", "className": "text-red", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, }, ], @@ -88,12 +88,12 @@ describe(render, () => { > world , - "type": "fragment", + "type": Symbol(tiny-react.fragment), "vnode": { "children": [ { "data": "hello", - "type": "text", + "type": Symbol(tiny-react.text), }, { "key": undefined, @@ -102,10 +102,10 @@ describe(render, () => { "children": "world", "className": "text-red", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, ], - "type": "fragment", + "type": Symbol(tiny-react.fragment), }, }, "hnode":
{
, "listeners": Map {}, - "type": "tag", + "type": Symbol(tiny-react.tag), "vnode": { "key": undefined, "name": "div", @@ -133,12 +133,12 @@ describe(render, () => { "children": "world", "className": "text-red", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, ], "className": "flex items-center gap-2", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, } `); @@ -157,10 +157,10 @@ describe(render, () => { { "child": { "hnode": reconcile, - "type": "text", + "type": Symbol(tiny-react.text), "vnode": { "data": "reconcile", - "type": "text", + "type": Symbol(tiny-react.text), }, }, "hnode":
{ reconcile
, "listeners": Map {}, - "type": "tag", + "type": Symbol(tiny-react.tag), "vnode": { "key": undefined, "name": "div", @@ -177,7 +177,7 @@ describe(render, () => { "children": "reconcile", "className": "flex items-center gap-2", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, } `); @@ -209,38 +209,38 @@ describe(render, () => { { "child": { "hnode": hello, - "type": "text", + "type": Symbol(tiny-react.text), "vnode": { "data": "hello", - "type": "text", + "type": Symbol(tiny-react.text), }, }, "hnode": hello , "listeners": Map {}, - "type": "tag", + "type": Symbol(tiny-react.tag), "vnode": { "key": undefined, "name": "span", "props": { "children": "hello", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, }, { "hnode": world, - "type": "text", + "type": Symbol(tiny-react.text), "vnode": { "data": "world", - "type": "text", + "type": Symbol(tiny-react.text), }, }, ], "parent": [Circular], "slot": world, - "type": "fragment", + "type": Symbol(tiny-react.fragment), "vnode": { "children": [ { @@ -249,14 +249,14 @@ describe(render, () => { "props": { "children": "hello", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, { "data": "world", - "type": "text", + "type": Symbol(tiny-react.text), }, ], - "type": "fragment", + "type": Symbol(tiny-react.fragment), }, }, "hnode":
@@ -266,7 +266,7 @@ describe(render, () => { world
, "listeners": Map {}, - "type": "tag", + "type": Symbol(tiny-react.tag), "vnode": { "key": undefined, "name": "div", @@ -278,12 +278,12 @@ describe(render, () => { "props": { "children": "hello", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, "world", ], }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, }, "hookContext": HookContext { @@ -309,14 +309,14 @@ describe(render, () => { world , - "type": "custom", + "type": Symbol(tiny-react.custom), "vnode": { "key": undefined, "props": { "value": "hello", }, "render": [Function], - "type": "custom", + "type": Symbol(tiny-react.custom), }, } `); @@ -1598,7 +1598,7 @@ describe("custom-children", () => { "children": "hello", }, "render": [Function], - "type": "custom", + "type": Symbol(tiny-react.custom), }, { "key": "key2", @@ -1606,11 +1606,11 @@ describe("custom-children", () => { "children": "hello", }, "render": [Function], - "type": "custom", + "type": Symbol(tiny-react.custom), }, ], }, - "type": "tag", + "type": Symbol(tiny-react.tag), } `); expect(parent).toMatchInlineSnapshot(` diff --git a/packages/tiny-react/src/server/index.test.tsx b/packages/tiny-react/src/server/index.test.tsx index 383d8b67..42f0faed 100644 --- a/packages/tiny-react/src/server/index.test.tsx +++ b/packages/tiny-react/src/server/index.test.tsx @@ -31,12 +31,12 @@ describe(serialize, () => { "children": "world", "title": "foo", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, ], "className": "flex", }, - "type": "tag", + "type": Symbol(tiny-react.tag), } `); @@ -56,12 +56,12 @@ describe(serialize, () => { "children": "world", "title": "foo", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, ], "className": "flex", }, - "type": "tag", + "type": Symbol(tiny-react.tag), } `); @@ -107,10 +107,10 @@ describe(serialize, () => { "props": { "children": "{"prop":123}", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, }, - "type": "tag", + "type": Symbol(tiny-react.tag), } `); @@ -126,10 +126,10 @@ describe(serialize, () => { "props": { "children": "{"prop":123}", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, }, - "type": "tag", + "type": Symbol(tiny-react.tag), } `); @@ -176,14 +176,14 @@ describe(serialize, () => { "key": undefined, "name": "span", "props": {}, - "type": "tag", + "type": Symbol(tiny-react.tag), }, }, - "type": "custom", + "type": Symbol(tiny-react.custom), }, "id": "server", }, - "type": "tag", + "type": Symbol(tiny-react.tag), } `); @@ -200,15 +200,15 @@ describe(serialize, () => { "key": undefined, "name": "span", "props": {}, - "type": "tag", + "type": Symbol(tiny-react.tag), }, }, "render": [Function], - "type": "custom", + "type": Symbol(tiny-react.custom), }, "id": "server", }, - "type": "tag", + "type": Symbol(tiny-react.tag), } `); @@ -273,17 +273,17 @@ describe(serialize, () => { "key": undefined, "name": "span", "props": {}, - "type": "tag", + "type": Symbol(tiny-react.tag), }, }, - "type": "custom", + "type": Symbol(tiny-react.custom), }, "id": "server", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, }, - "type": "custom", + "type": Symbol(tiny-react.custom), } `); @@ -306,19 +306,19 @@ describe(serialize, () => { "key": undefined, "name": "span", "props": {}, - "type": "tag", + "type": Symbol(tiny-react.tag), }, }, "render": [Function], - "type": "custom", + "type": Symbol(tiny-react.custom), }, "id": "server", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, }, "render": [Function], - "type": "custom", + "type": Symbol(tiny-react.custom), } `); @@ -376,13 +376,13 @@ describe(serialize, () => { "key": undefined, "name": "span", "props": {}, - "type": "tag", + "type": Symbol(tiny-react.tag), }, }, - "type": "custom", + "type": Symbol(tiny-react.custom), }, }, - "type": "custom", + "type": Symbol(tiny-react.custom), } `); @@ -401,15 +401,15 @@ describe(serialize, () => { "key": undefined, "name": "span", "props": {}, - "type": "tag", + "type": Symbol(tiny-react.tag), }, }, "render": [Function], - "type": "custom", + "type": Symbol(tiny-react.custom), }, }, "render": [Function], - "type": "custom", + "type": Symbol(tiny-react.custom), } `); @@ -436,17 +436,36 @@ describe(serialize, () => { ).rejects.toMatchInlineSnapshot(`[Error: Cannot serialize function]`); }); - it("'type' collision", async () => { + it("no 'type' collision", async () => { function Custom(_props: { x: { type: "tag" } }) { return <>; } registerClientReference(Custom, "#Custom"); - await expect(() => - serialize() - ).rejects.toMatchInlineSnapshot( - `[TypeError: Cannot convert undefined or null to object]` + const result = await serialize( + ); + expect(result).toMatchInlineSnapshot(` + { + "data": { + "$$id": "#Custom", + "key": undefined, + "props": { + "x": { + "type": "tag", + }, + }, + "type": Symbol(tiny-react.custom), + }, + "referenceIds": [ + "#Custom", + ], + } + `); }); it("multiple nodes", async () => { @@ -501,14 +520,14 @@ describe(serialize, () => { "props": { "children": "hey", }, - "type": "custom", + "type": Symbol(tiny-react.custom), }, }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, "id": "server", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, "k2": [ { @@ -525,14 +544,14 @@ describe(serialize, () => { "props": { "children": "yo", }, - "type": "custom", + "type": Symbol(tiny-react.custom), }, }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, "id": "server", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, ], } @@ -558,14 +577,14 @@ describe(serialize, () => { "children": "hey", }, "render": [Function], - "type": "custom", + "type": Symbol(tiny-react.custom), }, }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, "id": "server", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, "k2": [ { @@ -582,14 +601,14 @@ describe(serialize, () => { "children": "yo", }, "render": [Function], - "type": "custom", + "type": Symbol(tiny-react.custom), }, }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, "id": "server", }, - "type": "tag", + "type": Symbol(tiny-react.tag), }, ], } diff --git a/packages/tiny-react/src/server/index.ts b/packages/tiny-react/src/server/index.ts index eed9fb4c..7ad1dff8 100644 --- a/packages/tiny-react/src/server/index.ts +++ b/packages/tiny-react/src/server/index.ts @@ -25,6 +25,11 @@ import { // cf. https://react.dev/reference/rsc/use-client#serializable-types +// TOOD: we can probably use general framework to do RNode serialization such as +// packages/json-extra +// devalue +// turbo-stream + export type SerializeResult = { data: unknown; referenceIds: string[]; diff --git a/packages/tiny-react/src/ssr/index.test.tsx b/packages/tiny-react/src/ssr/index.test.tsx index 9a70048e..82e76fcf 100644 --- a/packages/tiny-react/src/ssr/index.test.tsx +++ b/packages/tiny-react/src/ssr/index.test.tsx @@ -173,7 +173,7 @@ describe(hydrate, () => { "props": { "children": "", }, - "type": "tag", + "type": Symbol(tiny-react.tag), } `); @@ -207,7 +207,7 @@ describe(hydrate, () => { "props": { "children": " ", }, - "type": "tag", + "type": Symbol(tiny-react.tag), } `); @@ -246,7 +246,7 @@ describe(hydrate, () => { "b", ], }, - "type": "tag", + "type": Symbol(tiny-react.tag), } `); diff --git a/packages/tiny-react/src/ssr/render.test.tsx b/packages/tiny-react/src/ssr/render.test.tsx index eccbc537..b79265d3 100644 --- a/packages/tiny-react/src/ssr/render.test.tsx +++ b/packages/tiny-react/src/ssr/render.test.tsx @@ -46,7 +46,7 @@ describe(renderToString, () => { ], "title": "a & b", }, - "type": "tag", + "type": Symbol(tiny-react.tag), } `); expect(renderToString(vnode)).toMatchInlineSnapshot( diff --git a/packages/tiny-react/src/virtual-dom.ts b/packages/tiny-react/src/virtual-dom.ts index c90d496c..22225bd3 100644 --- a/packages/tiny-react/src/virtual-dom.ts +++ b/packages/tiny-react/src/virtual-dom.ts @@ -13,12 +13,12 @@ export type HNode = Node; export type HTag = Element; export type HText = Text; -// node type (TODO: check perf between string, number, symbol) -export const NODE_TYPE_EMPTY = "empty" as const; -export const NODE_TYPE_TAG = "tag" as const; -export const NODE_TYPE_TEXT = "text" as const; -export const NODE_TYPE_CUSTOM = "custom" as const; -export const NODE_TYPE_FRAGMENT = "fragment" as const; +// node type +export const NODE_TYPE_EMPTY = Symbol.for("tiny-react.empty"); +export const NODE_TYPE_TAG = Symbol.for("tiny-react.tag"); +export const NODE_TYPE_TEXT = Symbol.for("tiny-react.text"); +export const NODE_TYPE_CUSTOM = Symbol.for("tiny-react.custom"); +export const NODE_TYPE_FRAGMENT = Symbol.for("tiny-react.fragment"); export function isVNode(v: unknown): v is VNode { return (