@statorjs/stator 1.7.1 → 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 +3 -2
- package/src/compiler/lower.ts +0 -0
- package/src/engine/actor.ts +10 -4
- package/src/engine/define-machine.ts +9 -0
- package/src/engine/types.ts +3 -0
- package/src/template/conditional.ts +6 -6
- package/src/template/defer.ts +3 -3
- package/src/template/each.ts +5 -6
- package/src/wire/apply.ts +75 -32
- package/src/wire/region-apply.ts +103 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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
|
}
|
package/src/compiler/lower.ts
CHANGED
|
Binary file
|
package/src/engine/actor.ts
CHANGED
|
@@ -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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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,
|
package/src/engine/types.ts
CHANGED
|
@@ -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
|
|
@@ -104,9 +104,9 @@ export function when<T>(cond: T | ReadResult<T>, fn: () => HtmlFragment): Branch
|
|
|
104
104
|
})
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
const html =
|
|
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
|
-
//
|
|
164
|
-
//
|
|
165
|
-
const html =
|
|
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
|
}
|
package/src/template/defer.ts
CHANGED
|
@@ -32,9 +32,9 @@ export function deferSentinel(slotId: SlotId): string {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
function deferPlaceholder(slotId: SlotId, inner: string): DeferResult {
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
const html =
|
|
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
|
|
package/src/template/each.ts
CHANGED
|
@@ -136,12 +136,11 @@ export function each<T>(
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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 (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
+
}
|