Skip to content

fix(subscriptions): rebuild subscriber when getConfig() subscribe changes - #20

Closed
jimmy-phantom wants to merge 6 commits into
mainfrom
jimmy/getconfig-subscribe-rebuild-fix
Closed

fix(subscriptions): rebuild subscriber when getConfig() subscribe changes#20
jimmy-phantom wants to merge 6 commits into
mainfrom
jimmy/getconfig-subscribe-rebuild-fix

Conversation

@jimmy-phantom

@jimmy-phantom jimmy-phantom commented May 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes a bug where getConfig() returning a different subscribe value mid-session had no effect: setupSubscription only consulted config.subscribe at activation, on params change, or when no subscriber was running, so once a subscriber was active any later value produced by getConfig() was ignored.

The two patterns that motivated the fix:

// Adapt poll interval to response state
getConfig() {
  return { subscribe: poll({ interval: this.response?.ok ? 100 : 5000 }) };
}

// Stop polling on a terminal error
getConfig() {
  return {
    subscribe: this.response?.status === 404 ? undefined : poll({ interval: 5000 }),
  };
}

These are the canonical legitimate getConfig() use cases: config field values that genuinely depend on per-fetch runtime state.

Approach

One rule: subscribe rebuilds when its reference changes from what's currently installed. No marker, no opt-in mechanism, no asymmetry between user code and built-in factories.

  • setupSubscription tracks lastSubscribeFn and short-circuits on ref equality. The post-fetch path always invokes it. The previous unsubscribe === undefined guard skipped the case where the new value is undefined and a running subscriber should be torn down.
  • runQuery re-resolves options after the fetch resolves so getConfig() observes the freshly assigned this.response before setupSubscription runs.
  • Param-change rebuilds clear lastSubscribeFn before calling setupSubscription, since the running subscriber captured the old params.

Trade-off worth flagging

Any subscribe value placed inside getConfig() produces a fresh function reference each call (whether it's poll(...) or an inline closure). The framework treats this honestly: re-evaluating getConfig() on each fetch produces a new ref, so the running subscriber is torn down and rebuilt. Per-fetch cost is one closure allocation, one cleanup call, and one clearTimeout/setTimeout swap for poll-like subscribers. Timing is unaffected because the next tick is scheduled interval ms after fetch resolution either way.

The escape hatch for stable subscribers is static config = { subscribe(...) { ... } }, which evaluates once at class-field-init time and gives a stable ref. The two forms have clear, distinct, predictable semantics:

  • config = ... for stable subscribers (the common case).
  • getConfig() when the value of a config field genuinely depends on runtime state.

Earlier iterations of this PR experimented with a marker-based opt-in mechanism (SUBSCRIBE_KEY) and a pollCache to give built-in factories stable identity. Both were removed in favor of the simpler rule above. The marker created a hidden distinction between built-in and user code that users would have to learn; the cache was an asymmetric optimization that only benefited poll() while leaving inline closures churning.

Test plan

  • Two failing repros (honors a state-dependent interval after first fetch resolves, stops polling when getConfig() switches subscribe to undefined after an error) now pass
  • Three lifecycle tests in query-stream.test.ts converted to static config = ... (their config values never depended on runtime state)
  • Full unit suite: 1135 passed
  • Full React suite: 94 passed
  • tsc --noEmit clean

🤖 Generated with Claude Code

jimmy-phantom and others added 3 commits May 7, 2026 16:45
getConfig() re-evaluates on every fetch and produces a fresh subscribe
value, but setupSubscription in QueryResult only consults config.subscribe
at activation, params change, or when no subscriber exists. Once a
subscriber is running, later subscribe values from getConfig() are ignored.

Adds two failing tests:

- A state-dependent interval (poll interval depending on this.response.ok)
  is captured at activation with response undefined, and never rebuilds
  once response.ok becomes true.
- subscribe: undefined returned from getConfig() after a 404 does not
  stop the running subscriber.

The existing "getConfig subscribe" tests pass because they return a
constant poll() value, never exercising the cross-fetch change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
setupSubscription previously only consulted config.subscribe at
activation, on params change, or when no subscriber was running. Once a
subscriber was active, later subscribe values from getConfig() on each
refetch were ignored, so patterns like

  subscribe: poll({ interval: this.response?.ok ? 100 : 5000 })
  subscribe: this.response?.status === 404 ? undefined : poll({ ... })

had no effect after the first activation.

Changes:

- setupSubscription tracks the last-installed subscribe ref and rebuilds
  on change. The post-fetch path always invokes it (the previous
  unsubscribe === undefined guard skipped the case where the new value
  is undefined and a running subscriber should be torn down).
- runQuery re-resolves options after the fetch resolves so getConfig()
  observes the freshly assigned this.response before setupSubscription
  runs.
- poll() is memoized by interval, so re-evaluating getConfig() with a
  stable interval returns the same subscribe reference and incurs no
  rebuild.
- Param-change rebuilds clear lastSubscribeFn before calling
  setupSubscription, since the running subscriber captured the old
  params.

Three lifecycle tests in query-stream.test.ts that used getConfig() to
scope an inline closure for test counters are converted to static
config = { subscribe(...) {...} }. Their config values never depended
on per-fetch state; the getConfig() form was incidental and would now
cause per-fetch rebuilds (the documented trade-off of choosing the
dynamic form).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Caching `poll()` output by interval was an asymmetric optimization:
re-evaluating `poll()` inside `getConfig()` on every fetch returned a
stable ref so steady-state polling didn't churn, but inline subscribe
closures inside `getConfig()` always returned fresh refs and rebuilt
on every fetch. The cache made poll-using queries cheaper than
arbitrary user closures for the same shape of code, which is a
distinction users would have to learn.

Removed. Now both produce a fresh ref each `getConfig()` call and
both incur the same cheap per-fetch rebuild (clearTimeout + setTimeout
+ one closure alloc), with no timing impact since the next tick is
scheduled `interval` ms after fetch resolution either way.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jimmy-phantom jimmy-phantom changed the title fix(fetchium): rebuild subscriber when getConfig() subscribe changes fix(subscriptions): rebuild subscriber when getConfig() subscribe changes May 8, 2026
jimmy-phantom and others added 3 commits May 8, 2026 10:15
The test still uses a 404 to trigger the transition, but the behavior
under test is `subscribe: undefined` after any error response, not
specifically 404.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spell out the chain — adapter populates this.response, getConfig() may
branch on it, the pre-fetch resolve saw it as undefined — so a reader
following runQuery doesn't have to reverse-engineer why the resolve is
called twice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The test existed to verify that two queries sharing a memoized poll()
reference each got independent per-invocation state. With the cache
removed, two queries calling poll(100) get distinct refs, and the
existing different-intervals test already covers per-query
independence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jimmy-phantom
jimmy-phantom marked this pull request as draft May 13, 2026 21:45
@jimmy-phantom jimmy-phantom mentioned this pull request May 16, 2026
2 tasks
@jimmy-phantom

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #25

@jimmy-phantom
jimmy-phantom deleted the jimmy/getconfig-subscribe-rebuild-fix branch May 18, 2026 15:28
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