ReviewOS

stacks/stx

composablesDir modules are injected into every page whole — no per-page pruning (+3.2KB gzipped on pages referencing none of them)

#1936
Closed glennmichael123 opened this 10 days ago ·
10 days ago

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 references

The 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:

pagereferences any of the 6?raw HTMLgzipped
blog.htmlno+13,264+3,238
timeline.htmlno+13,264+3,285
listening.htmlno+13,264+3,290
settings.html1 of 21+13,254+3,241
composer.htmlyes+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 build for both arms
10 days ago

Fixed in 2b1f0d5. A page now ships only the composables it can reach.

Selection is by mention against the rendered page, then transitively through the composables own source. The second half is easy to leave out and it is the one that matters: a composable called only by another composable appears nowhere in the markup, and dropping it leaves a ReferenceError at the moment a user clicks the thing that needs it. Under-inclusion breaks a page silently, over-inclusion only wastes bytes — so the mention test is deliberately loose in that direction.

A composable reached only from a store is covered without a second mechanism: composables are injected after the stores tag, so the store bundle is already part of the page when selection runs.

The one limitation, stated plainly: a name assembled at runtime cannot be seen and will be pruned. Such a name could never have resolved as a bare global either, so nothing that worked is taken away — but if you have that pattern, use the explicit import from @composables and it will be kept.

The selected subset is part of the cache key. Without that, the first page to render would decide what every later page ships — the same bug wearing a different hat.

One note on the tests, since it bears on how much to trust them: my first version of the transitive test asserted the bundle contained the callees NAME, which appears inside its callers body whether or not the callee was included — so it passed against a deliberately broken implementation. It now asserts on a sentinel that exists only in the callee, and the transitive walk fails under sabotage as it should.

Sign in to comment on this issue.