Skip to content

feat: add instance configuration service - #1831

Merged
MartinCupela merged 26 commits into
release-v10from
feat/llc-instance-configuration
Aug 21, 2026
Merged

feat: add instance configuration service#1831
MartinCupela merged 26 commits into
release-v10from
feat/llc-instance-configuration

Conversation

@MartinCupela

@MartinCupela MartinCupela commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

What

release-v10 could already register a setup function per entity — but only a function, and only
against four hardcoded keys. Anything that was just a value meant writing a function to go and set it.

This PR makes configuration values first-class: they can be registered declaratively, every key is
declared and type-checked, and every configurable object resolves its settings through one layered
pipeline with the server's say applied last.

The four ways to set configuration

Route Scope Use it for
Construction argumentnew StreamChat(key, { notifications }), new MessageComposer({ config }) one instance what you know at build time, for objects you build yourself
Declarativeclient.config.set({ channel: { … } }) per entity type the common case; reaches objects the SDK builds for you, including ones created later
Setup functionclient.config.setSetupFunction('messageComposer', fn) per entity type, but sees the instance behaviour values can't express — middleware, comparators, conditional exceptions
Imperativeinstance.updateConfig({ … }), and the setters that route through it one instance changes driven by app state after the fact

Plus one that isn't integrator's to set but participates: the server's configuration for the
channel — its type's settings, narrowed by that channel's own config_overrides where it has any.

Example of configuration API in use:

// declarative — applies to instances that exist and to any built later
client.config.set({
  channel: { messagePaginator: { pageSize: 50 }, typingEvents: { enabled: false } },
  messageComposer: { linkPreviews: { enabled: false } },
  client: { notifications: { durations: { error: 10_000 } } },
});

// setup function — a global default plus one conditional exception
client.config.setSetupFunction('messageComposer', ({ composer }) => {
  if (composer.threadId) composer.updateConfig({ text: { publishTypingEvents: false } });
});

client.config.reset() returns everything to its derived baseline.

The layers, and how they reconcile

For any one instance, later stages win:

# Stage Who set it Example
1a Class default the SDK, for every instance of the class every paginator starts at pageSize: 25
1b Instance default the SDK, for this particular object it built a channel's pinned list is wired differently from its main list, though both are MessagePaginators
2 Declarative you, per entity type client.config.set({ channel: { … } })
3 Construction argument you, for one instance new MessageComposer({ config })
4 Setup function you, per entity type, with the instance in hand client.config.setSetupFunction(key, fn)
5 Imperative you, for one instance, at any time instance.updateConfig({ … })
6 Server the server, per channel narrows only; can never be widened from the client

The one non-obvious part is why 1b and 3 are separate when both arrive through a constructor. It's who
passed the argument. When the SDK builds a paginator it fills in a page size, a throttle and a
cursor — if those counted as integrator's arguments they would outrank stage 2, and no client.config.set()
could ever change them. So the SDK's own values sit at 1b, below integrator's registration; only arguments integrators
actually passed sit at 3, above it.

Three properties of this that are load-bearing:

  • Re-resolved, not accumulated. Every layer is kept separately and the whole order replays whenever
    any of them changes. So applying the server's restrictions is idempotent, and stage 6 narrowing a field
    never destroys the request underneath it — if the server later relents, the request is honoured.
  • Server authority is last on every route. Declarative, setup function or updateConfig — all three
    have the server's restrictions re-asserted over the result. Nothing above can widen past them.
  • Booleans are ANDed at stage 6. Either side may switch a feature off; neither may widen. undefined
    from the server means "no opinion", not "no", so the request stands.

The consequence worth knowing: instance.config is the resulting answer. It should be read instead of the raw
server flag — channel.serverConfig?.uploads - which gives us only the server's half.

Why

Setting a page size used to require writing a function:

// before
client.instanceConfigurationService.setSetupFunctions({
  Channel: ({ channel }) => {
    channel.messagePaginator.updateConfig({ pageSize: 50 });
  },
});

// now
client.config.set({ channel: { messagePaginator: { pageSize: 50 } } });

Four things were wrong with the old version:

  1. A function was the only way in. There was no way to register a plain value.
  2. Only four classes were supportedChannel, MessageComposer, StreamChat, Thread. Paginators,
    the notification manager, the reminder manager and the delivery reporter had no way in at all.
  3. No way to undo it. Nothing tracked where a value came from, so nothing could put an instance back
    to a known state.
  4. Server settings were not included. If the server disabled uploads, the code had to check that
    separately and combine it in multiple places. Most code didn't, so a UI would offer a feature the SDK then
    refused.

Where it lives

Two objects, and neither holds what the other holds:

InstanceConfigurationRegistry (client.config) ConfigController (per instance)
Holds what you asked for (stages 2 and 4) what one instance ended up with
Count one per client one per configurable instance
Knows defaults no yes, and freezes them

Every configurable class exposes the same shape: configState (a StateStore), config,
updateConfig(), initializeConfig(). Thread was the last one resolving configuration by hand and now
goes through ConfigController too, so there are no exceptions left.

The key space is closed. InstanceConfigTree and InstanceSetupFunctionArgs are type aliases, not
interfaces, so a key can be neither misspelled into existence nor added by module augmentation:

client.config.setConfig('myWidget', { pollIntervalMs: 10_000 }); // ✗ does not compile
client.config.setSetupFunction('cahnnel', fn); // ✗ does not compile

A class an integrator owns configures itself — a constructor argument, a setter, its own registry.
Routing it through client.config would put a value the SDK can neither type nor apply into a tree whose
only reader is the SDK.

The machinery is internal too. ConfigController (which resolves a configuration) and
applyInstanceConfiguration (which subscribes an instance to a key) are both unexported: every
configurable class is one this package constructs, so neither has a caller outside it.

⚠️ Breaking changes

Details in v9-to-v10-migration-guide-other.md and …-type-renames.md.

Two that produce no compile error

Uploads to storage outside Stream must now be declared. A custom doUploadRequest used to be taken
as proof of that, which was wrong — many still post to Stream. Without the flag, uploads are refused for
users lacking the upload-file capability, and the attachment button disappears:

attachments: { doUploadRequest: myUpload, customCdn: true }

Link previews now default to on. The old default of off overrode apps that had enabled enrichment
server-side. Turning them off is now explicit:

client.config.set({ messageComposer: { linkPreviews: { enabled: false } } });

Renames — all caught by the compiler

channel.getConfig()            channel.serverConfig         // a getter; drop the ()
client.configs                 client.channelServerConfigs  // same cid keys, clearer name
ChannelInstanceConfig          ChannelConfig
ThreadInstanceConfig           ThreadConfig
InstanceConfigurationService   InstanceConfigurationRegistry

serverConfig returns what getConfig() did. channel.config is a different thing: the resolved
value, with six server flags already combined with locally registered settings — that is the one to read
when checking whether a feature is on.

serverConfig answers for this channel, not for its type. Most of ChannelConfigWithInfo is a
channel-type setting, but a channel's own config_overrides narrow uploads, url_enrichment,
typing_events, replies, quotes, reactions, shared_locations, max_message_length, commands
and user_message_reminders for that channel alone — and this SDK is one of the things that can set them
(client.channel(type, id, { config_overrides }) sends them on query/watch; channel.update() and
updatePartial() reach the same state). So the cache behind the getter is keyed by cid: v9's
client.configs key space, under a name that says whose configuration it holds now that client.config
is the integrator's.

It is undefined until the channel has been queried or watched, as in v9. There is deliberately no
type-level fallback — the only value available to seed one with is a sibling channel's effective config,
overrides included, which is the leak the cid keying exists to prevent. channel.config covers that
window with its defaults, which is another reason to read it rather than the raw flag.

Note for tests: serverConfig is a getter, so vi.spyOn(channel, 'getConfig') has no direct equivalent.
A mocked query response has to carry a cid matching the channel under test, or its config lands under a
different key.

Smaller things

  • MessageComposerConfig gains polls; AttachmentManagerConfig gains enabled and customCdn. All
    have defaults, so only code that types a variable as the complete config object is affected.
  • Removed from the client: setInstanceConfigurationFunction, instanceConfigurationService and
    configsStore (all RC-line only). configs is renamed to channelServerConfigs, not removed — the
    cid key space is unchanged, so a v9 client.configs[cid] lookup translates directly. Prefer
    channel.serverConfig over either.
  • Bug fix, no action required: setters no longer skip a write when the server is masking the field, which
    used to let a stale earlier request win once the server allowed it again.

RC-line only

None of these ever shipped in v9, so they are listed for anyone tracking release-v10 rather than as
migration steps.

  • channel.config carries only the channel's own fields. The channel slice can also carry
    messagePaginator, pinnedMessagesPaginator and messageOperations — those still work as scoped
    overrides for the objects the channel owns, they are just no longer copied onto channel.config as
    well, where nothing read them and ChannelConfig never declared them. Read
    channel.messagePaginator.config instead. Side benefit: registering a scoped option no longer notifies
    channel.configState subscribers. Silent — the old read returns undefined.
  • The key space is closed. setConfig('anything', …) and setSetupFunction('anything', fn) no longer
    compile, and the two key interfaces are type aliases so augmentation cannot add one either.
    applyInstanceConfiguration, ConfigController and ConfigControllerOptions are no longer exported.
  • liveLocationManager.dispose() is new, and needs calling. unregisterSubscriptions() is
    ref-counted and no longer releases the configuration subscription — it never should have, since with two
    callers sharing a manager the first to leave stopped a still-live instance from tracking
    client.config. Until dispose() runs, the configuration registry holds a handle to the manager.
    Nothing fails to compile. SearchController already worked this way.
  • Thread gains updateConfig() and resolves through ConfigController like every other entity.
    Additive.

Review guidance

  • src/configuration/ConfigController.ts — the layering itself (orderedLayers, resolve). Start here.
  • src/configuration/utils/serverAuthority.ts — stage 6: the AND rule and the upper-bound rule.
  • src/channel.ts — the most involved consumer: controller, server restrictions, and the
    subscription that re-derives when the server's answer arrives after construction.
  • src/configuration/shape.ts is 644 lines of field descriptions, not logic — skim it.

Docs

  • docs/instance-configuration.md — full reference: the stages, the registry/resolver split, server
    authority, custom keys, resetting.
  • Three v9→v10 migration guides updated, with a mechanical checklist.

# Conflicts:
#	CLAUDE.md
#	src/ChannelManager.ts
#	src/client.ts
#	src/pagination/paginators/ChannelPaginator.ts
#	test/unit/ChannelManager.test.ts
#	test/unit/pagination/paginators/ChannelPaginator.test.ts
#	v9-to-v10-migration-guide-methods.md
Channel-type flags were only readable from the raw server config, so any
consumer combining them with registered configuration had to do it itself —
and mostly didn't, offering features the client had already disabled.

They now resolve into the instance's own configuration, which becomes the
whole answer: uploads, polls and url_enrichment on MessageComposer;
typing_events, read_events, replies, user_message_reminders, delivery_events
and the command list on Channel. Channel gains a ConfigController to do it,
re-deriving when the server's answer arrives after construction.

Also fixes setters skipping a write when the server was masking the field,
which let a stale earlier request win once the server relented.

BREAKING CHANGE: `channel.getConfig()` is removed; use the `channel.serverConfig`
getter, or `channel.config` for the six flags that now have a resolved
counterpart. `ChannelInstanceConfig` -> `ChannelConfig`, `ThreadInstanceConfig`
-> `ThreadConfig`, `InstanceConfigurationService` ->
`InstanceConfigurationRegistry`. `MessageComposerConfig` gains required `polls`;
`AttachmentManagerConfig` gains required `enabled` and `customCdn`.
`linkPreviews.enabled` now defaults to `true`. See
v9-to-v10-migration-guide-other.md.
# Conflicts:
#	src/channel.ts
#	src/client.ts
#	src/thread.ts
#	v9-to-v10-migration-guide-other.md
#	v9-to-v10-migration-guide-type-renames.md
Comment thread src/client.ts Outdated
Comment thread src/LiveLocationManager.ts
Comment thread src/configuration/ConfigController.ts Outdated
Comment thread src/channel.ts Outdated
Comment thread src/channel.ts Outdated
Comment thread src/thread.ts Outdated
Comment thread src/CooldownTimer.ts Outdated
Comment thread src/configuration/utils/copyConfigPatch.ts Outdated
Comment thread src/configuration/InstanceConfigurationRegistry.ts Outdated
A channel's own `config_overrides` narrow its type's settings for that
channel alone, so a type-keyed cache could not hold two channels of one
type that disagree — they overwrote each other and every channel and
composer of the type re-derived to a value correct for at most one.
`client.configs` → `client.channelServerConfigs`; the cid key space is
v9's, unchanged.
BREAKING CHANGE: `client.configs` is renamed to
`client.channelServerConfigs`. Keys are still cids, so lookups translate
directly. Prefer `channel.serverConfig`.
# Conflicts:
#	src/channel.ts
#	src/client.ts
#	src/logger.ts
#	src/pagination/paginators/PinnedMessagePaginator.ts
#	src/types.ts
#	v9-to-v10-migration-guide-methods.md
#	v9-to-v10-migration-guide-other.md
#	v9-to-v10-migration-guide-type-renames.md
@MartinCupela
MartinCupela merged commit 434966c into release-v10 Aug 21, 2026
4 checks passed
@MartinCupela
MartinCupela deleted the feat/llc-instance-configuration branch August 21, 2026 16:53
github-actions Bot pushed a commit that referenced this pull request Aug 21, 2026
## [10.0.0-rc.7](v10.0.0-rc.6...v10.0.0-rc.7) (2026-08-21)

### ⚠ BREAKING CHANGES

* `client.instanceConfigurationService` is replaced by `client.config`,
and the exported class `InstanceConfigurationService` is renamed to
`InstanceConfigurationRegistry`.
* `client.setInstanceConfigurationFunction()` is removed. Register
setup functions with `client.config.setSetupFunction(key, fn)`.
`client.setMessageComposerSetupFunction()` is unchanged.
* the per-class setup types are replaced by generic ones keyed by
instance. Removed: `ChannelSetupFunction`, `ChannelSetupState`,
`ChannelTearDownFunction`, `MessageComposerSetupFunction`, `MessageComposerSetupState`,
`MessageComposerTearDownFunction`, `StreamChatSetupFunction`, `StreamChatSetupState`,
`StreamChatTearDownFunction`, `ThreadSetupFunction`, `ThreadSetupState`,
`ThreadTearDownFunction`, `SetInstanceConfigurationFunctions`,
`SetInstanceConfigurationServiceStates` and `SetupFnOf`. Use `InstanceSetupFunction`,
`InstanceSetupState`, `InstanceSetupTearDownFunction` and `InstanceSetupKey`.
* `ChannelInstanceConfig` is renamed to `ChannelConfig` and
`ThreadInstanceConfig` to `ThreadConfig`.
* `Channel.getConfig()` is removed. Read the server's channel-type
configuration from `channel.serverConfig`, or the resolved configuration from
`channel.config`.
* `client.configs` is renamed to `client.channelServerConfigs` and
`client.configsStore` to `client.channelServerConfigsStore`. Keys are still cids, so
lookups translate directly. Prefer `channel.serverConfig`.
* `channel.config.commands` is renamed to
`channel.config.availableCommands`. The server owns the list, so it is read-only and
absent from the declarative configuration tree.
* `applyCommandValidatorOverride` is no longer exported. Command
validation is configured through the composer's configuration.
* `MessageComposerConfig` gains a required `polls`;
`AttachmentManagerConfig` gains required `enabled` and `customCdn`;
`LocationComposerConfig` gains a required `minShareDurationMs`. A config object
built literally must supply them.
* `linkPreviews.enabled` now defaults to `true`, where it defaulted to
`false`. It is ANDed with the channel type's `url_enrichment`, so `true` means "no
opinion, let the server decide"; the old default double-gated the feature and kept it
off even where the server had enabled it.
* server flags gate the features they describe, ANDed with the
registered value rather than the client value winning: `typingEvents` with
`typing_events`, `readEvents` with `read_events`, `attachments` with `uploads`,
`polls` with `polls`, `location` with `shared_locations`, and `linkPreviews` with
`url_enrichment`. Code that set a flag to `true` and assumed it took effect may now
find the feature off.
* setters no longer discard a write when the server is masking the
field. Previously the setter compared against the effective value, so the write was
dropped and the earlier request was honoured once the server relented. Affects
`linkPreviewsManager.enabled`, `textComposer.enabled`, `textComposer.maxLengthOnEdit`,
`textComposer.maxLengthOnSend` and `attachmentManager.maxNumberOfFilesPerMessage`.

### Features

* add instance configuration service ([#1831](#1831)) ([434966c](434966c))
* channel state migration ([#1834](#1834)) ([9876add](9876add))
@stream-ci-bot

Copy link
Copy Markdown

🎉 This PR is included in version 10.0.0-rc.7 🎉

The release is available on:

Your semantic-release bot 📦🚀

isekovanic added a commit to GetStream/stream-chat-react-native that referenced this pull request Aug 22, 2026
## 🎯 Goal

Migrate the SDK onto `client.config`, the instance-configuration API
added in
[stream-chat-js#1831](GetStream/stream-chat-js#1831).

Two things follow from it:

- **Feature gates must be read resolved, not raw.** Six channel-type
flags and four composer flags now resolve *into* the instance's
configuration, ANDed with whatever the integrator registered. Reading
`channel.serverConfig?.read_events` answers only the server's half, so
UI gated on it offers features the client has already disabled.
- **Configuration belongs in one place.** Props that duplicated what
`client.config` can express are removed rather than kept as a second way
in.

## 🛠 Implementation details

### Resolved config replaces raw server flags

| Call site | Before | After |
|---|---|---|
| `useMarkRead` | `getConfig()?.read_events` |
`channel.config.readEvents.enabled` |
| `ChannelMessagePreviewDeliveryStatus` | same | reactive via
`useStateStore` |
| `ThreadMessagePreviewDeliveryStatus` | same | reactive via
`useStateStore` |
| `Channel` poll gate | `getConfig()?.polls` |
`messageComposer.config.polls.enabled` |
| `Channel` commands | `getConfig()?.commands?.length` |
`channel.config.availableCommands` |
| `AutoCompleteInput` | `getConfig()?.max_message_length` |
`composer.config.text.maxLengthOnSend` (server-capped) |
| `usePaginatedChannels` | `paginator.config.x = y` |
`paginator.updateConfig({ x })` |

`paginator.config` is `Readonly` now — direct assignment is a compile
error, and nested writes throw because defaults are deep-frozen.

### Props removed

| Removed | Replacement |
|---|---|
| `<Channel doMarkReadRequest>` | `client.config.set({ channel: {
requestHandlers: { markReadRequest } } })` |
| `<Channel doUpdateMessageRequest>` | `…{ requestHandlers: {
updateMessageRequest } }` |
| `doFileUploadRequest` | `client.config.set({ messageComposer: {
attachments: { doUploadRequest } } })` |
| `<Channel stateUpdateThrottleInterval>` | `…{ channel: {
messagePaginator: { stateThrottleMs } } }` |
| `<Channel newMessageStateUpdateThrottleInterval>` | same |

The two throttle props had **one reference each in the whole SDK** —
their own type declaration. Nothing read them. Deleted rather than left
inert.

`doSendMessageRequest` stays (for now). The SDK occupies
`requestHandlers.sendMessageRequest` unconditionally to run
`uploadPendingAttachments` inside the send pipeline (after the
optimistic ingest, before the POST), so it has to wrap an integrator
handler rather than be replaced by it. `TODO` in
`useChannelRequestHandlers` — it can be deleted once async uploads move
to the LLC, or if the LLC exposes a `next`-shaped handler slot. Since
we'll be moving the async uploads feature to the LLC most likely I
decided it was best to wait for this and then we can probably get rid of
the hook for good.

Removing `doFileUploadRequest` also fixed a latent bug: the
image-compression branch in `Channel.tsx` skipped compression only when
the *prop* was set, so a `doUploadRequest` registered through
`client.config` still got compressed. It now reads resolved config.

### `useChannelRequestHandlers`: two correctness fixes

**Re-apply on re-derivation.** `Channel.initializeConfig` *replaces*
`requestHandlers` from the declarative tree, and runs on every change to
`channel`, `messagePaginator` or `messageOperations` (the latter two are
`alsoWatch`). Our write goes through `configState.partialNext`, which is
not one of those layers — so any `client.config.set()` on those keys
dropped our send handler, and with it the attachment-upload step,
silently. A `configState.subscribe` re-apply guards it; the handler's
identity is the guard, so there is no write loop.

**Stopped deleting slots we don't own.** The hook used to `delete`
`markReadRequest` / `updateMessageRequest` before re-registering. Once a
handler arrives declaratively the LLC resolves it onto `configState`,
and that `delete` removed it — the LLC then fell back to
`ctx.defaults.*`, an unmocked request that hangs rather than errors. Now
only `sendMessageRequest` / `retrySendMessageRequest` are touched.

### Other

- `channel.configState` is a prototype getter now, so `{ ...channel }`
no longer carries it. Guarded in the hook.
- `initiateClientWithChannels` writes `client.channelServerConfigsStore`
instead of `jest.spyOn(channel, 'getConfig')` — `serverConfig` is a
getter, and going through the store also drives the channel's own
derivation, so `channel.config` is correct too.
- New `mock-builders/event/utils.ts` — `toChannelResponse()`. `Channel`
was structurally assignable to `Partial<ChannelResponse>` by accident;
it isn't now that `channel.config` means something different.
- SampleApp: `drafts` moved to `client.config.set()` at the
client-creation site; the setup function keeps only middleware and uses
`config.setSetupFunction` instead of the deprecated
`setMessageComposerSetupFunction`. Dropped `linkPreviews: { enabled:
true }` — that is the v10 default.
- `ai-docs/ai-migration-v9-to-v10.md`: new `§13.1` covering the whole
API (registration site, request handlers, setup functions,
resolved-vs-raw table, caveats), plus 9 quick-reference rows and
corrections to `§5`, `§13`, `§16.1`, `§17.3`. `§16.1` was telling people
to re-set `messagePaginator.pageSize` after mount, which is an
imperative patch that gets dropped on the next derivation.

### Behaviour changes without a compile error

- **`linkPreviews.enabled` defaults to `true`** (LLC change). Was
`false`, and `LinkPreviewsManager.enabled` used to AND `url_enrichment`
itself; that gate moved into resolved config. Link previews now appear
wherever enrichment is enabled server-side.
- **`attachmentManager.isUploadEnabled`** is now `config.enabled &&
hasAvailableUploadSlots && (!usesStreamStorage || hasUploadPermission)`,
with the channel type's `uploads` flag ANDed into `config.enabled`. A
custom `doUploadRequest` no longer waives the `upload-file` capability —
integrators uploading outside Stream need `attachments: { customCdn:
true }`.
- **Poll-button timing.** `pollCreationEnabled` was `false` until the
channel query landed (raw flag, `undefined` -> falsy). It is now `true`
optimistically and narrows to `false` if the server says no, because
`polls.enabled` defaults to `true` meaning "no opinion, let the server
decide".

## 🎨 UI Changes

No visual changes. Three behavioural ones are listed above — link
previews appearing by default is the visible one.

## 🧪 Testing

## ☑️ Checklist

- [x] I have signed the [Stream
CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform)
(required)
- [ ] PR targets the `develop` branch — **targets `V10`**
- [x] Documentation is updated
- [ ] New code is tested in main example apps, including all possible
scenarios
  - [ ] SampleApp iOS and Android
- [ ] Expo iOS and Android — no changes; ExpoMessaging has no drafts or
composer setup to migrate
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants