@ultimat3/render 19.1.2 → 19.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -2
- package/README.md +73 -1
- package/package.json +5 -5
- package/src/island-props.ts +52 -6
- package/src/module-loader.ts +19 -0
- package/src/server.ts +1 -0
package/CLAUDE.md
CHANGED
|
@@ -50,7 +50,7 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
|
|
|
50
50
|
| Island declaration order | `island()` above `defineRoute`, drained by it (`drainDeclaredIslands`). Package-internal — reachable from `./island`, never re-exported by `src/index.ts`: an app calling the drain between its `island()` and its `defineRoute` would silently un-declare the islands the route derives everything from, and a public export is semver-locked the moment it ships. Ambient, and NOT the thing the collector refuses to be: that one is per RENDER, where two requests would bill each other; this one is per MODULE, evaluated once, before any request — and `src` is resolved relative to the route file, so an `island()` call is route-module-local by construction. |
|
|
51
51
|
| Derived budget | `registry.ts`, not `defineRoute`: a ceiling is only meaningful against a surface baseline, and the surface is a fact of the file path the route table already reads. `site/` → `20kb`, `app/` → `34kb` (`DEFAULT_ISLAND_JS_BYTES` above `jsBaselineBytes`). **Calibrated on a Solid island** `As of 2026-08`: it was `4kb`, sized from `contact-sales.island.tsx`, which imports no `solid-js` at all — and `render(() => <p>hello</p>, el)` measures 12,588 B, so the default sat a factor of three below the floor of every island that uses the JSX runtime, on every surface. A declared `budget.js` wins; a `'never'` route gets none, so the contradiction stays visible. |
|
|
52
52
|
| `RouteEntry.islands` | filled from `config.islands` at registration, and from nothing else — `RegisterRouteInput` has no `islands` key. It was `input.islands ?? []`, undocumented and passed by nothing, so the now-deleted `routeJsBytes`'s "what registration declared" half read `[]` on every route in the framework's history; keeping it as a fallback would be a second answer to one question that can only ever weaken it, since a caller passing `[]` un-declares an island. The field survives its one former reader: it is the only record a build has of an island a page declared but did not render on a given pass. |
|
|
53
|
-
| Island props | declared, JSON-safe, under `ISLAND_PROPS_MAX_BYTES` — `island-props.ts` is the one gate. A structural walk, never a `JSON.stringify` round trip: stringify drops a function and an `undefined` silently, which is the footgun rather than the check. |
|
|
53
|
+
| Island props | declared, JSON-safe, under `ISLAND_PROPS_MAX_BYTES` — `island-props.ts` is the one gate. A structural walk, never a `JSON.stringify` round trip: stringify drops a function and an `undefined` silently, which is the footgun rather than the check. The cap is **16 KiB** `As of 2026-09-05` (was 4; a 34-row catalog at 8,812 B was a runtime 500) and its doc comment carries the arithmetic — the bag is inlined as a JSON script `measureDocumentJs` counts as zero JS, so this constant is the only ceiling on it. Over the cap, the cause names the **heaviest props with their bytes** and the fix names the endpoint pattern (`models: []` + `modelsEndpoint`), never "raise the cap". `X_ISLAND_PROPS_INVALID` has a **row** in `@ultimat3/http`'s table (500): unclassified, `problem+json` blanked the one sentence that is the instruction. |
|
|
54
54
|
| A prop lands via `Object.defineProperty` | never `out[key] = v`. For exactly one name — `__proto__`, which `JSON.parse` mints as a real OWN key off any request body — the assignment runs `Object.prototype`'s setter: the prop was DROPPED from the browser payload (the footgun the walk exists to prevent), the record handed back as `IslandProps` carried a prototype built from request data, so a later `bag.row.isAdmin` on the SERVER read attacker-chosen values, and `ISLAND_PROPS_MAX_BYTES` under-counted because `JSON.stringify` could not see it. Same shape `@ultimat3/mcp`'s `validate-args.ts` uses for the same class. |
|
|
55
55
|
| An attribute alias is a `Map` | never a record — an object lookup walks the prototype chain, so `<div {...row} />` with a column named `toString` resolved the alias to a FUNCTION and `attribute.toLowerCase()` threw a bare `TypeError`: no code, no fix, the whole page 500s off a `load()` result. Same reason `MODE_SPECS[config.render]` in `modes.ts` is guarded by `Object.hasOwn`, where `render: 'constructor'` returned a frozen descriptor for a mode nothing implements. |
|
|
56
56
|
| An attribute NAME is validated too | `ATTRIBUTE_NAME` in `html.ts` — `/^[A-Za-z_:][-A-Za-z0-9_:.]*$/`, refused as `null`. A name is emitted VERBATIM before the `=` and is escaped nowhere, so `{ 'q onmouseover=alert(1) r': 'ok' }` shipped a live event handler out of an object KEY. The handler check on the line under it folds case for the same reason: it was `name.startsWith('on')` while the two checks below it lowercased, so `ONERROR="alert(1)"` went out on the wire. Both are reachable by `<div {...row} />` over a JSON body or a JSONB column. `head.ts`'s `renderTag` is the package's OTHER attribute sink and shares the predicate (`isAttributeName`) — it emitted `<meta name="q" r onmouseover=alert(1) s="ok">` from an `attrs` key. One predicate, never two. |
|
|
@@ -97,7 +97,7 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
|
|
|
97
97
|
| Escaping | `html.ts` only, and that now includes `render-stream.ts` (`holeMarker`'s attribute, and `revealChunk`'s `$X(...)` argument via `JSON.stringify` — an id containing `")` closed the call and ran the rest) and `head.ts`'s `themeScript` (`storageKey`/`attribute` as JS string LITERALS). All author-controlled today — the identical status `emitIslandAttributes` had before the last sweep. `render-spa.ts` was the third entry here and went with the mode. A second escaper is how one of them ends up missing a character, and a missing character in an attribute is an injection. `escapeAttribute` itself is `@ultimat3/seo`'s (tier 1), re-exported by `html.ts` rather than reimplemented — the copy that lived here was the second escaper this row forbids, and `pwa/CLAUDE.md` already named seo's as the one. `head.ts` and `hydrate.ts` each had a private copy; both now import — `escapeAttribute` for every attribute value (`emitIslandAttributes` interpolated all five of its own raw until 2026-08, while this row already claimed otherwise) and `escapeJsonContent` for a JSON script body. |
|
|
98
98
|
| Script and style CONTENT | never emitted raw. Three rules, one choice: HTML text (`escapeText`), raw text for code (`escapeRawTextContent`: `</` → `<\/`, `<!--` → `<\!--`), and the total JSON rule for a `type` ending in `json` (`escapeJsonContent`: `<`, `>`, `&`, U+2028/9 → `\uXXXX`, still valid JSON). `meta.ld` is built from route data, and it was emitted VERBATIM until `As of 2026-08` — a title could close the element. Never HTML-escape a script body: a character reference is not decoded there, so `<` corrupts the code AND leaves the hole. |
|
|
99
99
|
| Which export is the page | `route-component.ts`, one precedence: `Page` → a single `…Page` → a single capitalised function. Never a per-generator name table. |
|
|
100
|
-
| Stylesheets | compiled by `css-modules.ts
|
|
100
|
+
| Stylesheets | compiled by `css-modules.ts`, still grouped per surface, and served by the CLI as **one content-hashed file per surface** (`@ultimat3/cli`'s `style-bundle.ts`) rather than inlined — measured 2026-09-06, the inline block was 156,738 bytes inside a `no-store` document, re-sent on every navigation. `stylesFor` is unchanged and is what that file is built from. `sass` is this package's only third-party dependency and its only reason to exist here. |
|
|
101
101
|
| CSS order | `stylesFor` sorts **globals before modules** (`isGlobalStylesheet`), never plain insertion order — the reset styles bare elements at the lowest specificity there is, so whichever page loaded first must not decide who wins a tie. `shared/` is carried by both graphs, like a package sheet: it is where an app's own global layer lives, and filtering it out is what made every deployed app render token-less. |
|
|
102
102
|
| The global layer | this package may not import `@ultimat3/ui` (tier 4, the same tier — sideways, not upward: `ui` moved 5 → 4 in 2026-08 and `render → ui` stays forbidden because a same-tier edge has to be declared in `scripts/lib/tiers.ts`, and this one deliberately is not — the static bundle graph may not reach the design system, axiom 6), so the app's source graph carries it: one `shared/global.scss` that `@use`s `@ultimat3/ui/global.scss`, side-effect-imported by `shared/global.ts`. One file, because each stylesheet is its own Sass compilation — a token file `@use`d per module duplicates its `:root` block per module. `x verify` fails with `X_STYLES_GLOBAL_MISSING` when a surface's document defines none. |
|
|
103
103
|
| Colours | tokens and `data-theme` only. No hex in `head.ts` or any emitted script. |
|
package/README.md
CHANGED
|
@@ -270,13 +270,85 @@ else is `X_ISLAND_INVALID`, and the fix is the `git mv`.
|
|
|
270
270
|
|---|---|
|
|
271
271
|
| every prop is declared in `props: [...]` | `X_ISLAND_PROPS_INVALID`, naming each undeclared key |
|
|
272
272
|
| every value is JSON — no function, `Date`, class instance, `bigint`, `undefined`, cycle | `X_ISLAND_PROPS_INVALID`, naming the path and the type |
|
|
273
|
-
| serialized props ≤ `ISLAND_PROPS_MAX_BYTES` (
|
|
273
|
+
| serialized props ≤ `ISLAND_PROPS_MAX_BYTES` (16,384 B) | `X_ISLAND_PROPS_INVALID`, naming the heaviest props with their bytes |
|
|
274
274
|
|
|
275
275
|
`<ContactModal {...post} />` fails and names `email`, `passwordHash` — every column the spread
|
|
276
276
|
would have shipped. The type refuses it first (`type-pins.tsx` pins that); the render refuses it
|
|
277
277
|
second, which for `static` and `isr` is build time. `children` are the server-rendered shell and
|
|
278
278
|
are never serialized.
|
|
279
279
|
|
|
280
|
+
`X_ISLAND_PROPS_INVALID` is a **declared 500** in `@ultimat3/http`'s status table `As of 2026-09-05`.
|
|
281
|
+
It is the author's fault and never the caller's, so 500 is the class — but until it had a row it
|
|
282
|
+
was an *unclassified* 500, and `problem+json` blanks the cause of one of those outside dev. A
|
|
283
|
+
34-row catalog over the cap took a page down with a document that said "the details are in this
|
|
284
|
+
process's logs" about an error whose whole value is the sentence naming the prop and its bytes.
|
|
285
|
+
|
|
286
|
+
### Large data rides over a query endpoint, after mount
|
|
287
|
+
|
|
288
|
+
The props bag is inlined **verbatim** in the document, as `<script type="application/json">`, on
|
|
289
|
+
every request — and `x verify`'s `budgets` step counts a JSON-typed script as data, not JS, so
|
|
290
|
+
`budget.js` never sees a byte of it. `ISLAND_PROPS_MAX_BYTES` is the only ceiling on that channel.
|
|
291
|
+
It is **16 KiB** `As of 2026-09-05` (it was 4 KiB, and a 34-model catalog at 8,812 B was a 500): a bag
|
|
292
|
+
comparable in size to the island's own code (`DEFAULT_ISLAND_JS_BYTES` derives 20 KiB for `site/`,
|
|
293
|
+
34 KiB for `app/`), ~3-4 KiB gzipped, under 100 ms on a 3G-class link. The constant's own comment
|
|
294
|
+
carries the arithmetic.
|
|
295
|
+
|
|
296
|
+
A list that is the same on every request is not a prop — it is a **dataset**, and under the cap
|
|
297
|
+
or over it the page is the wrong place for it: it renders into every document, is cached with
|
|
298
|
+
none of them, and is parsed before first paint. The props are what the island needs to draw its
|
|
299
|
+
first frame — an id, a count, and the URL of the read that answers the rest:
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
// page.tsx — the props are the id and the endpoint, never the rows
|
|
303
|
+
import { derivePath } from '@ultimat3/query';
|
|
304
|
+
import { island } from '@ultimat3/render';
|
|
305
|
+
|
|
306
|
+
const Dispatch = island({
|
|
307
|
+
src: './dispatch.island.tsx',
|
|
308
|
+
props: ['hostId', 'models', 'modelsEndpoint'],
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
export const DispatchFor = (host: { readonly id: string }) =>
|
|
312
|
+
Dispatch({ hostId: host.id, models: [], modelsEndpoint: `${derivePath('modelList')}?limit=100` });
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
```tsx
|
|
316
|
+
// dispatch.island.tsx — one GET after mount, cached by the browser like any read
|
|
317
|
+
import { createSignal } from 'solid-js';
|
|
318
|
+
import { render } from 'solid-js/web';
|
|
319
|
+
|
|
320
|
+
type Model = { readonly id: string; readonly label: string };
|
|
321
|
+
type DispatchProps = {
|
|
322
|
+
readonly hostId: string;
|
|
323
|
+
readonly models: readonly Model[];
|
|
324
|
+
readonly modelsEndpoint?: string;
|
|
325
|
+
};
|
|
326
|
+
const Picker = (props: { readonly models: readonly Model[] }) => <ul>{props.models.length}</ul>;
|
|
327
|
+
|
|
328
|
+
export function mount(el: HTMLElement, props: DispatchProps): void {
|
|
329
|
+
const [models, setModels] = createSignal<readonly Model[]>(props.models);
|
|
330
|
+
if (props.modelsEndpoint !== undefined) {
|
|
331
|
+
fetch(props.modelsEndpoint, { credentials: 'same-origin' })
|
|
332
|
+
.then((response) => response.json())
|
|
333
|
+
.then((rows: readonly Model[]) => setModels(rows));
|
|
334
|
+
}
|
|
335
|
+
render(() => <Picker models={models()} />, el);
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
`models: []` keeps the island's first frame honest (an empty picker, not a missing one) and
|
|
340
|
+
`derivePath` is the same derivation the typed `client()` uses, so the URL cannot drift from the
|
|
341
|
+
route. The `fix:` line of an over-cap `X_ISLAND_PROPS_INVALID` names exactly this edit, with the
|
|
342
|
+
heaviest prop's own name in it.
|
|
343
|
+
|
|
344
|
+
At **verify** time the overflow is a finding, not a runtime surprise: an `app/` page with an
|
|
345
|
+
island derives a budget, the `budgets` step's build renders every budgeted route to weigh it, and
|
|
346
|
+
a render that throws `X_ISLAND_PROPS_INVALID` is reported under that code — the island, the prop,
|
|
347
|
+
its bytes — rather than as an `X_BUDGET_UNMEASURED` whose fix is to go and read the build's list.
|
|
348
|
+
A prop that only exists per request (the row behind `[id]`) is still measured at the route's
|
|
349
|
+
pattern with empty params, so a static over-cap prop is caught, and a per-request one is caught
|
|
350
|
+
by the request that carries it.
|
|
351
|
+
|
|
280
352
|
### It counts against the route's budget
|
|
281
353
|
|
|
282
354
|
```ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "19.
|
|
3
|
+
"version": "19.2.0",
|
|
4
4
|
"description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
"test": "bun test"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@ultimat3/cache": "19.
|
|
40
|
-
"@ultimat3/core": "19.
|
|
41
|
-
"@ultimat3/i18n": "19.
|
|
42
|
-
"@ultimat3/seo": "19.
|
|
39
|
+
"@ultimat3/cache": "19.2.0",
|
|
40
|
+
"@ultimat3/core": "19.2.0",
|
|
41
|
+
"@ultimat3/i18n": "19.2.0",
|
|
42
|
+
"@ultimat3/seo": "19.2.0",
|
|
43
43
|
"sass": "1.102.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/island-props.ts
CHANGED
|
@@ -20,8 +20,30 @@ export type IslandProps = Readonly<Record<string, JsonValue>>;
|
|
|
20
20
|
/**
|
|
21
21
|
* Props ship inside the HTML of every response, so they are page weight the `budget` never sees
|
|
22
22
|
* as JS. A cap turns "I passed the whole row" into a number and a fix instead of a slow page.
|
|
23
|
+
*
|
|
24
|
+
* What the cap protects. `hydrateRuntime` inlines the bag VERBATIM as
|
|
25
|
+
* `<script type="application/json" data-x-props>` in the document, and `measureDocumentJs` counts
|
|
26
|
+
* a JSON-typed script as data — zero JS — so `budget.js` never sees a byte of it. This constant is
|
|
27
|
+
* the only ceiling on that channel. Every byte of it is parsed before first paint, on every
|
|
28
|
+
* request, on every page that renders the island, and cannot be cached apart from the page.
|
|
29
|
+
*
|
|
30
|
+
* Why 16 KiB and not 4. It was 4096 until 2026-09-05, and a page carrying a 34-row catalog
|
|
31
|
+
* (8,812 B) answered 500 at RUNTIME — a legitimate medium-sized bag, a page down. What 16 KiB
|
|
32
|
+
* costs, measured against the budgets the route table derives (`DEFAULT_ISLAND_JS_BYTES`:
|
|
33
|
+
* `site/` 20 KiB, `app/` 34 KiB of JS): the ceiling is a bag comparable in size to the island's
|
|
34
|
+
* OWN code, and never more than it. On the wire, JSON with repeated keys gzips 4-6x, so a full
|
|
35
|
+
* bag is ~3-4 KiB compressed — under 100 ms on a 3G-class link (~50 KB/s), ~1 ms of `JSON.parse`
|
|
36
|
+
* on a phone. Uncompressed (a dev server, a proxy that skips `text/html`) it is ~320 ms on that
|
|
37
|
+
* link, which is why the ceiling stays a ceiling and not a warning.
|
|
38
|
+
*
|
|
39
|
+
* Why a catalog is still the wrong thing to inline, even under the cap. A list that is the same
|
|
40
|
+
* on every request is a DATASET, not a prop: behind a query route it is one `GET`, cached by the
|
|
41
|
+
* browser and by the CDN, fetched once per session instead of rendered into every page. The prop
|
|
42
|
+
* is the id, the initial count, the endpoint — what the island needs to draw its first frame.
|
|
43
|
+
* `README.md` ("large data rides over a query endpoint") is the pattern, and the `fix:` below
|
|
44
|
+
* names it.
|
|
23
45
|
*/
|
|
24
|
-
export const ISLAND_PROPS_MAX_BYTES =
|
|
46
|
+
export const ISLAND_PROPS_MAX_BYTES = 16_384;
|
|
25
47
|
|
|
26
48
|
/** JSX keys that are markup, not data: they stay on the server and never serialize. */
|
|
27
49
|
const SERVER_ONLY_KEYS = new Set(['children']);
|
|
@@ -142,15 +164,39 @@ export function checkIslandProps(
|
|
|
142
164
|
put(bag, key, assertJsonSafe(props[key], `props.${key}`, seen, file));
|
|
143
165
|
}
|
|
144
166
|
|
|
145
|
-
const bytes =
|
|
167
|
+
const bytes = utf8Bytes(JSON.stringify(bag));
|
|
146
168
|
if (bytes > ISLAND_PROPS_MAX_BYTES) {
|
|
169
|
+
const heaviest = propBytes(bag).slice(0, HEAVIEST_NAMED);
|
|
170
|
+
const first = heaviest[0]?.[0] ?? 'rows';
|
|
147
171
|
throw new IslandPropsInvalidError(
|
|
148
|
-
`the ${moduleId} island in ${file} carries ${bytes}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
172
|
+
`the ${moduleId} island in ${file} carries ${bytes} B of props (cap ${ISLAND_PROPS_MAX_BYTES} B), ` +
|
|
173
|
+
'and every one of them ships inside the HTML on every request — ' +
|
|
174
|
+
heaviest.map(([key, size]) => `props.${key} is ${size} B of the ${bytes}`).join(', '),
|
|
175
|
+
`in ${file}, pass ${heaviest.map(([key]) => `\`${key}: []\``).join(' and ')} beside ` +
|
|
176
|
+
`\`${first}Endpoint: derivePath('<queryName>')\` (@ultimat3/query) and fetch the rows inside ` +
|
|
177
|
+
'the island after mount — a list that is the same on every request is a dataset, not a ' +
|
|
178
|
+
'prop; an id, a count and a URL are',
|
|
152
179
|
);
|
|
153
180
|
}
|
|
154
181
|
|
|
155
182
|
return bag;
|
|
156
183
|
}
|
|
184
|
+
|
|
185
|
+
const utf8Bytes = (text: string): number => new TextEncoder().encode(text).byteLength;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Which keys carry the weight, heaviest first — so the finding names the prop to move, not the
|
|
189
|
+
* bag. A page with `models` at 8,812 B beside `hostId` at 12 B gets one instruction, and the
|
|
190
|
+
* instruction names `models`. Per-key bytes are the value's serialisation plus its `"key":`.
|
|
191
|
+
*/
|
|
192
|
+
function propBytes(bag: Record<string, JsonValue>): readonly (readonly [string, number])[] {
|
|
193
|
+
return Object.entries(bag)
|
|
194
|
+
.map(([key, value]): readonly [string, number] => [
|
|
195
|
+
key,
|
|
196
|
+
utf8Bytes(JSON.stringify(key)) + 1 + utf8Bytes(JSON.stringify(value)),
|
|
197
|
+
])
|
|
198
|
+
.sort((a, b) => b[1] - a[1]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** At most two: one prop is the usual answer, two names a split, and a third is the whole bag. */
|
|
202
|
+
const HEAVIEST_NAMED = 2;
|
package/src/module-loader.ts
CHANGED
|
@@ -80,12 +80,30 @@ export interface Stylesheet {
|
|
|
80
80
|
|
|
81
81
|
const stylesheets = new Map<string, Stylesheet>();
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Bumped whenever the registry's CONTENT changes — a sheet arriving, a sheet's rules changing, the
|
|
85
|
+
* whole map being cleared. It exists so a caller can cache something derived from `stylesFor` (the
|
|
86
|
+
* CLI content-addresses the surface stylesheet and serves it as a file) without re-deriving it per
|
|
87
|
+
* request, and without a cache that goes stale the moment `x dev` rebuilds an island: island CSS
|
|
88
|
+
* registers through this same `loadStylesheet`, on every `Bun.build`.
|
|
89
|
+
*
|
|
90
|
+
* A re-registration with IDENTICAL css does not bump it. `buildIslands` re-runs its plugins on
|
|
91
|
+
* every watcher tick, so counting registrations rather than changes would mint a new stylesheet
|
|
92
|
+
* URL on every save of an unrelated file — the immutable-cache miss this counter exists to avoid.
|
|
93
|
+
*/
|
|
94
|
+
let revision = 0;
|
|
95
|
+
|
|
96
|
+
export function stylesheetsRevision(): number {
|
|
97
|
+
return revision;
|
|
98
|
+
}
|
|
99
|
+
|
|
83
100
|
export function registeredStylesheets(): readonly Stylesheet[] {
|
|
84
101
|
return [...stylesheets.values()];
|
|
85
102
|
}
|
|
86
103
|
|
|
87
104
|
/** Test seam: the registry is process-global because the module cache it mirrors is too. */
|
|
88
105
|
export function clearStylesheets(): void {
|
|
106
|
+
if (stylesheets.size > 0) revision += 1;
|
|
89
107
|
stylesheets.clear();
|
|
90
108
|
}
|
|
91
109
|
|
|
@@ -133,6 +151,7 @@ export function transformTsx(source: string): string {
|
|
|
133
151
|
export function loadStylesheet(path: string, source: string): string {
|
|
134
152
|
const compiled = compileStylesheet(path, source);
|
|
135
153
|
if (compiled.css.length > 0) {
|
|
154
|
+
if (stylesheets.get(path)?.css !== compiled.css) revision += 1;
|
|
136
155
|
stylesheets.set(path, {
|
|
137
156
|
file: path,
|
|
138
157
|
surface: surfaceOf(path),
|
package/src/server.ts
CHANGED