also looking at this
fix(stx): resolve project components from nested views + apply defineProps defaults in SSR
#1729
2 files
+155
-0
| @@ -709,6 +709,11 @@ export interface StxConfig { | ||
| 709 | 709 | enabled?: boolean |
| 710 | 710 | container?: string |
| 711 | 711 | 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 | |
| 712 | 717 | viewTransitions?: boolean |
| 713 | 718 | /** |
| 714 | 719 | * Duration of the default View Transitions fade, in milliseconds. |
| @@ -727,6 +732,11 @@ export interface StxConfig { | ||
| 727 | 732 | cacheTTL?: number |
| 728 | 733 | skipSelectors?: string |
| 729 | 734 | viewTransitionCSS?: Record<string, string> |
| 735 | /** | |
| 736 | * Show the top loading/progress bar during client-side navigation. | |
| 737 | * Default: true. | |
| 738 | */ | |
| 739 | progress?: boolean | |
| 730 | 740 | /** |
| 731 | 741 | * Progress-bar color (hex, rgb, css var, etc.). Default: '#78dce8'. |
| 732 | 742 | */ |
| @@ -305,6 +305,134 @@ function extractVariableNames(code: string): string[] { | ||
| 305 | 305 | /** |
| 306 | 306 | * Shared function to render a component with props and slot content |
| 307 | 307 | */ |
| 308 | /** | |
| 309 | * Split a destructuring body on top-level commas, respecting nested | |
| 310 | * brackets/braces/parens and string/template literals. | |
| 311 | */ | |
| 312 | function 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 | */ | |
| 338 | function 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 | */ | |
| 374 | function 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 | ||
| 308 | 436 | export async function renderComponentWithSlot( |
| 309 | 437 | componentPath: string, |
| 310 | 438 | props: Record<string, unknown>, |
| @@ -421,6 +549,17 @@ export async function renderComponentWithSlot( | ||
| 421 | 549 | // componentsDir but actual components live at the project-level src/components |
| 422 | 550 | const projectRoot = process.cwd() |
| 423 | 551 | 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'), | |
| 424 | 563 | path.resolve(projectRoot, 'src/components'), |
| 425 | 564 | path.resolve(projectRoot, 'components'), |
| 426 | 565 | // Built-in STX components (e.g., StxLink) |
| @@ -567,6 +706,12 @@ export async function renderComponentWithSlot( | ||
| 567 | 706 | __sections: {}, |
| 568 | 707 | } |
| 569 | 708 | |
| 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 | ||
| 570 | 715 | // SFC Support: Extract <template>, <script>, and <style> sections |
| 571 | 716 | let workingContent = componentContent |
| 572 | 717 | |