Skip to content

feat(signal): add a value getter that subscribes - #38

Open
brnrdog wants to merge 2 commits into
mainfrom
brnrdog/compassionate-maxwell-qvdaac
Open

brnrdog wants to merge 2 commits into
mainfrom
brnrdog/compassionate-maxwell-qvdaac

Conversation

@brnrdog

@brnrdog brnrdog commented Sep 17, 2026

Copy link
Copy Markdown
Owner

What

signal.value now reads through get, so it subscribes the current observer exactly as Signal.get(signal) does:

let count = Signal.make(0)

count.value          // reads and subscribes, like Signal.get(count)
"n = " ++ count.value->Int.toString   // works inside an expression

Why

Reading a signal currently means a function call, which is fine on its own but awkward the moment the value sits inside a larger expression. This is the same ergonomic Preact signals have, and it is the one shape ReScript can express, since record field access compiles to plain property access.

The motivating case is Xote's @xote.component, where a signal handed straight to the view is already resolved at runtime, but a signal inside a compound expression (class={"theme-" ++ theme}) is a type error. With this, class={"theme-" ++ theme.value} typechecks and stays reactive, with no type knowledge needed in the PPX.

How

The stored value moves to a raw field. Everything inside the package reads and writes that; value is an accessor over it. Keeping the two apart is what stops the accessor recursing into itself — this is the one correctness rule for anyone touching Signal.res afterwards.

value is not mutable, so signal.value = x still does not compile and no write can bypass the scheduler.

The accessor lives on a prototype, deliberately

An own accessor per instance moves the object into dictionary mode, which costs every other field access on the signal — subs and raw on the hot get path included. I measured both:

baseline own accessor prototype
create 10k signals 19.8 ms 356.9 ms 23.9 ms
get 1k x100 10.2 ms 35.4 ms 10.4 ms
set 1k x100 11.5 ms 64.9 ms 12.0 ms
create 5k computeds 22.4 ms 178.1 ms 25.0 ms

Per-instance was 18x slower creation and 3.5x slower reads. On the prototype every instance keeps one hidden class and the getter is free. The constructor is built once on first use and closed over inside the raw block, so nothing leaks to a global.

Tests

Five new cases in SignalTests.res: reading, seeing a write, subscribing an enclosing effect, refreshing a stale computed, and staying untracked inside untrack. Full suite is green — 68 in rescript-signals, 17 in rescript-signals-react.

One thing worth knowing, unrelated to this change

While checking this against Xote, one Xote test (View.render disposes its computed when the component unmounts) fails on main as it stands today, with or without this PR. Xote pins 3.1.0, and something in the detach/settle work since then changed that behaviour. Worth a look before the next release, but it is not caused by this branch.

Open question

Exposing value means the record fields are visible to consumers that re-export the type. Xote currently keeps Signal.t abstract, so it needs a matching decision on its side about how much of the record to expose. That is a Xote change, not this one.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LGJYMRxSMa4uSvmr4DjJyi


Generated by Claude Code

`signal.value` now reads through `get`, so it subscribes the current
observer exactly as `Signal.get(signal)` does. This lets a signal be
used directly inside an expression:

    let label = "theme-" ++ theme.value

The stored value moves to a `raw` field. Everything inside the package
reads and writes that; `value` is an accessor over it, and keeping the
two apart is what stops the accessor recursing into itself. `value` is
not mutable, so `signal.value = x` still does not compile and no write
can skip the scheduler.

The accessor lives on a prototype rather than on each instance. That
is not cosmetic: an own accessor moves the object into dictionary
mode, which costs every other field access on the signal, including
`subs` and `raw` on the hot `get` path. Measured on this package's
benchmark, per-instance was 3x slower reads and 18x slower creation;
on the prototype every instance keeps one hidden class.

    create 10k signals   19.8ms before   23.9ms after
    get 1k x100          10.2ms before   10.4ms after
    set 1k x100          11.5ms before   12.0ms after
    create 5k computeds  22.4ms before   25.0ms after

Five tests cover the accessor: reading, seeing a write, subscribing an
effect, refreshing a stale computed, and staying untracked inside
`untrack`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGJYMRxSMa4uSvmr4DjJyi
@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown

ReScript Signals benchmark: PR vs main

Compared implementations:

  • ReScript Signals (main)
  • ReScript Signals (PR)

Overall:

Version Total ms Avg ms/test
ReScript Signals (main) 5337.55 266.88
ReScript Signals (PR) 5743.90 287.20
Delta (ReScript Signals (PR) - ReScript Signals (main)) 406.35 7.61%

Per-test delta (lower is better):

Test Main ms PR ms Diff ms Diff %
2-10x5 - lazy80% 469.62 523.83 54.22 11.55%
25-1000x5 1080.09 1059.67 -20.43 -1.89%
3-5x500 216.15 250.49 34.34 15.89%
4-1000x12 - dyn5% 753.81 850.81 97.01 12.87%
6-100x15 - dyn50% 423.66 438.15 14.49 3.42%
6-10x10 - dyn25% - lazy80% 277.30 280.90 3.60 1.30%
avoidablePropagation 306.66 361.28 54.63 17.81%
broadPropagation 211.66 248.81 37.14 17.55%
cellx1000 16.00 14.37 -1.63 -10.20%
cellx2500 46.12 43.50 -2.62 -5.68%
createComputations 193.73 212.51 18.78 9.70%
createSignals 3.29 3.02 -0.27 -8.19%
deepPropagation 107.19 134.78 27.59 25.74%
diamond 182.36 212.12 29.76 16.32%
molBench 45.14 45.07 -0.06 -0.14%
mux 242.99 270.95 27.96 11.51%
repeatedObservers 41.45 40.80 -0.65 -1.56%
triangle 58.77 64.77 6.00 10.21%
unstable 67.79 64.59 -3.20 -4.72%
updateSignals 593.78 623.47 29.70 5.00%

Note: single-machine run in CI. Numbers can vary with runner load and Node/V8 version.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fe9798f777

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

/* Where the value is actually stored. Every read and write *inside* this
package goes here; `value` below is an accessor installed over it, and
keeping the two apart is what stops the accessor recursing into itself. */
mutable raw: 'a,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the backing field private

Signal.t is exported as an open record, so downstream ReScript consumers can write signal.raw = next. Unlike Signal.set, that mutation neither increments subs.version nor calls Scheduler.notifySubs, leaving dependent effects and computeds stale. This new mutable public field therefore still permits writes that bypass the scheduler; make the backing representation opaque or otherwise prevent external access to raw.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked this, and it's pre-existing rather than introduced here — main already has:

type t<'a> = {
  id: int,
  mutable value: 'a,
  ...
}

with no .resi anywhere in the package (find . -name '*.resi' returns nothing), so signal.value = next already bypasses Scheduler.notifySubs on main today. This PR renames that storage to raw; the escape hatch moves, it doesn't appear.

If anything the surface narrows: value is now non-mutable, so the assignment a consumer would reach for by accident — signal.value = x — stops compiling. Getting past the scheduler now requires deliberately writing signal.raw.

On the suggested remedy: making the representation opaque is mutually exclusive with the feature. count.value typechecking in consumer code requires the record to be public, and ReScript has no per-field privacy — an interface file has to restate the record type exactly, so it can't expose value while hiding raw. I also tried @deprecated on the field as a softer mitigation:

@deprecated("Internal storage. Use Signal.get / Signal.set.") mutable raw: 'a,

It compiles clean and produces no warning at an external use site, so it's inert here — not a usable middle ground.

That leaves the tradeoff as stated in the PR's "Open question": a readable .value field costs a visible record. Closing it properly means an abstract t with .value dropped, which is this PR reverted. Leaving the thread open for you to make that call.


Generated by Claude Code

@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown

Reactivity benchmark: PR vs top frameworks

Compared implementations:

  • Alien Signals
  • Preact Signals
  • SolidJS
  • Svelte v5
  • Vue
  • ReScript Signals (PR)

Overall ranking (lower total ms is better):

Rank Framework Total ms Avg ms/test
1 Alien Signals 2776.83 138.84
2 Preact Signals 2871.66 143.58
3 ReScript Signals (PR) 3667.21 183.36
4 Vue 4218.80 210.94
5 Svelte v5 6629.58 331.48
6 SolidJS 7688.38 384.42

Per-test runtime (ms):

Framework 2-10x5 - lazy80% 25-1000x5 3-5x500 4-1000x12 - dyn5% 6-100x15 - dyn50% 6-10x10 - dyn25% - lazy80% avoidablePropagation broadPropagation cellx1000 cellx2500 createComputations createSignals deepPropagation diamond molBench mux repeatedObservers triangle unstable updateSignals
Alien Signals 243.02 502.36 174.93 388.47 215.76 193.72 69.09 144.41 13.86 40.21 71.76 4.14 44.65 92.10 22.44 129.94 22.56 29.63 35.71 338.07
Preact Signals 302.16 521.28 161.11 435.98 229.88 188.63 73.42 139.28 9.99 32.43 79.73 4.32 50.45 97.05 21.80 128.73 15.06 33.41 25.18 321.76
ReScript Signals (PR) 354.70 560.32 158.39 405.86 253.76 209.64 254.59 177.72 16.75 46.34 186.26 5.61 78.27 147.37 31.16 232.15 26.62 43.15 45.42 433.12
Vue 444.24 737.80 226.15 549.43 301.93 277.27 174.05 211.16 28.22 88.31 111.43 4.95 78.22 140.37 48.10 185.20 20.30 47.02 36.62 508.03
Svelte v5 905.13 861.46 264.28 730.54 312.18 285.50 524.49 313.05 14.86 47.46 109.29 3.61 125.57 374.93 23.53 194.45 68.42 101.22 94.50 1275.11
SolidJS 1584.32 1083.47 399.32 813.38 474.98 549.43 234.43 435.14 26.01 69.46 140.00 6.04 148.96 328.51 27.41 230.57 83.40 105.58 114.84 833.14

Note: single-machine run in CI. Numbers can vary with runner load and Node/V8 version.

brnrdog commented Sep 17, 2026

Copy link
Copy Markdown
Owner Author

The benchmark numbers above are a harness artifact, not a regression

The bot reported createSignals +69.26% and createComputations +60.09%. I reproduced the CI setup locally (same js-reactivity-benchmark sbench, same framework adapter, same min-of-10) and ran a control: main vs main — two copies of the identical build, loaded from two directories so they're separate module instances.

Test main (slot 1) main (slot 2) "Delta"
createSignals 25.2 8.1 -68.0%
createComputations 568.5 597.8 +5.2%
updateSignals 620.4 698.1 +12.5%

Repeated twice more: -66.7% / -66.5% on createSignals, +11.3% / +15.3% on updateSignals. Identical code both sides. The number the harness produces is a function of which slot you run in, not of what you changed.

The reason is that benchmark-pr-vs-main.mjs loads both implementations into one V8 isolate and runs them back to back. createSignals is the smallest measurement in the suite (single-digit to ~25 ms) and sits entirely inside the JIT warmup window, so a 17 ms swing reads as 3x. There's also no --expose-gc, so sbench's if (globalThis.gc) cleanup never runs and whichever framework goes second inherits the other's heap.

Swapping the order and adding a third slot makes it unambiguous — the assignment of implementation to slot is irrelevant:

warm=main, pr, main   → createSignals   24.3    8.3    8.2
warm=main, main, pr   → createSignals   24.8   16.8   15.7

Same positions, 8 ms vs 17 ms, and in the first run the PR is the fast one.

What the change actually costs

One framework per process, five runs each (min-of-10 within each run):

Test main PR
createSignals 24.6 / 25.8 / 24.6 / 24.5 24.5 / 24.9 / 24.5 / 25.1
createComputations 500 / 580 / 563 / 496 573 / 582 / 516 / 514
updateSignals 609 / 642 / 627 / 623 615 / 626 / 631 / 617

createSignals and updateSignals are flat. createComputations has a ±8% run-to-run spread on both sides that swamps any difference between them. new Ctor(...) with a prototype accessor costs the same as the object literal, which matches the table in the PR description.

The harness is worth fixing separately

The in-process design will keep producing ±68% false deltas on every PR that touches this package. The fix is to run each implementation in its own node child process and compare the two outputs, and to pass --expose-gc so sbench's GC cleanup actually runs. That's a change to scripts/ci/benchmark-pr-vs-main.mjs, unrelated to this diff — happy to open it as its own PR rather than widen this one.


Generated by Claude Code

@brnrdog

brnrdog commented Sep 18, 2026

Copy link
Copy Markdown
Owner Author

[claude] Need to think more about the trade-offs for this one. List a summary of pros and cons, weighting the performance impact, bundle size, complexity added to the lib and its ecosystem, API simplification (read it as less ambiguity for humans and AI agents), and any other interesting metrics I might have missed for maintaining and consuming the lib.

brnrdog commented Sep 18, 2026

Copy link
Copy Markdown
Owner Author

Trade-offs

Measured rather than estimated where I could. The headline: performance and bundle are non-issues; the real cost is that .value stops being an inert property, and that has consequences beyond this repo.

Performance — neutral

One framework per process, 5 runs each, min-of-10 within each (the in-process A/B the CI job uses is unreliable, see my comment above):

Test main PR
createSignals 24.6 / 25.8 / 24.6 / 24.5 24.5 / 24.9 / 24.5 / 25.1
createComputations 500 / 580 / 563 / 496 573 / 582 / 516 / 514
updateSignals 609 / 642 / 627 / 623 615 / 626 / 631 / 617

Flat. The prototype accessor costs nothing because every instance keeps one hidden class. Weight: zero. This was the risk I expected to dominate and it doesn't.

Bundle — negligible

raw gzip
Signal.res.mjs +425 B +164 B
Computed.res.mjs +26 B +3 B
all signals modules +451 B +204 B

200 bytes gzipped for the whole package. Weight: near zero, unless the pitch is "zero-dependency and tiny," where a 4% gzip bump on the reactive core is worth a shrug.

Complexity — small but sharp

Nine sites across two files must read .raw and never .value; one slip is infinite recursion, not a test failure. That's a new class of bug in the most-edited file in the package, and it's invisible at the call site — signal.value inside Signal.res looks perfectly normal. The %raw constructor block also makes t no longer a plain object literal, so anything reasoning about its runtime shape is now reasoning about a prototype.

Weight: moderate. Not the line count, the invariant.

API simplification — genuinely good, with one asymmetry

"theme-" ++ theme.value beats "theme-" ++ Signal.get(theme) in a compound expression, and it's the idiom Preact/Vue readers and LLMs already carry. For agents specifically, the win is real: .value needs no import and no knowledge of which module the accessor lives in.

But the read API becomes two shapes with different capabilities:

tracked read untracked read
function Signal.get(s) Signal.peek(s)
field s.value — none —

There's no .peek field form, and there can't be a sensible one. So .value is the only field-style read, and it's the tracking one. Someone reaching for the "simple" syntax gets the subscribing behaviour by default — usually right, occasionally a surprise, and never visible in the diff.

What I'd add to your list: .value is no longer inert

This is the finding I'd weight highest, because it reaches past this repo. I measured it against both builds:

                                  main     PR
shape test that reads .value  ->  inert    SUBSCRIBES
shape test using `in`         ->  inert    inert
Object.keys                       [...,"value",...]   [...,"raw",...]
{...s} has value                  true     false
JSON.stringify has value          true     false

Two concrete consequences:

  1. A property read is now a tracked read. Xote's RuntimeValue.isSignalLike compiles to value["value"] — it asks "is this a signal?" by reading the field. Under this PR, that type test subscribes the enclosing observer and calls ensureComputedFresh. Xote's other detector (View.isSignal) uses "value" in v and is unaffected. Same library, same question, two spellings, and only one of them is still side-effect-free. Any consumer doing shape detection, logging, or a debugger watch expression has the same hazard.
  2. value left the own-property set. Spread, Object.keys and JSON.stringify now show raw and not value. Anything serialising or cloning a signal changes output.

Neither is hypothetical and neither is in this diff's tests.

Ecosystem / maintenance

  • Publishing the record is a semver commitment. Once t's fields are public, adding or renaming one is breaking for anyone who destructures or constructs. Today t is effectively opaque in practice; after this it isn't.
  • The scheduler bypass moves, it doesn't close. signal.value = x stops compiling (good), signal.raw = x starts compiling (not good). Net exposure is unchanged; the sharp edge just has a less inviting name.
  • Xote inherits a decision. Its Signal.t is abstract on purpose, pinned by tests/consumer/forbidden/SignalValueWrite.res. ReScript has no per-field privacy, so Xote can't publish value while hiding raw. Detailed in brnrdog/xote#167 and docs/proposals/signal-unwrapping.md.

Summary

Axis Verdict
Performance Neutral — measured flat
Bundle Neutral — +204 B gzip
Complexity Moderate — 9 sites under a recursion invariant
API ergonomics Positive — real win in compound expressions, real win for agents
Read-API coherence Negative — field form exists only for the tracking read
Side-effect transparency Negative — property reads and shape tests now subscribe
Encapsulation Negative — record goes public, bypass renames rather than closes

The case for merging is ergonomics; the case against is that it trades a visible cost (Signal.get(...) is noisy but honest) for an invisible one (a field read that subscribes). Performance and size, the two things that looked like blockers, are not.

If it helps break the tie: the isSignalLike hazard is fixable in Xote in one line ("value" in v), and the same fix generalises — detect with in, never with a read. That's a documentable rule. The read-API asymmetry has no such fix.


Generated by Claude Code

#39 moved the signal record into `Core.cell` so the scheduler can write a
computed's result directly, and collapsed `Computed.makeWithEquals` into
`makeWith(~equals, ~deferEffectsUntilRecompute)`. Both sides touched the same
record, so the resolution has to hold two invariants at once:

- `Core.cell` carries the `raw`/`value` split, since `Signal.t` is that type.
  The scheduler writes `raw`: writing `value` would recurse into the getter,
  and reading it during a recompute would subscribe the computed to itself.
- `Computed` builds through `Signal.makeRecord` rather than a record literal.
  The `value` accessor lives on the prototype that constructor installs, so a
  literal would produce a signal whose `.value` is undefined.

The per-computed `recompute` closures and the `signalRef` indirection this
branch carried are gone with `makeWithEquals` — the scheduler stores the value
and bumps the version itself now, which is the point of #39.

Suites green: 69 in `rescript-signals`, 17 in `rescript-signals-react`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGJYMRxSMa4uSvmr4DjJyi

brnrdog commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

Re-measured after the merge — and a correction to what I said earlier

The benchmark comment re-ran on the merged head (3e1eede) and reported a different set of regressions: propagation tests up 10–26%, where the pre-merge run had flagged createSignals +69% and createComputations +60%. I re-measured all 20 tests, one framework per process.

First, the correction. In my trade-off comment above I wrote "Performance — neutral. Weight: zero" and attributed the CI numbers to a harness ordering artifact. That control only covered sbench. I have now run the same control on the kairo propagation tests — main vs main, identical code, through the CI harness shape — and it comes out at ±4%, not the ±68% I measured for createSignals:

Test mainA mainB "delta"
avoidablePropagation 333.0 / 338.9 326.5 / 334.5 -2.0% / -1.3%
deepPropagation 133.4 / 124.7 128.4 / 130.1 -3.8% / +4.3%
diamond 154.6 / 157.3 155.7 / 153.4 +0.7% / -2.5%
mux 232.6 / 232.1 232.3 / 240.9 -0.1% / +3.8%

So for kairo the harness is reasonably stable, and "ordering artifact" does not explain the +10–26%. My earlier framing was too broad — it held for sbench, and I generalised it.

What isolation actually shows

Medians of 3 runs, one framework per process:

Test main (bd6806c) PR (3e1eede) Measured CI claimed
avoidablePropagation 307.9 302.9 -1.6% +17.81%
broadPropagation 199.3 194.8 -2.3% +17.55%
deepPropagation 112.7 111.5 -1.1% +25.74%
diamond 149.4 140.5 -6.0% +16.32%
mux 227.7 214.3 -5.9% +11.51%
triangle 47.1 45.2 -4.0% +10.21%
repeatedObservers 30.6 29.5 -3.6% -1.56%
unstable 51.3 49.8 -2.9% -4.72%
molBench 39.8 38.9 -2.3% -0.14%

Every test is equal or slightly faster. The uniform small negative sign across all nine suggests machine drift rather than a real win — the honest reading is no regression, not "6% faster".

cellx and the dynamic graph tests, same method (2 runs each), have wide run-to-run spread and no consistent direction — e.g. 4-1000x12 - dyn5%: main 556/602, PR 504/619; 25-1000x5: main 772/796, PR 650/697.

The strongest evidence is the two CI runs against each other

Same branch, essentially the same change, two runs:

Test run 1 (fe9798f) run 2 (3e1eede)
createSignals +69.26% -8.19%
createComputations +60.09% +9.70%
deepPropagation not flagged +25.74%
broadPropagation not flagged +17.55%

createSignals swung from +69% to −8% on a code path neither commit touched. The set of "regressed" tests is nearly disjoint between runs. A real regression hits the same tests twice.

What I can't explain, stated plainly

The overall figure landed at +7.42% and +7.61% — twice, which is more consistent than the per-test numbers. I cannot reproduce it here and I cannot rule out a small diffuse cost that only shows on the CI runner's hardware or Node build. What I can say is that no individual test reproduces a regression in isolation, and the per-test attribution in that comment is not trustworthy.

Until the harness runs each implementation in its own process (and with --expose-gc, so sbench's GC cleanup actually happens), these numbers can't settle the question either way. I'd still rather fix that in its own PR than widen this one — say the word.


Generated by Claude Code

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.

2 participants