caspian-utils 0.2.4 → 0.2.6

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: PulsePoint Runtime Guide
3
- description: Use this page when the task mentions PulsePoint, performance, rerenders, slow inputs or searches, `pp.state`, `pp.ref`, `pp.effect`, `pp-ref`, `pp-style`, `pp-for`, portals, `pp-reset-scroll`, SPA navigation, or `public/js/pp-reactive-v2.js`. Read "PulsePoint Is Not JSX" before writing templates and "High-performance authoring" before changing runtime reconciliation.
3
+ description: Use this page when the task mentions PulsePoint, performance, rerenders, slow inputs or searches, `pp.state`, `pp.ref`, `pp.effect`, `pp-ref`, `pp-style`, `pp-for`, portals, `pp-reset-scroll`, SPA navigation, or `public/js/pp-reactive-v2.js`. Read "PulsePoint Is Not JSX" before writing templates and "High-performance authoring" before changing runtime reconciliation.
4
4
  related:
5
5
  title: Related docs
6
6
  description: Read the components, routing, data-fetching, and project-structure docs alongside the PulsePoint runtime contract.
@@ -771,172 +771,308 @@ Behavior to rely on:
771
771
  - 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.
772
772
  - Event handler errors are not routed to boundaries. Handle those with `try`/`catch` inside the handler, which matches React.
773
773
 
774
- ## High-performance authoring
775
-
776
- PulsePoint renders synchronously when a component requests an update. That makes state ownership and component boundaries part of the performance contract: the runtime can optimize reconciliation, but it cannot decide that an authored state change was unnecessary.
777
-
778
- ### State means "render required"
779
-
780
- Use `pp.state(...)` when changing the value must affect at least one render-facing behavior:
781
-
782
- - template text, attributes, lists, or conditional visibility
783
- - a bound child prop or provided context value
784
- - a memo, effect, or other render dependency that must follow the value
785
- - authoritative data returned from an RPC call
786
-
787
- Use `pp.ref(...)` when a mutable value must persist but changing it should not render:
788
-
789
- - debounce and timeout handles
790
- - request generations or stale-response tokens
791
- - pagination cursors that are not displayed
792
- - previous values and imperative integration handles
793
- - transient search text used only to build a later RPC payload
794
-
795
- A ref mutation is intentionally invisible to the template. Do not replace state with a ref when the UI is expected to update.
796
-
797
- ### Debounce frequency and render cost are different
798
-
799
- A debounce answers "how often should this work start?" It does not answer "how much work happens when it starts?"
800
-
801
- This shape can still stall a large owner:
802
-
803
- ```html
804
- <script>
805
- const [query, setQuery] = pp.state("");
806
-
807
- function onSearch(value) {
808
- clearTimeout(onSearch.timer);
809
- onSearch.timer = setTimeout(() => setQuery(value), 300);
810
- }
811
-
812
- pp.effect(() => {
813
- loadRows(query);
814
- }, [query]);
815
- </script>
816
- ```
817
-
818
- When the timer fires, `setQuery` asks the whole owning component to render even if `query` never appears in its markup. If that owner also contains a large list, dialogs, forms, providers, or many nested components, typing that resumes near the timer can compete with the render.
819
-
820
- When query text exists only for an RPC request, keep the query and timer in refs, debounce the request itself, and render only the accepted result:
821
-
822
- ```html
823
- <section>
824
- <input
825
- type="search"
826
- placeholder="Search products"
827
- oninput="scheduleSearch(event.target.value)"
828
- />
829
-
830
- <template pp-for="row in rows">
831
- <article key="{row.id}">{row.label}</article>
832
- </template>
833
-
834
- <script>
835
- const [rows, setRows] = pp.state([]);
836
- const query = pp.ref("");
837
- const timer = pp.ref(null);
838
- const generation = pp.ref(0);
839
-
840
- function scheduleSearch(value) {
841
- query.current = value;
842
- const requestGeneration = ++generation.current;
843
-
844
- clearTimeout(timer.current);
845
- timer.current = setTimeout(
846
- () => search(requestGeneration),
847
- 300
848
- );
849
- }
850
-
851
- async function search(requestGeneration) {
852
- const response = await pp.rpc(
853
- "search_products",
854
- { query: query.current },
855
- { abortPrevious: true }
856
- );
857
-
858
- if (requestGeneration !== generation.current) return;
859
- setRows(response.items ?? []);
860
- }
861
-
862
- pp.effect(
863
- () => () => clearTimeout(timer.current),
864
- []
865
- );
866
- </script>
867
- </section>
868
- ```
869
-
870
- The native input remains immediate, timer/query bookkeeping does not render, and only the authoritative rows update the list. The generation check prevents an older response from overwriting a newer search. See [fetch-data.md](./fetch-data.md#search-filters-and-request-races) for the RPC-side rules.
871
-
872
- ### Keep high-frequency state close to the control
873
-
874
- Component boundaries define who owns a render. A keystroke-frequency value should live in the smallest component that needs to render from it.
875
-
876
- - Split a search/toolbar from a large result grid when their update frequencies differ.
877
- - Split frequently edited form state from unrelated dialogs, page shells, and providers.
878
- - Keep shared state in a parent only when multiple children genuinely need the same render-driving value.
879
- - Do not create wrapper components that still own all high-frequency state and therefore rerender every subtree.
880
-
881
- For a controlled input that genuinely must write state on every keystroke, keep its owner small. If an expensive derived consumer may lag by one commit, use `pp.deferredValue(...)` for that consumer:
882
-
883
- ```html
884
- <section>
885
- <input value="{query}" oninput="setQuery(event.target.value)" />
886
- <x-large-results query="{deferredQuery}" />
887
-
888
- <script>
889
- const [query, setQuery] = pp.state("");
890
- const deferredQuery = pp.deferredValue(query, "");
891
- </script>
892
- </section>
893
- ```
894
-
895
- `pp.deferredValue` is not a substitute for correct ownership, and `pp.transition()` does not time-slice or deprioritize work. PulsePoint remains synchronous.
896
-
897
- ### Avoid duplicate and invisible commits
898
-
899
- Each setter that changes a value requests a render, even when the eventual UI change is small.
900
-
901
- - Do not set `loading=true` for a background refresh when existing rows remain visible and no loading indicator changes.
902
- - Batch related state changes in the same synchronous turn when possible; PulsePoint coalesces scheduled updates.
903
- - Do not mirror the same value in multiple state hooks.
904
- - Keep pagination/request cursors in refs when moving the cursor should only start work, then put returned rows and visible metadata in state.
905
- - Memoize only genuinely expensive derivations or values whose identity matters. `pp.memo` does not make an unnecessary owner render free.
906
- - Use keyed `pp-for` rows so reconciliation can preserve row identity.
907
-
908
- ### Diagnose the owner before the runtime
909
-
910
- Classify the symptom before editing `public/js/pp-reactive-v2.js`:
911
-
912
- | Evidence | Likely classification | First action |
913
- | --- | --- | --- |
914
- | A state setter fires, but the changed value is not rendered | Component authoring issue | Move non-rendering bookkeeping to a ref or call the action directly |
915
- | A debounce fires just before typing stalls | Component/request ownership issue | Debounce the RPC or expensive action, not an unnecessary broad-owner setter |
916
- | Loading and result setters produce two large commits | Component authoring issue | Remove invisible loading commits or isolate the visible indicator |
917
- | A high-frequency control owns a large list/provider/dialog subtree | Component-boundary issue | Move the state to a smaller focused component |
918
- | Rendered HTML is byte-identical but a large stable subtree is still reconciled | Possible runtime issue | Profile runtime phases and verify the stable-DOM case |
919
- | One small necessary binding change spends disproportionate time in DOM diffing or nested bootstrap | Possible runtime issue | Build a focused reproduction and benchmark before changing reconciliation |
920
-
921
- Enable measurements only while profiling:
922
-
923
- ```html
924
- <script>
925
- pp.enablePerf();
926
- pp.resetPerfStats();
927
-
928
- // Reproduce one interaction, then inspect:
929
- console.log(pp.getPerfStats());
930
-
931
- pp.disablePerf();
932
- </script>
933
- ```
934
-
935
- Correlate the expensive component and phase with the setter or RPC completion that requested it. Compare the same interaction, DOM size, data, and number of repetitions before and after a change. Input paint timing and RPC latency are separate from render timing, so measure them separately.
936
-
937
- Do not "fix" a PulsePoint performance problem with `querySelector`, manual listeners, `innerHTML`, or parallel DOM state. Those bypass ownership instead of correcting it.
938
-
939
- ## Context
774
+ ## High-performance authoring
775
+
776
+ PulsePoint renders synchronously when a component requests an update. That makes state ownership and component boundaries part of the performance contract: the runtime can optimize reconciliation, but it cannot decide that an authored state change was unnecessary.
777
+
778
+ ### State means "render required"
779
+
780
+ Use `pp.state(...)` when changing the value must affect at least one render-facing behavior:
781
+
782
+ - template text, attributes, lists, or conditional visibility
783
+ - a bound child prop or provided context value
784
+ - a memo, effect, or other render dependency that must follow the value
785
+ - authoritative data returned from an RPC call
786
+
787
+ Use `pp.ref(...)` when a mutable value must persist but changing it should not render:
788
+
789
+ - debounce and timeout handles
790
+ - request generations or stale-response tokens
791
+ - pagination cursors that are not displayed
792
+ - previous values and imperative integration handles
793
+ - transient search text used only to build a later RPC payload
794
+
795
+ A ref mutation is intentionally invisible to the template. Do not replace state with a ref when the UI is expected to update.
796
+
797
+ ### Debounce frequency and render cost are different
798
+
799
+ A debounce answers "how often should this work start?" It does not answer "how much work happens when it starts?"
800
+
801
+ This shape can still stall a large owner:
802
+
803
+ ```html
804
+ <script>
805
+ const [query, setQuery] = pp.state("");
806
+
807
+ function onSearch(value) {
808
+ clearTimeout(onSearch.timer);
809
+ onSearch.timer = setTimeout(() => setQuery(value), 300);
810
+ }
811
+
812
+ pp.effect(() => {
813
+ loadRows(query);
814
+ }, [query]);
815
+ </script>
816
+ ```
817
+
818
+ When the timer fires, `setQuery` asks the whole owning component to render even if `query` never appears in its markup. If that owner also contains a large list, dialogs, forms, providers, or many nested components, typing that resumes near the timer can compete with the render.
819
+
820
+ When query text exists only for an RPC request, keep the query and timer in refs, debounce the request itself, and render only the accepted result:
821
+
822
+ ```html
823
+ <section>
824
+ <input
825
+ type="search"
826
+ placeholder="Search products"
827
+ oninput="scheduleSearch(event.target.value)"
828
+ />
829
+
830
+ <template pp-for="row in rows">
831
+ <article key="{row.id}">{row.label}</article>
832
+ </template>
833
+
834
+ <script>
835
+ const [rows, setRows] = pp.state([]);
836
+ const query = pp.ref("");
837
+ const timer = pp.ref(null);
838
+ const generation = pp.ref(0);
839
+
840
+ function scheduleSearch(value) {
841
+ query.current = value;
842
+ const requestGeneration = ++generation.current;
843
+
844
+ clearTimeout(timer.current);
845
+ timer.current = setTimeout(
846
+ () => search(requestGeneration),
847
+ 300
848
+ );
849
+ }
850
+
851
+ async function search(requestGeneration) {
852
+ const response = await pp.rpc(
853
+ "search_products",
854
+ { query: query.current },
855
+ { abortPrevious: true }
856
+ );
857
+
858
+ if (requestGeneration !== generation.current) return;
859
+ setRows(response.items ?? []);
860
+ }
861
+
862
+ pp.effect(
863
+ () => () => clearTimeout(timer.current),
864
+ []
865
+ );
866
+ </script>
867
+ </section>
868
+ ```
869
+
870
+ The native input remains immediate, timer/query bookkeeping does not render, and only the authoritative rows update the list. The generation check prevents an older response from overwriting a newer search. See [fetch-data.md](./fetch-data.md#search-filters-and-request-races) for the RPC-side rules.
871
+
872
+ ### Keep high-frequency state close to the control
873
+
874
+ Component boundaries define who owns a render. A keystroke-frequency value should live in the smallest component that needs to render from it.
875
+
876
+ - Split a search/toolbar from a large result grid when their update frequencies differ.
877
+ - Split frequently edited form state from unrelated dialogs, page shells, and providers.
878
+ - Keep shared state in a parent only when multiple children genuinely need the same render-driving value.
879
+ - Do not create wrapper components that still own all high-frequency state and therefore rerender every subtree.
880
+
881
+ For a controlled input that genuinely must write state on every keystroke, keep its owner small. If an expensive derived consumer may lag by one commit, use `pp.deferredValue(...)` for that consumer:
882
+
883
+ ```html
884
+ <section>
885
+ <input value="{query}" oninput="setQuery(event.target.value)" />
886
+ <x-large-results query="{deferredQuery}" />
887
+
888
+ <script>
889
+ const [query, setQuery] = pp.state("");
890
+ const deferredQuery = pp.deferredValue(query, "");
891
+ </script>
892
+ </section>
893
+ ```
894
+
895
+ `pp.deferredValue` is not a substitute for correct ownership, and `pp.transition()` does not time-slice or deprioritize work. PulsePoint remains synchronous.
896
+
897
+ ### Avoid duplicate and invisible commits
898
+
899
+ Each setter that changes a value requests a render, even when the eventual UI change is small.
900
+
901
+ - Do not set `loading=true` for a background refresh when existing rows remain visible and no loading indicator changes.
902
+ - Batch related state changes in the same synchronous turn when possible; PulsePoint coalesces scheduled updates.
903
+ - Do not mirror the same value in multiple state hooks.
904
+ - Keep pagination/request cursors in refs when moving the cursor should only start work, then put returned rows and visible metadata in state.
905
+ - Memoize only genuinely expensive derivations or values whose identity matters. `pp.memo` does not make an unnecessary owner render free.
906
+ - Use keyed `pp-for` rows so reconciliation can preserve row identity.
907
+
908
+ ### Keyed rows are reconciled per row, not per list
909
+
910
+ A render rebuilds the whole component as one HTML string and reconciles it against the live DOM. For a list, that used to mean changing one row cost the same as rebuilding every row: the entire list was re-serialized, re-parsed and re-walked.
911
+
912
+ The runtime now remembers the markup each keyed row produced. A row that renders byte-identically again is not re-parsed and not re-diffed — its live node is kept, along with its attributes, text and bound handlers. Runs of consecutive unchanged rows collapse together, so a list where a handful of rows changed costs about what those rows cost, largely independent of how long the list is.
913
+
914
+ This is automatic, but it only engages for rows the runtime can safely stand in for. A row participates when:
915
+
916
+ - the loop body renders **exactly one root element** per row,
917
+ - that element carries a non-empty, unique `key`,
918
+ - the loop is not nested inside another `pp-for`, and
919
+ - the row's markup contains no nested component boundary, owned slot content, or context provider.
920
+
921
+ Practical consequences when authoring a large list:
922
+
923
+ - **Always key rows.** An unkeyed row can never be reused, so an unkeyed list still reconciles in full.
924
+ - **Keep the row body single-rooted.** A loop body with two sibling elements, or with bare text next to the element, opts the whole loop out. Wrap the row in one element.
925
+ - **A row built entirely from a child component** (`<template pp-for="row in rows"><x-row key="{row.id}" … /></template>`) is reconciled the normal way, because the parent is responsible for refreshing that boundary's bound props. Prefer plain markup in the row when the list is large and its rows rarely change.
926
+ - Keep values that feed a row stable. A row whose markup is regenerated identically is free; a row whose markup differs by even one character is full work.
927
+
928
+ ### A mounted child boundary is reconciled by its attributes, not by its markup
929
+
930
+ The same idea applies to composition, which is the shape most pages actually have: a shell that assembles many `x-*` components rather than one component owning a wall of markup.
931
+
932
+ A child component's internals belong to the child, so the parent never reconciles into them — it only reconciles the child's root, whose attributes carry the props. Once the child is mounted, the parent therefore emits that root **with an empty body** instead of re-emitting the child's markup, which means a parent render no longer re-serializes and re-parses subtrees it was going to ignore. In a real browser, a shell of 30 small children went from 0.82 ms to about 0.30 ms per parent update, and the cost stopped scaling with how much markup the children contain.
933
+
934
+ This is automatic and needs no authoring change, but the shape still matters:
935
+
936
+ - **The first render of a boundary always emits its full markup**, because the child bootstraps from what the parent emitted. A boundary that a conditional removes and later restores starts that cycle again.
937
+ - **A boundary is excluded when the parent is responsible for its content**: owned/slot content (`pp-owner`), a context provider inside it, or a parent-owned `pp-ref` capture inside it. Those keep re-emitting in full.
938
+ - **Props stay live.** Attributes on the boundary root are emitted and reconciled on every render exactly as before, so a child whose props changed still re-renders.
939
+ - **Prefer passing data as props over projecting large slot content** into a frequently re-rendering shell — projected content is re-rendered by its owner, so it does not get this treatment.
940
+
941
+ If a reused boundary is ever found not to be mounted, the runtime logs `[PP-WARN] Nested boundary content reuse was abandoned…`, permanently disables the reuse for that component, and re-renders it from full markup. Seeing that warning means the cache and the DOM disagreed — report it rather than working around it.
942
+
943
+ Two related properties follow from the same idea, and they are worth knowing when you reason about what a parent render costs:
944
+
945
+ - **A bound prop is committed to the child's root only when its value changes.** `active="{selected === 3}"` is evaluated every render, but the attribute is written only on the render where the answer actually flips. The live attribute holds the evaluated value the whole time — it no longer flickers back to the authored `{expr}` text mid-render, so a `MutationObserver` or a CSS transition watching a component root sees only real changes.
946
+ - **A child whose props did not change is not re-walked.** A leaf child costs its parent one prop comparison, not a traversal of its subtree.
947
+
948
+ Together with the elided body, a mounted child that nothing changed about costs its parent: one prop evaluation per bound attribute, one shallow comparison, and no DOM writes.
949
+
950
+ ### Prop identity decides whether a child re-renders
951
+
952
+ A child re-renders when its props changed, and props are compared **shallowly, by identity**. Every brace expression on a boundary root is re-evaluated on each parent render, so an expression that *builds* a value returns a new identity every time and the child re-renders even when the data is identical.
953
+
954
+ This is the single most common cause of "my whole page re-renders when I type one character":
955
+
956
+ ```html
957
+ <!-- Every parent render creates a new array and a new function, so this child
958
+ re-renders on every parent render even when `products` never changed. -->
959
+ <x-product-table
960
+ rows="{products.filter((p) => p.active)}"
961
+ on-select="{(row) => setSelected(row.id)}"
962
+ />
963
+ ```
964
+
965
+ Give each of them a stable identity, and the child is skipped entirely when nothing it depends on changed:
966
+
967
+ ```html
968
+ <x-product-table
969
+ rows="{activeProducts}"
970
+ options="{tableOptions}"
971
+ on-select="{selectRow}"
972
+ />
973
+
974
+ <script>
975
+ const [products, setProducts] = pp.state([]);
976
+ const [selected, setSelected] = pp.state(null);
977
+
978
+ const activeProducts = pp.memo(
979
+ () => products.filter((p) => p.active),
980
+ [products]
981
+ );
982
+
983
+ // Constant for the life of the component.
984
+ const tableOptions = pp.memo(() => ({ dense: true }), []);
985
+
986
+ const selectRow = pp.callback((row) => setSelected(row.id), []);
987
+ </script>
988
+ ```
989
+
990
+ Rules of thumb:
991
+
992
+ - **Primitives are free.** `label="Total"`, `count="{items.length}"` and `active="{selected === row.id}"` compare by value, so they only "change" when the value really changes. Prefer passing primitives over passing an object the child immediately destructures.
993
+ - **Build object props in the script, never inline in the attribute.** An attribute value that starts with `{{` is read as a server template block, not a PulsePoint binding, so an inline object literal is not valid there — assign it to a name in `<script>` (memoized) and pass that name.
994
+ - **`pp.memo` for derived arrays/objects, `pp.callback` for handlers** passed as props. Both need a dependency array; without one they rebuild every render and nothing is gained.
995
+ - **Do not memoize primitives.** `pp.memo(() => a + b, [a, b])` costs more than the addition.
996
+ - This only matters for values crossing a component boundary. Inside one component's own markup, an inline object or arrow function is harmless.
997
+
998
+ ### Give a provider a stable context value
999
+
1000
+ Context values are compared by identity too, and a provider's consumers are refreshed whenever that identity changes. Providing a freshly built object makes **every consumer in the subtree** re-render on **every** provider render — the widest-blast-radius version of the problem above:
1001
+
1002
+ ```html
1003
+ <!-- Wrong: a new object every render, so all consumers refresh every render. -->
1004
+ <script>
1005
+ const [theme, setTheme] = pp.state("light");
1006
+ ThemeContext.Provider({ value: { theme, setTheme } });
1007
+ </script>
1008
+ ```
1009
+
1010
+ ```html
1011
+ <!-- Right: identity changes only when `theme` does. -->
1012
+ <script>
1013
+ const [theme, setTheme] = pp.state("light");
1014
+
1015
+ const themeValue = pp.memo(() => ({ theme, setTheme }), [theme]);
1016
+ ThemeContext.Provider({ value: themeValue });
1017
+ </script>
1018
+ ```
1019
+
1020
+ Split unrelated values into separate contexts when they change at different rates. A context carrying both a rarely-changing `user` and a per-keystroke `query` forces every `user` consumer to re-render on each keystroke; two contexts keep each consumer tied to what it actually reads.
1021
+
1022
+ ### Keep effects from doing work nobody asked for
1023
+
1024
+ An effect with no dependency array runs after **every** render of its component:
1025
+
1026
+ ```html
1027
+ <script>
1028
+ // Runs on every render, refetching on unrelated state changes.
1029
+ pp.effect(() => { loadRows(page); });
1030
+
1031
+ // Runs only when `page` changes.
1032
+ pp.effect(() => { loadRows(page); }, [page]);
1033
+
1034
+ // Runs once on mount.
1035
+ pp.effect(() => { subscribe(); return () => unsubscribe(); }, []);
1036
+ </script>
1037
+ ```
1038
+
1039
+ Dependencies are compared by identity as well, so an object or function rebuilt each render belongs in `pp.memo`/`pp.callback` before it is used as a dependency — otherwise the array never matches and the effect runs every render anyway.
1040
+
1041
+ ### Diagnose the owner before blaming the runtime
1042
+
1043
+ Nearly every "PulsePoint is slow" report is an authoring or ownership problem, and the fix is in the template or the component split — not in the runtime. Classify the symptom first:
1044
+
1045
+ | Evidence | Likely classification | First action |
1046
+ | --- | --- | --- |
1047
+ | A state setter fires, but the changed value is not rendered | Component authoring issue | Move non-rendering bookkeeping to a ref or call the action directly |
1048
+ | A debounce fires just before typing stalls | Component/request ownership issue | Debounce the RPC or expensive action, not an unnecessary broad-owner setter |
1049
+ | Loading and result setters produce two large commits | Component authoring issue | Remove invisible loading commits or isolate the visible indicator |
1050
+ | A high-frequency control owns a large list/provider/dialog subtree | Component-boundary issue | Move the state to a smaller focused component |
1051
+ | A child re-renders on every parent render although its visible props look unchanged | Component authoring issue | A prop is a new object/array/function identity each render — see "Prop identity decides whether a child re-renders" |
1052
+ | Every context consumer re-renders whenever the provider renders | Component authoring issue | The provided `value` is a fresh object each render — memoize it |
1053
+ | Changing one row of a large list costs about as much as changing every row | Component authoring issue | Check the row is keyed, single-rooted, and not a component boundary, so per-row reuse can engage |
1054
+ | Rendered HTML is byte-identical but a large stable subtree is still reconciled | Possible runtime issue | Capture `pp.getPerfStats()` for the interaction and report it with the component shape |
1055
+ | One small necessary binding change spends disproportionate time in `domDiff` or `bootstrapNested` | Possible runtime issue | Reduce it to the smallest reproducing template, then report it with the phase numbers |
1056
+
1057
+ Enable measurements only while profiling:
1058
+
1059
+ ```html
1060
+ <script>
1061
+ pp.enablePerf();
1062
+ pp.resetPerfStats();
1063
+
1064
+ // Reproduce one interaction, then inspect:
1065
+ console.log(pp.getPerfStats());
1066
+
1067
+ pp.disablePerf();
1068
+ </script>
1069
+ ```
1070
+
1071
+ Correlate the expensive component and phase with the setter or RPC completion that requested it. Compare the same interaction, DOM size, data, and number of repetitions before and after a change. Input paint timing and RPC latency are separate from render timing, so measure them separately.
1072
+
1073
+ Do not "fix" a PulsePoint performance problem with `querySelector`, manual listeners, `innerHTML`, or parallel DOM state. Those bypass ownership instead of correcting it.
1074
+
1075
+ ## Context
940
1076
 
941
1077
  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`.
942
1078
 
@@ -957,7 +1093,7 @@ Important:
957
1093
  - Context is component-level, not directive-based. There is no `pp-context` attribute.
958
1094
  - `pp.context(token)` resolves from ancestors. A component does not consume the value it provides in the same render.
959
1095
  - If provider and consumer live in different component script scopes, pass the token through props or store it in shared outer or global state.
960
- - The preferred authoring style is a lowercase provider tag in the template. The TypeScript runtime's `TemplateCompiler.transformContextProviderTags(...)` recognizes `*.provider` tags case-insensitively and rewrites them to runtime-owned `<pp-context-provider>` boundaries. The runtime context lookup is also case-insensitive, so `<themecontext.provider>` can resolve a script binding named `ThemeContext`.
1096
+ - The preferred authoring style is a lowercase provider tag in the template. The runtime recognizes `*.provider` tags case-insensitively and rewrites them to runtime-owned `<pp-context-provider>` boundaries. The context lookup is also case-insensitive, so `<themecontext.provider>` can resolve a script binding named `ThemeContext`.
961
1097
  - The runtime also supports imperative `ThemeContext.Provider({ value })` calls during render, but that form provides from the component boundary rather than documenting the HTML subtree shape. Use it only when component-wide provision is intended.
962
1098
  - Do not invent or document `pp.provideContext`. The current runtime explicitly does not expose it.
963
1099
 
@@ -1318,12 +1454,12 @@ Use these rules when generating or editing PulsePoint runtime code:
1318
1454
  - In authored Caspian templates, do not handwrite `pp-component` or `type="text/pp"`; let the render pipeline inject them.
1319
1455
  - For grouped subtrees, follow the section layout pattern in [routing.md](./routing.md), keep the shared interactive shell in the parent folder's `layout.html`, and keep route-specific PulsePoint code in each child `index.html`.
1320
1456
  - For grouped shells with independent shell and content scrolling, put `pp-reset-scroll="true"` on the content pane rather than the whole shell when only the page content should reset between child-route navigations.
1321
- - Prefer PulsePoint state and template directives over manual DOM mutation for reactive updates.
1322
- - Treat `pp.state(...)` as a request to render. Keep timers, request generations, undisplayed pagination cursors, and transient RPC-only query text in `pp.ref(...)`; do not move a value to a ref when markup or render dependencies must react to it.
1323
- - A debounce limits frequency but does not reduce the cost of the update it eventually runs. For server search, debounce the RPC, discard stale responses, and keep only accepted results in state instead of waking a page-sized owner through an undisplayed query state.
1324
- - Keep high-frequency input state in the smallest useful component boundary. Use `pp.deferredValue(...)` only when an expensive consumer may safely lag one commit, and remember that `pp.transition()` is not concurrent scheduling.
1325
- - Before changing runtime reconciliation, use `pp.enablePerf()` to decide whether the app requested a redundant broad render or stable byte-identical output still incurred disproportionate runtime work.
1326
- - Avoid generating ids, `data-*` state, `querySelector`, `getElementById`, `addEventListener`, manual `innerHTML`, or custom event buses for normal Caspian UI behavior.
1457
+ - Prefer PulsePoint state and template directives over manual DOM mutation for reactive updates.
1458
+ - Treat `pp.state(...)` as a request to render. Keep timers, request generations, undisplayed pagination cursors, and transient RPC-only query text in `pp.ref(...)`; do not move a value to a ref when markup or render dependencies must react to it.
1459
+ - A debounce limits frequency but does not reduce the cost of the update it eventually runs. For server search, debounce the RPC, discard stale responses, and keep only accepted results in state instead of waking a page-sized owner through an undisplayed query state.
1460
+ - Keep high-frequency input state in the smallest useful component boundary. Use `pp.deferredValue(...)` only when an expensive consumer may safely lag one commit, and remember that `pp.transition()` is not concurrent scheduling.
1461
+ - Before changing runtime reconciliation, use `pp.enablePerf()` to decide whether the app requested a redundant broad render or stable byte-identical output still incurred disproportionate runtime work.
1462
+ - Avoid generating ids, `data-*` state, `querySelector`, `getElementById`, `addEventListener`, manual `innerHTML`, or custom event buses for normal Caspian UI behavior.
1327
1463
  - Avoid data-attribute click wiring such as `data-action="save"` plus a delegated listener. Use `onclick="save()"` or `onsubmit="{save(event)}"` in authored HTML and keep reactive state in `pp.state(...)`.
1328
1464
  - If you are explicitly editing raw runtime HTML or internals, keep `pp-component` unique per live instance.
1329
1465
  - In authored templates, use a plain `<script>` inside the root. In runtime HTML, the owned script appears as `script[type="text/pp"]`.
@@ -1386,9 +1522,9 @@ These are current runtime caveats that matter for authors and AI tools:
1386
1522
  - `pp.provideContext` is not part of the current runtime API. Use an HTML-first lowercase provider tag such as `<themecontext.provider>`.
1387
1523
  - `pp.portal()` preserves logical ancestry through the registry, so context and prop refresh behavior continue to work through portaled descendants.
1388
1524
  - 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.
1389
- - `pp.transition()` does not deprioritize work. PulsePoint renders synchronously; the hook provides an accurate `isPending` flag, not a concurrent scheduler.
1390
- - `pp.state(...)` changes schedule a component render; `pp.ref(...)` mutations do not. This is an ownership contract, not merely a syntax choice.
1391
- - Debouncing a state setter does not make the resulting render cheaper. A large owner should not hold RPC-only query text, timer handles, request generations, or undisplayed pagination cursors in state.
1525
+ - `pp.transition()` does not deprioritize work. PulsePoint renders synchronously; the hook provides an accurate `isPending` flag, not a concurrent scheduler.
1526
+ - `pp.state(...)` changes schedule a component render; `pp.ref(...)` mutations do not. This is an ownership contract, not merely a syntax choice.
1527
+ - Debouncing a state setter does not make the resulting render cheaper. A large owner should not hold RPC-only query text, timer handles, request generations, or undisplayed pagination cursors in state.
1392
1528
  - `pp.syncExternalStore(...)` resubscribes whenever `subscribe` changes identity, so pass a `pp.callback(..., [])`-wrapped function.
1393
1529
  - `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.
1394
1530
  - `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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caspian-utils",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Caspian tooling",
5
5
  "main": "index.js",
6
6
  "scripts": {