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
{{ message }}
Repository navigation
feat(types): brand async sources with MaySuspend (WIP, discussion) - #4259
In Solid 2 an async value reads like a sync one: user().name throws NotReadyError to the nearest <Loading>, but nothing at the read or in the types says so. This draft tests how much the types can show without changing runtime behavior.
Async sources now carry a type-only brand, MaySuspend:
Call
Type
createMemo(() => fetchUser())
AsyncAccessor<User>
createMemo(() => 1)
SourceAccessor<number> (unchanged)
createSignal(async () => …)
AsyncSignal<T>
createProjection(async () => …), seeded or seedless
AsyncStore<T>
createStore(async () => …), seeded or seedless
AsyncStoreReturn<T>
any of the above with loadingValue or seedLoadingValue: true
unbranded, since those never suspend
A sync compute or an explicit type argument hovers exactly as it does today.
Each branded overload takes the compute's own type as a required F and passes it through SuspendingIf<F, Sync, Async, Untyped>, which checks whether F returns a promise or async iterable. The overload is followed by an unchanged twin, so a call with explicit type arguments (createMemo<T>(…)) skips the branded overload by type-argument count and behaves as before. The change touches the signals core, the solid-js client hydration wrapper and the server build. It adds no runtime code.
What the brand cannot do
The brand is on the accessor, so it does not survive a read. props.user, createMemo(() => user().name), a store getter and list.items all type as plain values. A type-aware lint can still mark a direct read such as {user().name}, because the callee is an AsyncAccessor. Following suspension through getters would need an effect system, which TypeScript lacks, so the rest is left to runtime diagnostics.
Open questions
Under noImplicitAny: false, tuple returns widen: const m: () => [number, boolean] = createMemo(() => [a(), b()]) fails with TS2322. Five other signatures were tried, and each one breaks either this case or prev inference under strict. This reproduces in packages/signals/tests/verdict-memo-under-held-memo-4050.test.ts with the signals tsconfig.
An unannotated prev in a memo or derived signal is now contextually typed any, so createMemo(prev => prev.toUpperCase()) compiles where it errors today. Tying F's constraint to T restores the check but makes T circular, so createMemo(prev => (prev ?? 0) + 1) infers unknown.
createDeferred, derived createOptimistic and derived createOptimisticStore are not branded yet.
An async-iterable projection stops suspending after its first yield, but it stays branded.
No changeset yet.
How did you test this change?
pnpm --filter solid-js test-types and pnpm --filter @solidjs/web test-types pass. The new packages/solid/test/may-suspend.type-tests.ts covers branded and unbranded forms, inference of tuples, prev, literals, explicit type arguments, any arguments, unions of compute functions, and seed-inferred T.
tsc --noEmit -p packages/signals/tsconfig.json reports 32 errors against 30 on next. The two new ones are the noImplicitAny: false tuple case above.
Hover text was checked with the TypeScript language service for every row in the table.
Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.
This PR includes no changesets
When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types
System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.
Some benchmarks with significant performance changes were compared across different runner types, so the changes might not come from the code. Run base and head on the same runner type, or use CodSpeed Macro Runners for more stable results.
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
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.
Summary
WIP, opened for discussion. Not ready to merge.
In Solid 2 an async value reads like a sync one:
user().namethrowsNotReadyErrorto the nearest<Loading>, but nothing at the read or in the types says so. This draft tests how much the types can show without changing runtime behavior.Async sources now carry a type-only brand,
MaySuspend:createMemo(() => fetchUser())AsyncAccessor<User>createMemo(() => 1)SourceAccessor<number>(unchanged)createSignal(async () => …)AsyncSignal<T>createProjection(async () => …), seeded or seedlessAsyncStore<T>createStore(async () => …), seeded or seedlessAsyncStoreReturn<T>loadingValueorseedLoadingValue: trueA sync compute or an explicit type argument hovers exactly as it does today.
Each branded overload takes the compute's own type as a required
Fand passes it throughSuspendingIf<F, Sync, Async, Untyped>, which checks whetherFreturns a promise or async iterable. The overload is followed by an unchanged twin, so a call with explicit type arguments (createMemo<T>(…)) skips the branded overload by type-argument count and behaves as before. The change touches the signals core, the solid-js client hydration wrapper and the server build. It adds no runtime code.What the brand cannot do
The brand is on the accessor, so it does not survive a read.
props.user,createMemo(() => user().name), a store getter andlist.itemsall type as plain values. A type-aware lint can still mark a direct read such as{user().name}, because the callee is anAsyncAccessor. Following suspension through getters would need an effect system, which TypeScript lacks, so the rest is left to runtime diagnostics.Open questions
noImplicitAny: false, tuple returns widen:const m: () => [number, boolean] = createMemo(() => [a(), b()])fails with TS2322. Five other signatures were tried, and each one breaks either this case orprevinference understrict. This reproduces inpackages/signals/tests/verdict-memo-under-held-memo-4050.test.tswith the signals tsconfig.previn a memo or derived signal is now contextually typedany, socreateMemo(prev => prev.toUpperCase())compiles where it errors today. TyingF's constraint toTrestores the check but makesTcircular, socreateMemo(prev => (prev ?? 0) + 1)infersunknown.createDeferred, derivedcreateOptimisticand derivedcreateOptimisticStoreare not branded yet.yield, but it stays branded.How did you test this change?
pnpm --filter solid-js test-typesandpnpm --filter @solidjs/web test-typespass. The newpackages/solid/test/may-suspend.type-tests.tscovers branded and unbranded forms, inference of tuples,prev, literals, explicit type arguments,anyarguments, unions of compute functions, and seed-inferredT.tsc --noEmit -p packages/signals/tsconfig.jsonreports 32 errors against 30 onnext. The two new ones are thenoImplicitAny: falsetuple case above.🤖 Generated with Claude Code