Conversation
Collaborator
…stale useMemo divergence Five new differential test files (~84 cases) pin React parity for components+ context, refs/effects (mostly skipped — gated on compile-emission blockers), control-flow directives, attribute shapes / namespace inheritance, and callback-shaped hooks. The expanded coverage caught a real compiler bug: mixed children with a Component preceding a static-element sibling rendered in reverse DOM order because componentSlot was appending to the host AFTER the static template content. Fixed by emitting a `<!>` anchor placeholder at the component's source-order index and passing it to componentSlot so insertion happens BEFORE the anchor — preserving source order. Also closes the useMemo factory-replay "divergence" — empirical inspection proved the factory runs exactly once across replay attempts (matches React), so the docs were stale. Test assertion tightened from `toBeGreaterThanOrEqual(1)` to `toBe(1)` to lock parity in.
…Block Yesterday's component-anchor fix (emit `<!>` placeholder + insertBefore) applied the same pattern to componentSlot, which already accepted an optional `anchor` parameter. The four control-flow block types had the analogous bug: when an @for / @if / @switch / @Try appeared before static-element siblings in a mixed-children parent, the block's start/end markers were appended to the host AFTER the static template content — flipping visible DOM order. Runtime: each block now takes an optional trailing `anchor?: Node | null` and uses `domParent.insertBefore(start, anchor ?? null)` for marker placement. Existing callers don't break (undefined anchor = null = appendChild, preserving current behaviour). Inner mountItem / reconcileKeyed / branch-swap paths use state.end as their insertBefore anchor so they're unaffected. Compiler: 12 edits mirroring the componentSlot pattern across four sites (mixed-children loop, mountLines, afterLines per block type). forBlock's trailing-arg backfill widens hasEmpty to hasEmpty || hasAnchor so the positional anchor lines up after the emptyBody slot. Pinned by anchor-order.test.ts: 12 new differential tests covering @for (mount + prepend/append/clear), @if (then/else/toggle), @switch (each case + cycling), @Try (mount). The throw-toggle @Try case is skipped — the React fixture has no wrapping ErrorBoundary, so the thrown Error propagates out before any DOM diff can run. Source-order parity for tryBlock is still proven by the mount-only case + the state.end invariant on catch-branch swap. 353 → 365 passing; one over-specific regex in tsrx-features.test.ts updated to accept the new trailing anchor arg without losing intent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The catch-branch source-order test in anchor-order.test.ts was skipped because the React side appeared to lack an ErrorBoundary. It actually has one — @tsrx/react lowers @catch to <TsrxErrorBoundary fallback={...}> (see ripple/packages/tsrx-react/src/error-boundary.js). The real blocker was the fixture: an eager IIFE that threw during the parent's prop evaluation — BEFORE TsrxErrorBoundary's descendant tree mounted — so the throw propagated past both runtimes' boundaries (React's contract only catches errors thrown during descendant render; inferno-next's tryBlock follows the same contract). Restructured the throw into a child component (Thrower) so it fires during the child's render, fully inside the boundary. The catch-branch differential test now actively pins React-parity for the catch slot's source-order position: throwing swaps the catch body INTO the same slot, keeping the .after sibling untouched. Follow-ups documented inline: 1. Fragment-text-only-child mount path uses __block.parentNode.childNodes[0] as its insertion anchor, which grabs the parent's first child (eating it) when the fragment is mounted into a parent with existing siblings. Worked around in the fixture by wrapping Thrower's throw in an inner <i>. The compile.js path that needs the fix is the fragment-only-text-child fast-path (single-text-child + Fragment top-level body). 2. reset() semantics diverge from React's TsrxErrorBoundary: React's setState({error:null}) re-renders the try body on the same commit; inferno-next's catch reset() leaves the catch mounted until the next render. The differential test trims the reset step pending that work. 365 → 366 passing; 15 → 14 skipped. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Source: ripple main HEAD (43c8cb3a). Notable upstream changes:
- f0018494 fix(parser): @{} is always a JSXCodeBlock
- 4af25913 Transform
- 92982ee5 feat: New dynamic element / component syntax <{tag}>
- 1693c9e6 Remove React/Preact conditional hooks
- 921fb9ce fix(parser): control flow trailing text
- d14ec84f fix(parser): white space preservation
Installed via local source copy (the published npm versions are behind:
0.1.20 / 0.2.20 latest in registry). pnpm-workspace.yaml gets a new
minimumReleaseAgeExclude entry — auto-added by pnpm to allow the
not-yet-public 0.1.29 / 0.2.29 to resolve.
Full suite passes unchanged (366 / 14 skipped) — no compiler or runtime
adaptation needed. AST node shapes (JSXCodeBlock / JSXIfExpression /
JSXForExpression / JSXTryExpression / JSXSwitchExpression) are
byte-for-byte compatible; the four @tsrx/core APIs compile.js depends
on (parseModule, prepareStylesheetForRender, renderStylesheets,
annotateWithHash) keep their signatures.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ntNode
A function-component body shaped `@{ <>{expr as string}</> }` compiles
through the multi-root path (noTemplate=false, single=false), which means
the text binding has path=[] and ensureVar remaps elVar to
`__block.parentNode`. But the `<!>` placeholder being walked still lives
in `_root` (the cloned fragment) until the drain at the end of mount.
The text-binding emit was generating:
const _m = __block.parentNode.childNodes[childIndex];
__block.parentNode.insertBefore(_t, _m);
__block.parentNode.removeChild(_m);
So when the fragment-bodied component mounted inside a parent that
already had other children, it grabbed the parent's FIRST existing child,
inserted the text node before it, then DELETED the sibling. The previous
commit's anchor-order fixture worked around this by wrapping the throw
in an inner <i>.
Fix: detect the `elVar === '__block.parentNode'` case and do the swap on
`_root` instead. The drain step then moves _t into the block range along
with the rest of the fragment's children.
Removed the <i class='thrower'> workaround from anchor-order.tsrx's
Thrower component — it now exercises the bare fragment body and
continues passing differential against React.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The differential test for tryBeforeSibling's reset button kept showing
the catch body still mounted after `() => { reset(); setThrowIt(false); }`
ran. Root cause: the reset callback closed over `mountTry(state)`, which
SYNCHRONOUSLY rebuilt the try body before the sibling setThrowIt update
took effect — so the body re-read the stale `throwIt=true` from its
closure, the child Thrower re-threw, and switchToCatch immediately
swapped right back to the catch branch.
React's TsrxErrorBoundary avoids this by deferring: its reset does
`this.setState({ error: null })`, which queues the state update; React
then batches it with the co-located sibling setState in the SAME event
handler into one commit where both reads see the updated values.
Mirror the pattern with a new requestReset() helper that:
- clears state.err / hasResolved
- rewinds state.branch to -1 (no branch shown)
- schedules a render on the parent block
The currently visible catch block stays mounted for one tick; mountTry's
existing teardown (state.block != null branch) removes it on the next
render. Both the branch===0 re-render closure (line 1619) and
switchToCatch's catch-arm closure (line ~2068) point at requestReset —
the line 1619 closure re-installs on every catch re-render, so missing
either site would silently regress.
Re-added the previously-trimmed reset step to anchor-order.test.ts
tryBeforeSibling: now actively asserts catch → try restoration parity.
Full suite still 366 / 14 skipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@tsrx/core 0.1.29 commit 92982ee5 introduced `<{expr}>` as the canonical
dynamic-component-tag syntax. Parser emits a JSXExpressionContainer at
node.openingElement.name with `isDynamic === true`, and the inner
expression is the component reference (see ripple/packages/tsrx/src/plugin.js
function #isDynamicJSXElementName at L2662-2664).
compile.js needed two surgical additions:
- isComponentTag: the new AST shape is always a component (no HTML
string tag is possible in this position), so the predicate accepts
`name.type === 'JSXExpressionContainer' && name.isDynamic === true`
alongside MemberExpression / capitalized Identifier.
- tagExpr: unwrap the inner expression and print it, parenthesized for
precedence safety. The returned string interpolates verbatim into
componentSlot(..., cc.compExpr, ...) at the existing emit site
(compile.js:1363) — no other lowering needed.
Coverage: __tests__/dynamic-tag.test.ts pins
- direct prop drive: `<{props.comp}/>`
- state-driven swap: `<{Comp}/>` where Comp is a let binding
- prop forwarding (every prop except the tag expression)
- remount on identity swap (one .leaf at a time)
- member expression: `<{props.lib.Red}/>` resolves correctly
This is in addition to the existing `<Dynamic is={X}/>` runtime helper,
which keeps working unchanged — no migration required.
366 → 371 passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
tsrx 0.1.29 commit f0018494 made @{} always a JSXCodeBlock, which means
it can now legally appear at JSX child position (not just at function
body position). normalizeChildren didn't have a branch for it, so a child
@{} fell into the catch-all `out.push(n)` and downstream emitNodeHtml
threw a confusing internal error.
Added a JSXCodeBlock branch in normalizeChildren with three cases:
- Empty `@{}`: silently dropped (degenerate but legal).
- Render-only `@{ <jsx/> }` (body is empty, render is the JSX root):
recurse — the wrapped JSX becomes a sibling child, just like a
JSXFragment. Source-order preserved via the existing anchor work for
elements vs control-flow vs components.
- Setup-bearing (body is non-empty): throws at compile time with a
workaround hint pointing at the render-prop arrow form
`{() => @{ … }}`, which IS supported via the existing
ArrowFunctionExpression → JSXCodeBlock route at compile.js:1081.
There's no sensible runtime semantics for setup statements at a JSX
child position — when do they run, who owns their Scope, how does
state thread back to siblings — so we surface the error rather than
invent one.
Coverage: code-block-child.test.ts pins
- render-only @{} renders its root in source order (3-sibling
sequence)
- multiple sibling @{}s render in source order (3 li elements)
- empty @{} is silently dropped (its two flanking siblings sit
adjacent in the DOM)
- compiler rejects setup-bearing @{} with the workaround-hint regex
371 → 375 passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
tsrx commit 921fb9ce fixed a parser bug where text right after a
control-flow closing brace — e.g. `@if (…) { … } trail` — was swallowed
into the control-flow body instead of being emitted as a trailing
JSXText sibling. We had zero regression coverage for that fix, so any
future parser regression would silently drop the text.
Added two fixtures (IfTrailingText, ForTrailingText) in control.tsrx
that place a literal `trailing!` / `tail` immediately after the closing
`}` of an @if and an @for. The pinning tests in control.test.ts under a
new `parser fixes (tsrx 0.1.29)` describe assert:
- text after @if {} is rendered (both then-branch and empty-branch
cases — proves the text survives independent of the branch)
- text after @for {} renders alongside the iterated items
Whitespace-preservation pin (d14ec84f) deferred: the natural repro
shape (`{{' spaced ' as string}}` inside an @if inside a <textarea>)
hits a separate compile.js bug where the `as string` cast leaks through
the expression-statement emission path. Filed as a follow-up via inline
NOTE in the fixture.
375 → 378 passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…cases
@tsrx/react aborts fixture-wide compilation when it sees a multi-ref
attribute ("Element has multiple `ref={...}` attributes; an element may
have at most one. Use a single array-valued ref such as `ref={[a, b]}`
…"), which was poisoning every React-side mount in
refs-effects.test.ts — the whole `describe('differential: useref.tsrx …')`
block was forced to `it.skip` for unrelated reasons.
Cut `MultipleRefsOneEl` to a dedicated useref-multi.tsrx so the rest of
useref.tsrx precompiles cleanly via @tsrx/react. useref.test.ts (the
non-differential suite) keeps importing it from its new home.
Un-skipped 10 of 11 useref cases. 9 pass on first try:
- PersistsAcrossRenders, MutationDoesNotRerender, StableIdentity,
PerRowRef, DomRefCallback, DomRefCleanup, DomRefObjectCleanup,
ImperativeOwner, LazyInit
Re-skipped two with narrower reasons:
- DomRefObject: useEffect body uses positional-deps args (inferno-
next-specific calling convention). React's useEffect calls the body
with no args, so reads of `target` / `refSlot` throw undefined.
Same blocker as effect-timing.tsrx, separate rewrite ticket.
- RefInIf: fixture authors useRef + useState INSIDE the @if branch
body. inferno-next supports this (per-block-boundary hook slots
reset on unmount/remount); React's rules-of-hooks rejects it
fixture-wide. Pure inferno-next feature, not a parity divergence.
378 → 387 passing (+9). 14 → 5 skipped (-9).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User feedback: the prior `ref={a} ref={b}` shape is wrong — refs should
follow React's canonical convention of either `ref={ref}` for a single
attachment or `ref={[ref, ref2]}` for multiple. Rewrote the fixture and
taught the runtime/compiler to honor the array form.
Runtime: added `attachRef(ref, el)` helper in src/runtime.ts that handles
all three supported shapes uniformly — function (call with el or null),
object (set `.current`), and array (recurse, attaching each item
independently). Exported from src/index.ts so compile.js can emit it.
Compiler: collapsed the existing mount + update ref bindings to call the
shared helper instead of inlining the type-narrow if/else. Both paths
now route through attachRef; the cleanup hook just calls it with null.
Side effect: `ref={[a, b]}` and `ref={someArray}` both work now.
Fixture: renamed `MultipleRefsOneEl` → `ArrayRefsOneEl` and rewrote to
the array form. The non-differential useref.test.ts asserts both refs
land on the same element. Cross-runtime differential coverage is still
blocked by the positional-deps useEffect convention this fixture
inherited (separate work item).
387 / 5 skipped passing throughout.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cross-runtime DOM-parity pin for @switch / @case / @default lowering. The inferno-next runtime mounts a switchBlock slot whose case selection mirrors React's standard discriminant comparison; @tsrx/react lowers the source to a chained ternary on the React side. The DOM diff confirms both runtimes pick the same case and unmount/remount cleanly when the discriminant changes. Six active cases: - PickKind: literal-string discriminant — matches "a", "b", and @default (kind='zzz') - Cycle: numeric discriminant, click-driven cycle 0→1→2→0→1 (proves the slot's unmount/remount path stays in sync with React's behavior across multiple cases) - NoDefault: missing @case leaves the slot empty between siblings (validates the source-order anchor fix from earlier in this branch keeps the .before / .after sibling spans intact) HookInCase skipped — fixture authors useState inside @case branch bodies, an inferno-next-specific feature (per-block-boundary hook slots that reset on branch swap). React's rules-of-hooks rejects this with "Rendered fewer hooks than expected." Same pattern as RefInIf in refs-effects.test.ts. 387 → 393 passing. 5 → 6 skipped (the new HookInCase). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two layered blockers in the rig's React precompile path made portal
fixtures fail at module load:
1. _setup.ts blindly rewrote `from 'inferno-next'` to `from 'react'`,
but React 19 exports createPortal from `react-dom`, not `react`.
The rewritten import became `import { createPortal } from "react"`
→ undefined at use site.
2. @tsrx/react lowers `createPortal(() => @{ <jsx/> }, target)`
verbatim — the children arg stays a function. React 19 expects a
ReactNode child; a thunk renders nothing (and would warn in dev).
Fix both with a single rig-side patch:
- strip `createPortal` out of the rewritten `react` named-import via a
regex that handles trailing/leading comma + empty-braces cleanup
- prepend `import { createPortal as __rd_createPortal } from "react-dom";`
- shim `const createPortal = (c, t) => __rd_createPortal(typeof c ===
"function" ? c() : c, t);` so function-children are unwrapped at the
call site, JSX-children pass through verbatim
Verified by inspecting freshly-compiled .react-cache/portal-eegoe4.js
and portal-events-44mrvq.js: both now start with the shim block, and
the createPortal call sites see the unwrapped child.
Added a smoke differential test that imports portal-events.tsrx's
BasicPortalClick, mounts it on both runtimes against a shared target,
and asserts the .modal subtree lands inside the portal target with the
correct content. Real cross-portal coverage (bubble-out via
$$portalParent, refcounted delegation, stopPropagation across the
boundary) is a bigger surface; this commit unblocks the work, doesn't
land it all.
393 → 394 passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ents
`{expr as string}` at a JSX child position has always been stripped at
planJsx time via stripStringishCast. But `as string` (or `!`, or
`satisfies T`) appearing inside an @if / @else / @for BODY — at
expression-statement position — was passing through to esrap's tsx
printer verbatim. The compiled output then contained literal substrings
like `'foo' as string;`, which rolldown rejects as TS syntax when
loading the compiled output as a .js module:
"Type assertion expressions can only be used in TypeScript files."
Repro shape (now passing):
function F(props) @{ <p>@if (props.show) {{'foo' as string}}</p> }
Fix: new stripTsOnlyWrappers helper next to stripStringishCast — walks
an AST in place, replaces TSAsExpression / TSTypeAssertion /
TSNonNullExpression / TSSatisfiesExpression / TSInstantiationExpression
with their .expression. Added as a third .map() step in the
rewrittenStatements chain (after rewriteHookCalls, after
rewriteTsrxBlocks). Skips `loc` / `range` / `start` / `end` / `parent`
fields to avoid walking source-position metadata.
Coverage: WhitespaceInIf fixture in control.tsrx + a load-only test in
control.test.ts. The pin is COMPILE-TIME — the fixture loading at all
proves the strip works (without the fix, rolldown rejects at module
load and the test file fails to import). Body renders nothing because
the expression-statement at @if body position isn't lifted to a JSX
child by current normalize semantics; that's a separate parser-
semantics question tracked under tsrx d14ec84f and documented inline.
Not covered (latent / follow-up):
- JSX-attribute-value leaks (`title={x as string}`) flow through
printExpr in planJsx, not the statement printer. Separate path.
- TS type annotations on VariableDeclarator.id / function params /
return types. Same TS-in-JS rolldown risk if a user authors them.
394 → 395 passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…own strings
Two-part cleanup of the as-string handling pipeline:
1) Centralize the TS-only-wrapper strip into printNode (the single esrap
entry point) instead of calling stripStringishCast at each emission
site. Every print path now strips:
- rewrittenStatements (statement-level, deletes the prior .map step)
- planJsx-emitted bindings (deletes pre-strip at JSXExpressionContainer)
- attribute/prop values via printExprWithTsrx
- any future emit path that lands in printNode
Old stripStringishCast helper only stripped OUTER wrappers — inner ones
leaked (e.g. `(foo as number).toFixed(2) as string` left `as number` in
the output). The new central strip walks the whole AST so inner
wrappers are handled too. Helper deleted.
2) New isKnownStringExpression(node) predicate runs at text-binding
creation time (BEFORE the strip, so the `as string` annotation is
still visible). Recognizes:
- String Literal / StringLiteral
- TemplateLiteral
- TSAsExpression / TSTypeAssertion / TSSatisfiesExpression with
TSStringKeyword (or a `string` TSTypeReference)
- TSNonNullExpression / TSInstantiationExpression: peel and recurse
- BinaryExpression `+` where either operand is known-string
Flags the binding with `knownString: true`; emitBindingMount and
emitBindingUpdate use the flag to emit `_v` directly instead of
`String(_v)` in the text/textOnlyChild/htmlOnlyChild coercion. Saves
a function call on every mount AND update.
Verified fast-path fires only when safe:
- anchor-order.tsrx → every createTextNode uses `_v` (all-string fixture)
- `{p.n}` (unknown type) → still emits `String(_v)`
395 / 6 skipped passing throughout. Bench / suite unchanged in behavior.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… PhaseOrder + PassiveDeferred
The inferno-next positional-deps useEffect convention
(`useEffect((dep1, dep2) => …, [a, b])`) is supported but optional in
inferno-next. React calls effect bodies with no arguments, so dep1 / dep2
arrive as `undefined` and the body throws on first read. Rewriting each
body to close over props lexically (`useEffect(() => { props.log.push(…) },
[deps…])`) works identically in both runtimes — the deps array still
drives re-fire on change; the body just stops relying on positional spread.
Rewrites:
- useref.tsrx DomRefObject: effect body now reads props.target.received
and ref.current from closure; deps array unchanged
- effect-timing.tsrx PhaseOrder: three (eff/lay/ins) useEffect bodies
each switch to lexical props.log; cleanup arrows same shape
- effect-timing.tsrx PassiveDeferred: useLayoutEffect + useEffect both
use lexical props.log
Conformance-suite observers (target.received, log[]) are unaffected —
they read the same shared object via closure or props (Probe A fact 5).
Un-skipped (now real differential pins):
- DomRefObject (refs-effects.test.ts)
- PhaseOrder (refs-effects.test.ts)
- PassiveDeferred (refs-effects.test.ts)
395 → 398 passing. 6 → 3 skipped (-3).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…Href rewrite React 19 treats literal `xlink:href` as a "non-standard DOM property" and drops it at render time, taking out the <use> element's id and the xlink-namespaced href. @tsrx/react emits the prop verbatim as `"xlink:href":` (string-keyed JSX prop), so the rig's React side renders nothing useful. Mirror the existing `createPortal` rewrite pattern in _setup.ts: after the `inferno-next` → `react` import swap, also rewrite all `"xlink:href":` occurrences to camelCase `xlinkHref:`. React 19 round- trips xlinkHref back to the namespaced attribute with XLINK_NS — byte- identical to inferno-next's setAttributeNS path. Inferno-next's source keeps the canonical `xlink:href` spelling; only the React-side cache is patched. Un-skipped NamespacedAttr in features-namespace.test.ts. The rig's intrinsic innerHTML parity check is the assertion; tests confirm both runtimes emit the same XLINK_NS attribute. 398 → 399 passing. 3 → 2 skipped (-1). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two skipped tests pinned an inferno-next feature React structurally rejects: hooks (useRef, useState) authored INSIDE an @if branch body or an @case branch body. inferno-next supports per-block-boundary hook slots that reset on branch swap; React's rules-of-hooks rejects with "Rendered fewer hooks than expected." No fixture rewrite can bridge this — hoisting hooks above the conditional changes the test's intent (branch-local reset is the contract). The non-differential conformance suites already cover both: - useref.test.ts:65 pins RefInIf branch-local reset - switch.test.ts:64 pins HookInCase per-case reset So the skipped differential blocks were dead weight. Deleted them, left a 4-line breadcrumb at each site pointing at the canonical coverage — no longer skipped, no longer marked as parity gaps, just routed to the right test suite. 399 / 0 skipped. (Was 399 / 2 skipped.) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What if Inferno was almost 1:1 with React's hooks API, breaking away from how Inferno works today. What if it was based on TSRX only to leverage compiler benefits? What if there was no virtual DOM? What if the performance was best-in-class for any framework.