You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
flowchart TB
subgraph NATIVE["Native host (brownfield shell)"]
iOS["iOS · Swift<br/>Codable struct"]
AND["Android · Kotlin<br/>@Serializable data class"]
end
subgraph CORE["Shared state backbone"]
CPP["C++ JSI store · folly::dynamic<br/>source of truth · dedup · change bus"]
HC["hostconfig store<br/>theme · locale · session · feature flags · env"]
CPP --- HC
end
subgraph RN["Single RN runtime — one JS context"]
SM["StoreManager<br/>one JS cache + listeners"]
subgraph MF["Re.Pack · Module Federation"]
W1["Widget A<br/>(MF remote)"]
W2["Widget B<br/>(MF remote)"]
W3["Widget C<br/>(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
Loading
Data flow for hostconfig:
The native shell writes hostconfig (theme / locale / session / flags) into the C++ folly::dynamic store at startup and on any change.
C++ emits a per-key nativeStoreDidChange(key); StoreManager refreshes only that store and notifies its listeners.
Every federated widget in the shared JS context reads hostconfig from the same StoreManager cache — one source of truth, no per-remote copy.
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
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:
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:
declare moduleaugmentation and use it. No generation step.Codablestruct.JSONDecoder/Codablealready ignores unknown keys by default; extra fields in the dynamic state simply aren't decoded, no error.@Serializabledata class decoded withJson { 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
hostconfigstore 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.flowchart TB subgraph NATIVE["Native host (brownfield shell)"] iOS["iOS · Swift<br/>Codable struct"] AND["Android · Kotlin<br/>@Serializable data class"] end subgraph CORE["Shared state backbone"] CPP["C++ JSI store · folly::dynamic<br/>source of truth · dedup · change bus"] HC["hostconfig store<br/>theme · locale · session · feature flags · env"] CPP --- HC end subgraph RN["Single RN runtime — one JS context"] SM["StoreManager<br/>one JS cache + listeners"] subgraph MF["Re.Pack · Module Federation"] W1["Widget A<br/>(MF remote)"] W2["Widget B<br/>(MF remote)"] W3["Widget C<br/>(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)"| SMData flow for
hostconfig:hostconfig(theme / locale / session / flags) into the C++folly::dynamicstore at startup and on any change.nativeStoreDidChange(key);StoreManagerrefreshes only that store and notifies its listeners.hostconfigfrom the sameStoreManagercache — one source of truth, no per-remote copy.Why this validates the proposal:
hostconfigis consumed by N independently-deployed remotes. With codegen, adding one field means *regenerate native types → rebuild + re-release the native shell → only