You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Written: 2026-09-13, against tsrx main @ ebec026 (@tsrx/core 0.1.70) and
ripple perf/explicit-props @ 446bf03ae (2026-09-11)
Companion decision: Ripple is removing lazy destructuring at the same time.
Ripple's prop getters are already gone (ripple ec3414d018, 2026-09-10, "props
are plain objects; reactivity crosses the boundary explicitly").
Scope: the language (specification section 4.5, the LazyAssignmentStatement
and lazy loop-header forms), @tsrx/core, the React, Preact, Solid, and Vue
targets, shared tooling, editor grammars, docs, and the @tsrx/ripple compiler
in the Ripple repository.
1. Summary
TSRX today extends the ECMAScript binding grammar with two sigils, &{ ... } and &[ ... ], that look like destructuring but defer the property or index access
until each binding is read. Every reference to such a binding compiles back to a
member expression on a generated source identifier (__lazy0.name, __lazy0[0]).
This RFC proposes removing the feature from the language entirely. The reason it
was created no longer exists, no supported target needs it, its semantics are the
kind of "cute" indirection that misleads both people and language models, and it
forces a dishonest type for Ripple's Tracked<V>. What remains is a fairly large,
whitespace-sensitive, non-TypeScript syntax that every tool in the ecosystem has
to understand for the benefit of one target that is about to stop using it.
After removal there is exactly one way to hold reactive state in every target, and
it is the target's own API:
From the specification (section 4.5) and llms.txt:
constUserCard=(&{ name, age }: {name: string;age: number})=><div><h2>{name}</h2><p>Age: {age}</p></div>;let&[count,setCount]=createSignal(0);
Lazy patterns are accepted in parameters, variable declarations, for / for...of / for...in / for await heads, @for targets, nested inside
ordinary patterns ({ count: &[count] }), and as the whole left-hand side of an
expression statement. The compiler replaces the pattern with a generated
identifier and rewrites every reference, including shorthand object properties
({ name } becomes { name: __lazy0.name }) and update and assignment
expressions (count++ becomes __lazy0[0]++).
2.2 Why it was created
Lazy destructuring exists because Ripple props used to be objects with accessor
properties. Plain destructuring ({ name }) would read the accessor once and
snapshot the value, breaking per-access reactivity across the component boundary. &{ name } kept the ergonomics of destructuring while compiling every read back
to props.name.
The same mechanism was then applied to track(): let &[count, countTracked] = track(0) gives a bare count that reads and writes
the tracked value, plus the tracked object itself for passing to children.
2.3 What each target does with it today
Target
Lowering of &{ name } / &[x]
Does the target need it?
React, Preact, Octane
__lazy0.name member access; identical in effect to plain destructuring because the component body re-runs on every render
No. Props are plain objects and useState already returns the value as a plain readable.
Solid
__lazy0.name; preserves getter-based prop reactivity. &[count, setCount] = createSignal(0) is meaningless, since Solid signals are functions; the Solid tests only assert that the plain destructure is preserved.
No. Solid declined to implement it for their own tooling. The Solid idiom is props.name, and eslint-plugin-solid already warns on destructuring props.
Vue (vapor)
__lazy0.count on a reactive proxy
No. The proxy makes state.count reactive on its own; toRefs covers the destructuring case.
Ripple
Fast path for tracked sources: reads become .value gets, writes become sets; plus the generic member-access path for objects.
Not any more. Props are plain objects; reactivity crosses the boundary explicitly as Tracked / Derived, read through .value.
The website's features page states that lazy destructuring "preserves tracked,
signal, or proxy-backed reactivity without needing a wrapper call". For Ripple and
Vue this is now describing a problem that no longer exists; for Solid it is
describing a shortcut the Solid ecosystem discourages.
3. Motivation
3.1 The reason for the feature is gone
Ripple ec3414d018 replaced prop getters with plain object literals evaluated once
at the call site. A child that must follow a change receives a Tracked or a Derived and reads .value. The Ripple llms.txt already documents the direct
form as a first-class alternative:
With prop getters gone, the only remaining real-world use of lazy destructuring in
Ripple is unwrapping track() results and tracked props. That is sugar, not
capability.
3.2 One pattern yields two different kinds of binding
Both lines do the same thing. count is a name that is secretly a property
access; countT is the object. Nothing in the source distinguishes "this
identifier subscribes" from "this identifier is a number". A reader has to know
that the & on the declaration, possibly many lines above, changed the meaning of
every later occurrence of count.
The Ripple docs already have to warn about the consequence: count={count} on a
lazy binding passes a snapshot and "the child never sees later changes". That is a
footgun created by the feature itself. With const count = track(0), passing count passes the tracked object and passing count.value passes a number, and
the two spellings look different.
3.3 The types cannot tell the difference
let&[count,countT]=track(0);// let countT: Tracked<number>// (property) TrackedBase<number>.value: numbercountT.value++;// let count: numbercount++;
Hovering count shows number. The editor, the type checker, and any tool built
on them believe count is a plain value. Reactivity is invisible in the type
system exactly where it matters most.
3.4 Tracked<V> lies about its shape to make the syntax type-check
The [V, Tracked<V>] tuple member exists only so that &[count, countT] = track(0) types as [number, Tracked<number>]. The runtime
object has no index 0 or 1. The compiler then has to police the lie with two
dedicated errors ("Do not access tracked values with [0]. Use .value or &[] lazy
destructuring instead" and the matching [1] message). Removing lazy
destructuring lets Tracked<V> become an honest { value: V } and deletes both
errors.
3.5 A lazy binding is not a variable
Because a lazy binding is a member access wearing a variable's name, it behaves
differently from a plain binding wherever JavaScript treats a binding as a value:
Shorthand properties. With &{ name }: Props in scope, const obj = { name }
compiles to { name: __lazy0.name }. The source reads as "copy a local into an
object"; it is a property read from the props object.
Update and assignment expressions. With let &[val] = getState(), the statement val++ compiles to __lazy0[0]++. The source reads as "increment a local"; it
mutates the array returned by getState().
The support is broad and each case is handled correctly. The cost is to the
reader: { name } and val++ look like operations on locals and are operations
on someone else's property, and nothing at the use site says which.
3.6 Whitespace-sensitive syntax that is not TypeScript
&{ and &[ are lazy-pattern introducers only when the ampersand is directly
followed by the bracket. & { and & [ are not. Every tool in the chain has to
reproduce that rule: the core parser plugin, Prettier, the ESLint parser and its no-lazy-destructuring-in-modules rule, the TextMate and Tree-sitter grammars,
the Zed and Neovim highlight queries, the language server's source mappings, and
the eventual @tsrx/oxc port. None of that would exist for a syntax that
TypeScript already has.
3.7 Predictability for agents
Most TSRX code will be written or edited by language models. Their prior is
TypeScript and the target framework's own idioms. const count = track(0); count.value++ matches that prior. let &[count] = track(0); count++ does not, and the failure mode is silent: a
model that forgets the &, or destructures with { name } instead of &{ name }, produces code that compiles and renders once. One explicit way to
read reactive state, spelled the same as the framework's own docs, is the safer
design.
3.8 The implementation cost is real
In @tsrx/core alone:
src/transform/lazy.js (1,495 lines) plus lazy-aware code in transform/segments.js, transform/jsx/index.js, analyze/index.js, analyze/validation.js, utils/ast.js, diagnostics.js, and the Acorn plugin
(plugin.js: lazyBindingPos, parseBindingAtom override, assignment-position
checks).
Metadata fields on the AST (lazy_id, has_lazy_descendants, has_lazy_var_loop_descendants, lazy_param_binding_mappings, lazy_array_source, lazy_array_index, lazy_array_source_tracked, lazy_array_rest), binding kinds lazy and lazy_fallback, and the exported
API (createLazyContext, collectLazyBindings, collectLazyBindingsFromStatements, preallocateLazyIds, applyLazyTransforms, validateUnsupportedLazyAssignmentPosition).
Roughly 265 references in the shared compile harness, plus per-target test
suites.
Every parser or transform change since has had to keep this machinery working
(loop headers, @for targets, nested patterns, @switch case bodies, source
mappings). The changelog for @tsrx/core records at least four separate bug-fix
rounds for it.
4. Proposal
Remove &{ ... } and &[ ... ] from the TSRX language. Concretely:
4.1 Language
Delete specification section 4.5, LazyObjectBindingPattern, LazyArrayBindingPattern, LazyAssignmentStatement, the lazy loop-header
forms, and the note about the whitespace-sensitive introducer.
& followed by { or [ in a binding position becomes a syntax error, as it
is in TypeScript.
No replacement syntax. Each target's own state API is the replacement.
4.2 @tsrx/core
Remove transform/lazy.js, the parser plugin hooks, the lazy metadata fields
and binding kinds, UNSUPPORTED_LAZY_ASSIGNMENT_POSITION, and the exported lazy
API listed in 3.8.
Remove lazy handling from segments.js, jsx/index.js, analyze/*, utils/ast.js, and the type declarations in types/index.d.ts and types/parse.d.ts.
Remove lazy cases from the shared compile harness and source-mapping tests.
4.3 Targets
@tsrx/react, @tsrx/preact, @tsrx/vue: drop the lazy transform call and
tests. Output for former &{ a } sites is the plain destructure users would
have written anyway.
@tsrx/solid: same. The documented alternative is props.name, or splitProps
when destructuring is wanted.
@tsrx/ripple (Ripple repository): remove setup_lazy_transforms, setup_tracked_lazy_array_transforms, setup_lazy_array_transforms, the build_lazy_array_* helpers, the tracked-index errors, and the lazy / lazy_fallback binding kinds; simplify Tracked, Derived, and WritableDerived to their base interfaces; rewrite tests/client/lazy-destructuring.test.tsrx and every other test or doc that
uses &[.
Octane consumes @tsrx/core and has its own runtime; confirm it has no
lazy-specific code paths (none are expected, since Octane re-renders like
React).
4.4 Tooling
Prettier plugin: remove the node.lazy branches for ObjectPattern and ArrayPattern.
ESLint: remove tsrx/no-lazy-destructuring-in-modules from @tsrx/eslint-plugin (it becomes moot) and the &{} line from the eslint-parser README.
Grammars: remove lazy_object_pattern and lazy_array_pattern from the
Tree-sitter grammar and regenerate; remove the operator highlights from the
Tree-sitter, Zed, and Neovim queries; remove any matching TextMate rule.
Language server and TypeScript plugin: no lazy-specific code was found; re-run
their suites after the core change.
MCP server prompt and generated docs index: drop "lazy destructuring" from the
list of core features.
4.5 Documentation
website-tsrx/public/llms.txt: remove the "Lazy destructuring" section and the
Solid note "lazy destructuring preserves signal reads".
website-tsrx/src/pages/specification.tsrx, features.tsrx, index.tsrx (the
Solid props paragraph and SOLID_LAZY_HTML sample), compiler-demo.tsrx.
README.md feature list and packages/tsrx/README.md.
Ripple: website/public/llms.txt (every &[ example, the "Reactivity" section
intro, the Card props example), READMEs, and AGENTS.md / rules.
.rulesync/rules/ in both repositories, then pnpm rules:generate.
let &[count, countT] = track(0); <Child count={countT} />
const count = track(0); <Child {count} />
let &[double] = track(() => count * 2)
const double = track(() => count.value * 2)
function Card({ count: &[count] }: { count: Tracked<number> })
function Card({ count }: { count: Tracked<number> }) and count.value
Solid: (&{ name }: Props) => <p>{name}</p>
(props: Props) => <p>{props.name}</p>
Vue: let &{ count } = state
const { count } = toRefs(state) and count.value, or state.count
React, Preact, Octane: (&{ name }: Props)
({ name }: Props)
A codemod is straightforward because the compiler already knows every reference to
every lazy binding; the same binding collection that today emits __lazy0.name
can emit props.name, count.value, or a plain destructure depending on the
target and source kind. The codemod ships in the same release as the removal, as npx @tsrx/codemod remove-lazy-destructuring or an equivalent script in each
repository.
The removal lands in one release, as a patch bump under the current policy,
since both repositories are in beta. Order of work: Ripple docs and tests move to .value first (they can land before any core change), then @tsrx/core, @tsrx/ripple, the other targets and tooling, then the website and rules in both
repositories. Changesets for every published package touched.
5. Alternatives considered
Keep &[ ... ] only for track() in Ripple. Rejected. It puts
target-specific syntax in a target-neutral language, keeps the Tracked tuple
lie, and keeps the two-bindings-from-one-pattern confusion that motivated this
RFC.
Keep &{ ... } only for parameters (Solid props). Rejected. Solid's own
guidance is not to destructure props; props.name and splitProps are the
idioms, and Solid chose not to adopt lazy destructuring in their tooling.
Replace it with a different sugar (a $-prefixed binding, an unwrap()
helper, a decorator). Out of scope. Any replacement reintroduces a second way to
read state, and the point of this change is to have one.
Type lazy bindings as Tracked<V> instead of V. Rejected. The entire
purpose of the bare binding was to read as V; typing it as Tracked<V> makes
the sugar pointless while keeping the cost.
Do nothing. Rejected for the reasons in section 3. The feature's
justification was removed on 2026-09-10 and the cost of carrying it is ongoing.
6. Drawbacks
Ripple code gets slightly longer: .value on every read and write. This is the
same trade Vue (ref.value), Solid (count()), and Preact Signals
(signal.value) made, and all three are widely understood.
Existing .tsrx files that use &{ or &[ must be migrated in one step. The
codemod mitigates this; the feature has only ever been in beta packages.
Solid users lose a way to destructure props reactively. The replacement is the
idiom their framework already recommends.
7. Impact inventory
Files known to mention or implement lazy destructuring, from a survey on
2026-09-13. Line counts are approximate and will drift.
Targets: packages/tsrx-solid/src/transform.js (one comment), and the basic.test.js suites for react, solid, and vue; runtime tests under packages/vite-plugin-*/tests/runtime.test.tsrx, rspack-plugin-react/tests, turbopack-plugin-react/tests.
Tooling: packages/prettier-plugin/src/index.js and its tests; packages/eslint-plugin/src/rules/no-lazy-destructuring-in-modules.ts, src/index.ts, README; packages/eslint-parser/README.md; packages/tsrx-mcp/src/server.js, scripts/generate-docs-index.js, src/generated/docs.js.
Grammars and editors: grammars/tree-sitter/grammar.js (lazy_object_pattern, lazy_array_pattern) and generated src/, grammars/tree-sitter/queries/highlights.scm, packages/zed-plugin/languages/tsrx/highlights.scm, packages/nvim-plugin/queries/tsrx/highlights.scm and README, grammars/textmate/tsrx.tmLanguage.json (verify).
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
RFC: Remove lazy destructuring (
&{ ... }/&[ ... ]) from TSRXmain@ ebec026 (@tsrx/core0.1.70) andripple
perf/explicit-props@ 446bf03ae (2026-09-11)Ripple's prop getters are already gone (ripple ec3414d018, 2026-09-10, "props
are plain objects; reactivity crosses the boundary explicitly").
LazyAssignmentStatementand lazy loop-header forms),
@tsrx/core, the React, Preact, Solid, and Vuetargets, shared tooling, editor grammars, docs, and the
@tsrx/ripplecompilerin the Ripple repository.
1. Summary
TSRX today extends the ECMAScript binding grammar with two sigils,
&{ ... }and&[ ... ], that look like destructuring but defer the property or index accessuntil each binding is read. Every reference to such a binding compiles back to a
member expression on a generated source identifier (
__lazy0.name,__lazy0[0]).This RFC proposes removing the feature from the language entirely. The reason it
was created no longer exists, no supported target needs it, its semantics are the
kind of "cute" indirection that misleads both people and language models, and it
forces a dishonest type for Ripple's
Tracked<V>. What remains is a fairly large,whitespace-sensitive, non-TypeScript syntax that every tool in the ecosystem has
to understand for the benefit of one target that is about to stop using it.
After removal there is exactly one way to hold reactive state in every target, and
it is the target's own API:
2. Background
2.1 What the feature is
From the specification (section 4.5) and
llms.txt:Lazy patterns are accepted in parameters, variable declarations,
for/for...of/for...in/for awaitheads,@fortargets, nested insideordinary patterns (
{ count: &[count] }), and as the whole left-hand side of anexpression statement. The compiler replaces the pattern with a generated
identifier and rewrites every reference, including shorthand object properties
(
{ name }becomes{ name: __lazy0.name }) and update and assignmentexpressions (
count++becomes__lazy0[0]++).2.2 Why it was created
Lazy destructuring exists because Ripple props used to be objects with accessor
properties. Plain destructuring (
{ name }) would read the accessor once andsnapshot the value, breaking per-access reactivity across the component boundary.
&{ name }kept the ergonomics of destructuring while compiling every read backto
props.name.The same mechanism was then applied to
track():let &[count, countTracked] = track(0)gives a barecountthat reads and writesthe tracked value, plus the tracked object itself for passing to children.
2.3 What each target does with it today
&{ name }/&[x]__lazy0.namemember access; identical in effect to plain destructuring because the component body re-runs on every renderuseStatealready returns the value as a plain readable.__lazy0.name; preserves getter-based prop reactivity.&[count, setCount] = createSignal(0)is meaningless, since Solid signals are functions; the Solid tests only assert that the plain destructure is preserved.props.name, andeslint-plugin-solidalready warns on destructuring props.__lazy0.counton a reactive proxystate.countreactive on its own;toRefscovers the destructuring case..valuegets, writes become sets; plus the generic member-access path for objects.Tracked/Derived, read through.value.The website's features page states that lazy destructuring "preserves tracked,
signal, or proxy-backed reactivity without needing a wrapper call". For Ripple and
Vue this is now describing a problem that no longer exists; for Solid it is
describing a shortcut the Solid ecosystem discourages.
3. Motivation
3.1 The reason for the feature is gone
Ripple ec3414d018 replaced prop getters with plain object literals evaluated once
at the call site. A child that must follow a change receives a
Trackedor aDerivedand reads.value. The Ripplellms.txtalready documents the directform as a first-class alternative:
With prop getters gone, the only remaining real-world use of lazy destructuring in
Ripple is unwrapping
track()results and tracked props. That is sugar, notcapability.
3.2 One pattern yields two different kinds of binding
Both lines do the same thing.
countis a name that is secretly a propertyaccess;
countTis the object. Nothing in the source distinguishes "thisidentifier subscribes" from "this identifier is a number". A reader has to know
that the
&on the declaration, possibly many lines above, changed the meaning ofevery later occurrence of
count.The Ripple docs already have to warn about the consequence:
count={count}on alazy binding passes a snapshot and "the child never sees later changes". That is a
footgun created by the feature itself. With
const count = track(0), passingcountpasses the tracked object and passingcount.valuepasses a number, andthe two spellings look different.
3.3 The types cannot tell the difference
Hovering
countshowsnumber. The editor, the type checker, and any tool builton them believe
countis a plain value. Reactivity is invisible in the typesystem exactly where it matters most.
3.4
Tracked<V>lies about its shape to make the syntax type-checkRipple's type is currently:
The
[V, Tracked<V>]tuple member exists only so that&[count, countT] = track(0)types as[number, Tracked<number>]. The runtimeobject has no index
0or1. The compiler then has to police the lie with twodedicated errors ("Do not access tracked values with [0]. Use .value or &[] lazy
destructuring instead" and the matching
[1]message). Removing lazydestructuring lets
Tracked<V>become an honest{ value: V }and deletes botherrors.
3.5 A lazy binding is not a variable
Because a lazy binding is a member access wearing a variable's name, it behaves
differently from a plain binding wherever JavaScript treats a binding as a value:
&{ name }: Propsin scope,const obj = { name }compiles to
{ name: __lazy0.name }. The source reads as "copy a local into anobject"; it is a property read from the props object.
let &[val] = getState(), the statementval++compiles to__lazy0[0]++. The source reads as "increment a local"; itmutates the array returned by
getState().The support is broad and each case is handled correctly. The cost is to the
reader:
{ name }andval++look like operations on locals and are operationson someone else's property, and nothing at the use site says which.
3.6 Whitespace-sensitive syntax that is not TypeScript
&{and&[are lazy-pattern introducers only when the ampersand is directlyfollowed by the bracket.
& {and& [are not. Every tool in the chain has toreproduce that rule: the core parser plugin, Prettier, the ESLint parser and its
no-lazy-destructuring-in-modulesrule, the TextMate and Tree-sitter grammars,the Zed and Neovim highlight queries, the language server's source mappings, and
the eventual
@tsrx/oxcport. None of that would exist for a syntax thatTypeScript already has.
3.7 Predictability for agents
Most TSRX code will be written or edited by language models. Their prior is
TypeScript and the target framework's own idioms.
const count = track(0); count.value++matches that prior.let &[count] = track(0); count++does not, and the failure mode is silent: amodel that forgets the
&, or destructures with{ name }instead of&{ name }, produces code that compiles and renders once. One explicit way toread reactive state, spelled the same as the framework's own docs, is the safer
design.
3.8 The implementation cost is real
In
@tsrx/corealone:src/transform/lazy.js(1,495 lines) plus lazy-aware code intransform/segments.js,transform/jsx/index.js,analyze/index.js,analyze/validation.js,utils/ast.js,diagnostics.js, and the Acorn plugin(
plugin.js:lazyBindingPos,parseBindingAtomoverride, assignment-positionchecks).
lazy_id,has_lazy_descendants,has_lazy_var_loop_descendants,lazy_param_binding_mappings,lazy_array_source,lazy_array_index,lazy_array_source_tracked,lazy_array_rest), binding kindslazyandlazy_fallback, and the exportedAPI (
createLazyContext,collectLazyBindings,collectLazyBindingsFromStatements,preallocateLazyIds,applyLazyTransforms,validateUnsupportedLazyAssignmentPosition).suites.
Every parser or transform change since has had to keep this machinery working
(loop headers,
@fortargets, nested patterns,@switchcase bodies, sourcemappings). The changelog for
@tsrx/corerecords at least four separate bug-fixrounds for it.
4. Proposal
Remove
&{ ... }and&[ ... ]from the TSRX language. Concretely:4.1 Language
LazyObjectBindingPattern,LazyArrayBindingPattern,LazyAssignmentStatement, the lazy loop-headerforms, and the note about the whitespace-sensitive introducer.
&followed by{or[in a binding position becomes a syntax error, as itis in TypeScript.
4.2
@tsrx/coretransform/lazy.js, the parser plugin hooks, the lazy metadata fieldsand binding kinds,
UNSUPPORTED_LAZY_ASSIGNMENT_POSITION, and the exported lazyAPI listed in 3.8.
segments.js,jsx/index.js,analyze/*,utils/ast.js, and the type declarations intypes/index.d.tsandtypes/parse.d.ts.4.3 Targets
@tsrx/react,@tsrx/preact,@tsrx/vue: drop the lazy transform call andtests. Output for former
&{ a }sites is the plain destructure users wouldhave written anyway.
@tsrx/solid: same. The documented alternative isprops.name, orsplitPropswhen destructuring is wanted.
@tsrx/ripple(Ripple repository): removesetup_lazy_transforms,setup_tracked_lazy_array_transforms,setup_lazy_array_transforms, thebuild_lazy_array_*helpers, the tracked-index errors, and thelazy/lazy_fallbackbinding kinds; simplifyTracked,Derived, andWritableDerivedto their base interfaces; rewritetests/client/lazy-destructuring.test.tsrxand every other test or doc thatuses
&[.@tsrx/coreand has its own runtime; confirm it has nolazy-specific code paths (none are expected, since Octane re-renders like
React).
4.4 Tooling
node.lazybranches forObjectPatternandArrayPattern.tsrx/no-lazy-destructuring-in-modulesfrom@tsrx/eslint-plugin(it becomes moot) and the&{}line from theeslint-parserREADME.lazy_object_patternandlazy_array_patternfrom theTree-sitter grammar and regenerate; remove the operator highlights from the
Tree-sitter, Zed, and Neovim queries; remove any matching TextMate rule.
their suites after the core change.
list of core features.
4.5 Documentation
website-tsrx/public/llms.txt: remove the "Lazy destructuring" section and theSolid note "lazy destructuring preserves signal reads".
website-tsrx/src/pages/specification.tsrx,features.tsrx,index.tsrx(theSolid props paragraph and
SOLID_LAZY_HTMLsample),compiler-demo.tsrx.README.mdfeature list andpackages/tsrx/README.md.website/public/llms.txt(every&[example, the "Reactivity" sectionintro, the
Cardprops example), READMEs, andAGENTS.md/ rules..rulesync/rules/in both repositories, thenpnpm rules:generate.4.6 Migration
Mechanical rewrites cover every real use:
let &[count] = track(0); count++; {count}const count = track(0); count.value++; {count.value}let &[count, countT] = track(0); <Child count={countT} />const count = track(0); <Child {count} />let &[double] = track(() => count * 2)const double = track(() => count.value * 2)function Card({ count: &[count] }: { count: Tracked<number> })function Card({ count }: { count: Tracked<number> })andcount.value(&{ name }: Props) => <p>{name}</p>(props: Props) => <p>{props.name}</p>let &{ count } = stateconst { count } = toRefs(state)andcount.value, orstate.count(&{ name }: Props)({ name }: Props)A codemod is straightforward because the compiler already knows every reference to
every lazy binding; the same binding collection that today emits
__lazy0.namecan emit
props.name,count.value, or a plain destructure depending on thetarget and source kind. The codemod ships in the same release as the removal, as
npx @tsrx/codemod remove-lazy-destructuringor an equivalent script in eachrepository.
The removal lands in one release, as a
patchbump under the current policy,since both repositories are in beta. Order of work: Ripple docs and tests move to
.valuefirst (they can land before any core change), then@tsrx/core,@tsrx/ripple, the other targets and tooling, then the website and rules in bothrepositories. Changesets for every published package touched.
5. Alternatives considered
&[ ... ]only fortrack()in Ripple. Rejected. It putstarget-specific syntax in a target-neutral language, keeps the
Trackedtuplelie, and keeps the two-bindings-from-one-pattern confusion that motivated this
RFC.
&{ ... }only for parameters (Solid props). Rejected. Solid's ownguidance is not to destructure props;
props.nameandsplitPropsare theidioms, and Solid chose not to adopt lazy destructuring in their tooling.
$-prefixed binding, anunwrap()helper, a decorator). Out of scope. Any replacement reintroduces a second way to
read state, and the point of this change is to have one.
Tracked<V>instead ofV. Rejected. The entirepurpose of the bare binding was to read as
V; typing it asTracked<V>makesthe sugar pointless while keeping the cost.
justification was removed on 2026-09-10 and the cost of carrying it is ongoing.
6. Drawbacks
.valueon every read and write. This is thesame trade Vue (
ref.value), Solid (count()), and Preact Signals(
signal.value) made, and all three are widely understood..tsrxfiles that use&{or&[must be migrated in one step. Thecodemod mitigates this; the feature has only ever been in beta packages.
idiom their framework already recommends.
7. Impact inventory
Files known to mention or implement lazy destructuring, from a survey on
2026-09-13. Line counts are approximate and will drift.
tsrx repository
packages/tsrx/src/transform/lazy.js,transform/segments.js,transform/jsx/index.js,analyze/index.js,analyze/validation.js,utils/ast.js,diagnostics.js,index.js,plugin.js,types/index.d.ts,types/parse.d.ts,types/jsx-platform.d.ts; tests intests/analyze/analyze.test.js,tests/utils/parser.test.js,tests/shared/compile.js,tests/shared/source-mappings.js.packages/tsrx-solid/src/transform.js(one comment), and thebasic.test.jssuites for react, solid, and vue; runtime tests underpackages/vite-plugin-*/tests/runtime.test.tsrx,rspack-plugin-react/tests,turbopack-plugin-react/tests.packages/prettier-plugin/src/index.jsand its tests;packages/eslint-plugin/src/rules/no-lazy-destructuring-in-modules.ts,src/index.ts, README;packages/eslint-parser/README.md;packages/tsrx-mcp/src/server.js,scripts/generate-docs-index.js,src/generated/docs.js.grammars/tree-sitter/grammar.js(lazy_object_pattern,lazy_array_pattern) and generatedsrc/,grammars/tree-sitter/queries/highlights.scm,packages/zed-plugin/languages/tsrx/highlights.scm,packages/nvim-plugin/queries/tsrx/highlights.scmand README,grammars/textmate/tsrx.tmLanguage.json(verify).README.md,packages/tsrx/README.md,website-tsrx/public/llms.txt,website-tsrx/src/pages/{specification,features,index}.tsrx,website-tsrx/src/components/compiler-demo.tsrx,.rulesync/rules/.ripple repository
packages/tsrx-ripple/src/analyze/index.js(lazy binding setup, tracked-indexerrors),
transform/client/index.js,transform/client/hoist.js,transform/server/index.js,utils.js;tests/basic.test.js.packages/ripple/types/index.d.ts(Tracked,Derived,WritableDerivedtuple members),
src/jsx-runtime.d.ts, runtime files that mention lazy props,src/utils/errors.js.packages/ripple/tests/client/lazy-destructuring.test.tsrxand every test using&[.website/public/llms.txt, READMEs,AGENTS.md,.rulesync/rules/, templatesand playground examples.
External
@tsrx/oxc: the port tracks core's parser tests; remove the lazy cases from theporting spec.
All reactions