Skip to content

a strange new dom alternative#1

Merged
NellowTCS merged 8 commits into
mainfrom
fs-dom
Mar 16, 2026
Merged

a strange new dom alternative#1
NellowTCS merged 8 commits into
mainfrom
fs-dom

Conversation

@NellowTCS

@NellowTCS NellowTCS commented Mar 15, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Path-based reactive graph and node APIs for scoped state and path utilities
    • New flow controls: sequence(), parallel(), race(), and updated pipeline/start APIs
    • Configurable runtime via configureSairin() and a new transition result API
  • Improvements

    • Array/Map stores: richer collection methods, iteration, and length/size accessors
    • Effect system: pooled reuse and idle scheduling; Suspense exposes loading/error signals
    • Improved testing hooks and registry reset utilities
  • Chores

    • Project scripts and formatting tooling updated; test tooling bumped and logging integration added

@codesandbox

codesandbox Bot commented Mar 15, 2026

Copy link
Copy Markdown

Review or Edit in CodeSandbox

Open the branch in Web EditorVS CodeInsiders

Open Preview

@coderabbitai

coderabbitai Bot commented Mar 15, 2026

Copy link
Copy Markdown

Important

Review skipped

This PR was authored by the user configured for CodeRabbit reviews. CodeRabbit does not review PRs authored by this user. It's recommended to use a dedicated user account to post CodeRabbit review feedback.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e07348ce-a392-4d02-851d-4c02093b1183

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)
📝 Walkthrough

Walkthrough

Adds a path-keyed reactive graph core (path, graph, node registry, locking, incremental cleanup), migrates signals/derived/effects to graph-backed nodes with path-scoped IDs, expands kernel API/config, refactors async/flow/store to use path-scoped signals, and adds tests plus tooling updates.

Changes

Cohort / File(s) Summary
Graph & Path Core
Build/src/kernel/graph.ts, Build/src/kernel/path.ts
Adds a typed PathKey system, graph node registry, subscription/watch/alias APIs, locking/assertions, incremental cleanup, capRetainedMemory, and testing reset hooks.
Kernel API & Index
Build/src/kernel/index.ts, Build/src/index.ts
Expands public exports (path utilities, graph ops, config, generateUniqueId, isSignal, effectIdle, etc.), and updates createSignal/createMemo exports to path-first signatures.
Signals / Derived / Effect / Batch
Build/src/kernel/signal.ts, Build/src/kernel/derived.ts, Build/src/kernel/effect.ts, Build/src/kernel/batch.ts
Migrates Signal/Derived to graph-backed nodes (path, version, node access), adds effect pooling/schedulers (effectIdle), onCleanup stack, error logging, flushGeneration guard and test reset API.
Config & IDs
Build/src/kernel/config.ts, Build/src/kernel/dependency.ts
Introduces Sairin configuration (LockViolationBehavior, satori logger helpers) and generateUniqueId() utility.
Async / Resource / Transition
Build/src/async/... (index.ts, resource.ts, transition.ts)
Converts resources/transitions to path-scoped signals with per-instance ids; deferred API returns disposable wrapper; adds useTransitionResult and accepts raw or Signal in useDeferredValue.
Flow primitives
Build/src/flow/index.ts
Refactors flows to per-flow path-scoped signals and IDs; pipeline API now single fn; introduces Sequence, Parallel, Race primitives with running/result/error signals and start/cancel semantics.
Stores (Array/Map/Reactive)
Build/src/store/array.ts, Build/src/store/map.ts, Build/src/store/reactive.ts, Build/src/store/index.ts
Store types use per-instance path-scoped signals and ids; add length/size getters, iteration helpers, $/$raw on reactive objects, key-id mapping for maps, and testing reset for store ids.
Graph-aware Tests
Build/tests/* (graph, path, kernel, flow-async, context-debug, benchmark, etc.)
Extensive new and updated tests to exercise path matching, locking, aliasing, incremental cleanup, and path-aware Signal/Derived/registry reset behavior.
DOM / Context / Debug formatting
Build/src/dom/bindings.ts, Build/src/context/index.ts, Build/src/debug/index.ts
Mostly formatting/signature trailing-comma updates; minor function signature formatting changes without behavioral changes.
Batch & Kernel utilities
Build/src/kernel/batch.ts, other kernel helpers
Adds flushGeneration guard for scheduled effects and exposes __resetBatchForTesting; various kernel utilities integrated with graph.
Build tooling & package
Build/package.json, Build/TODO.md, .gitignore
Bumps Jest to ^30.x, adds @nisoku/satori-log dependency and make-pretty script, adds TODO file and ignores *.code-workspace.

Sequence Diagram(s)

sequenceDiagram
    actor App
    participant Signal
    participant Graph
    participant Node as "Graph Node"
    participant Sub as "Subscribers"

    App->>Signal: new Signal(path, value)
    Signal->>Graph: getOrCreateNode(path, "signal")
    Graph->>Node: create/retrieve SignalNode
    Graph-->>Signal: return node
    Signal->>Node: store _node

    App->>Signal: set(newValue)
    Signal->>Graph: isLocked(path)?
    Signal->>Node: update value & version
    Signal->>Graph: notifySubscribers(node)
    Graph->>Sub: invoke subscribers
    Sub-->>App: reactions run

    App->>Signal: get()
    Signal->>Graph: trackNode(node)
    Graph-->>Signal: record dependency
    Signal->>App: return node.value
Loading
sequenceDiagram
    actor App
    participant Pipeline
    participant Executor
    participant Controller as AbortController
    participant Signals as "result/error signals"

    App->>Pipeline: start(input)
    Pipeline->>Controller: create controller
    Pipeline->>Executor: execute fn(input, controller.signal)
    Executor->>Executor: perform async work
    Executor->>Signals: update result or error (path-scoped)
    Signals->>Pipeline: notify subscribers
    Controller-->>Pipeline: abort (if triggered)
    Executor->>Pipeline: mark running false
    Pipeline-->>App: start() promise resolves
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐇 I tunneled through paths and left a trail of nodes,
IDs hop along and effects find cozy abodes.
Signals hum, sequences race, maps and arrays play,
The graph sleeps snug — I nibble carrots all day.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The PR title 'a strange new dom alternative' is vague and does not clearly convey the scope or primary changes of the substantial refactor. The changeset includes major architectural changes across multiple modules (path-based signal system, graph implementation, flow/async redesign, store updates, and extensive testing), but the title uses non-descriptive, colloquial language that fails to summarize the actual changes. Use a more specific title that describes the main architectural change, such as 'Introduce path-based reactive graph system' or 'Refactor signals and derivations to use path-scoped graph nodes'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

📝 Coding Plan
  • Generate coding plan for human review comments

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
Build/src/async/resource.ts (2)

61-63: ⚠️ Potential issue | 🟠 Major

abort() leaves loading stuck at true.

Both abort handlers skip the finally branch that clears loading because the controller is already aborted. A manual cancel therefore never transitions the resource back to idle.

Also applies to: 123-125

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

In `@Build/src/async/resource.ts` around lines 61 - 63, The abort() handler
currently only calls abortController?.abort() which bypasses the finally branch
that clears loading; update the abort implementations (the abort() functions
referencing abortController and the duplicate at lines ~123-125) to also reset
the resource state the finally branch would (e.g., set loading = false and any
status/err state and call the same cleanup function used in finally) so a manual
cancel returns the resource to idle; reuse the existing cleanup/teardown logic
rather than duplicating state changes where possible and ensure abort() does not
leave loading stuck true.

29-51: ⚠️ Potential issue | 🔴 Critical

Scope each load to the controller that started it.

Both load() implementations read the mutable abortController inside .then()/.catch()/.finally(). After refetch() or abort+restart, an older request can still overwrite the newer state; currentLoader does not fix this when the same loader function runs twice.

🔁 Suggested guard pattern
   const load = () => {
     abortController?.abort();
-    abortController = new AbortController();
+    const controller = new AbortController();
+    abortController = controller;

     loading.set(true);
     error.set(null);

     loader()
       .then((data) => {
-        if (!abortController!.signal.aborted) {
+        if (!controller.signal.aborted && abortController === controller) {
           value.set(data);
         }
       })
       .catch((e) => {
-        if (!abortController!.signal.aborted) {
+        if (!controller.signal.aborted && abortController === controller) {
           error.set(e instanceof Error ? e : new Error(String(e)));
         }
       })
       .finally(() => {
-        if (!abortController!.signal.aborted) {
+        if (abortController === controller) {
           loading.set(false);
         }
       });
   };

Apply the same pattern to both load() implementations.

Also applies to: 78-101

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

In `@Build/src/async/resource.ts` around lines 29 - 51, The load() implementation
reads the mutable abortController inside the promise handlers which allows an
older request to overwrite newer state; fix this by scoping the controller
created for each invocation into a local constant (e.g., const currentController
= new AbortController()) immediately after aborting the previous controller,
assign abortController = currentController, and then use
currentController.signal.aborted inside the .then/.catch/.finally handlers (and
do the same change for the other load() implementation around lines 78-101) so
each promise only updates state if its originating controller is still the
active one.
Build/src/flow/index.ts (1)

30-68: ⚠️ Potential issue | 🔴 Critical

Use a run-scoped controller in every start() implementation.

All five async runners store the active controller in a mutable variable and then read it later from async continuations. After cancel() + immediate restart — or overlapping start() calls in pipeline, sequence, parallel, and race — an older run can still clear running or overwrite the newer result/error.

🔁 Suggested pattern (example from flow())
-      abortController = new AbortController();
+      const controller = new AbortController();
+      abortController = controller;
       running.set(true);
       error.set(null);

       const perform = async () => {
         try {
-          const data = await fn(abortController!.signal);
-          if (!abortController!.signal.aborted) {
+          const data = await fn(controller.signal);
+          if (!controller.signal.aborted && abortController === controller) {
             result.set(data);
           }
         } catch (e) {
-          if (!abortController!.signal.aborted) {
+          if (!controller.signal.aborted && abortController === controller) {
             error.set(e instanceof Error ? e : new Error(String(e)));
           }
         } finally {
-          running.set(false);
+          if (abortController === controller) {
+            running.set(false);
+          }
         }
       };

Apply the same guard to results, errors, and winner in the other runners.

Also applies to: 93-116, 141-166, 191-219, 247-275

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

In `@Build/src/flow/index.ts` around lines 30 - 68, The start() implementations
use a shared mutable abortController and async continuations (perform,
currentPromise), which lets an older run overwrite state after cancel()/restart;
fix by creating a run-scoped controller and capturing it in the async closure
(e.g., inside start() do const runController = new AbortController(); assign
abortController = runController only for external cancel(), then use
runController.signal in perform and in all checks), and when clearing state
(setting running, result, error, results, errors, winner, etc.) guard those
updates by confirming the runController is still the active controller (or only
mutate shared state if abortController === runController); apply the same
pattern to other runners (pipeline, sequence, parallel, race) and to variables
named results, errors, winner so only the current run can update them.
Build/src/async/transition.ts (1)

64-97: ⚠️ Potential issue | 🟠 Major

Potential memory leak: effect is never disposed.

The effect() created at line 74 is never cleaned up. Each call to deferred() creates a new effect that will persist for the lifetime of the application, even if the returned deferredValue signal is no longer used.

Consider returning a cleanup mechanism or storing the dispose function:

Proposed approach

If the deferred signal lifecycle is tied to component/scope lifecycle, consider exposing the cleanup:

 export function deferred<T>(
   value: Signal<T>,
   options: DeferredValueOptions<T> = {},
-): Signal<T> {
+): Signal<T> & { dispose: () => void } {
   const { timeoutMs = 0, equals = Object.is } = options;
   const deferredValue = signal(
     path("transition", "deferred", generateUniqueId()),
     value.peek(),
   );

-  effect(() => {
+  const dispose = effect(() => {
     const newValue = value.get();
     // ... rest of effect
   });

-  return deferredValue;
+  return Object.assign(deferredValue, { dispose });
 }

Alternatively, if these are expected to be long-lived, document this behavior so consumers understand the lifecycle implications.

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

In `@Build/src/async/transition.ts` around lines 64 - 97, The effect created
inside deferred() (the call to effect(...) within function deferred<T>) is never
disposed and can leak; capture the dispose/stop function returned by effect and
expose it so callers can clean up the effect when the deferred signal is no
longer needed (for example return a tuple/object containing deferredValue and a
dispose/stop method, or register the dispose to the same lifecycle as
deferredValue by attaching a cleanup callback), and ensure the effect's disposer
is invoked when the consumer calls the returned dispose or when deferredValue is
garbage-collected via an explicit lifecycle hook.
Build/tests/context-debug.test.ts (1)

118-129: ⚠️ Potential issue | 🟡 Minor

Test may not validate graph capture as intended.

The test creates sig1 and sig2 but doesn't call registerSignal() to add them to the internal registry. Looking at lines 88-94, captureGraph() iterates over registeredSignals, so these signals won't appear in the captured graph.

If the intent is to verify that newly created signals appear in the graph snapshot, you need to register them first:

Proposed fix
   test('should capture graph', () => {
     clearGraph();
     
     const sig1 = new Signal(path("debug", "graph1"), 1);
     const sig2 = new Signal(path("debug", "graph2"), 2);
+    registerSignal(sig1);
+    registerSignal(sig2);
     
     const graph = captureGraph();
     
     expect(Array.isArray(graph.signals)).toBe(true);
+    expect(graph.signals.length).toBe(2);
     expect(Array.isArray(graph.effects)).toBe(true);
     expect(Array.isArray(graph.derived)).toBe(true);
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/tests/context-debug.test.ts` around lines 118 - 129, The test creates
signals but never registers them, so captureGraph() (which iterates
registeredSignals) won't include them; update the test to call
registerSignal(sig1) and registerSignal(sig2) after constructing sig1 and sig2
(or otherwise use the API that registers signals) before calling captureGraph(),
keeping clearGraph(), so the captured graph returned by captureGraph() contains
the created signals.
Build/src/store/reactive.ts (1)

73-84: ⚠️ Potential issue | 🟡 Minor

setReactive does not handle nested objects.

The function only updates top-level properties that are Signals, but if value contains nested objects that correspond to nested ReactiveObjects, those won't be recursively updated. The $.set(value) at the end updates the root signal, but individual nested reactive properties remain unchanged.

🐛 Proposed fix to handle nested reactive objects
 export function setReactive<T extends object>(
   reactiveObj: ReactiveObject<T>,
   value: T,
 ): void {
   for (const key of Object.keys(value) as (keyof T)[]) {
     const propSignal = reactiveObj[key];
     if (propSignal && propSignal instanceof Signal) {
       (propSignal as Signal<any>).set(value[key]);
+    } else if (isReactive(propSignal) && isObject(value[key])) {
+      setReactive(propSignal as ReactiveObject<any>, value[key] as object);
     }
   }
   reactiveObj.$.set(value);
 }
🧹 Nitpick comments (22)
Build/tests/kernel.test.ts (1)

216-239: Consider consolidating duplicate path tests.

These path tests overlap with the more comprehensive coverage in Build/tests/path.test.ts. While having some basic path tests here for quick validation is fine, you may want to remove these to avoid maintaining duplicate test code.

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

In `@Build/tests/kernel.test.ts` around lines 216 - 239, Remove the duplicate path
tests in the describe block that exercises the path helper (the describe('path',
...) block containing tests that call path("user","name"), path("components", 0,
"value"), path("user","*"), and path("user","**")), or replace them with a
single minimal smoke test that only asserts path(...) constructs without
duplicating coverage already present in path.test.ts; update or delete the
describe('path', ...) block in Build/tests/kernel.test.ts so only the
non-duplicative, quick-validation test (or none) remains.
Build/src/store/map.ts (4)

71-77: values() creates intermediate array unnecessarily.

The method collects all values into an array then returns its iterator. This loses the lazy iteration benefit. Consider using a generator function instead.

♻️ Proposed fix using generator
-  values(): IterableIterator<V> {
-    const values: V[] = [];
-    for (const sig of this.entries.values()) {
-      values.push(sig.get());
-    }
-    return values[Symbol.iterator]();
+  *values(): IterableIterator<V> {
+    for (const sig of this.entries.values()) {
+      yield sig.get();
+    }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/store/map.ts` around lines 71 - 77, The values() method currently
builds an intermediate array then returns its iterator, losing laziness; change
values() into a generator function that iterates over this.entries.values() and
yields sig.get() for each entry (referencing the values() method and the
sig.get() call) so consumers receive a lazy IterableIterator<V> without
allocating the temporary V[].

4-8: Module-level counter shares same concern as reactive.ts.

Similar to storeId in reactive.ts, the mapId counter persists across tests. Consider a consistent approach to resetting these counters for test isolation.

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

In `@Build/src/store/map.ts` around lines 4 - 8, The module-level counter mapId
used by nextMapId persists across test runs leading to flaky tests; mirror the
fix used for storeId in reactive.ts by making the counter resettable: refactor
mapId so it’s not permanently module-global (or export a resetMapId function)
and update nextMapId to use the refactored counter; ensure tests call the new
resetMapId (or the same centralized reset helper) to restore mapId between
tests.

29-31: get() returns value directly without dependency tracking.

The get() method calls sig.get() which tracks the signal dependency, but the ReactiveMap itself doesn't track the map structure. If entries are added/removed, consumers using get() won't be notified unless they also subscribe to the map.

This is likely intentional design but worth documenting.

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

In `@Build/src/store/map.ts` around lines 29 - 31, The get(key: K) method
currently returns this.entries.get(key)?.get() which only tracks the individual
entry's signal (sig.get()) and does not track the ReactiveMap's structure, so
consumers won't be notified when entries are added/removed; either update
get(key: K) in the ReactiveMap to also read a map-structure signal (so callers
subscribe to structural changes) whenever returning an entry, or add a clear
comment/docstring on the ReactiveMap/get method explaining that get only tracks
the entry signal and does not subscribe to map additions/removals (reference
symbols: get(key: K): V | undefined, this.entries, sig.get(), ReactiveMap).

79-85: Same intermediate array pattern in entriesIterable().

This method also creates an intermediate array. Consider using a generator for consistency and efficiency.

♻️ Proposed fix using generator
-  entriesIterable(): IterableIterator<[K, V]> {
-    const result: [K, V][] = [];
-    for (const [key, sig] of this.entries.entries()) {
-      result.push([key, sig.get()]);
-    }
-    return result[Symbol.iterator]();
+  *entriesIterable(): IterableIterator<[K, V]> {
+    for (const [key, sig] of this.entries.entries()) {
+      yield [key, sig.get()];
+    }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/store/map.ts` around lines 79 - 85, entriesIterable() currently
builds an intermediate array then returns its iterator which wastes memory;
change it to a generator by making entriesIterable a generator function (use
function* or add the * prefix) that iterates this.entries and yields [key,
sig.get()] directly (referencing the entries Map via this.entries and the
signature accessor sig.get()), removing the temporary result array so callers
receive an IterableIterator without the intermediate allocation.
Build/src/store/reactive.ts (2)

4-8: Module-level counter may cause issues in test isolation.

The storeId counter is module-level and never reset. While __resetRegistryForTesting resets the graph registry, stores created in different tests will have incrementing IDs that persist across test runs. This could lead to non-deterministic test behavior if tests depend on specific path names.

Consider exposing a reset function or making the counter resettable for testing purposes.

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

In `@Build/src/store/reactive.ts` around lines 4 - 8, The module-level counter
storeId used by nextStoreId causes persistent, non-deterministic IDs across
tests; add a reset mechanism (e.g., export a function like
resetStoreIdForTesting or extend __resetRegistryForTesting to reset storeId to
0) and update tests to call it where the registry is reset so store IDs start
from a deterministic value; ensure the reset function only used in test builds
or clearly named to indicate test-only usage.

43-60: Proxy set trap allows arbitrary property assignment.

When target[prop] is not a Signal, the set trap assigns newValue directly to the target without any validation. This could allow consumers to accidentally pollute the reactive object with non-reactive properties, breaking the invariant that all properties are Signals.

Consider throwing an error or creating a new signal for unknown properties instead of direct assignment.

♻️ Proposed fix to prevent arbitrary assignments
     set(target, prop, newValue) {
       if (prop === "$" || prop === "$raw") {
         throw new Error("Cannot set $ or $raw directly");
       }
       if (target[prop] && target[prop] instanceof Signal) {
         target[prop].set(newValue);
-      } else {
-        target[prop] = newValue as any;
+      } else if (prop in target) {
+        // Only allow setting existing non-signal properties
+        target[prop] = newValue as any;
+      } else {
+        throw new Error(`Cannot set unknown property "${String(prop)}" on reactive object`);
       }
       return true;
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/store/reactive.ts` around lines 43 - 60, The Proxy set trap
currently assigns newValue directly to target[prop] when the existing property
is not a Signal, which allows arbitrary non-reactive properties; update the set
handler in the proxy (the set trap on the `proxy` wrapping `result`) so that
instead of direct assignment it either throws for unknown keys or converts the
value into a Signal (e.g., create a new Signal for newValue and assign that to
target[prop]) to preserve the invariant that all properties are Signals; keep
the existing guard that prevents setting "$" or "$raw" and ensure the branch
that checks `target[prop] instanceof Signal` still calls
`target[prop].set(newValue)` for existing signals.
Build/src/kernel/index.ts (1)

28-28: Path alias may cause confusion.

Re-exporting path (a function) as Path (capitalized, typically denoting a type/class) could mislead consumers into thinking it's a type or constructor rather than a factory function. Consider renaming to createPath or documenting this clearly.

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

In `@Build/src/kernel/index.ts` at line 28, The export alias export { path as Path
} is misleading because capitalized identifiers imply a type/class; rename the
exported symbol to a clear verb form (e.g., export { path as createPath } or
export { path as makePath }) and update any imports using Path to the new name,
or alternatively keep Path but add explicit documentation comment clarifying
it's a factory function; update references to the function (the path function
and any imports) to match the new export name so consumers aren't misled.
Build/src/kernel/effect.ts (2)

92-96: effectIdle arrow formatting could be clearer.

The multi-line arrow function is harder to read. Consider reformatting for clarity.

♻️ Proposed formatting improvement
-export const effectIdle = (fn: () => CleanupFn) => createEffect(fn,
-  r => typeof requestIdleCallback !== "undefined"
-    ? requestIdleCallback(() => r())
-    : setTimeout(() => r(), 0)
-);
+export const effectIdle = (fn: () => CleanupFn) =>
+  createEffect(fn, (r) => {
+    if (typeof requestIdleCallback !== "undefined") {
+      requestIdleCallback(() => r());
+    } else {
+      setTimeout(() => r(), 0);
+    }
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/kernel/effect.ts` around lines 92 - 96, The arrow formatting of
effectIdle is hard to read; update the createEffect call for clarity by
expanding the arrow callback into a clearer multi-line function: put the
parameter (r) and the conditional logic on separate lines, use braces and an
explicit return (or explicit invocation) for the handler that chooses between
requestIdleCallback and setTimeout, and align indentation so the ternary branch
is easier to scan; focus on editing the export const effectIdle = (fn: () =>
CleanupFn) => createEffect(fn, r => ...) expression so the callback using
requestIdleCallback and setTimeout is formatted across several lines for
readability.

17-32: Effect pool stores stale function references.

When an effect is returned to the pool (line 86), it stores both the original fn and the current cleanupFn. However, getEffectFromPool() returns these stale references. The pool is then reused in createEffect() but pooled.fn is never used - only pooled.cleanup is assigned to cleanupFn. This seems intentional but the pool structure storing fn is misleading.

♻️ Proposed simplification of pool structure
-const effectPool: { fn: () => CleanupFn; cleanup: CleanupFn }[] = [];
+const effectPool: { cleanup: CleanupFn }[] = [];

-function getEffectFromPool(): { fn: () => CleanupFn; cleanup: CleanupFn } {
+function getEffectFromPool(): { cleanup: CleanupFn } {
   if (effectPool.length > 0) {
     return effectPool.pop()!;
   }
-  return { fn: () => undefined, cleanup: undefined };
+  return { cleanup: undefined };
 }

-function returnEffectToPool(effect: { fn: () => CleanupFn; cleanup: CleanupFn }): void {
+function returnEffectToPool(effect: { cleanup: CleanupFn }): void {
   if (effectPool.length < EFFECT_POOL_SIZE) {
     effect.cleanup = undefined;
     effectPool.push(effect);
   }
 }

And update line 86:

-    returnEffectToPool({ fn, cleanup: cleanupFn });
+    returnEffectToPool({ cleanup: cleanupFn });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/kernel/effect.ts` around lines 17 - 32, The pool currently stores
objects with both fn and cleanup but only cleanup is reused, causing misleading
stale fn references; fix by simplifying the pool to store only CleanupFn items
(use a type like CleanupFn[] or alias) and update getEffectFromPool to return a
CleanupFn (or undefined), update returnEffectToPool to push only the cleanup
function after setting it to undefined when returning, and adjust createEffect
(and any code using pooled.fn) to expect a pooled cleanup value instead of an
object with fn—remove references to pooled.fn and ensure the pool capacity uses
EFFECT_POOL_SIZE as before.
Build/tests/graph.test.ts (2)

175-181: Config restoration may not reset all fields.

The afterEach restores lockViolation to "throw" and satori to null, but originalConfig is captured once at module load time. If other tests modify additional config fields, they won't be restored. Consider using configureSairin(originalConfig) instead.

♻️ Proposed fix to restore full config
   describe('configureSairin', () => {
-    const originalConfig = getSairinConfig();
-
     afterEach(() => {
-      configureSairin({ lockViolation: "throw", satori: null });
+      // Reset to default config state
+      configureSairin({ lockViolation: "throw", satori: null });
+      // Also reset the registry to ensure clean state
+      __resetRegistryForTesting();
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/tests/graph.test.ts` around lines 175 - 181, The afterEach currently
only resets lockViolation and satori, which misses other fields; change the
teardown to restore the full original config by calling
configureSairin(originalConfig) and ensure originalConfig is captured fresh for
each test (move const originalConfig = getSairinConfig() into a beforeEach so
each test gets a fresh snapshot) referencing getSairinConfig and configureSairin
in the describe('configureSairin') block.

115-153: Test relies on internal implementation details.

Accessing (sig as any)._node and manipulating subscribers directly couples the test to internal implementation. While this may be necessary for testing cleanup behavior, consider whether there's a public API that could be used instead.

Build/tests/benchmark.test.ts (1)

8-20: Benchmark threshold is very permissive.

The threshold of 2000 nanoseconds (2μs) per read operation is quite high for a simple signal read. This may not catch performance regressions effectively. Consider tightening this threshold based on baseline measurements.

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

In `@Build/tests/benchmark.test.ts` around lines 8 - 20, The benchmark test
"signal read should be fast" uses a very permissive threshold
(expect(timePerOp).toBeLessThan(2000)) which won't catch regressions; tighten
this by measuring a realistic baseline and lowering the threshold (e.g., to
~500ns–1000ns) or compute a dynamic threshold from a short calibration run,
update the assertion accordingly, and document the chosen baseline; locate the
test around the Signal instantiation (Signal(path("bench","read"), 0)), the loop
that calls sig.get(), the iterations variable, and the timePerOp/expect
assertion to make the change.
Build/tests/flow-async.test.ts (2)

142-149: Timing-based test assertions could be flaky.

This test relies on setTimeout delays (20ms waits) to assert resource refetch behavior. In CI environments with variable load, these tight timings might cause intermittent failures.

Consider increasing the wait times or using a more reliable synchronization mechanism if available.

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

In `@Build/tests/flow-async.test.ts` around lines 142 - 149, The test uses fragile
20ms setTimeout waits after creating and after calling res.refetch(), which can
flake under CI; change the test to avoid fixed short sleeps by either awaiting a
real signal from the resource (e.g., await the Promise returned by res.refetch()
if it exists) or replacing the fixed waits with a small polling loop that
repeatedly checks res.value.get() until it equals the expected value (with a
generous timeout like 2000ms) so the assertions on res.value.get() become
robust; update references in this test to use res.refetch() and res.value.get()
accordingly.

52-57: Test timing may be fragile.

The test waits 100ms after starting a flow that takes 50ms, then asserts counter is 1. This timing-based assertion could be flaky on slower CI systems. Consider using await on the first start() or a more deterministic synchronization approach.

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

In `@Build/tests/flow-async.test.ts` around lines 52 - 57, The test is using
fragile timing: it calls myFlow.start() twice (one without awaiting) and then
waits 100ms to assert counter, which can flake; change to a deterministic wait
by awaiting the start promise (replace the first myFlow.start(); await
myFlow.start(); with a single await myFlow.start()) or, if start does not return
a completion promise, modify the test to await a provided completion signal
(e.g., a returned promise or event from myFlow like myFlow.waitForCompletion())
and then assert counter to avoid using setTimeout.
Build/src/kernel/derived.ts (1)

42-45: Tracker function notifies without checking for actual changes.

The tracker callback marks the node as dirty and notifies subscribers whenever any dependency changes. This is correct for invalidation, but consider whether notification should be deferred until the next get() call to avoid redundant subscriber notifications when multiple dependencies change.

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

In `@Build/src/kernel/derived.ts` around lines 42 - 45, The tracker callback
currently sets this._node.dirty = true and immediately calls
notifySubscribers(this._node) on every dependency change; change it to avoid
redundant notifications by either (a) only calling notifySubscribers when the
node transitions from clean to dirty (i.e., if !this._node.dirty then set dirty
= true and notify), or (b) set this._node.dirty = true unconditionally but defer
notifySubscribers until the next get() or via a single scheduled
microtask/next-tick so multiple dependency changes are coalesced; update the
tracker function accordingly (references: tracker, this._node.dirty,
notifySubscribers, get()).
Build/src/kernel/graph.ts (3)

44-44: Unused pathCache variable.

pathCache is declared but never used in this file. Consider removing it or implementing the intended caching logic.

🧹 Remove unused variable
-const pathCache = new Map<string, PathKey>();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/kernel/graph.ts` at line 44, The declared but unused variable
pathCache (const pathCache = new Map<string, PathKey>();) should be removed or
wired into the intended caching logic: either delete the declaration from
graph.ts if caching is not needed, or implement usage where PathKey-based path
lookups are repeated (e.g., in functions that compute paths use
pathCache.get/set with the PathKey key inside the path computation functions) so
the Map is actually read from and written to; reference the symbol pathCache and
the type PathKey when applying the change.

269-293: Duplicated lock violation handling logic.

handleLockViolation() in graph.ts and the lock violation handling in Signal.set() (lines 48-66 in signal.ts) implement nearly identical logic for logging and throwing on lock violations. Consider having Signal.set() call assertLock() or a shared utility to reduce duplication.

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

In `@Build/src/kernel/graph.ts` around lines 269 - 293, The lock-violation
branching in Signal.set duplicates the behavior already implemented in
handleLockViolation; refactor by removing the duplicated logic from Signal.set
and instead call the shared handler (either call handleLockViolation(path,
owner, attemptedOwner) directly or move the logic into a new shared
assertLock(path, owner, attemptedOwner) utility and use it from both Signal.set
and handleLockViolation). Ensure the call preserves the same behavior (uses
getSairinConfig/getSairinLogger, logs with tags ["lock","write"], falls back to
console for missing logger, and throws when config.lockViolation === "throw")
and keep unique symbols Signal.set, handleLockViolation (or assertLock) to
locate the change.

150-176: watch() only observes nodes existing at call time.

Nodes created after watch() is called won't trigger the callback. If dynamic observation of future nodes matching the pattern is intended, this would need a registry hook for node creation events.

Additionally, the cleanup function iterates all nodes to remove handlers. Consider storing node-to-handler mappings directly for more efficient cleanup.

Build/src/kernel/signal.ts (2)

76-78: Consider allowing options in update() for lock owner support.

The update() method calls set() but doesn't forward an options parameter. If a caller needs to update a locked signal with owner permissions, they cannot use update().

♻️ Optional enhancement
- update(fn: (value: T) => T): void {
-   this.set(fn(this._node.value));
+ update(fn: (value: T) => T, options?: { owner?: string }): void {
+   this.set(fn(this._node.value), options);
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/kernel/signal.ts` around lines 76 - 78, The update method on the
signal doesn't accept or forward an options argument for lock/owner semantics;
change update in the Signal (or equivalent) class to accept an optional options
parameter (e.g., update(fn: (value: T) => T, options?: SetOptions)) and forward
it to set by calling this.set(fn(this._node.value), options); ensure the options
type matches the existing set signature so callers can pass lock/owner options
through update to set.

101-117: Use isPathKey() instead of duck-typing for path detection.

The current check ("segments" in pathOrInitial) could false-positive on objects that happen to have a segments property but aren't valid PathKey instances. Use the isPathKey() type guard which is already re-exported from ./graph for a more robust check.

♻️ Proposed fix
 export function signal<T>(pathOrInitial: PathKey | T, initial?: T): Signal<T> {
-  if (
-    typeof pathOrInitial === "object" &&
-    pathOrInitial !== null &&
-    "segments" in pathOrInitial
-  ) {
+  if (isPathKey(pathOrInitial)) {
     let path = pathOrInitial as PathKey;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/kernel/signal.ts` around lines 101 - 117, The signal function
currently detects PathKey by duck-typing ("segments" in pathOrInitial) which can
false-positive; replace that check with the exported type guard isPathKey from
./graph (use isPathKey(pathOrInitial)) and then narrow/cast pathOrInitial to
PathKey; keep the existing logic that calls resolveAlias(path), getNode(path),
and constructs new Signal(path, ...), so only change the path detection to use
isPathKey and adjust imports if necessary.
Build/src/store/array.ts (1)

25-27: Avoid returning values from forEach callback.

The arrow function (fn) => fn() implicitly returns the result of fn(). While forEach ignores the return value, this pattern can be confusing. Use a block body or for...of loop.

♻️ Proposed fix
 private notify(): void {
-  this.subscribers.forEach((fn) => fn());
+  for (const fn of this.subscribers) {
+    fn();
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/store/array.ts` around lines 25 - 27, The notify method currently
uses an arrow callback that implicitly returns fn() which is confusing; update
notify (method name notify, property this.subscribers, callback param fn) to use
a statement body or a for...of loop so the subscriber calls are executed without
returning values (e.g., replace this.subscribers.forEach((fn) => fn()) with
this.subscribers.forEach((fn) => { fn(); }) or for (const fn of
this.subscribers) { fn(); }) to clarify intent and avoid implicit returns).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Build/src/async/index.ts`:
- Around line 47-53: The code creates a Signal via Signal(path(...), value) in
deferValue but immediately calls sig.peek() so the Signal is unused; either pass
the Signal directly to useDeferredValue (change useDeferredValue(sig.peek(),
timeoutMs) to useDeferredValue(sig, timeoutMs)) to preserve the Signal identity,
or remove Signal creation entirely and call useDeferredValue(value, timeoutMs);
update deferValue accordingly (symbols: deferValue, Signal, useDeferredValue,
generateUniqueId, path).

In `@Build/src/async/resource.ts`:
- Around line 135-136: The SuspenseBoundary instances currently share the same
signal paths (path("suspense","loading") and path("suspense","error")), causing
cross-instance state aliasing; fix by making the path unique per instance (e.g.,
add an instance id or index) and use that when creating the signals used by the
class (replace the current signal(path("suspense", "loading"), ...) and
signal(path("suspense", "error"), ...) calls with signals that include a
per-instance identifier generated in the SuspenseBoundary constructor such as
this.id (use a counter or UUID) so loading and error are namespaced to this
SuspenseBoundary only.

In `@Build/src/flow/index.ts`:
- Around line 195-209: The code currently classifies outcomes using instanceof
Error (in parallel() where fns, promises, outcomes, allResults, allErrors are
used), which misclassifies non-Error rejections and legitimate Error values;
change the implementation to use Promise.allSettled on the array of promises
returned by calling each fn(abortController!.signal), then iterate the settled
results and push settled.status === "rejected" ? settled.reason into allErrors
and settled.status === "fulfilled" ? settled.value into allResults (adjust types
as needed) so you reliably distinguish rejections from fulfilled values even
when T can be Error or rejections are strings/numbers.

In `@Build/src/kernel/batch.ts`:
- Around line 44-47: The scheduled microtask flush can still run after
__resetBatchForTesting() clears pendingEffects; add a generation token to
invalidate queued flushes: introduce a numeric token (e.g., flushGeneration)
that scheduleEffect() captures when it enqueues a microtask and the flush
routine checks and returns early if the token no longer matches, and increment
that token inside __resetBatchForTesting() (and clear
pendingEffects/flushScheduled as before). Update the references in
scheduleEffect(), the microtask callback (flush routine), and
__resetBatchForTesting() to use this token so any already-queued microtask is a
no-op after reset.

In `@Build/src/kernel/derived.ts`:
- Around line 36-55: The local variable oldSubscribers in recompute() is unused;
remove the capture const oldSubscribers = new Set(this._node.subscribers); from
the recompute method to eliminate the dead variable and keep the behavior as-is
(if the intent was to restore subscribers, add explicit restore logic that
merges or replaces this._node.subscribers after computation instead of keeping
an unused set). Ensure references to recompute, this._node.subscribers, and any
intended restore logic are updated accordingly.

In `@Build/src/kernel/effect.ts`:
- Around line 49-76: The runner in createEffect currently runs both runCleanup()
and the previous-run cleanupFn (variables: createEffect, runner, runCleanup,
cleanupFn), which can execute cleanup twice when onCleanup() pushes a cleanup to
the global stack and the effect also returns a cleanup function; fix by
consolidating cleanup handling so only one path is executed: either call
runCleanup() to flush any onCleanup-registered functions and skip calling the
returned cleanupFn, or call the returned cleanupFn if you choose not to use the
cleanup stack (and adjust getEffectFromPool/pooled.cleanup usage accordingly);
update runner to detect which cleanup mechanism produced work (e.g., prefer
stack-first and null out cleanupFn or vice versa) and adjust/or document the
intended behavior in createEffect/onCleanup to avoid double cleanup.

In `@Build/src/kernel/graph.ts`:
- Around line 119-130: getNodesUnder currently uses
node.path.raw.startsWith(prefixStr) which incorrectly matches siblings (e.g.,
"/foobar" for prefix "/foo"); update the check in getNodesUnder (function name)
to ensure the prefix match is either an exact match (node.path.raw ===
prefixStr) or the prefix is followed by a path separator (e.g., prefixStr + "/"
) before accepting the node, while preserving the existing
matchesPath(node.path) logic; apply this change where
node.path.raw.startsWith(prefixStr) is used and handle the root/prefix edge
cases (single "/" or empty) accordingly so you don't break legitimate
root-prefix behavior.
- Around line 50-63: getOrCreateNode currently returns an existing ReactiveNode
from nodeRegistry without checking its ReactiveKind, allowing callers to request
a different kind (e.g., "derived" after a "signal") and silently reuse an
incompatible node; update getOrCreateNode to validate that an existing node's
kind matches the requested kind (compare node.kind to the incoming kind) and if
they differ throw a clear error (e.g., TypeError) that includes the path string
and both kinds; use pathToString(p), nodeRegistry, createNode, ReactiveKind and
ReactiveNode to locate the logic and ensure you do not silently cast or replace
nodes.
- Around line 311-347: scheduleIncrementalCleanup currently drops subsequent
node lists because cleanupScheduled prevents re-scheduling; change it to
accumulate incoming nodes into a shared buffer and have the scheduled microtask
operate on that accumulated list. Concretely, introduce a module-level array
(e.g., scheduledCleanupNodes) that scheduleIncrementalCleanup pushes the passed
nodes into, update cleanupChunk to slice from scheduledCleanupNodes instead of
the local nodes param, and ensure cleanupScheduled is still used only to gate
the queueMicrotask call; keep existing chunking, logging (CLEANUP_CHUNK_SIZE,
CLEANUP_WARN_THRESHOLD_MS, getSairinLogger), and the logic that marks derived
nodes dirty/cached.

In `@Build/src/kernel/path.ts`:
- Around line 55-66: The deep-glob handling in matchesPath (checking
pattern.globType === "deep") incorrectly reduces patterns with "**" by filtering
them out (using baseSegments/basePath), causing "/**" and embedded "**" patterns
to fail; update matchesPath to either treat a sole "**" as matching everything
(if pattern.segments is ["**"] or baseSegments becomes empty) or, preferably,
implement proper segment-wise matching using pattern.segments and
target.segments: perform a two-pointer algorithm that treats "*" as matching
exactly one segment, "**" as matching zero-or-more segments (backtracking or
greedy-with-fallback), and literal segments as exact matches so patterns like
["a","**","b"] correctly allow any number of segments between a and b. Ensure
you reference and use pattern.segments, target.segments and pattern.globType ===
"deep" when replacing the baseSegments/basePath logic.
- Around line 84-90: getParentPath is dropping all non-glob segments (so
`/user/**` yields null) but the comment says the parent of `/user/**` should be
`/user`; update getParentPath to return the joined non-glob segments instead of
removing the last one: in function getParentPath compute nonGlobSegments as
before, return null only when nonGlobSegments.length === 0, otherwise return
path(...nonGlobSegments) (use the existing path(...) call and nonGlobSegments
identifier).
- Around line 16-35: The loop that processes path segments (iterating over
parts, pushing into segments) currently only rejects empty segments but allows
segments containing '/' which collides with serializePath() that joins with '/';
update the validation in the same loop (the code handling parts/str and
manipulating segments) to also reject any segment containing '/' (e.g. check
str.includes('/') and throw a descriptive Error like "Path segment cannot
contain '/'") so callers of path()/serializePath() cannot pass segments that
would collapse when joined.

In `@Build/src/store/array.ts`:
- Around line 84-92: The splice implementation currently treats an omitted
deleteCount as 0; change it so when deleteCount is undefined it deletes through
the end of the array like Array.prototype.splice. Inside splice (function
splice(start: number, deleteCount?: number, ...items: T[])) compute an
effectiveDeleteCount when deleteCount === undefined (e.g., effectiveDeleteCount
= Math.max(0, result.length - normalizedStart)) and use that in
result.splice(...). Keep using the existing update(...) block and deleted push
logic; normalize start the same way JS does if you already handle negatives,
otherwise clamp start to [0, result.length] before computing
effectiveDeleteCount.
- Around line 45-50: The set() method in the class updates itemsSignal but
doesn't update lengthSignal when assigning an index beyond current length (e.g.,
arr.set(10, v)), causing a stale length getter; update lengthSignal after
creating the new items array inside set() by comparing the new items.length and
setting lengthSignal to the new length (use the same logic used by other
mutators) so that lengthSignal always reflects the array size; locate the
set(index: number, value: T) method and modify it to update lengthSignal
whenever itemsSignal is replaced.

In `@Build/src/store/map.ts`:
- Around line 33-42: The set method uses String(key) to build the path which can
cause collisions for objects/symbols; create and use a dedicated Map<K,string>
(e.g., keyIdMap) that assigns a stable unique ID per key (generate numbered or
UUID strings) and use that ID instead of String(key) when calling
signal(path("map", this.id, keyId), value); ensure the key->id map is
consulted/created in set and used consistently in other Map methods (get, has,
delete) so all path lookups are stable and update sizeSignal/notify as before;
also ensure IDs are removed from keyIdMap when keys are deleted to avoid leaks.

---

Outside diff comments:
In `@Build/src/async/resource.ts`:
- Around line 61-63: The abort() handler currently only calls
abortController?.abort() which bypasses the finally branch that clears loading;
update the abort implementations (the abort() functions referencing
abortController and the duplicate at lines ~123-125) to also reset the resource
state the finally branch would (e.g., set loading = false and any status/err
state and call the same cleanup function used in finally) so a manual cancel
returns the resource to idle; reuse the existing cleanup/teardown logic rather
than duplicating state changes where possible and ensure abort() does not leave
loading stuck true.
- Around line 29-51: The load() implementation reads the mutable abortController
inside the promise handlers which allows an older request to overwrite newer
state; fix this by scoping the controller created for each invocation into a
local constant (e.g., const currentController = new AbortController())
immediately after aborting the previous controller, assign abortController =
currentController, and then use currentController.signal.aborted inside the
.then/.catch/.finally handlers (and do the same change for the other load()
implementation around lines 78-101) so each promise only updates state if its
originating controller is still the active one.

In `@Build/src/async/transition.ts`:
- Around line 64-97: The effect created inside deferred() (the call to
effect(...) within function deferred<T>) is never disposed and can leak; capture
the dispose/stop function returned by effect and expose it so callers can clean
up the effect when the deferred signal is no longer needed (for example return a
tuple/object containing deferredValue and a dispose/stop method, or register the
dispose to the same lifecycle as deferredValue by attaching a cleanup callback),
and ensure the effect's disposer is invoked when the consumer calls the returned
dispose or when deferredValue is garbage-collected via an explicit lifecycle
hook.

In `@Build/src/flow/index.ts`:
- Around line 30-68: The start() implementations use a shared mutable
abortController and async continuations (perform, currentPromise), which lets an
older run overwrite state after cancel()/restart; fix by creating a run-scoped
controller and capturing it in the async closure (e.g., inside start() do const
runController = new AbortController(); assign abortController = runController
only for external cancel(), then use runController.signal in perform and in all
checks), and when clearing state (setting running, result, error, results,
errors, winner, etc.) guard those updates by confirming the runController is
still the active controller (or only mutate shared state if abortController ===
runController); apply the same pattern to other runners (pipeline, sequence,
parallel, race) and to variables named results, errors, winner so only the
current run can update them.

In `@Build/tests/context-debug.test.ts`:
- Around line 118-129: The test creates signals but never registers them, so
captureGraph() (which iterates registeredSignals) won't include them; update the
test to call registerSignal(sig1) and registerSignal(sig2) after constructing
sig1 and sig2 (or otherwise use the API that registers signals) before calling
captureGraph(), keeping clearGraph(), so the captured graph returned by
captureGraph() contains the created signals.

---

Nitpick comments:
In `@Build/src/kernel/derived.ts`:
- Around line 42-45: The tracker callback currently sets this._node.dirty = true
and immediately calls notifySubscribers(this._node) on every dependency change;
change it to avoid redundant notifications by either (a) only calling
notifySubscribers when the node transitions from clean to dirty (i.e., if
!this._node.dirty then set dirty = true and notify), or (b) set this._node.dirty
= true unconditionally but defer notifySubscribers until the next get() or via a
single scheduled microtask/next-tick so multiple dependency changes are
coalesced; update the tracker function accordingly (references: tracker,
this._node.dirty, notifySubscribers, get()).

In `@Build/src/kernel/effect.ts`:
- Around line 92-96: The arrow formatting of effectIdle is hard to read; update
the createEffect call for clarity by expanding the arrow callback into a clearer
multi-line function: put the parameter (r) and the conditional logic on separate
lines, use braces and an explicit return (or explicit invocation) for the
handler that chooses between requestIdleCallback and setTimeout, and align
indentation so the ternary branch is easier to scan; focus on editing the export
const effectIdle = (fn: () => CleanupFn) => createEffect(fn, r => ...)
expression so the callback using requestIdleCallback and setTimeout is formatted
across several lines for readability.
- Around line 17-32: The pool currently stores objects with both fn and cleanup
but only cleanup is reused, causing misleading stale fn references; fix by
simplifying the pool to store only CleanupFn items (use a type like CleanupFn[]
or alias) and update getEffectFromPool to return a CleanupFn (or undefined),
update returnEffectToPool to push only the cleanup function after setting it to
undefined when returning, and adjust createEffect (and any code using pooled.fn)
to expect a pooled cleanup value instead of an object with fn—remove references
to pooled.fn and ensure the pool capacity uses EFFECT_POOL_SIZE as before.

In `@Build/src/kernel/graph.ts`:
- Line 44: The declared but unused variable pathCache (const pathCache = new
Map<string, PathKey>();) should be removed or wired into the intended caching
logic: either delete the declaration from graph.ts if caching is not needed, or
implement usage where PathKey-based path lookups are repeated (e.g., in
functions that compute paths use pathCache.get/set with the PathKey key inside
the path computation functions) so the Map is actually read from and written to;
reference the symbol pathCache and the type PathKey when applying the change.
- Around line 269-293: The lock-violation branching in Signal.set duplicates the
behavior already implemented in handleLockViolation; refactor by removing the
duplicated logic from Signal.set and instead call the shared handler (either
call handleLockViolation(path, owner, attemptedOwner) directly or move the logic
into a new shared assertLock(path, owner, attemptedOwner) utility and use it
from both Signal.set and handleLockViolation). Ensure the call preserves the
same behavior (uses getSairinConfig/getSairinLogger, logs with tags
["lock","write"], falls back to console for missing logger, and throws when
config.lockViolation === "throw") and keep unique symbols Signal.set,
handleLockViolation (or assertLock) to locate the change.

In `@Build/src/kernel/index.ts`:
- Line 28: The export alias export { path as Path } is misleading because
capitalized identifiers imply a type/class; rename the exported symbol to a
clear verb form (e.g., export { path as createPath } or export { path as
makePath }) and update any imports using Path to the new name, or alternatively
keep Path but add explicit documentation comment clarifying it's a factory
function; update references to the function (the path function and any imports)
to match the new export name so consumers aren't misled.

In `@Build/src/kernel/signal.ts`:
- Around line 76-78: The update method on the signal doesn't accept or forward
an options argument for lock/owner semantics; change update in the Signal (or
equivalent) class to accept an optional options parameter (e.g., update(fn:
(value: T) => T, options?: SetOptions)) and forward it to set by calling
this.set(fn(this._node.value), options); ensure the options type matches the
existing set signature so callers can pass lock/owner options through update to
set.
- Around line 101-117: The signal function currently detects PathKey by
duck-typing ("segments" in pathOrInitial) which can false-positive; replace that
check with the exported type guard isPathKey from ./graph (use
isPathKey(pathOrInitial)) and then narrow/cast pathOrInitial to PathKey; keep
the existing logic that calls resolveAlias(path), getNode(path), and constructs
new Signal(path, ...), so only change the path detection to use isPathKey and
adjust imports if necessary.

In `@Build/src/store/array.ts`:
- Around line 25-27: The notify method currently uses an arrow callback that
implicitly returns fn() which is confusing; update notify (method name notify,
property this.subscribers, callback param fn) to use a statement body or a
for...of loop so the subscriber calls are executed without returning values
(e.g., replace this.subscribers.forEach((fn) => fn()) with
this.subscribers.forEach((fn) => { fn(); }) or for (const fn of
this.subscribers) { fn(); }) to clarify intent and avoid implicit returns).

In `@Build/src/store/map.ts`:
- Around line 71-77: The values() method currently builds an intermediate array
then returns its iterator, losing laziness; change values() into a generator
function that iterates over this.entries.values() and yields sig.get() for each
entry (referencing the values() method and the sig.get() call) so consumers
receive a lazy IterableIterator<V> without allocating the temporary V[].
- Around line 4-8: The module-level counter mapId used by nextMapId persists
across test runs leading to flaky tests; mirror the fix used for storeId in
reactive.ts by making the counter resettable: refactor mapId so it’s not
permanently module-global (or export a resetMapId function) and update nextMapId
to use the refactored counter; ensure tests call the new resetMapId (or the same
centralized reset helper) to restore mapId between tests.
- Around line 29-31: The get(key: K) method currently returns
this.entries.get(key)?.get() which only tracks the individual entry's signal
(sig.get()) and does not track the ReactiveMap's structure, so consumers won't
be notified when entries are added/removed; either update get(key: K) in the
ReactiveMap to also read a map-structure signal (so callers subscribe to
structural changes) whenever returning an entry, or add a clear
comment/docstring on the ReactiveMap/get method explaining that get only tracks
the entry signal and does not subscribe to map additions/removals (reference
symbols: get(key: K): V | undefined, this.entries, sig.get(), ReactiveMap).
- Around line 79-85: entriesIterable() currently builds an intermediate array
then returns its iterator which wastes memory; change it to a generator by
making entriesIterable a generator function (use function* or add the * prefix)
that iterates this.entries and yields [key, sig.get()] directly (referencing the
entries Map via this.entries and the signature accessor sig.get()), removing the
temporary result array so callers receive an IterableIterator without the
intermediate allocation.

In `@Build/src/store/reactive.ts`:
- Around line 4-8: The module-level counter storeId used by nextStoreId causes
persistent, non-deterministic IDs across tests; add a reset mechanism (e.g.,
export a function like resetStoreIdForTesting or extend
__resetRegistryForTesting to reset storeId to 0) and update tests to call it
where the registry is reset so store IDs start from a deterministic value;
ensure the reset function only used in test builds or clearly named to indicate
test-only usage.
- Around line 43-60: The Proxy set trap currently assigns newValue directly to
target[prop] when the existing property is not a Signal, which allows arbitrary
non-reactive properties; update the set handler in the proxy (the set trap on
the `proxy` wrapping `result`) so that instead of direct assignment it either
throws for unknown keys or converts the value into a Signal (e.g., create a new
Signal for newValue and assign that to target[prop]) to preserve the invariant
that all properties are Signals; keep the existing guard that prevents setting
"$" or "$raw" and ensure the branch that checks `target[prop] instanceof Signal`
still calls `target[prop].set(newValue)` for existing signals.

In `@Build/tests/benchmark.test.ts`:
- Around line 8-20: The benchmark test "signal read should be fast" uses a very
permissive threshold (expect(timePerOp).toBeLessThan(2000)) which won't catch
regressions; tighten this by measuring a realistic baseline and lowering the
threshold (e.g., to ~500ns–1000ns) or compute a dynamic threshold from a short
calibration run, update the assertion accordingly, and document the chosen
baseline; locate the test around the Signal instantiation
(Signal(path("bench","read"), 0)), the loop that calls sig.get(), the iterations
variable, and the timePerOp/expect assertion to make the change.

In `@Build/tests/flow-async.test.ts`:
- Around line 142-149: The test uses fragile 20ms setTimeout waits after
creating and after calling res.refetch(), which can flake under CI; change the
test to avoid fixed short sleeps by either awaiting a real signal from the
resource (e.g., await the Promise returned by res.refetch() if it exists) or
replacing the fixed waits with a small polling loop that repeatedly checks
res.value.get() until it equals the expected value (with a generous timeout like
2000ms) so the assertions on res.value.get() become robust; update references in
this test to use res.refetch() and res.value.get() accordingly.
- Around line 52-57: The test is using fragile timing: it calls myFlow.start()
twice (one without awaiting) and then waits 100ms to assert counter, which can
flake; change to a deterministic wait by awaiting the start promise (replace the
first myFlow.start(); await myFlow.start(); with a single await myFlow.start())
or, if start does not return a completion promise, modify the test to await a
provided completion signal (e.g., a returned promise or event from myFlow like
myFlow.waitForCompletion()) and then assert counter to avoid using setTimeout.

In `@Build/tests/graph.test.ts`:
- Around line 175-181: The afterEach currently only resets lockViolation and
satori, which misses other fields; change the teardown to restore the full
original config by calling configureSairin(originalConfig) and ensure
originalConfig is captured fresh for each test (move const originalConfig =
getSairinConfig() into a beforeEach so each test gets a fresh snapshot)
referencing getSairinConfig and configureSairin in the
describe('configureSairin') block.

In `@Build/tests/kernel.test.ts`:
- Around line 216-239: Remove the duplicate path tests in the describe block
that exercises the path helper (the describe('path', ...) block containing tests
that call path("user","name"), path("components", 0, "value"), path("user","*"),
and path("user","**")), or replace them with a single minimal smoke test that
only asserts path(...) constructs without duplicating coverage already present
in path.test.ts; update or delete the describe('path', ...) block in
Build/tests/kernel.test.ts so only the non-duplicative, quick-validation test
(or none) remains.

Comment thread Build/src/async/index.ts
Comment thread Build/src/async/resource.ts Outdated
Comment thread Build/src/flow/index.ts Outdated
Comment thread Build/src/kernel/batch.ts
Comment thread Build/src/kernel/derived.ts
Comment thread Build/src/kernel/path.ts
Comment thread Build/src/kernel/path.ts
Comment thread Build/src/store/array.ts
Comment thread Build/src/store/array.ts
Comment thread Build/src/store/map.ts

@coderabbitai coderabbitai 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.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
Build/tests/flow-async.test.ts (1)

44-60: ⚠️ Potential issue | 🟡 Minor

Test logic may not verify concurrent start prevention.

The test awaits myFlow.start() which completes after ~50ms, then polls until running is false (which it already is). The assertion expect(counter).toBe(1) passes because only one flow ran to completion, but this doesn't actually test preventing a second start while already running.

To properly test this, you'd need to start the flow without awaiting, then immediately call start again:

🔧 Suggested fix to test concurrent start prevention
   test('should not start if already running', async () => {
     let counter = 0;
     const myFlow = flow(async () => {
       counter++;
       await new Promise(resolve => setTimeout(resolve, 50));
       return counter;
     });
     
-    await myFlow.start();
-    
-    // Use deterministic wait: poll until completion
-    const startTime = Date.now();
-    while (myFlow.running.get() && Date.now() - startTime < 1000) {
-      await new Promise(resolve => setTimeout(resolve, 10));
-    }
+    // Start without awaiting to test concurrent start prevention
+    const firstStart = myFlow.start();
+    
+    // Immediately try to start again while first is running
+    expect(myFlow.running.get()).toBe(true);
+    await myFlow.start(); // Should wait for first to complete, not increment counter
+    
+    await firstStart;
     expect(counter).toBe(1);
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/tests/flow-async.test.ts` around lines 44 - 60, The test currently
awaits myFlow.start(), so it never attempts a concurrent start; change it to
invoke myFlow.start() without awaiting (fire-and-forget), immediately call
myFlow.start() a second time, then wait for completion (polling myFlow.running
or await a finished promise) and assert counter is 1 to ensure the second start
was ignored; update references in the test for flow, myFlow.start,
myFlow.running, and counter accordingly so the test actually exercises
concurrent start prevention.
Build/src/async/resource.ts (1)

62-65: ⚠️ Potential issue | 🟠 Major

abort() leaves loading stuck at true.

When abort() is called on a resource, the finally block's guard (!currentController.signal.aborted && abortController === currentController) prevents loading.set(false) from executing, leaving the resource in a perpetual loading state.

Compare with resourceWithSignal (lines 125-131) which explicitly resets state on abort.

🐛 Proposed fix
     abort: () => {
       abortController?.abort();
+      loading.set(false);
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/async/resource.ts` around lines 62 - 65, The abort() implementation
leaves loading stuck true because the finally guard prevents loading.set(false)
when the controller is aborted; update abort() (and mirror behavior from
resourceWithSignal) to not only call abortController?.abort() but also
clear/reset state: call loading.set(false), set data.set(undefined) and
error.set(undefined) (or their equivalents), and set abortController =
undefined/null so the finally guard won't block future resets; alternatively,
adjust the finally guard in the async operation to allow state reset when
currentController.signal.aborted while ensuring abortController ===
currentController is still checked to avoid race conditions.
🧹 Nitpick comments (10)
SAIRIN.md (1)

27-32: Add language specifier to the fenced code block.

The path structure example lacks a language specifier. Consider adding text or plaintext to satisfy linters and improve rendering consistency.

-```
+```text
 /user/name       ← signal (inode)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@SAIRIN.md` around lines 27 - 32, The fenced code block containing the path
structure (lines showing "/user/name ← signal (inode)", "/user/age ← signal
(inode)", "/user ← namespace (directory)", "/ui/header ← derived, depends on
/user/name") should include a language specifier (e.g., add ```text or
```plaintext after the opening backticks) so linters and renderers treat it as
plain text; update the opening fence accordingly to include the specifier.
Build/src/store/reactive.ts (1)

38-42: Arrays treated as leaf values — verify this is intentional.

Arrays are excluded from recursive wrapping and become signals directly. If deep array reactivity is needed, ensure there's a separate mechanism (e.g., Build/src/store/array.ts). Otherwise, mutating array elements won't trigger reactivity.

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

In `@Build/src/store/reactive.ts` around lines 38 - 42, The current branch treats
arrays as leaf values (Array.isArray(value) bypasses recursion) so mutations to
elements won't be reactive; update the logic in the reactive function to handle
arrays explicitly by either calling a dedicated array-reactive helper (e.g.,
reactiveArray(value, `${storePath}/${String(key)}`) or by recursively mapping
elements through reactive/signal using the same path convention and storing the
resulting wrapped array in result[key]; keep the existing isObject(...) path for
plain objects, continue using signal(path(storePath, String(key)), value) only
for true primitives, and reference reactive, signal, isObject, storePath, path
and result[key] when making the change.
Build/tests/graph.test.ts (1)

175-186: Consider reordering variable declaration for clarity.

The originalConfig variable is used in beforeEach (line 178) before its declaration appears (line 186). While this works due to JavaScript's execution model (the let declaration is evaluated when the describe block runs synchronously, before any test callbacks execute), placing the declaration before its usage would improve readability.

♻️ Suggested reorder
   describe('configureSairin', () => {
+    let originalConfig: ReturnType<typeof getSairinConfig>;
+
     beforeEach(() => {
       // Capture fresh config snapshot for each test
       originalConfig = getSairinConfig();
     });

     afterEach(() => {
       // Restore full original config
       configureSairin(originalConfig);
     });

-    let originalConfig: ReturnType<typeof getSairinConfig>;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/tests/graph.test.ts` around lines 175 - 186, Move the declaration of
originalConfig above the beforeEach/afterEach hooks to improve readability:
declare let originalConfig: ReturnType<typeof getSairinConfig>; immediately
inside the describe('configureSairin', ...) block before the beforeEach, then
keep beforeEach() that assigns originalConfig = getSairinConfig() and
afterEach() that calls configureSairin(originalConfig); this reorders the
declaration relative to its use without changing behavior.
SAIRIN-UPDATES.md (2)

83-91: Add language specifier to the batching diagram.

📝 Suggested fix
-```
+```text
 tick:
   signal A .set()  → dirty, enqueue flush
   signal B .set()  → dirty, flush already queued → drop
   signal C .set()  → dirty, flush already queued → drop
   
 microtask:
   flush() → runs all dirty effects once
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @SAIRIN-UPDATES.md around lines 83 - 91, The code block showing the batching
diagram is missing a language specifier; update the triple-backtick fence around
the block that starts with "tick:" and ends with "flush() → runs all dirty
effects once" to include the language identifier "text" (i.e., change ``` to


12-23: Add language specifiers to code blocks.

The static analysis tool flagged missing language specifiers on fenced code blocks. Adding them improves syntax highlighting and documentation clarity.

📝 Suggested fix
 Most reactive systems (SolidJS, MobX, Preact Signals) model reactivity as:
-```
+```text
 signal → Set<subscriber>

Sairin models it as:
- +text
/user/name ← signal (inode)
/user/age ← signal (inode)
/user ← namespace (directory)
/ui/header ← derived, depends on /user/name

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

In `@SAIRIN-UPDATES.md` around lines 12 - 23, The fenced code blocks around the
examples are missing language specifiers; update the two blocks containing
"signal → Set<subscriber>" and the block listing "/user/name", "/user/age",
"/user", "/ui/header" to use a language tag (e.g., add "text" immediately after
the opening backticks: ```text) so syntax highlighters render them correctly and
match the suggested fix; ensure both opening fences are changed and no other
content is altered.
Build/src/kernel/effect.ts (1)

6-12: Remove unused imports.

subscribe, unsubscribe, getOrCreateNode, PathKey, and EffectNode are imported but not used anywhere in this file. They may be leftover from refactoring.

♻️ Suggested fix
-import {
-  subscribe,
-  unsubscribe,
-  getOrCreateNode,
-  type PathKey,
-  type EffectNode,
-} from "./graph";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/kernel/effect.ts` around lines 6 - 12, The import statement
currently brings in subscribe, unsubscribe, getOrCreateNode, PathKey, and
EffectNode from "./graph" but those symbols are unused; remove them from the
import list (or replace the whole import with only the exports that are actually
used in this file) so the file no longer imports subscribe, unsubscribe,
getOrCreateNode, PathKey, and EffectNode unnecessarily.
SAZAMI-UPDATES.md (1)

44-55: Add language specifier to architecture diagram.

📝 Suggested fix
-```
+```text
 SazamiComponent
   ├── mount(styles, template)     ← one-time structural stamp
   ├── scheduleRender(...)         ← batched re-render (pre-Sairin fallback)
   ├── _dirty: boolean             ← batching flag
   ├── _pendingTemplate: string    ← latest queued template
   └── _lastTemplate: string       ← template identity cache
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@SAZAMI-UPDATES.md` around lines 44 - 55, The fenced architecture diagram
block lacks a language specifier; update the code fence surrounding the
SazamiComponent diagram so it begins with ```text (not just ```), e.g. change
the opening fence for the block that contains SazamiComponent, mount(...),
scheduleRender(...), _dirty, _pendingTemplate and _lastTemplate to ```text to
ensure the diagram is rendered as plain text.
Build/src/kernel/graph.ts (1)

162-188: Consider tracking subscribed nodes for more efficient cleanup.

The current cleanup iterates all registry nodes to remove handlers. For large registries, tracking which nodes were actually subscribed to would be more efficient.

♻️ Optional optimization
 export function watch(
   pattern: PathKey,
   callback: (path: PathKey, kind: ReactiveKind) => void,
 ): () => void {
-  const registeredCallbacks: (() => void)[] = [];
+  const subscriptions: Array<{ node: ReactiveNode; handler: () => void }> = [];

   const checkAndSubscribe = (node: ReactiveNode) => {
     if (matchesPath(pattern, node.path)) {
       const handler = () => callback(node.path, node.kind);
-      registeredCallbacks.push(handler);
-      node.subscribers.add(handler);
+      subscriptions.push({ node, handler });
+      node.subscribers.add(handler);
     }
   };

   // ...

   return () => {
-    for (const node of nodeRegistry.values()) {
-      for (const handler of registeredCallbacks) {
-        node.subscribers.delete(handler);
-      }
-    }
-    registeredCallbacks.length = 0;
+    for (const { node, handler } of subscriptions) {
+      node.subscribers.delete(handler);
+    }
+    subscriptions.length = 0;
   };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/kernel/graph.ts` around lines 162 - 188, The cleanup in watch
currently iterates all nodeRegistry entries to remove handlers; instead record
the specific nodes you subscribed to and the handler per node when
checkAndSubscribe runs (e.g., maintain a Map<ReactiveNode, Set<Handler>> or an
array of {node,handler} entries alongside registeredCallbacks in
watch/checkAndSubscribe), push the node reference when you add handler to
node.subscribers, and then in the returned unsubscribe function iterate only
that recorded collection to delete handlers from node.subscribers and clear the
tracking structure and registeredCallbacks; update symbols: watch,
checkAndSubscribe, registeredCallbacks, node.subscribers to use the new per-node
tracking for efficient cleanup.
Build/src/flow/index.ts (1)

83-122: Pipeline lacks re-entrancy protection unlike flow.

flow.start() waits for currentPromise if already running, but pipeline.start() can be called multiple times concurrently, potentially causing overlapping executions with unpredictable state updates.

If this is intentional (pipelines are meant to be one-shot), consider documenting it. Otherwise:

♻️ Optional: Add re-entrancy protection
 export function pipeline<T, R>(
   fn: (input: T, signal: AbortSignal) => Promise<R>,
 ): Pipeline<T, R> {
   const id = nextFlowId();
   const running = signal(path("pipeline", id, "running"), false);
   const result = signal<R | null>(path("pipeline", id, "result"), null);
   const error = signal<Error | null>(path("pipeline", id, "error"), null);
   let abortController: AbortController | null = null;
+  let currentPromise: Promise<void> | null = null;

   return {
     running,
     result,
     error,
     start: async (input: T) => {
+      if (running.peek()) {
+        if (currentPromise) await currentPromise;
+        return;
+      }
       const runController = new AbortController();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/flow/index.ts` around lines 83 - 122, The pipeline currently allows
concurrent starts; update pipeline to add a currentPromise (or currentRun)
tracker and make start either await the existing currentPromise or reject new
starts to enforce single active run like flow.start; set currentPromise when
starting the run, clear it in the finally block, and ensure abortController
comparisons and running/result/error updates reference the same run (use
abortController === runController) so overlapping calls cannot overwrite state.
Build/src/store/array.ts (1)

1-2: Unused import: trackDependency.

trackDependency is imported but not used anywhere in this file.

🧹 Proposed fix
-import { Signal, signal, Subscriber, trackDependency } from "../kernel";
+import { Signal, signal, Subscriber } from "../kernel";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/store/array.ts` around lines 1 - 2, Remove the unused import symbol
trackDependency from the import statement that currently reads "import { Signal,
signal, Subscriber, trackDependency } from \"../kernel\""; update the import to
only include the actually used symbols (Signal, signal, Subscriber) so there are
no unused imports in this module (check the top of the file where the import is
declared and remove trackDependency).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Build/src/async/transition.ts`:
- Around line 103-115: useDeferredValue creates a temporary signal for raw
values and calls deferred(...) but ignores the returned dispose, leaking
resources; capture the deferred result as const { signal: deferredSig, dispose }
= deferred(sig, { timeoutMs }) (or similar) and ensure dispose is registered to
run when the created signal is cleaned up (e.g., call sig.onDispose(dispose) or
subscribe the dispose to the signal's teardown mechanism) so the deferred
timer/effects are cleaned up; keep returning the Signal<T> (deferredSig.signal
or sig) but ensure the dispose is wired to the signal's lifecycle.

In `@Build/src/kernel/effect.ts`:
- Around line 17-31: The effect pool is incorrectly storing CleanupFn instances
(effect-specific callbacks) via effectPool, getEffectFromPool and
returnEffectToPool which can lead to reusing teardown functions across effects;
change the pooling strategy: stop pooling CleanupFn values and either remove the
pool entirely or change effectPool to store neutral effect-runner/slot objects
(e.g., EffectRunner or a metadata object) that can be reused safely. Update
getEffectFromPool to return either undefined (if removing pool) or a
fresh/pooled runner placeholder, and update returnEffectToPool to push only
those neutral runner objects (not cleanup functions). Locate the symbols
effectPool, EFFECT_POOL_SIZE, getEffectFromPool and returnEffectToPool to
implement the change and update callers that currently expect a CleanupFn to
instead receive/set the cleanup on the runner/slot or handle no pooling.

In `@Build/src/kernel/graph.ts`:
- Around line 329-371: The race occurs because cleanupScheduled is set to false
before cleanupChunk requeues itself, allowing a concurrent
scheduleIncrementalCleanup call to enqueue a second microtask; update
scheduleIncrementalCleanup so cleanupScheduled remains true for the entire
processing period: keep setting cleanupScheduled = true when initially
scheduling, remove the cleanupScheduled = false from the microtask wrapper, and
instead set cleanupScheduled = false only when cleanupChunk finishes its final
else branch (after clearing scheduledCleanupNodes and resetting cleanupIndex);
references: scheduleIncrementalCleanup, cleanupScheduled, cleanupChunk,
scheduledCleanupNodes, cleanupIndex, queueMicrotask.

In `@Build/src/kernel/path.ts`:
- Around line 71-103: The "**" handling is greedy and can pick the first
occurrence of the next literal, causing false negatives; update the logic in the
path-matching routine that uses variables p, t, pi, ti so that when p[pi] ===
"**" and it's not the final segment you attempt matching the remainder for all
possible positions of the next segment instead of advancing to the first match
only — e.g., loop k from ti..t.length-1 (or search from the end) where t[k] ===
next and for each try to continue matching by setting pi+1 and ti=k (or
recurse/loop to test the rest); if any attempt yields a full match return true,
otherwise fall through to false. Ensure you still handle the trailing "**" and
single "*" cases as before.

In `@Build/src/store/map.ts`:
- Around line 51-53: The forEach callback in notify currently uses a concise
arrow `(fn) => fn()` which returns the Subscriber's void result and triggers a
linter/static-analysis complaint; update notify (the method named notify
iterating over this.subscribers) to call each subscriber without returning a
value by changing the callback to a block body that invokes fn() (e.g., `(fn) =>
{ fn(); }`) so the callback returns void.

In `@SAZAMI-UPDATES.md`:
- Around line 247-473: The document contains a duplicated block (the full
architecture/phases description beginning with "Sazami and Sairin are not
optional of each other." and repeating the Architectur al premise / Phases 1–3
content); remove the second copy so the architecture, "What this replaces",
"Architecture overview", and Phase 1–3 sections appear only once, then run the
static checks to confirm duplicate headings are gone and the remaining
headings/anchors stay consistent.

---

Outside diff comments:
In `@Build/src/async/resource.ts`:
- Around line 62-65: The abort() implementation leaves loading stuck true
because the finally guard prevents loading.set(false) when the controller is
aborted; update abort() (and mirror behavior from resourceWithSignal) to not
only call abortController?.abort() but also clear/reset state: call
loading.set(false), set data.set(undefined) and error.set(undefined) (or their
equivalents), and set abortController = undefined/null so the finally guard
won't block future resets; alternatively, adjust the finally guard in the async
operation to allow state reset when currentController.signal.aborted while
ensuring abortController === currentController is still checked to avoid race
conditions.

In `@Build/tests/flow-async.test.ts`:
- Around line 44-60: The test currently awaits myFlow.start(), so it never
attempts a concurrent start; change it to invoke myFlow.start() without awaiting
(fire-and-forget), immediately call myFlow.start() a second time, then wait for
completion (polling myFlow.running or await a finished promise) and assert
counter is 1 to ensure the second start was ignored; update references in the
test for flow, myFlow.start, myFlow.running, and counter accordingly so the test
actually exercises concurrent start prevention.

---

Nitpick comments:
In `@Build/src/flow/index.ts`:
- Around line 83-122: The pipeline currently allows concurrent starts; update
pipeline to add a currentPromise (or currentRun) tracker and make start either
await the existing currentPromise or reject new starts to enforce single active
run like flow.start; set currentPromise when starting the run, clear it in the
finally block, and ensure abortController comparisons and running/result/error
updates reference the same run (use abortController === runController) so
overlapping calls cannot overwrite state.

In `@Build/src/kernel/effect.ts`:
- Around line 6-12: The import statement currently brings in subscribe,
unsubscribe, getOrCreateNode, PathKey, and EffectNode from "./graph" but those
symbols are unused; remove them from the import list (or replace the whole
import with only the exports that are actually used in this file) so the file no
longer imports subscribe, unsubscribe, getOrCreateNode, PathKey, and EffectNode
unnecessarily.

In `@Build/src/kernel/graph.ts`:
- Around line 162-188: The cleanup in watch currently iterates all nodeRegistry
entries to remove handlers; instead record the specific nodes you subscribed to
and the handler per node when checkAndSubscribe runs (e.g., maintain a
Map<ReactiveNode, Set<Handler>> or an array of {node,handler} entries alongside
registeredCallbacks in watch/checkAndSubscribe), push the node reference when
you add handler to node.subscribers, and then in the returned unsubscribe
function iterate only that recorded collection to delete handlers from
node.subscribers and clear the tracking structure and registeredCallbacks;
update symbols: watch, checkAndSubscribe, registeredCallbacks, node.subscribers
to use the new per-node tracking for efficient cleanup.

In `@Build/src/store/array.ts`:
- Around line 1-2: Remove the unused import symbol trackDependency from the
import statement that currently reads "import { Signal, signal, Subscriber,
trackDependency } from \"../kernel\""; update the import to only include the
actually used symbols (Signal, signal, Subscriber) so there are no unused
imports in this module (check the top of the file where the import is declared
and remove trackDependency).

In `@Build/src/store/reactive.ts`:
- Around line 38-42: The current branch treats arrays as leaf values
(Array.isArray(value) bypasses recursion) so mutations to elements won't be
reactive; update the logic in the reactive function to handle arrays explicitly
by either calling a dedicated array-reactive helper (e.g., reactiveArray(value,
`${storePath}/${String(key)}`) or by recursively mapping elements through
reactive/signal using the same path convention and storing the resulting wrapped
array in result[key]; keep the existing isObject(...) path for plain objects,
continue using signal(path(storePath, String(key)), value) only for true
primitives, and reference reactive, signal, isObject, storePath, path and
result[key] when making the change.

In `@Build/tests/graph.test.ts`:
- Around line 175-186: Move the declaration of originalConfig above the
beforeEach/afterEach hooks to improve readability: declare let originalConfig:
ReturnType<typeof getSairinConfig>; immediately inside the
describe('configureSairin', ...) block before the beforeEach, then keep
beforeEach() that assigns originalConfig = getSairinConfig() and afterEach()
that calls configureSairin(originalConfig); this reorders the declaration
relative to its use without changing behavior.

In `@SAIRIN-UPDATES.md`:
- Around line 83-91: The code block showing the batching diagram is missing a
language specifier; update the triple-backtick fence around the block that
starts with "tick:" and ends with "flush() → runs all dirty effects once" to
include the language identifier "text" (i.e., change ``` to ```text) so the
diagram is rendered with the suggested language specifier.
- Around line 12-23: The fenced code blocks around the examples are missing
language specifiers; update the two blocks containing "signal → Set<subscriber>"
and the block listing "/user/name", "/user/age", "/user", "/ui/header" to use a
language tag (e.g., add "text" immediately after the opening backticks: ```text)
so syntax highlighters render them correctly and match the suggested fix; ensure
both opening fences are changed and no other content is altered.

In `@SAIRIN.md`:
- Around line 27-32: The fenced code block containing the path structure (lines
showing "/user/name ← signal (inode)", "/user/age ← signal (inode)", "/user ←
namespace (directory)", "/ui/header ← derived, depends on /user/name") should
include a language specifier (e.g., add ```text or ```plaintext after the
opening backticks) so linters and renderers treat it as plain text; update the
opening fence accordingly to include the specifier.

In `@SAZAMI-UPDATES.md`:
- Around line 44-55: The fenced architecture diagram block lacks a language
specifier; update the code fence surrounding the SazamiComponent diagram so it
begins with ```text (not just ```), e.g. change the opening fence for the block
that contains SazamiComponent, mount(...), scheduleRender(...), _dirty,
_pendingTemplate and _lastTemplate to ```text to ensure the diagram is rendered
as plain text.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 206f29ef-f4f2-47db-a16a-b1ea2794f106

📥 Commits

Reviewing files that changed from the base of the PR and between f58dc3b and 7cc7e53.

📒 Files selected for processing (23)
  • APPROACH.md
  • Build/src/async/index.ts
  • Build/src/async/resource.ts
  • Build/src/async/transition.ts
  • Build/src/flow/index.ts
  • Build/src/kernel/batch.ts
  • Build/src/kernel/derived.ts
  • Build/src/kernel/effect.ts
  • Build/src/kernel/graph.ts
  • Build/src/kernel/index.ts
  • Build/src/kernel/path.ts
  • Build/src/kernel/signal.ts
  • Build/src/store/array.ts
  • Build/src/store/map.ts
  • Build/src/store/reactive.ts
  • Build/tests/benchmark.test.ts
  • Build/tests/context-debug.test.ts
  • Build/tests/flow-async.test.ts
  • Build/tests/graph.test.ts
  • SAIRIN-UPDATES.md
  • SAIRIN.md
  • SAZAMI-UPDATES.md
  • Sairin.code-workspace
✅ Files skipped from review due to trivial changes (1)
  • Sairin.code-workspace
🚧 Files skipped from review as they are similar to previous changes (1)
  • Build/src/async/index.ts

Comment thread Build/src/async/transition.ts
Comment thread Build/src/kernel/effect.ts Outdated
Comment thread Build/src/kernel/graph.ts
Comment thread Build/src/kernel/path.ts Outdated
Comment thread Build/src/store/map.ts
Comment thread SAZAMI-UPDATES.md Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
Build/src/store/reactive.ts (1)

33-46: ⚠️ Potential issue | 🟠 Major

$ and $raw stop reflecting the store after child updates.

The aggregate signal/raw snapshot are initialized once from obj, but child signals are updated independently. After reactive({ x: 1 }).x.set(2), toRaw() and reactiveObj.$.get() still return the original object, and setReactive() still leaves $raw stale.

Also applies to: 78-89

Build/src/kernel/effect.ts (1)

34-45: ⚠️ Potential issue | 🟡 Minor

Global cleanupStack causes cleanup functions to execute in wrong effect context when effects nest.

When effectSync (or any effect) executes within another effect's function body, the inner effect's runner calls runCleanup() which consumes cleanup functions from the shared global stack. This means cleanup functions registered by the outer effect via onCleanup() execute during the inner effect's runner instead of at the intended cleanup time.

Example: If effect A registers cleanup via onCleanup(), then effect B's effectSync executes in A's function body, B's runner pops A's cleanup from the stack and executes it immediately—A's cleanup never runs at the proper time.

Consider associating cleanup registrations with the currently executing effect context (e.g., via a context stack) rather than a global stack to isolate cleanup functions per effect.

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

In `@Build/src/kernel/effect.ts` around lines 34 - 45, The global cleanupStack
causes cross-effect interference; change cleanup registration to be per-effect
by introducing a current effect context (e.g., a context stack or currentEffect
variable) and moving cleanup storage off the global cleanupStack into each
effect instance. Update onCleanup to push the cleanup function onto the
currently executing effect's cleanup array (not cleanupStack), update runCleanup
to operate on an effect instance's cleanup list (or be removed in favor of
instance.runCleanup), and ensure effectSync/runner sets and clears the current
effect context around executing the effect function so that each effect's runner
only runs its own cleanup functions. Reference symbols: cleanupStack, onCleanup,
runCleanup, effectSync, runner.
♻️ Duplicate comments (5)
Build/src/kernel/graph.ts (2)

126-134: ⚠️ Potential issue | 🟠 Major

Root lookups are broken here.

When prefix.raw === "/", Line 133 only accepts an exact / match or startsWith("//"), so getNodesUnder(path()) misses every normal node. matchesPath() does not rescue this because the root pattern is an exact non-glob path.

Suggested fix
-    const startsWithPrefix = node.path.raw === prefixStr || node.path.raw.startsWith(prefixStr + "/");
+    const startsWithPrefix =
+      prefixStr === "/" ||
+      node.path.raw === prefixStr ||
+      node.path.raw.startsWith(prefixStr + "/");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/kernel/graph.ts` around lines 126 - 134, getNodesUnder is missing
all non-root nodes when prefix.raw === "/" because startsWith check only matches
exact "/" or "//..."; update the startsWithPrefix calculation in getNodesUnder
to treat the root specially — e.g., compute startsWithPrefix as (prefixStr ===
"/" || node.path.raw === prefixStr || node.path.raw.startsWith(prefixStr + "/"))
— so root lookups return all nodes under "/" while preserving the existing
fallback to matchesPath(prefix, node.path).

334-363: ⚠️ Potential issue | 🟠 Major

Cleanup state resets too early and never fully drains multi-chunk batches.

Line 357 flips cleanupScheduled to false before the queued work finishes, so another caller can enqueue a second cleanup pass. Also, the buffer clear at Lines 359-362 only runs after the first chunk; once a batch spans multiple microtasks, scheduledCleanupNodes is left populated and gets reprocessed on the next call.

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

In `@Build/src/kernel/graph.ts` around lines 334 - 363, The cleanupScheduled flag
is being cleared too early and the scheduledCleanupNodes buffer is only cleared
after the first microtask chunk; change the logic so cleanupScheduled remains
true until all chunks are processed and clear scheduledCleanupNodes when
cleanupChunk finishes the final chunk. Specifically, update cleanupChunk (and
its use of index and scheduledCleanupNodes) to detect when index >=
scheduledCleanupNodes.length and then (1) reset cleanupScheduled = false and (2)
set scheduledCleanupNodes.length = 0, instead of clearing cleanupScheduled at
the start of the queued microtask; ensure queueMicrotask only invokes
cleanupChunk and does not flip the flag prematurely so multiple callers won’t
interleave batches.
Build/src/store/map.ts (2)

51-53: ⚠️ Potential issue | 🟡 Minor

Use a non-returning callback here.

Biome is right about Line 52: the concise arrow still returns fn(). A block body or for...of removes the lint error without changing behavior.

Suggested fix
-    this.subscribers.forEach((fn) => fn());
+    for (const fn of this.subscribers) {
+      fn();
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/store/map.ts` around lines 51 - 53, notify() currently uses a
concise arrow that returns fn() which triggers the lint about returning a value;
change the implementation to use a non-returning callback (e.g., use a block
arrow body or a for...of loop) so each subscriber is invoked but no value is
returned. Update the notify method that iterates over subscribers
(this.subscribers and the subscriber callback symbol fn) to call each subscriber
with a statement body (e.g., this.subscribers.forEach((fn) => { fn(); }); or for
(const fn of this.subscribers) fn();) ensuring subscribers are typed as () =>
void if not already.

15-33: ⚠️ Potential issue | 🟠 Major

Primitive key IDs still collide across distinct keys.

Line 26 stringifies the primitive before indexing primitiveKeyIdMap, so 1 and "1" share a kid, and two different symbols with the same description stringify alike. Those keys then reuse the same signal path under path("map", this.id, kid).

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

In `@Build/src/store/map.ts` around lines 15 - 33, The bug is that primitive keys
are stringified before lookup so distinct primitives (e.g. 1 vs "1" and
different Symbols with same description) collide; in keyToId you should stop
calling String(key) for primitives and instead use the original primitive value
as the Map key (i.e. let prim = key as string|number|symbol; use
primitiveKeyIdMap.get(prim) and primitiveKeyIdMap.set(prim, id)); keep using
keyIdMap for object/function keys and nextKeyId to generate ids, and ensure
primitiveKeyIdMap's declared key type supports symbol/number/string so symbol
identity is preserved.
Build/src/kernel/path.ts (1)

63-102: ⚠️ Potential issue | 🟠 Major

** matching still misses valid deep-glob paths.

The deep branch still jumps to the first segment equal to p[pi + 1] and treats that follower as a literal. path("**", "*") can never match anything, and path("a", "**", "b") still rejects targets like ["a", "b", "c", "b"]. This needs real segment-wise backtracking for **, not a first-hit scan.

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

In `@Build/src/kernel/path.ts` around lines 63 - 102, The current deep-glob
matching in the block handling pattern.globType === "deep" incorrectly advances
ti to the first occurrence of the next literal segment and thus fails on cases
like ["**","*"] or patterns like ["a","**","b"] where later occurrences should
match; modify the algorithm in this block (operating on pattern.segments and
target.segments using pi and ti) to perform true segment-wise backtracking for
"**" instead of a single first-hit scan — e.g., when encountering "**" try
matching the remainder of the pattern against every possible ti position
(including zero segments consumed) (implement via recursion or an explicit
backtrack loop/stack), ensure "*" still consumes exactly one segment, and
preserve the existing end-of-pattern checks (trailing "**" handling).
🧹 Nitpick comments (7)
Build/src/store/array.ts (1)

1-2: Unused import: trackDependency

trackDependency is imported but never used in this file. The reactive tracking is correctly done via itemsSignal.get() calls instead.

♻️ Remove unused import
-import { Signal, signal, Subscriber, trackDependency } from "../kernel";
+import { Signal, signal, Subscriber } from "../kernel";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/store/array.ts` around lines 1 - 2, The import list in
Build/src/store/array.ts includes an unused symbol trackDependency; remove
trackDependency from the import statement that currently reads "import { Signal,
signal, Subscriber, trackDependency } from \"../kernel\";" so the file imports
only the symbols actually used (e.g., Signal, signal, Subscriber). Verify no
other references to trackDependency exist (reactivity is handled via
itemsSignal.get()), then run a quick lint/compile to ensure the unused-import
warning is resolved.
Build/src/flow/index.ts (3)

148-157: Sequence continues after errors by design.

The loop continues executing subsequent functions even after an error occurs, collecting all errors into allErrors. This is intentional collect-all-errors behavior, but consider documenting this semantic since some users might expect fail-fast.

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

In `@Build/src/flow/index.ts` around lines 148 - 157, The loop over fns
intentionally collects results and errors (pushing successes into allResults and
errors into allErrors) instead of failing fast; add an explicit comment and/or
update the function’s docstring explaining that the behavior of the function
(the loop using fns with abortController.signal) is to continue after errors and
aggregate errors in allErrors rather than short-circuiting, and mention how
abortController.signal interacts with flow termination so callers know to expect
collect-all-errors semantics for functions like the one that contains the fns,
allResults, allErrors and abortController symbols.

231-276: winner signal remains -1 on error — document this behavior.

When the race resolves with an error (first promise rejects), winner stays at -1 since line 266 only sets it on success. This is reasonable but should be documented for consumers expecting to identify which promise failed first.

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

In `@Build/src/flow/index.ts` around lines 231 - 276, The race function currently
leaves the winner signal at -1 when the first settled promise is a rejection;
update the implementation documentation (add/extend the JSDoc or an inline
comment on the export function race) to explicitly state that winner will only
be set to a non-negative index on success and remains -1 when the first outcome
is an error, so consumers know this is intentional; reference the race function
and the winner signal to locate where to add the note (or, if you prefer
behavioral change instead of docs, set winner.set(outcome.index) inside the
error branch as an alternative).

30-36: Consider documenting concurrent start behavior.

When start() is called while already running, it awaits the current promise and returns without re-executing. This may surprise callers expecting idempotent behavior or re-triggering. Consider adding a JSDoc comment explaining this behavior.

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

In `@Build/src/flow/index.ts` around lines 30 - 36, Add a JSDoc comment to the
start function explaining its concurrent behavior: when running.peek() is true,
start will await currentPromise (if set) and return without re-executing the
operation, so callers should not expect re-triggering and can treat it as an
idempotent no-op during active runs; include notes on currentPromise and
running.peek() semantics and intended usage expectations to avoid confusion.
Build/src/kernel/effect.ts (2)

17-32: Effect pool stores fn but never uses it.

The pool stores { fn, cleanup } objects, but getEffectFromPool creates objects with a placeholder fn: () => undefined, and createEffect (line 49) never reads pooled.fn — it uses the fn parameter directly. The pool effectively only reuses the object shell while clearing cleanup.

Consider simplifying if the fn field isn't needed:

♻️ Simplify pool to only track cleanup
-const effectPool: { fn: () => CleanupFn; cleanup: CleanupFn }[] = [];
+const effectPool: { cleanup: CleanupFn }[] = [];

-function getEffectFromPool(): { fn: () => CleanupFn; cleanup: CleanupFn } {
+function getEffectFromPool(): { cleanup: CleanupFn } {
   if (effectPool.length > 0) {
     return effectPool.pop()!;
   }
-  return { fn: () => undefined, cleanup: undefined };
+  return { cleanup: undefined };
 }

-function returnEffectToPool(effect: { fn: () => CleanupFn; cleanup: CleanupFn }): void {
+function returnEffectToPool(effect: { cleanup: CleanupFn }): void {
   if (effectPool.length < EFFECT_POOL_SIZE) {
     effect.cleanup = undefined;
     effectPool.push(effect);
   }
 }

Then update line 94:

-    returnEffectToPool({ fn, cleanup: cleanupFn });
+    returnEffectToPool({ cleanup: cleanupFn });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/kernel/effect.ts` around lines 17 - 32, The effect pool currently
stores an unused fn field; simplify the pool to store only cleanup functions by
changing EFFECT_POOL_SIZE and effectPool to hold items of type { cleanup:
CleanupFn | undefined }, update getEffectFromPool to return { cleanup: undefined
} as the placeholder, and update returnEffectToPool to set effect.cleanup =
undefined before pushing; also update any usages (e.g., createEffect) to stop
referencing pooled.fn and instead assign the provided fn directly to the newly
created effect so the pooled object is only reused for its cleanup slot.

6-12: Remove unused imports from ./graph.

subscribe, unsubscribe, getOrCreateNode, PathKey, and EffectNode are imported but not used in this file.

♻️ Remove unused imports
-import {
-  subscribe,
-  unsubscribe,
-  getOrCreateNode,
-  type PathKey,
-  type EffectNode,
-} from "./graph";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/kernel/effect.ts` around lines 6 - 12, The import statement from
"./graph" includes unused symbols (subscribe, unsubscribe, getOrCreateNode,
PathKey, EffectNode); remove those names from the import list (or remove the
entire import if nothing from "./graph" is used in Build/src/kernel/effect.ts)
and leave only the symbols actually referenced in this file to eliminate unused
imports.
Build/src/kernel/index.ts (1)

20-28: Reorganize imports before exports for clarity.

Line 28 re-exports path as createPath using the local binding from the import on line 67. While ES module hoisting makes this work, having imports scattered after exports is unconventional and harder to follow. Consider moving all imports to the top.

♻️ Proposed reorganization

Move the local imports (lines 63-67) to the top of the file, before the re-exports:

+import { Signal } from "./signal";
+import { Derived } from "./derived";
+import { effect, effectSync, onCleanup, untracked } from "./effect";
+import { batch } from "./batch";
+import { type PathKey, path } from "./path";
+
 export { Signal, signal, isSignal } from "./signal";
 // ... other exports ...
 export { path as createPath }; // Now clearly uses the local import above
 // ... remaining exports ...
-
-import { Signal } from "./signal";
-import { Derived } from "./derived";
-import { effect, effectSync, onCleanup, untracked } from "./effect";
-import { batch } from "./batch";
-import { type PathKey, path } from "./path";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/kernel/index.ts` around lines 20 - 28, Move the local imports that
provide the `path` binding (and any related local imports used later) to the top
of the module so all imports appear before any exports; specifically, ensure the
module that defines `path`, `matchesPath`, `isPathKey`, `getParentPath`,
`joinPath`, and `PathKey` is imported before you export them and before you
re-export `path` as `createPath` to improve readability and follow conventional
ES module ordering.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Build/src/async/resource.ts`:
- Around line 48-50: The finally handler currently skips loading.set(false) when
currentController.signal.aborted, leaving loading stuck; update the .finally()
blocks (the ones guarding loading.set(false) around
currentController/abortController) to remove the signal.aborted check so that if
(abortController === currentController) loading.set(false) always runs, and also
ensure the abort() implementation (the function that calls
abortController.abort()) explicitly calls loading.set(false) after aborting so
aborted requests clear loading immediately; apply the same changes to both
finally sites referenced in the diff.

In `@Build/src/flow/index.ts`:
- Around line 260-269: After Promise.race resolves, abort any remaining
competitors by calling the shared AbortController so losers stop executing;
specifically, once you handle outcome (the if "error" in outcome / else branches
that call error.set, result.set and winner.set), invoke abortController.abort()
before setting running.set(false). Ensure the promises array was created with
the same abortController.signal so individual tasks (the promises) can observe
the abort and cancel side effects.

In `@Build/src/kernel/derived.ts`:
- Around line 36-53: recompute() is clearing the derived node's own subscribers
(this._node.subscribers.clear()) which removes Derived.subscribe() handlers but
does not unsubscribe the tracker from source nodes (trackNode() adds the tracker
to source.subscribers), causing stale subscriptions to accumulate; fix by
removing the tracker from each source node before re-tracking: iterate the
derived node's current dependency list (e.g., this._node.sources or
previousDependencies stored on the node) and call
source.subscribers.delete(trackerFn) (or the stored tracker reference like
this._node.tracker) to unsubscribe from each source, then proceed to
setGlobalActiveComputation and recompute so new source subscriptions are added
correctly.

In `@Build/src/kernel/graph.ts`:
- Around line 206-225: The circular-check Set on the active computation is being
used as a permanent membership list, so repeated reads (e.g., sig.get() +
sig.get()) falsely trigger the cycle path; change trackNode to treat
__circularCheck as a transient stack entry: after creating/obtaining
(computation as any).__circularCheck and adding the node, call subscribe(node,
computation) and then remove the node from the Set (nodes.delete(node)) so the
entry only protects the immediate subscription call; reference symbols:
trackNode, getGlobalActiveComputation, (computation as any).__circularCheck,
subscribe, and nodes.
- Around line 229-232: The test reset currently clears nodeRegistry and
lockedPaths in __resetRegistryForTesting(), but leaves aliases,
scheduledCleanupNodes, and cleanupScheduled intact which can leak state between
tests; update __resetRegistryForTesting() to also clear aliases (e.g.,
aliases.clear()), reset scheduledCleanupNodes (e.g.,
scheduledCleanupNodes.clear() or reassign an empty collection) and set
cleanupScheduled back to its initial false state so all global graph state is
fully reset for tests.

In `@Build/src/kernel/signal.ts`:
- Around line 77-88: The signal helper currently always constructs a new
Signal(path, initial, true) for the non-alias branch which overwrites any
existing node/value; update signal(pathOrInitial, initial) to check
getNode(path) in the non-alias branch and if an existingNode with kind ===
"signal" exists return it (or construct a Signal that preserves the existing
node's current value and metadata) instead of always creating a fresh Signal;
use resolveAlias(path) only for alias resolution but ensure both branches avoid
clobbering by reusing the existing signal node when present (refer to signal,
resolveAlias, getNode, and the Signal constructor to locate and change the
logic).

In `@Build/src/store/map.ts`:
- Around line 55-73: get()/has() and the iterator helpers currently only touch
existing entry signals so they don't track membership changes; update get(key:
K), has(key: K) and the iterator helpers (the methods referenced around lines
90-128) to also read the collection membership signal (e.g., call
this.sizeSignal.get() or the equivalent trackDependency for path("map", this.id)
or path("map", this.id, keyId)) when determining presence or when a key is
missing so derived/effects re-run on adds/removes; ensure set(...) continues to
update this.sizeSignal.set(...) and call notify() so the newly added/removed
entries cause invalidation.

In `@Build/src/store/reactive.ts`:
- Around line 33-41: The nested-store path is being built by concatenating
strings (reactive(..., `${storePath}/${String(key)}`)), which creates segments
containing '/' that path() rejects; replace the concatenation with a proper
path() construction and pass that to reactive so segments are validated (use
path(storePath, String(key)) as the second argument to reactive). Locate the
reactive(...) call and change its second argument from the combined string to
path(storePath, String(key)) so nested objects use the same safe path building
as the signal(...) calls.

---

Outside diff comments:
In `@Build/src/kernel/effect.ts`:
- Around line 34-45: The global cleanupStack causes cross-effect interference;
change cleanup registration to be per-effect by introducing a current effect
context (e.g., a context stack or currentEffect variable) and moving cleanup
storage off the global cleanupStack into each effect instance. Update onCleanup
to push the cleanup function onto the currently executing effect's cleanup array
(not cleanupStack), update runCleanup to operate on an effect instance's cleanup
list (or be removed in favor of instance.runCleanup), and ensure
effectSync/runner sets and clears the current effect context around executing
the effect function so that each effect's runner only runs its own cleanup
functions. Reference symbols: cleanupStack, onCleanup, runCleanup, effectSync,
runner.

---

Duplicate comments:
In `@Build/src/kernel/graph.ts`:
- Around line 126-134: getNodesUnder is missing all non-root nodes when
prefix.raw === "/" because startsWith check only matches exact "/" or "//...";
update the startsWithPrefix calculation in getNodesUnder to treat the root
specially — e.g., compute startsWithPrefix as (prefixStr === "/" ||
node.path.raw === prefixStr || node.path.raw.startsWith(prefixStr + "/")) — so
root lookups return all nodes under "/" while preserving the existing fallback
to matchesPath(prefix, node.path).
- Around line 334-363: The cleanupScheduled flag is being cleared too early and
the scheduledCleanupNodes buffer is only cleared after the first microtask
chunk; change the logic so cleanupScheduled remains true until all chunks are
processed and clear scheduledCleanupNodes when cleanupChunk finishes the final
chunk. Specifically, update cleanupChunk (and its use of index and
scheduledCleanupNodes) to detect when index >= scheduledCleanupNodes.length and
then (1) reset cleanupScheduled = false and (2) set scheduledCleanupNodes.length
= 0, instead of clearing cleanupScheduled at the start of the queued microtask;
ensure queueMicrotask only invokes cleanupChunk and does not flip the flag
prematurely so multiple callers won’t interleave batches.

In `@Build/src/kernel/path.ts`:
- Around line 63-102: The current deep-glob matching in the block handling
pattern.globType === "deep" incorrectly advances ti to the first occurrence of
the next literal segment and thus fails on cases like ["**","*"] or patterns
like ["a","**","b"] where later occurrences should match; modify the algorithm
in this block (operating on pattern.segments and target.segments using pi and
ti) to perform true segment-wise backtracking for "**" instead of a single
first-hit scan — e.g., when encountering "**" try matching the remainder of the
pattern against every possible ti position (including zero segments consumed)
(implement via recursion or an explicit backtrack loop/stack), ensure "*" still
consumes exactly one segment, and preserve the existing end-of-pattern checks
(trailing "**" handling).

In `@Build/src/store/map.ts`:
- Around line 51-53: notify() currently uses a concise arrow that returns fn()
which triggers the lint about returning a value; change the implementation to
use a non-returning callback (e.g., use a block arrow body or a for...of loop)
so each subscriber is invoked but no value is returned. Update the notify method
that iterates over subscribers (this.subscribers and the subscriber callback
symbol fn) to call each subscriber with a statement body (e.g.,
this.subscribers.forEach((fn) => { fn(); }); or for (const fn of
this.subscribers) fn();) ensuring subscribers are typed as () => void if not
already.
- Around line 15-33: The bug is that primitive keys are stringified before
lookup so distinct primitives (e.g. 1 vs "1" and different Symbols with same
description) collide; in keyToId you should stop calling String(key) for
primitives and instead use the original primitive value as the Map key (i.e. let
prim = key as string|number|symbol; use primitiveKeyIdMap.get(prim) and
primitiveKeyIdMap.set(prim, id)); keep using keyIdMap for object/function keys
and nextKeyId to generate ids, and ensure primitiveKeyIdMap's declared key type
supports symbol/number/string so symbol identity is preserved.

---

Nitpick comments:
In `@Build/src/flow/index.ts`:
- Around line 148-157: The loop over fns intentionally collects results and
errors (pushing successes into allResults and errors into allErrors) instead of
failing fast; add an explicit comment and/or update the function’s docstring
explaining that the behavior of the function (the loop using fns with
abortController.signal) is to continue after errors and aggregate errors in
allErrors rather than short-circuiting, and mention how abortController.signal
interacts with flow termination so callers know to expect collect-all-errors
semantics for functions like the one that contains the fns, allResults,
allErrors and abortController symbols.
- Around line 231-276: The race function currently leaves the winner signal at
-1 when the first settled promise is a rejection; update the implementation
documentation (add/extend the JSDoc or an inline comment on the export function
race) to explicitly state that winner will only be set to a non-negative index
on success and remains -1 when the first outcome is an error, so consumers know
this is intentional; reference the race function and the winner signal to locate
where to add the note (or, if you prefer behavioral change instead of docs, set
winner.set(outcome.index) inside the error branch as an alternative).
- Around line 30-36: Add a JSDoc comment to the start function explaining its
concurrent behavior: when running.peek() is true, start will await
currentPromise (if set) and return without re-executing the operation, so
callers should not expect re-triggering and can treat it as an idempotent no-op
during active runs; include notes on currentPromise and running.peek() semantics
and intended usage expectations to avoid confusion.

In `@Build/src/kernel/effect.ts`:
- Around line 17-32: The effect pool currently stores an unused fn field;
simplify the pool to store only cleanup functions by changing EFFECT_POOL_SIZE
and effectPool to hold items of type { cleanup: CleanupFn | undefined }, update
getEffectFromPool to return { cleanup: undefined } as the placeholder, and
update returnEffectToPool to set effect.cleanup = undefined before pushing; also
update any usages (e.g., createEffect) to stop referencing pooled.fn and instead
assign the provided fn directly to the newly created effect so the pooled object
is only reused for its cleanup slot.
- Around line 6-12: The import statement from "./graph" includes unused symbols
(subscribe, unsubscribe, getOrCreateNode, PathKey, EffectNode); remove those
names from the import list (or remove the entire import if nothing from
"./graph" is used in Build/src/kernel/effect.ts) and leave only the symbols
actually referenced in this file to eliminate unused imports.

In `@Build/src/kernel/index.ts`:
- Around line 20-28: Move the local imports that provide the `path` binding (and
any related local imports used later) to the top of the module so all imports
appear before any exports; specifically, ensure the module that defines `path`,
`matchesPath`, `isPathKey`, `getParentPath`, `joinPath`, and `PathKey` is
imported before you export them and before you re-export `path` as `createPath`
to improve readability and follow conventional ES module ordering.

In `@Build/src/store/array.ts`:
- Around line 1-2: The import list in Build/src/store/array.ts includes an
unused symbol trackDependency; remove trackDependency from the import statement
that currently reads "import { Signal, signal, Subscriber, trackDependency }
from \"../kernel\";" so the file imports only the symbols actually used (e.g.,
Signal, signal, Subscriber). Verify no other references to trackDependency exist
(reactivity is handled via itemsSignal.get()), then run a quick lint/compile to
ensure the unused-import warning is resolved.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 21a9183d-50a8-4334-b0c2-a2efb04daa2b

📥 Commits

Reviewing files that changed from the base of the PR and between 7cc7e53 and faf7fe2.

📒 Files selected for processing (13)
  • Build/src/async/index.ts
  • Build/src/async/resource.ts
  • Build/src/flow/index.ts
  • Build/src/kernel/batch.ts
  • Build/src/kernel/derived.ts
  • Build/src/kernel/effect.ts
  • Build/src/kernel/graph.ts
  • Build/src/kernel/index.ts
  • Build/src/kernel/path.ts
  • Build/src/kernel/signal.ts
  • Build/src/store/array.ts
  • Build/src/store/map.ts
  • Build/src/store/reactive.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • Build/src/kernel/batch.ts

Comment thread Build/src/async/resource.ts
Comment thread Build/src/flow/index.ts
Comment thread Build/src/kernel/derived.ts
Comment thread Build/src/kernel/effect.ts
Comment thread Build/src/kernel/graph.ts
Comment thread Build/src/kernel/graph.ts
Comment thread Build/src/kernel/signal.ts
Comment thread Build/src/store/map.ts
Comment thread Build/src/store/reactive.ts

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

♻️ Duplicate comments (7)
Build/src/store/map.ts (2)

61-63: ⚠️ Potential issue | 🟡 Minor

notify() still triggers the Biome callback-return warning.

this.subscribers.forEach((fn) => fn()); implicitly returns the callback result, which is the exact pattern Biome flags on this hunk. Switch to a block body or a for...of loop.

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

In `@Build/src/store/map.ts` around lines 61 - 63, The notify() method currently
uses a concise arrow callback this.subscribers.forEach((fn) => fn()); which
implicitly returns the callback result and triggers the Biome callback-return
warning; change the invocation to a non-returning form (e.g., use a block-bodied
arrow this.subscribers.forEach((fn) => { fn(); }) or replace the forEach with a
for...of loop iterating over this.subscribers and calling fn() inside the loop)
so the callback does not implicitly return a value; update the notify()
implementation accordingly.

65-67: ⚠️ Potential issue | 🟠 Major

Collection membership still isn't tracked for reads or iteration.

These helpers only read per-entry signals. Effects/deriveds that call get() on a missing key, has(), or iterate values(), entriesIterable(), forEach(), or toArray() won't rerun on add/remove because none of them touch sizeSignal. Read the membership signal before the lookup/iteration.

Also applies to: 81-83, 112-142

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

In `@Build/src/store/map.ts` around lines 65 - 67, The collection membership
(sizeSignal) must be read before any membership-sensitive operations so effects
re-run on add/remove; update the methods get(key: K), has(key: K), values(),
entriesIterable(), forEach(), and toArray() to first call or access
sizeSignal.get() (or otherwise read the membership signal) before performing the
per-entry lookup/iteration via this.entries, so reads of missing keys and all
iteration helpers observe collection membership changes and will rerun when
items are added/removed.
Build/src/store/reactive.ts (1)

33-39: ⚠️ Potential issue | 🔴 Critical

Nested object paths are still being serialized into a single segment.

Line 39 passes ${storePath}/${String(key)} into the next reactive() call. On the next recursion, Line 33 feeds that string back into path(storePath, "$"), and Build/src/kernel/path.ts rejects segments containing /. Any nested object store will still throw during construction.

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

In `@Build/src/store/reactive.ts` around lines 33 - 39, The bug is passing a
manually concatenated path "${storePath}/${String(key)}" into reactive(), which
injects '/' into a segment and later breaks path(...) in
Build/src/kernel/path.ts; fix by constructing the nested path with the path(...)
helper instead of string interpolation (call reactive(value, path(storePath,
String(key)))) and ensure signal(...) remains using path(storePath, "$") so no
segment contains '/'.
Build/src/kernel/signal.ts (1)

78-89: ⚠️ Potential issue | 🔴 Critical

Repeated signal(path, ...) calls still clobber existing nodes.

The alias branch is guarded, but the non-alias path still returns new Signal(path, initial, true) unconditionally. signal(existingPath) clears the current value to undefined, and signal(existingPath, next) overwrites the existing signal instead of reusing it.

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

In `@Build/src/kernel/signal.ts` around lines 78 - 89, The signal function
currently creates a new Signal for a path even when a signal node already
exists, which clobbers existing state; change the logic in signal(pathOrInitial)
to call getNode(path) after resolving aliases (use resolveAlias and getNode) and
if existingNode is present and existingNode.kind === "signal" return that
existing node (cast to Signal<T>) instead of constructing a new Signal; only
construct new Signal(path, initial, true) when no existing node is found, and do
the same check in the alias-resolved branch so you never pass undefined to the
Signal constructor to overwrite an existing signal.
Build/src/kernel/derived.ts (1)

36-55: ⚠️ Potential issue | 🔴 Critical

recompute() is still clearing the wrong subscriber set.

trackNode() in Build/src/kernel/graph.ts subscribes the tracker onto source nodes, not onto this._node. Clearing this._node.subscribers therefore removes Derived.subscribe() listeners while stale source subscriptions keep accumulating across recomputes. Preserve the derived's own subscribers and explicitly unsubscribe the previous tracker from prior sources before re-tracking.

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

In `@Build/src/kernel/derived.ts` around lines 36 - 55, recompute() is clearing
this._node.subscribers (removing Derived.subscribe listeners) instead of
removing the previous tracker subscriptions on source nodes; remove the
this._node.subscribers.clear() call and instead keep/track the previous tracker
unsubscribe handle (or subscription list) so you can explicitly unsubscribe the
prior tracker from source nodes before re-tracking. Concretely: in recompute(),
do not clear this._node.subscribers; create the tracker as you do, call an
unsubscribe/cleanup for the previous tracker (store that handle on the derived
node), then setGlobalActiveComputation(tracker), run compute, restore the
previous global, and store the new tracker unsubscribe handle returned by
trackNode()/subscription API so future recomputes can remove stale source
subscriptions while preserving Derived.subscribe() listeners.
Build/src/kernel/graph.ts (1)

362-369: ⚠️ Potential issue | 🟠 Major

Race condition in incremental cleanup scheduling persists.

The cleanupScheduled flag is set to false (line 365) before cleanupChunk() runs. If cleanupChunk has more work and re-queues itself (line 354), a concurrent call to scheduleIncrementalCleanup during that time sees cleanupScheduled=false and schedules a duplicate microtask.

🐛 Proposed fix: Keep cleanupScheduled true while processing
   if (!cleanupScheduled) {
     cleanupScheduled = true;
     queueMicrotask(() => {
-      cleanupScheduled = false;
       cleanupChunk();
     });
   }

And reset cleanupScheduled only when processing completes:

     } else {
       // Finished, reset for next batch
       scheduledCleanupNodes.length = 0;
       cleanupIndex = 0;
+      cleanupScheduled = false;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/kernel/graph.ts` around lines 362 - 369, The race occurs because
cleanupScheduled is cleared before cleanupChunk finishes, allowing a concurrent
scheduleIncrementalCleanup to enqueue a duplicate microtask; change the
microtask callback to keep cleanupScheduled true while cleanupChunk runs and
only set it to false after cleanupChunk completes (handle both sync and async
returns). Concretely, in the queueMicrotask callback call
Promise.resolve(cleanupChunk()).finally(() => { cleanupScheduled = false; })
instead of setting cleanupScheduled = false before invoking cleanupChunk,
referencing the existing cleanupScheduled flag and cleanupChunk() function.
Build/src/kernel/path.ts (1)

121-128: ⚠️ Potential issue | 🟡 Minor

getParentPath() behavior may not match the comment for single-segment globs.

The comment states "parent of /user/** is /user", but for path("user", "**"), nonGlobSegments = ["user"] with length 1, which returns null per line 124-125. This contradicts the documented intent.

🧭 Suggested fix to match documented behavior
 export function getParentPath(p: PathKey): PathKey | null {
   const nonGlobSegments = p.segments.filter((s) => s !== "*" && s !== "**");
+  // For glob paths, return the non-glob base if it exists
+  if (p.isGlob && nonGlobSegments.length > 0) {
+    return path(...nonGlobSegments);
+  }
   if (nonGlobSegments.length <= 1) {
     return null;
   }
   return path(...nonGlobSegments.slice(0, -1));
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/kernel/path.ts` around lines 121 - 128, getParentPath currently
strips all glob segments and returns null when nonGlobSegments.length <= 1,
which makes path("user","**") return null instead of "/user"; update
getParentPath so that if nonGlobSegments.length === 0 it returns null, if length
=== 1 it returns path(...nonGlobSegments) (the single-segment parent like
"/user"), and otherwise return path(...nonGlobSegments.slice(0, -1)); reference
function getParentPath, variable nonGlobSegments and the path(...) constructor
to locate and implement the simple conditional fix.
🧹 Nitpick comments (3)
Build/src/kernel/effect.ts (1)

6-12: Unused imports from graph module.

The imports subscribe, unsubscribe, getOrCreateNode, PathKey, and EffectNode from "./graph" are not used anywhere in this file. Consider removing them to avoid confusion.

🧹 Proposed fix
 import {
   getGlobalActiveComputation,
   setGlobalActiveComputation,
 } from "./dependency";
 import { scheduleEffect } from "./batch";
-import {
-  subscribe,
-  unsubscribe,
-  getOrCreateNode,
-  type PathKey,
-  type EffectNode,
-} from "./graph";
 import { getSairinLogger } from "./config";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/kernel/effect.ts` around lines 6 - 12, The import statement in
effect.ts includes unused symbols from "./graph" — remove subscribe,
unsubscribe, getOrCreateNode, PathKey, and EffectNode from the import list (or
collapse the import entirely if none of the remaining symbols from "./graph" are
used) so only actually referenced exports are imported; update the import line
that currently names subscribe, unsubscribe, getOrCreateNode, PathKey, and
EffectNode accordingly.
Build/src/kernel/graph.ts (1)

371-380: Unused logger variable.

getSairinLogger() is called at line 372 but logger is never used in the function body.

🧹 Remove unused variable
 export function capRetainedMemory(nodes: ReactiveNode[]): void {
-  const logger = getSairinLogger();
   for (const node of nodes) {
     node.subscribers.clear();
     if (node.kind === "derived") {
       (node as any).cached = undefined;
       (node as any).dirty = true;
     }
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Build/src/kernel/graph.ts` around lines 371 - 380, The function
capRetainedMemory declares an unused variable logger by calling
getSairinLogger(); remove the unused call/variable (delete the line "const
logger = getSairinLogger();") so capRetainedMemory only iterates nodes, clears
node.subscribers and resets derived node cached/dirty flags; alternatively, if
logging is intended, use the returned logger to emit a relevant message inside
capRetainedMemory instead of leaving it unused.
Build/src/flow/index.ts (1)

83-122: Consider documenting concurrent start behavior.

Unlike flow(), pipeline.start() doesn't guard against concurrent runs. Calling start() while a previous run is active will orphan the first run (its results are silently discarded). If this "last-write-wins" behavior is intentional, consider adding a brief comment.

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

In `@Build/src/flow/index.ts` around lines 83 - 122, The pipeline function's
start() allows concurrent runs and can orphan earlier runs (their results/errors
are discarded) because it overwrites abortController and only applies writes
when abortController === runController; add a concise comment above pipeline or
the start method (referencing pipeline, start, abortController, running, result,
error) stating this "last-write-wins" behavior and that concurrent calls to
start() will not be queued or merged, so callers must manage concurrency —
alternatively, if intended to prevent this, implement a guard in start() to
no-op or throw when running is true.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Build/src/store/map.ts`:
- Around line 15-32: The primitive-stringification in keyToId causes collisions
(e.g., 1 vs "1" and Symbols) — stop using String(key) and use the primitive
value itself as the Map key; update keyToId to treat non-object/ non-function
keys by looking up primitiveKeyIdMap.get(key) (using key as-is) and setting a
generated id via (++nextKeyId).toString(36) when missing; make the analogous
change in removeKeyId so it deletes by the primitive key (not String(key)); keep
object-handling logic using keyIdMap unchanged and continue to reference
nextKeyId, primitiveKeyIdMap, keyIdMap, keyToId and removeKeyId when applying
the patch.

In `@Build/src/store/reactive.ts`:
- Around line 54-64: The setter logic uses target.$.path.raw (serialized) which
breaks child path construction and skips nested object updates; change both
set(target, prop, ...) and setReactive(...) to derive the parent path from
target.$.path.segments.slice(0, -1) (use that array to build child paths via
path(...segments, String(prop)) rather than path(raw,...)), and when newValue is
an object (and not a Signal) recurse into its keys to create/update child
Signals instead of assigning a single root Signal — ensure you call the same
conversion logic used for existing Signal children (referencing the set method
and setReactive function names) so nested ReactiveObject properties are
updated/created recursively.

---

Duplicate comments:
In `@Build/src/kernel/derived.ts`:
- Around line 36-55: recompute() is clearing this._node.subscribers (removing
Derived.subscribe listeners) instead of removing the previous tracker
subscriptions on source nodes; remove the this._node.subscribers.clear() call
and instead keep/track the previous tracker unsubscribe handle (or subscription
list) so you can explicitly unsubscribe the prior tracker from source nodes
before re-tracking. Concretely: in recompute(), do not clear
this._node.subscribers; create the tracker as you do, call an
unsubscribe/cleanup for the previous tracker (store that handle on the derived
node), then setGlobalActiveComputation(tracker), run compute, restore the
previous global, and store the new tracker unsubscribe handle returned by
trackNode()/subscription API so future recomputes can remove stale source
subscriptions while preserving Derived.subscribe() listeners.

In `@Build/src/kernel/graph.ts`:
- Around line 362-369: The race occurs because cleanupScheduled is cleared
before cleanupChunk finishes, allowing a concurrent scheduleIncrementalCleanup
to enqueue a duplicate microtask; change the microtask callback to keep
cleanupScheduled true while cleanupChunk runs and only set it to false after
cleanupChunk completes (handle both sync and async returns). Concretely, in the
queueMicrotask callback call Promise.resolve(cleanupChunk()).finally(() => {
cleanupScheduled = false; }) instead of setting cleanupScheduled = false before
invoking cleanupChunk, referencing the existing cleanupScheduled flag and
cleanupChunk() function.

In `@Build/src/kernel/path.ts`:
- Around line 121-128: getParentPath currently strips all glob segments and
returns null when nonGlobSegments.length <= 1, which makes path("user","**")
return null instead of "/user"; update getParentPath so that if
nonGlobSegments.length === 0 it returns null, if length === 1 it returns
path(...nonGlobSegments) (the single-segment parent like "/user"), and otherwise
return path(...nonGlobSegments.slice(0, -1)); reference function getParentPath,
variable nonGlobSegments and the path(...) constructor to locate and implement
the simple conditional fix.

In `@Build/src/kernel/signal.ts`:
- Around line 78-89: The signal function currently creates a new Signal for a
path even when a signal node already exists, which clobbers existing state;
change the logic in signal(pathOrInitial) to call getNode(path) after resolving
aliases (use resolveAlias and getNode) and if existingNode is present and
existingNode.kind === "signal" return that existing node (cast to Signal<T>)
instead of constructing a new Signal; only construct new Signal(path, initial,
true) when no existing node is found, and do the same check in the
alias-resolved branch so you never pass undefined to the Signal constructor to
overwrite an existing signal.

In `@Build/src/store/map.ts`:
- Around line 61-63: The notify() method currently uses a concise arrow callback
this.subscribers.forEach((fn) => fn()); which implicitly returns the callback
result and triggers the Biome callback-return warning; change the invocation to
a non-returning form (e.g., use a block-bodied arrow
this.subscribers.forEach((fn) => { fn(); }) or replace the forEach with a
for...of loop iterating over this.subscribers and calling fn() inside the loop)
so the callback does not implicitly return a value; update the notify()
implementation accordingly.
- Around line 65-67: The collection membership (sizeSignal) must be read before
any membership-sensitive operations so effects re-run on add/remove; update the
methods get(key: K), has(key: K), values(), entriesIterable(), forEach(), and
toArray() to first call or access sizeSignal.get() (or otherwise read the
membership signal) before performing the per-entry lookup/iteration via
this.entries, so reads of missing keys and all iteration helpers observe
collection membership changes and will rerun when items are added/removed.

In `@Build/src/store/reactive.ts`:
- Around line 33-39: The bug is passing a manually concatenated path
"${storePath}/${String(key)}" into reactive(), which injects '/' into a segment
and later breaks path(...) in Build/src/kernel/path.ts; fix by constructing the
nested path with the path(...) helper instead of string interpolation (call
reactive(value, path(storePath, String(key)))) and ensure signal(...) remains
using path(storePath, "$") so no segment contains '/'.

---

Nitpick comments:
In `@Build/src/flow/index.ts`:
- Around line 83-122: The pipeline function's start() allows concurrent runs and
can orphan earlier runs (their results/errors are discarded) because it
overwrites abortController and only applies writes when abortController ===
runController; add a concise comment above pipeline or the start method
(referencing pipeline, start, abortController, running, result, error) stating
this "last-write-wins" behavior and that concurrent calls to start() will not be
queued or merged, so callers must manage concurrency — alternatively, if
intended to prevent this, implement a guard in start() to no-op or throw when
running is true.

In `@Build/src/kernel/effect.ts`:
- Around line 6-12: The import statement in effect.ts includes unused symbols
from "./graph" — remove subscribe, unsubscribe, getOrCreateNode, PathKey, and
EffectNode from the import list (or collapse the import entirely if none of the
remaining symbols from "./graph" are used) so only actually referenced exports
are imported; update the import line that currently names subscribe,
unsubscribe, getOrCreateNode, PathKey, and EffectNode accordingly.

In `@Build/src/kernel/graph.ts`:
- Around line 371-380: The function capRetainedMemory declares an unused
variable logger by calling getSairinLogger(); remove the unused call/variable
(delete the line "const logger = getSairinLogger();") so capRetainedMemory only
iterates nodes, clears node.subscribers and resets derived node cached/dirty
flags; alternatively, if logging is intended, use the returned logger to emit a
relevant message inside capRetainedMemory instead of leaving it unused.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0635626c-ef43-41fe-a6cf-12ee76b92a4d

📥 Commits

Reviewing files that changed from the base of the PR and between 2138d76 and cae5d99.

📒 Files selected for processing (12)
  • Build/src/flow/index.ts
  • Build/src/kernel/derived.ts
  • Build/src/kernel/effect.ts
  • Build/src/kernel/graph.ts
  • Build/src/kernel/path.ts
  • Build/src/kernel/signal.ts
  • Build/src/store/map.ts
  • Build/src/store/reactive.ts
  • Build/tests/benchmark.test.ts
  • Build/tests/context-debug.test.ts
  • Build/tests/graph.test.ts
  • Build/tests/kernel.test.ts

Comment thread Build/src/store/map.ts Outdated
Comment thread Build/src/store/reactive.ts
@NellowTCS NellowTCS merged commit 4413eb9 into main Mar 16, 2026
1 check passed
@NellowTCS NellowTCS deleted the fs-dom branch March 16, 2026 04:40
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