@xmachines/play-actor 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,8 +2,7 @@
2
2
 
3
3
  Abstract Actor base class for XMachines Play Architecture.
4
4
 
5
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
- [![Version](https://img.shields.io/badge/version-1.1.0-blue)](https://www.npmjs.com/package/@xmachines/play-actor)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Version](https://img.shields.io/badge/version-2.0.0-blue)](https://www.npmjs.com/package/@xmachines/play-actor)
7
6
 
8
7
  Part of the [xmachines-js monorepo](../../README.md).
9
8
 
@@ -16,7 +15,7 @@ pnpm add @xmachines/play-actor
16
15
  **Peer dependencies** — install alongside the package:
17
16
 
18
17
  ```bash
19
- pnpm add xstate @xmachines/play @xmachines/play-signals
18
+ pnpm add xstate @xmachines/play @xmachines/play-signals @xmachines/json-render-core
20
19
  ```
21
20
 
22
21
  ## Overview
@@ -87,27 +86,25 @@ class AuthActor extends AbstractActor<AnyActorLogic, AuthEvent> {
87
86
  }
88
87
  ```
89
88
 
90
- ### `typedSpec<TContext>(spec)`
89
+ ### `typedSpec(spec)`
91
90
 
92
- Identity helper that constrains a `PlaySpec` object's `contextProps` to keys of a specific machine context type. This enables compile-time validation and IDE autocomplete without any runtime cost.
91
+ Identity helper that types a view-spec literal as `PlaySpec` at the definition site. XState's
92
+ `meta` field is `Record<string, unknown>`, so this is where the spec shape gets compile-time
93
+ validation and IDE autocomplete — without any runtime cost.
93
94
 
94
95
  ```ts
95
96
  import { typedSpec } from "@xmachines/play-actor";
96
97
 
97
- interface DashboardCtx {
98
- username: string;
99
- params: Record<string, string>;
100
- query: Record<string, string>;
101
- }
102
-
103
98
  // In an XState machine meta block:
104
99
  meta: {
105
- view: typedSpec<DashboardCtx>({
100
+ view: typedSpec({
106
101
  root: "root",
107
- contextProps: ["username"], // ✓ key of DashboardCtx
108
- // contextProps: ["usernaem"], // ✗ compile error
109
102
  elements: {
110
- root: { type: "Dashboard", props: {}, children: [] },
103
+ root: {
104
+ type: "Dashboard",
105
+ props: { username: { $state: "/context/username" } },
106
+ children: [],
107
+ },
111
108
  },
112
109
  }),
113
110
  }
@@ -115,20 +112,53 @@ meta: {
115
112
 
116
113
  ### `PlaySpec`
117
114
 
118
- Extends `@xmachines/json-render-core`'s `Spec` with an optional `contextProps` field — an explicit allowlist of machine context fields that are merged into element props at view derivation time.
115
+ Extends `@xmachines/json-render-core`'s `Spec`. The machine's whole context is projected into
116
+ every derived view's state store under the read-only **`/context` subtree**, so specs read it
117
+ through the ordinary `{ $state: "/context/…" }` grammar — in props, `visible` conditions, and
118
+ `repeat.statePath` alike.
119
+
120
+ `/context` is read-only by design — never writable. Machine context changes only through events;
121
+ a `$bindState` or `setState` write under `/context` throws with an error naming the event to
122
+ send instead. This is the model: bindable ephemeral state lives at the store root (seeded from
123
+ `spec.state`), domain state lives in the machine and changes via meaningful, inspectable events.
124
+
125
+ Provenance is legible in the path: `/context/params/username` is visibly URL-derived, while
126
+ `/context/username` is machine-owned — one can never shadow the other.
127
+
128
+ Two consequences of the everything-is-projected model are worth knowing. First, **exposure**:
129
+ the whole context is client-visible in the view store (debug panels, inspectors, validators) —
130
+ context is a client-side value either way, so keep secrets out of it. Second, **emission
131
+ granularity**: the emit gate compares context per top-level field, so an event that changes any
132
+ field re-emits the view with the same `viewKey`. Providers refresh `/context` in the live store
133
+ rather than reseeding it — no remount, ephemeral view state and focus survive — but a
134
+ re-emission is still a render pass in the framework layer. Keep high-frequency ephemeral data
135
+ (per-keystroke drafts, timers) in the view store (`spec.state` + `$bindState`) or a child actor
136
+ rather than in machine context; domain state belongs in context, keystrokes do not.
119
137
 
120
138
  ```ts
121
139
  import type { PlaySpec } from "@xmachines/play-actor";
122
140
 
123
141
  const spec: PlaySpec = {
124
142
  root: "root",
125
- contextProps: ["username"], // only these keys are exposed to components
126
143
  elements: {
127
- root: { type: "Profile", props: { username: undefined }, children: [] },
144
+ root: {
145
+ type: "Profile",
146
+ props: { username: { $state: "/context/username" } },
147
+ children: [],
148
+ },
128
149
  },
129
150
  };
130
151
  ```
131
152
 
153
+ > Historical note: earlier versions had a `contextProps` field. It first drove an implicit
154
+ > prop-enrichment pass that merged allowlisted context fields and URL params into every
155
+ > element's props (removed — it injected values into components that never asked for them and
156
+ > let user-manipulable URL data silently shadow machine-owned state), and was then briefly a
157
+ > projection filter (removed — filtering what a view may read added machinery without a real
158
+ > problem to solve). Validate the **derived** view (`actor.currentView.get()`), not the raw
159
+ > `meta.view`: the derived spec's `state` carries the projection, so tools like `validateSpec`
160
+ > see a self-consistent spec.
161
+
132
162
  ### `Routable`
133
163
 
134
164
  Interface for actors that support routing.
@@ -29,56 +29,55 @@ export interface Routable {
29
29
  /**
30
30
  * XMachines extension of `@xmachines/json-render-core` `Spec`.
31
31
  *
32
- * Adds `contextProps` an explicit allowlist of machine context fields that
33
- * `deriveCurrentView` merges into element props as low-priority slots. Only
34
- * fields named here are ever exposed to components; nothing leaks from context
35
- * without an opt-in declaration.
36
- *
37
- * Use `typedSpec<TContext>(...)` at the definition site to validate `contextProps`
38
- * entries against your machine's context type at compile time.
32
+ * The machine's context is projected into every derived view's state store
33
+ * under the read-only `/context` subtree, so specs read it through the
34
+ * ordinary `{ $state: "/context/…" }` grammar in props, `visible`
35
+ * conditions, and `repeat.statePath` alike. The whole context is always
36
+ * projected; a spec simply reads the paths it needs.
39
37
  */
40
38
  export interface PlaySpec extends Spec {
41
39
  /**
42
- * Explicit allowlist of machine context field names to expose as prop slots.
43
- * Each named field is merged into every spec element's `props` at view derivation
44
- * time, filling any slot whose current value is `undefined`.
45
- *
46
- * Use `typedSpec<TContext>(...)` to constrain entries to `keyof TContext & string`.
40
+ * Identity of the view this derived spec came from set by
41
+ * `deriveCurrentView` from the meta entry the derivation actually selected.
42
+ * Providers key their store lifecycle on it: a changed `viewKey` reseeds the
43
+ * store; an unchanged one refreshes `/context` in place, preserving
44
+ * ephemeral view state. Never author this field in `meta.view`.
47
45
  */
48
- readonly contextProps?: readonly string[];
46
+ readonly viewKey?: string;
49
47
  }
50
48
  /**
51
- * Identity helper that constrains a `PlaySpec` object's `contextProps` to keys
52
- * of a specific machine context type, giving compile-time validation and IDE
53
- * autocomplete at the definition site.
49
+ * Identity helper that types a view spec literal as `PlaySpec` at the
50
+ * definition site, giving compile-time validation and IDE autocomplete.
54
51
  *
55
52
  * XState's `meta` field is typed as `Record<string, unknown>`, so TypeScript
56
- * cannot infer the constraint from context. `typedSpec<MyCtx>(...)` is the
57
- * opt-in mechanism that activates enforcement where the spec is written.
53
+ * cannot infer the spec shape from context. `typedSpec(...)` is the opt-in
54
+ * mechanism that activates checking where the spec is written. `viewKey` is
55
+ * excluded from the parameter — derivation stamps it and would silently
56
+ * overwrite an authored value, so authoring one is rejected at compile time.
57
+ *
58
+ * Excess-property checking only applies to an inline object literal; for a
59
+ * spec built in a variable or through spreads, use `satisfies PlaySpec` at
60
+ * the literal instead.
58
61
  *
59
62
  * At runtime this is a no-op — the spec object is returned unchanged.
60
63
  *
61
64
  * @example
62
65
  * ```ts
63
- * interface DashboardCtx {
64
- * username: string;
65
- * params: Record<string, string>;
66
- * query: Record<string, string>;
67
- * }
68
- *
69
66
  * meta: {
70
- * view: typedSpec<DashboardCtx>({
67
+ * view: typedSpec({
71
68
  * root: "root",
72
- * contextProps: ["username"], // ✓ key of DashboardCtx
73
- * // contextProps: ["usernaem"], // ✗ compile error
74
- * elements: { root: { type: "Dashboard", props: {}, children: [] } },
69
+ * elements: {
70
+ * root: {
71
+ * type: "Dashboard",
72
+ * props: { username: { $state: "/context/username" } },
73
+ * children: [],
74
+ * },
75
+ * },
75
76
  * }),
76
77
  * }
77
78
  * ```
78
79
  */
79
- export declare function typedSpec<TContext extends object>(spec: Omit<PlaySpec, "contextProps"> & {
80
- readonly contextProps?: readonly (keyof TContext & string)[];
81
- }): PlaySpec;
80
+ export declare function typedSpec(spec: Omit<PlaySpec, "viewKey">): PlaySpec;
82
81
  /**
83
82
  * Actor capability for exposing renderable view state.
84
83
  *
@@ -1 +1 @@
1
- {"version":3,"file":"abstract-actor.d.ts","sourceRoot":"","sources":["../src/abstract-actor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,KAAK,EAAE,KAAK,aAAa,EAAE,KAAK,WAAW,EAAE,MAAM,QAAQ,CAAC;AACrE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AACtD,OAAO,KAAK,EACX,IAAI,EACJ,UAAU,EACV,kBAAkB,EAClB,aAAa,EACb,MAAM,6BAA6B,CAAC;AAErC;;GAEG;AACH,MAAM,WAAW,QAAQ;IACxB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC,QAAQ,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACtD,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CACrC;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,QAAS,SAAQ,IAAI;IACrC;;;;;;OAMG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,SAAS,CAAC,QAAQ,SAAS,MAAM,EAChD,IAAI,EAAE,IAAI,CAAC,QAAQ,EAAE,cAAc,CAAC,GAAG;IACtC,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,CAAC,MAAM,QAAQ,GAAG,MAAM,CAAC,EAAE,CAAC;CAC7D,GACC,QAAQ,CAEV;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,QAAQ;IACxB;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC,KAAK,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAC;CACpD;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,oBAAoB,CAAC,SAAS,SAAS,MAAM;IAC7D,sCAAsC;IACtC,IAAI,EAAE,QAAQ,CAAC;IACf,4DAA4D;IAC5D,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IACxC,uDAAuD;IACvD,QAAQ,EAAE,SAAS,CAAC;IACpB,gHAAgH;IAChH,KAAK,EAAE,UAAU,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,WAAW,sBAAsB,CACtC,SAAS,SAAS;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,OAAO,CAAA;CAAE;IAE/E,6EAA6E;IAC7E,KAAK,EAAE,aAAa,CAAC,aAAa,CAAC,GAAG,QAAQ,CAAC;IAC/C,uGAAuG;IACvG,cAAc,EAAE,SAAS,CAAC;IAC1B;;;;OAIG;IACH,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB;;;OAGG;IACH,aAAa,CAAC,EAAE,kBAAkB,CAAC;CACnC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,8BAAsB,aAAa,CAClC,MAAM,SAAS,aAAa,EAC5B,MAAM,SAAS,WAAW,GAAG,WAAW,CACvC,SAAQ,KAAK,CAAC,MAAM,CAAC;IACtB;;;;;OAKG;IACH,SAAgB,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IAE7C;;;;;;;;;;;;OAYG;aACsB,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;CAClD"}
1
+ {"version":3,"file":"abstract-actor.d.ts","sourceRoot":"","sources":["../src/abstract-actor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,KAAK,EAAE,KAAK,aAAa,EAAE,KAAK,WAAW,EAAE,MAAM,QAAQ,CAAC;AACrE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AACtD,OAAO,KAAK,EACX,IAAI,EACJ,UAAU,EACV,kBAAkB,EAClB,aAAa,EACb,MAAM,6BAA6B,CAAC;AAErC;;GAEG;AACH,MAAM,WAAW,QAAQ;IACxB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC,QAAQ,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACtD,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CACrC;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,QAAS,SAAQ,IAAI;IACrC;;;;;;OAMG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,QAAQ,EAAE,SAAS,CAAC,GAAG,QAAQ,CAEnE;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,QAAQ;IACxB;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC,KAAK,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAC;CACpD;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,oBAAoB,CAAC,SAAS,SAAS,MAAM;IAC7D,sCAAsC;IACtC,IAAI,EAAE,QAAQ,CAAC;IACf,4DAA4D;IAC5D,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IACxC,uDAAuD;IACvD,QAAQ,EAAE,SAAS,CAAC;IACpB,gHAAgH;IAChH,KAAK,EAAE,UAAU,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,WAAW,sBAAsB,CACtC,SAAS,SAAS;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,OAAO,CAAA;CAAE;IAE/E,6EAA6E;IAC7E,KAAK,EAAE,aAAa,CAAC,aAAa,CAAC,GAAG,QAAQ,CAAC;IAC/C,uGAAuG;IACvG,cAAc,EAAE,SAAS,CAAC;IAC1B;;;;OAIG;IACH,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB;;;OAGG;IACH,aAAa,CAAC,EAAE,kBAAkB,CAAC;CACnC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,8BAAsB,aAAa,CAClC,MAAM,SAAS,aAAa,EAC5B,MAAM,SAAS,WAAW,GAAG,WAAW,CACvC,SAAQ,KAAK,CAAC,MAAM,CAAC;IACtB;;;;;OAKG;IACH,SAAgB,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IAE7C;;;;;;;;;;;;OAYG;aACsB,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;CAClD"}
@@ -18,30 +18,33 @@
18
18
  */
19
19
  import { Actor } from "xstate";
20
20
  /**
21
- * Identity helper that constrains a `PlaySpec` object's `contextProps` to keys
22
- * of a specific machine context type, giving compile-time validation and IDE
23
- * autocomplete at the definition site.
21
+ * Identity helper that types a view spec literal as `PlaySpec` at the
22
+ * definition site, giving compile-time validation and IDE autocomplete.
24
23
  *
25
24
  * XState's `meta` field is typed as `Record<string, unknown>`, so TypeScript
26
- * cannot infer the constraint from context. `typedSpec<MyCtx>(...)` is the
27
- * opt-in mechanism that activates enforcement where the spec is written.
25
+ * cannot infer the spec shape from context. `typedSpec(...)` is the opt-in
26
+ * mechanism that activates checking where the spec is written. `viewKey` is
27
+ * excluded from the parameter — derivation stamps it and would silently
28
+ * overwrite an authored value, so authoring one is rejected at compile time.
29
+ *
30
+ * Excess-property checking only applies to an inline object literal; for a
31
+ * spec built in a variable or through spreads, use `satisfies PlaySpec` at
32
+ * the literal instead.
28
33
  *
29
34
  * At runtime this is a no-op — the spec object is returned unchanged.
30
35
  *
31
36
  * @example
32
37
  * ```ts
33
- * interface DashboardCtx {
34
- * username: string;
35
- * params: Record<string, string>;
36
- * query: Record<string, string>;
37
- * }
38
- *
39
38
  * meta: {
40
- * view: typedSpec<DashboardCtx>({
39
+ * view: typedSpec({
41
40
  * root: "root",
42
- * contextProps: ["username"], // ✓ key of DashboardCtx
43
- * // contextProps: ["usernaem"], // ✗ compile error
44
- * elements: { root: { type: "Dashboard", props: {}, children: [] } },
41
+ * elements: {
42
+ * root: {
43
+ * type: "Dashboard",
44
+ * props: { username: { $state: "/context/username" } },
45
+ * children: [],
46
+ * },
47
+ * },
45
48
  * }),
46
49
  * }
47
50
  * ```
@@ -1 +1 @@
1
- {"version":3,"file":"abstract-actor.js","sourceRoot":"","sources":["../src/abstract-actor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,KAAK,EAAwC,MAAM,QAAQ,CAAC;AAuCrE;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,UAAU,SAAS,CACxB,IAEC;IAED,OAAO,IAAgB,CAAC;AACzB,CAAC;AAiFD;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,OAAgB,aAGpB,SAAQ,KAAa;CAuBtB"}
1
+ {"version":3,"file":"abstract-actor.js","sourceRoot":"","sources":["../src/abstract-actor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,KAAK,EAAwC,MAAM,QAAQ,CAAC;AAqCrE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,UAAU,SAAS,CAAC,IAA+B;IACxD,OAAO,IAAI,CAAC;AACb,CAAC;AAiFD;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,OAAgB,aAGpB,SAAQ,KAAa;CAuBtB"}
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Context projection — machine context as a read-only `/context` subtree of the
3
+ * view state store.
4
+ *
5
+ * `deriveCurrentView` composes each emitted view's `state` as
6
+ * `{ ...meta.view.state, context: <slice> }`, so the spec is self-consistent:
7
+ * its `state` honestly describes the store contents, and `repeat.statePath`,
8
+ * `visible.$state`, `$state` props and validators can all read machine context
9
+ * through the ordinary `{ $state: "/context/…" }` grammar.
10
+ *
11
+ * The subtree is read-only **to the spec, not to the machinery**: providers
12
+ * hand bindings and action handlers a store wrapped with
13
+ * {@link guardContextWrites}, while refreshing the projection through the
14
+ * unguarded store underneath. Context changes flow exclusively through machine
15
+ * events — a write under `/context` is always a spec bug, and the guard says so.
16
+ *
17
+ * @packageDocumentation
18
+ */
19
+ import type { StateStore } from "@xmachines/json-render-core";
20
+ import type { PlaySpec } from "./abstract-actor.js";
21
+ /**
22
+ * The reserved top-level state key the projection materializes under.
23
+ *
24
+ * A view's authored `spec.state` must not declare this key — when it does, the
25
+ * projection is skipped for that view (the authored state wins) and a dev
26
+ * warning is emitted. See {@link composePlayState}.
27
+ */
28
+ export declare const CONTEXT_STATE_KEY = "context";
29
+ /**
30
+ * Own-key shallow equality with `Object.is`, optionally ignoring one key on
31
+ * both sides.
32
+ *
33
+ * The single comparison rule behind every emission-dedup decision: slice
34
+ * equality and the player's emit gate alike (spec-field comparison in
35
+ * `viewSpecsEquivalent`, composed-state reuse in {@link reuseComposedState}).
36
+ */
37
+ export declare function shallowEqualExcept(a: object, b: object, except?: string): boolean;
38
+ /**
39
+ * Reuse the previous emission's composed `state` — or the whole previous spec
40
+ * — when the projection is value-unchanged.
41
+ *
42
+ * `deriveCurrentView` composes `state: { ...meta.view.state, context: slice }`
43
+ * fresh per call, and XState's `assign` produces a fresh context object on
44
+ * every event — so without reuse, every event would present a new `state`
45
+ * reference and the emit gate's per-field `Object.is` would re-emit (and
46
+ * remount) constantly. Slices are compared per field ({@link shallowEqualExcept} —
47
+ * XState's `assign` produces a fresh context object per event, so whole-object
48
+ * identity would report a change every time); the authored fields are spread
49
+ * from the static `meta.view.state`, so for an unchanged `viewKey` their
50
+ * references are stable and plain `Object.is` holds.
51
+ *
52
+ * Returns, in order of preference:
53
+ * - `prev` itself when nothing observable changed — the emit gate then
54
+ * short-circuits on reference identity with no element walk;
55
+ * - `{ ...next, state: prev.state }` when the state is value-unchanged but
56
+ * some other top-level field differs;
57
+ * - `next` when something actually changed.
58
+ */
59
+ export declare function reuseComposedState(prev: PlaySpec | null, next: PlaySpec | null): PlaySpec | null;
60
+ /**
61
+ * Wrap a StateStore so writes under `/context` are rejected.
62
+ *
63
+ * Applied by providers to the store they hand to bindings and action handlers
64
+ * (`$bindState`, `setState`, chained `set`). The provider keeps the unwrapped
65
+ * store and refreshes the projection through it — read-only to the spec, not
66
+ * to the machinery.
67
+ *
68
+ * A write whose value is **identical** to the current one passes silently:
69
+ * `setState`-style handlers read the full snapshot, transform it, and write
70
+ * the whole object back — the untouched `context` key flowing through that
71
+ * round-trip is not a mutation attempt. Only a write that would actually
72
+ * change the subtree throws.
73
+ *
74
+ * Reads pass through untouched, and every member is delegated explicitly
75
+ * rather than spread: a consumer-supplied store may be a class instance, whose
76
+ * methods live on the prototype and would not survive `{ ...store }` — the
77
+ * first render would die on `store.getSnapshot is not a function`. The wrapper
78
+ * is built once per resolved store, so the delegating `getSnapshot`/`subscribe`
79
+ * identities stay stable for `useSyncExternalStore`-style consumers.
80
+ *
81
+ * @param store - The underlying store.
82
+ * @returns A store with guarded `set`/`update`.
83
+ */
84
+ export declare function guardContextWrites(store: StateStore): StateStore;
85
+ /**
86
+ * Refresh a live store's `/context` subtree from a derived view's composed
87
+ * state. A same-`viewKey` emission means only the projection changed — the
88
+ * subtree is replaced wholesale (never merged per-field: `update` cannot
89
+ * delete keys) and every ephemeral root-level value is left untouched.
90
+ * No-op when the view carries no slice or the store already holds it.
91
+ *
92
+ * @param store - The UNGUARDED store (providers refresh through it).
93
+ * @param view - The derived view whose `state.context` carries the slice.
94
+ */
95
+ export declare function refreshContextSubtree(store: StateStore, view: {
96
+ state?: unknown;
97
+ }): void;
98
+ /**
99
+ * Compose a view's effective state: the authored `spec.state` plus the
100
+ * `/context` slice.
101
+ *
102
+ * When the authored state already declares the reserved key, the projection is
103
+ * skipped (authored state wins) with a dev warning — no existing spec breaks.
104
+ *
105
+ * @param authoredState - The raw `meta.view.state` (may be anything; sanitized by the caller/toAtomState).
106
+ * @param slice - The machine's context, projected wholesale.
107
+ */
108
+ export declare function composePlayState(authoredState: Record<string, unknown> | undefined, slice: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
109
+ //# sourceMappingURL=context-projection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context-projection.d.ts","sourceRoot":"","sources":["../src/context-projection.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAE9D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAEpD;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,YAAY,CAAC;AAiB3C;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAUjF;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,QAAQ,GAAG,IAAI,EAAE,IAAI,EAAE,QAAQ,GAAG,IAAI,GAAG,QAAQ,GAAG,IAAI,CAiBhG;AAgBD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,UAAU,GAAG,UAAU,CA6ChE;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAKxF;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAC/B,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAClD,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GACxC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAcrC"}
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Context projection — machine context as a read-only `/context` subtree of the
3
+ * view state store.
4
+ *
5
+ * `deriveCurrentView` composes each emitted view's `state` as
6
+ * `{ ...meta.view.state, context: <slice> }`, so the spec is self-consistent:
7
+ * its `state` honestly describes the store contents, and `repeat.statePath`,
8
+ * `visible.$state`, `$state` props and validators can all read machine context
9
+ * through the ordinary `{ $state: "/context/…" }` grammar.
10
+ *
11
+ * The subtree is read-only **to the spec, not to the machinery**: providers
12
+ * hand bindings and action handlers a store wrapped with
13
+ * {@link guardContextWrites}, while refreshing the projection through the
14
+ * unguarded store underneath. Context changes flow exclusively through machine
15
+ * events — a write under `/context` is always a spec bug, and the guard says so.
16
+ *
17
+ * @packageDocumentation
18
+ */
19
+ /**
20
+ * The reserved top-level state key the projection materializes under.
21
+ *
22
+ * A view's authored `spec.state` must not declare this key — when it does, the
23
+ * projection is skipped for that view (the authored state wins) and a dev
24
+ * warning is emitted. See {@link composePlayState}.
25
+ */
26
+ export const CONTEXT_STATE_KEY = "context";
27
+ /**
28
+ * Once-per-spec diagnostic dedup. The condition reported here is a static
29
+ * property of the authored meta.view (its `state` object never changes
30
+ * between events), but `deriveCurrentView` runs on every machine snapshot —
31
+ * reporting per derivation would flood the console at event rate. Keyed by
32
+ * the static object's identity, so each spec reports exactly once and tests
33
+ * with fresh literals stay isolated.
34
+ */
35
+ const reportedDiagnostics = new WeakSet();
36
+ function warnOnce(anchor, message) {
37
+ if (reportedDiagnostics.has(anchor))
38
+ return;
39
+ reportedDiagnostics.add(anchor);
40
+ console.warn(message);
41
+ }
42
+ /**
43
+ * Own-key shallow equality with `Object.is`, optionally ignoring one key on
44
+ * both sides.
45
+ *
46
+ * The single comparison rule behind every emission-dedup decision: slice
47
+ * equality and the player's emit gate alike (spec-field comparison in
48
+ * `viewSpecsEquivalent`, composed-state reuse in {@link reuseComposedState}).
49
+ */
50
+ export function shallowEqualExcept(a, b, except) {
51
+ const aEntries = Object.entries(a).filter(([key]) => key !== except);
52
+ const bKeyCount = Object.keys(b).filter((key) => key !== except).length;
53
+ if (aEntries.length !== bKeyCount)
54
+ return false;
55
+ const bRecord = b;
56
+ for (const [key, value] of aEntries) {
57
+ // nosemgrep: gitlab.eslint.detect-object-injection
58
+ if (!Object.hasOwn(b, key) || !Object.is(value, bRecord[key]))
59
+ return false;
60
+ }
61
+ return true;
62
+ }
63
+ /**
64
+ * Reuse the previous emission's composed `state` — or the whole previous spec
65
+ * — when the projection is value-unchanged.
66
+ *
67
+ * `deriveCurrentView` composes `state: { ...meta.view.state, context: slice }`
68
+ * fresh per call, and XState's `assign` produces a fresh context object on
69
+ * every event — so without reuse, every event would present a new `state`
70
+ * reference and the emit gate's per-field `Object.is` would re-emit (and
71
+ * remount) constantly. Slices are compared per field ({@link shallowEqualExcept} —
72
+ * XState's `assign` produces a fresh context object per event, so whole-object
73
+ * identity would report a change every time); the authored fields are spread
74
+ * from the static `meta.view.state`, so for an unchanged `viewKey` their
75
+ * references are stable and plain `Object.is` holds.
76
+ *
77
+ * Returns, in order of preference:
78
+ * - `prev` itself when nothing observable changed — the emit gate then
79
+ * short-circuits on reference identity with no element walk;
80
+ * - `{ ...next, state: prev.state }` when the state is value-unchanged but
81
+ * some other top-level field differs;
82
+ * - `next` when something actually changed.
83
+ */
84
+ export function reuseComposedState(prev, next) {
85
+ if (!prev || !next || prev.viewKey !== next.viewKey)
86
+ return next;
87
+ const prevState = prev.state;
88
+ const nextState = next.state;
89
+ if (prevState !== nextState) {
90
+ if (prevState === undefined || nextState === undefined)
91
+ return next;
92
+ if (!shallowEqualExcept(prevState, nextState, CONTEXT_STATE_KEY))
93
+ return next;
94
+ const prevSlice = prevState[CONTEXT_STATE_KEY];
95
+ const nextSlice = nextState[CONTEXT_STATE_KEY];
96
+ if (prevSlice !== nextSlice) {
97
+ if (prevSlice === undefined || nextSlice === undefined)
98
+ return next;
99
+ if (!shallowEqualExcept(prevSlice, nextSlice))
100
+ return next;
101
+ }
102
+ if (shallowEqualExcept(prev, next, "state"))
103
+ return prev;
104
+ return { ...next, state: prevState };
105
+ }
106
+ return shallowEqualExcept(prev, next, "state") ? prev : next;
107
+ }
108
+ /**
109
+ * Paths that address the reserved subtree: "/context" itself or below it.
110
+ * Store update maps may carry bare top-level keys ("context") as well as
111
+ * JSON Pointers ("/context/…") — both forms address the same subtree.
112
+ */
113
+ function toPointer(path) {
114
+ return path.startsWith("/") ? path : `/${path}`;
115
+ }
116
+ /** Expects a NORMALIZED pointer from {@link toPointer}. */
117
+ function isContextPointer(pointer) {
118
+ return pointer === `/${CONTEXT_STATE_KEY}` || pointer.startsWith(`/${CONTEXT_STATE_KEY}/`);
119
+ }
120
+ /**
121
+ * Wrap a StateStore so writes under `/context` are rejected.
122
+ *
123
+ * Applied by providers to the store they hand to bindings and action handlers
124
+ * (`$bindState`, `setState`, chained `set`). The provider keeps the unwrapped
125
+ * store and refreshes the projection through it — read-only to the spec, not
126
+ * to the machinery.
127
+ *
128
+ * A write whose value is **identical** to the current one passes silently:
129
+ * `setState`-style handlers read the full snapshot, transform it, and write
130
+ * the whole object back — the untouched `context` key flowing through that
131
+ * round-trip is not a mutation attempt. Only a write that would actually
132
+ * change the subtree throws.
133
+ *
134
+ * Reads pass through untouched, and every member is delegated explicitly
135
+ * rather than spread: a consumer-supplied store may be a class instance, whose
136
+ * methods live on the prototype and would not survive `{ ...store }` — the
137
+ * first render would die on `store.getSnapshot is not a function`. The wrapper
138
+ * is built once per resolved store, so the delegating `getSnapshot`/`subscribe`
139
+ * identities stay stable for `useSyncExternalStore`-style consumers.
140
+ *
141
+ * @param store - The underlying store.
142
+ * @returns A store with guarded `set`/`update`.
143
+ */
144
+ export function guardContextWrites(store) {
145
+ const reject = (path) => {
146
+ throw new Error(`[play-actor] "${path}" is read-only: /${CONTEXT_STATE_KEY} mirrors the machine's context. ` +
147
+ `Send the actor an event to change it — context never changes through the view store.`);
148
+ };
149
+ const checkWrite = (path, value) => {
150
+ const pointer = toPointer(path);
151
+ if (!isContextPointer(pointer))
152
+ return true;
153
+ // Unchanged round-trip (read-modify-write of the whole snapshot) is a
154
+ // no-op for this subtree, not a mutation — drop the key silently.
155
+ if (Object.is(store.get(pointer), value))
156
+ return false;
157
+ return reject(path);
158
+ };
159
+ const guarded = {
160
+ get: (path) => store.get(path),
161
+ getSnapshot: () => store.getSnapshot(),
162
+ subscribe: (listener) => store.subscribe(listener),
163
+ set: (path, value) => {
164
+ if (checkWrite(path, value))
165
+ store.set(path, value);
166
+ },
167
+ update: (updates) => {
168
+ const allowed = {};
169
+ let dropped = false;
170
+ for (const [path, value] of Object.entries(updates)) {
171
+ if (checkWrite(path, value)) {
172
+ allowed[path] = value; // nosemgrep: gitlab.eslint.detect-object-injection
173
+ }
174
+ else {
175
+ dropped = true;
176
+ }
177
+ }
178
+ store.update(dropped ? allowed : updates);
179
+ },
180
+ };
181
+ // Optional in the contract, and its absence is meaningful: json-render-core
182
+ // falls back to `getSnapshot` when the key is missing, so the wrapper must
183
+ // not manufacture one that returns `undefined`.
184
+ const { getServerSnapshot } = store;
185
+ if (getServerSnapshot) {
186
+ guarded.getServerSnapshot = () => getServerSnapshot.call(store);
187
+ }
188
+ return guarded;
189
+ }
190
+ /**
191
+ * Refresh a live store's `/context` subtree from a derived view's composed
192
+ * state. A same-`viewKey` emission means only the projection changed — the
193
+ * subtree is replaced wholesale (never merged per-field: `update` cannot
194
+ * delete keys) and every ephemeral root-level value is left untouched.
195
+ * No-op when the view carries no slice or the store already holds it.
196
+ *
197
+ * @param store - The UNGUARDED store (providers refresh through it).
198
+ * @param view - The derived view whose `state.context` carries the slice.
199
+ */
200
+ export function refreshContextSubtree(store, view) {
201
+ const slice = view.state?.[CONTEXT_STATE_KEY];
202
+ if (slice !== undefined && !Object.is(store.get(`/${CONTEXT_STATE_KEY}`), slice)) {
203
+ store.update({ [`/${CONTEXT_STATE_KEY}`]: slice });
204
+ }
205
+ }
206
+ /**
207
+ * Compose a view's effective state: the authored `spec.state` plus the
208
+ * `/context` slice.
209
+ *
210
+ * When the authored state already declares the reserved key, the projection is
211
+ * skipped (authored state wins) with a dev warning — no existing spec breaks.
212
+ *
213
+ * @param authoredState - The raw `meta.view.state` (may be anything; sanitized by the caller/toAtomState).
214
+ * @param slice - The machine's context, projected wholesale.
215
+ */
216
+ export function composePlayState(authoredState, slice) {
217
+ if (authoredState !== undefined && Object.hasOwn(authoredState, CONTEXT_STATE_KEY)) {
218
+ warnOnce(authoredState, `[play-actor] spec.state declares a top-level "${CONTEXT_STATE_KEY}" key; ` +
219
+ `skipping the machine-context projection for this view. ` +
220
+ `Rename the state field to project context at /${CONTEXT_STATE_KEY}.`);
221
+ return authoredState;
222
+ }
223
+ if (slice === undefined) {
224
+ return authoredState;
225
+ }
226
+ return { ...authoredState, [CONTEXT_STATE_KEY]: slice };
227
+ }
228
+ //# sourceMappingURL=context-projection.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context-projection.js","sourceRoot":"","sources":["../src/context-projection.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -17,4 +17,7 @@
17
17
  */
18
18
  export { AbstractActor, typedSpec, type Routable, type Viewable, type PlaySpec, type BaseActorProviderProps, type BaseViewContextValue, } from "./abstract-actor.js";
19
19
  export { toAtomState, attachRenderErrorHandler } from "./provider-guards.js";
20
+ export { CONTEXT_STATE_KEY, guardContextWrites, refreshContextSubtree, composePlayState, shallowEqualExcept, reuseComposedState, } from "./context-projection.js";
21
+ export { createViewStoreLifecycle } from "./view-store-lifecycle.js";
22
+ export type { ViewStoreLifecycle, ViewStoreResolution, ResolveViewStoreOptions, } from "./view-store-lifecycle.js";
20
23
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EACN,aAAa,EACb,SAAS,EACT,KAAK,QAAQ,EACb,KAAK,QAAQ,EACb,KAAK,QAAQ,EACb,KAAK,sBAAsB,EAC3B,KAAK,oBAAoB,GACzB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAE,WAAW,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EACN,aAAa,EACb,SAAS,EACT,KAAK,QAAQ,EACb,KAAK,QAAQ,EACb,KAAK,QAAQ,EACb,KAAK,sBAAsB,EAC3B,KAAK,oBAAoB,GACzB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAE,WAAW,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AAE7E,OAAO,EACN,iBAAiB,EACjB,kBAAkB,EAClB,qBAAqB,EACrB,gBAAgB,EAChB,kBAAkB,EAClB,kBAAkB,GAClB,MAAM,yBAAyB,CAAC;AAEjC,OAAO,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AACrE,YAAY,EACX,kBAAkB,EAClB,mBAAmB,EACnB,uBAAuB,GACvB,MAAM,2BAA2B,CAAC"}
package/dist/index.js CHANGED
@@ -17,4 +17,6 @@
17
17
  */
18
18
  export { AbstractActor, typedSpec, } from "./abstract-actor.js";
19
19
  export { toAtomState, attachRenderErrorHandler } from "./provider-guards.js";
20
+ export { CONTEXT_STATE_KEY, guardContextWrites, refreshContextSubtree, composePlayState, shallowEqualExcept, reuseComposedState, } from "./context-projection.js";
21
+ export { createViewStoreLifecycle } from "./view-store-lifecycle.js";
20
22
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EACN,aAAa,EACb,SAAS,GAMT,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAE,WAAW,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EACN,aAAa,EACb,SAAS,GAMT,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAE,WAAW,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AAE7E,OAAO,EACN,iBAAiB,EACjB,kBAAkB,EAClB,qBAAqB,EACrB,gBAAgB,EAChB,kBAAkB,EAClB,kBAAkB,GAClB,MAAM,yBAAyB,CAAC;AAEjC,OAAO,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC"}
@@ -0,0 +1,77 @@
1
+ /**
2
+ * View-store lifecycle — the framework-agnostic reseed-vs-refresh decision
3
+ * every provider wires into its own reactivity.
4
+ *
5
+ * The policy in one place (previously hand-copied per renderer, where the
6
+ * copies drifted into real bugs):
7
+ *
8
+ * - `viewKey` changed (or first resolve, or actor swapped) → RESEED: a fresh
9
+ * store from the view's composed state.
10
+ * - `viewKey` unchanged → REFRESH: only the /context projection moved, so the
11
+ * `/context` subtree is replaced in place and ephemeral root-level state
12
+ * (drafts, toggles) survives.
13
+ * - No `viewKey` (a hand-built Viewable that does not stamp one) → reseed per
14
+ * EMISSION, the safe pre-viewKey behavior.
15
+ * - Actor swap → the kept-alive store dies with its actor (lifetime rule).
16
+ * - Controlled mode (caller-supplied store) → the caller owns seeding and
17
+ * lifecycle, but /context is machinery-owned in both modes and is refreshed
18
+ * here — unless the caller defers it ({@link ResolveViewStoreOptions}),
19
+ * which React's render path needs (store subscribers must not be notified
20
+ * mid-render; its effects refresh instead).
21
+ *
22
+ * What stays in each provider is only reactivity wiring: WHEN to resolve, and
23
+ * how the result reaches children.
24
+ *
25
+ * @packageDocumentation
26
+ */
27
+ import type { StateStore } from "@xmachines/json-render-core";
28
+ import type { PlaySpec } from "./abstract-actor.js";
29
+ /** What a resolve produced — see {@link ViewStoreLifecycle.resolve}. */
30
+ export interface ViewStoreResolution {
31
+ /** The UNGUARDED store — the machinery's own refresh reference. */
32
+ store: StateStore;
33
+ /**
34
+ * The store to hand children ($bindState, setState, chained set): writes
35
+ * under /context throw. One wrapper per underlying store — the identity is
36
+ * cached so `useSyncExternalStore`-style consumers stay stable.
37
+ */
38
+ guardedStore: StateStore;
39
+ /**
40
+ * True when this resolve created a fresh store (first resolve, viewKey
41
+ * change, per-emission fallback, actor swap). Providers that remount a
42
+ * subtree per store (Vue's storeKey) key on it.
43
+ */
44
+ reseeded: boolean;
45
+ }
46
+ export interface ResolveViewStoreOptions {
47
+ /**
48
+ * Controlled mode only: refresh the external store's /context during this
49
+ * resolve (default true). Pass false where notifying store subscribers is
50
+ * not allowed at the call site (React's render path) and refresh from an
51
+ * effect instead.
52
+ */
53
+ refreshExternalStore?: boolean;
54
+ }
55
+ export interface ViewStoreLifecycle {
56
+ /**
57
+ * Bring the store in line with an emission and return it (with its guard).
58
+ *
59
+ * @param actor - The actor the emission came from — a swap drops the kept store.
60
+ * @param view - The derived view (non-null; a null emission is a GAP, not a
61
+ * new view — callers simply do not resolve on null, keeping the store).
62
+ * @param externalStore - Controlled mode: the caller-owned store.
63
+ * @param options - See {@link ResolveViewStoreOptions}.
64
+ */
65
+ resolve(actor: unknown, view: PlaySpec, externalStore?: StateStore | undefined, options?: ResolveViewStoreOptions): ViewStoreResolution;
66
+ /** Drop everything (unmount/disconnect) — the next resolve reseeds. */
67
+ reset(): void;
68
+ }
69
+ /**
70
+ * Create a lifecycle coordinator.
71
+ *
72
+ * @param createStore - Framework-supplied store factory; receives the
73
+ * proto-safe seed (`toAtomState(view.state)` — the composed state already
74
+ * carries /context).
75
+ */
76
+ export declare function createViewStoreLifecycle(createStore: (seed: Record<string, unknown>) => StateStore): ViewStoreLifecycle;
77
+ //# sourceMappingURL=view-store-lifecycle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"view-store-lifecycle.d.ts","sourceRoot":"","sources":["../src/view-store-lifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAE9D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAIpD,wEAAwE;AACxE,MAAM,WAAW,mBAAmB;IACnC,mEAAmE;IACnE,KAAK,EAAE,UAAU,CAAC;IAClB;;;;OAIG;IACH,YAAY,EAAE,UAAU,CAAC;IACzB;;;;OAIG;IACH,QAAQ,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,uBAAuB;IACvC;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAC/B;AAED,MAAM,WAAW,kBAAkB;IAClC;;;;;;;;OAQG;IACH,OAAO,CACN,KAAK,EAAE,OAAO,EACd,IAAI,EAAE,QAAQ,EACd,aAAa,CAAC,EAAE,UAAU,GAAG,SAAS,EACtC,OAAO,CAAC,EAAE,uBAAuB,GAC/B,mBAAmB,CAAC;IACvB,uEAAuE;IACvE,KAAK,IAAI,IAAI,CAAC;CACd;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CACvC,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,UAAU,GACxD,kBAAkB,CA4DpB"}
@@ -0,0 +1,94 @@
1
+ /**
2
+ * View-store lifecycle — the framework-agnostic reseed-vs-refresh decision
3
+ * every provider wires into its own reactivity.
4
+ *
5
+ * The policy in one place (previously hand-copied per renderer, where the
6
+ * copies drifted into real bugs):
7
+ *
8
+ * - `viewKey` changed (or first resolve, or actor swapped) → RESEED: a fresh
9
+ * store from the view's composed state.
10
+ * - `viewKey` unchanged → REFRESH: only the /context projection moved, so the
11
+ * `/context` subtree is replaced in place and ephemeral root-level state
12
+ * (drafts, toggles) survives.
13
+ * - No `viewKey` (a hand-built Viewable that does not stamp one) → reseed per
14
+ * EMISSION, the safe pre-viewKey behavior.
15
+ * - Actor swap → the kept-alive store dies with its actor (lifetime rule).
16
+ * - Controlled mode (caller-supplied store) → the caller owns seeding and
17
+ * lifecycle, but /context is machinery-owned in both modes and is refreshed
18
+ * here — unless the caller defers it ({@link ResolveViewStoreOptions}),
19
+ * which React's render path needs (store subscribers must not be notified
20
+ * mid-render; its effects refresh instead).
21
+ *
22
+ * What stays in each provider is only reactivity wiring: WHEN to resolve, and
23
+ * how the result reaches children.
24
+ *
25
+ * @packageDocumentation
26
+ */
27
+ import { guardContextWrites, refreshContextSubtree } from "./context-projection.js";
28
+ import { toAtomState } from "./provider-guards.js";
29
+ /**
30
+ * Create a lifecycle coordinator.
31
+ *
32
+ * @param createStore - Framework-supplied store factory; receives the
33
+ * proto-safe seed (`toAtomState(view.state)` — the composed state already
34
+ * carries /context).
35
+ */
36
+ export function createViewStoreLifecycle(createStore) {
37
+ let internalStore = null;
38
+ let lastViewKey = undefined;
39
+ let lastView = null;
40
+ let lastActor = null;
41
+ // Guard identity cache — one wrapper per underlying store.
42
+ let guardedSource = null;
43
+ let guardedStore = null;
44
+ const reset = () => {
45
+ internalStore = null;
46
+ lastViewKey = undefined;
47
+ lastView = null;
48
+ lastActor = null;
49
+ guardedSource = null;
50
+ guardedStore = null;
51
+ };
52
+ return {
53
+ reset,
54
+ resolve(actor, view, externalStore, options) {
55
+ // Lifetime rule: a kept-alive store must not outlive its actor.
56
+ if (lastActor !== actor) {
57
+ const firstResolve = lastActor === null && internalStore === null;
58
+ if (!firstResolve)
59
+ reset();
60
+ lastActor = actor;
61
+ }
62
+ let resolved;
63
+ let reseeded = false;
64
+ if (externalStore) {
65
+ resolved = externalStore;
66
+ if (options?.refreshExternalStore !== false) {
67
+ refreshContextSubtree(resolved, view);
68
+ }
69
+ }
70
+ else {
71
+ const viewKey = view.viewKey;
72
+ let nextStore = internalStore;
73
+ if (nextStore === null ||
74
+ (viewKey !== undefined ? lastViewKey !== viewKey : lastView !== view)) {
75
+ nextStore = createStore(toAtomState(view.state));
76
+ internalStore = nextStore;
77
+ lastViewKey = viewKey;
78
+ reseeded = true;
79
+ }
80
+ else if (viewKey !== undefined) {
81
+ refreshContextSubtree(nextStore, view);
82
+ }
83
+ lastView = view;
84
+ resolved = nextStore;
85
+ }
86
+ if (guardedSource !== resolved) {
87
+ guardedSource = resolved;
88
+ guardedStore = guardContextWrites(resolved);
89
+ }
90
+ return { store: resolved, guardedStore: guardedStore, reseeded };
91
+ },
92
+ };
93
+ }
94
+ //# sourceMappingURL=view-store-lifecycle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"view-store-lifecycle.js","sourceRoot":"","sources":["../src/view-store-lifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAKH,OAAO,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AACpF,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAkDnD;;;;;;GAMG;AACH,MAAM,UAAU,wBAAwB,CACvC,WAA0D;IAE1D,IAAI,aAAa,GAAsB,IAAI,CAAC;IAC5C,IAAI,WAAW,GAAuB,SAAS,CAAC;IAChD,IAAI,QAAQ,GAAoB,IAAI,CAAC;IACrC,IAAI,SAAS,GAAY,IAAI,CAAC;IAC9B,2DAA2D;IAC3D,IAAI,aAAa,GAAsB,IAAI,CAAC;IAC5C,IAAI,YAAY,GAAsB,IAAI,CAAC;IAE3C,MAAM,KAAK,GAAG,GAAS,EAAE;QACxB,aAAa,GAAG,IAAI,CAAC;QACrB,WAAW,GAAG,SAAS,CAAC;QACxB,QAAQ,GAAG,IAAI,CAAC;QAChB,SAAS,GAAG,IAAI,CAAC;QACjB,aAAa,GAAG,IAAI,CAAC;QACrB,YAAY,GAAG,IAAI,CAAC;IACrB,CAAC,CAAC;IAEF,OAAO;QACN,KAAK;QACL,OAAO,CAAC,KAAK,EAAE,IAAI,EAAE,aAAa,EAAE,OAAO;YAC1C,gEAAgE;YAChE,IAAI,SAAS,KAAK,KAAK,EAAE,CAAC;gBACzB,MAAM,YAAY,GAAG,SAAS,KAAK,IAAI,IAAI,aAAa,KAAK,IAAI,CAAC;gBAClE,IAAI,CAAC,YAAY;oBAAE,KAAK,EAAE,CAAC;gBAC3B,SAAS,GAAG,KAAK,CAAC;YACnB,CAAC;YAED,IAAI,QAAoB,CAAC;YACzB,IAAI,QAAQ,GAAG,KAAK,CAAC;YACrB,IAAI,aAAa,EAAE,CAAC;gBACnB,QAAQ,GAAG,aAAa,CAAC;gBACzB,IAAI,OAAO,EAAE,oBAAoB,KAAK,KAAK,EAAE,CAAC;oBAC7C,qBAAqB,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;gBACvC,CAAC;YACF,CAAC;iBAAM,CAAC;gBACP,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC;gBAC7B,IAAI,SAAS,GAAsB,aAAa,CAAC;gBACjD,IACC,SAAS,KAAK,IAAI;oBAClB,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,QAAQ,KAAK,IAAI,CAAC,EACpE,CAAC;oBACF,SAAS,GAAG,WAAW,CAAC,WAAW,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;oBACjD,aAAa,GAAG,SAAS,CAAC;oBAC1B,WAAW,GAAG,OAAO,CAAC;oBACtB,QAAQ,GAAG,IAAI,CAAC;gBACjB,CAAC;qBAAM,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;oBAClC,qBAAqB,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;gBACxC,CAAC;gBACD,QAAQ,GAAG,IAAI,CAAC;gBAChB,QAAQ,GAAG,SAAS,CAAC;YACtB,CAAC;YAED,IAAI,aAAa,KAAK,QAAQ,EAAE,CAAC;gBAChC,aAAa,GAAG,QAAQ,CAAC;gBACzB,YAAY,GAAG,kBAAkB,CAAC,QAAQ,CAAC,CAAC;YAC7C,CAAC;YACD,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,YAA0B,EAAE,QAAQ,EAAE,CAAC;QAChF,CAAC;KACD,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmachines/play-actor",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "private": false,
5
5
  "description": "Abstract Actor base class for XMachines Play Architecture",
6
6
  "keywords": [
@@ -14,7 +14,7 @@
14
14
  "author": "XMachines Contributors",
15
15
  "repository": {
16
16
  "type": "git",
17
- "url": "git+ssh://git@gitlab.com/xmachin-es/xmachines-js.git",
17
+ "url": "git+https://gitlab.com/xmachin-es/xmachines-js.git",
18
18
  "directory": "packages/play-actor"
19
19
  },
20
20
  "files": [
@@ -24,17 +24,20 @@
24
24
  ],
25
25
  "type": "module",
26
26
  "sideEffects": false,
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
27
29
  "exports": {
28
30
  ".": {
29
31
  "types": "./dist/index.d.ts",
30
- "import": "./dist/index.js"
31
- }
32
+ "default": "./dist/index.js"
33
+ },
34
+ "./package.json": "./package.json"
32
35
  },
33
36
  "publishConfig": {
34
37
  "access": "public"
35
38
  },
36
39
  "scripts": {
37
- "build": "tsc --build",
40
+ "build": "vite build && tsc --build",
38
41
  "clean": "rm -rf dist *.tsbuildinfo coverage node_modules/.svelte2tsx-* node_modules/.vite*",
39
42
  "test": "vitest",
40
43
  "lint": "oxlint .",
@@ -42,20 +45,20 @@
42
45
  "format": "oxfmt .",
43
46
  "format:check": "oxfmt --check ."
44
47
  },
45
- "dependencies": {
46
- "@xmachines/json-render-core": "^0.19.0-xm.2"
47
- },
48
48
  "devDependencies": {
49
49
  "@testing-library/jest-dom": "^6.9.1",
50
50
  "@types/node": "^26.2.0",
51
+ "@xmachines/json-render-core": "^0.20.0-xm.2",
51
52
  "oxfmt": "^0.64.0",
52
53
  "oxlint": "^1.79.0",
54
+ "vite": "^8.0.10",
53
55
  "vitest": "^4.1.11",
54
56
  "xstate": "^5.31.0"
55
57
  },
56
58
  "peerDependencies": {
57
- "@xmachines/play": "1.1.0",
58
- "@xmachines/play-signals": "1.1.0",
59
+ "@xmachines/json-render-core": "^0.20.0-xm.2",
60
+ "@xmachines/play": "2.0.0",
61
+ "@xmachines/play-signals": "2.0.0",
59
62
  "xstate": "^5.31.0"
60
63
  },
61
64
  "engines": {