ReviewOS

also looking at this

stacks/stx

fix(stx): resolve project components from nested views + apply defineProps defaults in SSR

#1729
Merged chris wants to merge fix/ssr-component-resolution-and-defineprops-defaults into main
2 files +155 -0
packages/stx/src/types/config-types.tsmodified+10-0
Changes to packages/stx/src/types/config-types.ts
@@ -709,6 +709,11 @@ export interface StxConfig {
709709 enabled?: boolean
710710 container?: string
711711 linkSelector?: string
712 /**
713 * Intercept every in-app `<a>` for client-side navigation rather than
714 * only those matching `linkSelector`. Default: false.
715 */
716 interceptAllLinks?: boolean
712717 viewTransitions?: boolean
713718 /**
714719 * Duration of the default View Transitions fade, in milliseconds.
@@ -727,6 +732,11 @@ export interface StxConfig {
727732 cacheTTL?: number
728733 skipSelectors?: string
729734 viewTransitionCSS?: Record<string, string>
735 /**
736 * Show the top loading/progress bar during client-side navigation.
737 * Default: true.
738 */
739 progress?: boolean
730740 /**
731741 * Progress-bar color (hex, rgb, css var, etc.). Default: '#78dce8'.
732742 */
packages/stx/src/utils.tsmodified+145-0
Changes to packages/stx/src/utils.ts
@@ -305,6 +305,134 @@ function extractVariableNames(code: string): string[] {
305305/**
306306 * Shared function to render a component with props and slot content
307307 */
308/**
309 * Split a destructuring body on top-level commas, respecting nested
310 * brackets/braces/parens and string/template literals.
311 */
312function splitTopLevelCommas(body: string): string[] {
313 const parts: string[] = []
314 let depth = 0
315 let start = 0
316 let quote: string | null = null
317 for (let i = 0; i < body.length; i++) {
318 const c = body[i]
319 if (quote) {
320 if (c === '\\') { i++; continue }
321 if (c === quote) quote = null
322 continue
323 }
324 if (c === '"' || c === '\'' || c === '`') { quote = c; continue }
325 if (c === '(' || c === '[' || c === '{') depth++
326 else if (c === ')' || c === ']' || c === '}') depth--
327 else if (c === ',' && depth === 0) { parts.push(body.slice(start, i)); start = i + 1 }
328 }
329 if (start < body.length) parts.push(body.slice(start))
330 return parts
331}
332
333/**
334 * Index of the top-level `=` (default assignment) in a destructuring entry,
335 * skipping `==`/`===`/`!=`/`<=`/`>=`/`=>` and anything inside brackets or
336 * strings. Returns -1 when the entry has no default.
337 */
338function topLevelDefaultEq(entry: string): number {
339 let depth = 0
340 let quote: string | null = null
341 for (let i = 0; i < entry.length; i++) {
342 const c = entry[i]
343 if (quote) {
344 if (c === '\\') { i++; continue }
345 if (c === quote) quote = null
346 continue
347 }
348 if (c === '"' || c === '\'' || c === '`') { quote = c; continue }
349 if (c === '(' || c === '[' || c === '{') depth++
350 else if (c === ')' || c === ']' || c === '}') depth--
351 else if (c === '=' && depth === 0) {
352 const prev = entry[i - 1]
353 const next = entry[i + 1]
354 if (prev === '!' || prev === '<' || prev === '>' || prev === '=') continue
355 if (next === '=' || next === '>') continue
356 return i
357 }
358 }
359 return -1
360}
361
362/**
363 * Apply a component's `defineProps` destructuring defaults to its SSR render
364 * context. STX evaluates a component's `<script>` block client-side only, so
365 * default values written as `const { steps = [...] } = defineProps()` never
366 * reached server-side template evaluation: a prop the caller omits was
367 * `undefined` during SSR, which broke `@foreach (steps as ...)` ("steps is not
368 * iterable") and any `function prop(options) { return options; }` / `:prop` relying on a default. This fills in
369 * only the keys the caller did not provide, evaluating each default against the
370 * already-resolved context (so a default may reference earlier props). It is
371 * best-effort — any parse/eval failure leaves the context untouched, matching
372 * the pre-fix behavior (e.g. defaults that reference browser-only globals).
373 */
374function applyDestructuredPropDefaults(
375 componentContent: string,
376 ctx: Record<string, unknown>,
377): void {
378 try {
379 const m = /=\s*(?:withDefaults\s*\([\s\S]*?defineProps|defineProps)\b/.exec(componentContent)
380 if (!m)
381 return
382 // Walk left from the `=` to the destructuring `}` … `{` that precedes it.
383 let i = m.index - 1
384 while (i >= 0 && /\s/.test(componentContent[i])) i--
385 if (componentContent[i] !== '}')
386 return
387 const end = i
388 let depth = 0
389 let start = -1
390 for (; i >= 0; i--) {
391 const c = componentContent[i]
392 if (c === '}')
393 depth++
394 else if (c === '{') {
395 depth--
396 if (depth === 0) { start = i; break }
397 }
398 }
399 if (start === -1)
400 return
401
402 const body = componentContent.slice(start + 1, end)
403 for (const rawEntry of splitTopLevelCommas(body)) {
404 const entry = rawEntry.trim()
405 if (!entry || entry.startsWith('...'))
406 continue
407 const eq = topLevelDefaultEq(entry)
408 if (eq === -1)
409 continue
410 const name = entry.slice(0, eq).trim()
411 // Only simple `name = default` entries; skip renames / nested patterns.
412 if (!/^[A-Z_$][\w$]*$/i.test(name))
413 continue
414 if (ctx[name] !== undefined)
415 continue // caller-provided value always wins
416 const expr = entry.slice(eq + 1).trim()
417 if (!expr)
418 continue
419 try {
420 const keys = Object.keys(ctx)
421 // eslint-disable-next-line no-new-func
422 const fn = new Function(...keys, `"use strict"; return (${expr});`)
423 ctx[name] = fn(...keys.map(k => ctx[k]))
424 }
425 catch {
426 // Default references browser-only globals or unavailable vars — leave
427 // it undefined, exactly as before this fix.
428 }
429 }
430 }
431 catch {
432 // Never let prop-default extraction break component rendering.
433 }
434}
435
308436export async function renderComponentWithSlot(
309437 componentPath: string,
310438 props: Record<string, unknown>,
@@ -421,6 +549,17 @@ export async function renderComponentWithSlot(
421549 // componentsDir but actual components live at the project-level src/components
422550 const projectRoot = process.cwd()
423551 const fallbackDirs = [
552 // Canonical Stacks project components dir. Without this, a tag
553 // referenced from a *nested* view (e.g. resources/views/products/
554 // index.stx) only searched its own `<viewDir>/components` and the
555 // global componentsDir (the framework defaults), so project-level
556 // components in `resources/views/components` resolved for root
557 // views but 404'd for any view in a subdirectory. Adding it as a
558 // project-wide fallback makes project components resolve from any
559 // view depth — matching how the framework defaults already do.
560 path.resolve(projectRoot, 'resources/views/components'),
561 // Legacy scaffold location (pre-`resources/views/` move).
562 path.resolve(projectRoot, 'resources/components'),
424563 path.resolve(projectRoot, 'src/components'),
425564 path.resolve(projectRoot, 'components'),
426565 // Built-in STX components (e.g., StxLink)
@@ -567,6 +706,12 @@ export async function renderComponentWithSlot(
567706 __sections: {},
568707 }
569708
709 // Fill in `defineProps` destructuring defaults for any prop the caller
710 // omitted, so server-side template evaluation sees them (the component's
711 // `<script>` runs client-side only). Must run after `...props` above so
712 // caller-provided values are never overwritten.
713 applyDestructuredPropDefaults(componentContent, componentContext)
714
570715 // SFC Support: Extract <template>, <script>, and <style> sections
571716 let workingContent = componentContent
572717