update solid compiler

This commit is contained in:
2026-07-15 11:28:24 -04:00
parent f1745b8a1b
commit 4e378842e4
8 changed files with 255 additions and 38 deletions

View File

@@ -4,8 +4,9 @@ package jsbundler
//
// Strategy (behavior-parity with babel-preset-solid, not byte-parity):
// - Static element structure is baked into an HTML template string cloned at
// runtime via _$template(); we always quote attrs and emit closing tags
// (valid HTML the browser parses to the same DOM babel's terser template does).
// runtime via _$template(); we always quote attrs and emit closing tags,
// except for void elements (br, img, input, ...) which never get one —
// see voidElements.
// - Dynamic children -> _$insert(parent, () => expr, marker). Every dynamic
// expression is wrapped in a thunk: correct and (for static exprs) merely an
// extra no-op effect. babel unwraps `f()` -> `f` as an optimization; we skip
@@ -461,11 +462,16 @@ func (g *solidGen) genElement(node *jsxNode) string {
return tv + "()"
}
var b strings.Builder
b.WriteString("(() => { var " + strings.Join(c.decls, ", ") + "; ")
b.WriteString("(() => { var ")
b.WriteString(strings.Join(c.decls, ", "))
b.WriteString("; ")
for _, op := range c.ops {
b.WriteString(op + "; ")
b.WriteString(op)
b.WriteString("; ")
}
b.WriteString("return " + root + "; })()")
b.WriteString("return ")
b.WriteString(root)
b.WriteString("; })()")
return b.String()
}
@@ -566,7 +572,7 @@ func exprIsEmpty(expr string) bool {
func (c *iife) assignChildRefs(node *jsxNode, parentRef string) map[int]string {
refs := map[int]string{}
// Which children need a ref?
need := func(i int, ch *jsxNode) bool {
need := func(_ int, ch *jsxNode) bool {
switch ch.kind {
case jsxElement:
return elementNeedsRef(ch)
@@ -644,6 +650,14 @@ func (c *iife) attr(ref string, a jsxAttr) {
// innerHTML icon content would never appear). Assign the property.
c.ops = append(c.ops, fmt.Sprintf("%s(() => %s.%s = %s)",
c.g.helper("effect"), ref, a.name, expr))
case booleanAttrs[a.name]:
// HTML boolean attributes are presence-based: setAttribute(name,
// String(value)) would emit `checked="false"`, which the browser
// parses as present (any string value) -> checked stays true. Use
// _$setBoolAttribute, which adds the attribute (empty) when truthy
// and removes it when falsy.
c.ops = append(c.ops, fmt.Sprintf("%s(() => %s(%s, %q, %s))",
c.g.helper("effect"), c.g.helper("setBoolAttribute"), ref, a.name, expr))
default:
c.ops = append(c.ops, fmt.Sprintf("%s(() => %s(%s, %q, %s))",
c.g.helper("effect"), c.g.helper("setAttribute"), ref, a.name, expr))
@@ -725,30 +739,44 @@ func (g *solidGen) genComponent(node *jsxNode) string {
}
// componentProps builds the props object: static attrs as plain values, dynamic
// attrs as reactive getters, and children as a `children` prop.
// attrs as reactive getters, children as a `children` prop, and any {...spread}
// threaded through the mergeProps helper (mirroring spread(), preserving source
// order so later props override earlier ones, matching babel).
func (g *solidGen) componentProps(node *jsxNode) string {
var parts []string
var args []string
var obj []string
flush := func() {
if len(obj) > 0 {
args = append(args, "{ "+strings.Join(obj, ", ")+" }")
obj = nil
}
}
for _, a := range node.attrs {
switch a.kind {
case attrStatic:
if a.boolt {
parts = append(parts, fmt.Sprintf("%s: true", propKey(a.name)))
obj = append(obj, fmt.Sprintf("%s: true", propKey(a.name)))
} else {
parts = append(parts, fmt.Sprintf("%s: %s", propKey(a.name), jsStringLit(a.value)))
obj = append(obj, fmt.Sprintf("%s: %s", propKey(a.name), jsStringLit(a.value)))
}
case attrExpr:
parts = append(parts, fmt.Sprintf("get %s() { return %s; }", propKey(a.name), g.compileExpr(a.expr)))
obj = append(obj, fmt.Sprintf("get %s() { return %s; }", propKey(a.name), g.compileExpr(a.expr)))
case attrSpread:
// milestone 3 (mergeProps)
flush()
args = append(args, g.compileExpr(a.expr))
}
}
if ch := g.childrenProp(node); ch != "" {
parts = append(parts, ch)
obj = append(obj, ch)
}
if len(parts) == 0 {
flush()
if len(args) == 0 {
return "{}"
}
return "{ " + strings.Join(parts, ", ") + " }"
if len(args) == 1 {
return args[0]
}
return fmt.Sprintf("%s(%s)", g.helper("mergeProps"), strings.Join(args, ", "))
}
// childrenProp builds a component's `children` prop entry. JSX children are
@@ -786,8 +814,8 @@ func (g *solidGen) genFragment(node *jsxNode) string {
var kids []*jsxNode
for i := range node.children {
ch := &node.children[i]
if ch.kind == jsxText && strings.TrimSpace(ch.text) == "" {
continue
if !renderedChild(ch) {
continue // drop whitespace-only text and comment-only expressions ({/* ... */})
}
kids = append(kids, ch)
}
@@ -807,6 +835,21 @@ func (g *solidGen) genFragment(node *jsxNode) string {
// ---- template building -----------------------------------------------------
// voidElements are HTML elements that can never have a closing tag or
// children. Writing one out as `<tag></tag>` isn't just redundant — for `br`
// specifically it's actively wrong: the HTML parser doesn't ignore a stray
// `</br>` like it does for other unmatched end tags, it's spec'd to act as
// *another* `<br>` start tag. That silently inserts an extra DOM node,
// shifting every subsequent .firstChild/.nextSibling in the compiled
// navigation chain by one and crashing template hydration. Matches the list
// solid-js/html's own VOID_ELEMENTS uses (frontend/vendor/solid-js/html).
var voidElements = map[string]bool{
"area": true, "base": true, "br": true, "col": true, "embed": true,
"hr": true, "img": true, "input": true, "keygen": true, "link": true,
"menuitem": true, "meta": true, "param": true, "source": true,
"track": true, "wbr": true,
}
func buildTemplate(node *jsxNode) string {
var sb strings.Builder
writeTemplate(node, &sb)
@@ -814,19 +857,28 @@ func buildTemplate(node *jsxNode) string {
}
func writeTemplate(node *jsxNode, sb *strings.Builder) {
sb.WriteString("<" + node.tag)
sb.WriteString("<")
sb.WriteString(node.tag)
if !hasSpread(node) { // with a spread, all attrs are applied at runtime
for _, a := range node.attrs {
if a.kind == attrStatic {
if a.boolt {
sb.WriteString(" " + a.name)
sb.WriteString(" ")
sb.WriteString(a.name)
} else {
sb.WriteString(" " + a.name + `="` + a.value + `"`)
sb.WriteString(" ")
sb.WriteString(a.name)
sb.WriteString(`="`)
sb.WriteString(a.value)
sb.WriteString(`"`)
}
}
}
}
sb.WriteString(">")
if voidElements[strings.ToLower(node.tag)] {
return // no children, no closing tag
}
for i := range node.children {
ch := &node.children[i]
switch ch.kind {
@@ -840,7 +892,9 @@ func writeTemplate(node *jsxNode, sb *strings.Builder) {
}
}
}
sb.WriteString("</" + node.tag + ">")
sb.WriteString("</")
sb.WriteString(node.tag)
sb.WriteString(">")
}
// annotateMarkers sets node.marker on dynamic children that need a `<!>` anchor:
@@ -904,6 +958,21 @@ func isStaticLiteral(e string) bool {
// direct property assignment rather than setAttribute.
var contentProps = map[string]bool{"innerHTML": true, "textContent": true, "innerText": true}
// booleanAttrs are HTML boolean attributes: presence means true regardless of
// the attribute's string value, so a naive setAttribute(name, String(false))
// (which emits e.g. checked="false") leaves the attribute present and the
// browser reads it as true. These must go through setBoolAttribute instead,
// which adds/removes the attribute based on the JS value's truthiness.
var booleanAttrs = map[string]bool{
"allowfullscreen": true, "async": true, "autofocus": true, "autoplay": true,
"checked": true, "controls": true, "default": true, "defer": true,
"disabled": true, "formnovalidate": true, "hidden": true, "indeterminate": true,
"ismap": true, "loop": true, "multiple": true, "muted": true,
"nomodule": true, "novalidate": true, "open": true, "playsinline": true,
"readonly": true, "required": true, "reversed": true, "seamless": true,
"selected": true,
}
func isEventAttr(name string) bool {
// An event handler is `on` followed by the event name; casing after `on` is
// irrelevant (onClick and onclick both mean click) — the name is lowercased

View File

@@ -151,6 +151,9 @@ func TestGoCompilerRenderSpreadRef(t *testing.T) {
{"spread", `export const A = () => { const p = { id: "pid", title: "t" }; return <div {...p} class="x">hi</div>; };`},
{"spread-override", `export const A = () => { const p = { class: "from-p" }; return <div {...p} class="from-attr">hi</div>; };`},
{"ref", `export const A = () => { let r; return <div ref={r}>hi</div>; };`},
{"component-spread", `export const A = () => { const Box = (props) => <div id={props.id} title={props.title}>{props.children}</div>; const p = { id: "pid", title: "t" }; return <Box {...p}>hi</Box>; };`},
{"component-spread-override", `export const A = () => { const Box = (props) => <div class={props.class}>x</div>; const p = { class: "from-p" }; return <Box {...p} class="from-attr" />; };`},
{"component-spread-before", `export const A = () => { const Box = (props) => <div class={props.class}>x</div>; const p = { class: "from-p" }; return <Box class="from-attr" {...p} />; };`},
}
for _, c := range cases {
c := c
@@ -299,3 +302,71 @@ func TestGoCompilerStyleAndInnerHTML(t *testing.T) {
})
}
}
// Regression: HTML boolean attributes (checked, disabled, required, readonly,
// hidden, selected, ...) are presence-based — any attribute value, including
// the string "false", still counts as present. A dynamic false must go through
// setBoolAttribute (which removes the attribute), not setAttribute (which would
// emit e.g. checked="false", read by the browser as checked=true).
func TestGoCompilerBooleanAttrs(t *testing.T) {
root, _ := filepath.Abs("../..")
t.Chdir(root)
src := `export const A = () => {
const no = () => false;
const yes = () => true;
return <div>
<input type="checkbox" checked={no()} disabled={no()} />
<input type="radio" checked={yes()} required={yes()} />
</div>;
};`
out, err := compileSolidGo(src, "bools.tsx", false)
if err != nil {
t.Fatal(err)
}
html, err := renderComponent(t, out)
if err != nil {
t.Fatalf("render: %v\n%s", err, out)
}
for _, broken := range []string{`checked="false"`, `disabled="false"`} {
if strings.Contains(html, broken) {
t.Errorf("false-valued boolean attr still present (reads as true in browser): %q in %s", broken, html)
}
}
for _, present := range []string{`checked=""`, `required=""`} {
if !strings.Contains(html, present) {
t.Errorf("true-valued boolean attr missing: expected %q in %s", present, html)
}
}
}
// Regression: a comment-only JSX expression child ({/* note */}) must be
// dropped like whitespace, not compiled as a real expression. childrenProp
// (component children) already filtered these via renderedChild, but a bare
// root fragment with 2+ children went through genFragment's own filter, which
// only skipped blank text — a comment-only child slipped through and compiled
// to `_$memo(() => /* note */)`, a syntax error that breaks the whole module.
func TestGoCompilerFragmentCommentChild(t *testing.T) {
root, _ := filepath.Abs("../..")
t.Chdir(root)
src := `export const A = () => {
return <>
{/* a comment */}
<div>one</div>
<div>two</div>
</>;
};`
out, err := compileSolidGo(src, "frag-comment.tsx", false)
if err != nil {
t.Fatal(err)
}
if err := validateJS(out); err != nil {
t.Fatalf("compiled output doesn't parse: %v\n--- output ---\n%s", err, out)
}
html, err := renderComponent(t, out)
if err != nil {
t.Fatalf("render: %v\n%s", err, out)
}
if !strings.Contains(html, "one") || !strings.Contains(html, "two") {
t.Errorf("expected both siblings in output, got: %q", html)
}
}

View File

@@ -115,20 +115,20 @@ func TestDevServerEndToEnd(t *testing.T) {
}
// --- SPA entry: hot bootstrap + bare imports kept + relative imports rewritten
code, app := get(srcURLPrefix + "app.ts")
code, app := get(srcURLPrefix + "app.tsx")
if code != 200 {
t.Fatalf("app.ts status %d:\n%s", code, app)
t.Fatalf("app.tsx status %d:\n%s", code, app)
}
for _, want := range []string{
`__createHotContext("/@src/app.ts")`, // hot bootstrap
`__createHotContext("/@src/app.tsx")`, // hot bootstrap
`/@hmr/client`, // client import injected
`"solid-js/web"`, // bare specifier preserved for import map
`"@solidjs/router"`, // bare specifier preserved
`/@src/routes/app-routes.ts`, // relative import rewritten
`/@src/layouts/AppLayout.ts`, // relative import rewritten
`/@src/layouts/AppLayout.tsx`, // relative import rewritten
} {
if !strings.Contains(app, want) {
t.Errorf("app.ts missing %q", want)
t.Errorf("app.tsx missing %q", want)
}
}

View File

@@ -65,6 +65,17 @@ func StartDevHMR(mux *http.ServeMux, cfg Config) (importMap string, err error) {
return "", err
}
d.register(mux)
// The watcher's seed scan only records starting mtimes and never fires
// onchange (see watch()), so anything already referenced in source before
// this boot — e.g. an icon name — would otherwise sit missing from the
// generated registry until some later edit happened to retrigger it. Run it
// once up front so a bare `go run -tags dev ./cmd/server` (skipping the
// "Bundle: Build" preLaunchTask) still starts consistent.
if err := generateFAIcons(); err != nil {
fmt.Fprintf(os.Stderr, "[hmr] generating FA icons: %v\n", err)
}
go d.watch()
fmt.Println("HMR dev server: serving native-ESM source from /@src/, WebSocket at /@hmr/ws")

View File

@@ -75,8 +75,10 @@ func (d *devServer) scan(roots []string, mtimes map[string]time.Time, onchange f
func (d *devServer) handleChanges(changed []string) {
pagesManifestAbs := filepath.Join(d.frontend, filepath.FromSlash(pagesManifest))
faIconsOutAbs := filepath.Join(d.frontend, "src", "ui", "generated", "faIcons.ts")
cssDirty := false
srcDirty := false
iconsDirty := false
for _, p := range changed {
base := filepath.Base(p)
@@ -102,9 +104,23 @@ func (d *devServer) handleChanges(changed []string) {
d.hmrJS(p)
cssDirty = true // a class may have been added/removed
srcDirty = true
// The icon registry is itself a generated .ts file under srcRoot, so its
// own hot-reload (above) must not re-trigger a regeneration pass — that
// would bump its mtime and loop forever.
if p != faIconsOutAbs {
iconsDirty = true
}
}
}
if iconsDirty {
// A source edit may have referenced a new icon name; rescan so it's
// available without a manual `go run ./cmd/bundle`. The regenerated file's
// own mtime change is picked up and hot-reloaded on the next poll.
if err := generateFAIcons(); err != nil {
fmt.Fprintf(os.Stderr, "[hmr] regenerating FA icons: %v\n", err)
}
}
if cssDirty {
select {
case d.cssTrigger <- struct{}{}:

View File

@@ -26,15 +26,15 @@ func esbuildDefine() map[string]string {
}
// resolveEntryPoint returns the path (relative to frontendDir) of the
// SPA entry point, preferring app.ts over app.js.
// SPA entry point, preferring app.tsx over app.js.
func resolveEntryPoint() string {
if _, err := os.Stat(filepath.Join(frontendDir, "src/app.ts")); err == nil {
return "src/app.ts"
if _, err := os.Stat(filepath.Join(frontendDir, "src/app.tsx")); err == nil {
return "src/app.tsx"
}
return "src/app.js"
}
// bundleJS bundles the SPA entry (app.ts/app.js) into bundle.min.js.
// bundleJS bundles the SPA entry (app.tsx/app.js) into bundle.min.js.
func bundleJS() (bundleStats, error) {
return bundleJSEntry(filepath.Join(frontendDir, resolveEntryPoint()), "bundle.min.js")
}
@@ -145,7 +145,7 @@ func bundleJSEntry(entry, outName string) (bundleStats, error) {
// drop leading `../` segments. esbuild writes paths relative to the output
// file's directory; since the bundle lives in wwwroot/ and the sources live
// in frontend/, every entry starts with `../frontend/`. Stripping the prefix
// yields project-rooted paths like `frontend/src/app.ts`.
// yields project-rooted paths like `frontend/src/app.tsx`.
func stripSourcemapParentPrefix(mapPath string) error {
data, err := os.ReadFile(mapPath)
if err != nil {

View File

@@ -4435,7 +4435,7 @@ func (t *Theme) keysInNamespaces(themeKeys []string) []string {
if !strings.HasPrefix(key, prefix) {
continue
}
if strings.Index(key[2:], "--") != -1 {
if strings.Contains(key[2:], "--") {
continue
}
if isIgnoredThemeKey(key, namespace) {
@@ -8989,7 +8989,7 @@ func createVariants(theme *Theme) *Variants {
registerCompoundVariants(variants, theme)
registerPseudoVariants(variants)
registerFunctionalVariants(variants, theme)
registerFunctionalVariants(variants)
registerBreakpointVariants(variants, theme)
registerMediaVariants(variants)
@@ -9362,7 +9362,7 @@ func registerPseudoVariants(variants *Variants) {
sv("inert", "&:is([inert], [inert] *)")
}
func registerFunctionalVariants(variants *Variants, theme *Theme) {
func registerFunctionalVariants(variants *Variants) {
variants.functional("aria", func(r *AstNode, variant *Variant) bool {
if variant.Value == nil || variant.Modifier != nil {
return false

View File

@@ -198,6 +198,56 @@ func ValidateZipCode(zip string) error {
return nil
}
func ValidateEmail(email string) error {
err := errors.New("Invalid email address.")
if email == "" {
return err
}
emailParts := strings.Split(email, "@")
if len(emailParts) != 2 {
return err
}
account := emailParts[0]
address := emailParts[1]
lenErr := errors.New("Email address is too long.")
if len(account) > 64 {
return lenErr
} else if len(address) > 255 {
return lenErr
}
domainParts := strings.Split(address, ".")
for _, part := range domainParts {
if len(part) > 63 {
return lenErr
}
}
re := regexp.MustCompile(`^[-!#$%&'*+/0-9=?A-Z^_a-z` + "`" + `{|}~](\.?[-!#$%&'*+/0-9=?A-Z^_a-z` + "`" + `{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$`)
if !re.MatchString(email) {
return err
}
return nil
}
func ValidateUsername(username string) error {
if len(username) < 5 || len(username) > 50 {
return errors.New("Username must have 5-50 characters.")
}
isAlphanumeric, _ := regexp.MatchString(`^[A-Za-z0-9]*$`, username)
if !isAlphanumeric {
return errors.New("Username must only contain alphanumeric characters.")
}
return nil
}
func ValidateUrl(url string) error {
err := errors.New("Invalid URL.")