callstack / callstack/react-native-brownfield
A codegen-free "tolerant store" mode — decouple store schema evolution from native rebuilds
- Dominant language
- TypeScript
- Stars
- 547
- Forks
- 52
- Avg merge
- 3d 16h
- Merged PRs (30d)
- 5
Description
## Summary
Add an **optional, codegen-free "tolerant store" mode** alongside the existing generated-types path. Instead of generating exact native mirrors of the TS store type, the native side **tolerantly (de)serializes and ignores unknown properties**, and the shape is described only in TS.
This lets store-shape changes ship over-the-air (new RN bundle) without forcing a native rebuild + app-store release, while keeping the current strict/generated mode available for teams that want compile-time guarantees.
We run this variant in production today and would like to propose upstreaming it as a documented, supported mode. We're happy to contribute the docs + wiring + an optional validator as PRs.
## Motivation
Codegen is great for strictness, but it **couples every store-shape change to a native build + app-store release cycle**:
> add a field → regenerate Swift/Kotlin → recompile the native binary → ship through review
The JS bundle alone can't introduce a new store or field that the native side will accept.
In brownfield apps, the native shell and the RN bundle very often ship on **different cadences** (native = store review; RN = OTA / CodePush / remote bundle). Forcing a native rebuild for a pure data-shape change breaks that decoupling — which is the whole reason teams adopt OTA in the first place.
## Proposed solution: the "tolerant store" model
C++ holds the full state as a dynamic value (`folly::dynamic`) — it's already the bridge representation. This is the **lossless source of truth**; it carries every field, including ones a given platform doesn't know about.
Each side decodes only the subset it declares, ignoring the rest:
- **TypeScript** — describe the shape with the usual `declare module` augmentation and use it. No generation step.
- **Swift** — a plain `Codable` struct. `JSONDecoder`/`Codable` already ignores unknown keys by default; extra fields in the dynamic state simply aren't decoded, no error.
- **Kotlin** — a `@Serializable` data class decoded with `Json { ignoreUnknownKeys = true }` — same tolerance.
Writes are **field-scoped merges**: a partial `setState({ a })` merges into the dynamic state in C++, so fields owned by other consumers are preserved across a write from any side. Unknown-to-this-platform fields survive a native write because they live in the dynamic layer, not in the platform struct.
**Net effect:** TS is the place you add a field; native keeps working without recompilation as long as it doesn't need to *read* the new field. The day native wants that field, you add it to the native struct — still no codegen, just one property.
## Real-world architecture (our production setup)
This isn't hypothetical. Our brownfield native shell hosts a **single RN runtime**, and all UI widgets are **Re.Pack Module-Federation remotes** loaded into that one JS context. Everything — native and every federated widget — reads and writes the same `hostconfig` store through our store layer (Unistore). Each remote ships on its own cadence, so a shared-config field change **cannot** be allowed to force a native rebuild.
```mermaid
flowchart TB
subgraph NATIVE["Native host (brownfield shell)"]
iOS["iOS · Swift
Codable struct"]
AND["Android · Kotlin
@Serializable data class"]
end
subgraph CORE["Shared state backbone"]
CPP["C++ JSI store · folly::dynamic
source of truth · dedup · change bus"]
HC["hostconfig store
theme · locale · session · feature flags · env"]
CPP --- HC
end
subgraph RN["Single RN runtime — one JS context"]
SM["StoreManager
one JS cache + listeners"]
subgraph MF["Re.Pack · Module Federation"]
W1["Widget A
(MF remote)"]
W2["Widget B
(MF remote)"]
W3["Widget C
(MF remote)"]
end
SM --- W1
SM --- W2
SM --- W3
end
iOS <-->|hostconfig r/w| CPP
AND <-->|hostconfig r/w| CPP
CPP <-->|"JSI bridge · nativeStoreDidChange(key)"| SM
```
**Data flow for `hostconfig`:**
1. The native shell writes `hostconfig` (theme / locale / session / flags) into the C++ `folly::dynamic` store at startup and on any change.
2. C++ emits a per-key `nativeStoreDidChange(key)`; `StoreManager` refreshes only that store and notifies its listeners.
3. Every federated widget in the shared JS context reads `hostconfig` from the same `StoreManager` cache — one source of truth, no per-remote copy.
4. A widget can write back (e.g. toggling a preference); the partial merge lands in the C++ dynamic store and propagates to native and to the other widgets — bidirectionally.
**Why this validates the proposal:** `hostconfig` is consumed by N independently-deployed remotes. With codegen, adding one field means *regenerate native types → rebuild + re-release the native shell → only
Contributor guide
Assessment
This issue has not been assessed yet.