Conversation
Review or Edit in CodeSandboxOpen the branch in Web Editor • VS Code • Insiders |
|
Important Review skippedThis 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughAdds 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
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
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
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. 📝 Coding Plan
Comment |
There was a problem hiding this comment.
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()leavesloadingstuck attrue.Both abort handlers skip the
finallybranch that clearsloadingbecause 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 | 🔴 CriticalScope each load to the controller that started it.
Both
load()implementations read the mutableabortControllerinside.then()/.catch()/.finally(). Afterrefetch()or abort+restart, an older request can still overwrite the newer state;currentLoaderdoes 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 | 🔴 CriticalUse 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 overlappingstart()calls inpipeline,sequence,parallel, andrace— an older run can still clearrunningor 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, andwinnerin 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 | 🟠 MajorPotential memory leak: effect is never disposed.
The
effect()created at line 74 is never cleaned up. Each call todeferred()creates a new effect that will persist for the lifetime of the application, even if the returneddeferredValuesignal 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 | 🟡 MinorTest may not validate graph capture as intended.
The test creates
sig1andsig2but doesn't callregisterSignal()to add them to the internal registry. Looking at lines 88-94,captureGraph()iterates overregisteredSignals, 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 | 🟡 MinorsetReactive does not handle nested objects.
The function only updates top-level properties that are Signals, but if
valuecontains 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
storeIdinreactive.ts, themapIdcounter 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 callssig.get()which tracks the signal dependency, but theReactiveMapitself doesn't track the map structure. If entries are added/removed, consumers usingget()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 inentriesIterable().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
storeIdcounter is module-level and never reset. While__resetRegistryForTestingresets 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 assignsnewValuedirectly 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:Pathalias may cause confusion.Re-exporting
path(a function) asPath(capitalized, typically denoting a type/class) could mislead consumers into thinking it's a type or constructor rather than a factory function. Consider renaming tocreatePathor 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:effectIdlearrow 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
fnand the currentcleanupFn. However,getEffectFromPool()returns these stale references. The pool is then reused increateEffect()butpooled.fnis never used - onlypooled.cleanupis assigned tocleanupFn. This seems intentional but the pool structure storingfnis 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
afterEachrestoreslockViolationto "throw" andsatorito null, butoriginalConfigis captured once at module load time. If other tests modify additional config fields, they won't be restored. Consider usingconfigureSairin(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)._nodeand manipulatingsubscribersdirectly 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
setTimeoutdelays (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
counteris 1. This timing-based assertion could be flaky on slower CI systems. Consider usingawaiton the firststart()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: UnusedpathCachevariable.
pathCacheis 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()ingraph.tsand the lock violation handling inSignal.set()(lines 48-66 insignal.ts) implement nearly identical logic for logging and throwing on lock violations. Consider havingSignal.set()callassertLock()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 allowingoptionsinupdate()for lock owner support.The
update()method callsset()but doesn't forward anoptionsparameter. If a caller needs to update a locked signal with owner permissions, they cannot useupdate().♻️ 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: UseisPathKey()instead of duck-typing for path detection.The current check (
"segments" in pathOrInitial) could false-positive on objects that happen to have asegmentsproperty but aren't validPathKeyinstances. Use theisPathKey()type guard which is already re-exported from./graphfor 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 fromforEachcallback.The arrow function
(fn) => fn()implicitly returns the result offn(). WhileforEachignores the return value, this pattern can be confusing. Use a block body orfor...ofloop.♻️ 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.
There was a problem hiding this comment.
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 | 🟡 MinorTest logic may not verify concurrent start prevention.
The test awaits
myFlow.start()which completes after ~50ms, then polls untilrunningis false (which it already is). The assertionexpect(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()leavesloadingstuck attrue.When
abort()is called on aresource, thefinallyblock's guard (!currentController.signal.aborted && abortController === currentController) preventsloading.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
textorplaintextto 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
originalConfigvariable is used inbeforeEach(line 178) before its declaration appears (line 186). While this works due to JavaScript's execution model (theletdeclaration 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.mdaround 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, andEffectNodeare 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 unlikeflow.
flow.start()waits forcurrentPromiseif already running, butpipeline.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.
trackDependencyis 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
📒 Files selected for processing (23)
APPROACH.mdBuild/src/async/index.tsBuild/src/async/resource.tsBuild/src/async/transition.tsBuild/src/flow/index.tsBuild/src/kernel/batch.tsBuild/src/kernel/derived.tsBuild/src/kernel/effect.tsBuild/src/kernel/graph.tsBuild/src/kernel/index.tsBuild/src/kernel/path.tsBuild/src/kernel/signal.tsBuild/src/store/array.tsBuild/src/store/map.tsBuild/src/store/reactive.tsBuild/tests/benchmark.test.tsBuild/tests/context-debug.test.tsBuild/tests/flow-async.test.tsBuild/tests/graph.test.tsSAIRIN-UPDATES.mdSAIRIN.mdSAZAMI-UPDATES.mdSairin.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
There was a problem hiding this comment.
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$rawstop reflecting the store after child updates.The aggregate signal/raw snapshot are initialized once from
obj, but child signals are updated independently. Afterreactive({ x: 1 }).x.set(2),toRaw()andreactiveObj.$.get()still return the original object, andsetReactive()still leaves$rawstale.Also applies to: 78-89
Build/src/kernel/effect.ts (1)
34-45:⚠️ Potential issue | 🟡 MinorGlobal
cleanupStackcauses 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'srunnercallsrunCleanup()which consumes cleanup functions from the shared global stack. This means cleanup functions registered by the outer effect viaonCleanup()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'seffectSyncexecutes 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 | 🟠 MajorRoot lookups are broken here.
When
prefix.raw === "/", Line 133 only accepts an exact/match orstartsWith("//"), sogetNodesUnder(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 | 🟠 MajorCleanup state resets too early and never fully drains multi-chunk batches.
Line 357 flips
cleanupScheduledtofalsebefore 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,scheduledCleanupNodesis 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 | 🟡 MinorUse a non-returning callback here.
Biome is right about Line 52: the concise arrow still returns
fn(). A block body orfor...ofremoves 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 | 🟠 MajorPrimitive key IDs still collide across distinct keys.
Line 26 stringifies the primitive before indexing
primitiveKeyIdMap, so1and"1"share akid, and two different symbols with the same description stringify alike. Those keys then reuse the same signal path underpath("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, andpath("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
trackDependencyis imported but never used in this file. The reactive tracking is correctly done viaitemsSignal.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:winnersignal remains-1on error — document this behavior.When the race resolves with an error (first promise rejects),
winnerstays at-1since 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 storesfnbut never uses it.The pool stores
{ fn, cleanup }objects, butgetEffectFromPoolcreates objects with a placeholderfn: () => undefined, andcreateEffect(line 49) never readspooled.fn— it uses thefnparameter directly. The pool effectively only reuses the object shell while clearingcleanup.Consider simplifying if the
fnfield 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, andEffectNodeare 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
pathascreatePathusing 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
📒 Files selected for processing (13)
Build/src/async/index.tsBuild/src/async/resource.tsBuild/src/flow/index.tsBuild/src/kernel/batch.tsBuild/src/kernel/derived.tsBuild/src/kernel/effect.tsBuild/src/kernel/graph.tsBuild/src/kernel/index.tsBuild/src/kernel/path.tsBuild/src/kernel/signal.tsBuild/src/store/array.tsBuild/src/store/map.tsBuild/src/store/reactive.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- Build/src/kernel/batch.ts
There was a problem hiding this comment.
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 afor...ofloop.🤖 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 | 🟠 MajorCollection 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 iteratevalues(),entriesIterable(),forEach(), ortoArray()won't rerun on add/remove because none of them touchsizeSignal. 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 | 🔴 CriticalNested object paths are still being serialized into a single segment.
Line 39 passes
${storePath}/${String(key)}into the nextreactive()call. On the next recursion, Line 33 feeds that string back intopath(storePath, "$"), andBuild/src/kernel/path.tsrejects 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 | 🔴 CriticalRepeated
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 toundefined, andsignal(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()inBuild/src/kernel/graph.tssubscribes the tracker onto source nodes, not ontothis._node. Clearingthis._node.subscriberstherefore removesDerived.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 | 🟠 MajorRace condition in incremental cleanup scheduling persists.
The
cleanupScheduledflag is set tofalse(line 365) beforecleanupChunk()runs. IfcleanupChunkhas more work and re-queues itself (line 354), a concurrent call toscheduleIncrementalCleanupduring that time seescleanupScheduled=falseand schedules a duplicate microtask.🐛 Proposed fix: Keep cleanupScheduled true while processing
if (!cleanupScheduled) { cleanupScheduled = true; queueMicrotask(() => { - cleanupScheduled = false; cleanupChunk(); }); }And reset
cleanupScheduledonly 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 forpath("user", "**"),nonGlobSegments = ["user"]with length 1, which returnsnullper 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, andEffectNodefrom"./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: Unusedloggervariable.
getSairinLogger()is called at line 372 butloggeris 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. Callingstart()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
📒 Files selected for processing (12)
Build/src/flow/index.tsBuild/src/kernel/derived.tsBuild/src/kernel/effect.tsBuild/src/kernel/graph.tsBuild/src/kernel/path.tsBuild/src/kernel/signal.tsBuild/src/store/map.tsBuild/src/store/reactive.tsBuild/tests/benchmark.test.tsBuild/tests/context-debug.test.tsBuild/tests/graph.test.tsBuild/tests/kernel.test.ts
Summary by CodeRabbit
New Features
Improvements
Chores