/** * Jinja2 template test renderer * * Compiles JSX source with `JinjaAdapter` and renders the resulting `.jinja` * templates to HTML via `python3` + `Jinja2` driven through the bundled * `barefootjs` Python runtime (`packages/adapter-jinja/python/barefootjs/`: * `runtime.BarefootJS` + `backend_jinja.JinjaBackend`). Used by the * adapter-tests conformance runner (`runAdapterConformanceTests`). * * Near-mechanical port of the sibling Xslate harness * (`packages/adapter-xslate/src/test-render.ts`) — same `RenderOptions` * contract, same prop / signal / memo seeding order, same multi-component * child-renderer registration via the production `register_child_renderer` * path (so a child's `bf-s` scope id derives from `_` * exactly as a real `bf build` page would). Only the target language of the * generated render script (Python, not Perl) and its literal syntax differ. */ import { extractSsrDefaults, deriveStashFromDefaults, importsSearchParams } from '@barefootjs/jsx' import { compileFixtureJSX } from '@barefootjs/adapter-tests/harness-program' import type { ComponentIR, SsrDefault } from '@barefootjs/jsx' import { mkdir, rm } from 'node:fs/promises' import { resolve } from 'node:path' const RENDER_TEMP_DIR = resolve(import.meta.dir, '../.render-temp') // The bundled Python runtime (`barefootjs.BarefootJS` + `barefootjs.backend_jinja.JinjaBackend`) // lives alongside this package (mirrors adapter-go-template bundling `runtime/` // in-tree). The render script's `sys.path` must include this directory so // `import barefootjs` resolves. const PYTHON_DIR = resolve(import.meta.dir, '../python') export class PythonNotAvailableError extends Error { constructor(message: string) { super(message) this.name = 'PythonNotAvailableError' } } /** * Recover the bare component name from a compiler-emitted template file * path. `templatesPerComponent` adapters write each component to * `/` (Jinja: `.jinja`), and * downstream pairing logic needs the raw component name back so it can * look up the matching IR in `irsByName`. * * Exported for testing. */ export function templateBaseName(path: string, extension: string): string { const filename = path.substring(path.lastIndexOf('/') + 1) return filename.endsWith(extension) ? filename.slice(0, -extension.length) : filename } let _pythonAvailable: boolean | null = null async function isJinjaAvailable(): Promise { if (_pythonAvailable !== null) return _pythonAvailable try { const proc = Bun.spawn(['python3', '-c', 'import jinja2'], { stdout: 'pipe', stderr: 'pipe', }) await proc.exited _pythonAvailable = proc.exitCode === 0 } catch { _pythonAvailable = false } return _pythonAvailable } export interface RenderOptions { /** JSX source code */ source: string /** Template adapter to use */ adapter: import('@barefootjs/jsx').TemplateAdapter /** Props to inject (optional) */ props?: Record /** Additional component files (filename → source) */ components?: Record /** * Explicit component to render when `source` declares multiple * exports (e.g. `ReactiveProps.tsx` → `PropsReactivityComparison`). * Mirrors the Hono reference's `componentName`; omitted for * single-export fixtures, which fall back to the default/first export. */ componentName?: string } export async function renderJinjaComponent(options: RenderOptions): Promise { const { source, adapter, props, components, componentName: requestedName } = options // Compile child components first. // // A child SOURCE FILE may export more components than the parent actually // references (e.g. `../icon` exports ~30 icons + a generic `Icon`, but // `Checkbox` only imports `CheckIcon`). Some of those unreferenced // components legitimately can't lower to Jinja — the generic `Icon` spreads // `{...props}` onto CHILD components (``), which has // no Jinja dict-splat form (Jinja dict literals can't splat a runtime dict // into named entries at a call site). Throwing on those would block a // fixture that never renders them. So defer the per-file error gate: collect // every component's template + IR up front, then (after the parent compile // pins the reachable set) re-generate ONLY the reachable children and throw // if any of THOSE error. Mirrors the Xslate harness's reachable-children // emission (#checkbox). const childTemplates: Map = new Map() if (components) { for (const [filename, childSource] of Object.entries(components)) { const childResult = compileFixtureJSX(childSource, filename, { adapter, outputIR: true }) const childTemplateFiles = childResult.files.filter(f => f.type === 'markedTemplate') if (childTemplateFiles.length === 0) throw new Error(`No marked template for ${filename}`) const childIrFiles = childResult.files.filter(f => f.type === 'ir') if (childIrFiles.length === 0) throw new Error(`No IR output for ${filename}`) const childIrs = childIrFiles.map(f => JSON.parse(f.content) as ComponentIR) if (childTemplateFiles.length === 1) { childTemplates.set(childIrs[0].metadata.componentName, { template: childTemplateFiles[0].content, ir: childIrs[0] }) } else { // Multi-component child source: pair template ↔ IR by basename. const childIrsByName = new Map(childIrs.map(i => [i.metadata.componentName, i])) for (const tf of childTemplateFiles) { const baseName = templateBaseName(tf.path, adapter.extension) const matchedIR = childIrsByName.get(baseName) ?? childIrs[0] childTemplates.set(matchedIR.metadata.componentName, { template: tf.content, ir: matchedIR }) } } } } // Compile parent source. `siblingTemplatesRegistered: Boolean(components)` // matches this harness's real behavior — every sibling child template is registered // alongside the parent before rendering, so a loop-body cross-template // call resolves at render time (#2205). const result = compileFixtureJSX(source, 'component.tsx', { adapter, outputIR: true, siblingTemplatesRegistered: Boolean(components), }) const errors = result.errors.filter(e => e.severity === 'error') if (errors.length > 0) { throw new Error(`Compilation errors:\n${errors.map(e => e.message).join('\n')}`) } const templateFiles = result.files.filter(f => f.type === 'markedTemplate') if (templateFiles.length === 0) throw new Error('No marked template in compile output') const irFiles = result.files.filter(f => f.type === 'ir') if (irFiles.length === 0) throw new Error('No IR output (set outputIR: true)') const irs = irFiles.map(f => JSON.parse(f.content) as ComponentIR) // Explicit `componentName` wins (multi-export sources pin the render // target); otherwise default-export, first inline-exported, first IR. // Mirrors the Hono reference so multi-component fixtures render the // same export across adapters. const ir = (requestedName ? irs.find(i => i.metadata.componentName === requestedName) : undefined) ?? irs.find(i => i.metadata.hasDefaultExport) ?? irs.find(i => i.metadata.isExported) ?? irs[0] let templateFile: { content: string } | undefined if (templateFiles.length === 1) { templateFile = templateFiles[0] } else { // Multi-component source: split the entry-point template from // siblings by pairing each template file to its IR by basename. const irsByName = new Map(irs.map(i => [i.metadata.componentName, i])) for (const tf of templateFiles) { const baseName = templateBaseName(tf.path, adapter.extension) const matchedIR = irsByName.get(baseName) if (matchedIR === ir) { templateFile = tf } else if (matchedIR) { childTemplates.set(matchedIR.metadata.componentName, { template: tf.content, ir: matchedIR }) } } } if (!templateFile) throw new Error('No marked template in compile output') // Reachable-children error gate (#checkbox). Now that the entry-point `ir` is // pinned, close transitively over its cross-file component imports and verify // each reachable child lowers without error — re-generating the child IR // through a fresh adapter to attribute errors per component (the aggregate // compile errors aren't component-tagged). A child file may export // unreferenced components that legitimately can't lower (e.g. `../icon`'s // generic `Icon`); those are dropped silently rather than failing a fixture // that never renders them. { const reachable = new Set() const queue = [...collectImportedComponentNames(ir)] while (queue.length > 0) { const name = queue.shift()! if (reachable.has(name)) continue const entry = childTemplates.get(name) if (!entry) continue // in-source sibling or non-compiled import reachable.add(name) queue.push(...collectImportedComponentNames(entry.ir)) } for (const name of reachable) { const entry = childTemplates.get(name) if (!entry) continue // The child was first compiled WITHOUT `siblingTemplatesRegistered`, so // `entry.ir.errors` may already carry suppressible BF103s (cross-template // loop references the harness DOES register). Re-generate with siblings // registered and inspect ONLY the errors that pass appends — `generate` // resets its own error list and appends to `ir.errors`, so anything after // the pre-existing count is the authoritative siblings-registered result. const before = entry.ir.errors?.length ?? 0 adapter.generate(entry.ir, { siblingTemplatesRegistered: true }) const childErrors = (entry.ir.errors ?? []) .slice(before) .filter(e => e.severity === 'error') if (childErrors.length > 0) { throw new Error( `Compilation errors in reachable child ${name}:\n${childErrors.map(e => e.message).join('\n')}`, ) } } } const componentName = ir.metadata.componentName // Build temp directory. const tempDir = resolve( RENDER_TEMP_DIR, `jinja-${Date.now()}-${Math.random().toString(36).slice(2)}`, ) await mkdir(tempDir, { recursive: true }) try { // Write `.jinja` files (parent + children), named by snake_case so // the adapter's `bf.render_child('', …)` calls + the backend's // `render_named('', …)` resolve from the dir. await Bun.write(resolve(tempDir, `${toSnakeCase(componentName)}.jinja`), templateFile.content) for (const [childName, { template }] of childTemplates) { await Bun.write(resolve(tempDir, `${toSnakeCase(childName)}.jinja`), template) } // Build props dict for Python. const { propsPy, userPropsPy } = buildPythonProps(componentName, props, ir) // Honour `__instanceId` from props for the root scope id so // shared-component fixtures (which pin `_test`) match // cross-adapter; default to 'test' otherwise. const rootScopeIdRaw = typeof props?.__instanceId === 'string' ? props.__instanceId : 'test' // Build child-renderer registration for Python. const childRenderers = buildChildRenderers(childTemplates, ir) const renderScript = `import sys sys.path.insert(0, ${pyStr(PYTHON_DIR)}) import uuid from barefootjs import BarefootJS, SearchParams from barefootjs.backend_jinja import JinjaBackend from barefootjs.runtime import jinja_ident, _derive_stash_from_defaults # Single Jinja2 backend over the temp template dir. backend = JinjaBackend(paths=[${pyStr(tempDir)}]) bf = BarefootJS(None, {'backend': backend}) # Honour an explicit __instanceId so shared-component fixtures match the # scope ids Hono / Go emit; default to 'test'. bf._scope_id(${pyStr(rootScopeIdRaw)}) props = ${propsPy} # Mirrors production's \`props_attr\` contract (see runtime.py's \`_props\` # dual-accessor and its Ruby/Perl siblings): the caller is expected to # seed \`_props\` before rendering the root component, so this harness # must call it explicitly. # # \`userPropsPy\` — NOT \`props\` above — is what \`_props\` gets: \`props\` is # this harness's internal vars hash (scope_id, signal/memo seed values, # and — when the component imports searchParams — a non-JSON-serializable # SearchParams reader object), none of which a real Flask route handler # has or passes. Production's bf-p carries none of those, only the # caller-facing props. bf._props(${userPropsPy}) ${childRenderers} html = backend.render_named(${pyStr(toSnakeCase(componentName))}, bf, props) sys.stdout.write(html) # Mirrors where an app's own layout places \`{{ bf.portals() | safe }}\` # near — after the component's own output (#3119). Collected but # never emitted otherwise, since this harness renders no layout at all. sys.stdout.write(bf.portals()) ` await Bun.write(resolve(tempDir, 'render.py'), renderScript) if (!(await isJinjaAvailable())) { throw new PythonNotAvailableError('python3 with jinja2 not found — skipping Jinja rendering') } const proc = Bun.spawn(['python3', 'render.py'], { cwd: tempDir, stdout: 'pipe', stderr: 'pipe', }) const [stdout, stderr] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), ]) const exitCode = await proc.exited if (exitCode !== 0) { throw new Error(`python3 render failed (exit ${exitCode}):\n${stderr}`) } return stdout } finally { await rm(tempDir, { recursive: true, force: true }).catch(() => {}) } } /** * Component names a component IR imports from sibling source files — i.e. * non-type imports from relative (`./` / `../`) specifiers. Used to compute the * transitive set of child components a fixture actually references (#checkbox). * Mirrors the Go / Xslate harness helper of the same name. */ function collectImportedComponentNames(ir: ComponentIR): string[] { const names: string[] = [] for (const imp of ir.metadata.imports ?? []) { if (imp.isTypeOnly) continue if (!imp.source.startsWith('.')) continue for (const spec of imp.specifiers ?? []) { if (spec.isNamespace) continue names.push(spec.alias ?? spec.name) } } return names } /** * Build Python code that registers one child-component renderer per child * template via the production `BarefootJS.register_child_renderer`. * * The closure mirrors the manifest-driven path in `runtime.py` * (`register_components_from_manifest`'s `make_renderer`): it derives the * child scope id from `_` (the parent's * `bf.render_child('', {…, '_bf_slot': ''})` passes * `_bf_slot`), seeds signal / memo / prop defaults from the child IR's * `ssrDefaults`, shares the parent's script list, and renders the child * `.jinja` through the same backend. Loop children (no `_bf_slot`) fall back * to `_` like the Xslate harness. * * `child_props` arrives ALREADY keyword-mangled: `bf.render_child` (the * runtime method the compiled parent template calls) mangles every prop key * via `jinja_ident` before invoking the registered renderer — see * `runtime.BarefootJS.render_child`'s docstring. So the rest-bag routing * below and the `_bf_slot` / `key` / `children` pops all compare against * ALREADY-mangled key spellings; the `keep` set mangles the child's declared * param (CALLER-facing, `sourceName ?? name`) names to match. * * Template vars seed through the production `_derive_stash_from_defaults` * (`barefootjs.runtime`, the same function `register_components_from_ * manifest`'s `make_renderer` calls) rather than a flat `{**_defaults, * **child_props}` merge: the flat merge never resolves an aliased * destructured prop's CALLER-facing key (`n`) onto its local template var * (`count`) — `_defaults` carries the FULL `{value, propName?, * isRestProps?}` shape (not flattened to bare values) so the propName * resolution has something to read (#2524 SSR half). */ function buildChildRenderers( childTemplates: Map, _parentIR: ComponentIR, ): string { if (childTemplates.size === 0) return '' const lines: string[] = [] lines.push(`# Register child component renderers`) for (const [componentName, { ir: childIR }] of childTemplates) { const snakeName = toSnakeCase(componentName) const fnSuffix = snakeName.replace(/[^a-zA-Z0-9_]/g, '_') // Statically-derived ssrDefaults the child template's vars seed from // (prop defaults + signal / memo initial values), serialised VERBATIM // (propName / isRestProps intact) to a Python dict literal — resolved // per-call against the REAL child props by `_derive_stash_from_defaults` // below, not flattened here. const ssrDefaults = extractSsrDefaults(childIR.metadata) ?? {} const defaultsPy = ssrDefaultsToPy(ssrDefaults) const restPropsName = childIR.metadata.restPropsName const paramNames = (childIR.metadata.propsParams ?? []).map(p => p.sourceName ?? p.name) lines.push(`def _make_child_renderer_${fnSuffix}():`) lines.push(` _defaults = ${defaultsPy}`) lines.push(` def _renderer(child_props, caller_bf=None):`) // `caller_bf` is the instance whose template invoked render_child // (#1897) — nested children chain their scope/slot identity off it. lines.push(` host_scope = caller_bf._scope_id() if caller_bf is not None else bf._scope_id()`) if (restPropsName) { // A child that destructures a rest bag references it in its template; // seed it with an empty dict when the caller didn't pass one so // Jinja's var lookup doesn't fault. Route non-param props into the // rest bag, mirroring the production runtime's // `_derive_stash_from_defaults` isRestProps branch and JSX rest // semantics: a caller prop the child didn't destructure belongs in // the bag, not as a top-level stash var the template never reads. const restKeyPy = pyStr(restPropsName) const keepNamesPy = pyList([...new Set([...paramNames, restPropsName, 'children', 'key', '_bf_slot'])]) lines.push(` _rest_key = jinja_ident(${restKeyPy})`) lines.push(` child_props.setdefault(_rest_key, {})`) lines.push(` _keep = {jinja_ident(k) for k in ${keepNamesPy}}`) lines.push(` for _k in list(child_props.keys()):`) lines.push(` if _k not in _keep:`) lines.push(` child_props[_rest_key][_k] = child_props.pop(_k)`) } lines.push(` slot_id = child_props.pop('_bf_slot', None)`) lines.push(` child_bf = BarefootJS(None, {'backend': backend})`) // JSX `key` (reserved prop) → data-key on the child scope root, for keyed // loop reconciliation parity with Hono. lines.push(` data_key = child_props.pop('key', None)`) lines.push(` if data_key is not None:`) lines.push(` child_bf._data_key(data_key)`) // A loop child (no slot) gets a fresh `_` id per // iteration — the PascalCase name is what `normalizeHTML` canonicalises to // `_*`; a slotted child derives from the parent scope. lines.push(` if slot_id:`) lines.push(` child_bf._scope_id(host_scope + '_' + slot_id)`) lines.push(` else:`) lines.push(` child_bf._scope_id(${pyStr(componentName)} + '_' + uuid.uuid4().hex[:6])`) lines.push(` child_bf._is_child(True)`) lines.push(` if slot_id:`) lines.push(` child_bf._bf_parent(host_scope)`) lines.push(` child_bf._bf_mount(slot_id)`) // (#1897) A child template may itself call `bf.render_child(...)` // (AccordionTrigger renders ChevronDownIcon) — inside that template // `bf` is THIS fresh child instance, whose renderer registry starts // empty, so the nested call silently rendered ''. Share the parent's // registry so nested child renders resolve. lines.push(` child_bf._child_renderers(bf._child_renderers())`) lines.push(` child_bf._scripts(bf._scripts())`) lines.push(` child_bf._script_seen(bf._script_seen())`) // Shares the root's `_portal_elements` list object (#3119) — same // reference-propagation `register_components_from_manifest` does in // production (see `runtime.py`'s `register_portal_element` // docstring): an `ssrPortalOwnerScope`-flagged element nested inside a // child template (e.g. DialogContent inside Dialog) must reach the // SAME collector this harness reads back via `bf.portals()`. lines.push(` child_bf._portal_elements(bf._portal_elements())`) // Seed template vars through the production `_derive_stash_from_defaults` // — resolves each entry's `propName` against the REAL `child_props` // (already keyword-mangled above), falling back to the static `value`; // `isRestProps` entries pass the already-routed rest bag through. Caller // props still win overall via the final merge (mirrors the ERB // harness's `child_props.merge(extra)` and the production // `register_components_from_manifest` renderer closure). lines.push(` _extra = _derive_stash_from_defaults(_defaults, child_props)`) lines.push(` _vars = {**child_props, **_extra}`) lines.push(` rendered = backend.render_named(${pyStr(snakeName)}, child_bf, _vars)`) lines.push(` if isinstance(rendered, str) and rendered.endswith('\\n'):`) lines.push(` rendered = rendered[:-1]`) lines.push(` return rendered`) lines.push(` return _renderer`) lines.push(``) lines.push(`bf.register_child_renderer(${pyStr(snakeName)}, _make_child_renderer_${fnSuffix}())`) lines.push(``) } return lines.join('\n') } /** Render a JSON string-array as a Python list-of-strings literal. */ function pyList(names: string[]): string { return `[${names.map(pyStr).join(', ')}]` } /** * Serialise an ssrDefaults map to a Python dict literal, VERBATIM — * `value` / `propName` / `isRestProps` intact, exactly the shape * `_derive_stash_from_defaults` expects (and exactly what the production * build manifest embeds). Do NOT flatten to bare `value`s here: that was * the #2524 SSR-half bug — a flattened entry has nothing left for the * propName-aware resolution to read, so an aliased destructured prop's * caller-facing key is silently dropped. */ function ssrDefaultsToPy(defaults: Record): string { const entries: string[] = [] for (const [name, d] of Object.entries(defaults)) { entries.push(`${pyStr(name)}: ${ssrDefaultEntryToPy(d)}`) } return `{${entries.join(', ')}}` } /** Serialise a single `SsrDefault` entry to a Python dict literal. */ function ssrDefaultEntryToPy(d: SsrDefault): string { const parts: string[] = [`'value': ${toPyLiteral(d.value)}`] if (d.propName !== undefined) parts.push(`'propName': ${pyStr(d.propName)}`) if (d.isRestProps) parts.push(`'isRestProps': True`) return `{${parts.join(', ')}}` } /** * Convert PascalCase to snake_case for template naming (matches the * adapter's `toTemplateName`). */ function toSnakeCase(name: string): string { return name .replace(/([A-Z])/g, '_$1') .toLowerCase() .replace(/^_/, '') } /** * Build a Python dict literal from props (+ signal / memo seeds). * * Returns the full internal vars-hash Python literal (`props`, what the * compiled template renders against) AND `userPropsPy` — the exact raw * fixture props (no defaults, no null-fill, no signal/memo seeding), * mirroring production's route-handler call `bf._props(props)` verbatim. * `userPropsPy` is for `bf._props(...)` (bf-p hydration payload) only — * these must NOT be the same value, and `userPropsPy` must NOT be * derived from `entries` or the defaulted stash: production's own bf-p * carries only what the caller actually passed in, unmodified. */ function buildPythonProps( _componentName: string, props: Record | undefined, ir: ComponentIR, ): { propsPy: string; userPropsPy: string } { // The full internal vars hash (local-template-var-name keyed, e.g. // `count` for `{ n: count }`) that the compiled template renders // against. const entries: string[] = [] // Prop params with defaults (before signals, so signals can reference them). // Seeded through the shared `deriveStashFromDefaults` (the TS twin of the // production `_derive_stash_from_defaults` this harness ALSO calls at // render time for child components) so an aliased destructured prop's // CALLER-facing key (`propName`, e.g. `n` for `{ n: count }`) is honoured, // not just the local template var name (#2524 SSR half). `props` is keyed // by the caller-facing name, exactly what `propName` resolves against. const rootSsrDefaults = extractSsrDefaults(ir.metadata) ?? {} const derivedProps = deriveStashFromDefaults(rootSsrDefaults, props ?? {}) for (const param of ir.metadata.propsParams) { if (param.isRest) continue // No default + no caller value: pass `None` so Jinja's var lookup for // an optional prop doesn't fault before its falsy branch elides. const value = derivedProps[param.name] ?? null entries.push(`${pyStr(param.name)}: ${toPyLiteral(value)}`) } // Route undeclared props into the rest bag (`bf.spread_attrs($)`). const restPropsName = ir.metadata.restPropsName // Caller-facing keys — an aliased destructured prop's DECLARED set for // rest-bag routing must match what the caller actually sent (`n`), not // the local binding (`count`), or the caller's own prop silently gets // swept into the rest bag as an undeclared extra (#2524). const declaredParams = new Set(ir.metadata.propsParams.map(p => p.sourceName ?? p.name)) const restBagEntries: Array<[string, unknown]> = [] if (restPropsName && props) { for (const [key, value] of Object.entries(props)) { if (key.startsWith('__')) continue if (key === restPropsName || declaredParams.has(key)) continue restBagEntries.push([key, value]) } } const routedKeys = new Set(restBagEntries.map(([k]) => k)) if (restPropsName && !(props && restPropsName in props)) { entries.push(`${pyStr(restPropsName)}: ${toPyLiteral(Object.fromEntries(restBagEntries))}`) } // User props. Skip a key that's already a declared param's LOCAL template // var name — `derivedProps` above already resolved that var correctly // (through `propName`, for an aliased prop); re-pushing the raw `props[key]` // here would silently clobber it with an UNRELATED value whenever a caller // happens to also pass a same-spelled-as-local-name prop that isn't this // param's actual `propName` (#2524 — surfaced by the aliased-destructured-prop // generated data points, which pass both `n` (the real propName) and an // incidental `count` key). const localParamNames = new Set(ir.metadata.propsParams.map(p => p.name)) if (props) { for (const [key, value] of Object.entries(props)) { if (key.startsWith('__')) continue if (routedKeys.has(key)) continue if (localParamNames.has(key)) continue if (typeof value === 'string') { entries.push(`${pyStr(key)}: ${pyStr(value)}`) } else if (typeof value === 'number') { entries.push(`${pyStr(key)}: ${pyNumber(value)}`) } else if (typeof value === 'boolean') { entries.push(`${pyStr(key)}: ${value ? 'True' : 'False'}`) } else if (Array.isArray(value) || (value && typeof value === 'object')) { entries.push(`${pyStr(key)}: ${toPyLiteral(value)}`) } } } // `bf._props(...)` payload (bf-p hydration): mirrors production's // route-handler call `bf._props(props)` verbatim — the caller's raw // props dict, unmodified. No default-filling, no signal/memo seeding, // no rest-bag nesting: a real Flask route handler never rearranges // the dict it received before handing it to `_props`. Excludes // internal harness-only keys (`__instanceId` etc.), which are never a // real caller-facing prop. const userEntries: string[] = [] if (props) { for (const [key, value] of Object.entries(props)) { if (key.startsWith('__')) continue userEntries.push(`${pyStr(key)}: ${toPyLiteral(value)}`) } } const userPropsPy = `{${userEntries.join(', ')}}` const explicitScope = typeof props?.__instanceId === 'string' ? props.__instanceId : 'test' const fullEntries = [...entries, `${pyStr('scope_id')}: ${pyStr(explicitScope)}`] // No initializer re-evaluation against raw props — production's // before_render seeds only from `deriveStashFromDefaults` (#2696). for (const signal of ir.metadata.signals) { // Env signals (#2057) are bound below via `SearchParams('')`, not from a // static initial value. if (signal.envReader) continue if (Object.prototype.hasOwnProperty.call(derivedProps, signal.getter)) { fullEntries.push(`${pyStr(signal.getter)}: ${toPyLiteral(derivedProps[signal.getter])}`) } } // No `?? 0` for entries the manifest lacks — production seeds nothing there (#2696). for (const memo of ir.metadata.memos) { if (Object.prototype.hasOwnProperty.call(derivedProps, memo.name)) { fullEntries.push(`${pyStr(memo.name)}: ${toPyLiteral(derivedProps[memo.name])}`) } } // #2813: `extractSsrDefaults` can seed an entry under a name that is // none of `propsParams` / `signals` / `memos` above — a bare local-const // alias of a signal/memo getter (`const items__alias = items`, // `ssr-defaults.ts`) seeds `items__alias` alongside `items`. Production // hands the whole manifest-derived stash straight through // (`deriveStashFromDefaults` IS the production seeding mechanism here, // not a harness stand-in), so any leftover `derivedProps` key must be // pushed too, or this harness would silently omit what production // actually provides, mismeasuring this conformance case. const seededNames = new Set([ ...ir.metadata.propsParams.filter(p => !p.isRest).map(p => p.name), ...(restPropsName ? [restPropsName] : []), ...ir.metadata.signals.filter(s => !s.envReader).map(s => s.getter), ...ir.metadata.memos.map(m => m.name), ]) for (const [name, value] of Object.entries(derivedProps)) { if (seededNames.has(name)) continue fullEntries.push(`${pyStr(name)}: ${toPyLiteral(value)}`) } // (#1922) Request-scoped `searchParams()`: bind `searchParams` to an // empty-query reader (so the render script needn't build one from a real // request). The conformance harness issues no query string (the // production Flask integration builds this from the request's query), so // `.get(k)` resolves to `None` and the author's `?? default` renders. Only // when the component imports `searchParams`. if (importsSearchParams(ir.metadata)) { fullEntries.push(`${pyStr('searchParams')}: SearchParams('')`) } return { propsPy: `{${fullEntries.join(', ')}}`, userPropsPy } } /** * Python string literal for arbitrary text, via `JSON.stringify`. JSON's * string grammar (`\\`, `\"`, `\n`, `\r`, `\t`, `\b`, `\f`, `\uXXXX`) is a * faithful subset of Python's double-quoted string-literal escapes, so this * is exact — simpler and more robust than a hand-rolled escaper (no * character can slip through unescaped). */ function pyStr(s: string): string { return JSON.stringify(s) } /** Python numeric literal, with JS's NaN/±Infinity mapped to `float(...)` calls. */ function pyNumber(n: number): string { if (Number.isNaN(n)) return "float('nan')" if (n === Infinity) return "float('inf')" if (n === -Infinity) return "float('-inf')" return String(n) } function toPyLiteral(value: unknown): string { if (typeof value === 'string') return pyStr(value) if (typeof value === 'number') return pyNumber(value) if (typeof value === 'boolean') return value ? 'True' : 'False' if (value instanceof Date) { // A Date prop crosses the language boundary as its ISO-8601 string — // the shared `date()` runtime helper (#2274/#2288) accepts an ISO // string as well as a native `datetime`. MUST come before the generic // `value && typeof value === 'object'` branch below, which would // otherwise `Object.entries()` a Date's (non-enumerable) internals // into an empty dict literal, silently erasing the value. return pyStr(value.toISOString()) } if (Array.isArray(value)) { return `[${value.map(toPyLiteral).join(', ')}]` } if (value && typeof value === 'object') { const entries: string[] = [] for (const [key, v] of Object.entries(value as Record)) { entries.push(`${pyStr(key)}: ${toPyLiteral(v)}`) } return `{${entries.join(', ')}}` } return 'None' }