@tenphi/tasty 3.5.0 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{astro-ib7E7V4Y.js → astro-CeYENy2x.js} +61 -67
- package/dist/astro-CeYENy2x.js.map +1 -0
- package/dist/{collector-C6TtL8HJ.js → collector-B3OsM252.js} +2 -2
- package/dist/{collector-C6TtL8HJ.js.map → collector-B3OsM252.js.map} +1 -1
- package/dist/core/index.js +1 -1
- package/dist/{core-Bq7w2kti.js → core-DGm0CFHP.js} +76 -30
- package/dist/{core-Bq7w2kti.js.map → core-DGm0CFHP.js.map} +1 -1
- package/dist/css-resources-Cyl_axbI.js +149 -0
- package/dist/css-resources-Cyl_axbI.js.map +1 -0
- package/dist/{format-rules-rCZ37rqY.js → format-rules-XRw9u7d4.js} +2 -28
- package/dist/format-rules-XRw9u7d4.js.map +1 -0
- package/dist/index.js +2 -2
- package/dist/ssr/astro-middleware-extract-static.js +1 -1
- package/dist/ssr/astro-middleware-extract.js +1 -1
- package/dist/ssr/astro-middleware-static.js +1 -1
- package/dist/ssr/astro-middleware.js +1 -1
- package/dist/ssr/astro.d.ts +9 -1
- package/dist/ssr/astro.js +1 -1
- package/dist/ssr/index.js +2 -2
- package/dist/ssr/next-config.d.ts +66 -0
- package/dist/ssr/next-config.js +115 -0
- package/dist/ssr/next-config.js.map +1 -0
- package/dist/ssr/next.d.ts +8 -1
- package/dist/ssr/next.js +24 -7
- package/dist/ssr/next.js.map +1 -1
- package/dist/ssr-collector-ref-COs_ioWl.js +29 -0
- package/dist/ssr-collector-ref-COs_ioWl.js.map +1 -0
- package/docs/debug.md +13 -0
- package/docs/runtime-benchmarks.md +185 -12
- package/docs/ssr.md +151 -31
- package/package.json +9 -1
- package/dist/astro-ib7E7V4Y.js.map +0 -1
- package/dist/format-rules-rCZ37rqY.js.map +0 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
//#region src/ssr/ssr-collector-ref.ts
|
|
2
|
+
const GETTER_KEY = "__tasty_ssr_collector_getter__";
|
|
3
|
+
let _getSSRCollector = null;
|
|
4
|
+
/**
|
|
5
|
+
* Register the collector getter in the current module graph only.
|
|
6
|
+
* Used by Next.js TastyRegistry.
|
|
7
|
+
*/
|
|
8
|
+
function registerSSRCollectorGetter(fn) {
|
|
9
|
+
_getSSRCollector = fn;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Register the collector getter on globalThis so it is visible across
|
|
13
|
+
* separate module graphs (e.g. Astro middleware ↔ page components).
|
|
14
|
+
*/
|
|
15
|
+
function registerSSRCollectorGetterGlobal(fn) {
|
|
16
|
+
globalThis[GETTER_KEY] = fn;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Retrieve the SSR collector: module-level first, globalThis fallback.
|
|
20
|
+
*/
|
|
21
|
+
function getRegisteredSSRCollector() {
|
|
22
|
+
if (_getSSRCollector) return _getSSRCollector();
|
|
23
|
+
const getter = globalThis[GETTER_KEY];
|
|
24
|
+
return getter ? getter() : null;
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
export { registerSSRCollectorGetter as n, registerSSRCollectorGetterGlobal as r, getRegisteredSSRCollector as t };
|
|
28
|
+
|
|
29
|
+
//# sourceMappingURL=ssr-collector-ref-COs_ioWl.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ssr-collector-ref-COs_ioWl.js","names":[],"sources":["../src/ssr/ssr-collector-ref.ts"],"sourcesContent":["/**\n * Global reference to the SSR collector getter function.\n *\n * This indirection avoids importing 'node:async_hooks' in the browser bundle.\n * The SSR entry point sets this ref when loaded on the server. The useStyles\n * hook calls it if set; on the client it stays null and is never called.\n *\n * Uses a module-level variable as the primary mechanism. In Next.js App\n * Router the RSC and SSR module graphs load separate copies of this module,\n * so the getter registered by TastyRegistry (SSR layer) is invisible to\n * server components (RSC layer) — which correctly fall through to inline\n * RSC styles.\n *\n * A globalThis fallback (`registerSSRCollectorGetterGlobal`) is provided\n * for frameworks like Astro where middleware and page components live in\n * different module graphs and must share the getter across them.\n */\n\nimport type { ServerStyleCollector } from './collector';\n\ntype SSRCollectorGetter = () => ServerStyleCollector | null;\n\nconst GETTER_KEY = '__tasty_ssr_collector_getter__';\n\nlet _getSSRCollector: SSRCollectorGetter | null = null;\n\n/**\n * Register the collector getter in the current module graph only.\n * Used by Next.js TastyRegistry.\n */\nexport function registerSSRCollectorGetter(fn: SSRCollectorGetter): void {\n _getSSRCollector = fn;\n}\n\n/**\n * Register the collector getter on globalThis so it is visible across\n * separate module graphs (e.g. Astro middleware ↔ page components).\n */\nexport function registerSSRCollectorGetterGlobal(fn: SSRCollectorGetter): void {\n (globalThis as Record<string, unknown>)[GETTER_KEY] = fn;\n}\n\n/**\n * Retrieve the SSR collector: module-level first, globalThis fallback.\n */\nexport function getRegisteredSSRCollector(): ServerStyleCollector | null {\n if (_getSSRCollector) return _getSSRCollector();\n const getter = (globalThis as Record<string, unknown>)[GETTER_KEY] as\n SSRCollectorGetter | undefined;\n return getter ? getter() : null;\n}\n"],"mappings":";AAsBA,MAAM,aAAa;AAEnB,IAAI,mBAA8C;;;;;AAMlD,SAAgB,2BAA2B,IAA8B;CACvE,mBAAmB;AACrB;;;;;AAMA,SAAgB,iCAAiC,IAA8B;CAC7E,WAAwC,cAAc;AACxD;;;;AAKA,SAAgB,4BAAyD;CACvE,IAAI,kBAAkB,OAAO,iBAAiB;CAC9C,MAAM,SAAU,WAAuC;CAEvD,OAAO,SAAS,OAAO,IAAI;AAC7B"}
|
package/docs/debug.md
CHANGED
|
@@ -54,6 +54,19 @@ interface DebugOptions {
|
|
|
54
54
|
|
|
55
55
|
When `raw` is `false` (the default), results are logged to the console **and** returned. When `raw` is `true`, results are returned silently.
|
|
56
56
|
|
|
57
|
+
### Environments Without a DOM
|
|
58
|
+
|
|
59
|
+
`tastyDebug` reads the document, so on a server — SSR, a Node REPL, a test
|
|
60
|
+
runner in the `node` environment — there is nothing for it to read. Every
|
|
61
|
+
method returns its empty result (`''`, an empty `InspectResult`, a summary of
|
|
62
|
+
zeroes, `metrics: null`) instead of throwing, and explains itself once with a
|
|
63
|
+
console warning. `{ raw: true }` suppresses that warning along with the rest of
|
|
64
|
+
the logging.
|
|
65
|
+
|
|
66
|
+
To see the styles a server render produced, read the `ServerStyleCollector`
|
|
67
|
+
instead — `collector.getCSS()` and `collector.getRenderedClassNames()`. See
|
|
68
|
+
[SSR](./ssr.md).
|
|
69
|
+
|
|
57
70
|
---
|
|
58
71
|
|
|
59
72
|
## API Reference
|
|
@@ -1,17 +1,22 @@
|
|
|
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
|
|
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.
|
|
8
8
|
2. The React overhead of an empty `tasty({})` wrapper.
|
|
9
9
|
3. Cold browser generation and injection compared with equivalent CSS that is
|
|
10
10
|
already on the page.
|
|
11
|
+
4. The steady-state interaction path — mod flips and styled subtrees opening
|
|
12
|
+
and closing after the page has loaded.
|
|
13
|
+
5. Page-load cold start: network, module compilation, execution and first
|
|
14
|
+
paint, end to end in a throttled browser.
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
The first four are focused microbenchmarks, not page-level scores. The fifth is
|
|
17
|
+
a page-level measurement and is the only one that answers "what does a visitor
|
|
18
|
+
wait for". Run them several times on an otherwise idle machine and use a
|
|
19
|
+
production profile to decide whether any cost matters in an application.
|
|
15
20
|
|
|
16
21
|
## Reproducing the Results
|
|
17
22
|
|
|
@@ -19,17 +24,21 @@ to decide whether any cost matters in an application.
|
|
|
19
24
|
pnpm bench
|
|
20
25
|
pnpm bench:overhead
|
|
21
26
|
pnpm bench:injection
|
|
27
|
+
pnpm bench:interaction
|
|
28
|
+
pnpm bench:cold-start
|
|
22
29
|
```
|
|
23
30
|
|
|
24
|
-
`pnpm bench` runs the core pipeline benchmarks in Node. The
|
|
25
|
-
|
|
26
|
-
`pnpm test:setup` to download Chromium
|
|
31
|
+
`pnpm bench` runs the core pipeline benchmarks in Node. The rest use production
|
|
32
|
+
code paths in headless Chromium. The first checkout may require
|
|
33
|
+
`pnpm test:setup` to download Chromium, and `pnpm bench:cold-start` needs a
|
|
34
|
+
current `dist/` — run `pnpm build` first.
|
|
27
35
|
|
|
28
36
|
Run the Node and browser suites separately so they do not compete for CPU. The
|
|
29
|
-
browser timer has 0.1 ms resolution, so
|
|
37
|
+
browser timer has 0.1 ms resolution, so the browser benchmarks perform many
|
|
30
38
|
matched operations per sample and divide the absolute difference by the number
|
|
31
|
-
of elements or
|
|
32
|
-
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.
|
|
33
42
|
|
|
34
43
|
## Core Style Pipeline
|
|
35
44
|
|
|
@@ -159,10 +168,165 @@ together. Because the same resolution pattern is present in each workload's
|
|
|
159
168
|
control, the difference answers the narrower delivery question: how much extra
|
|
160
169
|
work did Tasty perform when the same CSS was not already there?
|
|
161
170
|
|
|
171
|
+
## Steady-State Interaction
|
|
172
|
+
|
|
173
|
+
The benchmarks above measure mounting and whole-tree updates. A running
|
|
174
|
+
application spends most of its time on neither. It flips mods — hovered,
|
|
175
|
+
pressed, selected, expanded — on elements whose styles never change, one
|
|
176
|
+
element at a time, and it mounts and unmounts small styled subtrees as menus
|
|
177
|
+
and dialogs open. Both paths go through the state-map and ref-counting
|
|
178
|
+
machinery rather than the parser, so a regression in them is invisible to every
|
|
179
|
+
other benchmark here.
|
|
180
|
+
|
|
181
|
+
[`tasty-interaction.bench.tsx`](../src/tasty-interaction.bench.tsx) pairs each
|
|
182
|
+
case with a raw-DOM equivalent driven by a hand-written stylesheet that
|
|
183
|
+
produces the same computed color and background in both states. The benchmark
|
|
184
|
+
fails if either arm resolves to anything else, so an arm that quietly rendered
|
|
185
|
+
unstyled elements cannot report a flattering number.
|
|
186
|
+
|
|
187
|
+
Each leaf owns its own `useState`, which is what keeps a single-element
|
|
188
|
+
interaction single: re-rendering the root to flip one row would time the whole
|
|
189
|
+
tree. One toggle is far below Chromium's 0.1 ms timer resolution, so a sample
|
|
190
|
+
flips a 100-element tree three times over — 300 commits — and the churn case
|
|
191
|
+
performs 20 open/close cycles. Divide the raw/Tasty difference by those counts.
|
|
192
|
+
|
|
193
|
+
Two things had to be sized deliberately, and both are the difference between a
|
|
194
|
+
readable number and noise:
|
|
195
|
+
|
|
196
|
+
- **The tree is small (100 elements), not large.** React locates a leaf's
|
|
197
|
+
pending update by walking the sibling list, so in a 1,000-element tree a
|
|
198
|
+
single-element update costs ~68 us of traversal — identical in both arms and
|
|
199
|
+
an order of magnitude above anything the styling layer contributes.
|
|
200
|
+
- **The sample resolves style once, not once per flip.** Forcing a recalc
|
|
201
|
+
between flips costs ~70 us in both arms, which buries the delta the same way.
|
|
202
|
+
The browser's side of an interaction is real, but it is the browser's;
|
|
203
|
+
resolution boundaries are what the injection benchmark above measures.
|
|
204
|
+
|
|
205
|
+
The contract check also reads the injected CSS **before** any toggle and fails
|
|
206
|
+
if the hovered rule is not already there. That a style map's states all ship in
|
|
207
|
+
one chunk on first render is the premise of this case; if the hovered rule
|
|
208
|
+
arrived lazily, the first sample would be timing injection.
|
|
209
|
+
|
|
210
|
+
On an Apple M1 Max with React 19.2.8 and Chromium 151, across several runs:
|
|
211
|
+
|
|
212
|
+
| Workload | Raw elements | Tasty mods | Extra per unit |
|
|
213
|
+
| ---------------------------------------------------- | -----------: | -----------: | -----------------------: |
|
|
214
|
+
| 300 single-element mod toggles in a 100-element tree | 2.1–2.5 ms | 2.7–3.1 ms | 1.6–2.1 us / interaction |
|
|
215
|
+
| 20 mount + unmount cycles of a 200-element subtree | 7.6–8.0 ms | 12.6–12.7 ms | 1.22–1.27 us / element |
|
|
216
|
+
|
|
217
|
+
The absolute columns move several percent with machine load; the delta between
|
|
218
|
+
the arms is the stable quantity, so read that rather than either column.
|
|
219
|
+
|
|
220
|
+
Two things are worth reading out of this.
|
|
221
|
+
|
|
222
|
+
A mod flip on an already-mounted element costs about 2 us. The CSS for both
|
|
223
|
+
states already exists — Tasty emits every state of a style map in one chunk on
|
|
224
|
+
first render — so both arms perform the same commit, and what is left is
|
|
225
|
+
Tasty's props and mod handling. That is the same order as the ~1 us empty
|
|
226
|
+
wrapper measured above, which is most of where it comes from.
|
|
227
|
+
|
|
228
|
+
Subtree churn is not about styling at all. Its ~1.25 us per element sits right
|
|
229
|
+
on the empty-wrapper mount cost, because the styles are already cached:
|
|
230
|
+
reopening a menu re-pays the React wrapper, not the style pipeline.
|
|
231
|
+
|
|
232
|
+
## Page-Load Cold Start
|
|
233
|
+
|
|
234
|
+
Every benchmark above deliberately excludes the network, module compilation and
|
|
235
|
+
the first render. [`scripts/cold-start`](../scripts/cold-start) measures exactly
|
|
236
|
+
those: what a visitor waits for between requesting a page and seeing styled
|
|
237
|
+
content, in a real Chromium under CDP network and CPU throttling.
|
|
238
|
+
|
|
239
|
+
Three pages render the same 50 styled components and are verified, before any
|
|
240
|
+
timing, to produce the same 50 elements at the same computed color:
|
|
241
|
+
|
|
242
|
+
- **baseline** — the components server-rendered: identical markup, identical
|
|
243
|
+
class names, a linked stylesheet, and no Tasty on the page. Every other
|
|
244
|
+
column is a delta against this one.
|
|
245
|
+
- **runtime** — Tasty generates the CSS in the browser, as a client-rendered
|
|
246
|
+
application does. The run asserts it really did (69 rules generated).
|
|
247
|
+
- **prewarm** — the same, after one throwaway `computeStyles()` against a
|
|
248
|
+
detached root before the first component renders.
|
|
249
|
+
|
|
250
|
+
Each cell is the median of 5 uncached loads in a fresh browser context. The run
|
|
251
|
+
ends at the first contentful paint, observed through a `PerformanceObserver`
|
|
252
|
+
rather than counted in animation frames — `requestAnimationFrame` fires before
|
|
253
|
+
paint, so a page that commits fast can reach its second frame with nothing
|
|
254
|
+
painted yet.
|
|
255
|
+
|
|
256
|
+
Two things about the payload decide whether this measures a deployment or a
|
|
257
|
+
straw man, so both are enforced rather than assumed:
|
|
258
|
+
|
|
259
|
+
- **Assets are served brotli-compressed**, the way a static host serves them.
|
|
260
|
+
The bundle is 52.0 KB on the wire and 186 KB decoded; putting the decoded
|
|
261
|
+
bytes on a 1.6 Mbps link would add ~700 ms and charge it to Tasty. The run
|
|
262
|
+
reads `encodedBodySize` back out of resource timing and fails if what
|
|
263
|
+
crossed the wire is not the compressed size the table reports.
|
|
264
|
+
- **The bundle is built from what the page imports** (`tasty`, `configure`,
|
|
265
|
+
`computeStyles`, `tastyDebug`), so it is tree-shaken as an application's
|
|
266
|
+
would be. Re-exporting the whole library adds ~4 KB brotli of code no page
|
|
267
|
+
here calls.
|
|
268
|
+
|
|
269
|
+
On an Apple M1 Max with React 19.2.8 and Chromium 151, first contentful paint:
|
|
270
|
+
|
|
271
|
+
| Link / CPU | baseline | runtime | prewarm | Tasty's cost |
|
|
272
|
+
| --------------------- | -------: | ------: | ------: | -----------: |
|
|
273
|
+
| No throttling, 1x | 40 ms | 52 ms | 52 ms | +12 ms |
|
|
274
|
+
| Fast 4G, 1x | 624 ms | 680 ms | 676 ms | +56 ms |
|
|
275
|
+
| Slow 4G, 1x | 2028 ms | 2304 ms | 2304 ms | +276 ms |
|
|
276
|
+
| No throttling, 4x CPU | 148 ms | 196 ms | 196 ms | +48 ms |
|
|
277
|
+
| Fast 4G, 4x CPU | 684 ms | 784 ms | 788 ms | +100 ms |
|
|
278
|
+
| Slow 4G, 4x CPU | 2096 ms | 2416 ms | 2408 ms | +320 ms |
|
|
279
|
+
|
|
280
|
+
That is one full run of the matrix; a second moved every cell by a few percent.
|
|
281
|
+
|
|
282
|
+
**The cost is the bundle, not the work.** On Slow 4G the extra transfer alone
|
|
283
|
+
accounts for 262 ms of the 276 ms FCP delta — nearly all of it. Everything
|
|
284
|
+
Tasty then *does* is small by comparison:
|
|
285
|
+
|
|
286
|
+
| Phase (Slow 4G, 1x) | baseline | runtime | prewarm |
|
|
287
|
+
| ----------------------- | -------: | ------: | ------: |
|
|
288
|
+
| js+css transfer | 1420 ms | 1682 ms | 1681 ms |
|
|
289
|
+
| module compile (shared) | 1.2 ms | 1.5 ms | 2.0 ms |
|
|
290
|
+
| tasty top-level execute | — | 1.2 ms | 0.9 ms |
|
|
291
|
+
| `configure()` | — | 0.6 ms | 0.5 ms |
|
|
292
|
+
| prewarm | — | — | 5.3 ms |
|
|
293
|
+
| render 1st component | 2.0 ms | 8.2 ms | 2.7 ms |
|
|
294
|
+
| render 49 more | 1.0 ms | 7.1 ms | 6.0 ms |
|
|
295
|
+
|
|
296
|
+
Importing Tasty costs about 1 ms of top-level execution; `configure()` costs
|
|
297
|
+
half of one. The rest of the CPU delta — about 13 ms for 50 components — is
|
|
298
|
+
generation and injection, which is the cost the injection benchmark isolates.
|
|
299
|
+
|
|
300
|
+
One asymmetry is worth naming: the control links a render-blocking stylesheet
|
|
301
|
+
and the runtime modes have none, so the control's first paint waits for CSS the
|
|
302
|
+
runtime modes never request. That is the real difference between the two
|
|
303
|
+
delivery models, not a thumb on the scale, but it means the FCP delta is not
|
|
304
|
+
purely "what Tasty costs to execute".
|
|
305
|
+
|
|
306
|
+
**Prewarming moves the wake-up, it does not remove it.** The first styled render
|
|
307
|
+
is ~5 ms more expensive than the ones after it, because that is when the
|
|
308
|
+
engine's deferred payload is actually compiled. A throwaway `computeStyles()`
|
|
309
|
+
against a detached root pays it early: `render 1st` drops from 8.2 ms to
|
|
310
|
+
2.7 ms. The prewarm itself costs 5.3 ms, so FCP does not move. It is worth
|
|
311
|
+
doing only when something else can overlap it, or when the first render is on a
|
|
312
|
+
latency-critical path and the page has idle time before it.
|
|
313
|
+
|
|
314
|
+
**Retained heap.** After a forced collection, the runtime page holds about
|
|
315
|
+
1,013 KB more than the control (2,632 KB vs 1,619 KB) for 50 components — the
|
|
316
|
+
parser caches, the chunk cache, the injector's registry and the generated CSS.
|
|
317
|
+
The control is not zero either; most of its 1.6 MB is React and the DOM.
|
|
318
|
+
|
|
319
|
+
CPU throttling changes which line moves. At 4x, module compilation of the
|
|
320
|
+
larger graph becomes visible (5.9 ms → 25 ms) where at 1x it is free: V8
|
|
321
|
+
pre-parses at import and compiles lazily, so a slower CPU pays for code the
|
|
322
|
+
faster one never fully compiled. Transfer numbers from the unthrottled cells
|
|
323
|
+
are not worth reading — with no emulated link, resource timings are scheduling
|
|
324
|
+
jitter.
|
|
325
|
+
|
|
162
326
|
## Reading the Results Together
|
|
163
327
|
|
|
164
|
-
Do not add
|
|
165
|
-
They describe different paths:
|
|
328
|
+
Do not add the microbenchmark numbers together to estimate an application
|
|
329
|
+
blindly. They describe different paths:
|
|
166
330
|
|
|
167
331
|
- A stable `tasty()` factory can skip the style pipeline on later renders, but
|
|
168
332
|
its React wrapper still participates in reconciliation.
|
|
@@ -170,9 +334,18 @@ They describe different paths:
|
|
|
170
334
|
rule.
|
|
171
335
|
- A genuinely new style pays generation and injection once, then becomes
|
|
172
336
|
reusable.
|
|
337
|
+
- A mod flip on an already-styled element pays neither; it is a class-name
|
|
338
|
+
change.
|
|
173
339
|
- Browser style resolution, layout, and paint depend on the actual document and
|
|
174
340
|
need application-level profiling.
|
|
175
341
|
|
|
342
|
+
The cold-start measurement is the one that puts the rest in proportion. On a
|
|
343
|
+
slow connection, nearly all of Tasty's page-load cost is transferring the
|
|
344
|
+
library — 262 ms of a 276 ms delta — while the generation and injection the
|
|
345
|
+
microbenchmarks obsess over is ~13 ms for 50 components. Bundle size is
|
|
346
|
+
therefore the lever with the largest effect on first paint, and the runtime
|
|
347
|
+
levers matter for what happens after it.
|
|
348
|
+
|
|
176
349
|
The practical optimization target is therefore repeated work: keep style input
|
|
177
350
|
stable when possible, reuse generated chunks, and generate CSS at build or
|
|
178
351
|
server time when runtime flexibility is unnecessary.
|
package/docs/ssr.md
CHANGED
|
@@ -86,6 +86,92 @@ export default function RootLayout({
|
|
|
86
86
|
|
|
87
87
|
That's it. All `tasty()` components inside the tree automatically get SSR support. No per-component changes needed.
|
|
88
88
|
|
|
89
|
+
### Optional shared globals stylesheet
|
|
90
|
+
|
|
91
|
+
By default, configured global CSS is included in the streamed Tasty style tag
|
|
92
|
+
for every route. `withTastyNext()` can move that stable CSS into one
|
|
93
|
+
content-hashed stylesheet shared by all routes while leaving component and hook
|
|
94
|
+
styles in the normal streaming path.
|
|
95
|
+
|
|
96
|
+
Keep the Tasty config in a side-effect-free module:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
// app/tasty.config.ts
|
|
100
|
+
import type { TastyConfig } from '@tenphi/tasty';
|
|
101
|
+
|
|
102
|
+
const config: TastyConfig = {
|
|
103
|
+
tokens: { $gap: '8px', '#brand': 'rebeccapurple' },
|
|
104
|
+
globalStyles: { body: { margin: '0', color: '#brand' } },
|
|
105
|
+
fontFaces: {
|
|
106
|
+
Brand: { src: 'url("/fonts/brand.woff2") format("woff2")' },
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export default config;
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Use it from the Next config:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
// next.config.ts
|
|
117
|
+
import { withTastyNext } from '@tenphi/tasty/ssr/next-config';
|
|
118
|
+
import config from './app/tasty.config';
|
|
119
|
+
|
|
120
|
+
export default withTastyNext({
|
|
121
|
+
config,
|
|
122
|
+
})({
|
|
123
|
+
// your Next.js config
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The runtime must receive the same config. Import and configure it before the
|
|
128
|
+
registry renders:
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
// app/tasty-registry.tsx
|
|
132
|
+
'use client';
|
|
133
|
+
|
|
134
|
+
import { configure } from '@tenphi/tasty';
|
|
135
|
+
import { TastyRegistry } from '@tenphi/tasty/ssr/next';
|
|
136
|
+
import config from './tasty.config';
|
|
137
|
+
|
|
138
|
+
configure(config);
|
|
139
|
+
|
|
140
|
+
export default function TastyStyleRegistry({ children }) {
|
|
141
|
+
return <TastyRegistry>{children}</TastyRegistry>;
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The generated file contains eager configuration artifacts: built-in and custom
|
|
146
|
+
`@property` rules, tokens and presets, `@font-face`, `@counter-style`, native
|
|
147
|
+
CSS `@function` definitions, and `globalStyles`. Route-dependent component
|
|
148
|
+
rules and calls to `useGlobalStyles`, `useRawCSS`, `useKeyframes`,
|
|
149
|
+
`useProperty`, `useFontFace`, `useCounterStyle`, and `useFunction` remain
|
|
150
|
+
route-specific. Configured keyframes also remain lazy and are streamed only
|
|
151
|
+
when a route references them.
|
|
152
|
+
|
|
153
|
+
The default output directory is `public/_tasty`. The wrapper adds the generated
|
|
154
|
+
URL to `TastyRegistry`, respects `basePath`, preserves existing `env` and
|
|
155
|
+
`headers` config, and serves the content-hashed file with an immutable one-year
|
|
156
|
+
cache header on Next server deployments. Static exports keep the content-hashed
|
|
157
|
+
URL and leave cache headers to the hosting provider. Older hashes are not
|
|
158
|
+
deleted automatically, so rolling deployments cannot break pages from the
|
|
159
|
+
previous build; clean the generated directory as part of a clean deployment if
|
|
160
|
+
needed. Page-relative CSS resources such as
|
|
161
|
+
`url(../fonts/brand.woff2)` are rejected because moving them would change their
|
|
162
|
+
meaning; use root-relative, absolute, or data URLs.
|
|
163
|
+
|
|
164
|
+
`withTastyNext()` options:
|
|
165
|
+
|
|
166
|
+
| Option | Type | Default | Description |
|
|
167
|
+
| ------------ | ------------- | ------------------------- | ---------------------------------------------------------------------- |
|
|
168
|
+
| `config` | `TastyConfig` | — | Config object; takes precedence over `configFile` |
|
|
169
|
+
| `configFile` | `string` | — | Project-relative config module path; requires the optional `jiti` peer |
|
|
170
|
+
| `rootDir` | `string` | current directory | Next app root when the build runs from a monorepo root |
|
|
171
|
+
| `outputDir` | `string` | `public/_tasty` | Filesystem output directory |
|
|
172
|
+
| `publicPath` | `string` | inferred from `outputDir` | Root-relative URL; required when output is outside `public` |
|
|
173
|
+
| `enabled` | `boolean` | `true` | Disable generation without changing wrapper composition |
|
|
174
|
+
|
|
89
175
|
### How it works
|
|
90
176
|
|
|
91
177
|
- `TastyRegistry` is a `'use client'` component, but Next.js still server-renders it on initial page load. The `'use client'` boundary is required solely to access `useServerInsertedHTML` — **not** because `tasty()` components need the client.
|
|
@@ -130,13 +216,13 @@ The nonce is automatically applied to all `<style>` and `<script>` tags injected
|
|
|
130
216
|
|
|
131
217
|
Tasty offers several levels of Astro integration. Choose the one that matches your needs:
|
|
132
218
|
|
|
133
|
-
| Setup
|
|
134
|
-
|
|
|
135
|
-
| Zero setup
|
|
136
|
-
| `tastyIntegration({ islands: false })`
|
|
137
|
-
| `tastyIntegration()`
|
|
138
|
-
| `tastyIntegration({ css: { mode: 'extract' } })`
|
|
139
|
-
| `tastyIntegration({ islands: false, css: { mode: 'extract' } })` | One line
|
|
219
|
+
| Setup | Config needed | Deduplication | Hooks work | Client JS |
|
|
220
|
+
| ---------------------------------------------------------------- | ------------- | ------------------------- | ---------------------- | -------------- |
|
|
221
|
+
| Zero setup | None | Per render tree | Yes (within each tree) | None |
|
|
222
|
+
| `tastyIntegration({ islands: false })` | One line | Cross-tree | Yes | None |
|
|
223
|
+
| `tastyIntegration()` | One line | Cross-tree | Yes | Auto-hydration |
|
|
224
|
+
| `tastyIntegration({ css: { mode: 'extract' } })` | One line | Cross-tree and cross-page | Yes | Auto-hydration |
|
|
225
|
+
| `tastyIntegration({ islands: false, css: { mode: 'extract' } })` | One line | Cross-tree and cross-page | Yes | None |
|
|
140
226
|
|
|
141
227
|
### Zero setup (static pages)
|
|
142
228
|
|
|
@@ -235,8 +321,8 @@ This gives the same middleware deduplication and hook support, but ships zero cl
|
|
|
235
321
|
|
|
236
322
|
#### Build-wide CSS extraction
|
|
237
323
|
|
|
238
|
-
Static Astro builds can move
|
|
239
|
-
|
|
324
|
+
Static Astro builds can move Tasty CSS into content-hashed, browser-cacheable
|
|
325
|
+
shared and page assets:
|
|
240
326
|
|
|
241
327
|
```ts
|
|
242
328
|
export default defineConfig({
|
|
@@ -257,14 +343,38 @@ output. Extraction requires Astro 5 or newer and only applies to prerendered
|
|
|
257
343
|
production pages. Development, preview-time SSR, and on-demand routes continue
|
|
258
344
|
to receive the normal inline `<style data-tasty-ssr>` output.
|
|
259
345
|
|
|
260
|
-
Extraction
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
346
|
+
Extraction writes every artifact emitted by all styled pages to a shared
|
|
347
|
+
stylesheet. Each page's strict set difference is written to a separate page
|
|
348
|
+
stylesheet. The shared link comes first and the page link follows, so shared
|
|
349
|
+
styles form the base cascade and page-only styles can override them. A fully
|
|
350
|
+
shared page omits the empty page stylesheet. If generated pages have no common
|
|
351
|
+
artifacts, each page receives only its page stylesheet.
|
|
352
|
+
|
|
353
|
+
The shared-base/page-override order is the extraction-mode cascade contract.
|
|
354
|
+
It does not preserve an inline artifact order where a page-only rule originally
|
|
355
|
+
appeared before a shared rule. Use shared styles for defaults and page-only
|
|
356
|
+
styles for overrides.
|
|
357
|
+
|
|
358
|
+
Extracted CSS preserves resource URLs verbatim. Relative URLs in an inline
|
|
359
|
+
style resolve from the page, but in an extracted stylesheet they resolve from
|
|
360
|
+
the asset directory. Use absolute URLs or data URLs when extraction is enabled.
|
|
361
|
+
Root-relative URLs such as `url(/fonts/brand.woff2)` are also safe while the
|
|
362
|
+
stylesheet stays on the page's origin. The build fails with a clear error if an
|
|
363
|
+
artifact contains a page-relative or fragment-only URL, including URL strings
|
|
364
|
+
in `image-set()`, `image()`, `src()`, and `@import`.
|
|
365
|
+
|
|
366
|
+
Assets are written under Astro's configured `build.assets` directory (for
|
|
367
|
+
example, `/_astro/tasty.shared.a1b2c3.css` and
|
|
368
|
+
`/_astro/tasty.page.d4e5f6.css`). Links include the configured Astro `base`, so
|
|
369
|
+
nested routes do not need relative-path handling. Content hashes and output are
|
|
370
|
+
deterministic for identical builds. If `build.assetsPrefix` is configured,
|
|
371
|
+
Tasty uses its CSS-specific prefix (or `fallback`) just like Astro-generated
|
|
372
|
+
stylesheets. When that prefix points to a different origin, root-relative
|
|
373
|
+
resources would resolve against the asset origin rather than the page's origin,
|
|
374
|
+
so the build rejects them as well. Tasty compares the prefix with Astro's `site`
|
|
375
|
+
when it is configured; without `site`, an absolute or protocol-relative prefix
|
|
376
|
+
is treated conservatively as cross-origin. Use a fully absolute resource URL in
|
|
377
|
+
that configuration.
|
|
268
378
|
|
|
269
379
|
### Manual middleware (advanced)
|
|
270
380
|
|
|
@@ -300,12 +410,13 @@ Astro's `@astrojs/react` renderer calls `renderToString()` for each React compon
|
|
|
300
410
|
- The middleware reads the full response body, then injects the collected CSS into `</head>` before sending the final HTML.
|
|
301
411
|
- In extraction mode, prerendered responses also carry temporary structured
|
|
302
412
|
artifact metadata. `astro:build:done` uses those collector-provided
|
|
303
|
-
boundaries to write
|
|
304
|
-
the metadata.
|
|
413
|
+
boundaries to write shared and page assets and rewrite generated HTML, then
|
|
414
|
+
removes the metadata. Artifact boundaries are never inferred by splitting or
|
|
415
|
+
reparsing the generated CSS.
|
|
305
416
|
|
|
306
417
|
### CSP nonce
|
|
307
418
|
|
|
308
|
-
Call `configure({ nonce: '...' })` before any rendering happens. The middleware reads the nonce and applies it to injected `<style>` and `<script>` tags. In extraction mode,
|
|
419
|
+
Call `configure({ nonce: '...' })` before any rendering happens. The middleware reads the nonce and applies it to injected `<style>` and `<script>` tags. In extraction mode, the external stylesheet links retain the nonce.
|
|
309
420
|
|
|
310
421
|
---
|
|
311
422
|
|
|
@@ -388,12 +499,13 @@ const stream = await runWithCollector(collector, () =>
|
|
|
388
499
|
|
|
389
500
|
### Entry points
|
|
390
501
|
|
|
391
|
-
| Import path
|
|
392
|
-
|
|
|
393
|
-
| `@tenphi/tasty/ssr`
|
|
394
|
-
| `@tenphi/tasty/ssr/next`
|
|
395
|
-
| `@tenphi/tasty/ssr/
|
|
396
|
-
| `@tenphi/tasty/ssr/astro
|
|
502
|
+
| Import path | Description |
|
|
503
|
+
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
504
|
+
| `@tenphi/tasty/ssr` | Core SSR API: `ServerStyleCollector`, `createServerStyleCollector`, `runWithCollector`, `hydrateTastyClasses` |
|
|
505
|
+
| `@tenphi/tasty/ssr/next` | Next.js App Router: `TastyRegistry` component |
|
|
506
|
+
| `@tenphi/tasty/ssr/next-config` | Next.js config wrapper: shared, content-hashed global stylesheet |
|
|
507
|
+
| `@tenphi/tasty/ssr/astro` | Astro: `tastyIntegration`, `tastyMiddleware` |
|
|
508
|
+
| `@tenphi/tasty/ssr/astro-client` | Astro: client-side cache hydration (auto-injected by integration, or import manually) |
|
|
397
509
|
| `@tenphi/tasty/ssr/astro-middleware`<br>`@tenphi/tasty/ssr/astro-middleware-static`<br>`@tenphi/tasty/ssr/astro-middleware-extract`<br>`@tenphi/tasty/ssr/astro-middleware-extract-static` | Astro: the middleware entrypoints `tastyIntegration()` registers via `addMiddleware()`. Exported so Astro can resolve them by specifier; you should not import them. For manual setups use `tastyMiddleware()`. |
|
|
398
510
|
|
|
399
511
|
### `ServerStyleCollector`
|
|
@@ -414,7 +526,7 @@ Constructor: `new ServerStyleCollector(namePrefix?)`, or use the `createServerSt
|
|
|
414
526
|
| `allocateCounterStyleName(providedName?)` | Allocate a counter-style name. Returns `providedName` if given, otherwise generates one using `${namePrefix}c${counter}` (e.g. `tc0`, `tc1`, ...). |
|
|
415
527
|
| `collectGlobalStyles(key, css)` | Record global styles (from `useGlobalStyles`). Deduplicated by key. |
|
|
416
528
|
| `collectRawCSS(key, css)` | Record raw CSS text (from `useRawCSS`). Deduplicated by key. |
|
|
417
|
-
| `collectInternals()` | Collect
|
|
529
|
+
| `collectInternals()` | Collect eager configured globals: `@property`, `:root` tokens and presets, `@font-face`, `@counter-style`, `@function`, and `globalStyles`. Called automatically on first chunk collection; idempotent. |
|
|
418
530
|
| `getCSS()` | Get all collected CSS as a single string. For non-streaming SSR. |
|
|
419
531
|
| `flushCSS()` | Get only CSS collected since the last flush. For streaming SSR. |
|
|
420
532
|
| `getRenderedClassNames()` | Get the list of class names rendered so far. Serialized to `window.__TASTY__` for client hydration via `hydrateTastyClasses()`. |
|
|
@@ -423,10 +535,18 @@ Constructor: `new ServerStyleCollector(namePrefix?)`, or use the `createServerSt
|
|
|
423
535
|
|
|
424
536
|
Next.js App Router component. Props:
|
|
425
537
|
|
|
426
|
-
| Prop
|
|
427
|
-
|
|
|
428
|
-
| `children`
|
|
429
|
-
| `transferCache`
|
|
538
|
+
| Prop | Type | Default | Description |
|
|
539
|
+
| ------------------ | ----------------- | --------- | ------------------------------------------------------------------------- |
|
|
540
|
+
| `children` | `ReactNode` | required | Application tree |
|
|
541
|
+
| `transferCache` | `boolean` | `true` | Embed cache state script for zero-cost hydration |
|
|
542
|
+
| `sharedStylesheet` | `string \| false` | generated | Override the shared URL, or disable generated shared CSS for the registry |
|
|
543
|
+
|
|
544
|
+
### `withTastyNext(options)`
|
|
545
|
+
|
|
546
|
+
Next.js configuration wrapper exported from
|
|
547
|
+
`@tenphi/tasty/ssr/next-config`. It generates a shared stylesheet from eager
|
|
548
|
+
Tasty configuration artifacts and wires its URL and immutable cache header
|
|
549
|
+
into the Next config. Route-specific CSS continues through `TastyRegistry`.
|
|
430
550
|
|
|
431
551
|
### `tastyIntegration(options?)`
|
|
432
552
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tenphi/tasty",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.0",
|
|
4
4
|
"description": "A design-system-integrated styling system and DSL for concise, state-aware UI styling",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -52,6 +52,11 @@
|
|
|
52
52
|
"import": "./dist/ssr/next.js",
|
|
53
53
|
"default": "./dist/ssr/next.js"
|
|
54
54
|
},
|
|
55
|
+
"./ssr/next-config": {
|
|
56
|
+
"types": "./dist/ssr/next-config.d.ts",
|
|
57
|
+
"import": "./dist/ssr/next-config.js",
|
|
58
|
+
"default": "./dist/ssr/next-config.js"
|
|
59
|
+
},
|
|
55
60
|
"./ssr/astro": {
|
|
56
61
|
"types": "./dist/ssr/astro.d.ts",
|
|
57
62
|
"import": "./dist/ssr/astro.js",
|
|
@@ -174,6 +179,7 @@
|
|
|
174
179
|
"@types/react-dom": "^19.0.0",
|
|
175
180
|
"@vitest/browser-playwright": "^4.1.10",
|
|
176
181
|
"astro": "^5.18.2",
|
|
182
|
+
"esbuild": "^0.27.7",
|
|
177
183
|
"eslint": "^10.0.0",
|
|
178
184
|
"eslint-config-prettier": "^10.1.8",
|
|
179
185
|
"jiti": "^2.6.1",
|
|
@@ -249,6 +255,8 @@
|
|
|
249
255
|
"bench:browser": "vitest bench --project browser",
|
|
250
256
|
"bench:overhead": "vitest bench --config vitest.production-browser-bench.config.ts src/tasty-overhead.bench.tsx",
|
|
251
257
|
"bench:injection": "vitest bench --config vitest.production-browser-bench.config.ts src/tasty-injection.bench.ts",
|
|
258
|
+
"bench:interaction": "vitest bench --config vitest.production-browser-bench.config.ts src/tasty-interaction.bench.tsx",
|
|
259
|
+
"bench:cold-start": "node scripts/cold-start/index.mjs",
|
|
252
260
|
"changeset": "changeset",
|
|
253
261
|
"version": "changeset version",
|
|
254
262
|
"release": "changeset publish",
|