Skip to content

[Demo, not for merge] Solid TSX frontend checked by ReactiveScript - #21

Draft
devagrawal09 wants to merge 1 commit into
ryansolid:mainfrom
devagrawal09:tsx-frontend-demo
Draft

devagrawal09 wants to merge 1 commit into
ryansolid:mainfrom
devagrawal09:tsx-frontend-demo

Conversation

@devagrawal09

Copy link
Copy Markdown
Collaborator

Demo, not for merge. Context for #1 and #20.

Authors write ordinary Solid 2 TSX. A frontend lowers it into the existing RX AST, and the RX checker, interpreter and compiler run on it unchanged. Accepted TSX must behave exactly as native Solid; anything RX would interpret differently is refused, never silently rewritten.

Example: an issue tracker

A store, a filter, derived memos, Show/For, a child component with a callback prop, and an optimistic action with rollback:

import { createSignal, createOptimistic, createStore, createMemo, action, Show, For } from "solid-js";
import { assignIssue } from "./api";

export default function Tracker() {
  const [issues, setIssues] = createStore({
    list: [
      { id: 1, title: "Crash on save", assignee: "" },
      { id: 2, title: "Slow search", assignee: "ana" },
      { id: 3, title: "Typo in footer", assignee: "" },
    ],
  });
  const [mode, setMode] = createSignal("all");
  const [claiming, setClaiming] = createOptimistic(() => 0);
  const [status, setStatus] = createSignal("");

  const visible = createMemo(() =>
    issues.list.filter((issue) => mode() === "all" || issue.assignee === ""),
  );
  const unassigned = createMemo(() => issues.list.filter((issue) => issue.assignee === "").length);

  // Show the claim at once; commit the server's answer, or roll back and explain.
  const claim = action(function* (id: number) {
    setClaiming(id);
    try {
      const owner: string = yield assignIssue(id);
      setIssues((draft) => {
        draft.list[id - 1].assignee = owner; // ids are 1-based list positions here
      });
      setStatus("");
    } catch (error) {
      setStatus("Could not assign #" + id);
    }
  });

  return (
    <section>
      <button onClick={() => setMode(mode() === "all" ? "open" : "all")}>Filter: {mode()}</button>
      <b>{unassigned()} open</b>
      <Show when={visible().length > 0} fallback={<i>Nothing to triage</i>}>
        <For each={visible()}>
          {(issue) => (
            <IssueRow issue={issue} claiming={claiming() === issue.id} onAssign={() => claim(issue.id)} />
          )}
        </For>
      </Show>
      <em>{status()}</em>
    </section>
  );
}

function IssueRow(props: {
  issue: { id: number; title: string; assignee: string };
  claiming: boolean;
  onAssign: () => void;
}) {
  return (
    <p>
      {props.issue.title}: {props.claiming ? "me" : props.issue.assignee || "nobody"}
      <button onClick={() => props.onAssign()}>take</button>
    </p>
  );
}
The same component, handwritten in RX
import { assignIssue } from "./api";

export default component Tracker() {
  source issues := {
    list: [
      { id: 1, title: "Crash on save", assignee: "" },
      { id: 2, title: "Slow search", assignee: "ana" },
      { id: 3, title: "Typo in footer", assignee: "" },
    ],
  };
  source mode := "all";
  source claiming = 0;
  source status := "";

  visible = filter(issues.list, issue => mode == "all" || issue.assignee == "");
  unassigned = filter(issues.list, issue => issue.assignee == "").length;

  action claim(id: number) {
    optimistic claiming := id;
    try {
      let owner = await assignIssue(id);
      issues.list[id - 1].assignee := owner;
      status := "";
    } catch (error) {
      status := "Could not assign #" + id;
    }
  }

  <section>
    <button onClick={() => { mode := mode == "all" ? "open" : "all" }}>Filter: {mode}</button>
    <b>{"" + unassigned} open</b>
    {if visible.length > 0 {
      for issue in visible { <IssueRow issue={issue} claiming={claiming == issue.id} onAssign={() => { claim(issue.id) }}/> }
    } else { <i>Nothing to triage</i> }}
    <em>{status}</em>
  </section>
}

component IssueRow(props: { issue: { id: number; title: string; assignee: string }; claiming: boolean; onAssign: action catches(nothing) }) {
  <p>{props.issue.title}: {if props.claiming { {"me"} } else { {props.issue.assignee || "nobody"} }}<button onClick={() => { props.onAssign() }}>take</button></p>
}

tsx/demo.test.ts checks that:

  • the TSX and the handwritten RX produce identical checker facts, and snapshotting mode in the RX shows up as a difference;
  • five runs render the same eight frames: native Solid, compiled TSX, compiled RX, interpreted TSX and interpreted RX.
initial → claim #2 shows "me" → #2 fails: rolls back, "Could not assign #2"
→ filter "open" → claim #1 → #1 succeeds, leaves the view
→ claim #3 → #3 succeeds → "Nothing to triage"

Known rough edges: rows are written by position (list[id - 1]) because the frontend doesn't pass For indexes yet; the optimistic value is the claimed id rather than a per-row assignee; and the script never retries an issue, because a repeated request with the same arguments reuses the earlier rejected answer.

Refusing instead of changing the meaning

An async callback is refused at async (#20). In Solid, a write before the await takes effect immediately; RX would infer an action that holds it. The diagnostic suggests action(function* () { … yield … }) and says plainly that this opts into held writes. Plain createEffect, object-valued createSignal, and a signal named filter (which shadows a built-in) are refused the same way.

What it unlocks

  • RX's checks on existing Solid code, adopted one file at a time; .tsx and .rx modules already import each other.
  • Differential fuzzing: generate TSX and compare native Solid, the RX interpreter and the RX compiler. Hand-written pairs have already found upstream bugs.
  • Checking refactors and codemods: identical facts before and after a change means reactivity was preserved.
  • An eval arm: TSX with the RX checker vs .rx vs React. Is it the checks or the syntax that drives correctness?
  • Bug checks without new syntax: checks like AI eval rounds 4-8; compiler checks STALE_WRITE_BACK (AC-1e) and UNGUARDED_SNAPSHOT (R-8) #18's STALE_WRITE_BACK would catch the same bug in plain TSX.

Status

Verified parity subset npm run verify:subset: 21/21 (tracker, counter, composition, boundary contracts)
Port of the full original prototype suite Not ready: 472/568 pass with one proposed core fix (patches/0001, not applied here); failures classified in docs/RESULTS.md
Footprint Separate package; only tsx/rx/ imports RX internals; no AST, syntax or inference changes; pinned to aa85cbd

Setup and commands: tsx-frontend/README.md → Running it.

Questions

  1. Is "lower into the existing AST, refuse whatever doesn't match the host" a reasonable way to explore other frontends?
  2. Explicit ordinary async callbacks alongside inferred actions? #20: should ordinary async callbacks get an explicit form, or should TSX keep refusing them?

An Oxc TSX frontend that lowers authored Solid 2 TSX onto ref/'s existing Program and checker, with no shared
AST changes. It is added as tsx-frontend/ on top of aa85cbd; nothing outside that directory changes.

npm run verify:subset (21 tests) checks the typecheck, pin/adapter boundary contracts, a paired counter, component
composition, and an issue-tracker demo (tsx/fixtures/demo/tracker.{tsx,rx}): equal canonical checker facts with
handwritten RX, and identical frames from native Solid, the RX compiler and the RX interpreter, including an
explicit generator action with optimistic claim, commit and rollback. Ordinary async callbacks are refused for
now pending ryansolid#20.

This is a subset demo, not a parity claim: the full suite still has failures, classified in
tsx-frontend/docs/RESULTS.md (measured out of tree). See tsx-frontend/README.md, Running it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant