Skip to content

A codegen-free "tolerant store" mode — decouple store schema evolution from native rebuilds #406

Description

@whydidoo

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.

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:

  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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions