@statorjs/stator 1.3.0 → 1.4.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.3.0",
3
+ "version": "1.4.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,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.3.0"
91
+ "@statorjs/stator": "1.4.0"
92
92
  },
93
93
  "scripts": {
94
94
  "typecheck": "tsc --noEmit",
@@ -0,0 +1,6 @@
1
+ // `.css` files are imported as text (esbuild `.css: 'text'` loader in
2
+ // `bundleInspector()`), so the default export is the stylesheet source string.
3
+ declare module '*.css' {
4
+ const css: string
5
+ export default css
6
+ }
@@ -0,0 +1,50 @@
1
+ /* Stator dev inspector — element-flash styles.
2
+ *
3
+ * These decorate APP elements (the patched nodes in the page), so they cannot
4
+ * live in the widget's shadow root — they're injected document-level as a
5
+ * constructable stylesheet in `@layer stator-inspector`, deliberately the
6
+ * lowest-priority author layer. An app's own styles are unlayered, and
7
+ * unlayered author styles beat every layer, so the app ALWAYS wins: the flash
8
+ * can never override the page it is inspecting.
9
+ *
10
+ * Bundled into the inspector asset as text by `bundleInspector()`'s esbuild
11
+ * `.css: 'text'` loader; there is no separate stylesheet request.
12
+ */
13
+ @layer stator-inspector {
14
+ /* Highlight the changed element with an OUTLINE only — never animate its
15
+ background-color. A CSS animation's background-color overrides the element's
16
+ own background (even an .active state) for the animation's full duration, so
17
+ flashing background would MASK the very change it's meant to highlight: a
18
+ patch that lights up a toggle button's background wouldn't visibly settle
19
+ until the 1.2s flash cleared. Outline is painted outside the box and masks
20
+ nothing — and unlike background, no app is likely to animate it. */
21
+ .stator-flash {
22
+ outline-style: solid;
23
+ outline-offset: 3px;
24
+ animation: stator-flash 1200ms ease-out forwards;
25
+ border-radius: 2px;
26
+ }
27
+ @keyframes stator-flash {
28
+ 0% {
29
+ outline-width: 3px;
30
+ outline-color: var(--flash-color, dodgerblue);
31
+ }
32
+ 30% {
33
+ outline-width: 3px;
34
+ outline-color: var(--flash-color, dodgerblue);
35
+ }
36
+ 100% {
37
+ outline-width: 0;
38
+ outline-color: transparent;
39
+ }
40
+ }
41
+ .stator-flash--text {
42
+ --flash-color: #3b82f6;
43
+ }
44
+ .stator-flash--attr {
45
+ --flash-color: #a855f7;
46
+ }
47
+ .stator-flash--html {
48
+ --flash-color: #14b8a6;
49
+ }
50
+ }
@@ -0,0 +1,196 @@
1
+ /* Stator dev inspector — widget (toolbar + drawer) styles.
2
+ *
3
+ * Attached by inspector.ts to the <stator-inspector> custom element's SHADOW
4
+ * ROOT via adoptedStyleSheets. Shadow isolation cuts both ways: the app's
5
+ * global element selectors (todomvc's bare `button` reset was the shipped
6
+ * case) can't restyle the widget, and the widget's styles provably can't
7
+ * touch the page. No `@layer` needed in here — nothing else competes inside
8
+ * the shadow tree. The element flash decorates APP nodes and therefore lives
9
+ * document-level instead — see inspector-flash.css.
10
+ *
11
+ * Bundled into the inspector asset as text by `bundleInspector()`'s esbuild
12
+ * `.css: 'text'` loader; there is no separate stylesheet request.
13
+ */
14
+ :host {
15
+ display: block;
16
+ position: fixed;
17
+ bottom: 0;
18
+ left: 0;
19
+ right: 0;
20
+ z-index: 2147483000;
21
+ pointer-events: none;
22
+ }
23
+ .stator-inspector-toggle,
24
+ .stator-inspector-drawer {
25
+ pointer-events: auto;
26
+ }
27
+ .stator-inspector-drawer[hidden],
28
+ .stator-inspector-toggle[hidden] {
29
+ display: none;
30
+ }
31
+ .stator-inspector-toggle {
32
+ position: fixed;
33
+ bottom: 1rem;
34
+ right: 1rem;
35
+ background: #1a1a1a;
36
+ color: #d0d0d0;
37
+ border: 1px solid #2a2a2a;
38
+ padding: 0.45rem 0.85rem;
39
+ border-radius: 999px;
40
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
41
+ font-size: 0.82rem;
42
+ cursor: pointer;
43
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
44
+ display: inline-flex;
45
+ align-items: center;
46
+ gap: 0.4rem;
47
+ }
48
+ .stator-inspector-toggle:hover {
49
+ background: #232323;
50
+ color: #f0f0f0;
51
+ }
52
+ .stator-inspector-drawer {
53
+ background: #141414;
54
+ color: #d0d0d0;
55
+ border-top: 1px solid #2a2a2a;
56
+ max-height: 240px;
57
+ display: flex;
58
+ flex-direction: column;
59
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
60
+ font-size: 0.78rem;
61
+ box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.25);
62
+ }
63
+ .stator-inspector-header {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 1rem;
67
+ padding: 0.5rem 0.9rem;
68
+ background: #1a1a1a;
69
+ border-bottom: 1px solid #262626;
70
+ flex-shrink: 0;
71
+ }
72
+ .stator-inspector-title {
73
+ display: inline-flex;
74
+ align-items: center;
75
+ gap: 0.5rem;
76
+ color: #f5f5f5;
77
+ font-weight: 600;
78
+ letter-spacing: 0.02em;
79
+ }
80
+ .stator-inspector-dot {
81
+ width: 8px;
82
+ height: 8px;
83
+ border-radius: 50%;
84
+ background: #6a9955;
85
+ box-shadow: 0 0 0 2px rgba(106, 153, 85, 0.18);
86
+ }
87
+ .stator-inspector-legend {
88
+ display: inline-flex;
89
+ gap: 0.75rem;
90
+ color: #888;
91
+ }
92
+ .stator-inspector-key--up {
93
+ color: #dcdcaa;
94
+ }
95
+ .stator-inspector-key--down {
96
+ color: #9cdcfe;
97
+ }
98
+ .stator-inspector-header button {
99
+ background: transparent;
100
+ border: 1px solid #333;
101
+ color: #aaa;
102
+ padding: 0.15rem 0.55rem;
103
+ font-family: inherit;
104
+ font-size: 0.75rem;
105
+ border-radius: 4px;
106
+ cursor: pointer;
107
+ }
108
+ .stator-inspector-header button:hover {
109
+ background: #232323;
110
+ color: #f0f0f0;
111
+ }
112
+ /* higher specificity than `.stator-inspector-header button` so it wins without !important */
113
+ .stator-inspector-header button.stator-inspector-close {
114
+ margin-left: auto;
115
+ font-size: 1rem;
116
+ line-height: 1;
117
+ padding: 0.05rem 0.5rem;
118
+ }
119
+ .stator-inspector-body {
120
+ flex: 1;
121
+ overflow-y: auto;
122
+ padding: 0;
123
+ }
124
+ .stator-inspector-empty {
125
+ margin: 0;
126
+ padding: 1rem 0.9rem;
127
+ color: #6a6a6a;
128
+ font-style: italic;
129
+ }
130
+ .stator-inspector-row {
131
+ border-bottom: 1px solid #1f1f1f;
132
+ }
133
+ .stator-inspector-row:last-child {
134
+ border-bottom: none;
135
+ }
136
+ .stator-inspector-summary {
137
+ display: grid;
138
+ grid-template-columns: 90px 16px 130px 1fr auto;
139
+ align-items: baseline;
140
+ gap: 0.75rem;
141
+ padding: 0.35rem 0.9rem;
142
+ cursor: pointer;
143
+ user-select: none;
144
+ }
145
+ .stator-inspector-summary:hover {
146
+ background: #1c1c1c;
147
+ }
148
+ .stator-inspector-time {
149
+ color: #6a6a6a;
150
+ font-variant-numeric: tabular-nums;
151
+ }
152
+ .stator-inspector-arrow {
153
+ text-align: center;
154
+ font-weight: 700;
155
+ }
156
+ .stator-inspector-row--up .stator-inspector-arrow {
157
+ color: #dcdcaa;
158
+ }
159
+ .stator-inspector-row--down .stator-inspector-arrow {
160
+ color: #9cdcfe;
161
+ }
162
+ .stator-inspector-machine {
163
+ color: #c586c0;
164
+ font-weight: 500;
165
+ }
166
+ .stator-inspector-row--down .stator-inspector-machine {
167
+ color: #9cdcfe;
168
+ }
169
+ .stator-inspector-event-type {
170
+ color: #4ec9b0;
171
+ font-weight: 500;
172
+ }
173
+ .stator-inspector-params {
174
+ color: #888;
175
+ text-align: right;
176
+ overflow: hidden;
177
+ text-overflow: ellipsis;
178
+ white-space: nowrap;
179
+ font-variant-numeric: tabular-nums;
180
+ }
181
+ .stator-inspector-detail {
182
+ margin: 0;
183
+ padding: 0.5rem 0.9rem 0.75rem 2.5rem;
184
+ background: #0e0e0e;
185
+ color: #cfcfcf;
186
+ font-size: 0.74rem;
187
+ white-space: pre-wrap;
188
+ word-break: break-word;
189
+ border-top: 1px dashed #2a2a2a;
190
+ }
191
+
192
+ @media (max-height: 600px) {
193
+ .stator-inspector-drawer {
194
+ max-height: 50vh;
195
+ }
196
+ }
@@ -8,72 +8,17 @@
8
8
  *
9
9
  * It depends on nothing but that event contract — the same surface any external
10
10
  * devtool would use. Self-contained: it injects its own styles.
11
+ *
12
+ * The widget is a `<stator-inspector>` custom element with a shadow root
13
+ * (the Astro-dev-toolbar / vite-error-overlay pattern): the inspected app's
14
+ * global element selectors can't restyle the toolbar, and the toolbar's styles
15
+ * provably can't touch the page. Only the element-flash styles live at
16
+ * document level (they decorate app nodes), in the lowest-priority
17
+ * `@layer stator-inspector` so the app always wins over them.
11
18
  */
12
19
 
13
- const STYLES = `
14
- .stator-inspector { position: fixed; bottom: 0; left: 0; right: 0; z-index: 2147483000; pointer-events: none; }
15
- .stator-inspector > * { pointer-events: auto; }
16
- .stator-inspector-drawer[hidden], .stator-inspector-toggle[hidden] { display: none; }
17
- .stator-inspector-toggle {
18
- position: fixed; bottom: 1rem; right: 1rem; background: #1a1a1a; color: #d0d0d0;
19
- border: 1px solid #2a2a2a; padding: 0.45rem 0.85rem; border-radius: 999px;
20
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.82rem;
21
- cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.18); display: inline-flex;
22
- align-items: center; gap: 0.4rem;
23
- }
24
- .stator-inspector-toggle:hover { background: #232323; color: #f0f0f0; }
25
- .stator-inspector-drawer {
26
- background: #141414; color: #d0d0d0; border-top: 1px solid #2a2a2a; max-height: 240px;
27
- display: flex; flex-direction: column; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
28
- font-size: 0.78rem; box-shadow: 0 -2px 12px rgba(0,0,0,0.25);
29
- }
30
- .stator-inspector-header {
31
- display: flex; align-items: center; gap: 1rem; padding: 0.5rem 0.9rem;
32
- background: #1a1a1a; border-bottom: 1px solid #262626; flex-shrink: 0;
33
- }
34
- .stator-inspector-title { display: inline-flex; align-items: center; gap: 0.5rem; color: #f5f5f5; font-weight: 600; letter-spacing: 0.02em; }
35
- .stator-inspector-dot { width: 8px; height: 8px; border-radius: 50%; background: #6a9955; box-shadow: 0 0 0 2px rgba(106,153,85,0.18); }
36
- .stator-inspector-legend { display: inline-flex; gap: 0.75rem; color: #888; }
37
- .stator-inspector-key--up { color: #dcdcaa; }
38
- .stator-inspector-key--down { color: #9cdcfe; }
39
- .stator-inspector-header button {
40
- background: transparent; border: 1px solid #333; color: #aaa; padding: 0.15rem 0.55rem;
41
- font-family: inherit; font-size: 0.75rem; border-radius: 4px; cursor: pointer;
42
- }
43
- .stator-inspector-header button:hover { background: #232323; color: #f0f0f0; }
44
- .stator-inspector-close { margin-left: auto; font-size: 1rem !important; line-height: 1; padding: 0.05rem 0.5rem !important; }
45
- .stator-inspector-body { flex: 1; overflow-y: auto; padding: 0; }
46
- .stator-inspector-empty { margin: 0; padding: 1rem 0.9rem; color: #6a6a6a; font-style: italic; }
47
- .stator-inspector-row { border-bottom: 1px solid #1f1f1f; }
48
- .stator-inspector-row:last-child { border-bottom: none; }
49
- .stator-inspector-summary {
50
- display: grid; grid-template-columns: 90px 16px 130px 1fr auto; align-items: baseline;
51
- gap: 0.75rem; padding: 0.35rem 0.9rem; cursor: pointer; user-select: none;
52
- }
53
- .stator-inspector-summary:hover { background: #1c1c1c; }
54
- .stator-inspector-time { color: #6a6a6a; font-variant-numeric: tabular-nums; }
55
- .stator-inspector-arrow { text-align: center; font-weight: 700; }
56
- .stator-inspector-row--up .stator-inspector-arrow { color: #dcdcaa; }
57
- .stator-inspector-row--down .stator-inspector-arrow { color: #9cdcfe; }
58
- .stator-inspector-machine { color: #c586c0; font-weight: 500; }
59
- .stator-inspector-row--down .stator-inspector-machine { color: #9cdcfe; }
60
- .stator-inspector-event-type { color: #4ec9b0; font-weight: 500; }
61
- .stator-inspector-params { color: #888; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
62
- .stator-inspector-detail {
63
- margin: 0; padding: 0.5rem 0.9rem 0.75rem 2.5rem; background: #0e0e0e; color: #cfcfcf;
64
- font-size: 0.74rem; white-space: pre-wrap; word-break: break-word; border-top: 1px dashed #2a2a2a;
65
- }
66
- .stator-flash { outline-style: solid; outline-offset: 3px; animation: stator-flash 1200ms ease-out forwards; border-radius: 2px; }
67
- @keyframes stator-flash {
68
- 0% { outline-width: 3px; outline-color: var(--flash-color, dodgerblue); background-color: var(--flash-bg, rgba(30,144,255,0.16)); }
69
- 30% { outline-width: 3px; outline-color: var(--flash-color, dodgerblue); background-color: var(--flash-bg, rgba(30,144,255,0.16)); }
70
- 100% { outline-width: 0; outline-color: transparent; background-color: transparent; }
71
- }
72
- .stator-flash--text { --flash-color: #3b82f6; --flash-bg: rgba(59,130,246,0.12); }
73
- .stator-flash--attr { --flash-color: #a855f7; --flash-bg: rgba(168,85,247,0.12); }
74
- .stator-flash--html { --flash-color: #14b8a6; --flash-bg: rgba(20,184,166,0.10); }
75
- @media (max-height: 600px) { .stator-inspector-drawer { max-height: 50vh; } }
76
- `
20
+ import inspectorCss from './inspector.css'
21
+ import flashCss from './inspector-flash.css'
77
22
 
78
23
  const STORAGE_KEY = 'stator:inspector:open'
79
24
  const MAX_ENTRIES = 40
@@ -113,13 +58,24 @@ function formatEventParams(event: Record<string, unknown>): string {
113
58
  }
114
59
 
115
60
  function mount(): void {
116
- const style = document.createElement('style')
117
- style.textContent = STYLES
118
- document.head.appendChild(style)
119
-
120
- const root = document.createElement('div')
121
- root.className = 'stator-inspector'
122
- root.innerHTML = `
61
+ // Flash styles decorate APP elements, so they can't live in the widget's
62
+ // shadow — they're document-level, in `@layer stator-inspector` (see
63
+ // inspector-flash.css) so the app's own (unlayered) styles always win.
64
+ const flashSheet = new CSSStyleSheet()
65
+ flashSheet.replaceSync(flashCss)
66
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, flashSheet]
67
+
68
+ // The widget itself: a custom element whose shadow root carries the markup
69
+ // and styles — isolated from the page's cascade in both directions.
70
+ if (!customElements.get('stator-inspector')) {
71
+ customElements.define('stator-inspector', class extends HTMLElement {})
72
+ }
73
+ const root = document.createElement('stator-inspector')
74
+ const shadow = root.attachShadow({ mode: 'open' })
75
+ const widgetSheet = new CSSStyleSheet()
76
+ widgetSheet.replaceSync(inspectorCss)
77
+ shadow.adoptedStyleSheets = [widgetSheet]
78
+ shadow.innerHTML = `
123
79
  <button class="stator-inspector-toggle" type="button" aria-label="Show stator inspector">
124
80
  <span aria-hidden="true">{ }</span> Inspect
125
81
  </button>
@@ -139,7 +95,7 @@ function mount(): void {
139
95
  </section>`
140
96
  document.body.appendChild(root)
141
97
 
142
- const q = (sel: string) => root.querySelector(sel) as HTMLElement
98
+ const q = (sel: string) => shadow.querySelector(sel) as HTMLElement
143
99
  const drawer = q('.stator-inspector-drawer')
144
100
  const body = q('.stator-inspector-body')
145
101
  const toggle = q('.stator-inspector-toggle')
@@ -219,6 +175,9 @@ function mount(): void {
219
175
  window.addEventListener('stator:patch-applied', (e: Event) => {
220
176
  const { patch, element } = (e as CustomEvent).detail
221
177
  if (!element) return
178
+ // Only flash while the drawer is open — the flash is an inspection aid, not
179
+ // ambient page decoration; a closed inspector shouldn't touch the app.
180
+ if (drawer.hidden) return
222
181
  const opClass = `stator-flash--${patch.op}`
223
182
  ;(element as HTMLElement).classList.add('stator-flash', opClass)
224
183
  window.setTimeout(() => {
@@ -69,12 +69,16 @@ function initLiveChannel(): void {
69
69
  return
70
70
  }
71
71
  if (data.patches) {
72
+ // SSE is server-pushed — no client round-trip to time — so report the
73
+ // apply duration (still the useful "how expensive was this update?").
74
+ const startedAt = performance.now()
75
+ applyPatches(data.patches)
72
76
  emit('stator:patches-received', {
73
77
  patches: data.patches,
74
78
  source: 'sse',
79
+ durationMs: Math.round(performance.now() - startedAt),
75
80
  timestamp: Date.now(),
76
81
  })
77
- applyPatches(data.patches)
78
82
  }
79
83
  if (data.directives && data.directives.length > 0) {
80
84
  applyDirectives(data.directives)
@@ -48,7 +48,7 @@ export interface CompileResult {
48
48
  }
49
49
 
50
50
  const PRIMITIVES_IMPORT =
51
- "import { html, read, each, when, match, on, classList, styleList } from '@statorjs/stator/template'"
51
+ "import { html, read, each, itemBind, when, match, defer, on, classList, styleList } from '@statorjs/stator/template'"
52
52
 
53
53
  export interface CompileOptions {
54
54
  /** Stable id for the component (file path). Used for the scope hash so the
@@ -186,11 +186,11 @@ function compileClient(
186
186
  const attrDecl = `{ ${[...cls.staticAttrs].map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')} }`
187
187
  const rootScope = ctx.scopeAttr ? ` data-s-${ctx.hash}` : ''
188
188
  const serverCode = [
189
- "import { html, read, each, when, match, on, classList, styleList, createHtmlFragment, clientShellAttrs } from '@statorjs/stator/template'",
189
+ "import { html, read, each, when, match, defer, on, classList, styleList, createHtmlFragment, clientShellAttrs } from '@statorjs/stator/template'",
190
190
  '',
191
191
  `export default function (props = {}) {`,
192
192
  ` const __inner = ${innerExpr}`,
193
- ` const __attrs = clientShellAttrs(props, ${attrDecl})`,
193
+ ` const __attrs = clientShellAttrs(props, ${attrDecl}, ${JSON.stringify(root.rootAttrs)})`,
194
194
  ` return createHtmlFragment(\`<${root.tag}\${__attrs}${rootScope}>\` + __inner.html + \`</${root.tag}>\`)`,
195
195
  '}',
196
196
  '',
@@ -214,9 +214,36 @@ function compileClient(
214
214
  }
215
215
  }
216
216
 
217
- /** Parse a client template, returning the custom-element root tag and the inner
218
- * source (its children). Enforces "root must be the custom element". */
219
- function extractClientRoot(template: string, file?: string): { tag: string; inner: string } {
217
+ /** Static attributes authored on a client component's ROOT element its own
218
+ * base `class`, `hidden`, ARIA, `data-*`. These are carried across the
219
+ * split-and-reassemble so a component can style/flag its own root; the use site
220
+ * merges them under its props (FINDINGS #4). Namespaced directives (class:list,
221
+ * on:) and expression-valued attrs are not static and are skipped here. */
222
+ function staticRootAttrs(
223
+ attrs: ts.JsxAttributes,
224
+ sf: ts.SourceFile,
225
+ ): Record<string, string | boolean> {
226
+ const out: Record<string, string | boolean> = {}
227
+ for (const attr of attrs.properties) {
228
+ if (!ts.isJsxAttribute(attr) || ts.isJsxNamespacedName(attr.name)) continue
229
+ const name = attr.name.getText(sf)
230
+ const init = attr.initializer
231
+ if (init === undefined) {
232
+ out[name] = true // valueless boolean attribute
233
+ } else if (ts.isStringLiteral(init)) {
234
+ out[name] = init.text
235
+ }
236
+ }
237
+ return out
238
+ }
239
+
240
+ /** Parse a client template, returning the custom-element root tag, its inner
241
+ * source (its children), and the root's own static attributes. Enforces "root
242
+ * must be the custom element". */
243
+ function extractClientRoot(
244
+ template: string,
245
+ file?: string,
246
+ ): { tag: string; inner: string; rootAttrs: Record<string, string | boolean> } {
220
247
  const wrapped = `const __t = (<>${template}</>);`
221
248
  const sf = ts.createSourceFile('t.tsx', wrapped, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
222
249
  let fragment: ts.JsxFragment | undefined
@@ -241,13 +268,13 @@ function extractClientRoot(template: string, file?: string): { tag: string; inne
241
268
  if (ts.isJsxSelfClosingElement(rootEl)) {
242
269
  const tag = rootEl.tagName.getText(sf)
243
270
  requireCustomRoot(tag, file)
244
- return { tag, inner: '' }
271
+ return { tag, inner: '', rootAttrs: staticRootAttrs(rootEl.attributes, sf) }
245
272
  }
246
273
  const el = rootEl as ts.JsxElement
247
274
  const tag = el.openingElement.tagName.getText(sf)
248
275
  requireCustomRoot(tag, file)
249
276
  const inner = wrapped.slice(el.openingElement.getEnd(), el.closingElement.getStart())
250
- return { tag, inner }
277
+ return { tag, inner, rootAttrs: staticRootAttrs(el.openingElement.attributes, sf) }
251
278
  }
252
279
 
253
280
  function requireCustomRoot(tag: string, file?: string): void {
Binary file
@@ -49,7 +49,7 @@ export interface VirtualCodeResult {
49
49
  // runtime hides a missing-import bug; a name in both collides with the
50
50
  // author's legitimate import (`raw` is NOT a runtime global — authors
51
51
  // import it — which is why it must not be in this list).
52
- const TEMPLATE_GLOBALS = ['read', 'each', 'when', 'match', 'on', 'classList', 'styleList']
52
+ const TEMPLATE_GLOBALS = ['read', 'each', 'when', 'match', 'defer', 'on', 'classList', 'styleList']
53
53
  const CLIENT_GLOBALS = [
54
54
  'StatorElement',
55
55
  'use',
@@ -222,7 +222,7 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
222
222
  return streamSSE(c, async (stream) => {
223
223
  const runtime = new SessionRuntime(sessionId, config.store)
224
224
  await runtime.loadGraph(route.reads)
225
- const { renderState } = renderRoute(route, routeKey, sessionId, runtime, request)
225
+ const { renderState } = await renderRoute(route, routeKey, sessionId, runtime, request)
226
226
  const conn = registerConnection({
227
227
  sessionId,
228
228
  clientId: c.req.query('client'),
@@ -320,7 +320,11 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
320
320
  await runtime.loadGraph([...route.reads, originDef])
321
321
  runtime.wireSubscriptions()
322
322
 
323
- const { renderState } = renderRoute(route, routeKey, sessionId, runtime, request)
323
+ // Baseline render for the diff runs UNDER the session lock — never
324
+ // resolve defer slots here (that would kick their I/O under the lock).
325
+ const { renderState } = await renderRoute(route, routeKey, sessionId, runtime, request, {
326
+ resolveDeferred: false,
327
+ })
324
328
 
325
329
  const touched = runtime.processEvent(body.machine, body.event)
326
330
 
@@ -417,6 +421,9 @@ async function bundleInspector(): Promise<string> {
417
421
  target: 'es2020',
418
422
  write: false,
419
423
  minify: false,
424
+ // The inspector imports its CSS as a text string (bundled inline, no
425
+ // separate stylesheet request).
426
+ loader: { '.css': 'text' },
420
427
  logLevel: 'silent',
421
428
  })
422
429
  cachedInspectorJs = result.outputFiles[0]!.text
@@ -457,7 +464,7 @@ async function handleGet(
457
464
  const runtime = new SessionRuntime(sessionId, store)
458
465
  try {
459
466
  await runtime.loadGraph(route.reads)
460
- const result = renderRoute(route, routeKey, sessionId, runtime, request)
467
+ const result = await renderRoute(route, routeKey, sessionId, runtime, request)
461
468
  let html = result.html
462
469
 
463
470
  const headHtml: string[] = []