Skip to content

🤖 Angular Pacer adapter review experiment 🤖 - #282

Draft
benjavicente wants to merge 10 commits into
TanStack:mainfrom
benjavicente:feat-angular-agentic-review
Draft

benjavicente wants to merge 10 commits into
TanStack:mainfrom
benjavicente:feat-angular-agentic-review

Conversation

@benjavicente

@benjavicente benjavicente commented Oct 6, 2026 •

Copy link
Copy Markdown

Experiment: not intended to be merged. This PR was created entirely by an AI agent, including the review, implementation, tests, commits, and description. @benjavicente will take it over later for human review and follow-up. Please keep it as a draft.

🎯 Changes

Align Angular Pacer with the Angular adapter patterns, TanStack Query's Angular integration, and the Solid/React Pacer APIs. Return explicit refs with real readonly signals, initialize cores lazily, apply current options before operations, keep callbacks untracked, and own observation/disposal and pending work through Angular's lifecycle.

The history preserves seven reproduction commits, followed by a separate Angular peer-range fix, one consolidated adapter fix, and one follow-up commit for stability, cleanup, helper types, and selector equality. The reproduced issues were:

The following snippets reproduce the original upstream/main behavior at 32efe7d5022c4b1ecc3d2fe3529cee2c4aab5fd2; the fixes in this PR correct these failures. Function examples assume an Angular injection context, and timing examples use Angular TestBed.

  1. Unsupported Angular peer range. Pacer allowed Angular 17/18 even though its required Angular Store dependency requires 19+. This is an installation/support mismatch.

    {
      "angular-pacer peers": { "@angular/core": ">=17.0.0" },
      "angular-store peers": { "@angular/core": ">=19.0.0", "@angular/common": ">=19.0.0" }
    }
  2. Managed functions were not Angular signals. This broke Angular signal identity and typed composition with other helpers.

    const value = injectDebouncedSignal('', { wait: 100 })
    isSignal(value) // false
    const source: Signal<string> = value // Type error: signal brand missing
  3. Initial values violated their types. Immediate reads could crash even though the helper promised a string.

    const value = injectThrottledValue(signal('hello'), { wait: 100 })
    const text: string = value() // Actually undefined before the first effect
    text.toUpperCase() // TypeError
  4. Aliasing a method evaluated required inputs. Ordinary component field setup threw before Angular could bind inputs.

    wait = input.required<number>()
    debouncer = injectDebouncer(() => {}, () => ({ wait: this.wait() }))
    search = this.debouncer.maybeExecute // NG0950 during field initialization
  5. Operations used stale reactive options. Disabling the utility did not prevent a leading call before change detection.

    const enabled = signal(true)
    const debouncer = injectDebouncer(save, () => ({
      wait: 100, leading: true, enabled: enabled(),
    }))
    TestBed.tick()
    enabled.set(false)
    debouncer.maybeExecute() // save() still runs
  6. Callback reads became caller dependencies. An unrelated signal update reran an effect and repeated its operation.

    const audit = signal(false)
    const limiter = injectRateLimiter(() => persist(audit()), {
      limit: 100, window: 1000,
    })
    effect(() => limiter.maybeExecute())
    TestBed.tick()
    audit.set(true)
    TestBed.tick() // persist() runs again although the caller did not read audit
  7. Zoneless stability completed before async work. Tests could continue too early; SSR could serialize incomplete output.

    let finish!: () => void
    const loader = injectAsyncDebouncer(
      () => new Promise<void>(resolve => { finish = resolve }),
      { wait: 0, leading: true },
    )
    const app = TestBed.inject(ApplicationRef)
    const loading = loader.maybeExecute()
    await app.whenStable() // Resolves while loading is still unfinished
    finish()
    await loading

API inconsistencies compared with Solid and React

This comparison uses the adapters at the reviewed upstream/main commit, 32efe7d5022c4b1ecc3d2fe3529cee2c4aab5fd2. These are API and behavior differences in addition to the seven failure demonstrations above. The examples describe each framework's own setup; they are not intended to run together in one component.

  1. Value helpers mixed source derivation with a separate initial-value API. Angular added (source, initialValue, options, selector?) overloads, whereas Solid and React value helpers use (source/value, options, selector?). Explicit editable initial state belongs to createThrottledSignal / useThrottledState / injectThrottledSignal. Angular's argument-discrimination code also treated function arguments differently according to their position. This PR removes the extra value-helper overloads.

    // Angular before: an additional overload absent from sibling value helpers
    injectThrottledValue(source, 'fallback', { wait: 100 })
    
    // Solid / React: source or current value, options, optional selector
    createThrottledValue(source, { wait: 100 }, select)
    useThrottledValue(currentValue, { wait: 100 }, select)
    
    // Angular in this PR: the same argument roles
    injectThrottledValue(source, { wait: 100 }, select)
  2. Reactive input contracts were unnecessarily narrower in Angular. Angular required a branded Signal<T> even though these helpers only called the source. Solid accepts Accessor<T> (() => T); React receives the value from its current render. This PR accepts () => T in Angular, which includes signals and ordinary reactive source functions.

    // Angular before: rejected because this function is not a branded Signal
    injectThrottledValue(() => query(), { wait: 100 })
    
    // Solid accepts an accessor; React takes the current render value
    createThrottledValue(() => query(), { wait: 100 })
    useThrottledValue(queryValue, { wait: 100 })
    
    // Angular in this PR also accepts the accessor form
    injectThrottledValue(() => query(), { wait: 100 })
  3. The first derived value differed from the siblings' source-initialized value. Solid initializes its managed signal with value() and React initializes state with the supplied value. Angular's debounced/throttled/rate-limited value helpers fabricated an undefined initial value unless the extra overload was used. This PR initializes from the source lazily, preserving Angular required-input timing while matching the siblings' initial-value behavior.

    // Implementation excerpts from the original adapters
    // Angular: fabricated unavailable TValue
    const initialValue = hasInitialValue
      ? initialValueOrOptions as TValue
      : undefined as unknown as TValue
    
    // Solid: actual source value
    createThrottledSignal(value(), initialOptions, selector)
    
    // React: actual render value
    useThrottledState(value, options, selector)
  4. Angular changed its ref protocol according to the options' shape. Static options returned an ordinary object; factories/getters returned a proxy whose property access constructed the core. Solid and React returned ordinary refs in their own setup paths. Angular therefore made the same method alias behave differently depending on how options were expressed. This PR returns declared signals and stable methods through one lazy path for both forms.

    // Angular's old implementation selected different public ref mechanisms
    if (!reactive) {
      const result = untracked(() => create(readOptions(), () => result))
      return result
    }
    return new Proxy({}, {
      get: (_target, property) => Reflect.get(getInstance(), property),
    })
    
    // Caller consequence: just taking a method reference could initialize options
    const execute = ref.maybeExecute
  5. Some helpers omitted adapter-specific options from their public types. Solid and React managed helpers accept their adapter's options, including onUnmount. Angular's corresponding helpers used core-only DebouncerOptions, ThrottlerOptions, RateLimiterOptions, QueuerOptions, or AsyncQueuerOptions. This rejects adapter cleanup in direct object-literal calls and omits contextual typing for that callback, even though low-level Angular utilities expose it. Both value helpers and all five managed-signal helpers now accept the corresponding Angular options; the follow-up commit closes this gap.

    // Solid / React: adapter-specific cleanup is part of the declared helper API
    createDebouncedSignal('', { wait: 100, onUnmount: ref => ref.cancel() })
    useDebouncedState('', { wait: 100, onUnmount: ref => ref.cancel() })
    
    // Angular managed helper, before and still in this experimental PR:
    // onUnmount is absent from its declared core-only options type
    injectDebouncedSignal('', { wait: 100, onUnmount: ref => ref.cancel() })
    
    // Angular low-level utility does expose the cleanup option
    injectDebouncer(() => {}, { wait: 100, onUnmount: ref => ref.cancel() })

Intentional Angular differences: inject* naming, an injection-context lifetime, named fields instead of Solid/React tuples, native Signal<T> outputs, and the approved readonly options(), key(), fn(), and store() metadata are Angular adaptations. Solid also uses an options getter, so getter removal here is an Angular design decision, not a claim that only Angular had getters. React/Solid expose JSX Subscribe conveniences; Angular exposes selected state() signals and does not add a matching component in this experiment. Default-selector casts also exist in the sibling adapters, so the honest selector overloads are additional Angular hardening rather than an Angular-only inconsistency.

Source comparison: Angular value helper, Solid value helper, React value helper, Angular managed helper, Solid managed helper, React managed helper, and Angular proxy helper. The managed-helper option gap was reproduced by type checking and is fixed in the follow-up commit.

Follow-up adapter fixes

Follow-up commit ef8b50ed fixes the six failure modes below. These snippets show the behavior before this follow-up, at 9bd0ef28; runtime regressions were run and failed before implementation, and type checking confirmed the managed-helper option gap.

  1. Leading debounce leaked displayed pending state and Angular stability. A lone leading call suppressed the trailing callback, but never cleared isPending after its timer fired.

    const d = injectDebouncer(save, { wait: 100, leading: true, trailing: true })
    d.maybeExecute() // save runs once
    vi.advanceTimersByTime(100)
    TestBed.tick()
    d.store().state.isPending // true indefinitely; Angular remains unstable
  2. Synchronous reset released stability before retained work ran. Resetting state did not cancel the timer, but the adapter trusted the reset flag.

    d.maybeExecute('draft')
    d.reset()
    TestBed.tick()
    d.store().state.isPending // false
    // ApplicationRef.isStable becomes true while save('draft') is still scheduled
  3. Reactive trailing options could enable unowned timer execution. An existing cooldown could become executable when trailing changed, without making Angular pending.

    const trailing = signal(false)
    const d = injectDebouncer(save, () => ({ wait: 100, trailing: trailing() }))
    TestBed.tick()
    d.maybeExecute()
    trailing.set(true)
    TestBed.tick() // Angular still reports stable
    vi.advanceTimersByTime(100) // save nevertheless runs
  4. Disposal read a stale cleanup policy. Destruction immediately after a factory change invoked the old callback because the options effect had not run.

    const policy = signal(oldCleanup)
    injectDebouncer(save, () => ({ wait: 100, onUnmount: policy() }))
    TestBed.tick()
    policy.set(newCleanup)
    TestBed.resetTestingModule() // oldCleanup runs; newCleanup does not
  5. Managed helper option types rejected Angular cleanup. All five managed helpers now accept their corresponding Angular options with correctly typed refs and selected state; value widening remains intact.

    injectDebouncedSignal('', {
      wait: 100,
      onUnmount: ref => ref.cancel(), // previously rejected: unknown option
    })
  6. Object selectors notified consumers for unchanged selected fields. Angular used reference equality while React and Solid use shallow equality.

    const d = injectDebouncer(save, { wait: 100 }, s => ({ isPending: s.isPending }))
    effect(() => render(d.state()))
    TestBed.tick()
    d.maybeExecute(); TestBed.tick() // render sees pending=true
    d.maybeExecute(); TestBed.tick() // render ran again for identical selected fields

The core now exposes Debouncer.getIsScheduled() to report an actual executable trailing timer independently of resettable display state and leading cooldowns. Angular uses it at operation and options-effect boundaries and after store notifications. Timer/flush completion preserves reentrant schedules and clears failed work. Reset retains its existing timer and reset-to-flush semantics. Cleanup merges the latest resolved policy with applied core options, preserving omitted fields and honoring explicit undefined. Selected state uses the same shallow comparator as sibling adapters. Core and Angular changesets and API references are included.

Additional regressions cover honest omitted-selector types, current/manual option precedence, queue constructor callback timing and bounded startup ordering, restored scheduling flags, rejection/cancellation, and overlapping async work after resets.

Public API changes: read metadata with options(), key(), fn(), and store(); use setOptions() for explicit updates. Value helpers use (source, options, selector?); managed signal helpers support explicit initial values. Angular core/common peers now require 19+. Debouncer.getIsScheduled() exposes actual trailing timer ownership in core and adapter refs. Subpath exports remain intact. Utility-specific glue is explicit; shared helpers cover only external stores, zone isolation, and pending tasks.

Disposal belongs to an identity-only effect. An operation before that effect temporarily registers captured-resource cleanup at its acquisition boundary, then transfers ownership to the effect. This preserves early-operation cancellation without initializing anything during teardown.

Validation: NX_PARALLEL=1 pnpm test, NX_BASE=upstream/main NX_PARALLEL=1 pnpm run test:pr, adapter runtime/type/lint checks, and browser tests across all Angular examples plus representative React/Solid debounce refs and managed state helpers. 121 Angular adapter tests, 130 targeted core debounce tests, and 78 browser tests (62 Angular and 16 React/Solid). Node 24.21.0 / pnpm 12.6.0. The first full follow-up run passed 762 tasks but hit three Ember type-check races while concurrent builds replaced generated JavaScript assets; all 765 tasks passed when rerun serially after builds completed. No Ember source changes were needed. Original reproduction failures were verified against upstream/main; follow-up regressions were verified against the preceding adapter implementation before fixing them. The agent reviewed its generated code and used an adversarial Angular review agent.

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested code changes locally with pnpm run test:pr, or these tests do not apply to this pull request.
  • I fully understand the code in this pull request, including any code generated with AI assistance.

The agent reviewed its generated code and used an adversarial review agent. The human-understanding checkbox remains pending @benjavicente's review.

🚀 Release Impact

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

Run: Node 24.21.0, pnpm exec vitest run --project @tanstack/angular-pacer packages/angular-pacer/tests/peer-support.test.ts. Expected failure confirmed: advertised minimum 17 is below the required store minimum 19. This is an intentionally failing regression before the fix.
Run: targeted Vitest regressions and pnpm --dir packages/angular-pacer test:types. Confirmed three false isSignal results, five missing Signal brands, and rejection of DebouncedSignal as a value-helper input. Tests intentionally express the correct behavior before implementation changes.
Run: targeted Vitest regressions. Both tests fail: all three value helpers return undefined for an initialized string source, and a bound required input is unavailable until effect execution. Construction must remain lazy while the first read returns a valid source value.
Run: targeted Vitest regressions. Confirmed NG0950 during component construction when merely assigning the debouncer method to a field. The method and state signals must be accessible without evaluating required options inputs.
Run: targeted Vitest regressions. Both tests fail: increasing the limit still rejects a call before effects run, and disabling a leading debouncer still executes its callback. Operations must synchronize current options immediately.
Run: targeted Vitest regressions. Both tests fail: an audit signal triggers an extra persistence call, and a function-valued wait option reschedules source synchronization. Imperative work must not attach incidental dependencies to caller effects.
Run: targeted Vitest regression from a stable ApplicationRef. Confirmed that starting unresolved async work leaves isStable true. The assertion excludes incidental setup scheduling and always settles the deferred response during cleanup.
Require Angular core and common 19+, matching Angular Store and the
signal/lifecycle APIs used by the adapter. Add a separate minor changeset.

The first reproduction commit verifies this compatibility boundary.
Validation: pnpm test and NX_BASE=upstream/main pnpm run test:pr.
Replace proxy-based management with explicit signals and stable methods.
Keep option writes visible in their effect and before each operation;
construct cores lazily and run imperative callbacks outside tracking.

Return genuine managed signals, initialize value helpers from their source,
and use the sibling source/options/selector API without fallback overloads.
Make omitted selector types honest and keep value inference independent
from option callbacks.

Own subscriptions and normal disposal in effects. Early operations capture
temporary cleanup until the identity-only effect takes ownership. Account
for scheduled work, automatic queue startup, and overlapping async callbacks
without assuming a reset or abort settles an unfinished callback.

Preserve queue insertion order and callback timing at the owned startup
boundary, including bounded queues and restored pendingTick flags.
Update examples, API references, migration guidance, and minor changeset.

Validation: pnpm test; NX_BASE=upstream/main pnpm run test:pr;
Angular runtime/type/lint regressions; Playwright across Angular examples.
The preceding reproduction commits document the baseline failures.
@changeset-bot

changeset-bot Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ef8b50e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@tanstack/pacer Minor
@tanstack/angular-pacer Minor
@tanstack/alpine-pacer Patch
@tanstack/ember-pacer Patch
@tanstack/lit-pacer Patch
@tanstack/octane-pacer Patch
@tanstack/preact-pacer Patch
@tanstack/react-pacer Patch
@tanstack/solid-pacer Patch
@tanstack/svelte-pacer Patch
@tanstack/vue-pacer Patch

Not sure what this means? Click here to learn what changesets are.

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

@coderabbitai

coderabbitai Bot commented Oct 6, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Track executable trailing timers independently of resettable Debouncer state.
Release leading cooldowns and failed work, retain pending ownership through
reset and reactive trailing changes, and preserve reentrant timer/flush calls.

Resolve the latest cleanup policy across all ten Angular refs, expose Angular
options in all five managed helpers, and match React/Solid shallow selection
comparison. Keep option updates explicit and lifecycle ownership unchanged.

Add regressions before implementation: six Angular runtime failures and the
managed-helper type errors were observed on the previous adapter. Include core
scheduling/throw/reentrancy coverage, docs, and changesets for both packages.

Validation: 121 Angular tests, 130 targeted debounce tests, and 78 browser tests
(62 Angular; 16 React/Solid). Full pnpm test and upstream/main PR checks pass
with NX_PARALLEL=1. The initial parallel full run hit three existing Ember
build/type-check asset races; all passed after serial rerun.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant