Fix spread serialization in aser/async-aser wire format

Spread values from make-spread were crashing the wire format serializer:
- serialize() had no "spread" case, fell through to (str val) producing
  Python repr "<shared.sx.ref.sx_ref._Spread...>" which was treated as
  an undefined symbol
- aser-call/async-aser-call didn't handle spread children — now merges
  spread attrs as keyword args into the parent element
- aser-fragment/async-aser-fragment didn't filter spreads — now filters
  them (fragments have no parent element to merge into)
- serialize() now handles spread type: (make-spread {:key "val"})

Added 3 aser-spreads tests. All 562 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 12:20:16 +00:00
parent 427dee13f0
commit 859aad4333
6 changed files with 88 additions and 33 deletions

View File

@@ -14,7 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-13T11:02:33Z";
var SX_VERSION = "2026-03-13T12:16:43Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -1510,7 +1510,7 @@ continue; } else { return NIL; } } };
})(); };
// sx-serialize
var sxSerialize = function(val) { return (function() { var _m = typeOf(val); if (_m == "nil") return "nil"; if (_m == "boolean") return (isSxTruthy(val) ? "true" : "false"); if (_m == "number") return (String(val)); if (_m == "string") return (String("\"") + String(escapeString(val)) + String("\"")); if (_m == "symbol") return symbolName(val); if (_m == "keyword") return (String(":") + String(keywordName(val))); if (_m == "list") return (String("(") + String(join(" ", map(sxSerialize, val))) + String(")")); if (_m == "dict") return sxSerializeDict(val); if (_m == "sx-expr") return sxExprSource(val); return (String(val)); })(); };
var sxSerialize = function(val) { return (function() { var _m = typeOf(val); if (_m == "nil") return "nil"; if (_m == "boolean") return (isSxTruthy(val) ? "true" : "false"); if (_m == "number") return (String(val)); if (_m == "string") return (String("\"") + String(escapeString(val)) + String("\"")); if (_m == "symbol") return symbolName(val); if (_m == "keyword") return (String(":") + String(keywordName(val))); if (_m == "list") return (String("(") + String(join(" ", map(sxSerialize, val))) + String(")")); if (_m == "dict") return sxSerializeDict(val); if (_m == "sx-expr") return sxExprSource(val); if (_m == "spread") return (String("(make-spread ") + String(sxSerializeDict(spreadAttrs(val))) + String(")")); return (String(val)); })(); };
// sx-serialize-dict
var sxSerializeDict = function(d) { return (String("{") + String(join(" ", reduce(function(acc, key) { return concat(acc, [(String(":") + String(key)), sxSerialize(dictGet(d, key))]); }, [], keys(d)))) + String("}")); };
@@ -1786,7 +1786,11 @@ return (function() { var _m = typeOf(expr); if (_m == "number") return expr; if
})() : (function() {
var val = aser(arg, env);
if (isSxTruthy(!isSxTruthy(isNil(val)))) {
(isSxTruthy((typeOf(val) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? append_b(parts, serialize(item)) : NIL); }, val) : append_b(parts, serialize(val)));
(isSxTruthy(isSpread(val)) ? forEach(function(k) { return (function() {
var v = dictGet(spreadAttrs(val), k);
parts.push((String(":") + String(k)));
return append_b(parts, serialize(v));
})(); }, keys(spreadAttrs(val))) : (isSxTruthy((typeOf(val) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? append_b(parts, serialize(item)) : NIL); }, val) : append_b(parts, serialize(val))));
}
return (i = (i + 1));
})())); } }

View File

@@ -775,6 +775,7 @@
(define-async async-aser-fragment :effects [render io]
(fn ((children :as list) (env :as dict) ctx)
;; Spreads are filtered — fragments have no parent element to merge into
(let ((parts (list)))
(for-each
(fn (c)
@@ -782,10 +783,10 @@
(if (= (type-of result) "list")
(for-each
(fn (item)
(when (not (nil? item))
(when (and (not (nil? item)) (not (spread? item)))
(append! parts (serialize item))))
result)
(when (not (nil? result))
(when (and (not (nil? result)) (not (spread? result)))
(append! parts (serialize result))))))
children)
(if (empty? parts)
@@ -885,13 +886,21 @@
(set! i (inc i)))
(let ((result (async-aser arg env ctx)))
(when (not (nil? result))
(if (= (type-of result) "list")
(if (spread? result)
;; Spread child — merge attrs as keyword args into parent element
(for-each
(fn (item)
(when (not (nil? item))
(append! parts (serialize item))))
result)
(append! parts (serialize result))))
(fn (k)
(let ((v (dict-get (spread-attrs result) k)))
(append! parts (str ":" k))
(append! parts (serialize v))))
(keys (spread-attrs result)))
(if (= (type-of result) "list")
(for-each
(fn (item)
(when (not (nil? item))
(append! parts (serialize item))))
result)
(append! parts (serialize result)))))
(set! i (inc i))))))
args)
(when token (svg-context-reset! token))

View File

@@ -110,6 +110,7 @@
(fn ((children :as list) (env :as dict))
;; Serialize (<> child1 child2 ...) to sx source string
;; Must flatten list results (e.g. from map/filter) to avoid nested parens
;; Spreads are filtered — fragments have no parent element to merge into
(let ((parts (list)))
(for-each
(fn (c)
@@ -117,10 +118,10 @@
(if (= (type-of result) "list")
(for-each
(fn (item)
(when (not (nil? item))
(when (and (not (nil? item)) (not (spread? item)))
(append! parts (serialize item))))
result)
(when (not (nil? result))
(when (and (not (nil? result)) (not (spread? result)))
(append! parts (serialize result))))))
children)
(if (empty? parts)
@@ -151,13 +152,21 @@
(set! i (inc i)))
(let ((val (aser arg env)))
(when (not (nil? val))
(if (= (type-of val) "list")
(if (spread? val)
;; Spread child — merge attrs as keyword args into parent element
(for-each
(fn (item)
(when (not (nil? item))
(append! parts (serialize item))))
val)
(append! parts (serialize val))))
(fn (k)
(let ((v (dict-get (spread-attrs val) k)))
(append! parts (str ":" k))
(append! parts (serialize v))))
(keys (spread-attrs val)))
(if (= (type-of val) "list")
(for-each
(fn (item)
(when (not (nil? item))
(append! parts (serialize item))))
val)
(append! parts (serialize val)))))
(set! i (inc i))))))
args)
(str "(" (join " " parts) ")"))))

View File

@@ -366,6 +366,7 @@
"list" (str "(" (join " " (map sx-serialize val)) ")")
"dict" (sx-serialize-dict val)
"sx-expr" (sx-expr-source val)
"spread" (str "(make-spread " (sx-serialize-dict (spread-attrs val)) ")")
:else (str val))))

View File

@@ -1,3 +1,5 @@
# WARNING: special-forms.sx declares forms not in eval.sx: reset, shift
# WARNING: eval.sx dispatches forms not in special-forms.sx: form?, provide
"""
sx_ref.py -- Generated from reference SX evaluator specification.
@@ -2559,10 +2561,10 @@ def aser_fragment(children, env):
result = aser(c, env)
if sx_truthy((type_of(result) == 'list')):
for item in result:
if sx_truthy((not sx_truthy(is_nil(item)))):
if sx_truthy(((not sx_truthy(is_nil(item))) if not sx_truthy((not sx_truthy(is_nil(item)))) else (not sx_truthy(is_spread(item))))):
parts.append(serialize(item))
else:
if sx_truthy((not sx_truthy(is_nil(result)))):
if sx_truthy(((not sx_truthy(is_nil(result))) if not sx_truthy((not sx_truthy(is_nil(result)))) else (not sx_truthy(is_spread(result))))):
parts.append(serialize(result))
if sx_truthy(empty_p(parts)):
return ''
@@ -2590,12 +2592,18 @@ def aser_call(name, args, env):
else:
val = aser(arg, env)
if sx_truthy((not sx_truthy(is_nil(val)))):
if sx_truthy((type_of(val) == 'list')):
for item in val:
if sx_truthy((not sx_truthy(is_nil(item)))):
parts.append(serialize(item))
if sx_truthy(is_spread(val)):
for k in keys(spread_attrs(val)):
v = dict_get(spread_attrs(val), k)
parts.append(sx_str(':', k))
parts.append(serialize(v))
else:
parts.append(serialize(val))
if sx_truthy((type_of(val) == 'list')):
for item in val:
if sx_truthy((not sx_truthy(is_nil(item)))):
parts.append(serialize(item))
else:
parts.append(serialize(val))
_cells['i'] = (_cells['i'] + 1)
return sx_str('(', join(' ', parts), ')')
@@ -4140,10 +4148,10 @@ async def async_aser_fragment(children, env, ctx):
result = (await async_aser(c, env, ctx))
if sx_truthy((type_of(result) == 'list')):
for item in result:
if sx_truthy((not sx_truthy(is_nil(item)))):
if sx_truthy(((not sx_truthy(is_nil(item))) if not sx_truthy((not sx_truthy(is_nil(item)))) else (not sx_truthy(is_spread(item))))):
parts.append(serialize(item))
else:
if sx_truthy((not sx_truthy(is_nil(result)))):
if sx_truthy(((not sx_truthy(is_nil(result))) if not sx_truthy((not sx_truthy(is_nil(result)))) else (not sx_truthy(is_spread(result))))):
parts.append(serialize(result))
if sx_truthy(empty_p(parts)):
return make_sx_expr('')
@@ -4225,12 +4233,18 @@ async def async_aser_call(name, args, env, ctx):
else:
result = (await async_aser(arg, env, ctx))
if sx_truthy((not sx_truthy(is_nil(result)))):
if sx_truthy((type_of(result) == 'list')):
for item in result:
if sx_truthy((not sx_truthy(is_nil(item)))):
parts.append(serialize(item))
if sx_truthy(is_spread(result)):
for k in keys(spread_attrs(result)):
v = dict_get(spread_attrs(result), k)
parts.append(sx_str(':', k))
parts.append(serialize(v))
else:
parts.append(serialize(result))
if sx_truthy((type_of(result) == 'list')):
for item in result:
if sx_truthy((not sx_truthy(is_nil(item)))):
parts.append(serialize(item))
else:
parts.append(serialize(result))
_cells['i'] = (_cells['i'] + 1)
if sx_truthy(token):
svg_context_reset(token)

View File

@@ -270,3 +270,21 @@
(deftest "or returns last falsy"
(assert-equal "false"
(render-sx "(or false false)"))))
;; --------------------------------------------------------------------------
;; Spread serialization
;; --------------------------------------------------------------------------
(defsuite "aser-spreads"
(deftest "spread in element merges attrs"
(assert-equal "(div :class \"card\" \"hello\")"
(render-sx "(div (make-spread {:class \"card\"}) \"hello\")")))
(deftest "multiple spreads merge into element"
(assert-equal "(div :class \"card\" :style \"color:red\" \"hello\")"
(render-sx "(div (make-spread {:class \"card\"}) (make-spread {:style \"color:red\"}) \"hello\")")))
(deftest "spread in fragment is filtered"
(assert-equal "(<> \"hello\")"
(render-sx "(<> (make-spread {:class \"card\"}) \"hello\")"))))