@tenphi/tasty 0.0.0-snapshot.06c8a41 → 0.0.0-snapshot.07ea4a0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.md +408 -385
  2. package/dist/babel-types-BKDTT4--.d.ts +18 -0
  3. package/dist/chunks/async-storage-f9j9JRtq.js +37 -0
  4. package/dist/chunks/async-storage-f9j9JRtq.js.map +1 -0
  5. package/dist/chunks/build-config-CW4k04_V.js +60 -0
  6. package/dist/chunks/build-config-CW4k04_V.js.map +1 -0
  7. package/dist/{collector-B3OsM252.js → chunks/collector-DKWQXEwN.js} +10 -127
  8. package/dist/chunks/collector-DKWQXEwN.js.map +1 -0
  9. package/dist/chunks/config-engine-DTglOA78.js +272 -0
  10. package/dist/chunks/config-engine-DTglOA78.js.map +1 -0
  11. package/dist/chunks/config-file-DlZXJMj8.js +25 -0
  12. package/dist/chunks/config-file-DlZXJMj8.js.map +1 -0
  13. package/dist/chunks/css-definitions-_C7C-daI.js +910 -0
  14. package/dist/chunks/css-definitions-_C7C-daI.js.map +1 -0
  15. package/dist/{css-resources-Cyl_axbI.js → chunks/css-resources-P7IBUUZs.js} +2 -9
  16. package/dist/chunks/css-resources-P7IBUUZs.js.map +1 -0
  17. package/dist/chunks/debug-BapJwy9w.js +502 -0
  18. package/dist/chunks/debug-BapJwy9w.js.map +1 -0
  19. package/dist/chunks/dsl-DYSJc1-T.js +1383 -0
  20. package/dist/chunks/dsl-DYSJc1-T.js.map +1 -0
  21. package/dist/chunks/hydration-TRcz5vnk.js +41 -0
  22. package/dist/chunks/hydration-TRcz5vnk.js.map +1 -0
  23. package/dist/chunks/react-runtime-DayjnPuC.js +689 -0
  24. package/dist/chunks/react-runtime-DayjnPuC.js.map +1 -0
  25. package/dist/chunks/runtime-engine-IwGbSMb4.js +2432 -0
  26. package/dist/chunks/runtime-engine-IwGbSMb4.js.map +1 -0
  27. package/dist/chunks/runtime-state-CVrL92zX.js +152 -0
  28. package/dist/chunks/runtime-state-CVrL92zX.js.map +1 -0
  29. package/dist/{merge-styles-DuoZEsm9.js → chunks/shared-utils-y-HjvPN4.js} +60 -55
  30. package/dist/chunks/shared-utils-y-HjvPN4.js.map +1 -0
  31. package/dist/chunks/style-engine-CVmNIjow.js +5521 -0
  32. package/dist/chunks/style-engine-CVmNIjow.js.map +1 -0
  33. package/dist/{css-writer-Bgowobbx.js → chunks/zero-engine-igHhzYM6.js} +65 -145
  34. package/dist/chunks/zero-engine-igHhzYM6.js.map +1 -0
  35. package/dist/{collector-BTVXj2hS.d.ts → collector-CTU-nl0g.d.ts} +6 -5
  36. package/dist/{config-LfIDmVHx.d.ts → config-CcxqSZI2.d.ts} +45 -40
  37. package/dist/core/index.d.ts +5 -5
  38. package/dist/core/index.js +9 -6
  39. package/dist/hydrate-CRyjjAyl.d.ts +22 -0
  40. package/dist/{index-Bm9ltvdH.d.ts → index--GdqCPlp.d.ts} +271 -242
  41. package/dist/index.d.ts +5 -5
  42. package/dist/index.js +11 -922
  43. package/dist/{merge-styles-BcLl1u_d.d.ts → merge-styles-DSkVc6CD.d.ts} +2 -3
  44. package/dist/ssr/astro-client.d.ts +1 -1
  45. package/dist/ssr/astro-client.js +1 -14
  46. package/dist/ssr/astro-client.js.map +1 -1
  47. package/dist/ssr/astro-middleware-extract-static.d.ts +1 -2
  48. package/dist/ssr/astro-middleware-extract-static.js +1 -1
  49. package/dist/ssr/astro-middleware-extract.d.ts +1 -2
  50. package/dist/ssr/astro-middleware-extract.js +1 -1
  51. package/dist/ssr/astro-middleware-static.d.ts +1 -2
  52. package/dist/ssr/astro-middleware-static.js +1 -12
  53. package/dist/ssr/astro-middleware-static.js.map +1 -1
  54. package/dist/ssr/astro-middleware.d.ts +1 -2
  55. package/dist/ssr/astro-middleware.js +1 -13
  56. package/dist/ssr/astro-middleware.js.map +1 -1
  57. package/dist/ssr/astro.d.ts +8 -16
  58. package/dist/ssr/astro.js +220 -1
  59. package/dist/ssr/astro.js.map +1 -0
  60. package/dist/ssr/client.d.ts +2 -0
  61. package/dist/ssr/client.js +2 -0
  62. package/dist/ssr/index.d.ts +5 -25
  63. package/dist/ssr/index.js +5 -5
  64. package/dist/ssr/index.js.map +1 -1
  65. package/dist/ssr/next-config.d.ts +3 -5
  66. package/dist/ssr/next-config.js +43 -35
  67. package/dist/ssr/next-config.js.map +1 -1
  68. package/dist/ssr/next.d.ts +3 -9
  69. package/dist/ssr/next.js +7 -38
  70. package/dist/ssr/next.js.map +1 -1
  71. package/dist/static/index.d.ts +8 -9
  72. package/dist/static/index.js +4 -22
  73. package/dist/static/index.js.map +1 -1
  74. package/dist/static/inject.d.ts +1 -2
  75. package/dist/{index-BmogEzTo.d.ts → types-DJEM_BOJ.d.ts} +852 -860
  76. package/dist/zero/babel.d.ts +67 -2
  77. package/dist/zero/babel.js +77 -140
  78. package/dist/zero/babel.js.map +1 -1
  79. package/dist/zero/index.d.ts +62 -8
  80. package/dist/zero/index.js +24 -2
  81. package/dist/zero/index.js.map +1 -0
  82. package/dist/zero/next.d.ts +4 -5
  83. package/dist/zero/next.js +3 -38
  84. package/dist/zero/next.js.map +1 -1
  85. package/docs/README.md +4 -1
  86. package/docs/adoption.md +25 -17
  87. package/docs/ai-agents.md +66 -33
  88. package/docs/comparison.md +9 -8
  89. package/docs/configuration.md +192 -60
  90. package/docs/debug.md +5 -7
  91. package/docs/design-system.md +117 -15
  92. package/docs/dsl.md +204 -82
  93. package/docs/getting-started.md +81 -22
  94. package/docs/injector.md +41 -40
  95. package/docs/methodology.md +48 -23
  96. package/docs/migration-v3.md +3 -3
  97. package/docs/pipeline.md +54 -27
  98. package/docs/plugins.md +22 -42
  99. package/docs/react-api.md +72 -9
  100. package/docs/runtime-benchmarks.md +146 -91
  101. package/docs/ssr.md +92 -66
  102. package/docs/styles.md +117 -31
  103. package/docs/tasty-static.md +162 -58
  104. package/package.json +96 -38
  105. package/dist/astro-CeYENy2x.js +0 -301
  106. package/dist/astro-CeYENy2x.js.map +0 -1
  107. package/dist/async-storage-DKK-wTD4.js +0 -44
  108. package/dist/async-storage-DKK-wTD4.js.map +0 -1
  109. package/dist/babel-DWCsafYw.d.ts +0 -83
  110. package/dist/collector-B3OsM252.js.map +0 -1
  111. package/dist/config-B3gPdCqd.js +0 -12421
  112. package/dist/config-B3gPdCqd.js.map +0 -1
  113. package/dist/context-CA8YKeMn.js +0 -24
  114. package/dist/context-CA8YKeMn.js.map +0 -1
  115. package/dist/core-DGm0CFHP.js +0 -1732
  116. package/dist/core-DGm0CFHP.js.map +0 -1
  117. package/dist/css-resources-Cyl_axbI.js.map +0 -1
  118. package/dist/css-writer-Bgowobbx.js.map +0 -1
  119. package/dist/format-global-rules-DklyaXv-.js +0 -22
  120. package/dist/format-global-rules-DklyaXv-.js.map +0 -1
  121. package/dist/format-rules-XRw9u7d4.js +0 -104
  122. package/dist/format-rules-XRw9u7d4.js.map +0 -1
  123. package/dist/hydrate-CNOmZprz.js +0 -37
  124. package/dist/hydrate-CNOmZprz.js.map +0 -1
  125. package/dist/index.js.map +0 -1
  126. package/dist/keyframes-DE-OE76F.js +0 -535
  127. package/dist/keyframes-DE-OE76F.js.map +0 -1
  128. package/dist/merge-styles-DuoZEsm9.js.map +0 -1
  129. package/dist/resolve-recipes-H9NqOQuP.js +0 -144
  130. package/dist/resolve-recipes-H9NqOQuP.js.map +0 -1
  131. package/dist/ssr-collector-ref-COs_ioWl.js +0 -29
  132. package/dist/ssr-collector-ref-COs_ioWl.js.map +0 -1
@@ -1,7 +1,7 @@
1
1
  # Runtime Benchmarks
2
2
 
3
3
  Tasty keeps its performance claims in reproducible benchmarks rather than
4
- combining unlike measurements into one score. The repository measures three
4
+ combining unlike measurements into one score. The repository measures five
5
5
  different costs:
6
6
 
7
7
  1. Style parsing and generation in Node.
@@ -34,37 +34,55 @@ code paths in headless Chromium. The first checkout may require
34
34
  current `dist/` — run `pnpm build` first.
35
35
 
36
36
  Run the Node and browser suites separately so they do not compete for CPU. The
37
- browser timer has 0.1 ms resolution, so both browser benchmarks perform many
37
+ browser timer has 0.1 ms resolution, so the browser benchmarks perform many
38
38
  matched operations per sample and divide the absolute difference by the number
39
- of elements or rules. Machine load, browser versions, and CPU power will move
40
- the results.
39
+ of elements, rules or interactions. Machine load, browser versions, and CPU
40
+ power will move the results — on a loaded machine the absolute columns drift
41
+ several percent while the raw/Tasty delta holds, so read the delta.
42
+
43
+ **On a laptop, check the power state first.** Low Power Mode caps CPU frequency,
44
+ and it is the single largest source of error here — larger than background load.
45
+ Measured on the machine below, running on battery with Low Power Mode enabled
46
+ made every one of the 53 Node cases slower, by a median of 26%, and halving the
47
+ background load recovered only 3 points of that. A frequency cap scales every
48
+ case by roughly the same factor, so it does not look like noise; it looks like a
49
+ uniformly slower library. Verify before trusting a run:
50
+
51
+ ```bash
52
+ pmset -g ps | head -2 # want 'AC Power'
53
+ pmset -g | grep -i lowpowermode # want 0
54
+ ```
41
55
 
42
56
  ## Core Style Pipeline
43
57
 
44
- The following numbers are single-call throughput measured on an Apple M1 Max
45
- with Node 22:
46
-
47
- | Operation | ops/sec | Latency (mean) |
48
- | ----------------------------------------------------------- | -------------------: | -------------: |
49
- | `renderStyles` — 5 flat properties (cold) | ~60,000 | ~17 us |
50
- | `renderStyles` — state map with media/hover/modifier (cold) | ~18,500 | ~54 us |
51
- | `renderStyles` — same styles (cached) | ~5,800,000 | ~0.17 us |
52
- | `parseStateKey` — simple key like `:hover` (cold) | ~790,000 | ~1.3 us |
53
- | `parseStateKey` — complex OR/AND/NOT key (cold) | ~140,000 | ~7 us |
54
- | `parseStateKey` — any key (cached) | ~3,400,000–8,300,000 | ~0.1–0.3 us |
55
- | `parseStyle` — value tokens like `2x 4x` (cold) | ~344,000 | ~2.9 us |
56
- | `parseStyle` — color tokens (cold) | ~567,000 | ~1.8 us |
57
- | `parseStyle` — any value (cached) | ~15,250,000 | ~0.07 us |
58
+ Single-call throughput, three consecutive runs on an Apple M3 Pro with Node
59
+ 24.18.0 on AC power:
60
+
61
+ | Operation | ops/sec | Latency (mean) |
62
+ | ----------------------------------------------------------- | --------------------: | -------------: |
63
+ | `renderStyles` — 5 flat properties (cold) | 143,000–158,000 | 6.3–7.0 us |
64
+ | `renderStyles` — state map with media/hover/modifier (cold) | 31,000–36,000 | 28–32 us |
65
+ | `renderStyles` — OR state map across contexts (cold) | 13,000–14,000 | 71–76 us |
66
+ | `renderStyles` — same styles (cached) | 9,830,000–10,420,000 | ~0.10 us |
67
+ | `parseStateKey` — simple key like `:hover` (cold) | 1,910,000–1,980,000 | ~0.50 us |
68
+ | `parseStateKey` — value modifier key (cold) | 1,120,000–1,160,000 | ~0.90 us |
69
+ | `parseStateKey` — complex OR/AND/NOT key (cold) | 341,000–357,000 | 2.8–2.9 us |
70
+ | `parseStateKey` — any key (cached) | 4,900,000–16,530,000 | 0.10–0.20 us |
71
+ | `parseStyle` — value tokens like `2x 4x` (cold) | 575,000–605,000 | ~1.7 us |
72
+ | `parseStyle` — color tokens (cold) | 1,160,000–1,240,000 | 0.80–0.90 us |
73
+ | `parseStyle` — layered functions (cold) | 175,000–184,000 | 5.4–5.7 us |
74
+ | `parseStyle` — any value (cached) | 29,500,000–30,750,000 | ~0.03 us |
58
75
 
59
76
  “Cold” cases use unique inputs to bypass the relevant caches. Cached cases
60
- reuse one input and measure the LRU hot path. Expect roughly ±10% between runs.
61
- These benchmarks do not include React, DOM work, stylesheet injection, style
62
- resolution, layout, or paint.
77
+ reuse one input and measure the LRU hot path. Run-to-run spread on an idle
78
+ machine was 1–7% for most cases, rising to 10–16% for the two `renderStyles`
79
+ cold cases. These benchmarks do not include React, DOM work, stylesheet
80
+ injection, style resolution, layout, or paint.
63
81
 
64
82
  The benchmark sources are colocated with the code they exercise:
65
- [`pipeline.bench.ts`](../src/pipeline/pipeline.bench.ts),
66
- [`parseStateKey.bench.ts`](../src/pipeline/parseStateKey.bench.ts), and the
67
- parser benchmark files under [`src/parser`](../src/parser).
83
+ [`pipeline.bench.ts`](https://github.com/tenphi/tasty/blob/main/src/pipeline/pipeline.bench.ts),
84
+ [`parseStateKey.bench.ts`](https://github.com/tenphi/tasty/blob/main/src/pipeline/parseStateKey.bench.ts), and the
85
+ parser benchmark files under [`src/parser`](https://github.com/tenphi/tasty/tree/main/src/parser).
68
86
 
69
87
  ## Empty Wrapper Overhead
70
88
 
@@ -73,7 +91,7 @@ Skipping the style pipeline does not make a `tasty()` component free. Even
73
91
  tracks another fiber, and Tasty still processes and forwards the element's
74
92
  props.
75
93
 
76
- [`tasty-overhead.bench.tsx`](../src/tasty-overhead.bench.tsx) compares 10,000
94
+ [`tasty-overhead.bench.tsx`](https://github.com/tenphi/tasty/blob/main/src/tasty-overhead.bench.tsx) compares 10,000
77
95
  raw `<div className>` siblings with 10,000 instances of one module-scoped
78
96
  `tasty({})` component. Both receive the same props, and the benchmark fails if
79
97
  they do not produce equivalent DOM.
@@ -85,19 +103,19 @@ excludes layout, paint, and stylesheet matching. Every commit is wrapped in
85
103
  `flushSync`, keeping its synchronous reconciliation and commit inside the
86
104
  sample. This does not estimate React's concurrent scheduling latency.
87
105
 
88
- On an Apple M3 Pro with React 19.2.4 and Chromium 151, three consecutive runs
106
+ On an Apple M3 Pro with React 19.2.8 and Chromium 151, three consecutive runs
89
107
  produced these ranges:
90
108
 
91
- | Work on 10,000 siblings | Raw elements | `tasty({})` | Extra per wrapped element |
92
- | ----------------------------------- | -----------: | -----------: | ------------------------: |
93
- | Mount + remove | 4.5–4.9 ms | 14.0–14.9 ms | 0.95–1.00 us |
94
- | Rerender, same host props | 1.3–1.4 ms | 10.9–11.4 ms | 0.96–1.00 us |
95
- | Rerender, change one host attribute | 2.8–3.4 ms | 15.0–15.7 ms | 1.21–1.27 us |
109
+ | Work on 10,000 siblings | Raw elements | `tasty({})` | Extra per wrapped element |
110
+ | ----------------------------------- | -----------: | ----------: | ------------------------: |
111
+ | Mount + remove | 4.2–4.3 ms | 9.2–9.7 ms | 0.50–0.54 us |
112
+ | Rerender, same host props | 1.2–1.3 ms | 6.3–6.5 ms | 0.51–0.52 us |
113
+ | Rerender, change one host attribute | 2.5–2.7 ms | 11.2–11.6 ms | 0.86–0.91 us |
96
114
 
97
115
  The useful result is the raw/Tasty time difference divided by 10,000, not the
98
116
  ratio between the two times. The ratio becomes large because the raw baseline
99
- is tiny. In this synthetic workload, an empty wrapper adds roughly 1 us per
100
- participating element, or 1.2–1.3 us when React also changes a DOM attribute.
117
+ is tiny. In this synthetic workload, an empty wrapper adds roughly 0.5 us per
118
+ participating element, or ~0.9 us when React also changes a DOM attribute.
101
119
 
102
120
  This is the floor Tasty consumes when it has no styling job. It is not a
103
121
  page-level score. Real trees include application components, effects, layout,
@@ -107,7 +125,7 @@ snapshot experiment with controlled garbage collection.
107
125
 
108
126
  ## Cold Generation and Injection
109
127
 
110
- [`tasty-injection.bench.ts`](../src/tasty-injection.bench.ts) measures the extra
128
+ [`tasty-injection.bench.ts`](https://github.com/tenphi/tasty/blob/main/src/tasty-injection.bench.ts) measures the extra
111
129
  work when Tasty must generate and inject CSS that an otherwise equivalent page
112
130
  already has. It does not compare different stylesheet insertion techniques.
113
131
 
@@ -142,17 +160,23 @@ ranges:
142
160
 
143
161
  | Workload | CSS already present | Tasty runtime | Incremental Tasty cost |
144
162
  | ---------------------------------------------------- | ------------------: | -------------: | ---------------------: |
145
- | One new rule + immediate resolution, per transaction | 2.8–3.3 us | 110.3–113.8 us | 107.3–111.0 us |
146
- | 1,000 new rules + one resolution | 1.86–2.06 ms | 9.01–9.98 ms | 7.13–7.92 ms |
147
- | 1,000-rule workload, incremental cost per rule | — | — | 7.1–7.9 us |
163
+ | One new rule + immediate resolution, per transaction | 2.6–3.0 us | 102.8–107.0 us | 100.3–104.3 us |
164
+ | 1,000 new rules + one resolution | 1.69–1.79 ms | 7.42–7.67 ms | 5.73–5.94 ms |
165
+ | 1,000-rule workload, incremental cost per rule | — | — | 5.7–5.9 us |
148
166
 
149
167
  Directly compared, injecting 1,000 rules before one resolution boundary cost
150
- about 66–71 times as much in total as injecting one rule and resolving it—not
151
- 1,000 times as much. Its average incremental cost per rule was about 14–16
168
+ about 55–59 times as much in total as injecting one rule and resolving it—not
169
+ 1,000 times as much. Its average incremental cost per rule was about 17–18
152
170
  times lower. This is the same Tasty generation and injection path in both cases;
153
171
  the group amortizes fixed transaction work and lets the browser resolve all the
154
172
  stylesheet writes together.
155
173
 
174
+ The per-transaction single-rule figure is the one number here that engine work
175
+ does not move: it is dominated by the browser's injection-to-resolution
176
+ boundary, which is crossed once per rule regardless of how fast generation is.
177
+ The 1,000-rule column, where Tasty's own generation dominates, is where engine
178
+ changes show up.
179
+
156
180
  The subtraction is the meaningful result. It includes Tasty's cold style
157
181
  generation, cache and injector bookkeeping, rule insertion, and any additional
158
182
  style invalidation exposed by that workload's resolution boundary. It does not
@@ -173,11 +197,11 @@ The benchmarks above measure mounting and whole-tree updates. A running
173
197
  application spends most of its time on neither. It flips mods — hovered,
174
198
  pressed, selected, expanded — on elements whose styles never change, one
175
199
  element at a time, and it mounts and unmounts small styled subtrees as menus
176
- and dialogs open. Both paths go through the state-map and ref-counting
177
- machinery rather than the parser, so a regression in them is invisible to every
200
+ and dialogs open. Both paths go through mod handling and the chunk cache
201
+ rather than the parser, so a regression in them is invisible to every
178
202
  other benchmark here.
179
203
 
180
- [`tasty-interaction.bench.tsx`](../src/tasty-interaction.bench.tsx) pairs each
204
+ [`tasty-interaction.bench.tsx`](https://github.com/tenphi/tasty/blob/main/src/tasty-interaction.bench.tsx) pairs each
181
205
  case with a raw-DOM equivalent driven by a hand-written stylesheet that
182
206
  produces the same computed color and background in both states. The benchmark
183
207
  fails if either arm resolves to anything else, so an arm that quietly rendered
@@ -201,30 +225,37 @@ readable number and noise:
201
225
  The browser's side of an interaction is real, but it is the browser's;
202
226
  resolution boundaries are what the injection benchmark above measures.
203
227
 
204
- On an Apple M1 Max with React 19.2.8 and Chromium 151, three consecutive runs
205
- produced these ranges:
228
+ The contract check also reads the injected CSS **before** any toggle and fails
229
+ if the hovered rule is not already there. That a style map's states all ship in
230
+ one chunk on first render is the premise of this case; if the hovered rule
231
+ arrived lazily, the first sample would be timing injection.
206
232
 
207
- | Workload | Raw elements | Tasty mods | Extra per unit |
208
- | --------------------------------------------------- | -----------: | ------------: | ------------------------: |
209
- | 300 single-element mod toggles in a 100-element tree | 2.4–2.5 ms | 2.9–3.0 ms | 1.6–1.7 us / interaction |
210
- | 20 mount + unmount cycles of a 200-element subtree | 7.6–7.7 ms | 12.6–12.7 ms | 1.22–1.27 us / element |
233
+ On an Apple M3 Pro with React 19.2.8 and Chromium 151, three consecutive runs:
234
+
235
+ | Workload | Raw elements | Tasty mods | Extra per unit |
236
+ | ---------------------------------------------------- | -----------: | ---------: | -----------------------: |
237
+ | 300 single-element mod toggles in a 100-element tree | 1.7–1.8 ms | 2.0–2.1 ms | 1.2–1.3 us / interaction |
238
+ | 20 mount + unmount cycles of a 200-element subtree | 5.2–5.4 ms | 8.3–8.6 ms | 0.77–0.81 us / element |
239
+
240
+ The absolute columns move several percent with machine load; the delta between
241
+ the arms is the stable quantity, so read that rather than either column.
211
242
 
212
243
  Two things are worth reading out of this.
213
244
 
214
- A mod flip on an already-mounted element costs about 1.7 us. The CSS for both
245
+ A mod flip on an already-mounted element costs about 1.2 us. The CSS for both
215
246
  states already exists — Tasty emits every state of a style map in one chunk on
216
247
  first render — so both arms perform the same commit, and what is left is
217
- Tasty's props and mod handling. That is the same order as the ~1 us empty
248
+ Tasty's props and mod handling. That is the same order as the ~0.5 us empty
218
249
  wrapper measured above, which is most of where it comes from.
219
250
 
220
- Subtree churn is not about styling at all. Its ~1.25 us per element sits right
251
+ Subtree churn is not about styling at all. Its ~0.8 us per element sits right
221
252
  on the empty-wrapper mount cost, because the styles are already cached:
222
253
  reopening a menu re-pays the React wrapper, not the style pipeline.
223
254
 
224
255
  ## Page-Load Cold Start
225
256
 
226
257
  Every benchmark above deliberately excludes the network, module compilation and
227
- the first render. [`scripts/cold-start`](../scripts/cold-start) measures exactly
258
+ the first render. [`scripts/cold-start`](https://github.com/tenphi/tasty/tree/main/scripts/cold-start) measures exactly
228
259
  those: what a visitor waits for between requesting a page and seeing styled
229
260
  content, in a real Chromium under CDP network and CPU throttling.
230
261
 
@@ -245,39 +276,61 @@ rather than counted in animation frames — `requestAnimationFrame` fires before
245
276
  paint, so a page that commits fast can reach its second frame with nothing
246
277
  painted yet.
247
278
 
248
- The Tasty bundle is built from what the page imports (`tasty`, `configure`,
249
- `computeStyles`, `tastyDebug`), so it is tree-shaken the way an application's
250
- would be: 52.0 KB brotli, 186 KB raw. Re-exporting the whole library instead
251
- adds ~4 KB brotli of code no page here calls, which would inflate the column
252
- that dominates this result.
253
-
254
- On an Apple M1 Max with React 19.2.8 and Chromium 151, first contentful paint:
255
-
256
- | Link / CPU | baseline | runtime | prewarm | Tasty's cost |
257
- | --------------------- | -------: | ------: | ------: | -----------: |
258
- | No throttling, 1x | 44 ms | 56 ms | 56 ms | +12 ms |
259
- | Fast 4G, 1x | 744 ms | 924 ms | 920 ms | +180 ms |
260
- | Slow 4G, 1x | 2764 ms | 3708 ms | 3712 ms | +944 ms |
261
- | No throttling, 4x CPU | 156 ms | 204 ms | 204 ms | +48 ms |
262
- | Fast 4G, 4x CPU | 820 ms | 1052 ms | 1048 ms | +232 ms |
263
- | Slow 4G, 4x CPU | 2824 ms | 3836 ms | 3844 ms | +1012 ms |
279
+ Two things about the payload decide whether this measures a deployment or a
280
+ straw man, so both are enforced rather than assumed:
281
+
282
+ - **Assets are served brotli-compressed**, the way a static host serves them.
283
+ The bundle is 51.5 KB on the wire and 180 KB decoded; putting the decoded
284
+ bytes on a 1.6 Mbps link would add ~700 ms and charge it to Tasty. The run
285
+ reads `encodedBodySize` back out of resource timing and fails if what
286
+ crossed the wire is not the compressed size the table reports.
287
+ - **The bundle is built from what the page imports** (`tasty`, `configure`,
288
+ `computeStyles`, `tastyDebug`), so it is tree-shaken as an application's
289
+ would be. Re-exporting the whole library adds ~4 KB brotli of code no page
290
+ here calls.
291
+
292
+ On an Apple M3 Pro with React 19.2.8 and Chromium 151, first contentful paint,
293
+ median of three full runs of the matrix:
294
+
295
+ | Link / CPU | baseline | runtime | prewarm | Tasty's cost | noise |
296
+ | --------------------- | -------: | ------: | ------: | -----------: | ----: |
297
+ | No throttling, 1x | 36 ms | 40 ms | 40 ms | +4 ms | 0 ms |
298
+ | Fast 4G, 1x | 628 ms | 672 ms | 688 ms | +44 ms | 20 ms |
299
+ | Slow 4G, 1x | 2044 ms | 2308 ms | 2308 ms | +264 ms | 24 ms |
300
+ | No throttling, 4x CPU | 112 ms | 144 ms | 144 ms | +32 ms | 16 ms |
301
+ | Fast 4G, 4x CPU | 656 ms | 728 ms | 736 ms | +72 ms | 4 ms |
302
+ | Slow 4G, 4x CPU | 2064 ms | 2360 ms | 2360 ms | +296 ms | 8 ms |
303
+
304
+ The `noise` column is not an estimate. The `baseline` page contains no Tasty at
305
+ all, so its three samples should be identical; the spread they actually show is
306
+ this cell's measurement error, and it applies to the other columns too.
307
+
308
+ Read that column before any other. Only the Slow 4G rows carry a cost an order
309
+ of magnitude above their own noise. The unthrottled 1x row (+4 ms) means "too
310
+ small to measure this way", not "4 ms". And a single run is genuinely not
311
+ enough here: taken alone, the first of these three runs put Fast 4G 1x at
312
+ +72 ms, which the median over three corrects to +44 ms.
313
+
314
+ For the same reason, `prewarm` landing above `runtime` in the Fast 4G 1x row is
315
+ noise, not a cost — the two modes differ only in when the engine compiles, and
316
+ that difference is measured in the phase table below, not in FCP.
264
317
 
265
318
  **The cost is the bundle, not the work.** On Slow 4G the extra transfer alone
266
- accounts for 946 ms of the 944 ms FCP delta — the whole of it, within noise.
267
- Everything Tasty then *does* is small by comparison:
319
+ accounts for 263 ms of the 264 ms FCP delta — effectively all of it. Everything
320
+ Tasty then *does* is small by comparison (median of three runs):
268
321
 
269
322
  | Phase (Slow 4G, 1x) | baseline | runtime | prewarm |
270
323
  | ----------------------- | -------: | ------: | ------: |
271
- | js+css transfer | 2150 ms | 3096 ms | 3096 ms |
272
- | module compile (shared) | 1.7 ms | 1.6 ms | 1.5 ms |
273
- | tasty top-level execute | — | 0.9 ms | 1.1 ms |
274
- | `configure()` | — | 0.5 ms | 0.4 ms |
275
- | prewarm | — | — | 4.7 ms |
276
- | render 1st component | 2.0 ms | 6.7 ms | 2.5 ms |
277
- | render 49 more | 1.0 ms | 6.0 ms | 5.7 ms |
324
+ | js+css transfer | 1423 ms | 1686 ms | 1690 ms |
325
+ | module compile (shared) | 3.5 ms | 3.3 ms | 3.0 ms |
326
+ | tasty top-level execute | — | 1.2 ms | 0.8 ms |
327
+ | `configure()` | — | 0.6 ms | 0.4 ms |
328
+ | prewarm | — | — | 3.3 ms |
329
+ | render 1st component | 3.0 ms | 7.3 ms | 1.9 ms |
330
+ | render 49 more | 1.3 ms | 5.2 ms | 3.8 ms |
278
331
 
279
332
  Importing Tasty costs about 1 ms of top-level execution; `configure()` costs
280
- half of one. The rest of the CPU delta — under 10 ms for 50 components — is
333
+ half of one. The rest of the CPU delta — about 10 ms for 50 components — is
281
334
  generation and injection, which is the cost the injection benchmark isolates.
282
335
 
283
336
  One asymmetry is worth naming: the control links a render-blocking stylesheet
@@ -287,20 +340,21 @@ delivery models, not a thumb on the scale, but it means the FCP delta is not
287
340
  purely "what Tasty costs to execute".
288
341
 
289
342
  **Prewarming moves the wake-up, it does not remove it.** The first styled render
290
- is ~4.5 ms more expensive than the ones after it, because that is when the
343
+ is ~5.4 ms more expensive than the ones after it, because that is when the
291
344
  engine's deferred payload is actually compiled. A throwaway `computeStyles()`
292
- against a detached root pays it early: `render 1st` drops from 6.7 ms to
293
- 2.5 ms. The prewarm itself costs 4.7 ms, so FCP does not move. It is worth
294
- doing only when something else can overlap it, or when the first render is on a
295
- latency-critical path and the page has idle time before it.
345
+ against a detached root pays it early: `render 1st` drops from 7.3 ms to
346
+ 1.9 ms. The prewarm itself costs 3.3 ms, so the work is moved rather than
347
+ removed and FCP does not move. It is worth doing only when something else can
348
+ overlap it, or when the first render is on a latency-critical path and the page
349
+ has idle time before it.
296
350
 
297
351
  **Retained heap.** After a forced collection, the runtime page holds about
298
- 1,013 KB more than the control (2,626 KB vs 1,613 KB) for 50 components — the
352
+ 985 KB more than the control (2,605 KB vs 1,619 KB) for 50 components — the
299
353
  parser caches, the chunk cache, the injector's registry and the generated CSS.
300
354
  The control is not zero either; most of its 1.6 MB is React and the DOM.
301
355
 
302
356
  CPU throttling changes which line moves. At 4x, module compilation of the
303
- larger graph becomes visible (7.4 ms → 26 ms) where at 1x it is free: V8
357
+ larger graph becomes visible (2.6 ms → 19 ms) where at 1x it is free: V8
304
358
  pre-parses at import and compiles lazily, so a slower CPU pays for code the
305
359
  faster one never fully compiled. Transfer numbers from the unthrottled cells
306
360
  are not worth reading — with no emulated link, resource timings are scheduling
@@ -317,16 +371,17 @@ blindly. They describe different paths:
317
371
  rule.
318
372
  - A genuinely new style pays generation and injection once, then becomes
319
373
  reusable.
320
- - A mod flip on an already-styled element pays neither; it is a class-name
321
- change.
374
+ - A mod flip on an already-styled element pays neither; it toggles a `data-*`
375
+ attribute and the class stays the same.
322
376
  - Browser style resolution, layout, and paint depend on the actual document and
323
377
  need application-level profiling.
324
378
 
325
379
  The cold-start measurement is the one that puts the rest in proportion. On a
326
- slow connection, essentially all of Tasty's page-load cost is transferring the
327
- library; the generation and injection the microbenchmarks obsess over is ~10 ms
328
- for 50 components. Bundle size is therefore the lever with the largest effect
329
- on first paint, and the runtime levers matter for what happens after it.
380
+ slow connection, nearly all of Tasty's page-load cost is transferring the
381
+ library — 263 ms of a 264 ms delta — while the generation and injection the
382
+ microbenchmarks obsess over is ~10 ms for 50 components. Bundle size is
383
+ therefore the lever with the largest effect on first paint, and the runtime
384
+ levers matter for what happens after it.
330
385
 
331
386
  The practical optimization target is therefore repeated work: keep style input
332
387
  stable when possible, reuse generated chunks, and generate CSS at build or