Sitelet https://github.com/coactionjs/coaction/blob/main/docs/migration/v4.md
Skip to content

Latest commit

 

History

History
173 lines (130 loc) · 7.59 KB

File metadata and controls

173 lines (130 loc) · 7.59 KB

Migrating to Coaction 4.0

Most native local applications on React 18 or 19 keep the same APIs. Check shared imports and integration boundaries below. A shorter website guide is available in English and Chinese.

Two of the changes below have guides of their own, because they need more than a paragraph: coaction is the local runtime and store.apply belongs to Coaction.

Nothing to do for most applications

If your native local store uses create, setState, actions, observer, selectors and computed getters, uses supported React versions, and does not rely on the old /local aliases or initial-state SSR selectors, the main APIs remain the same. Review the integration sections if you write an adapter or call store.apply yourself. The changes below are either a different import path, a stricter refusal of something that was already outside the contract, or a behaviour that was wrong.

Entry points

coaction is the runtime for a store that lives in one JavaScript context. coaction/shared is the one that can cross a Worker.

-import { create } from 'coaction/local';
+import { create } from 'coaction';
 // creating a shared store or a client mirror
-import { create } from 'coaction';
+import { create } from 'coaction/shared';

@coaction/react/local is gone; @coaction/react is the local entry. Passing a transport option to the default entry throws and names the entry to switch to, so a missed call site fails loudly. The full guide has the table.

store.apply

Both forms publish a commit now. store.apply(state) did; store.apply(state, patches) changed the state and told nobody, so @coaction/history had nothing to undo and @coaction/sync never queued it.

With patches, state must be the state the store holds — omit it, or pass getPureState():

-store.apply(someOtherState, patches);
+store.apply(store.getPureState(), patches);

A pair describes a change to the current state. Applied to another base it still applies, and the commit still says what the pair says, so the store lands somewhere its own commits do not lead. That is now refused.

Writing an adapter

store.apply belongs to Coaction. An adapter sets internal.externalApply — how to write a change into its own runtime — and nothing else:

-store.apply = (state = store.getPureState(), patches) => { /* write */ };
+internal.externalApply = (state = store.getPureState(), patches) => { /* write */ };

Same signature; do not publish a commit from it. An adapter that replaced apply only to refuse a direct write needs no writer at all. The full guide covers both, and the middleware consequence.

Middleware

A middleware that wraps store.apply now keeps working on a store built through any binder. It used to work on a native store and vanish on a MobX, Valtio or Pinia one.

onStoreCommit remains the right hook for observing transitions: it sees every one of them, including those that never go through apply.

Do not assume every commit has leaf-level patch paths. Ordinary trees normally retain precise patches; positional edits that cannot replay exactly replace the changed top-level keys. Local graphs, sparse arrays, symbol properties, null-prototype records and atomic non-plain values can require a full root snapshot (path: []) to preserve values and reference topology in both replay directions. This preserves the topology of the committed state, not necessarily the topology before a recipe ran.

Local state can retain cyclic values during initialization and complete value replacement. Native recipes still use Mutative's draft contract: creating cycles from draft references and editing inside cyclic drafts are unsupported. Build a new graph from ordinary objects and use setState({ node: nextNode }) to replace its field, or apply(nextState) to replace a cyclic root. A recipe may assign a complete external value, but must not use draft references to construct a cycle. Acyclic shared nodes follow Mutative's independent-path editing behavior; explicitly assign draft.right = draft.left to share the result. See the local graph contract.

These local value guarantees do not extend the JSON contracts of shared transport or remote synchronization.

Server rendering

On the server, useStore(selector) reads the current state. It used to read the initial state, while useStore() and observer read the current one — so a store written to before rendering produced two different values for the same field, and neither matched what the client hydrated against.

If you relied on a selector returning the initial state during SSR, read getInitialState() explicitly.

@coaction/sync

The durable checkpoint carries a formatVersion, and everything read from storage or the network is checked rather than asserted. A checkpoint written by a newer build is refused and left where it is; one that is not JSON, or whose outbox holds a malformed mutation, is refused the same way — hydration rejects, flush() and pull() surface it, and status goes to error.

Checkpoints written before this carry no version and are read as format 1, so there is no migration to run.

State a synced store holds must be JSON. A write that introduces something else — a Date, a Map — is refused at the setState that made it, rather than committed and then dropped on its way to the outbox.

sync() refuses a runtime with nowhere durable to write, rather than falling back to memory.

The CRUD, Supabase and Firestore adapters changed shape: the CRUD adapter keeps a durable record of what the remote holds, Supabase applies its schema option to reads as well as writes, and Firestore separates the read source from the write address. Their READMEs have the details.

React 17

@coaction/react requires React 18 or 19. The peer range used to include 17, and nothing tested it — the suite now runs against every version the range claims, and 17 is not one it can run on.

The runtime still reads through use-sync-external-store/shim, so React 17 may well work; it is simply not something this project verifies, and a supported version should mean a tested one.

Values that cross entry points

coaction and coaction/shared are separate bundles, but the registries behind whole() and reactive tracking live in the global symbol registry, so a value from one is recognised by the other. Importing whole from either entry works.

Three things the word "sync" gets used for

Not a 4.0 change, but the distinction is easier to miss now that all three exist. Reactive tracking is which components re-render. Shared authority (coaction/shared) is one store owning the state while other JavaScript contexts hold mirrors of it. Remote synchronization (@coaction/sync) is a durable outbox and a rebase against a server.

They compose and none implies another: a local store can sync to a server without a Worker, and a shared store's mirrors are views of one authority rather than replicas that diverge and merge. The architecture overview has the table.

What did not change

create, setState, actions, slices, observer, selectors, computed getters, getState, getPureState, subscribe, destroy, the patch and schema helpers, @coaction/history, @coaction/logger, @coaction/persist, and every binder's public surface.