Skip to content
Open
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 crates/topcoat-runtime/browser/dist/index.js

Large diffs are not rendered by default.

55 changes: 55 additions & 0 deletions crates/topcoat-runtime/browser/src/binding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { expect, it } from "vitest";

import { writeAttribute } from "./binding";
import { Bool } from "./surrogate/bool";
import { Option } from "./surrogate/option";
import { String as Owned } from "./surrogate/string";

/** The two calls `writeAttribute` makes, without needing a real DOM. */
function element() {
const attrs = new Map<string, string>();
return {
attrs,
el: {
setAttribute: (n: string, v: string) => void attrs.set(n, v),
removeAttribute: (n: string) => void attrs.delete(n),
} as unknown as Element,
};
}

// `AttributeValueViewParts for (T1, T2)` concatenates its elements and is
// present when any element is, so these are the attributes the server wrote.

it("writes a tuple attribute by concatenating its elements", () => {
const { attrs, el } = element();

writeAttribute(el, "title", [new Owned("a"), new Owned("b")]);

expect(attrs.get("title")).toBe("ab");
});

it("skips an absent element inside a tuple attribute", () => {
const { attrs, el } = element();

writeAttribute(el, "title", [new Owned("a"), Option.none(), new Owned("b")]);

expect(attrs.get("title")).toBe("ab");
});

it("removes the attribute when no element of a tuple is present", () => {
const { attrs, el } = element();
attrs.set("title", "stale");

// `false` is a bool that is not present, the way a bare `false` attribute is.
writeAttribute(el, "title", [Option.none(), new Bool(false)]);

expect(attrs.has("title")).toBe(false);
});

it("still writes a plain surrogate attribute", () => {
const { attrs, el } = element();

writeAttribute(el, "title", new Owned("a"));

expect(attrs.get("title")).toBe("a");
});
24 changes: 22 additions & 2 deletions crates/topcoat-runtime/browser/src/binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,35 @@ export function setupBinding(el: Element, attr: Attr, scope: Scope): void {
const { context } = scope.runtime;
scope.run(() => {
effect(() => {
write(el, name, compute(context));
writeAttribute(el, name, compute(context));
});
});
}

function write(el: Element, name: string, value: unknown): void {
export function writeAttribute(
el: Element,
name: string,
value: unknown,
): void {
if (PROPERTY_NAMES.has(name)) {
(el as Element & Record<string, unknown>)[name] = value;
}
// A tuple, which hydrates as an array. `AttributeValueViewParts for
// (T1, T2)` is present when any element is, and writes the elements one
// after another; an element that is not present writes nothing, the way a
// `None` does. Falling through would give `String(array)` and its commas.
if (Array.isArray(value)) {
const present = value.filter(
(element) =>
isAttributeValueViewParts(element) && element.isAttributePresent(),
);
if (present.length === 0) {
el.removeAttribute(name);
return;
}
el.setAttribute(name, present.map((e) => e.toAttributeValue()).join(""));
return;
}
if (isAttributeValueViewParts(value)) {
if (!value.isAttributePresent()) {
el.removeAttribute(name);
Expand Down
9 changes: 9 additions & 0 deletions crates/topcoat-runtime/browser/src/surrogate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export type DehydratedSurrogate =
| { t: "i32"; v: number }
| { t: "str"; v: string }
| string
// A tuple. Serde writes a Rust tuple as an array, and `expr!` compiles
// tuple field access to array indexing, so it stays an array here.
| DehydratedSurrogate[]
| { t: "Option"; v: DehydratedSurrogate | null }
| { t: "Result"; ok: DehydratedSurrogate }
| { t: "Result"; err: DehydratedSurrogate }
Expand All @@ -51,6 +54,12 @@ export function hydrateSurrogate(
case "function":
throw new Error(`Unknown surrogate type: ${typeof value}`);
case "object":
// Checked before the tag, because an array is an object with no
// `t`. `expr!` indexes a tuple with `pair[0]`, so it has to stay a
// real array rather than become a surrogate object.
if (Array.isArray(value)) {
return value.map((element) => hydrateSurrogate(element, cx));
}
switch (value.t) {
case "str":
return new Str(value.v);
Expand Down
58 changes: 58 additions & 0 deletions crates/topcoat-runtime/browser/src/surrogate/tuple.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { expect, it } from "vitest";

import { Context } from "../context";
import { SignalRegistry } from "../signal";
import { F64 } from "./f64";
import { hydrateSurrogate } from "./index";
import { Option } from "./option";
import { String as Owned } from "./string";

const cx = () => new Context(new SignalRegistry());

// The inputs below are the JSON `__surrogate` writes for the corresponding
// Rust value, so they pin the wire format rather than a JavaScript shape.

// Regression for the tuple arm being absent: an array fell through the tag
// switch and threw `Unknown surrogate type: undefined`, so every expression
// capturing a tuple failed to hydrate.
it("hydrates a tuple as an array of surrogates", () => {
// `(1.5f64, 2.5f64)`
const pair = hydrateSurrogate(JSON.parse("[1.5,2.5]"), cx()) as unknown[];

expect(Array.isArray(pair)).toBe(true);
expect(pair).toHaveLength(2);
expect(pair[0]).toBeInstanceOf(F64);
// `expr!` compiles `pair.0` to `pair[0]`, so indexing has to work.
expect((pair[0] as F64).toNodeText()).toBe("1.5");
expect((pair[1] as F64).toNodeText()).toBe("2.5");
});

it("hydrates the elements of a tuple, not just the tuple", () => {
// `(1.0f64, Some(2.0f64), "x".to_owned())`
const parts = hydrateSurrogate(
JSON.parse('[1.0,{"t":"Option","v":2.0},"x"]'),
cx(),
) as unknown[];

expect(parts[0]).toBeInstanceOf(F64);
expect(parts[1]).toBeInstanceOf(Option);
expect((parts[1] as Option<F64>).unwrap()).toBeInstanceOf(F64);
expect(parts[2]).toBeInstanceOf(Owned);
});

it("hydrates a nested tuple", () => {
// `((1.0f64, 2.0f64), 3.0f64)`
const outer = hydrateSurrogate(JSON.parse("[[1.0,2.0],3.0]"), cx()) as [
unknown[],
unknown,
];

expect(Array.isArray(outer[0])).toBe(true);
// `2.0f64` renders as `2`, the way Rust's `Display` writes it.
expect((outer[0][1] as F64).toNodeText()).toBe("2");
expect(outer[1]).toBeInstanceOf(F64);
});

it("hydrates an empty tuple", () => {
expect(hydrateSurrogate(JSON.parse("[]"), cx())).toEqual([]);
});
28 changes: 28 additions & 0 deletions crates/topcoat-runtime/browser/src/text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { expect, it } from "vitest";

import { F64 } from "./surrogate/f64";
import { Option } from "./surrogate/option";
import { String as Owned } from "./surrogate/string";
import { toText } from "./text";

// `NodeViewParts for (T1, T2)` writes its elements one after another with no
// separator, so these are the strings the server rendered for the same values.

it("renders a tuple by concatenating its elements", () => {
expect(toText([new F64(1.5), new F64(2.5)])).toBe("1.52.5");
expect(toText([new Owned("a"), new Owned("b")])).toBe("ab");
});

it("renders an absent element in a tuple as nothing", () => {
expect(toText([new Owned("a"), Option.none(), new Owned("b")])).toBe("ab");
});

it("renders a nested tuple flat", () => {
expect(toText([[new Owned("a"), new Owned("b")], new Owned("c")])).toBe(
"abc",
);
});

it("renders an empty tuple as nothing", () => {
expect(toText([])).toBe("");
});
6 changes: 5 additions & 1 deletion crates/topcoat-runtime/browser/src/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,16 @@ function write(start: Comment, end: Comment, value: unknown): void {
}
}

function toText(value: unknown): string {
export function toText(value: unknown): string {
let current = value;
while (isRefLike(current)) {
current = current.deref();
}
if (current == null) return "";
// A tuple, which hydrates as an array. `NodeViewParts for (T1, T2)` writes
// its elements one after another with no separator, so joining on "" is
// what the server rendered; `String(array)` would insert commas.
if (Array.isArray(current)) return current.map(toText).join("");
if (isNodeViewParts(current)) return current.toNodeText();
return String(current);
}
Expand Down
29 changes: 29 additions & 0 deletions crates/topcoat-runtime/src/surrogate/tuple.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,32 @@ impl_tuple_surrogate!(T1 0, T2 1, T3 2, T4 3, T5 4, T6 5, T7 6, T8 7, T9 8, T10
impl_tuple_surrogate!(
T1 0, T2 1, T3 2, T4 3, T5 4, T6 5, T7 6, T8 7, T9 8, T10 9, T11 10, T12 11,
);

#[cfg(test)]
mod tests {
use crate::Surrogated;

/// A captured value reaches the browser as `cx.hydrate(<json>)`, and the
/// browser reads a tuple as an array because `expr!` compiles tuple field
/// access to array indexing. Pin the shape both sides depend on.
#[test]
fn a_tuple_surrogate_serializes_as_an_array() {
let pair = (1.5f64, 2.5f64).into_surrogate();
assert_eq!(serde_json::to_string(&pair).unwrap(), "[1.5,2.5]");
}

#[test]
fn the_elements_of_a_tuple_keep_their_own_encoding() {
let mixed = (1.0f64, Some(2.0f64), true).into_surrogate();
assert_eq!(
serde_json::to_string(&mixed).unwrap(),
r#"[1.0,{"t":"Option","v":2.0},true]"#
);
}

#[test]
fn a_nested_tuple_nests_its_arrays() {
let nested = ((1.0f64, 2.0f64), 3.0f64).into_surrogate();
assert_eq!(serde_json::to_string(&nested).unwrap(), "[[1.0,2.0],3.0]");
}
}