expr.md lists "Tuples of vocabulary types" in the shared vocabulary, and expr_field.rs:21 compiles pair.0 into JavaScript pair[0], so a tuple is meant to be an array on the browser side. The Rust side serializes it as one. hydrateSurrogate has no array case, so every expression that captures a tuple throws in the browser.
Reproduction
$(pair.0) with let pair = (1.5f64, 2.5f64) renders:
<!-- ::topcoat::expr::start("(() => { const [__external0] = [cx.hydrate([1.5,2.5])]; return __external0[0]; })()") -->1.5<!-- ::topcoat::expr::end -->
The server text is correct. The JavaScript is not. cx.hydrate([1.5,2.5]) reaches hydrateSurrogate with an array, typeof value === "object" while value.t is undefined, so it falls through to the default arm:
Error: Unknown surrogate type: undefined
❯ hydrateSurrogate src/surrogate/index.ts:73:12
$(pair) on its own does the same, with return __external0 in place of the index.
Where it comes from
crates/topcoat-runtime/src/lib.rs:52 writes a captured value into the page as cx.hydrate(<json>).
- A tuple surrogate is a plain Rust tuple of surrogates, so serde emits an array:
(1.5f64, 2.5f64) gives [1.5,2.5], and (1.0f64, Some(2.0f64)) gives [1.0,{"t":"Option","v":2.0}].
browser/src/surrogate/index.ts has no array member in DehydratedSurrogate and no array case in hydrateSurrogate.
Reach
scan() walks the document in a single for loop and there is no try/catch in scan.ts, text.ts or binding.ts, so the throw leaves the loop and nothing after the offending expression is hydrated. I have verified the throw itself; I have not measured the page-level effect in a browser.
One thing a fix should not miss
Hydrating the array into a plain array of surrogates is what the generated pair[0] needs, but the text path would then disagree rather than throw. NodeViewParts for (T1, T2) concatenates its elements with no separator, so the server renders $(pair) as 1.52.5, while String(array) at text.ts:55 would give 1.5,2.5. Attribute values have the same shape: AttributeValueViewParts for (T1, T2) concatenates, and is present when any element is present.
expr.mdlists "Tuples of vocabulary types" in the shared vocabulary, andexpr_field.rs:21compilespair.0into JavaScriptpair[0], so a tuple is meant to be an array on the browser side. The Rust side serializes it as one.hydrateSurrogatehas no array case, so every expression that captures a tuple throws in the browser.Reproduction
$(pair.0)withlet pair = (1.5f64, 2.5f64)renders:The server text is correct. The JavaScript is not.
cx.hydrate([1.5,2.5])reacheshydrateSurrogatewith an array,typeof value === "object"whilevalue.tisundefined, so it falls through to the default arm:$(pair)on its own does the same, withreturn __external0in place of the index.Where it comes from
crates/topcoat-runtime/src/lib.rs:52writes a captured value into the page ascx.hydrate(<json>).(1.5f64, 2.5f64)gives[1.5,2.5], and(1.0f64, Some(2.0f64))gives[1.0,{"t":"Option","v":2.0}].browser/src/surrogate/index.tshas no array member inDehydratedSurrogateand no array case inhydrateSurrogate.Reach
scan()walks the document in a singleforloop and there is notry/catchinscan.ts,text.tsorbinding.ts, so the throw leaves the loop and nothing after the offending expression is hydrated. I have verified the throw itself; I have not measured the page-level effect in a browser.One thing a fix should not miss
Hydrating the array into a plain array of surrogates is what the generated
pair[0]needs, but the text path would then disagree rather than throw.NodeViewParts for (T1, T2)concatenates its elements with no separator, so the server renders$(pair)as1.52.5, whileString(array)attext.ts:55would give1.5,2.5. Attribute values have the same shape:AttributeValueViewParts for (T1, T2)concatenates, and is present when any element is present.