sygnal 6.2.0 → 6.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +3 -1
  3. package/dist/ai.cjs.js +36 -0
  4. package/dist/ai.cjs.js.map +1 -1
  5. package/dist/ai.esm.js +1 -1
  6. package/dist/astro/index.cjs.js +69 -3
  7. package/dist/astro/index.cjs.js.map +1 -1
  8. package/dist/astro/index.mjs +69 -3
  9. package/dist/astro/index.mjs.map +1 -1
  10. package/dist/astro/server.cjs.js +3 -3
  11. package/dist/astro/server.cjs.js.map +1 -1
  12. package/dist/astro/server.mjs +3 -3
  13. package/dist/astro/server.mjs.map +1 -1
  14. package/dist/diagnostics.cjs.js +211 -11
  15. package/dist/diagnostics.cjs.js.map +1 -1
  16. package/dist/diagnostics.esm.js +211 -11
  17. package/dist/diagnostics.esm.js.map +1 -1
  18. package/dist/guide/agent.md +7 -1
  19. package/dist/guide/ai-cards.md +319 -0
  20. package/dist/guide/ai-chat.md +4 -0
  21. package/dist/guide/ai-compose.md +881 -0
  22. package/dist/guide/ai-decisions.md +16 -0
  23. package/dist/guide/http.md +1 -0
  24. package/dist/guide/mcp-apps.md +59 -4
  25. package/dist/index.cjs.js +5704 -350
  26. package/dist/index.cjs.js.map +1 -1
  27. package/dist/index.d.ts +647 -10
  28. package/dist/index.esm.js +5694 -350
  29. package/dist/index.esm.js.map +1 -1
  30. package/dist/sygnal.min.js +1 -1
  31. package/dist/sygnal.min.js.map +1 -1
  32. package/dist/vike/config/package.json +1 -1
  33. package/dist/vike/onRenderClient.cjs.js +3 -2
  34. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  35. package/dist/vike/onRenderClient.mjs +3 -2
  36. package/dist/vike/onRenderClient.mjs.map +1 -1
  37. package/dist/vite/plugin.cjs.js +69 -3
  38. package/dist/vite/plugin.cjs.js.map +1 -1
  39. package/dist/vite/plugin.mjs +69 -3
  40. package/dist/vite/plugin.mjs.map +1 -1
  41. package/llms.txt +7 -5
  42. package/package.json +2 -1
  43. package/src/ai.d.ts +642 -7
  44. package/src/ai.ts +6 -0
  45. package/src/collection.ts +2 -1
  46. package/src/core/instance.ts +6 -3
  47. package/src/core/markers/lazy.ts +7 -3
  48. package/src/core/markers/portal.ts +5 -2
  49. package/src/core/markers/suspense.ts +3 -2
  50. package/src/core/markers/transition.ts +6 -3
  51. package/src/core/registry.ts +9 -5
  52. package/src/cycle/dom/IsolateModule.ts +25 -22
  53. package/src/extra/ai/chat/behavior.ts +354 -81
  54. package/src/extra/ai/chat/driver.ts +7 -4
  55. package/src/extra/ai/compose/agent.ts +55 -0
  56. package/src/extra/ai/compose/ask.ts +147 -0
  57. package/src/extra/ai/compose/behavior.ts +183 -0
  58. package/src/extra/ai/compose/catalog.ts +211 -0
  59. package/src/extra/ai/compose/composed.ts +61 -0
  60. package/src/extra/ai/compose/decisions.ts +1649 -0
  61. package/src/extra/ai/compose/diag.ts +33 -0
  62. package/src/extra/ai/compose/index.ts +23 -0
  63. package/src/extra/ai/compose/layout.ts +441 -0
  64. package/src/extra/ai/compose/llm.ts +1120 -0
  65. package/src/extra/ai/compose/render.ts +159 -0
  66. package/src/extra/ai/compose/testing.ts +71 -0
  67. package/src/extra/ai/compose/types.ts +159 -0
  68. package/src/extra/ai/decide.ts +97 -1
  69. package/src/extra/ai/index.ts +6 -1
  70. package/src/extra/diagnostics/checks/dom.ts +10 -2
  71. package/src/extra/diagnostics/checks/next.ts +103 -3
  72. package/src/extra/diagnostics/checks/persist.ts +4 -2
  73. package/src/extra/diagnostics/checks/wiring.ts +14 -6
  74. package/src/extra/diagnostics/codes.ts +59 -0
  75. package/src/extra/fetchDriver.ts +58 -27
  76. package/src/extra/lens.ts +15 -0
  77. package/src/extra/pwa.ts +3 -1
  78. package/src/extra/reducers.ts +10 -4
  79. package/src/extra/ssr.ts +3 -3
  80. package/src/extra/testing.ts +2 -0
  81. package/src/extra/widget.ts +3 -3
  82. package/src/index.d.ts +50 -2
  83. package/src/index.ts +8 -0
  84. package/src/lazy.ts +3 -2
  85. package/src/portal.ts +5 -2
  86. package/src/pragma/index.ts +2 -1
  87. package/src/suspense.ts +5 -2
  88. package/src/switchable.ts +2 -1
  89. package/src/transition.ts +5 -2
  90. package/src/vike/onRenderClient.ts +3 -2
  91. package/src/vite/plugin.ts +63 -2
@@ -17,7 +17,8 @@
17
17
  * a Collection's viewTransitionName that isn't an identifier, the same name twice on the
18
18
  * page after a patch, G-460: on rendered elements), SYG612 (D173: a form
19
19
  * 6.0 removed, met at runtime; once per form and component, with a link to the migration
20
- * guide).
20
+ * guide), SYG453 (PLAN-9, D470: two children with one identity share one instance), SYG426
21
+ * (PLAN-9, D471: a lowercase <collection> / <switchable> tag, deprecated in 6.3.0).
21
22
  *
22
23
  * Only while diagnostics are on (the checks' own rule); production builds never load this.
23
24
  */
@@ -65,6 +66,18 @@ function removed(component: any, name: string, anchor: string, detail?: string):
65
66
  })
66
67
  }
67
68
 
69
+ /** SYG427: CHILD.select(x) with no component (an import of a name the module doesn't export) */
70
+ function notComponent(f: any, x: any): void {
71
+ if (!once(`SYG427:${f.name}`)) return
72
+ const got = x === null ? 'null' : typeof x == 'object' ? 'an object' : String(x)
73
+ devReport('SYG427', {
74
+ component: f.name,
75
+ message: `${f.name}'s intent calls CHILD.select(${got}): that is not a component, so it never emits`,
76
+ fix: "Pass the child component itself, and check its import: a name its module doesn't export is undefined (Vitest runs on; the browser fails to link the module). Export it: export function Child() { … }",
77
+ data: {got},
78
+ })
79
+ }
80
+
68
81
  /** the removed forms a definition shows (statics, view arity, model keys), once per function */
69
82
  let seenDefs = new WeakSet<object>()
70
83
  // G-332: resetChecks() forgets them (as the once() dedupe)
@@ -94,6 +107,85 @@ function removedTags(f: any, v: any, depth = 0): void {
94
107
  if (Array.isArray(kids)) for (const k of kids) removedTags(f, k, depth + 1)
95
108
  }
96
109
 
110
+ /**
111
+ * PLAN-9 SYG453 (D470): two children of one view with the same identity (the same component and
112
+ * `key`, else `id`; `sel::key` in the core) are one instance, rendered in both places. The core
113
+ * doesn't look (0 production bytes): in the owner's injected vnode that instance's output, a
114
+ * scoped root (`data.isolate` in the real DOM driver, a `___<scope>` class in the mock DOM), is
115
+ * the same object twice. Its name and key come from the child whose DOM source has that scope.
116
+ */
117
+ const scopeOfVnode = (n: any): string | undefined => {
118
+ const iso = n.data?.isolate
119
+ if (Array.isArray(iso)) return iso.length ? iso[iso.length - 1]?.scope : undefined
120
+ const m = typeof n.sel == 'string' && /\.___([^.]+)/.exec(n.sel)
121
+ return m ? m[1] : undefined
122
+ }
123
+ const scopeOfSource = (dom: any): string | undefined => {
124
+ const ns = dom?.namespace
125
+ if (Array.isArray(ns)) return ns[ns.length - 1]?.scope
126
+ const p = dom?._path
127
+ const m = Array.isArray(p) && /^\.___(.+)$/.exec(String(p[p.length - 1]))
128
+ return m ? m[1] : undefined
129
+ }
130
+ function sharedIdentity(iv: any, v: any): void {
131
+ const seen = new Set<any>(), dup = new Set<string>()
132
+ const walk = (n: any, d: number): void => {
133
+ if (!n || typeof n != 'object' || d > 300) return
134
+ const sc = scopeOfVnode(n)
135
+ if (sc !== undefined) {
136
+ if (seen.has(n)) { dup.add(sc); return }
137
+ seen.add(n)
138
+ }
139
+ const kids = n.children
140
+ if (Array.isArray(kids)) for (const k of kids) walk(k, d + 1)
141
+ }
142
+ walk(v, 0)
143
+ if (!dup.size) return
144
+ const owner = iv.name
145
+ for (const sc of dup) {
146
+ let child: any
147
+ try { child = iv.children().find((c: any) => scopeOfSource(c.sources?.DOM) === sc) } catch (_) { /* ignore */ }
148
+ const name = child ? child.name : 'a child component'
149
+ // the identity's own part of the child's uid (the owner's uid + '-' + its key, id or path)
150
+ const key = child && typeof child.uid == 'string' && child.uid.startsWith(iv.uid + '-') ? child.uid.slice(iv.uid.length + 1) : undefined
151
+ if (!once(`SYG453:${owner}:${name}:${key ?? sc}`)) continue
152
+ devReport('SYG453', {
153
+ component: owner,
154
+ message: `${owner} renders ${child ? `<${name}>` : name} in two places with the same identity${key !== undefined ? ` (key or id ${JSON.stringify(key)})` : ''}, so both places show one instance: it has one state, and the second tag's props replace the first's`,
155
+ fix: `Give each <${child ? name : 'Child'}> a different key (or id) among its parent's children, e.g. key={item.id}; a key on a component is its identity`,
156
+ data: {child: child ? name : undefined, key},
157
+ })
158
+ }
159
+ }
160
+
161
+ /**
162
+ * PLAN-9 SYG426 (D471): the lowercase host tags, `<collection>` / `<switchable>` (JSX string tags,
163
+ * or `h('collection')`), deprecated in 6.3.0; the next major removes them (and registers the hosts
164
+ * on first use, as Portal). The core doesn't look (0 production bytes): the instance's template
165
+ * keeps a host marker after reconcile (only `pres` markers are rewritten), and the imported
166
+ * components' markers carry their function in `data.m`, a string tag's don't. The template is
167
+ * the raw instance's (`iv.i`: the InstanceView's own field, a dev entry of the same build reads it;
168
+ * the vnode onRender gets has the hosts' output injected in their place). Once per tag.
169
+ */
170
+ const HOST_TAGS: Record<string, string> = {collection: 'Collection', switchable: 'Switchable'}
171
+ function lowercaseTags(iv: any): void {
172
+ const raw = iv.i
173
+ if (!raw?.kids?.size || !raw.tmpl) return
174
+ const walk = (n: any, d: number): void => {
175
+ if (!n || typeof n != 'object' || n.$p || d > 300) return
176
+ const name = typeof n.sel == 'string' && !n.data?.m && HOST_TAGS[n.sel]
177
+ if (name && once(`SYG426:${n.sel}`)) devReport('SYG426', {
178
+ component: iv.name,
179
+ message: `${iv.name} renders the lowercase <${n.sel}> tag, which is deprecated since Sygnal 6.3.0 and will be removed in the next major (it will then render an unknown <${n.sel}> element)`,
180
+ fix: `import { ${name} } from 'sygnal' and write <${name} …> (sygnal-check --fix does it)`,
181
+ data: {tag: n.sel, component: name},
182
+ })
183
+ const kids = n.children
184
+ if (Array.isArray(kids)) for (const k of kids) walk(k, d + 1)
185
+ }
186
+ walk(raw.tmpl, 0)
187
+ }
188
+
97
189
  /** the instance fields the checks read from today's Component instances */
98
190
  class Facade {
99
191
  _so: any
@@ -301,7 +393,11 @@ export function nextHooks(_api: any): any {
301
393
  get(t: any, k: any) {
302
394
  const v = t[k]
303
395
  if (k !== 'CHILD' || !v || typeof v.select != 'function') return v
304
- return {...v, select: (x: any, ...r: any[]) => { if (typeof x == 'string') removed(f, f.name, 'child-select-name', `CHILD.select('${x}')`); return v.select(x, ...r) }}
396
+ return {...v, select: (x: any, ...r: any[]) => {
397
+ if (typeof x == 'string') removed(f, f.name, 'child-select-name', `CHILD.select('${x}')`)
398
+ else if (typeof x != 'function') notComponent(f, x)
399
+ return v.select(x, ...r)
400
+ }}
305
401
  },
306
402
  })
307
403
  },
@@ -311,7 +407,11 @@ export function nextHooks(_api: any): any {
311
407
  onRender(iv: any, vnode: any) {
312
408
  const f = fac(iv)
313
409
  H.onRender(f, vnode)
314
- if (on()) removedTags(f, vnode)
410
+ if (!on()) return
411
+ removedTags(f, vnode)
412
+ try { lowercaseTags(iv) } catch (_) { /* a check never breaks the app */ }
413
+ // SYG453: only a view with children can render one twice
414
+ if (iv.children().length) try { sharedIdentity(iv, vnode) } catch (_) { /* a check never breaks the app */ }
315
415
  },
316
416
  onReducer(iv: any, type: string, prev: any, next: any) {
317
417
  H.onReducer(fac(iv), type, prev, next, 'STATE')
@@ -6,7 +6,8 @@
6
6
  * or restored. Under Vike's Layout/Wrapper shell (the `__vike` source) the message says
7
7
  * persist isn't supported on Vike pages, layouts or wrappers yet.
8
8
  * SYG223 (warn) — a `pick` / `omit` key of persist() that isn't a key of the component's
9
- * `initialState` static (a typo: that key is never saved, or never left out).
9
+ * `initialState` static or of its `uses` (a behavior's slice, such as a chat's), so it is
10
+ * a typo: that key is never saved, or never left out.
10
11
  * Once per component name. Mechanism: onModel reads the static `view.persist` (the persist()
11
12
  * value carries its options) and `view.initialState`.
12
13
  */
@@ -38,7 +39,8 @@ export const persistCheck: DiagnosticCheck = {
38
39
  }
39
40
  const o = p.options, init = view.initialState
40
41
  if (!o || !init || typeof init != 'object') return
41
- const keys = Object.keys(init)
42
+ // a behavior's slice (`uses = { chat: chat() }`) is a state key too
43
+ const keys = [...Object.keys(init), ...Object.keys(view.uses || {})]
42
44
  for (const opt of ['pick', 'omit']) {
43
45
  for (const k of Array.isArray(o[opt]) ? o[opt] : []) {
44
46
  if (keys.includes(k) || !once(`SYG223:${name}:${opt}:${k}`)) continue
@@ -36,12 +36,20 @@ const underTest = (c: any) => {
36
36
  const dom = c?.sources?.[c.DOMSourceName || 'DOM']
37
37
  return !!dom && typeof dom == 'object' && '_hub' in dom
38
38
  }
39
- // G-620: an LLM / chat sink gets the chat driver's fix, not makeFetchDriver's
40
- const driverFix = (sink: string) =>
41
- `Pass a driver named ${sink} to run(): ` + (/^(LLM|CHAT|AI|ASSISTANT)$/.test(sink)
42
- ? `run(App, { ${sink}: makeChatDriver({ transport: openResponses({ baseURL: 'http://localhost:11434/v1', model: 'llama3.2' }) }) }) (from 'sygnal/ai'; uiMessageStream('/api/chat') for an AI SDK server)`
43
- : `run(App, { ${sink}: makeFetchDriver() }) for HTTP, or driverFromAsync(fn) / your own driver`) +
44
- `. Check the spelling against the drivers you pass`
39
+ // G-620: the usual driver for a known sink name (an LLM / chat sink gets the chat driver's fix,
40
+ // SW the service worker's), the generic text for any other name
41
+ const DRIVER_FOR: Array<[RegExp, (sink: string) => string]> = [
42
+ [/^(LLM|CHAT|AI|ASSISTANT)$/, (s) => `run(App, { ${s}: makeChatDriver({ transport: openResponses({ baseURL: 'http://localhost:11434/v1', model: 'llama3.2' }) }) }) (from 'sygnal/ai'; uiMessageStream('/api/chat') for an AI SDK server)`],
43
+ [/^(SW|SERVICE_WORKER)$/, (s) => `run(App, { ${s}: makeServiceWorkerDriver('/sw.js', { enabled: import.meta.env.PROD }) }) (enabled: false keeps the driver, with no service worker, on a dev server)`],
44
+ [/^(HTTP|API|FETCH)$/, (s) => `run(App, { ${s}: makeFetchDriver() })`],
45
+ [/^(WS|SOCKET)$/, (s) => `run(App, { ${s}: makeSocketDriver() })`],
46
+ ]
47
+ const driverFix = (sink: string) => {
48
+ const known = DRIVER_FOR.find(([re]) => re.test(sink))
49
+ return `Pass a driver named ${sink} to run(): ` +
50
+ (known ? known[1](sink) : `run(App, { ${sink}: makeFetchDriver() }) for HTTP, or driverFromAsync(fn) / your own driver`) +
51
+ `. Check the spelling against the drivers you pass`
52
+ }
45
53
 
46
54
  export const wiringCheck: DiagnosticCheck = {
47
55
  id: 'wiring',
@@ -276,6 +276,13 @@ export const CODE_TITLES: Record<string, string> = {
276
276
  SYG423: 'Context change skipped a view whose output it changes',
277
277
  SYG424: 'Duplicate Collection item id',
278
278
  SYG425: 'isolatedState slice lacks initialState keys',
279
+ // PLAN-9 (D471): checks/next.ts; dev entry and sygnal-check
280
+ SYG426: 'Deprecated lowercase marker tag',
281
+ // checks/next.ts and sygnal-check: a component reference that is not a function (an import of
282
+ // a name the module doesn't export is undefined under Vitest)
283
+ SYG427: 'CHILD.select() of something that is not a component',
284
+ // PLAN-9 (D470): checks/next.ts; dev entry
285
+ SYG453: 'Two children with the same identity share one instance',
279
286
  SYG601: "Retired in 6.0: Missing or invalid sources",
280
287
  SYG602: 'Intent is not a function',
281
288
  SYG603: 'Intent returned an invalid value',
@@ -339,6 +346,29 @@ export const CODE_TITLES: Record<string, string> = {
339
346
  // PLAN-6 A-2: experimentalExposeWebMcp (src/extra/ai/webmcp.ts; dev)
340
347
  SYG674: 'WebMCP not available',
341
348
  SYG676: 'WebMCP registerTool rejected',
349
+ // ---- PLAN-9 compose (A: src/extra/ai/compose/; dev, reported with the severity passed) ----
350
+ SYG161: 'Catalog container render is a component with a model',
351
+ SYG162: 'Catalog part with neither component nor render',
352
+ SYG250: 'Layout names an unknown part or slot',
353
+ SYG251: 'Invalid param value in a layout',
354
+ SYG252: 'Record param from() does not return an array of { value }',
355
+ SYG450: 'Catalog in names an unknown container or slot',
356
+ SYG451: 'Composed has no catalog or from is not a composition',
357
+ SYG452: 'Layout lists a node key twice',
358
+ SYG681: 'Compose backend option problem',
359
+ // PLAN-9 B-1/B-2: compose.decisions() (src/extra/ai/compose/decisions.ts; explicit severities)
360
+ SYG680: 'Part is LLM-only under a decision backend',
361
+ SYG682: 'Decision model answered outside the criteria',
362
+ // PLAN-9 B-3: compose.llm() (src/extra/ai/compose/llm.ts; the compose status becomes 'error')
363
+ SYG683: 'LLM layout invalid after repair',
364
+ // PLAN-9 X-2: static only (sygnal-check rules/syg253-catalog.js)
365
+ SYG253: 'Misspelled catalog declaration key',
366
+ SYG254: 'Catalog descriptions or options overlap',
367
+ // PLAN-9 H: chat client tools and ask (src/extra/ai/chat/behavior.ts, compose/ask.ts, compose/render.ts)
368
+ SYG160: 'Result part rendered outside a card',
369
+ SYG684: 'Client tool shadows an agent tool',
370
+ SYG685: 'Answer for a tool call that is not open',
371
+ SYG686: 'Ask catalog without a result part',
342
372
  // PLAN-4 GS-3: a11y lane (static only, sygnal-check)
343
373
  SYG701: 'Click listener on a non-interactive element',
344
374
  SYG702: 'Form field without an accessible label',
@@ -457,6 +487,12 @@ export const DEV_CODE_SEVERITY: Record<string, DiagnosticSeverity> = {
457
487
  SYG423: 'warn',
458
488
  SYG424: 'warn',
459
489
  SYG425: 'warn',
490
+ // PLAN-9 (checks/next.ts): D471 a lowercase <collection> / <switchable> tag (deprecated in 6.3.0)
491
+ SYG426: 'warn',
492
+ // checks/next.ts: CHILD.select(undefined), usually a missing export
493
+ SYG427: 'error',
494
+ // PLAN-9 (checks/next.ts): D470 key identity, two children with one identity
495
+ SYG453: 'warn',
460
496
  SYG612: 'error',
461
497
  // PLAN-4 D131 (element.ts, through the core bridge; a console warning in dev with diagnostics off)
462
498
  SYG644: 'warn',
@@ -497,6 +533,16 @@ export const DEV_CODE_SEVERITY: Record<string, DiagnosticSeverity> = {
497
533
  // PLAN-6 A-2 (experimentalExposeWebMcp reports them with an explicit severity)
498
534
  SYG674: 'info',
499
535
  SYG676: 'warn',
536
+ // PLAN-9 B-1/B-2 (compose.decisions() reports them with an explicit severity)
537
+ SYG680: 'info',
538
+ SYG682: 'warn',
539
+ // PLAN-9 B-3 (compose.llm)
540
+ SYG683: 'error',
541
+ // PLAN-9 H (chat client tools and ask; reported with the severity passed)
542
+ SYG160: 'warn',
543
+ SYG684: 'warn',
544
+ SYG685: 'warn',
545
+ SYG686: 'warn',
500
546
  // PLAN-5 V-1: VirtualCollection (checks/virtual.ts, through the core bridge)
501
547
  SYG430: 'warn',
502
548
  SYG431: 'warn',
@@ -528,6 +574,19 @@ export const DEV_CODE_SEVERITY: Record<string, DiagnosticSeverity> = {
528
574
  SYG153: 'warn',
529
575
  SYG730: 'warn',
530
576
  SYG731: 'warn',
577
+ // ---- PLAN-9 compose (A: src/extra/ai/compose/, reported with the severity passed, dev) ----
578
+ SYG161: 'error',
579
+ SYG162: 'error',
580
+ SYG250: 'warn',
581
+ SYG251: 'warn',
582
+ SYG252: 'warn',
583
+ SYG450: 'warn',
584
+ SYG451: 'warn',
585
+ SYG452: 'warn',
586
+ SYG681: 'error',
587
+ // PLAN-9 X-2: static only (sygnal-check); listed so the entry knows their titles
588
+ SYG253: 'warn',
589
+ SYG254: 'info',
531
590
  }
532
591
 
533
592
  export function getCodeInfo(code: string): DiagnosticCodeInfo | undefined {
@@ -81,6 +81,12 @@ import {validateWith} from './standardSchema';
81
81
  * the socket driver's backoff; the failure (with `attempts`) is delivered once, at the end.
82
82
  * - `validate: schema` (a Standard Schema): the parsed body is validated (and transformed); a
83
83
  * failure is an error with `issues`.
84
+ * - D472 (G-757): `batch: [request, …]` sends those requests in parallel instead of this one (each
85
+ * inherits its fields; its own `json` / `body` / `parse` win) and `combine(bodies, requests)`
86
+ * merges their parsed bodies into the one reply; `split(request, error)` turns a failed one (or
87
+ * the request itself) into requests sent again in parallel and combined, at most 3 deep. One
88
+ * failure fails the request once (`error.status` / `error.body`). No cache entry sharing, no
89
+ * per-part retry. The test fake (`_whole`) sends and answers it as one request. decide() uses it.
84
90
  * - dispose / sink completion aborts everything in flight.
85
91
  * - `fetch` is read at request time (options.fetch, else globalThis.fetch), so test stubs work.
86
92
  * - PLAN-3 5-1 (H-9): renderComponent's HTTP fake runs this driver over an in-memory fetch;
@@ -137,6 +143,15 @@ const autoParse = async (res: any) => {
137
143
  return text ? JSON.parse(text) : null;
138
144
  };
139
145
 
146
+ /** a non-2xx response */
147
+ const bad = (res: any) => res.ok === false || res.status < 200 || res.status > 299;
148
+ /** the failure of a non-2xx response: `status`, `body` (parsed with 'auto') */
149
+ const httpError = async (res: any, href: string) => {
150
+ let body: any;
151
+ try { body = await autoParse(res); } catch (_) {}
152
+ return Object.assign(new Error(`HTTP ${res.status}${res.statusText ? ' ' + res.statusText : ''}: ${href}`), {status: res.status, body});
153
+ };
154
+
140
155
  const PARSERS: Record<string, (res: any) => any> = {
141
156
  auto: autoParse,
142
157
  json: res => res.json(),
@@ -144,6 +159,8 @@ const PARSERS: Record<string, (res: any) => any> = {
144
159
  response: res => res,
145
160
  };
146
161
 
162
+ const parserOf = (pz: any) => typeof pz == 'function' ? pz : PARSERS[pz] || autoParse;
163
+
147
164
  const pickInit = (init: any) => {
148
165
  const out: any = {};
149
166
  if (init && typeof init == 'object') INIT_KEYS.forEach(k => { if (init[k] !== undefined) out[k] = init[k]; });
@@ -379,15 +396,23 @@ export function makeFetchDriver(options: any = {}) {
379
396
  if (abort) return cancel(r => r.scope === scope && (!('category' in req) || r.category === category));
380
397
  if (rn !== undefined || (!pf && (latest ?? options.latest))) cancel(rk === undefined ? r => r.rk === undefined && r.scope === scope && r.category === category : r => r.sender === sender && r.rk === rk);
381
398
 
382
- const href = withQuery((options.baseUrl || '') + (url ?? ''), query);
383
- const base = options.init, own = init;
384
- const h = mergeHeaders(options.headers, base?.headers, own?.headers, headers);
385
- let b = body ?? own?.body;
386
- if (json !== undefined) {
387
- b = JSON.stringify(json);
388
- if (!Object.keys(h).some(k => k.toLowerCase() == 'content-type')) h['content-type'] = 'application/json';
389
- }
390
- const m = method || own?.method || base?.method || (b !== undefined ? 'POST' : 'GET');
399
+ const base = options.init;
400
+ // a request's URL and fetch() init (the batch's sub-requests inherit the request's fields)
401
+ const prep = ({url, method, headers, query, json, body, init: own}: any) => {
402
+ const h = mergeHeaders(options.headers, base?.headers, own?.headers, headers);
403
+ let b = body ?? own?.body;
404
+ if (json !== undefined) {
405
+ b = JSON.stringify(json);
406
+ if (!Object.keys(h).some(k => k.toLowerCase() == 'content-type')) h['content-type'] = 'application/json';
407
+ }
408
+ return [withQuery((options.baseUrl || '') + (url ?? ''), query), {...pickInit(base), ...pickInit(own), method: method || own?.method || base?.method || (b !== undefined ? 'POST' : 'GET'), headers: h, body: b}];
409
+ };
410
+ const [href, fetchInit] = prep(req);
411
+ const m = fetchInit.method;
412
+ // D472: `batch` (sub-requests sent in parallel) and `split` (a failed request sent again in
413
+ // parts, at most 3 deep), their parsed bodies merged by `combine` into one reply. The test
414
+ // fake (_whole) answers such a request as one
415
+ const {batch, split, combine = (x: any) => x} = options._whole ? {} as any : req;
391
416
  const k = C?.key(req, rn !== undefined || pf);
392
417
  const id = ++seq;
393
418
  const r: any = {category, scope, sender, rk};
@@ -421,18 +446,10 @@ export function makeFetchDriver(options: any = {}) {
421
446
  const tries = pol.count ?? 3;
422
447
  F = {id, k, q: req, s: new Set(), n: 0};
423
448
  if (k !== undefined) C.f.set(k, F);
424
- const pz = parse || options.parse || 'auto';
425
- const parser = typeof pz == 'function' ? pz : PARSERS[pz] || autoParse;
449
+ const parser = parserOf(parse || options.parse);
426
450
  // the global fetch is called as a method: a detached window.fetch throws "Illegal invocation"
427
451
  const g: any = globalThis;
428
452
  const doFetch = options.fetch || (typeof g.fetch == 'function' && ((u: string, i: any) => g.fetch(u, i)));
429
- const fetchInit = {
430
- ...pickInit(base),
431
- ...pickInit(own),
432
- method: m,
433
- headers: h,
434
- body: b,
435
- };
436
453
  // the outcome, to every request waiting on this fetch (the failure once, after the last attempt)
437
454
  const end = (e: any, x?: any, status?: number) => {
438
455
  done(F);
@@ -454,23 +471,37 @@ export function makeFetchDriver(options: any = {}) {
454
471
  // 5-1 (H-9): renderComponent's fake learns which request (and resource) the next fetch is for
455
472
  options._tap?.(req, rn, pf);
456
473
  // called synchronously, so a test sees the call right after the sink emitted
457
- p = Promise.resolve(doFetch(href, {...fetchInit, signal: ctl.signal}));
474
+ p = batch ? Promise.resolve() : Promise.resolve(doFetch(href, {...fetchInit, signal: ctl.signal}));
475
+ if (batch || split) {
476
+ // D472: parts in parallel; a failed one split again when `split` says how
477
+ const one = (q: any, d: number, first?: any): Promise<any> => {
478
+ const [u, i] = prep({...req, ...q});
479
+ return (first || Promise.resolve(doFetch(u, {...i, signal: ctl.signal}))).then(async (res: any) => {
480
+ if (bad(res)) {
481
+ const e = await httpError(res, u);
482
+ const qs = d < 3 && live() && split?.(q, e);
483
+ if (qs?.length > 1) return all(qs, d + 1);
484
+ throw e;
485
+ }
486
+ return parserOf(q.parse || parse || options.parse)(res);
487
+ });
488
+ };
489
+ const all = (qs: any[], d: number) => Promise.all(qs.map(q => one(q, d))).then(bodies => combine(bodies, qs));
490
+ p = (batch ? all(batch, 0) : one(req, 0, p)).then(v => ({b: v}));
491
+ }
458
492
  } catch (e) {
459
493
  p = Promise.reject(e);
460
494
  }
461
495
  p.then(async (res: any) => {
496
+ if (batch || split) return live() && (k !== undefined && !F.z && C.put(k, req, res.b), end(0, res.b, 200));
462
497
  if (!live()) return;
463
498
  // R4-3: anything that throws here (a non-Response, a parser) fails the request
464
499
  try {
465
500
  const st = res.status;
466
- if (res.ok === false || st < 200 || st > 299) {
467
- let errBody: any;
468
- try { errBody = await autoParse(res); } catch (_) {}
501
+ if (bad(res)) {
502
+ const e = await httpError(res, href);
469
503
  if (!live() || ((st == 408 || st == 429 || st > 499) && again(res))) return;
470
- const e: any = new Error(`HTTP ${st}${res.statusText ? ' ' + res.statusText : ''}: ${href}`);
471
- e.status = st;
472
- e.body = errBody;
473
- return end(e, {status: st, body: errBody});
504
+ return end(e, {status: st, body: e.body});
474
505
  }
475
506
  let v: any;
476
507
  try { v = await parser(res); } catch (e) { return live() && end(e, {status: st}); }
@@ -480,7 +511,7 @@ export function makeFetchDriver(options: any = {}) {
480
511
  } catch (e) {
481
512
  live() && end(e);
482
513
  }
483
- }, (e: any) => { live() && !again() && end(e); });
514
+ }, (e: any) => { live() && (e?.status ? end(e, {status: e.status, body: e.body}) : !again() && end(e)); });
484
515
  };
485
516
  go();
486
517
  }
@@ -0,0 +1,15 @@
1
+ /*
2
+ * PLAN-9 (D463): byId(field, id, idKey = 'id'), a { get, set } lens to the element of the array
3
+ * `state[field]` whose `idKey` is `id`: a record part's state (`state: (node) => byId('projects',
4
+ * node.params.id)`), or any child's `state={byId('todos', 3)}`. get: the element (undefined when it
5
+ * is gone); set: the array with that element replaced (the state unchanged when it is gone).
6
+ * Side-effect free.
7
+ */
8
+ export const byId = (field: string, id: any, idKey = 'id') => ({
9
+ get: (s: any) => (Array.isArray(s?.[field]) ? s[field].find((x: any) => x?.[idKey] === id) : undefined),
10
+ set: (s: any, v: any) => {
11
+ const a = s?.[field]
12
+ if (!Array.isArray(a) || !a.some((x: any) => x?.[idKey] === id)) return s
13
+ return {...s, [field]: a.map((x: any) => (x?.[idKey] === id ? v : x))}
14
+ },
15
+ })
package/src/extra/pwa.ts CHANGED
@@ -18,6 +18,8 @@ export interface ServiceWorkerCommand {
18
18
 
19
19
  export interface ServiceWorkerOptions {
20
20
  scope?: string;
21
+ /** false: register nothing, ignore commands, emit nothing (a dev server: `enabled: import.meta.env.PROD`) */
22
+ enabled?: boolean;
21
23
  }
22
24
 
23
25
  export interface InstallPrompt {
@@ -47,7 +49,7 @@ export function makeServiceWorkerDriver(
47
49
  return function serviceWorkerDriver(sink$: Stream<ServiceWorkerCommand>): ServiceWorkerSource {
48
50
  const events = new EventTarget();
49
51
 
50
- if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
52
+ if (options.enabled !== false && typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
51
53
  navigator.serviceWorker
52
54
  .register(scriptUrl, {scope: options.scope})
53
55
  .then((reg) => {
@@ -5,6 +5,8 @@
5
5
  * shorthand factories for the most frequent reducer shapes.
6
6
  */
7
7
 
8
+ import {ABORT, isAbort} from '../shared'
9
+
8
10
  // ── set() ──────────────────────────────────────────────────────────
9
11
  /**
10
12
  * Create a reducer that merges a partial update into state.
@@ -40,13 +42,17 @@ export function toggle<S = any>(field: keyof S & string): (state: S) => S {
40
42
  }
41
43
 
42
44
  // event(type, payload?) → EVENTS sink fn for the object form:
43
- // `ACTION: { EVENTS: event('TYPE', (state, data) => payload) }` (static payloads allowed)
45
+ // `ACTION: { EVENTS: event('TYPE', (state, data) => payload) }` (static payloads allowed).
46
+ // A payload function that returns ABORT sends nothing, as ABORT does from any other sink.
44
47
  export function event(
45
48
  type: string,
46
49
  payload?: any | ((state: any, data: any, next: Function, props: any) => any)
47
- ): (state: any, data: any, next: Function, props: any) => { type: string; data: any } {
50
+ ): (state: any, data: any, next: Function, props: any) => { type: string; data: any } | typeof ABORT {
48
51
  return typeof payload === 'function'
49
- ? (state, data, next, props) => ({ type, data: payload(state, data, next, props) })
52
+ ? (state, data, next, props) => {
53
+ const d = payload(state, data, next, props)
54
+ return isAbort(d) ? ABORT : { type, data: d }
55
+ }
50
56
  : () => ({ type, data: payload })
51
57
  }
52
58
 
@@ -66,6 +72,6 @@ export function event(
66
72
  export function emit(
67
73
  type: string,
68
74
  data?: any | ((state: any, actionData: any, next: Function, props: any) => any)
69
- ): { EVENTS: (state: any, actionData: any, next: Function, props: any) => { type: string; data: any } } {
75
+ ): { EVENTS: (state: any, actionData: any, next: Function, props: any) => { type: string; data: any } | typeof ABORT } {
70
76
  return { EVENTS: event(type, data) }
71
77
  }
package/src/extra/ssr.ts CHANGED
@@ -163,14 +163,14 @@ function withResources(def: any, state: any): any {
163
163
  }
164
164
 
165
165
  // PLAN-4 GS-9: uid(name?) as on the client: the root is 'u'; a child component adds its path in
166
- // the parent's view (or its `id` prop), as getComponentIdFromElement and instantiateSubComponents
166
+ // the parent's view (or its vnode `key`, else its `id` prop, D456), as getComponentIdFromElement and instantiateSubComponents
167
167
  // in the core do; a Collection item adds its key, a Switchable page its name; anything but
168
168
  // each part encoded by uidPart (G-214); in the root option anything but [A-Za-z0-9_-] becomes '_'
169
169
  // (as run() does). SSR ids = hydration ids
170
170
  const makeUid = (base: string) => (n?: string) => n ? base + '-' + n : base
171
171
  function childUid(uid: string, vnode: any, path: string): string {
172
- const id = vnode.data?.props?.id
173
- return uid + '-' + uidPart(('::' + ((id && JSON.stringify(id).replaceAll('"', '')) || path)).replace(/.*::(r\.)?/, ''))
172
+ const id = vnode.key ?? vnode.data?.props?.id
173
+ return uid + '-' + uidPart(('::' + ((id != null && JSON.stringify(id).replaceAll('"', '')) || path)).replace(/.*::(r\.)?/, ''))
174
174
  }
175
175
 
176
176
  // G-567: a view that returns a string or a number renders it as text (core/instance.ts view());
@@ -1754,6 +1754,8 @@ export function renderComponent(
1754
1754
  // 5-3: focus / online come from t.focus() / t.online() only
1755
1755
  _on: (f: any) => { signals.add(f); return () => signals.delete(f); },
1756
1756
  _tap: (req: any, res?: string, pf?: any) => { tapped = [req, res, pf]; },
1757
+ // D472: a batch / split request (a large decide()) is one fetch here, answered as one
1758
+ _whole: true,
1757
1759
  fetch: (url: string, init: any) => new Promise((resolve, reject) => {
1758
1760
  const [req, res, pf] = tapped || [{url}];
1759
1761
  tapped = undefined;
@@ -171,14 +171,14 @@ function rewrite(n: any, o: any, path: string): any {
171
171
  return r
172
172
  }
173
173
 
174
- /** widget markers among a Portal's children (the core doesn't walk them) */
174
+ /** widget markers among a Portal's children (the core doesn't walk them): `pres.widget.$k`, which the
175
+ * Portal's rewrite calls (core/markers/portal.ts; G-751: either may be registered first) */
175
176
  const inPortal = (c: any[], o: any, x: string): any[] =>
176
177
  c.map((n, i) => n?.sel == 'widget' ? rewrite(n, o, x + '.' + i) : n?.children ? {...n, children: inPortal(n.children, o, x + '.' + i)} : n)
178
+ ;(rewrite as any).$k = inPortal
177
179
 
178
180
  export function defineWidget(def: any): any {
179
181
  pres.widget ||= rewrite
180
- const p: any = pres.portal
181
- if (p && !p.$w) ((pres.portal = (n: any, o: any, x: string) => p({...n, children: inPortal(n.children || [], o, x)}, o, x)) as any).$w = 1
182
182
  const w: any = () => {}, commands: any = {}
183
183
  // D200: any name, `close` and `togglePopover` too (the core passes a widget command the options)
184
184
  for (const m in def.commands) commands[m] = (el: any, o: any) => el[W] && def.commands[m](el[W].i, o || {}, el)