caspian-utils 0.2.5 → 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.
@@ -909,7 +909,7 @@ Each setter that changes a value requests a render, even when the eventual UI ch
909
909
 
910
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
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. Reconciliation work scales with the number of rows that actually changed.
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
913
 
914
914
  This is automatic, but it only engages for rows the runtime can safely stand in for. A row participates when:
915
915
 
@@ -925,9 +925,122 @@ Practical consequences when authoring a large list:
925
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
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
927
 
928
- ### Diagnose the owner before the runtime
928
+ ### A mounted child boundary is reconciled by its attributes, not by its markup
929
929
 
930
- Classify the symptom before editing `public/js/pp-reactive-v2.js`:
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:
931
1044
 
932
1045
  | Evidence | Likely classification | First action |
933
1046
  | --- | --- | --- |
@@ -935,9 +1048,11 @@ Classify the symptom before editing `public/js/pp-reactive-v2.js`:
935
1048
  | A debounce fires just before typing stalls | Component/request ownership issue | Debounce the RPC or expensive action, not an unnecessary broad-owner setter |
936
1049
  | Loading and result setters produce two large commits | Component authoring issue | Remove invisible loading commits or isolate the visible indicator |
937
1050
  | A high-frequency control owns a large list/provider/dialog subtree | Component-boundary issue | Move the state to a smaller focused component |
938
- | 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 |
939
- | 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 |
940
- | Changing one row of a large list costs about as much as changing every row | Component authoring issue first | Check the row is keyed, single-rooted, and not a component boundary, so per-row reuse can engage |
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 |
941
1056
 
942
1057
  Enable measurements only while profiling:
943
1058
 
@@ -978,7 +1093,7 @@ Important:
978
1093
  - Context is component-level, not directive-based. There is no `pp-context` attribute.
979
1094
  - `pp.context(token)` resolves from ancestors. A component does not consume the value it provides in the same render.
980
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.
981
- - 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`.
982
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.
983
1098
  - Do not invent or document `pp.provideContext`. The current runtime explicitly does not expose it.
984
1099
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caspian-utils",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Caspian tooling",
5
5
  "main": "index.js",
6
6
  "scripts": {