Skip to content
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
2 changes: 1 addition & 1 deletion .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 100,
"proseWrap": "never",
"ignorePatterns": [".fresh", ".output", "www/public", "src/vendor"]
"ignorePatterns": [".fresh", ".output", "www/public"]
}
1 change: 0 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"ignorePatterns": ["src/vendor"],
"overrides": [
{
"files": ["**/src/**/*.{ts,tsx}"],
Expand Down
2 changes: 1 addition & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Gaps surfaced by comparing against the Fresh 2 test suite. (Programmatic route r
- [x] Partial-navigation client e2e — scroll reset on a forward swap, scroll restoration on back/forward traverse, full-page fallback when a partial fetch 500s, and native in-page hash-link scrolling (no swap/reload). All in `tests/e2e/partials.spec.ts`. Scroll reset/restore is handled entirely by the Navigation API's default `intercept({ scroll: "after-transition" })` (no fresh3 code needed) — the test drives navigation via a JS `.click()` (`clickViaJs` in `util.ts`) so Playwright's scroll-into-view doesn't reset the page to the top before the navigation fires.
- [ ] Remaining partial client e2e: focus reset after a swap, and request-abort behaviour when one navigation supersedes another in flight.
- [ ] Head e2e gaps: multiple islands each rendering `<Head>` (merge), reactive `<meta>` content updates post-hydration, and `<style>`/`<template>`/`<noscript>` collection into the head.
- [ ] Serializer wire-format coverage leans on devalue at the e2e layer; explicit unit round-trips for Temporal types, `Uint8Array` / typed arrays, `URL`, sparse arrays, and `undefined` / `NaN` / `±Infinity` are missing (only partly exercised via `tests/e2e/serialization.spec.ts`).
- [x] Serializer wire-format unit round-trips in `src/server/serialization.test.ts` — Temporal types, `ArrayBuffer` / every typed array (incl. subarrays and shared buffers), `DataView`, `URL` / `URLSearchParams`, sparse arrays, and `undefined` / `NaN` / `±Infinity` / `-0`. These run against upstream `devalue` (6.x), which replaced our vendored fork once its `js`-template replacer API landed.

### WWW

Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,12 @@
}
},
"scripts": {
"build": "GOMAXPROCS=1 tsgo -p tsconfig.json && find dist -name '*.test.*' -delete && cp -R vendor dist/vendor",
"build": "GOMAXPROCS=1 tsgo -p tsconfig.json && find dist -name '*.test.*' -delete",
"prepack": "rm -rf dist && pnpm run build"
},
"dependencies": {
"@prefresh/vite": "^4.0.1",
"devalue": "^6.0.3",
"magic-string": "^1.4.2",
"nitro": "^3.0.260903-beta",
"preact-render-to-string": "^6.8.0",
Expand Down
155 changes: 147 additions & 8 deletions src/server/serialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,9 @@ test("factory args are recursively walked through the replacer — signals insid
const factory = tagIslandExport((s: unknown) => ({ derived: s }), "islands/utils.tsx", "wrap");
const sig = signal(7);
const result = { __FRESH_FROM_FACTORY: { factory, args: [sig] } };
// Serialize the factory result on its own. (Sharing `sig` across the
// factory args AND a sibling position is NOT preserved — see the next
// test — and would push devalue into its IIFE-dedup form, which is a
// different shape than what this test is asserting about.)
// Serialize the factory result on its own. (Sharing `sig` with a
// sibling position would push devalue into its IIFE-dedup form, which is
// a different shape than what this test is asserting about.)
const src = ser.serialize(result);
// The arg is itself a signal-call expression — proving the args
// went back through the replacer rather than being inlined as a
Expand All @@ -198,10 +197,7 @@ test("factory args are recursively walked through the replacer — signals insid
test("the SAME factory-result reference used twice survives as one instance via devalue's IIFE-bound local", () => {
// The simpler dedup case — two references to the same factory
// result. devalue notices the same JS object and IIFE-wraps the
// shared expression. (Cross-boundary sharing — same signal both
// inside a factory's args AND as a sibling — is NOT preserved; that
// would require hoisting expressions into top-level `const`
// bindings, which we don't.)
// shared expression.
const ser = createSerializer();
const factory = tagIslandExport(
(n: number) => ({ doubled: n * 2 }),
Expand All @@ -214,6 +210,15 @@ test("the SAME factory-result reference used twice survives as one instance via
assert.equal(built.a, built.b);
});

test("a signal inside a factory's args AND in a sibling position survives as one shared instance", () => {
const ser = createSerializer();
const factory = tagIslandExport((s: unknown) => ({ derived: s }), "islands/utils.tsx", "wrap");
const sig = signal(7);
const src = ser.serialize({ a: { __FRESH_FROM_FACTORY: { factory, args: [sig] } }, b: sig });
const built = evalProgram(src, ser.imports) as { a: { __call: { args: unknown[] } }; b: unknown };
assert.equal(built.a.__call.args[0], built.b);
});

// ---------- aliasFor (post-hoc imports, e.g. the boot runtime) ----------

test("aliasFor can register an additional import after serialize() — gets the next module index", () => {
Expand Down Expand Up @@ -284,6 +289,140 @@ test("`navigating()` serializes as a factory call via fresh/signals (not a bare
assert.ok(entry.imports.has("navigating"));
});

// ---------- wire-format round-trips for built-in value types ----------

/** Serialize `value` and eval the emitted expression back into a value. */
function roundTrip<T>(value: T): T {
const ser = createSerializer();
const src = ser.serialize(value);
return evalProgram(src, ser.imports) as T;
}

test("`undefined`, `NaN`, `±Infinity`, and `-0` round-trip, including nested in objects and arrays", () => {
for (const v of [undefined, NaN, Infinity, -Infinity, -0]) {
assert.ok(Object.is(roundTrip(v), v), `${String(v)} must round-trip`);
}
const out = roundTrip({ u: undefined, n: NaN, arr: [Infinity, -Infinity, -0, undefined] });
assert.ok("u" in out, "an `undefined`-valued key must survive, not be dropped");
assert.equal(out.u, undefined);
assert.ok(Number.isNaN(out.n));
assert.equal(out.arr.length, 4);
assert.equal(out.arr[0], Infinity);
assert.equal(out.arr[1], -Infinity);
assert.ok(Object.is(out.arr[2], -0));
assert.ok(3 in out.arr, "an explicit `undefined` element is not a hole");
});

/** An array of `length` holes with only `entries` populated. */
function sparse(length: number, entries: Record<number, unknown>): unknown[] {
const arr: unknown[] = [];
arr.length = length;
return Object.assign(arr, entries);
}

test("sparse arrays keep their holes and length", () => {
// Mildly sparse (devalue emits `[1,,3]`), very sparse (emitted as
// `Object.assign(Array(n),{…})`), and a trailing hole (needs an extra `,`).
const cases = [
sparse(3, { 0: 1, 2: 3 }),
sparse(1000, { 3: "a", 997: "b" }),
sparse(3, { 0: 1, 1: 2 }),
];
for (const arr of cases) {
const out = roundTrip(arr);
assert.equal(out.length, arr.length);
for (let i = 0; i < arr.length; i++) {
assert.equal(i in out, i in arr, `index ${i} hole-ness must match`);
assert.equal(out[i], arr[i]);
}
}
});

test("`URL` and `URLSearchParams` revive as instances", () => {
const out = roundTrip({
url: new URL("https://example.com/a?b=1#c"),
params: new URLSearchParams("x=1&x=2&y=</script>"),
});
assert.ok(out.url instanceof URL);
assert.equal(out.url.href, "https://example.com/a?b=1#c");
assert.ok(out.params instanceof URLSearchParams);
assert.deepEqual(out.params.getAll("x"), ["1", "2"]);
assert.equal(out.params.get("y"), "</script>");
});

test("`ArrayBuffer`, `Uint8Array`, and every other typed array revive with their type and contents", () => {
const values = [
new Int8Array([-128, 0, 127]),
new Uint8Array([0, 1, 255]),
new Uint8ClampedArray([0, 128, 255]),
new Int16Array([-32768, 32767]),
new Uint16Array([0, 65535]),
new Float16Array([1.5, -2]),
new Int32Array([-1, 2 ** 31 - 1]),
new Uint32Array([0, 2 ** 32 - 1]),
new Float32Array([0.5, -Infinity]),
new Float64Array([Math.PI, NaN, -0]),
new BigInt64Array([-(2n ** 63n), 1n]),
new BigUint64Array([2n ** 64n - 1n]),
];
for (const v of values) {
const out = roundTrip(v);
assert.equal(out.constructor, v.constructor);
assert.deepEqual(out, v);
}
// A typed array referenced twice goes through devalue's hoisted-value path,
// which writes its elements separately from the inline path.
const big = new BigInt64Array([-(2n ** 63n), 1n]);
const shared = roundTrip({ a: big, b: big });
assert.equal(shared.a, shared.b);
assert.deepEqual(shared.a, big);
const buf = roundTrip(new Uint8Array([1, 2, 3]).buffer);
assert.ok(buf instanceof ArrayBuffer);
assert.deepEqual(new Uint8Array(buf), new Uint8Array([1, 2, 3]));
});

test("typed-array subarrays keep their window, and views sharing a buffer still share it", () => {
const buffer = new Uint8Array([10, 20, 30, 40, 50]).buffer;
const out = roundTrip({
whole: new Uint8Array(buffer),
sub: new Uint8Array(buffer, 1, 3),
});
assert.deepEqual([...out.sub], [20, 30, 40]);
assert.equal(out.sub.byteOffset, 1);
assert.equal(out.whole.buffer, out.sub.buffer, "both views must share one revived buffer");
out.whole[2] = 99;
assert.equal(out.sub[1], 99);
});

test("a `DataView` revives with its bytes, and a sub-view keeps its offset", () => {
const buffer = new Uint8Array([1, 2, 3, 4, 5, 6]).buffer;
const out = roundTrip(new DataView(buffer));
assert.ok(out instanceof DataView);
assert.equal(out.getUint32(0), 0x01020304);
const sub = roundTrip(new DataView(buffer, 2, 3));
assert.equal(sub.byteOffset, 2);
assert.equal(sub.byteLength, 3);
assert.equal(sub.getUint8(0), 3);
});

test("every Temporal type revives as an instance with the same value", () => {
const values = [
Temporal.Duration.from("P1Y2M3DT4H5M6.789S"),
Temporal.Instant.from("2026-01-02T03:04:05.123456789Z"),
Temporal.PlainDate.from("2026-01-02"),
Temporal.PlainTime.from("03:04:05.123"),
Temporal.PlainDateTime.from("2026-01-02T03:04:05"),
Temporal.PlainMonthDay.from("--01-02"),
Temporal.PlainYearMonth.from("2026-01"),
Temporal.ZonedDateTime.from("2026-01-02T03:04:05+01:00[Europe/Berlin]"),
];
for (const v of values) {
const out = roundTrip(v);
assert.equal(Object.getPrototypeOf(out), Object.getPrototypeOf(v));
assert.equal(out.toString(), v.toString());
}
});

// ---------- `</script>` injection safety via uneval ----------

test("strings containing `</script>` are escaped by devalue.uneval — `</script>` never appears verbatim", () => {
Expand Down
61 changes: 36 additions & 25 deletions src/server/serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
// custom-typed nodes replaced by alias / call expressions. Shared
// references survive because devalue's `uneval` notices when the
// same JS object appears in two places and hoists it into an
// IIFE-bound local — including across `recurse` boundaries — so
// IIFE-bound local — including inside replacer `js` fragments — so
// a signal that appears as an island prop AND inside an event-
// handler factory's args revives as ONE instance;
//
Expand All @@ -43,7 +43,18 @@
// `<signalAlias>(<peeked-value>)`; the peeked inner value goes back
// through the replacer for nested signal / factory handling.

import { uneval } from "../vendor/devalue/index.js";
import { uneval, type JavaScriptFragment, type JavaScriptTag } from "devalue";

/**
* Build a `js` fragment from template pieces that aren't known until
* runtime (import aliases, a variable number of factory args). `strings`
* is literal JS source and must be one longer than `values`, exactly like
* a tagged template's string array; each value is serialized by devalue.
*/
function source(js: JavaScriptTag, strings: string[], values: unknown[]): JavaScriptFragment {
const template = Object.assign([...strings], { raw: [...strings] });
return js(template, ...values);
}

const SIGNAL_BRAND = Symbol.for("preact-signals");

Expand Down Expand Up @@ -115,8 +126,8 @@ export interface Serializer {
* expressions, and return the JS source to place after `const data =
* ` and before `;`. Shared references survive — devalue's own
* IIFE-bound-local mechanism dedupes any value reached via the same
* JS object identity more than once, including across `recurse`
* boundaries.
* JS object identity more than once, including inside a factory's
* args.
*/
serialize(value: unknown): string;
/**
Expand Down Expand Up @@ -157,38 +168,39 @@ export function createSerializer(options: SerializerOptions = {}): Serializer {
return `_${mod.index}_${idx}`;
}

// devalue's `uneval` invokes the replacer as `(value, recurse)` where
// `recurse` is a bound helper that shares the outer call's reference
// graph and dedup state. Recursing through it (rather than calling
// `uneval(...)` again) is what lets devalue notice when the same
// signal / factory-result / function reference appears in two places
// and emit a single IIFE-bound local — so reactivity is preserved
// across an island prop + a `{signal}` text child + an event-handler
// argument without us tracking sharing ourselves.
const replacer = (v: unknown, recurse: (value: unknown) => string): string | undefined => {
// devalue's `uneval` invokes the replacer as `(value, js)`. Each `js`
// fragment is literal JS source with "holes" that devalue serializes
// itself, after its reference analysis has seen them. That is what lets
// devalue notice when the same signal / factory-result / function
// reference appears in two places — including once as a top-level value
// and once inside a factory's args — and emit a single IIFE-bound local,
// so reactivity is preserved across an island prop + a `{signal}` text
// child + an event-handler argument without us tracking sharing ourselves.
const replacer = (v: unknown, js: JavaScriptTag): JavaScriptFragment | undefined => {
// Order matters. `makeComputed` results carry BOTH the factory
// marker AND the signal brand — the only correct round-trip path
// is to re-invoke the factory, so FactoryFunction wins.
if (isFromFactory(v)) {
const { factory, args } = (v as { __FRESH_FROM_FACTORY: FactoryMarker }).__FRESH_FROM_FACTORY;
// The factory is itself a SerializableFunction — `recurse(factory)`
// re-enters the replacer, hits the `isSerializableFunction`
// branch below, registers the import, and returns the alias. The
// pre-check just gives a specific error message instead of
// devalue's generic "can't uneval a function".
// The factory is itself a SerializableFunction — as a hole it
// re-enters the replacer, hits the `isSerializableFunction` branch
// below, registers the import, and becomes the alias. The pre-check
// just gives a specific error message instead of devalue's generic
// "can't uneval a function".
if (!isSerializableFunction(factory)) {
throw new TypeError(
"factory(...) wrapper isn't an island-exported function — make sure the factory itself is exported from an `islands/` module (or shipped by a `fresh/...` runtime entrypoint).",
);
}
const factorySrc = recurse(factory);
const argSources = args.map(recurse);
return `(${factorySrc})(${argSources.join(",")})`;
// `(<factory>)(<arg0>,<arg1>,…)`
const strings =
args.length === 0 ? ["(", ")()"] : ["(", ")(", ...args.slice(1).map(() => ","), ")"];
return source(js, strings, [factory, ...args]);
}

if (isSerializableFunction(v)) {
const { specifier, export: exp } = v.__FRESH_SERIALIZABLE_FUNCTION;
return aliasFor(specifier, exp);
return source(js, [aliasFor(specifier, exp)], []);
}

if (isSignalLike(v)) {
Expand All @@ -200,9 +212,8 @@ export function createSerializer(options: SerializerOptions = {}): Serializer {
"Cannot serialize a computed signal; wrap its factory with `makeComputed()` from `fresh/signals` or pass a plain signal instead.",
);
}
const innerSrc = recurse(v.peek());
const signalAlias = aliasFor(SIGNAL_HELPER_SPECIFIER, SIGNAL_HELPER_EXPORT);
return `${signalAlias}(${innerSrc})`;
return source(js, [`${signalAlias}(`, ")"], [v.peek()]);
}

// A VNode prop (JSX passed to an island) — not structurally serializable
Expand All @@ -212,7 +223,7 @@ export function createSerializer(options: SerializerOptions = {}): Serializer {
const slotIndex = options.slot(v);
if (slotIndex !== null) {
const slotAlias = aliasFor(SIGNAL_HELPER_SPECIFIER, SLOT_HELPER_EXPORT);
return `${slotAlias}(${slotIndex})`;
return source(js, [`${slotAlias}(${slotIndex})`], []);
}
}

Expand Down
7 changes: 0 additions & 7 deletions src/vendor/devalue/LICENSE

This file was deleted.

12 changes: 0 additions & 12 deletions src/vendor/devalue/constants.js

This file was deleted.

2 changes: 0 additions & 2 deletions src/vendor/devalue/index.d.ts

This file was deleted.

6 changes: 0 additions & 6 deletions src/vendor/devalue/index.js

This file was deleted.

Loading
Loading