fix(ui): stop registry modules doing work at module scope - #1360
Conversation
The GROUPS table spread `...borderGroups()` at module scope, which is a real top-level call, so the elision analyser read lib/utils.ts as client-effecting. Every page or layout reaching `cn` on a component-free path then shipped whole instead of being elided, and `cn` is reached by essentially every kit helper, so this cost page elision in every app that runs `webjsui init`. Memoising the table behind a function keeps the same literal and the same output (12,100 pairs and 242,000 triples over the sync test's 110-token battery, zero mismatches) while leaving nothing to run at module load. The blog copy carries the same change: it is the second of the two hand-synced sources, and examples/blog/app/ui-demo/page.ts shipped whole for this reason.
native-select.ts injected its <option> / <optgroup> colour rule from module scope, guarded by a `typeof document` check. That made the module both a top-level call and a browser-global reference, so the elision analyser pinned every page rendering a <select>, and it was a progressive-enhancement regression on its own terms: the rule exists to stop dark-mode options going invisible, and injecting it from JavaScript leaves the bug visible with JavaScript off and for one frame before hydration. The CSS is four static, selector-only lines, so it belongs in the stylesheet the kit already manages. It goes in the theme block's `@layer base`, outside the `:root` and `.dark` blocks `mergeThemeCss` rewrites, so all seven base colours carry it with no per-colour edit, and being layered makes it strictly more overridable than the unlayered injected <style> was. `installNativeSelectStyles` is removed rather than left as a no-op. It had no callers, and the kit is copy-on-add, so an existing app keeps its own copy and a re-adder takes the new file whole. An app that already ran `init` does NOT get the rule: `ensureTheme` returns early on its marker, so no command rewrites an existing theme block. Its <option> colours revert to the browser default until it re-runs `init` or adds the rule by hand. That gap is why checkbox and radio-group keep their injections, since theirs are the only source of the checkmark and the dot. The marketing site gets the rule by hand in its own input.css, beside the other kit rule it reproduces, because it never runs `webjs ui init`. `hasModuleScopeSideEffect` is exported so the purity guard calls the real predicate rather than re-implementing its depth-0 scan, which would drift the moment the analyser changes.
|
Design rationale: why the option CSS moved to the theme block, and what that costs an existing app The obvious smaller fix for Putting it in
What this genuinely costs: an app that already ran On |
The skill reference promised that importing any Tier-1 helper never pins a page, which the PR's own pinned-set test contradicts: checkbox, radio-group, pagination and progress are Tier-1 and all six flagged modules still ship their importing page. Name them instead, and say the analyser-precision four cost the same elision as the two real injections. The AGENTS.md line calling checkbox and radio-group the last JavaScript-injected stylesheets was wrong too: dialog and alert-dialog inject one from a lifecycle hook. They are Tier-2 and need JavaScript regardless, which is the distinction the sentence was reaching for.
vivek7405
left a comment
There was a problem hiding this comment.
The mechanical change is right and I checked it rather than trusting it: both files come back clean from the framework's own predicate, the pinned set matches what the analyser actually reports, the memoised table gives byte-identical output over the sync test's whole token battery, and the theme rule survives all seven base-colour merges inside @layer base. Moving the CSS into the theme block instead of keeping a lazy injection is the right call, since nativeSelectClass() runs at SSR where there is no document, so lazy could never have installed anything.
Where it falls down is the docs, which is the part of this change an agent actually reads before writing a page. Two claims in there are just false, and the PR's own test proves one of them false. Comments inline.
Closes #1320
Two
@webjsdev/uiregistry modules ran work at module scope, which the elision analyser correctly reads as client work, so every page or layout reaching them on a component-free path shipped whole instead of being elided.cnsits under essentially every kit helper and every scaffolded app runswebjsui init, so this silently cost page elision in every app using the kit.What changed
lib/utils.ts: theGROUPSconflict table spread...borderGroups()at module scope, a real top-level call. The table is now built on first use behindlet _groups; function GROUPS() { return (_groups ??= [...]) }. The literal is untouched, only re-indented into the function body.native-select.ts: it injected its<option>/<optgroup>colour rule from module scope behind atypeof documentguard, which made the module both a top-level call and a browser-global reference. The rule moved into the theme block's@layer base, andinstallNativeSelectStylesis removed. That also fixes a progressive-enhancement regression on its own terms: the rule exists to stop dark-mode options going invisible, and injecting it from JavaScript left the bug visible with JS off and for one frame before hydration.examples/blog/lib/utils/cn.tscarries the same memoisation. It is the second of the two hand-synced sources, andapp/ui-demo/page.tsshipped whole for exactly this reason.website/public/input.cssgets the<option>rule by hand, beside the other kit rule the site already reproduces, because it writes its own stylesheet instead of runningwebjs ui init.packages/server/src/component-elision.js:hasModuleScopeSideEffectis exported. One word, no behaviour change, so the purity guard calls the real predicate instead of re-implementing its depth-0 scan.Consequences worth knowing
An app that already ran
initdoes NOT receive the<option>rule.ensureThemekeys the whole block on its marker and returns early, andinit --overwrite's flag reaches onlywriteLibUtils, so no command rewrites an existing theme block. Such an app keeps the browser-default<option>colours until it re-runsinitor adds the rule by hand. Documented inpackages/ui/AGENTS.mdand the skill reference.That asymmetry is why
checkbox.tsandradio-group.tskeep their injections. Their CSS is the ONLY source of the checkmark and the radio dot, so moving it would silently break the checked state in every already-initialised app (a WCAG 1.4.1 failure, not a degraded read). They are pinned in the purity test's allowlist with the reason written down.Test plan
packages/ui/test/utils-purity.test.jsgains a pinned-set EQUALITY over every.tsunderregistry/libandregistry/components, calling the framework's own predicate. Equality, not subset, so a new offender fails immediately and an entry can only be removed deliberately. Plus two named assertions thatutils.tsandnative-select.tsare clean and that the injector is gone.packages/ui/test/cn-helper.test.jsgains a table of the order-dependent conflict cases, since the table is now built on first call.packages/ui/test/base-colors.test.jsasserts the rule ships in all seven base colours and sits INSIDE@layer base(sliced to the block's closing brace, since placement is what a plain substring match cannot see).packages/ui/test/components/browser/ui-native-select.test.js. Importing the module injects no<style>, and the rule paints options in light and dark, wrapped and bare. Bare is the case the original wrapper-scoped selector missed.test/scaffolds/scaffold-ui-integration.test.jsscaffolds an app,addsbutton card input native-select, writes a page using all four, and asserts on the elision REPORT (never on byte size, which moves with unrelated kit changes).test/ui/cn-copies-in-sync.test.mjspasses unchanged, which is the guard that the blog mirror was done right.cnbefore and after were loaded side by side and compared over the sync test's 110-token battery. 12,100 pairs and 242,000 triples, zero mismatches.Counterfactuals and the dogfood results are posted on the PR.
Docs
packages/ui/AGENTS.md: the no-module-scope-work rule, the copy-on-add caveat including the theme-block gap, thenative-selectinventory row, and the a11y form-controls bullets..agents/skills/webjs/references/ui-kit.md: both facts an agent needs, under Idioms.website/public/input.csscarries a comment beside the rule saying why the site needs its own copy.website/app/docs/**documents framework surfaces, not kit internals, and no page mentionsinstallNativeSelectStylesor the<option>rule.README.md, no headline capability changed.packages/cli/templates/, which has noreferences/directory and carriesthemes/index.cssverbatim throughcreate.js.Layers that do not apply
packages/*/srcedit adds theexportkeyword to a static source-scanning predicate, which matches none of the patternsrequire-bun-parity-with-runtime-src.shgates on, and the registry.tsfiles are copied text rather than framework runtime.native-select.tsnow contains nodocumenttoken at all (asserted), so the rule cannot be JavaScript-dependent.examples/blogrenders no<select>, so the only blog-visible change is the elision verdict, covered at the right layer.Merge order
This must merge BEFORE #1338, which edits the same
packages/ui/packages/registry/lib/utils.tsandexamples/blog/lib/utils/cn.ts. The regions do not overlap (this one is the table opener, closer, and the single read site; #1338 ishintedGroupandvariantPrefix), so they resolve cleanly in that order.