ngx-json-render 0.2.1 → 0.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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Angular renderer for [json-render](https://github.com/vercel-labs/json-render) — give an LLM a catalog of your components, stream back a JSON spec, and render it as real Angular components. No `innerHTML`, no `eval`, no framework lock-in on the wire format.
6
6
 
7
- Built on `@json-render/core` (the same spec format, expressions, state store, actions, and streaming compiler used by the React, Vue, Solid, and Svelte renderers) and idiomatic modern Angular: standalone components, signals, zoneless-friendly, `OnPush` everywhere.
7
+ Built on `@json-render/core` (the same spec format, expressions, state store, actions, and streaming compiler used by the React, Vue, Solid, and Svelte renderers) and idiomatic modern Angular: standalone components, signals, `OnPush` everywhere, and zoneless — no `NgZone`, no Zone.js.
8
8
 
9
9
  This is an Angular **adapter over the official core**, not a second implementation of it — `@json-render/core` is a peer dependency, and the spec your model emits is the same one the React, Vue, Solid and Svelte renderers consume. A catalog and a spec written here move to another framework unchanged.
10
10
 
@@ -18,7 +18,62 @@ This is an Angular **adapter over the official core**, not a second implementati
18
18
  npm install ngx-json-render @json-render/core zod
19
19
  ```
20
20
 
21
- Requires Angular ≥ 20.
21
+ Requires Angular ≥ 20. CI proves both ends of that range on every push: the
22
+ library builds and its suite passes on Angular 20 (the floor) and on Angular
23
+ 22 (the current release), as well as on the 21 the workspace itself pins.
24
+
25
+ **Zoneless, and checked as such.** Zone.js appears in no manifest here, `NgZone`
26
+ in no source file, and neither in the published bundles; every suite runs under
27
+ `provideZonelessChangeDetection()`. CI asserts all four on each commit
28
+ (`npm run check:zoneless`), so this is a tested property rather than a
29
+ statement of intent — which matters because zoneless is the default for new
30
+ applications from Angular 21 on.
31
+
32
+ ## The shortest path: a ready-made catalog
33
+
34
+ Writing a catalog is the honest first step, but you do not have to take it to
35
+ see a spec render. [`ngx-json-render-material`](https://www.npmjs.com/package/ngx-json-render-material)
36
+ ships 28 Angular Material components already registered, so a generated spec
37
+ renders with nothing else wired up:
38
+
39
+ ```bash
40
+ npm install ngx-json-render-material @angular/material
41
+ ```
42
+
43
+ Those are real Material components, so the app needs a Material theme and the
44
+ icon font as usual.
45
+
46
+ ```ts
47
+ import { Component, signal } from '@angular/core';
48
+ import { JsonRenderer, type Spec } from 'ngx-json-render';
49
+ import { materialRegistry } from 'ngx-json-render-material';
50
+
51
+ @Component({
52
+ selector: 'app-root',
53
+ imports: [JsonRenderer],
54
+ template: `<json-render [spec]="spec()" [registry]="registry" />`,
55
+ })
56
+ export class App {
57
+ readonly registry = materialRegistry;
58
+ readonly spec = signal<Spec>({
59
+ root: 'card',
60
+ state: { name: '' },
61
+ elements: {
62
+ card: { type: 'Card', props: { title: 'Profile' }, children: ['name', 'hi'] },
63
+ name: { type: 'Input', props: { label: 'Name', value: { $bindState: '/name' } } },
64
+ hi: {
65
+ type: 'Text',
66
+ props: { content: { $template: 'Hello, ${/name}!' } },
67
+ visible: { $state: '/name', neq: '' },
68
+ },
69
+ },
70
+ });
71
+ }
72
+ ```
73
+
74
+ `materialCatalog.prompt()` is the system prompt that teaches a model that
75
+ vocabulary. Everything below is the other path — your own components, which is
76
+ what the catalog API is for.
22
77
 
23
78
  ## Quick start
24
79
 
@@ -83,7 +138,13 @@ export class ButtonComponent {
83
138
 
84
139
  ```ts
85
140
  import { Component, signal } from '@angular/core';
86
- import { JsonRenderer, type Spec, defineRegistry } from 'ngx-json-render';
141
+ import {
142
+ type ActionHandler,
143
+ JsonRenderer,
144
+ type Spec,
145
+ type StateChange,
146
+ defineRegistry,
147
+ } from 'ngx-json-render';
87
148
  import { catalog } from './catalog';
88
149
 
89
150
  const { registry } = defineRegistry(catalog, {
@@ -119,8 +180,10 @@ export class Page {
119
180
  },
120
181
  },
121
182
  });
122
- handlers = { refresh: async () => { /* ... */ } };
123
- onStateChange(changes: unknown) { console.log(changes); }
183
+ readonly handlers: Record<string, ActionHandler> = {
184
+ refresh: async () => { /* ... */ },
185
+ };
186
+ onStateChange(changes: StateChange[]) { console.log(changes); }
124
187
  }
125
188
  ```
126
189
 
@@ -178,6 +241,61 @@ app.post('/api/generate', async (req, res) => {
178
241
 
179
242
  The patches apply to the `spec` signal as each line arrives, so the UI assembles on screen while the model is still generating — exactly what the [demo's Streaming tab](https://shteynu.github.io/ngx-json-render/) replays. Prefer structured output? `catalog.jsonSchema()` exports a JSON Schema for `streamObject`/tool calls, and `catalog.validate(spec)` checks a finished spec against the catalog.
180
243
 
244
+ ### Chat + GenUI
245
+
246
+ When the model answers in prose _and_ renders a UI in the same turn, reach for
247
+ `injectChatUI` instead. Its endpoint takes `{ messages }` and streams prose
248
+ mixed with ` ```spec ` fenced JSONL. Each assistant message carries `text`
249
+ and/or its own `spec`, so earlier turns keep the UI they generated:
250
+
251
+ ```ts
252
+ import { Component } from '@angular/core';
253
+ import { JsonRenderer, injectChatUI } from 'ngx-json-render';
254
+
255
+ @Component({
256
+ imports: [JsonRenderer],
257
+ template: `
258
+ @for (m of chat.messages(); track m.id) {
259
+ <p>{{ m.text }}</p>
260
+ @if (m.spec) {
261
+ <json-render [spec]="m.spec" [registry]="registry" />
262
+ }
263
+ }
264
+ <button (click)="chat.send('show me revenue for the quarter')">Ask</button>
265
+ `,
266
+ })
267
+ export class ChatPage {
268
+ readonly chat = injectChatUI({ api: '/api/chat' });
269
+ readonly registry = registry;
270
+ }
271
+ ```
272
+
273
+ ### Reading an AI SDK message
274
+
275
+ If the UI arrives as AI SDK data parts rather than through these hooks,
276
+ `buildSpecFromParts` / `getTextFromParts` / `jsonRenderMessage` read a
277
+ `UIMessage.parts` array directly — pass it as it is, no cast:
278
+
279
+ ```ts
280
+ readonly msg = jsonRenderMessage(() => this.message().parts);
281
+ // template: {{ msg.text() }} @if (msg.hasSpec()) { <json-render [spec]="msg.spec()" ... /> }
282
+ ```
283
+
284
+ Two things about the parts themselves, both the SDK's semantics rather than
285
+ this package's, and both silent when you get them wrong:
286
+
287
+ - **Do not give patch parts an `id`.** A data part written with one is
288
+ _replaced_ by the next part carrying the same id, so a spec streamed as
289
+ patches under one id arrives as its last patch alone. An id is for a part
290
+ that is a snapshot of itself — a `flat` or `nested` whole-spec part.
291
+ - **A transient part never reaches `message.parts`.** It goes to `onData` and
292
+ nowhere else, so a spec written transiently cannot be rebuilt from the
293
+ message.
294
+
295
+ `projects/ngx-json-render/src/lib/ai-sdk-parts.spec.ts` runs the real SDK —
296
+ writes the chunks a server route would, reads the message a client would —
297
+ and asserts both of these, so this section cannot quietly go stale.
298
+
181
299
  ### Supplying the transport
182
300
 
183
301
  By default the request goes through the global `fetch`. Pass your own to add
@@ -218,11 +336,167 @@ The second argument also carries `context`, forwarded to the endpoint as-is:
218
336
 
219
337
  Also available:
220
338
 
221
- - `injectChatUI({ api, fetch? })` — chat + GenUI: the endpoint takes `{ messages }` and streams prose mixed with ` ```spec ` fenced JSONL; each assistant message carries `text` and/or a `spec`.
222
339
  - `applyPatch(spec, patch)` — immutably apply one RFC 6902 patch to a spec, sharing every subtree the patch did not touch. Both hooks and `buildSpecFromParts` apply through it, so the same stream produces the same spec whichever one you reach for. One deliberate deviation from the RFC: a failing `test` op is a no-op rather than an abort, because these patches come off a model's output and dropping a bad line beats killing the generation.
223
340
  - `buildSpecFromParts` / `getTextFromParts` / `jsonRenderMessage` — derive specs from AI SDK `message.parts`.
224
341
  - `catalog.prompt()` / `buildUserPrompt` (from `@json-render/core`) — generate the system/user prompts for your catalog.
225
342
 
343
+ ### Checking what the model produced
344
+
345
+ A generation can end malformed — a child that never arrived, a `visible` the
346
+ model wrote inside `props` where nothing reads it. Opt into a structural check
347
+ with `validate`, on the renderer or on either hook:
348
+
349
+ ```html
350
+ <json-render [spec]="ui.spec()" [registry]="registry" [loading]="ui.isStreaming()" validate="warn" />
351
+ ```
352
+
353
+ ```ts
354
+ readonly ui = injectUIStream({ api: '/api/generate', validate: 'strict' });
355
+ // ui.issues() — what was wrong with the finished spec
356
+ ```
357
+
358
+ - `'off'` (the default) — render whatever arrives, as before.
359
+ - `'warn'` — report what is wrong and render anyway.
360
+ - `'strict'` — a spec with errors does not render, and a generation that ends
361
+ with one fails instead of completing, so it never reaches the `onComplete`
362
+ where apps persist it.
363
+
364
+ Both modes first apply the lossless fixes `autoFixSpec` provides: `visible`,
365
+ `on` and `repeat` misplaced inside `props` move back onto the element, where
366
+ they take effect instead of being ignored. Content is never pruned — the lossy
367
+ fixes are left out on purpose, because re-prompting beats a renderer that
368
+ silently deletes elements.
369
+
370
+ The check waits for the spec to settle. While `loading` is true a missing child
371
+ is a patch that has not arrived yet, not a defect, so nothing is reported and
372
+ `strict` keeps rendering; the hooks check once, when the generation completes.
373
+
374
+ Pass the catalog too, and the same check covers what structure alone cannot
375
+ see — every `type` is one the catalog defines, and the props match its schema:
376
+
377
+ ```html
378
+ <json-render [spec]="spec()" [registry]="registry" [catalog]="catalog" validate="strict" />
379
+ ```
380
+
381
+ ### Capping what a spec may cost
382
+
383
+ `validate` describes a spec. `renderLimits` constrains it:
384
+
385
+ ```html
386
+ <json-render
387
+ [spec]="spec()"
388
+ [registry]="registry"
389
+ [renderLimits]="{ maxElements: 500, maxDepth: 16, maxRepeatItems: 200 }"
390
+ />
391
+ ```
392
+
393
+ ```ts
394
+ readonly ui = injectUIStream({
395
+ api: '/api/generate',
396
+ renderLimits: { maxElements: 500, maxDepth: 16 },
397
+ });
398
+ ```
399
+
400
+ | Limit | What it caps | When it is passed |
401
+ | ---------------- | ------------------------------- | --------------------------------------------------------------- |
402
+ | `maxElements` | Elements in the spec | Nothing renders — there is no useful part of an oversized graph |
403
+ | `maxDepth` | Nesting, counting the root as 1 | Elements below the cap do not render; what fits still does |
404
+ | `maxRepeatItems` | Items one `repeat` expands | The surplus items do not render |
405
+
406
+ Every limit is opt-in and unset means unlimited; there are no defaults, so
407
+ upgrading changes nothing about what your app renders today.
408
+
409
+ An element a cap refuses does not act either. `watch` is the one thing an
410
+ element does without being on screen, and it stays unwired while the element is
411
+ capped — otherwise a spec could keep dispatching actions from behind a limit
412
+ that was supposed to have stopped it. The same holds for the element that
413
+ closes a cycle.
414
+
415
+ Two things follow from limits being a control rather than a report. They are
416
+ enforced in **every** mode, `validate="off"` included — the app already made
417
+ the decision by setting a number. And they apply while `loading`: a partial
418
+ spec is a subset of the finished one, so a cap can only ever fire early, never
419
+ falsely. Under `strict`, a spec over `maxDepth` is refused outright rather than
420
+ truncated, which is how an app says it would rather draw nothing than draw the
421
+ first sixteen levels of something hostile.
422
+
423
+ In the hooks, a limit fails the generation instead of calling `onComplete`, in
424
+ any mode — the same reasoning as `strict`: `onComplete` is where apps persist a
425
+ spec, and this is one the app has already refused.
426
+
427
+ One order is deliberate rather than incidental. The cheap caps run before the
428
+ structural check, because core's `validateSpec` walks the tree by recursion:
429
+ the specs that most need a limit are exactly the ones that would overflow the
430
+ stack proving they exceed it. If you accept specs you did not generate, set
431
+ `maxDepth` — with no cap there is nothing to stop the check from recursing as
432
+ deep as the spec asks.
433
+
434
+ ## Testing
435
+
436
+ `ngx-json-render/testing` is a separate entry point, so nothing in it can reach
437
+ an application bundle by accident.
438
+
439
+ ### Rendering a spec
440
+
441
+ `renderSpec` mounts a spec against a registry and hands back the few things a
442
+ test does to one — no host component, no TestBed module, no settling by hand:
443
+
444
+ ```ts
445
+ import { renderSpec } from 'ngx-json-render/testing';
446
+
447
+ it('dispatches the action its spec asked for', async () => {
448
+ const ui = await renderSpec(
449
+ {
450
+ root: 'save',
451
+ elements: {
452
+ save: {
453
+ type: 'Button',
454
+ props: { label: 'Save' },
455
+ on: { press: { action: 'save' } },
456
+ },
457
+ },
458
+ },
459
+ { registry: { Button: MyButton } },
460
+ );
461
+
462
+ expect(ui.text('button')).toBe('Save');
463
+ await ui.click('button');
464
+ expect(ui.dispatched).toEqual([{ name: 'save', params: {} }]);
465
+ });
466
+ ```
467
+
468
+ `dispatched` records every action the spec fired, handled or not. The rest of
469
+ the harness: `text` / `texts` / `find` / `findAll` for the DOM, `click` /
470
+ `fill` for input, `read` / `write` / `state` / `changes` for state, `setSpec` /
471
+ `setLoading` / `settle` for later frames, and `fixture` / `element` /
472
+ `renderer` / `store` / `validation` for everything the harness does not cover.
473
+ Options mirror the renderer's inputs, plus `providers` for anything the
474
+ components under test inject.
475
+
476
+ ### Replaying a generation
477
+
478
+ `recordedTransport` is a `fetch` that answers from a recording, so a test runs
479
+ the real client — request body, streamed lines, usage metadata, abort on
480
+ supersede — with no server and no API key:
481
+
482
+ ```ts
483
+ import { recordedTransport, specStream, usageLine } from 'ngx-json-render/testing';
484
+
485
+ const ui = injectUIStream({
486
+ api: '/api/generate',
487
+ fetch: recordedTransport([...specStream(expectedSpec), usageLine({ totalTokens: 15 })]),
488
+ });
489
+ ```
490
+
491
+ `specStream(spec)` writes the JSONL patch lines a model would emit to build
492
+ that spec, so a test says what it renders rather than how the wire spells it.
493
+ Pass a `Record<prompt, lines>` to answer each prompt differently (anything
494
+ else gets a 404 the hook surfaces as an error), or a function for more. The
495
+ options are `delayMs` for a visible pace, `fail` for an error response — as a
496
+ function, so it can be switched on and off between sends — and `promptOf` for
497
+ a request body neither hook sends. The default reads `injectUIStream`'s
498
+ `prompt` and `injectChatUI`'s last message.
499
+
226
500
  ## Spec features supported
227
501
 
228
502
  Full parity with the baseline json-render contract:
@@ -236,14 +510,42 @@ Full parity with the baseline json-render contract:
236
510
  | Computed / directives | `{ "$computed": "fmtDate", "args": {...} }`, custom `$`-directives |
237
511
  | Visibility | `"visible": { "$state": "/count", "gte": 5 }` (incl. `$and`/`$or`, `$item`, `$index`) |
238
512
  | Events → actions | `"on": { "press": { "action": "...", "params": {...}, "confirm": {...}, "onSuccess": ..., "onError": ... } }` |
239
- | Built-in actions | `setState`, `pushState` (with `$id`), `removeState`, `push`/`pop`, `validateForm` |
513
+ | Built-in actions | `setState`, `pushState` (with `$id`), `removeState`, `push`/`pop`, `validateForm`, `submitForm` |
240
514
  | Repeat | `"repeat": { "statePath": "/todos", "key": "id" }`, nested via `{ "$item": "..." }` |
241
515
  | Watch | `"watch": { "/country": { "action": "loadCities" } }` |
242
516
  | Slots | `"slots": { "header": ["title-el"] }` + `<jr-children slot="header" />` |
243
- | Validation | field checks via `ValidationConfig`, `validateForm`, `injectFieldValidation` |
517
+ | Validation | field checks via `ValidationConfig`, `validateForm` / `submitForm`, `injectFieldValidation` |
244
518
  | Confirm dialogs | built-in `<jr-confirm-dialog>` (auto-rendered) |
245
519
  | Devtools hooks | action observer + `data-jr-key` picker attributes |
246
520
 
521
+ ### Ready-made directives
522
+
523
+ `@json-render/directives` is written against the core, not against any one
524
+ renderer, so its `$format`, `$math`, `$concat`, `$count`, `$truncate`,
525
+ `$pluralize`, `$join` and `$t` work here as they are:
526
+
527
+ ```ts
528
+ import { createI18nDirective, standardDirectives } from '@json-render/directives';
529
+
530
+ readonly directives = [
531
+ ...standardDirectives,
532
+ createI18nDirective({ locale, messages }),
533
+ ];
534
+ ```
535
+
536
+ ```html
537
+ <json-render [spec]="spec()" [registry]="registry" [directives]="directives" />
538
+ ```
539
+
540
+ ```json
541
+ { "content": { "$format": "currency", "value": { "$state": "/price" }, "currency": "USD" } }
542
+ ```
543
+
544
+ A directive reads state through the same resolution as any other prop, so a
545
+ value it derives updates when that state does.
546
+ `projects/ngx-json-render/src/lib/directives.spec.ts` renders every one of
547
+ them through the renderer, which is what keeps this paragraph honest.
548
+
247
549
  ## State
248
550
 
249
551
  Each `<json-render>` owns a state store (JSON Pointer addressed). Seeding order: `store` input (controlled) → `state` input → `spec.state`.
@@ -262,6 +564,78 @@ Share one store across renderers (or drive it from your own state management) by
262
564
 
263
565
  If a catalog component renders `<input [value]="ctx.props().value">`, remember that one-way bindings do not re-assert the DOM when the bound value returns to its previously applied value while the user typed in between (e.g. `pushState` + `clearStatePath`). Sync imperatively instead — see `InputComponent` in the demo app for the pattern.
264
566
 
567
+ ## Confirmation dialogs
568
+
569
+ An action binding with a `confirm` field routes through a dialog before its
570
+ handler runs. The packaged one is a real modal — `role="dialog"` with
571
+ `aria-modal`, labelled by its title and described by its message, focus moved
572
+ onto _Cancel_ on open and returned to the trigger on close, Tab kept inside and
573
+ Escape cancelling.
574
+
575
+ Three levels of control, in the order you are likely to need them:
576
+
577
+ ```ts
578
+ // 1. Its two words, when the spec does not supply confirmLabel / cancelLabel.
579
+ { provide: JR_CONFIRM_LABELS, useValue: { confirm: 'Подтвердить', cancel: 'Отмена' } }
580
+ ```
581
+
582
+ ```css
583
+ /* 2. Its colours. Light and dark defaults ship; these override both. */
584
+ json-render { --jr-confirm-surface: #101418; --jr-confirm-accent: #4f9cf9; }
585
+ ```
586
+
587
+ Also `--jr-confirm-ink`, `--jr-confirm-muted`, `--jr-confirm-border`,
588
+ `--jr-confirm-scrim`, `--jr-confirm-danger`, `--jr-confirm-on-accent` and
589
+ `--jr-confirm-radius`.
590
+
591
+ ```ts
592
+ // 3. The whole dialog. Your component injects the context instead of taking
593
+ // inputs, the same way catalog components do.
594
+ @Component({
595
+ template: `<my-modal [title]="ctx.config.title" (ok)="ctx.confirm()" (dismiss)="ctx.cancel()" />`,
596
+ })
597
+ export class AppConfirm {
598
+ readonly ctx = injectConfirmContext();
599
+ }
600
+ // providers: [{ provide: JR_CONFIRM_DIALOG, useValue: AppConfirm }]
601
+ ```
602
+
603
+ ## Submitting a form
604
+
605
+ `validateForm` validates every bound field at once and writes
606
+ `{ valid, errors }` to `/formValidation` (or the `statePath` you pass). What it
607
+ cannot do is act on the answer: a model writing a submit button had to emit one
608
+ binding for the validation and hope the app's own handler re-checked the form.
609
+
610
+ `submitForm` is both halves in one binding — validate everything, and dispatch
611
+ the submit only if it all passes:
612
+
613
+ ```json
614
+ {
615
+ "type": "Button",
616
+ "props": { "label": "Save" },
617
+ "on": {
618
+ "press": {
619
+ "action": "submitForm",
620
+ "params": {
621
+ "action": "saveUser",
622
+ "params": { "email": { "$state": "/email" } }
623
+ },
624
+ "onSuccess": { "navigate": "/thanks" }
625
+ }
626
+ },
627
+ "children": []
628
+ }
629
+ ```
630
+
631
+ An invalid form stops there, with the errors written to state and every field
632
+ marked validated so its message is on screen. A valid one dispatches
633
+ `saveUser` as an ordinary action: the same handler lookup, the same `confirm`
634
+ (asked after validation — there is no point asking about a form that cannot be
635
+ submitted), the same `onSuccess` / `onError`, the same loading state. `params`
636
+ resolves `{ "$state": "/path" }` one level down, the way `pushState`'s `value`
637
+ does.
638
+
265
639
  ## Security
266
640
 
267
641
  Specs are attacker-shaped input: whatever produced one — a model, a prompt, a
@@ -278,9 +652,12 @@ text reaches the DOM through Angular interpolation. Script injection through a
278
652
  spec is not a thing you have to defend against.
279
653
 
280
654
  **A spec can only name actions you registered.** Built-ins (`setState`,
281
- `pushState`, `removeState`, `push`, `pop`, `validateForm`) are handled inside
282
- the renderer; every other action name is looked up in the `handlers` you pass.
283
- An unrecognised name logs a warning and does nothing.
655
+ `pushState`, `removeState`, `push`, `pop`, `validateForm`, `submitForm`) are
656
+ handled inside the renderer; every other action name is looked up in the
657
+ `handlers` you pass. An unrecognised name logs a warning and does nothing.
658
+ `submitForm` is no exception to this: the action it is told to submit goes
659
+ through the same lookup, so it gates a handler you registered rather than
660
+ reaching one you did not.
284
661
 
285
662
  The exception is `onAction`, which is a deliberate catch-all: when you pass it,
286
663
  **every** action name in the spec reaches it, including ones you never put in
@@ -299,10 +676,36 @@ string to the `navigate` callback you provide, verbatim. Treat it as untrusted:
299
676
  match it against known routes, and never hand it to `window.location` or
300
677
  `router.navigateByUrl` unchecked.
301
678
 
302
- **A spec sizes its own render tree.** `repeat` iterates a state array the spec
303
- may itself have supplied, so specs are a denial-of-service surface against the
304
- browser tab. Cap spec size and array lengths at the boundary where you accept
305
- one.
679
+ **A spec cannot render forever.** Two elements naming each other as children,
680
+ or one naming itself, would recurse until the tab died. The renderer refuses to
681
+ draw an element that is rendering itself again _without reading any deeper into
682
+ state_, so the cycle is broken where it closes and everything above it still
683
+ renders. This holds in every mode, including `validate="off"` and mid-stream:
684
+ it is a crash guard, not an opinion about spec quality. Core's `validateSpec`
685
+ does not report cycles, so nothing else in the stack catches this for you.
686
+
687
+ Recursion that goes somewhere is untouched. A tree — a comment thread, a file
688
+ browser, a nested menu — is an element repeating over a path relative to the
689
+ item it is already inside (`{"$item": "children"}`) and rendering itself for
690
+ each one, so every pass reads one level further in and the drawing ends where
691
+ the data does. What the guard stops is the pass that reads the same array
692
+ again: `repeat` over a fixed `/items` inside itself never runs out, however
693
+ much data there is.
694
+
695
+ **A spec sizes its own render tree, up to the caps you set.** `repeat` iterates
696
+ a state array the spec may itself have supplied, and nesting costs a component
697
+ per level, so specs are a denial-of-service surface against the browser tab.
698
+ `renderLimits` is the cap: `maxElements`, `maxDepth` and `maxRepeatItems`,
699
+ enforced in every mode. They are unset by default — a renderer cannot guess
700
+ what your catalog considers a reasonable page — so an app taking specs it did
701
+ not generate should set all three. See
702
+ [Capping what a spec may cost](#capping-what-a-spec-may-cost).
703
+
704
+ **A spec can name components you never built.** `validate` with a `catalog`
705
+ reports every `type` the catalog does not define and every prop its schema
706
+ rejects; `strict` refuses such a spec outright. Without a catalog the renderer
707
+ only warns and draws nothing in that element's place, which degrades well but
708
+ tells you nothing until you read the console.
306
709
 
307
710
  **`confirm` is a UX affordance, not a security control.** It routes an action
308
711
  through the confirmation dialog before the handler runs, but it is set on the
@@ -314,28 +717,43 @@ handler, on the server.
314
717
 
315
718
  Components: `JsonRenderer` (`<json-render>`), `JrChildren`, `JrConfirmDialog`, `JrElement`, `JrRepeatScope`.
316
719
 
317
- Injectables/helpers: `injectRenderContext`, `injectRepeatScope`, `injectStateStore`, `injectStateValue`, `injectStateBinding`, `injectBoundProp`, `injectActions`, `injectAction`, `injectValidation`, `injectFieldValidation`, `injectUIStream`, `injectChatUI`, `injectDevtoolsActive`, `jsonRenderMessage`.
720
+ Injectables/helpers: `injectRenderContext`, `injectElementKey`, `injectRepeatScope`, `injectStateStore`, `injectStateValue`, `injectStateBinding`, `injectBoundProp`, `injectActions`, `injectAction`, `injectValidation`, `injectFieldValidation`, `injectUIStream`, `injectChatUI`, `injectDevtoolsActive`, `injectConfirmContext`, `jsonRenderMessage`, `isActionCancelled`, `checkSpec`.
721
+
722
+ Tokens: `JR_CONFIRM_DIALOG` (replace the confirmation dialog), `JR_CONFIRM_LABELS` (its two words), `CONFIRM_CONTEXT`, `RENDER_CONTEXT`, `REPEAT_SCOPE`.
723
+
724
+ Spec checking: `checkSpec`, `formatSpecCheckIssues`, and the types
725
+ `RenderLimits`, `SpecCheck`, `SpecCheckIssue`, `SpecCheckIssueCode`,
726
+ `SpecCheckOptions`, `SpecCatalog`, `SpecValidationMode`.
727
+
728
+ `injectActions().execute()` rejects when the user dismisses a `confirm`
729
+ dialog, which is a normal gesture rather than a failure — `isActionCancelled(error)`
730
+ is how you tell the two apart.
318
731
 
319
732
  Registry & schema: `defineRegistry`, `createStoreSetState`, `schema`.
320
733
 
734
+ Testing (`ngx-json-render/testing`): `renderSpec`, `recordedTransport`, `specStream`, `usageLine`.
735
+
321
736
  Everything from `@json-render/core` (types, `createStateStore`, `nestedToFlat`, prompt builders, spec validators, SpecStream compiler) composes with this package; the most common symbols are re-exported.
322
737
 
323
738
  ## Renderer inputs
324
739
 
325
- | Input | Type | Purpose |
326
- | --------------------- | ------------------------------------ | ------------------------------------------------------ |
327
- | `spec` | `Spec \| null` | The UI spec (may be partial while streaming) |
328
- | `registry` | `ComponentRegistry` | Catalog type → Angular component |
329
- | `loading` | `boolean` | Suppress missing-element warnings while streaming |
330
- | `fallback` | `Type<unknown>` | Component for unknown types |
331
- | `state` | `StateModel` | Initial state (uncontrolled; defaults to `spec.state`) |
332
- | `store` | `StateStore` | External store (controlled mode) |
333
- | `handlers` | `Record<string, ActionHandler>` | Action handlers |
334
- | `onAction` | `(name, params) => unknown` | Catch-all action handler |
335
- | `navigate` | `(path) => void` | Used by `onSuccess: { navigate }` |
336
- | `validationFunctions` | `Record<string, ValidationFunction>` | Custom validation |
337
- | `functions` | `Record<string, ComputedFunction>` | `$computed` functions |
338
- | `directives` | `DirectiveDefinition[]` | Custom `$`-prefixed expressions |
740
+ | Input | Type | Purpose |
741
+ | --------------------- | ------------------------------------ | ------------------------------------------------------- |
742
+ | `spec` | `Spec \| null` | The UI spec (may be partial while streaming) |
743
+ | `registry` | `ComponentRegistry` | Catalog type → Angular component |
744
+ | `loading` | `boolean` | Suppress missing-element warnings while streaming |
745
+ | `fallback` | `Type<unknown>` | Component for unknown types |
746
+ | `validate` | `'off' \| 'warn' \| 'strict'` | Check the settled spec's structure (default `'off'`) |
747
+ | `renderLimits` | `RenderLimits` | Cap elements, depth and repeat expansion (default none) |
748
+ | `catalog` | `Catalog` | Also check types and props against the catalog |
749
+ | `state` | `StateModel` | Initial state (uncontrolled; defaults to `spec.state`) |
750
+ | `store` | `StateStore` | External store (controlled mode) |
751
+ | `handlers` | `Record<string, ActionHandler>` | Action handlers |
752
+ | `onAction` | `(name, params) => unknown` | Catch-all action handler |
753
+ | `navigate` | `(path) => void` | Used by `onSuccess: { navigate }` |
754
+ | `validationFunctions` | `Record<string, ValidationFunction>` | Custom validation |
755
+ | `functions` | `Record<string, ComputedFunction>` | `$computed` functions |
756
+ | `directives` | `DirectiveDefinition[]` | Custom `$`-prefixed expressions |
339
757
 
340
758
  Output: `(stateChange)` — batched `{ path, value }[]` in uncontrolled mode.
341
759