@statorjs/stator 1.4.0 → 1.4.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source (Vite/tsx-native by design).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -88,7 +88,7 @@
88
88
  "pino-pretty": "^13.1.3",
89
89
  "vite": "^6.0.0",
90
90
  "vitest": "^2.1.0",
91
- "@statorjs/stator": "1.4.0"
91
+ "@statorjs/stator": "1.4.1"
92
92
  },
93
93
  "scripts": {
94
94
  "typecheck": "tsc --noEmit",
@@ -21,6 +21,15 @@ import { type DirectiveInvocation, defineDirective, invoke } from './core.ts'
21
21
 
22
22
  type ConditionalEntry<TValue> = TValue | ReadResult<TValue>
23
23
 
24
+ /** Resolve the machine instance a spec read evaluates against. During a
25
+ * fan-out recompute this returns the CURRENT proxy — the ReadResult's captured
26
+ * `.instance` was frozen at first render, and `rehydrate()` has since replaced
27
+ * the connection's actor (FINDINGS #3, one composition boundary further out:
28
+ * the spec object outlives the render that built it). Null runtime (initial
29
+ * render, request-scoped recompute) falls back to the captured instance,
30
+ * which is current there by construction. */
31
+ type InstanceResolver = (r: ReadResult<unknown>) => unknown
32
+
24
33
  /** Recursive shape accepted by class:list. */
25
34
  export type ClassListSpec =
26
35
  | string
@@ -41,8 +50,8 @@ export type StyleListSpec =
41
50
  | Record<string, ConditionalEntry<string | number | null | undefined>>
42
51
  | StyleListSpec[]
43
52
 
44
- function evalRead<T>(value: ConditionalEntry<T>): T {
45
- return isReadResult(value) ? (value.selector(value.instance) as T) : value
53
+ function evalRead<T>(value: ConditionalEntry<T>, resolve: InstanceResolver): T {
54
+ return isReadResult(value) ? (value.selector(resolve(value)) as T) : value
46
55
  }
47
56
 
48
57
  /** Backstop for hand-written templates (the compiler rejects this at build
@@ -78,42 +87,42 @@ function collectMachines(spec: unknown, out: Set<string>): void {
78
87
  }
79
88
  }
80
89
 
81
- function composeClass(spec: ClassListSpec): string {
90
+ function composeClass(spec: ClassListSpec, resolve: InstanceResolver): string {
82
91
  const parts: string[] = []
83
- walkClass(spec, parts)
92
+ walkClass(spec, parts, resolve)
84
93
  return parts.join(' ')
85
94
  }
86
95
 
87
- function walkClass(spec: ClassListSpec, out: string[]): void {
96
+ function walkClass(spec: ClassListSpec, out: string[], resolve: InstanceResolver): void {
88
97
  if (spec == null || spec === false) return
89
98
  if (typeof spec === 'string') {
90
99
  if (spec.length > 0) out.push(spec)
91
100
  return
92
101
  }
93
102
  if (isReadResult(spec)) {
94
- const v = spec.selector(spec.instance)
103
+ const v = spec.selector(resolve(spec))
95
104
  if (typeof v === 'string' && v.length > 0) out.push(v)
96
105
  return
97
106
  }
98
107
  if (Array.isArray(spec)) {
99
- for (const item of spec) walkClass(item, out)
108
+ for (const item of spec) walkClass(item, out, resolve)
100
109
  return
101
110
  }
102
111
  if (typeof spec === 'object') {
103
112
  for (const [name, cond] of Object.entries(spec)) {
104
- const truthy = evalRead(cond)
113
+ const truthy = evalRead(cond, resolve)
105
114
  if (truthy) out.push(name)
106
115
  }
107
116
  }
108
117
  }
109
118
 
110
- function composeStyle(spec: StyleListSpec): string {
119
+ function composeStyle(spec: StyleListSpec, resolve: InstanceResolver): string {
111
120
  const decls: string[] = []
112
- walkStyle(spec, decls)
121
+ walkStyle(spec, decls, resolve)
113
122
  return decls.join('; ')
114
123
  }
115
124
 
116
- function walkStyle(spec: StyleListSpec, out: string[]): void {
125
+ function walkStyle(spec: StyleListSpec, out: string[], resolve: InstanceResolver): void {
117
126
  if (spec == null || spec === false) return
118
127
  if (typeof spec === 'string') {
119
128
  const trimmed = spec.trim()
@@ -121,19 +130,19 @@ function walkStyle(spec: StyleListSpec, out: string[]): void {
121
130
  return
122
131
  }
123
132
  if (isReadResult(spec)) {
124
- const v = spec.selector(spec.instance)
133
+ const v = spec.selector(resolve(spec))
125
134
  if (typeof v === 'string' && v.trim().length > 0) {
126
135
  out.push(v.trim().replace(/;\s*$/, ''))
127
136
  }
128
137
  return
129
138
  }
130
139
  if (Array.isArray(spec)) {
131
- for (const item of spec) walkStyle(item, out)
140
+ for (const item of spec) walkStyle(item, out, resolve)
132
141
  return
133
142
  }
134
143
  if (typeof spec === 'object') {
135
144
  for (const [prop, raw] of Object.entries(spec)) {
136
- const value = evalRead(raw)
145
+ const value = evalRead(raw, resolve)
137
146
  if (value == null || value === '') continue
138
147
  // A single property's value must not carry `;` — a `read()`-sourced value
139
148
  // like `red; position: fixed; …` would otherwise inject extra
@@ -156,26 +165,32 @@ function cssValue(v: string): string {
156
165
  /**
157
166
  * Register one attr-binding per unique machine the spec depends on. Each
158
167
  * binding's selector ignores its arg and recomputes the entire attribute
159
- * by re-walking the spec — every ReadResult inside re-evaluates its
160
- * `.selector(.instance)`, returning fresh state.
168
+ * by re-walking the spec — every ReadResult inside re-evaluates its selector
169
+ * against the RESOLVED instance (current proxy under a fan-out recompute,
170
+ * the captured one otherwise), returning fresh state.
161
171
  */
162
172
  function bindListAttr<TSpec>(
163
173
  state: RenderState,
164
174
  spec: TSpec,
165
175
  attrName: string,
166
176
  elementId: string,
167
- compose: (spec: TSpec) => string,
177
+ compose: (spec: TSpec, resolve: InstanceResolver) => string,
168
178
  ): string {
169
179
  const machines = new Set<string>()
170
180
  collectMachines(spec, machines)
171
181
 
172
- const initial = compose(spec)
182
+ // Resolution is per-evaluation, through the render state the binding was
183
+ // registered under — recompute sets `currentRuntime` for the fan-out pass.
184
+ const resolve: InstanceResolver = (r) =>
185
+ state.currentRuntime?.proxyFor(r.machineName) ?? r.instance
186
+
187
+ const initial = compose(spec, resolve)
173
188
  for (const machineName of machines) {
174
189
  const slotId = allocSlotId(state)
175
190
  registerBinding(state, {
176
191
  slotId,
177
192
  machineName,
178
- selector: () => compose(spec),
193
+ selector: () => compose(spec, resolve),
179
194
  lastValue: initial,
180
195
  kind: 'attr',
181
196
  attrName,