Skip to content

refactor(store): improve tree-shaking of alien-signals implementation#297

Merged
KevinVandy merged 2 commits intoTanStack:mainfrom
Sheraff:refactor-store-alien-signal-tree-shaking
Mar 25, 2026
Merged

refactor(store): improve tree-shaking of alien-signals implementation#297
KevinVandy merged 2 commits intoTanStack:mainfrom
Sheraff:refactor-store-alien-signal-tree-shaking

Conversation

@Sheraff
Copy link
Copy Markdown
Contributor

@Sheraff Sheraff commented Mar 21, 2026

🎯 Changes

Ensure nothing from the signals implementation is used by the atoms implementation

  • splitting the batch logic
  • atoms do not refer to the flush() method of signals anymore
  • move signals to their own file. This file is not exported from the lib, and ignored by knip.

In Router, these changes translate into bundle-size savings

  • -1.15 kB raw
  • -354 B gzip
  • -455 B brotli

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm test:pr.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • Chores
    • Improved tree-shaking capabilities for better bundle optimization
    • Reorganized internal module structure for enhanced performance
    • Updated documentation references to reflect code changes

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 21, 2026

📝 Walkthrough

Walkthrough

Reorganizes reactive system implementations from alien.ts to a new signal.ts module, marks createReactiveSystem with a no-side-effects annotation for tree-shaking optimization, consolidates the batch function into atom.ts using local batching logic, removes the standalone batch module, and updates documentation source references accordingly.

Changes

Cohort / File(s) Summary
Changeset Release Metadata
.changeset/curvy-apes-matter.md
Added patch-level release note documenting improved tree-shaking of alien-signals.
Documentation Source References
docs/reference/functions/batch.md, docs/reference/functions/createAsyncAtom.md, docs/reference/functions/createAtom.md, docs/reference/functions/flush.md
Updated "Defined in" line references for four function documentation pages to reflect source location changes in atom.ts.
Configuration Update
knip.json
Modified ignore list to exclude packages/store/src/signal.ts instead of packages/store/src/alien.ts from Knip analysis.
Core Reactive System Refactoring
packages/store/src/alien.ts, packages/store/src/signal.ts
Removed all reactive runtime implementations (signal, computed, effect, effectScope, trigger, batching utilities, node interfaces) from alien.ts; created new signal.ts module containing complete reactive system with signal creation, computed values, effects, effect scopes, and dependency tracking. Marked createReactiveSystem in alien.ts with /*@__NO_SIDE_EFFECTS__*/ annotation.
Batch Function Migration
packages/store/src/atom.ts, packages/store/src/batch.ts, packages/store/src/index.ts
Removed standalone batch.ts module; integrated batch(fn) function directly into atom.ts with local batchDepth counter instead of relying on alien.ts batching utilities; removed re-export of batch from main index.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 We hop through the signals with code neat and trim,
From alien lands to signal's bright whim,
Batch depths are counted where batches belong,
Tree-shaking grows stronger, our trees grow more long! 🌳✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the main objective of the PR—improving tree-shaking by separating signal and atom implementations into distinct modules.
Description check ✅ Passed The pull request description provides clear context about the changes, motivation, and bundle-size impact, with the contributing guide and local testing checklist partially completed.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@nx-cloud
Copy link
Copy Markdown

nx-cloud bot commented Mar 21, 2026

View your CI Pipeline Execution ↗ for commit 4a10f8f

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded 52s View ↗
nx run-many --target=build --exclude=examples/** ✅ Succeeded 16s View ↗

☁️ Nx Cloud last updated this comment at 2026-03-21 23:07:39 UTC

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 21, 2026

@tanstack/angular-store

npm i https://pkg.pr.new/@tanstack/angular-store@297

@tanstack/preact-store

npm i https://pkg.pr.new/@tanstack/preact-store@297

@tanstack/react-store

npm i https://pkg.pr.new/@tanstack/react-store@297

@tanstack/solid-store

npm i https://pkg.pr.new/@tanstack/solid-store@297

@tanstack/store

npm i https://pkg.pr.new/@tanstack/store@297

@tanstack/svelte-store

npm i https://pkg.pr.new/@tanstack/svelte-store@297

@tanstack/vue-store

npm i https://pkg.pr.new/@tanstack/vue-store@297

commit: 4a10f8f

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/store/src/signal.ts`:
- Around line 147-167: The effect() function links the new EffectNode into the
reactive graph before running e.fn(), so if e.fn() throws the caller never gets
the disposer and the half-constructed node remains subscribed; change the setup
so that any partial construction is cleaned up on throw: wrap the e.fn()
invocation in try/catch, and in the catch remove the node from its
parent/subscription (undo the link call referencing setActiveSub/link and the
prevSub variable), remove any deps/subs added to e (clear
deps/depsTail/subs/subsTail and clear flags like ReactiveFlags.Watching),
restore activeSub, then rethrow the error so the caller sees it; ensure
effectOper.bind(e) is only returned for fully-initialized nodes (or still
returned after cleanup if you want the same contract) and reference effect,
setActiveSub, link, effectOper, and ReactiveFlags in your changes so the undo
logic targets the correct symbols.
- Around line 92-95: endBatch currently decrements the shared batchDepth
unguarded which allows it to become negative; change endBatch so it only
decrements when batchDepth > 0 (or capture the previous value and clamp to
zero), and only call flush when batchDepth transitions from 1 to 0; use the
existing symbols batchDepth and flush (and function endBatch) to implement this
guard, and optionally log or noop on extra calls instead of letting batchDepth
go negative.
- Around line 223-224: Replace strict inequality checks that currently use !==
with Object.is to correctly compare previous and new signal values (handling NaN
and -0). Specifically, in updateComputed() change the comparison that returns
oldValue !== (c.value = c.getter(oldValue)) to use Object.is(oldValue, newValue)
logic (compute newValue from c.getter(oldValue) then use !Object.is(oldValue,
newValue) for invalidation); apply the same change in updateSignal() where the
code compares previous and assigned values, and in signalOper() at the
comparison around signal value assignment—compute the new value into a
temporary, then use Object.is to decide whether to invalidate/update. Ensure you
reference the same variables c.value, c.getter(oldValue), and the signal
value/temp newValue when making these replacements.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 57513166-d3b1-4f6a-accf-96b12c16578e

📥 Commits

Reviewing files that changed from the base of the PR and between 4ac32a1 and 4a10f8f.

📒 Files selected for processing (11)
  • .changeset/curvy-apes-matter.md
  • docs/reference/functions/batch.md
  • docs/reference/functions/createAsyncAtom.md
  • docs/reference/functions/createAtom.md
  • docs/reference/functions/flush.md
  • knip.json
  • packages/store/src/alien.ts
  • packages/store/src/atom.ts
  • packages/store/src/batch.ts
  • packages/store/src/index.ts
  • packages/store/src/signal.ts
💤 Files with no reviewable changes (2)
  • packages/store/src/index.ts
  • packages/store/src/batch.ts

Comment on lines +92 to +95
export function endBatch() {
if (!--batchDepth) {
flush()
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Prevent endBatch() from corrupting the scheduler state.

An extra endBatch() call pushes the shared batchDepth negative here, and from that point later writes stop flushing on the right boundary because the module never gets back to the expected 0 → flush transition.

🐛 Proposed fix
 export function endBatch() {
+  if (batchDepth <= 0) {
+    throw new Error('endBatch() called without a matching startBatch()')
+  }
   if (!--batchDepth) {
     flush()
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/store/src/signal.ts` around lines 92 - 95, endBatch currently
decrements the shared batchDepth unguarded which allows it to become negative;
change endBatch so it only decrements when batchDepth > 0 (or capture the
previous value and clamp to zero), and only call flush when batchDepth
transitions from 1 to 0; use the existing symbols batchDepth and flush (and
function endBatch) to implement this guard, and optionally log or noop on extra
calls instead of letting batchDepth go negative.

Comment on lines +147 to +167
export function effect(fn: () => void): () => void {
const e: EffectNode = {
fn,
subs: undefined,
subsTail: undefined,
deps: undefined,
depsTail: undefined,
flags: ReactiveFlags.Watching | ReactiveFlags.RecursedCheck,
}
const prevSub = setActiveSub(e)
if (prevSub !== undefined) {
link(e, prevSub, 0)
}
try {
e.fn()
} finally {
activeSub = prevSub
e.flags &= ~ReactiveFlags.RecursedCheck
}
return effectOper.bind(e)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Dispose partially constructed effects/scopes when setup throws.

Both factories link the new node into the graph before running user code. If fn() throws during that first run, the caller never receives the disposer, but the half-built node stays subscribed through any deps/parent links created before the throw.

🐛 Proposed fix
 export function effect(fn: () => void): () => void {
   const e: EffectNode = {
     fn,
     subs: undefined,
     subsTail: undefined,
     deps: undefined,
     depsTail: undefined,
     flags: ReactiveFlags.Watching | ReactiveFlags.RecursedCheck,
   }
   const prevSub = setActiveSub(e)
   if (prevSub !== undefined) {
     link(e, prevSub, 0)
   }
+  let didThrow = true
   try {
     e.fn()
+    didThrow = false
   } finally {
     activeSub = prevSub
     e.flags &= ~ReactiveFlags.RecursedCheck
+    if (didThrow) {
+      effectScopeOper.call(e)
+    }
   }
   return effectOper.bind(e)
 }
 
 export function effectScope(fn: () => void): () => void {
   const e: ReactiveNode = {
     deps: undefined,
     depsTail: undefined,
     subs: undefined,
     subsTail: undefined,
     flags: ReactiveFlags.None,
   }
   const prevSub = setActiveSub(e)
   if (prevSub !== undefined) {
     link(e, prevSub, 0)
   }
+  let didThrow = true
   try {
     fn()
+    didThrow = false
   } finally {
     activeSub = prevSub
+    if (didThrow) {
+      effectScopeOper.call(e)
+    }
   }
   return effectScopeOper.bind(e)
 }

Also applies to: 169-187

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/store/src/signal.ts` around lines 147 - 167, The effect() function
links the new EffectNode into the reactive graph before running e.fn(), so if
e.fn() throws the caller never gets the disposer and the half-constructed node
remains subscribed; change the setup so that any partial construction is cleaned
up on throw: wrap the e.fn() invocation in try/catch, and in the catch remove
the node from its parent/subscription (undo the link call referencing
setActiveSub/link and the prevSub variable), remove any deps/subs added to e
(clear deps/depsTail/subs/subsTail and clear flags like ReactiveFlags.Watching),
restore activeSub, then rethrow the error so the caller sees it; ensure
effectOper.bind(e) is only returned for fully-initialized nodes (or still
returned after cleanup if you want the same contract) and reference effect,
setActiveSub, link, effectOper, and ReactiveFlags in your changes so the undo
logic targets the correct symbols.

Comment on lines +223 to +224
const oldValue = c.value
return oldValue !== (c.value = c.getter(oldValue))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n packages/store/src/signal.ts | sed -n '220,240p'

Repository: TanStack/store

Length of output: 749


🏁 Script executed:

cat -n packages/store/src/signal.ts | sed -n '305,315p'

Repository: TanStack/store

Length of output: 490


🏁 Script executed:

wc -l packages/store/src/signal.ts

Repository: TanStack/store

Length of output: 90


🏁 Script executed:

rg "Object\.is" packages/store/src/ -A 2 -B 2

Repository: TanStack/store

Length of output: 385


🏁 Script executed:

rg "NaN" packages/store/src/signal.ts

Repository: TanStack/store

Length of output: 40


🏁 Script executed:

# Search for any tests related to cache invalidation with NaN values
rg "NaN" packages/store --type-list | head -20

Repository: TanStack/store

Length of output: 447


🏁 Script executed:

# Search for tests related to NaN or signals
find packages/store -name "*.test.*" -o -name "*.spec.*" | head -10

Repository: TanStack/store

Length of output: 247


🏁 Script executed:

# Check what the compare function defaults to in the context
cat -n packages/store/src/atom.ts | sed -n '1,50p'

Repository: TanStack/store

Length of output: 1912


🏁 Script executed:

# Look at the broader context of the three locations mentioned
cat -n packages/store/src/signal.ts | sed -n '218,240p'
cat -n packages/store/src/signal.ts | sed -n '230,240p'
cat -n packages/store/src/signal.ts | sed -n '305,320p'

Repository: TanStack/store

Length of output: 1648


🏁 Script executed:

# Check if there's a custom compare function already used elsewhere
rg "compare" packages/store/src/signal.ts -B 2 -A 2

Repository: TanStack/store

Length of output: 40


🏁 Script executed:

# Look for the complete function signatures to understand context better
cat -n packages/store/src/signal.ts | sed -n '210,230p'

Repository: TanStack/store

Length of output: 642


🏁 Script executed:

# Check if there are any TypeScript type annotations that might restrict values
rg "SignalNode|ComputedNode" packages/store/src/signal.ts -A 5 | head -50

Repository: TanStack/store

Length of output: 1489


🏁 Script executed:

# Search test files for NaN-related tests
grep -r "NaN" packages/store/tests/

Repository: TanStack/store

Length of output: 40


🏁 Script executed:

# Look at how atom.ts implements the compare function
cat -n packages/store/src/atom.ts | sed -n '50,120p'

Repository: TanStack/store

Length of output: 2341


🏁 Script executed:

# Check if there's any documentation or comments about equality comparisons
rg "equality|compare|SameValue|Object.is" packages/store/src/ -B 1 -A 1

Repository: TanStack/store

Length of output: 627


🏁 Script executed:

# Look for any existing issues or patterns around NaN handling
git log --oneline --all -S "NaN" packages/store/ 2>/dev/null | head -5

Repository: TanStack/store

Length of output: 40


Replace !== with Object.is for cache invalidation to handle NaN and -0 correctly.

The !== comparisons treat NaN as changed every time (since NaN !== NaN is true), causing unnecessary downstream invalidation. Object.is correctly treats NaN values as equal and handles -0 properly, aligning with the approach already used by default in atom.ts.

Apply to:

  • Line 224 in updateComputed()
  • Line 234 in updateSignal()
  • Line 310 in signalOper()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/store/src/signal.ts` around lines 223 - 224, Replace strict
inequality checks that currently use !== with Object.is to correctly compare
previous and new signal values (handling NaN and -0). Specifically, in
updateComputed() change the comparison that returns oldValue !== (c.value =
c.getter(oldValue)) to use Object.is(oldValue, newValue) logic (compute newValue
from c.getter(oldValue) then use !Object.is(oldValue, newValue) for
invalidation); apply the same change in updateSignal() where the code compares
previous and assigned values, and in signalOper() at the comparison around
signal value assignment—compute the new value into a temporary, then use
Object.is to decide whether to invalidate/update. Ensure you reference the same
variables c.value, c.getter(oldValue), and the signal value/temp newValue when
making these replacements.

@davidkpiano davidkpiano self-requested a review March 25, 2026 17:17
@KevinVandy KevinVandy merged commit d8b51a7 into TanStack:main Mar 25, 2026
6 of 7 checks passed
@github-actions github-actions bot mentioned this pull request Mar 25, 2026
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.

3 participants