composablesDir modules are injected into every built page whole, regardless of whether the page references a single export. There is no per-page pruning, so the cost of adding a composable is paid by every route in the app — including ones that cannot reach it.
Reproduction
App with composablesDir: 'resources/functions' (config/ui.ts) holding 9 modules / 21 exports.
resources/views/blog.stx references zero composables:
$ rg -c 'fetchProviders|providerLabel|publishCrosspost|fetchBlueskyStatus|renderAnalyticsChart|increment|toggleDark' resources/views/blog.stx
→ 0 referencesThe built page registers all 21 anyway:
$ grep -o '__c\["[a-zA-Z_]*"\]' dist/blog.html | sort -u
__c["count"] __c["decrement"] __c["fetchBlueskyStatus"] __c["fetchInstagramStatus"]
__c["fetchLinkedInStatus"] __c["fetchProviders"] __c["fetchThreadsStatus"]
__c["formatBlueskyConnectionLabel"] __c["formatInstagramConnectionLabel"]
__c["formatLinkedInConnectionLabel"] __c["formatThreadsConnectionLabel"]
__c["increment"] __c["isDark"] __c["preferredDark"] __c["providerLabel"]
__c["publishCrosspost"] __c["renderAnalyticsChart"] __c["reset"]
__c["SESSION_MAX_AGE"] __c["toggleDark"] __c["useSessionToken"]Not just the names — the bodies. dist/blog.html contains PROVIDER_LABELS, the string /api/postline/publish, and the chart's drawAnalyticsChart, on a page that renders a blog index.
The same holds on marketing pages using a different layout: dist/login.html and dist/index.html both carry increment and toggleDark.
Measured cost
Moving 6 small browser modules (~8.5KB bundled, previously loaded as <script type="module">) into composablesDir, changing nothing else:
| page | references any of the 6? | raw HTML | gzipped |
|---|---|---|---|
| blog.html | no | +13,264 | +3,238 |
| timeline.html | no | +13,264 | +3,285 |
| listening.html | no | +13,264 | +3,290 |
| settings.html | 1 of 21 | +13,254 | +3,241 |
| composer.html | yes | +12,757 | +3,157 |
| site total (9 app pages) | +118,419 | +29,264 |
Marketing pages that don't use the app layout were unaffected (+0), so this is the composable injection specifically, not a build-wide change.
The comparison isn't quite fair to the old shape either: those 8.5KB were one cacheable bundle shared across all 9 pages. Inlined, the bytes are re-sent with every page — and stx-router fetches full page HTML on navigation, so it's re-sent on every in-app navigation too.
Why this looks fixable
The build already emits a per-page registration list (__c["name"] = name) — it knows the set of names it is publishing. What it doesn't do is intersect that set with the identifiers the page's script blocks and template expressions actually reference. The template expression scanner already exists; stx typecheck reports "613 template expressions in 22 files" on this app, so reference extraction is present in the toolchain.
A conservative version would still be a big win: prune a composable when no script block or template expression in the page mentions its exported name. Dynamic access (globalThis[name]) would defeat it, so an opt-out (composables: { prune: false }, or a per-module /* @stx-keep */) covers the escape hatch.
Impact
The current behaviour makes composablesDir quietly expensive to adopt. The natural refactor — move shared browser logic out of hand-rolled window.* bridges and into composables, which is exactly what #1780 asks for — is a payload regression on every page that doesn't use the code. Right now the incentive is to keep composables small and few, which is backwards.
Environment
- stx from
node_modules/@stacksjs/stx, SSG build (ssr: false), 24 routes composablesDir: 'resources/functions'- Measured on a clean
rm -rf dist && buddy buildfor both arms