Skip to content

feat(types): brand async sources with MaySuspend (WIP, discussion) - #4259

Draft
brenelz wants to merge 1 commit into
solidjs:nextfrom
brenelz:proto/may-suspend-brand
Draft

brenelz wants to merge 1 commit into
solidjs:nextfrom
brenelz:proto/may-suspend-brand

Conversation

@brenelz

@brenelz brenelz commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

Summary

WIP, opened for discussion. Not ready to merge.

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.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Oct 25, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 3639319

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

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@brenelz

brenelz commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Another approach could be some kind of Loading Fallback Diagnostic

@codspeed

codspeed Bot commented Oct 25, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 6.36%

2 warnings

⚠️ 3 benchmarks are dominated by system calls

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.

Learn more about system calls

See affected benchmarks

⚠️ Base and head ran on different runner types

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.

Learn more about Macro Runners

Open the report in CodSpeed to investigate

❌ 1 regressed benchmark
✅ 195 untouched benchmarks
⏩ 5 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ memo + sync render effect only (reference) 29.6 ms 31.6 ms -6.36%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing brenelz:proto/may-suspend-brand (3639319) with next (2a0da2f)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

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