@statorjs/stator 1.7.0 → 1.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
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,12 +88,13 @@
88
88
  "pino-pretty": "^13.1.3",
89
89
  "vite": "^6.0.0",
90
90
  "vitest": "^2.1.0",
91
- "@statorjs/stator": "1.7.0"
91
+ "@statorjs/stator": "1.8.0"
92
92
  },
93
93
  "scripts": {
94
94
  "typecheck": "tsc --noEmit",
95
95
  "test": "vitest run",
96
96
  "test:watch": "vitest",
97
+ "test:browser": "node browser-tests/tables.mjs",
97
98
  "build:client": "esbuild src/client/runtime.ts --bundle --format=iife --target=es2020 --outfile=dist/client.js"
98
99
  }
99
100
  }
Binary file
@@ -244,10 +244,16 @@ export function createActor<C extends object, E extends EventObject, S extends s
244
244
  // transition uniformly against the full event union (the runtime event
245
245
  // IS the narrowed type for this key, so the coercion is sound).
246
246
  type RawTransition = ((ctx: C, ev: E, h: ActionHelpers) => void) | TransitionConfig<C, E, S>
247
- const entry = node?.on?.[event.type as E['type']] as
248
- | RawTransition
249
- | RawTransition[]
250
- | undefined
247
+ let entry = node?.on?.[event.type as E['type']] as RawTransition | RawTransition[] | undefined
248
+ // Machine-level fallback: consulted ONLY when the current state does not
249
+ // declare this event (state-scoped handlers win — most specific). This is
250
+ // how a completion is handled regardless of the machine-wide state (a save
251
+ // completing while the machine reloads) without duplicating the handler
252
+ // into every state. A state that declares the event owns it, even if its
253
+ // guard drops — no fall-through to machine level in that case.
254
+ if (entry === undefined) {
255
+ entry = def.on?.[event.type as E['type']] as RawTransition | RawTransition[] | undefined
256
+ }
251
257
  if (!entry) return
252
258
 
253
259
  const h = helpers()
@@ -6,6 +6,7 @@ import type {
6
6
  EventObject,
7
7
  Lifecycle,
8
8
  MachineDef,
9
+ OnMap,
9
10
  ReadsMap,
10
11
  SelectorMap,
11
12
  StateNode,
@@ -34,6 +35,13 @@ export interface DefineMachineConfig<
34
35
  initial: NoInfer<S>
35
36
  /** Transitions see `helpers.reads` typed as `ReadsMap<TReads>`. */
36
37
  states: Record<S, StateNode<C, E, S, ReadsMap<TReads>>>
38
+ /** Machine-level transitions: handlers that apply in ANY state, consulted only
39
+ * when the current state does not declare the event (a state-scoped handler
40
+ * always wins). The home for a completion event whose handling must not depend
41
+ * on an unrelated machine-wide state — e.g. a per-record save completing while
42
+ * the machine is busy reloading the collection. Without this, such a
43
+ * completion is dropped wherever the current state has no handler for it. */
44
+ on?: OnMap<C, E, S, ReadsMap<TReads>>
37
45
  selectors?: Sel
38
46
  /** APP machines only: persist this machine's snapshot through the AppStore
39
47
  * so its state survives restarts. Opt-in — caches and other
@@ -106,6 +114,7 @@ export function defineMachine<
106
114
  capabilities: computeCapabilities(reads),
107
115
  initial: config.initial,
108
116
  states: config.states as Record<string, StateNode<C, E, S>>,
117
+ on: config.on as OnMap<C, E, S> | undefined,
109
118
  context: config.context,
110
119
  __context: undefined as unknown as C,
111
120
  __event: undefined as unknown as E,
@@ -252,6 +252,9 @@ export interface MachineDef<
252
252
  * reads typing that matters is enforced at the authoring callsite) and the
253
253
  * initial context. */
254
254
  states: Record<string, StateNode<C, E, S>>
255
+ /** Machine-level transitions — handlers consulted when the current state does
256
+ * not declare the event (state-scoped handlers take precedence). */
257
+ on?: OnMap<C, E, S>
255
258
  context: C
256
259
  /** Type-level carriers — never read at runtime. */
257
260
  readonly __context: C
@@ -6,7 +6,12 @@ import { scopedLogger } from './logger.ts'
6
6
  import type { MachineStore } from './machine-store.ts'
7
7
  import type { DiscoveredRoute } from './route-discovery.ts'
8
8
  import { buildRouteRequest } from './route-request.ts'
9
- import type { QueryRouteDefinition, QueryRouteResult, RouteContext } from './routing.ts'
9
+ import type {
10
+ QueryRouteDefinition,
11
+ QueryRouteHelpers,
12
+ QueryRouteResult,
13
+ RouteContext,
14
+ } from './routing.ts'
10
15
  import { getOrCreateSessionId } from './session.ts'
11
16
  import { withSessionLock } from './session-lock.ts'
12
17
  import { SessionRuntime } from './session-runtime.ts'
@@ -74,7 +79,11 @@ export async function runQueryRoute(
74
79
 
75
80
  let result: QueryRouteResult
76
81
  try {
77
- result = await route.handler(request, { machines })
82
+ // The map is built dynamically by machine name; the typed ReadsMap
83
+ // guarantee is for the user's handler, recovered at their call site.
84
+ result = await route.handler(request, {
85
+ machines: machines as QueryRouteHelpers['machines'],
86
+ })
78
87
  } catch (err) {
79
88
  queryLog.error({ err: String(err), path: c.req.path }, 'data route handler threw')
80
89
  return c.text('Internal Server Error', 500)
@@ -1,6 +1,6 @@
1
1
  import type { HtmlFragment } from '../template/types.ts'
2
2
  import type { WireEnvelope } from '../wire/index.ts'
3
- import type { AnyMachineDef, EventOf } from './define-machine.ts'
3
+ import type { AnyMachineDef, EventOf, ReadsMap } from './define-machine.ts'
4
4
 
5
5
  /** Machine context passed to a route's render function. Keyed by machine name. */
6
6
  export type RouteContext = Record<string, unknown>
@@ -170,7 +170,7 @@ export interface DefineApiRouteConfig<TReads extends ReadonlyArray<AnyMachineDef
170
170
  /** `method: 'GET'` discriminates: with it, the handler is a read-only data
171
171
  * route typed with `{ machines }`; without it, a command route typed with
172
172
  * `{ dispatch, rotateSession }` — exactly as before. */
173
- export function defineApiRoute<TReads extends ReadonlyArray<AnyMachineDef>>(
173
+ export function defineApiRoute<TReads extends ReadonlyArray<AnyMachineDef> = readonly []>(
174
174
  config: DefineQueryRouteConfig<TReads>,
175
175
  ): QueryRouteDefinition
176
176
  export function defineApiRoute<TReads extends ReadonlyArray<AnyMachineDef>>(
@@ -209,10 +209,15 @@ export function isStatorApiRoute(v: unknown): v is ApiRouteDefinition {
209
209
  * there is no `dispatch` here, and that structural absence is what makes
210
210
  * handler reads safe — a handler that cannot dispatch has nothing to
211
211
  * interleave with effect completions or other sessions' commits. */
212
- export interface QueryRouteHelpers {
212
+ export interface QueryRouteHelpers<
213
+ TReads extends ReadonlyArray<AnyMachineDef> = ReadonlyArray<AnyMachineDef>,
214
+ > {
213
215
  /** Read proxies keyed by machine name — the same shape a page's render
214
- * context uses (selector + context reads; no send). */
215
- machines: RouteContext
216
+ * context uses (selector + context reads; no send). Typed off the route's
217
+ * `reads:` tuple (`ReadsMap`), so `machines.SitesMachine` is a real proxy
218
+ * and a mistyped name is a compile error rather than `unknown`. The default
219
+ * keeps the erased runtime definition loose. */
220
+ machines: ReadsMap<TReads>
216
221
  }
217
222
 
218
223
  /** What a data GET handler may return: a raw `Response` (passed through
@@ -231,14 +236,14 @@ export interface QueryRouteDefinition {
231
236
  ) => Promise<QueryRouteResult> | QueryRouteResult
232
237
  }
233
238
 
234
- export interface DefineQueryRouteConfig<TReads extends ReadonlyArray<AnyMachineDef>> {
239
+ export interface DefineQueryRouteConfig<TReads extends ReadonlyArray<AnyMachineDef> = readonly []> {
235
240
  /** The capability discriminant: `'GET'` declares a read-only data route.
236
241
  * Discovery cross-checks it against the export name. */
237
242
  method: 'GET'
238
243
  reads?: TReads
239
244
  handler: (
240
245
  request: RouteRequest,
241
- helpers: QueryRouteHelpers,
246
+ helpers: QueryRouteHelpers<TReads>,
242
247
  ) => Promise<QueryRouteResult> | QueryRouteResult
243
248
  }
244
249
 
@@ -104,9 +104,9 @@ export function when<T>(cond: T | ReadResult<T>, fn: () => HtmlFragment): Branch
104
104
  })
105
105
  }
106
106
 
107
- // `display: contents` so the wrapper span doesn't break the surrounding
108
- // element's layout (grid/flex) — see each.ts for the same rationale.
109
- const html = `<span data-slot="${slotId}" data-branch="true" style="display:contents">${innerHtml}</span>`
107
+ // Comment markers delimit the region (no injected node, valid in restricted
108
+ // parents) — see each.ts for the rationale.
109
+ const html = `<!--s:${slotId}-->${innerHtml}<!--/s:${slotId}-->`
110
110
  return { __isBranchResult: true, html, slotId }
111
111
  }
112
112
 
@@ -160,8 +160,8 @@ export function match<TKey extends string>(
160
160
  })
161
161
  }
162
162
 
163
- // `display: contents` so the wrapper span doesn't break the surrounding
164
- // element's layout (grid/flex) — see each.ts for the same rationale.
165
- const html = `<span data-slot="${slotId}" data-branch="true" style="display:contents">${innerHtml}</span>`
163
+ // Comment markers delimit the region (no injected node, valid in restricted
164
+ // parents) — see each.ts for the rationale.
165
+ const html = `<!--s:${slotId}-->${innerHtml}<!--/s:${slotId}-->`
166
166
  return { __isBranchResult: true, html, slotId }
167
167
  }
@@ -32,9 +32,9 @@ export function deferSentinel(slotId: SlotId): string {
32
32
  }
33
33
 
34
34
  function deferPlaceholder(slotId: SlotId, inner: string): DeferResult {
35
- // `display: contents` so the wrapper span doesn't disturb the surrounding
36
- // element's layout — same rationale as each()/when().
37
- const html = `<span data-slot="${slotId}" data-defer="true" style="display:contents">${inner}</span>`
35
+ // Comment markers delimit the region (no injected node, valid in restricted
36
+ // parents) — same rationale as each()/when().
37
+ const html = `<!--s:${slotId}-->${inner}<!--/s:${slotId}-->`
38
38
  return { __isDeferResult: true, html, slotId }
39
39
  }
40
40
 
@@ -136,12 +136,11 @@ export function each<T>(
136
136
  }
137
137
  }
138
138
 
139
- // `display: contents` so the wrapper span is invisible to layout — its
140
- // children become layout-children of the surrounding element. Without
141
- // this, putting `each()` inside a CSS grid or flex container would
142
- // collapse all items into a single grid/flex cell. The span itself stays
143
- // in the DOM as the patch addressing target.
144
- const html = `<span data-slot="${slotId}" data-list="true" style="display:contents">${innerHtml}</span>`
139
+ // Comment markers delimit the region instead of a wrapper element: they inject
140
+ // no node into the user's DOM (no layout box, no CSS sibling/child selector
141
+ // pollution) and are valid in restricted parents like <table>/<tbody> where a
142
+ // <span> is foster-parented out. The client addresses the list by its markers.
143
+ const html = `<!--s:${slotId}-->${innerHtml}<!--/s:${slotId}-->`
145
144
  return { __isEachResult: true, html, slotId }
146
145
  }
147
146
 
package/src/wire/apply.ts CHANGED
@@ -9,6 +9,14 @@
9
9
  * path applied it.
10
10
  */
11
11
  import type { Directive, Patch } from './index.ts'
12
+ import {
13
+ elementAt,
14
+ findRegion,
15
+ insertAt,
16
+ moveWithin,
17
+ removeAt,
18
+ replaceRegion,
19
+ } from './region-apply.ts'
12
20
  import { isSafeNavigationUrl } from './safe-url.ts'
13
21
 
14
22
  function emit(name: string, detail: unknown): void {
@@ -22,44 +30,79 @@ export function resolveTarget(target: { kind: 'slot' | 'element'; id: string }):
22
30
  return document.querySelector(`[data-stator-id="${target.id}"]`)
23
31
  }
24
32
 
33
+ function warnMissing(target: { kind: string; id: string }): void {
34
+ // A missing target means this DOM diverged from server truth (stale non-live
35
+ // page, or client code removed server-owned nodes). Skipping is safe — arm/
36
+ // key-scoped ids guarantee a patch can't land on the wrong content — but the
37
+ // divergence is worth surfacing.
38
+ console.warn(`stator: patch target ${target.kind} "${target.id}" not in DOM — skipped`)
39
+ }
40
+
25
41
  export function applyPatches(patches: Patch[]): void {
42
+ // Resolve each region's marker pair once per batch. The markers are stable
43
+ // anchors — ops mutate only the nodes between them — so a pair found for one
44
+ // op stays valid for the rest of the batch (keyed lists emit a run of ops on
45
+ // the same slot). null caches a genuine miss.
46
+ const regions = new Map<string, { start: Comment; end: Comment } | null>()
47
+ const regionFor = (id: string): { start: Comment; end: Comment } | null => {
48
+ let r = regions.get(id)
49
+ if (r === undefined) {
50
+ r = findRegion(document, id)
51
+ regions.set(id, r)
52
+ }
53
+ return r
54
+ }
55
+
26
56
  for (const patch of patches) {
57
+ const element = applyPatch(patch, regionFor)
58
+ emit('stator:patch-applied', { patch, element, timestamp: Date.now() })
59
+ }
60
+ }
61
+
62
+ /** Apply one patch. Returns the element the inspector should flash (or null when
63
+ * the target is missing or the change left no single node to highlight). */
64
+ function applyPatch(
65
+ patch: Patch,
66
+ regionFor: (id: string) => { start: Comment; end: Comment } | null,
67
+ ): Element | null {
68
+ // `text`/`attr` address a single node: a text-binding `data-slot` span or a
69
+ // `data-stator-id` element.
70
+ if (patch.op === 'text' || patch.op === 'attr') {
27
71
  const element = resolveTarget(patch.target)
28
72
  if (!element) {
29
- // A missing target means this DOM diverged from server truth (stale
30
- // non-live page, or client code removed server-owned nodes). Skipping
31
- // is safe — arm/key-scoped slot ids guarantee a patch can't land on
32
- // the wrong content — but the divergence is worth surfacing.
33
- console.warn(
34
- `stator: patch target ${patch.target.kind} "${patch.target.id}" not in DOM — skipped`,
35
- )
36
- continue
73
+ warnMissing(patch.target)
74
+ return null
37
75
  }
38
- if (element) {
39
- if (patch.op === 'text') element.textContent = patch.value
40
- else if (patch.op === 'html') element.innerHTML = patch.value
41
- else if (patch.op === 'attr') {
42
- if (patch.value === null) element.removeAttribute(patch.name)
43
- else element.setAttribute(patch.name, patch.value)
44
- }
45
- // Keyed-list ops address element children by index, sequentially: each
46
- // op sees the DOM as left by the previous one (see wire/index.ts).
47
- else if (patch.op === 'insert') {
48
- const tpl = document.createElement('template')
49
- tpl.innerHTML = patch.value
50
- element.insertBefore(tpl.content, element.children[patch.index] ?? null)
51
- } else if (patch.op === 'remove') {
52
- element.children[patch.index]?.remove()
53
- } else if (patch.op === 'move') {
54
- const node = element.children[patch.from]
55
- if (node) {
56
- node.remove()
57
- element.insertBefore(node, element.children[patch.to] ?? null)
58
- }
59
- }
60
- }
61
- emit('stator:patch-applied', { patch, element, timestamp: Date.now() })
76
+ if (patch.op === 'text') element.textContent = patch.value
77
+ else if (patch.value === null) element.removeAttribute(patch.name)
78
+ else element.setAttribute(patch.name, patch.value)
79
+ return element
80
+ }
81
+
82
+ // `html`/`insert`/`remove`/`move` address a REGION delimited by comment
83
+ // markers (`<!--s:id-->…<!--/s:id-->`). Keyed-list ops address element
84
+ // children by index, sequentially: each op sees the DOM as left by the
85
+ // previous one (see wire/index.ts).
86
+ const region = regionFor(patch.target.id)
87
+ if (!region) {
88
+ warnMissing(patch.target)
89
+ return null
90
+ }
91
+ const { start, end } = region
92
+ if (patch.op === 'html') {
93
+ replaceRegion(start, end, patch.value)
94
+ return start.parentElement
95
+ }
96
+ if (patch.op === 'insert') {
97
+ insertAt(start, end, patch.index, patch.value)
98
+ return elementAt(start, end, patch.index)
99
+ }
100
+ if (patch.op === 'remove') {
101
+ removeAt(start, end, patch.index)
102
+ return start.parentElement
62
103
  }
104
+ moveWithin(start, end, patch.from, patch.to)
105
+ return elementAt(start, end, patch.to)
63
106
  }
64
107
 
65
108
  export function applyDirectives(directives: Directive[]): void {
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Comment-marker region boundaries. A reactive region (`each`/`when`/`match`/
3
+ * `defer`) is delimited on the client by a pair of HTML comments
4
+ * `<!--s:ID-->…<!--/s:ID-->` instead of a wrapper element, so the framework
5
+ * injects no node into the user's authored DOM — comments are valid anywhere
6
+ * (including in `<table>`/`<tbody>` where a `<span>` is foster-parented out) and
7
+ * do not participate in the CSS sibling/child selector graph.
8
+ *
9
+ * A region is the run of nodes strictly between its two markers, sharing the
10
+ * markers' parent. Keyed-list ops (insert/remove/move) address the region's
11
+ * ELEMENT children by index — the same contract as the old wrapper's
12
+ * `element.children[i]`, just counted between markers instead of inside a span.
13
+ */
14
+
15
+ export const startTag = (id: string): string => `s:${id}`
16
+ export const endTag = (id: string): string => `/s:${id}`
17
+
18
+ /** Parse a trusted HTML fragment through a `<template>`, whose "in template"
19
+ * insertion mode preserves table-context children (`<tr>`, `<td>`, `<option>`)
20
+ * that a plain element's `innerHTML` would drop. */
21
+ export function parseFragment(html: string): DocumentFragment {
22
+ const tpl = document.createElement('template')
23
+ tpl.innerHTML = html
24
+ return tpl.content
25
+ }
26
+
27
+ /** Locate a region's marker pair by scanning comment nodes under `root`.
28
+ * (Prototype: linear scan. Production adds a hydration-time index keyed by id.) */
29
+ export function findRegion(root: Node, id: string): { start: Comment; end: Comment } | null {
30
+ const s = startTag(id)
31
+ const e = endTag(id)
32
+ let start: Comment | null = null
33
+ const walk = (node: Node): Comment | null => {
34
+ for (let n = node.firstChild; n; n = n.nextSibling) {
35
+ if (n.nodeType === 8 /* Comment */) {
36
+ const data = (n as Comment).data
37
+ if (data === s) start = n as Comment
38
+ else if (data === e && start) return n as Comment
39
+ }
40
+ const found = walk(n)
41
+ if (found) return found
42
+ }
43
+ return null
44
+ }
45
+ const end = walk(root)
46
+ return start && end ? { start, end } : null
47
+ }
48
+
49
+ /** The i-th ELEMENT between the markers (mirrors `element.children[i]`); text
50
+ * and nested-region comment nodes are skipped. Null when out of range. */
51
+ export function elementAt(start: Comment, end: Comment, index: number): Element | null {
52
+ let i = 0
53
+ for (let n = start.nextSibling; n && n !== end; n = n.nextSibling) {
54
+ if (n.nodeType === 1 /* Element */) {
55
+ if (i === index) return n as Element
56
+ i++
57
+ }
58
+ }
59
+ return null
60
+ }
61
+
62
+ /** Count element children in the region. */
63
+ export function elementCount(start: Comment, end: Comment): number {
64
+ let i = 0
65
+ for (let n = start.nextSibling; n && n !== end; n = n.nextSibling) {
66
+ if (n.nodeType === 1) i++
67
+ }
68
+ return i
69
+ }
70
+
71
+ /** Insert `html` so its (single-root) element becomes element-index `index`
72
+ * within the region — before the element currently there, or before the end
73
+ * marker when appending. */
74
+ export function insertAt(start: Comment, end: Comment, index: number, html: string): void {
75
+ const ref = elementAt(start, end, index) ?? end
76
+ ref.parentNode?.insertBefore(parseFragment(html), ref)
77
+ }
78
+
79
+ /** Remove the element at `index`. */
80
+ export function removeAt(start: Comment, end: Comment, index: number): void {
81
+ elementAt(start, end, index)?.remove()
82
+ }
83
+
84
+ /** Move the element from `from` to `to`. `to` is the index in the list AFTER the
85
+ * removal — matching the wire contract's sequential-op replay. */
86
+ export function moveWithin(start: Comment, end: Comment, from: number, to: number): void {
87
+ const node = elementAt(start, end, from)
88
+ if (!node) return
89
+ node.remove()
90
+ const ref = elementAt(start, end, to) ?? end
91
+ ref.parentNode?.insertBefore(node, ref)
92
+ }
93
+
94
+ /** Replace the whole region body (the `html` op) — remove everything between the
95
+ * markers, then insert the freshly-parsed content before the end marker. */
96
+ export function replaceRegion(start: Comment, end: Comment, html: string): void {
97
+ for (let n = start.nextSibling; n && n !== end; ) {
98
+ const next = n.nextSibling
99
+ n.remove()
100
+ n = next
101
+ }
102
+ end.parentNode?.insertBefore(parseFragment(html), end)
103
+ }