caspian-utils 0.1.26 → 0.2.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.
@@ -350,6 +350,10 @@ Important:
350
350
  - Component scripts are plain JavaScript executed with `new Function(...)`. Do not use `import`, `export`, or top-level `await` inside them.
351
351
  - The runtime auto-returns supported top-level bindings from the script. Do not rely on manual `return { ... }` objects.
352
352
  - `pp.props` contains the current prop bag for the component.
353
+ - `pp.props` is computed from the **rendered root element's attributes**, not from any server-side signature. A prop that never lands on the root is `undefined` in the browser, with no server error and no console warning. In a Python component this means every prop the template references must be forwarded through `get_attributes({...}, props)`; see [components.md](./components.md#every-prop-a-template-reads-must-be-forwarded-to-the-root).
354
+ - Prop value types depend on how the attribute reaches the root. An unevaluated brace expression such as `volume="{vol}"` is evaluated in the parent component's scope and keeps its real type (number, boolean, object, function). A literal attribute such as `volume="0"` arrives as the **string** `"0"`, so `volume === 0` is false. A valueless attribute arrives as boolean `true`. Coerce or compare loosely when the value may be a server-rendered literal.
355
+ - Attribute names round-trip through kebab-case: `isFullscreen` renders as `is-fullscreen` and returns as `pp.props.isFullscreen`.
356
+ - Attribute names that are JavaScript reserved words are dropped from the prop bag, so `pp.props.class` and `pp.props.for` never exist. Use a non-reserved prop name when the script must read the value.
353
357
  - `pp.props.children` contains the root's initial inner HTML before the owned script is removed from the render template.
354
358
  - Props are not auto-injected as standalone top-level template or handler variables. Read them through `pp.props` or explicitly destructure them in the component script.
355
359
 
@@ -402,6 +406,13 @@ Hooks exposed inside component scripts through `pp`:
402
406
  - `pp.reducer(reducer, initialState)` returns `[state, dispatch]`.
403
407
  - `pp.context(token)` resolves a provided context value from ancestor components.
404
408
  - `pp.portal(ref, target?)` registers a ref-managed element for portal rendering and returns an object that includes `sourceParent`.
409
+ - `pp.id()` returns a stable, DOM-safe unique id for the life of that hook slot.
410
+ - `pp.syncExternalStore(subscribe, getSnapshot)` subscribes to a mutable source outside PulsePoint and rerenders when its snapshot changes.
411
+ - `pp.imperativeHandle(ref, createHandle, deps?)` publishes an imperative API on a parent-owned ref instead of the raw DOM node.
412
+ - `pp.transition()` returns `[isPending, startTransition]`.
413
+ - `pp.deferredValue(value, initialValue?)` returns a copy of `value` that catches up one commit later.
414
+ - `pp.optimistic(passthrough, reducer?)` returns `[optimisticState, addOptimistic]`.
415
+ - `pp.errorBoundary()` returns `[error, reset]` and marks the component as an error boundary.
405
416
  - `pp.props` exposes the current props.
406
417
 
407
418
  Global helpers exposed through the `pp` singleton and also merged into the component runtime:
@@ -425,6 +436,12 @@ Notes:
425
436
  - Older docs may call the RPC helper `pp.fetchFunction()`. In the current bundled runtime the implemented global API is `pp.rpc()`.
426
437
  - Keep template-facing bindings at the top level so the AST-based exporter can see them.
427
438
  - For predictable code generation, prefer passing an explicit dependency array to `pp.effect`, `pp.layoutEffect`, `pp.memo`, and `pp.callback`.
439
+ - Top-level destructuring is exported to template scope for every form: array patterns, object patterns, nested patterns, defaults, rest elements, and holes. `const [isPending, startTransition] = pp.transition()` and `const [error, reset] = pp.errorBoundary()` reach the template the same way `pp.state` does.
440
+ - `pp.id()` is derived from the component id plus the hook slot, so the same slot keeps its id across rerenders and two instances of the same component never collide. Use it for `id`/`for` pairing and `aria-*` references instead of hand-rolled counters.
441
+ - `pp.syncExternalStore` requires a referentially stable `subscribe`. Wrap it in `pp.callback(..., [])`, otherwise the store is unsubscribed and resubscribed on every render. The runtime re-reads the snapshot immediately after subscribing so a change that lands between render and subscription is not lost.
442
+ - `pp.transition` does not deprioritize rendering the way React's concurrent scheduler does, because PulsePoint renders synchronously. It does give a correct `isPending` flag for both synchronous and promise-returning scopes, which is the common use (disabling a submit button while `pp.rpc()` resolves). Overlapping scopes stay pending until the last one settles.
443
+ - `pp.optimistic` drops its pending actions as soon as `passthrough` changes, which is the point at which the server has confirmed or rejected the guess. Without a reducer, each action replaces the value.
444
+ - `pp.deferredValue` lags by one commit. Use it to keep an input responsive while an expensive derived subtree catches up.
428
445
 
429
446
  Effect with cleanup and dependencies:
430
447
 
@@ -513,6 +530,160 @@ Portal pattern for overlays that must escape clipping ancestors:
513
530
 
514
531
  `pp.portal(ref)` moves the ref-managed element to `document.body` by default, or to the element passed as the second argument. Portaled content keeps its logical component ancestry, so context, props, and events keep working.
515
532
 
533
+ Stable ids for accessible field pairing:
534
+
535
+ ```html
536
+ <div>
537
+ <label for="{emailId}">Email</label>
538
+ <input id="{emailId}" type="email" aria-describedby="{hintId}" />
539
+ <p id="{hintId}">We never share this.</p>
540
+
541
+ <script>
542
+ const emailId = pp.id();
543
+ const hintId = pp.id();
544
+ </script>
545
+ </div>
546
+ ```
547
+
548
+ Never derive ids from an index or a module-level counter. `pp.id()` is the only form that stays stable across rerenders and unique across instances of the same component.
549
+
550
+ External store subscription:
551
+
552
+ ```html
553
+ <section>
554
+ <p>{isNarrow ? "Compact layout" : "Wide layout"}</p>
555
+
556
+ <script>
557
+ const subscribe = pp.callback((onStoreChange) => {
558
+ const query = window.matchMedia("(max-width: 640px)");
559
+ query.addEventListener("change", onStoreChange);
560
+ return () => query.removeEventListener("change", onStoreChange);
561
+ }, []);
562
+
563
+ const isNarrow = pp.syncExternalStore(
564
+ subscribe,
565
+ () => window.matchMedia("(max-width: 640px)").matches
566
+ );
567
+ </script>
568
+ </section>
569
+ ```
570
+
571
+ Prefer this over `pp.state` plus `pp.effect` for anything the component does not own. It reads the snapshot during render and closes the gap between render and subscription, which a hand-rolled effect silently drops.
572
+
573
+ Imperative handle for a child-owned API. The parent passes its own ref down as an ordinary prop:
574
+
575
+ ```html
576
+ <!-- parent -->
577
+ <div>
578
+ <button onclick="{dialogControl.current.open()}">Open</button>
579
+ <x-confirm-dialog control-ref="{dialogControl}" />
580
+
581
+ <script>
582
+ const dialogControl = pp.ref(null);
583
+ </script>
584
+ </div>
585
+ ```
586
+
587
+ ```html
588
+ <!-- ConfirmDialog component -->
589
+ <section>
590
+ <script>
591
+ const [open, setOpen] = pp.state(false);
592
+
593
+ pp.imperativeHandle(
594
+ pp.props.controlRef,
595
+ () => ({
596
+ open: () => setOpen(true),
597
+ close: () => setOpen(false),
598
+ }),
599
+ []
600
+ );
601
+ </script>
602
+
603
+ <div hidden="{!open}">
604
+ <button onclick="{setOpen(false)}">Close</button>
605
+ </div>
606
+ </section>
607
+ ```
608
+
609
+ Notes:
610
+
611
+ - A prop whose bound expression evaluates to an object is passed through by reference, so `control-ref="{dialogControl}"` arrives as the real ref object on `pp.props.controlRef`.
612
+ - Do **not** author `pp-ref-forward` to make this work. That attribute is runtime-managed, and `pp-ref` on an `x-*` tag binds the child's root DOM node, which is a different feature. Use a normal prop when the parent needs a child-defined API rather than an element.
613
+ - The handle is published in the layout-effect phase and cleared on unmount, so parents should call into it from event handlers or effects, not during their own render.
614
+
615
+ Optimistic update around an RPC call:
616
+
617
+ ```html
618
+ <section>
619
+ <button onclick="{addLike()}">Like ({likes})</button>
620
+
621
+ <script>
622
+ const [confirmedLikes, setConfirmedLikes] = pp.state(pp.props.likes ?? 0);
623
+ const [likes, addOptimisticLike] = pp.optimistic(
624
+ confirmedLikes,
625
+ (state, delta) => state + delta
626
+ );
627
+
628
+ async function addLike() {
629
+ addOptimisticLike(1);
630
+ const result = await pp.rpc("like_post", { postId: pp.props.postId });
631
+ setConfirmedLikes(result.likes);
632
+ }
633
+ </script>
634
+ </section>
635
+ ```
636
+
637
+ The optimistic `+1` is discarded the moment `confirmedLikes` changes, so the server value never double-counts the guess.
638
+
639
+ Transition for pending async work:
640
+
641
+ ```html
642
+ <section>
643
+ <button onclick="{save()}" disabled="{isSaving}">
644
+ {isSaving ? "Saving..." : "Save"}
645
+ </button>
646
+
647
+ <script>
648
+ const [isSaving, startSaving] = pp.transition();
649
+
650
+ function save() {
651
+ startSaving(() => pp.rpc("save_profile", { name: pp.props.name }));
652
+ }
653
+ </script>
654
+ </section>
655
+ ```
656
+
657
+ ### Error boundaries
658
+
659
+ `pp.errorBoundary()` marks a component as the recovery point for render and effect failures. Without one, a throwing render is logged to the console and leaves the subtree in whatever state it reached.
660
+
661
+ ```html
662
+ <section>
663
+ <script>
664
+ const [error, reset] = pp.errorBoundary();
665
+ </script>
666
+
667
+ <div hidden="{!error}">
668
+ <p>Something went wrong: {error?.message}</p>
669
+ <button onclick="{reset()}">Try again</button>
670
+ </div>
671
+
672
+ <div hidden="{!!error}">
673
+ <x-report-chart />
674
+ </div>
675
+ </section>
676
+ ```
677
+
678
+ Behavior to rely on:
679
+
680
+ - Errors thrown during a child's render or effects bubble up the logical component ancestry to the nearest boundary. If nothing catches, the runtime logs `[PP-ERROR] Render Cycle Failed` as before.
681
+ - Unlike React, a boundary also catches throws from its **own** render and effects, because in Caspian the boundary and its fallback markup live in the same single-root component.
682
+ - Effect callbacks and effect cleanups take the same path as render failures, so a throwing subscription teardown is not silently swallowed.
683
+ - The boundary **latches**: it keeps holding the error until `reset()` is called.
684
+ - A boundary absorbs at most five errors without an intervening `reset()`, then logs and stops catching. This stops a fallback that itself throws from spinning the render loop. `reset()` re-arms it.
685
+ - Event handler errors are not routed to boundaries. Handle those with `try`/`catch` inside the handler, which matches React.
686
+
516
687
  ## Context
517
688
 
518
689
  Context is implemented in the current runtime with a React-style provider pattern rather than a legacy `pp.provideContext(...)` helper. Because Caspian templates are HTML-first, authored provider tags should be written in lowercase HTML form, for example `<themecontext.provider>`, even when the JavaScript token is named `ThemeContext`.
@@ -567,11 +738,11 @@ Consumer example for a child component that receives the token through props:
567
738
 
568
739
  When a child component needs the same token object, pass it from the provider scope as a prop such as `theme-token="{ThemeContext}"`.
569
740
 
570
- ## Props and nested components
571
-
572
- In Caspian single-file Python components, the root element's attributes must be authored via `get_attributes(...)` and `{{ attributes }}`; see [components.md](./components.md#receiving-props-in-a-python-component). Attributes accepted from an `x-*` tag but not forwarded to the rendered root are dropped and never reach `pp.props`.
573
-
574
- - Child component props are derived from DOM attributes.
741
+ ## Props and nested components
742
+
743
+ In Caspian single-file Python components, the root element's attributes must be authored via `get_attributes(...)` and `{{ attributes }}`; see [components.md](./components.md#receiving-props-in-a-python-component). Attributes accepted from an `x-*` tag but not forwarded to the rendered root are dropped and never reach `pp.props`.
744
+
745
+ - Child component props are derived from DOM attributes.
575
746
  - Attribute names are converted from kebab-case to camelCase for the prop bag.
576
747
  - Native `on*` attributes and `pp-component` are not included in props.
577
748
  - Empty attributes become boolean `true` props.
@@ -606,6 +777,7 @@ Nested components:
606
777
  - Use `pp-spread="{...attrs}"` to spread an object expression into attributes.
607
778
  - `pp-spread` omits nullish values, omits known HTML boolean attributes when their value is `false`, emits them bare when `true`, preserves string-valued `aria-*`/`data-*` booleans, and escapes `&`, `"`, and `<` in emitted attribute values.
608
779
  - Use plain `key` for keyed diffing. `pp-key` is not implemented.
780
+ - Only top-level declarations in the component script reach template scope, but every destructuring shape is supported there: `const [a, b] = anyTuple()`, `const [, only] = pair`, `const [first = 1, ...rest] = list`, `const [{ id }] = rows`, and the object-pattern forms. A binding declared inside a block or function is not exported.
609
781
 
610
782
  Example:
611
783
 
@@ -889,6 +1061,12 @@ These are current runtime caveats that matter for authors and AI tools:
889
1061
  - `pp.context()` resolves through ancestor components, not the current component's own pending providers.
890
1062
  - `pp.provideContext` is not part of the current runtime API. Use an HTML-first lowercase provider tag such as `<themecontext.provider>`.
891
1063
  - `pp.portal()` preserves logical ancestry through the registry, so context and prop refresh behavior continue to work through portaled descendants.
1064
+ - Top-level array destructuring is exported to template scope for any initializer, not only `pp.state` and `pp.reducer`. A binding declared inside a block or function is still not exported.
1065
+ - `pp.transition()` does not deprioritize work. PulsePoint renders synchronously; the hook provides an accurate `isPending` flag, not a concurrent scheduler.
1066
+ - `pp.syncExternalStore(...)` resubscribes whenever `subscribe` changes identity, so pass a `pp.callback(..., [])`-wrapped function.
1067
+ - `pp.optimistic(...)` clears its pending actions on the render where `passthrough` changes, using a render-phase update rather than an effect, so there is no frame showing stale optimistic output.
1068
+ - `pp.errorBoundary()` catches render, effect, and effect-cleanup throws, including the boundary component's own. It does not catch event-handler errors. It latches until `reset()` and stops catching after five captures without a reset.
1069
+ - `pp.imperativeHandle(...)` needs the parent's ref passed down as an ordinary prop. `pp-ref` on an `x-*` tag is a different feature that binds the child's root DOM node, and `pp-ref-forward` is runtime-managed and must not be authored.
892
1070
 
893
1071
  ## Final reminder
894
1072
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caspian-utils",
3
- "version": "0.1.26",
3
+ "version": "0.2.0",
4
4
  "description": "Caspian tooling",
5
5
  "main": "index.js",
6
6
  "scripts": {