ReviewOS

stacks/stx

fix(view-composers): `composer()` / `composerPattern()` are never exported, so the loader layer runs on every render against an empty registry

#1860
Closed glennmichael123 opened this 14 days ago · 1 comment
14 days ago

Summary

stx has a Laravel-style view-composer layer, and it is wired into the render path: runComposers(filePath, context) is called for every template. But the two functions that register a composer — composer() and composerPattern() — are not exported from the package entry. packages/stx/src/index.ts has no export * from './view-composers', and neither name appears in dist/index.js or dist/index.d.ts. There is no way for an application to put anything into the registry, so runComposers iterates an empty map on every render.

The practical effect is that stx has no usable loader/controller layer, and data access has nowhere to live except inside the view.

Impact (real app)

analyticshq's dashboard is the whole data layer inside a template file:

  • resources/views/dashboard.stx:220-504 — loadData(): a dynamic import('@stacksjs/database'), a hand-written ? → $n Postgres placeholder rewriter (:228), server-side auth from a cookie (:238-245), and ~25 raw SQL queries.
  • resources/views/dashboard.stx:545-549 — the timeout, raced by hand, because there is no framework-owned boundary:
    await Promise.race([
      loadData(),
      new Promise((_, reject) => setTimeout(() => reject(new Error('db timeout')), 5000)),
    ])
    with catch { live = false } at :550-553 as the only error boundary. One slow query out of 25 drops the entire dashboard to an empty state, because there is no per-section streaming boundary.
  • resources/views/dashboard.stx:505-544 — a 34-line injection protocol we invented for a host that does not exist:
    // A host server may pre-fetch the data (e.g. when rendering in a context that
    // can't reach the DB module) and inject it as `preloaded`; otherwise the view
    // queries PostgreSQL itself.
    if (typeof preloaded !== 'undefined' && preloaded) {
      kpis = preloaded.kpis ?? kpis
      series = preloaded.series ?? []
      …  // repeated for 30 fields
    }
    grep -rn 'preloaded' resources/ app/ routes/ scripts/ finds it only inside dashboard.stx. There is no producer, because there is no supported way to write one.

And none of the 500 lines is checked — types/dashboard.d.ts:9-13:

tsc cannot see inside .stx, so these annotate the page for a human and an editor and are verified by nothing.

composer('dashboard', ctx => { ctx.preloaded = … }) is exactly the missing producer. It cannot be written.

Evidence in stx

The layer exists — packages/stx/src/view-composers.ts:

export function composer(view: string, callback: ViewComposerCallback): void          // :126
export function composerPattern(pattern: string | RegExp, callback: ViewComposerCallback): void  // :160
export async function runComposers(filePath: string, context: Record<string, any>): Promise<void> // :181

It runs — packages/stx/src/process.ts:50, :1204-1212, :722:

import { runComposers } from './view-composers'                       // :50
…
async function runViewComposersForTemplate(filePath, context) {       // :1204
  await safeExecuteAsync(() => runComposers(filePath, context), …)    // :1206
}
…
await runViewComposersForTemplate(filePath, context)                  // :722

It cannot be registered:

$ grep -n "view-composers" packages/stx/src/index.ts
(no match)

$ grep -rn "view-composers" packages/stx/src/*.ts
packages/stx/src/process.ts:50:import { runComposers } from './view-composers'

$ grep -o "composer[A-Za-z]*" packages/stx/dist/index.js | sort -u
(empty)

$ grep -o "composer[A-Za-z]*" packages/stx/dist/index.d.ts | sort -u
(empty)

So runComposers executes on every template render against a registry nothing can populate. This is the same shape as #1823 (stx-plugin registers only ./src/ui, so four components were exported but unrenderable) — capability present, entry point missing.

Prior art

  • Laravel: View::composer('dashboard', DashboardComposer::class) — which is precisely what view-composers.ts models.
  • Remix: loader() returning typed data to the route component.
  • Next.js: getServerSideProps / RSC.
  • Nuxt: defineEventHandler + useAsyncData with <Suspense> boundaries.
  • Rails: controller actions.

Ask

  1. Add export * from './view-composers' to packages/stx/src/index.ts so composer / composerPattern / clearComposers ship. This alone makes the loader layer usable.
  2. Document the composer as the supported place for route data, with an example that moves a <script server> data block into one.
  3. Give the composer a typed contract — let it declare the shape it contributes to the context so a view has something checkable, since tsc cannot see inside .stx.
  4. Add a framework-owned timeout/fallback per composer, and a per-section streaming boundary, so one slow query degrades one panel instead of the whole page (<Suspense> already exists in the runtime — signals.ts:1199-1206).

Related: #1823 (exported-but-unregistered components), #1746 (streaming SSR + islands).

14 days ago

Claiming this one — working on it now.

Sign in to comment on this issue.