octane 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,6 +39,7 @@ import {
39
39
  strongHash,
40
40
  } from '@tsrx/core';
41
41
  import { parseModule } from '#octane/compiler-parser';
42
+ import { createStyleScopePass } from './style-scopes.js';
42
43
  import { decodeHTMLStrict } from 'entities';
43
44
  import { print as esrapPrint } from 'esrap';
44
45
  import esrapTsx from 'esrap/languages/tsx';
@@ -240,6 +241,10 @@ function inheritOriginLoc(root, origin) {
240
241
  if (origin == null || origin.loc == null) return root;
241
242
  const { start, end, loc } = origin;
242
243
  mapAst(root, (node) => {
244
+ // A parsed `<style>` body is a CSS AST whose positions are relative to the
245
+ // CSS text, never to the module: stamping module positions onto it would
246
+ // corrupt the scoped-CSS render (and it is parser-owned, so shared).
247
+ if (node.type === 'StyleSheet') return node;
243
248
  // Only real AST nodes are stamped — mapAst also visits typeless data
244
249
  // objects (TemplateElement.value, Literal.regex, embedded config), which
245
250
  // must stay untouched (and may be frozen).
@@ -317,6 +322,13 @@ function markSynthesizedAttr(attr) {
317
322
  return attr;
318
323
  }
319
324
 
325
+ /** A compiler-added scope-hash class, not an authored `class`/`className`. */
326
+ function isSynthesizedClassAttr(attr) {
327
+ const raw = jsxAttrRawName(attr);
328
+ if (raw !== 'class' && raw !== 'className') return false;
329
+ return attr.name?.metadata?.tsrx_synthesized_origin === true;
330
+ }
331
+
320
332
  // Curated exact origins (`inspect: true`). A generated token whose authored
321
333
  // counterpart the compiler KNOWS — the `$$click` slot key of an `onClick`, the
322
334
  // block helper an `@if`/`@for` lowers to — but which the module map alone
@@ -6714,9 +6726,21 @@ function mapCallbackCapturesLexicalReceiver(root) {
6714
6726
  return walk(root);
6715
6727
  }
6716
6728
 
6717
- // Recognise `{style (expr)}` — TSRX parses it as a plain
6718
- // `CallExpression(style, [expr])` (the dedicated `Style` intrinsic node is
6719
- // gone); resolveStyleExpr rewrites it into a scoped class-string expression.
6729
+ // Scoped `<style>` blocks, `$class`, and `apply` (RFC tsrx-org/RFCs#1): one
6730
+ // copy-on-write pre-pass per top-level statement, see ./style-scopes.js.
6731
+ const { applyStyleScopes } = createStyleScopePass({
6732
+ inheritOriginLoc,
6733
+ markSynthesized,
6734
+ markSynthesizedAttr,
6735
+ headResourceKind,
6736
+ isCompositeJsxTag,
6737
+ isStyleCall,
6738
+ });
6739
+
6740
+ // Recognise `style(expr)` — a plain `CallExpression(style, [expr])`, Octane's
6741
+ // class-string expression. The style-scope pre-pass (./style-scopes.js)
6742
+ // resolves it where TSRX reads a class value (attribute values and template
6743
+ // child holes, the `style` attribute excepted); anywhere else it is a user call.
6720
6744
  function isStyleCall(node) {
6721
6745
  return (
6722
6746
  node &&
@@ -6728,26 +6752,14 @@ function isStyleCall(node) {
6728
6752
  );
6729
6753
  }
6730
6754
 
6731
- // Resolve a `{style (expr)}` CallExpression (see isStyleCall) into a plain
6732
- // expression that yields a class string, with the component's scoped css hash
6733
- // prepended (so `{style ('row')}` in a component with hash "tsrx-abc" produces
6734
- // "tsrx-abc row"). Literal values inline; dynamic values become a runtime
6735
- // string concat. Components without a <style> block (no hash) — and any other
6736
- // expression shape — pass through untouched.
6737
- function resolveStyleExpr(node, cssHash) {
6738
- if (!node) return node;
6739
- if (!isStyleCall(node) || !cssHash) return node;
6740
- const inner = node.arguments[0];
6741
- if (inner.type === 'Literal' && typeof inner.value === 'string') {
6742
- const combined = inner.value ? `${cssHash} ${inner.value}` : cssHash;
6743
- // The resolved class string maps to the authored `{style (…)}` call.
6744
- return b.literal(combined, JSON.stringify(combined), node);
6745
- }
6746
- // Dynamic: emit `(<hash> + ' ' + (expr))` so absent/null produces "<hash> ".
6747
- return inheritOriginLoc(
6748
- b.binary('+', b.literal(cssHash + ' ', JSON.stringify(cssHash + ' ')), inner),
6749
- node,
6750
- );
6755
+ // The emitters' former `style(expr)` expansion. The pre-pass resolves every
6756
+ // class-string position before the emitters run, and a `style(...)` still on
6757
+ // the AST here is a user call in another position (a `style={style(p)}` value,
6758
+ // a child hole's call argument), which must print as authored — so this is a
6759
+ // pass-through kept at its call sites; `cssHash` is only the "this component
6760
+ // owns scoped CSS" flag they thread along.
6761
+ function resolveStyleExpr(node, _cssHash) {
6762
+ return node;
6751
6763
  }
6752
6764
 
6753
6765
  /**
@@ -6773,6 +6785,11 @@ function isComponentFunction(node) {
6773
6785
  // hooks slotted (via withSlot) and its returned JSX lowered to a descriptor, and
6774
6786
  // whatever USES it (`<Foo/>` / `{expr}`) is what renders it. (Async/generator
6775
6787
  // excluded; a `use*` custom hook that returns JSX is still a hook, not this.)
6788
+ //
6789
+ // Owned returns inside `if`/`else`/`switch`/`try`/loops count: a function whose
6790
+ // only JSX exits are nested still needs the return-JSX path so scoped `<style>`
6791
+ // (hash stamp, injectStyle, `ref` class-map writes) runs. Nested function
6792
+ // bodies are a different component/value boundary and are ignored.
6776
6793
  function isReturnJsxFunction(node) {
6777
6794
  if (!node) return false;
6778
6795
  if (node.type !== 'FunctionDeclaration' && node.type !== 'FunctionExpression') return false;
@@ -6782,9 +6799,58 @@ function isReturnJsxFunction(node) {
6782
6799
  if (node.id == null) return false;
6783
6800
  if (node.async || node.generator) return false;
6784
6801
  if (!node.body || node.body.type !== 'BlockStatement') return false;
6785
- return (node.body.body || []).some(
6786
- (s) => s.type === 'ReturnStatement' && s.argument && isJsxNode(s.argument),
6787
- );
6802
+ const statements = node.body.body || [];
6803
+ for (let i = 0; i < statements.length; i++) {
6804
+ if (statementHasOwnedJsxReturn(statements[i])) return true;
6805
+ }
6806
+ return false;
6807
+ }
6808
+
6809
+ /** @param {any} stmt @returns {boolean} */
6810
+ function statementHasOwnedJsxReturn(stmt) {
6811
+ if (!stmt) return false;
6812
+ if (stmt.type === 'ReturnStatement') return !!(stmt.argument && isJsxNode(stmt.argument));
6813
+ if (stmt.type === 'BlockStatement') {
6814
+ const list = stmt.body || [];
6815
+ for (let i = 0; i < list.length; i++) {
6816
+ if (statementHasOwnedJsxReturn(list[i])) return true;
6817
+ }
6818
+ return false;
6819
+ }
6820
+ if (stmt.type === 'IfStatement') {
6821
+ return (
6822
+ statementHasOwnedJsxReturn(stmt.consequent) || statementHasOwnedJsxReturn(stmt.alternate)
6823
+ );
6824
+ }
6825
+ if (stmt.type === 'SwitchStatement') {
6826
+ const cases = stmt.cases || [];
6827
+ for (let i = 0; i < cases.length; i++) {
6828
+ const consequent = cases[i].consequent || [];
6829
+ for (let j = 0; j < consequent.length; j++) {
6830
+ if (statementHasOwnedJsxReturn(consequent[j])) return true;
6831
+ }
6832
+ }
6833
+ return false;
6834
+ }
6835
+ if (stmt.type === 'TryStatement') {
6836
+ return (
6837
+ statementHasOwnedJsxReturn(stmt.block) ||
6838
+ (stmt.handler ? statementHasOwnedJsxReturn(stmt.handler.body) : false) ||
6839
+ statementHasOwnedJsxReturn(stmt.finalizer)
6840
+ );
6841
+ }
6842
+ if (
6843
+ stmt.type === 'ForStatement' ||
6844
+ stmt.type === 'ForInStatement' ||
6845
+ stmt.type === 'ForOfStatement' ||
6846
+ stmt.type === 'WhileStatement' ||
6847
+ stmt.type === 'DoWhileStatement' ||
6848
+ stmt.type === 'LabeledStatement' ||
6849
+ stmt.type === 'WithStatement'
6850
+ ) {
6851
+ return statementHasOwnedJsxReturn(stmt.body);
6852
+ }
6853
+ return false;
6788
6854
  }
6789
6855
 
6790
6856
  /** True when `node` is one plain lowercase host element (never a component/fragment). */
@@ -9814,16 +9880,11 @@ function compileInternal(
9814
9880
  // use, custom helpers, etc.) — merged into the single prelude import.
9815
9881
  addUserImportSpecifiers(ctx, node);
9816
9882
  } else {
9817
- // Style maps: rewrite `const x = <style>…</style>` before printing — the
9818
- // initialiser becomes an ObjectExpression with hashed class names, and
9819
- // the stylesheet flows through the regular cssInjections pipeline.
9820
- node = applyStyleMap(node, ctx);
9821
- // Also handle `export const x = <style>…</style>` (declaration wrapped
9822
- // in an ExportNamedDeclaration).
9823
- if (node.type === 'ExportNamedDeclaration' && node.declaration) {
9824
- const declaration = applyStyleMap(node.declaration, ctx);
9825
- if (declaration !== node.declaration) node = { ...node, declaration };
9826
- }
9883
+ // Style blocks anywhere in a non-component statement: assigned blocks
9884
+ // (`const x = <style>…</style>`, exported or nested in a helper) become
9885
+ // class-map objects and their sheets flow through cssInjections; scoped
9886
+ // blocks inside plain functions' templates get their scope hashes.
9887
+ node = applyStyleScopes(node, ctx).node;
9827
9888
  // HOOKS EVERYWHERE: a plain function (a custom hook, a helper) can hold
9828
9889
  // octane hooks too — slot them the same way components do, so their base
9829
9890
  // hooks get a per-call-site symbol (and custom-hook calls inside get one to
@@ -10392,11 +10453,18 @@ function compileServer(
10392
10453
  let newBody = null;
10393
10454
  const statements = ast.body;
10394
10455
  for (let i = 0; i < statements.length; i++) {
10395
- let node = applyStyleMap(statements[i], ctx);
10396
- if (node.type === 'ExportNamedDeclaration' && node.declaration) {
10397
- const declaration = applyStyleMap(node.declaration, ctx);
10398
- if (declaration !== node.declaration) node = { ...node, declaration };
10399
- }
10456
+ // Components lower their own scopes inside compileServerComponent so
10457
+ // only the CSS they own is emitted per render; every other statement's
10458
+ // blocks are module-level.
10459
+ const statement = statements[i];
10460
+ const declaration =
10461
+ statement.type === 'ExportDefaultDeclaration' || statement.type === 'ExportNamedDeclaration'
10462
+ ? statement.declaration
10463
+ : statement;
10464
+ const node =
10465
+ isComponentFunction(declaration) || isReturnJsxFunction(declaration)
10466
+ ? statement
10467
+ : applyStyleScopes(statement, ctx).node;
10400
10468
  if (newBody === null && node !== statements[i]) newBody = statements.slice(0, i);
10401
10469
  if (newBody !== null) newBody.push(node);
10402
10470
  }
@@ -10556,11 +10624,11 @@ function compileServerComponent(node, ctx) {
10556
10624
  const isExported = !!(node.export || node.default);
10557
10625
  const isDefault = !!node.default;
10558
10626
 
10559
- // Scoped <style>: applyCssScoping stamps hash classes + registers cssInjections.
10627
+ // Scoped <style>: applyStyleScopes stamps scope classes + registers cssInjections.
10560
10628
  // Capture this component's entries to emit injectStyle INSIDE the body (so the
10561
10629
  // active server render collects CSS only for components it actually renders).
10562
10630
  const beforeCss = ctx.cssInjections.length;
10563
- const scoping = applyCssScoping(node, ctx);
10631
+ const scoping = applyStyleScopes(node, ctx);
10564
10632
  node = scoping.node;
10565
10633
  const cssHash = scoping.cssHash;
10566
10634
  const cssEntries = [...ctx.moduleCssInjections, ...ctx.cssInjections.slice(beforeCss)].sort(
@@ -10588,6 +10656,12 @@ function compileServerComponent(node, ctx) {
10588
10656
  cssEntries,
10589
10657
  'opaque',
10590
10658
  node.body?.type === 'JSXCodeBlock',
10659
+ null,
10660
+ false,
10661
+ false,
10662
+ // Imported themes this component applies are touched at the top of
10663
+ // its body (serverThemeTouchStatements) so their CSS precedes its own.
10664
+ scoping.runtimeApplied,
10591
10665
  );
10592
10666
  } finally {
10593
10667
  ctx.currentComponentLocals = prevLocals;
@@ -10672,6 +10746,7 @@ function ssrCompileBody(
10672
10746
  componentNs = null,
10673
10747
  returnedFragmentTemplate = false,
10674
10748
  returnedFragmentRoot = false,
10749
+ themeTouches = [],
10675
10750
  unboxedHtml = false,
10676
10751
  ) {
10677
10752
  const prevMapTemps = ctx.currentMapTemps;
@@ -10688,6 +10763,7 @@ function ssrCompileBody(
10688
10763
  componentNs,
10689
10764
  returnedFragmentTemplate,
10690
10765
  returnedFragmentRoot,
10766
+ themeTouches,
10691
10767
  unboxedHtml,
10692
10768
  );
10693
10769
  } finally {
@@ -10706,6 +10782,9 @@ function ssrCompileBodyWithMapTemps(
10706
10782
  componentNs,
10707
10783
  returnedFragmentTemplate,
10708
10784
  returnedFragmentRoot,
10785
+ // Imported themes the component's scopes apply — only the component body
10786
+ // itself passes them; nested branch/loop helpers (ssrCompileSub) pass none.
10787
+ themeTouches,
10709
10788
  unboxedHtml,
10710
10789
  ) {
10711
10790
  const returnedOutput = node.body?.type === 'JSXCodeBlock' && hasOwnValueReturn(node);
@@ -10860,6 +10939,7 @@ function ssrCompileBodyWithMapTemps(
10860
10939
  ctx._valueDirectiveLowering = prevValueDirectiveLowering;
10861
10940
 
10862
10941
  const body = [];
10942
+ body.push(...serverThemeTouchStatements(themeTouches ?? [], ctx, node));
10863
10943
  if (cssEntries && cssEntries.length) {
10864
10944
  ctx.runtimeNeeded.add('injectStyle');
10865
10945
  for (const entry of cssEntries) {
@@ -11084,6 +11164,23 @@ function ssrCall(helper, args, origin) {
11084
11164
  );
11085
11165
  }
11086
11166
 
11167
+ /**
11168
+ * `_$touchStyleMap(theme)` for every imported theme a server component's
11169
+ * scopes apply, emitted before the component's own `injectStyle` calls so the
11170
+ * theme's CSS (a lazily injecting `_$styleMap`) lands first in the request
11171
+ * collector and the applying scope's rules win the cascade.
11172
+ */
11173
+ function serverThemeTouchStatements(runtimeApplied, ctx, origin) {
11174
+ if (!runtimeApplied || runtimeApplied.length === 0) return [];
11175
+ ctx.runtimeNeeded.add('touchStyleMap');
11176
+ return runtimeApplied.map((expression) =>
11177
+ inheritOriginLoc(
11178
+ b.stmt(ssrCall('touchStyleMap', [cloneAstNode(expression)], expression)),
11179
+ origin,
11180
+ ),
11181
+ );
11182
+ }
11183
+
11087
11184
  function ssrThunk(expression, origin) {
11088
11185
  return inheritOriginLoc(b.arrow([], expression), origin);
11089
11186
  }
@@ -11097,17 +11194,17 @@ function ssrSourcePair(present, value, origin) {
11097
11194
  }
11098
11195
 
11099
11196
  /**
11100
- * One `[spread, name, value]` source row for a grouped attribute helper. The
11101
- * name literal is the only token in the group that names THIS attribute, so it
11102
- * is what the authored name is claimed against (see registerAttrLoweringOrigin);
11103
- * the helper itself stays anchored on the element it serializes.
11197
+ * One `[spread, name, value, merge?]` source row for a grouped attribute helper.
11198
+ * The name literal is the only token in the group that names THIS attribute, so
11199
+ * it is what the authored name is claimed against (see registerAttrLoweringOrigin);
11200
+ * the helper itself stays anchored on the element it serializes. A trailing
11201
+ * `true` marks a synthesized scope class that must merge with a spread's class.
11104
11202
  */
11105
- function ssrDirectSource(ctx, name, value, origin) {
11203
+ function ssrDirectSource(ctx, name, value, origin, merge) {
11106
11204
  registerAttrLoweringOrigin(ctx, origin?.name, null, name);
11107
- return inheritOriginLoc(
11108
- b.array([b.literal(false, 'false'), b.literal(name, JSON.stringify(name)), value]),
11109
- origin,
11110
- );
11205
+ const cells = [b.literal(false, 'false'), b.literal(name, JSON.stringify(name)), value];
11206
+ if (merge) cells.push(b.literal(true, 'true'));
11207
+ return inheritOriginLoc(b.array(cells), origin);
11111
11208
  }
11112
11209
 
11113
11210
  /**
@@ -11866,7 +11963,15 @@ function ssrEmitElement(node, ctx, name, inlinedSubs, parentNs, cssHash, compone
11866
11963
  );
11867
11964
  attrExpr = tsrxExprNode(attrInner, ctx, name, inlinedSubs);
11868
11965
  }
11869
- attrSources.push(ssrDirectSource(ctx, rawAttrName, bindAttributeEvaluation(attrExpr), attr));
11966
+ attrSources.push(
11967
+ ssrDirectSource(
11968
+ ctx,
11969
+ rawAttrName,
11970
+ bindAttributeEvaluation(attrExpr),
11971
+ attr,
11972
+ isSynthesizedClassAttr(attr),
11973
+ ),
11974
+ );
11870
11975
  continue;
11871
11976
  }
11872
11977
 
@@ -12453,8 +12558,7 @@ function ssrEmitComponent(node, ctx, name, inlinedSubs, parentNs, cssHash, compo
12453
12558
  const children = rewriteOpaqueTitles(sourceChildren, ctx, 'opaque');
12454
12559
  const opaqueChildren = !activityDescriptor && !isFragmentLongForm(node, ctx);
12455
12560
  const descriptorChildren =
12456
- (tagBindingName(node) !== null &&
12457
- ctx.descriptorChildrenBindings?.has(tagBindingName(node))) ||
12561
+ isDescriptorChildrenTag(node, ctx) ||
12458
12562
  ctx._tsxValuePos ||
12459
12563
  (returnedFragmentTemplate && !requiresTemplateNormalization(node, parentNs, true, ctx));
12460
12564
  if (descriptorChildren) {
@@ -12674,6 +12778,8 @@ function ssrCompileSub(
12674
12778
  componentNs,
12675
12779
  returnedFragmentTemplate,
12676
12780
  returnedFragmentRoot,
12781
+ // Nested helpers touch no themes; the component body already did.
12782
+ [],
12677
12783
  unboxedHtml,
12678
12784
  );
12679
12785
  } finally {
@@ -13304,147 +13410,6 @@ function hasModuleStyleMaps(body) {
13304
13410
  });
13305
13411
  }
13306
13412
 
13307
- // Copy-on-write: returns the (possibly rebuilt) statement; the input is never
13308
- // modified. The core analyze/prepare pipeline mutates the sheet it is given,
13309
- // so it runs over a clone of the (bounded) StyleSheet subtree.
13310
- function applyStyleMap(stmt, ctx) {
13311
- if (stmt.type !== 'VariableDeclaration') return stmt;
13312
- let newDeclarations = null;
13313
- const declarations = stmt.declarations;
13314
- for (let i = 0; i < declarations.length; i++) {
13315
- const decl = declarations[i];
13316
- let next = decl;
13317
- const styleNode = decl.init && decl.init.type === 'JSXStyleElement' ? decl.init : null;
13318
- const parsedSheet = styleNode
13319
- ? (styleNode.children || []).find((c) => c && c.type === 'StyleSheet')
13320
- : null;
13321
- const hash = parsedSheet
13322
- ? styleNode.metadata?.styleScopeHash || parsedSheet.hash || null
13323
- : null;
13324
- if (parsedSheet && hash) {
13325
- const sheet = cloneAstNode(parsedSheet);
13326
- // `analyzeCss` marks `:global(...)` selectors (is_global / is_global_block
13327
- // metadata) so the renderer leaves them UNSCOPED. Without this pass
13328
- // `:global(a)` would be scoped to `.<hash>a`. Mirrors tsrx-ripple, which
13329
- // runs analyzeCss(stylesheet) before prepareStylesheetForRender.
13330
- analyzeCss(sheet);
13331
- prepareStylesheetForRender(sheet, true);
13332
- const css = renderStylesheets([sheet]);
13333
- ctx.cssInjections.push({
13334
- hash,
13335
- css,
13336
- order: styleNode.start ?? stmt.start ?? 0,
13337
- // The authored `<style>` element. `injectStyle` is emitted at module
13338
- // scope, so without this the whole block maps to the module origin
13339
- // and neither the tag nor its CSS is reachable from the output.
13340
- origin: styleNode,
13341
- });
13342
- ctx.runtimeNeeded.add('injectStyle');
13343
- // Replace the JSXStyleElement init with the class-map ObjectExpression
13344
- // (built loc-less by the core helper — it maps to the authored <style>).
13345
- next = {
13346
- ...decl,
13347
- init: inheritOriginLoc(createStyleClassMapFromStylesheet(sheet), styleNode),
13348
- };
13349
- }
13350
- if (newDeclarations === null && next !== decl) newDeclarations = declarations.slice(0, i);
13351
- if (newDeclarations !== null) newDeclarations.push(next);
13352
- }
13353
- return newDeclarations === null ? stmt : { ...stmt, declarations: newDeclarations };
13354
- }
13355
-
13356
- // Wrap every DYNAMIC `class` / `className` expression in a `normalizeClass(...)` call
13357
- // BEFORE the scoped-CSS hash is appended. `@tsrx/core`'s annotate_with_hash bakes the
13358
- // hash onto a dynamic class via a template literal — `` `${expr} <hash>` `` — which would
13359
- // stringify an array/object clsx value the wrong way (`['a','b']` → "a,b", `{}` →
13360
- // "[object Object]"). Normalizing first makes the interpolated slot a plain string, so
13361
- // the hash concat stays correct AND clsx composition works in scoped components. (It also
13362
- // turns a bare `class={undefined}` in a scoped component from "undefined <hash>" into just
13363
- // "<hash>".) Unscoped components need no wrap — the runtime `setClassName` / `ssrAttr`
13364
- // normalize the raw value directly. String literals are left alone so they keep folding
13365
- // into the static template. Stops at nested component function boundaries (their class
13366
- // exprs belong to a different scope), mirroring annotate_with_hash's own traversal.
13367
- // Copy-on-write: rebuilt spines only where a wrap lands; untouched subtrees
13368
- // are returned by reference and the input node is never written to. Callers
13369
- // must use the return value.
13370
- function wrapScopedClassExprs(node, ctx) {
13371
- if (!node || typeof node !== 'object') return node;
13372
- if (Array.isArray(node)) {
13373
- let out = null;
13374
- for (let i = 0; i < node.length; i++) {
13375
- const mapped = wrapScopedClassExprs(node[i], ctx);
13376
- if (out === null && mapped !== node[i]) out = node.slice(0, i);
13377
- if (out !== null) out.push(mapped);
13378
- }
13379
- return out ?? node;
13380
- }
13381
- if (
13382
- (node.type === 'FunctionDeclaration' ||
13383
- node.type === 'FunctionExpression' ||
13384
- node.type === 'ArrowFunctionExpression') &&
13385
- node.metadata?.tsrx_dynamic_wrapper !== true
13386
- ) {
13387
- return node;
13388
- }
13389
- let working = node;
13390
- if (node.type === 'JSXElement') {
13391
- const attrs = node.openingElement?.attributes;
13392
- if (Array.isArray(attrs)) {
13393
- let newAttrs = null;
13394
- for (let i = 0; i < attrs.length; i++) {
13395
- const attr = attrs[i];
13396
- let next = attr;
13397
- if (
13398
- attr?.type === 'JSXAttribute' &&
13399
- attr.name?.type === 'JSXIdentifier' &&
13400
- (attr.name.name === 'class' || attr.name.name === 'className') &&
13401
- attr.value?.type === 'JSXExpressionContainer'
13402
- ) {
13403
- const expr = attr.value.expression;
13404
- // Skip string literals (fold statically) and `{style (…)}` calls
13405
- // (resolveStyleExpr owns those). Everything else is a runtime value.
13406
- if (
13407
- expr &&
13408
- !(expr.type === 'Literal' && typeof expr.value === 'string') &&
13409
- !isStyleCall(expr)
13410
- ) {
13411
- next = {
13412
- ...attr,
13413
- value: {
13414
- ...attr.value,
13415
- // The wrapper maps to the class expression it normalizes.
13416
- expression: inheritOriginLoc(b.call('_$normalizeClass', expr), expr),
13417
- },
13418
- };
13419
- ctx.runtimeNeeded.add('normalizeClass');
13420
- }
13421
- }
13422
- if (newAttrs === null && next !== attr) newAttrs = attrs.slice(0, i);
13423
- if (newAttrs !== null) newAttrs.push(next);
13424
- }
13425
- if (newAttrs !== null) {
13426
- working = { ...node, openingElement: { ...node.openingElement, attributes: newAttrs } };
13427
- if ('attributes' in node) working.attributes = newAttrs;
13428
- }
13429
- }
13430
- }
13431
- // Generic descent AFTER the element-level wrap (mirrors the historical pass
13432
- // order): nested JSX inside attribute values and children is wrapped too.
13433
- let out = working === node ? null : working;
13434
- for (const key of Object.keys(working)) {
13435
- if (key === 'loc' || key === 'start' || key === 'end' || key === 'parent') continue;
13436
- if (key === 'metadata' || key === 'css') continue;
13437
- const v = working[key];
13438
- if (!v || typeof v !== 'object') continue;
13439
- const mapped = wrapScopedClassExprs(v, ctx);
13440
- if (mapped !== v) {
13441
- if (out === null) out = { ...node };
13442
- out[key] = mapped;
13443
- }
13444
- }
13445
- return out ?? working;
13446
- }
13447
-
13448
13413
  // Local copy-on-write mirror of @tsrx/core's annotate_with_hash (scoping.js):
13449
13414
  // stamps the scoped-CSS hash class onto native (and dynamic-tag) JSX elements
13450
13415
  // and drops JSXStyleElement nodes from the rendered tree. The core helper
@@ -13459,301 +13424,6 @@ function isCompositeJsxTag(node) {
13459
13424
  return name.type === 'JSXMemberExpression';
13460
13425
  }
13461
13426
 
13462
- function addHashClassToElement(element, hash, classAttrName) {
13463
- const openingElement = element.openingElement;
13464
- const attrs = openingElement?.attributes || [];
13465
- const index = attrs.findIndex(
13466
- (attr) =>
13467
- attr?.type === 'JSXAttribute' &&
13468
- attr.name?.type === 'JSXIdentifier' &&
13469
- (attr.name.name === 'class' || attr.name.name === 'className'),
13470
- );
13471
- let newAttrs;
13472
- if (index === -1) {
13473
- // A synthesized class attribute maps to the element's opening tag — the
13474
- // loc keeps generated positions inside the element for source maps, but
13475
- // there is no authored `class` here, so it is marked NOT AUTHORED. Left
13476
- // unmarked, inspection reports the whole opening tag as the origin of the
13477
- // scoped class, and a hover on any attribute in that tag (`onClick`, …)
13478
- // resolves to the class the compiler added.
13479
- newAttrs = [
13480
- ...attrs,
13481
- markSynthesizedAttr(
13482
- inheritOriginLoc(
13483
- b.jsx_attribute(b.jsx_id(classAttrName), b.literal(hash, JSON.stringify(hash))),
13484
- openingElement,
13485
- ),
13486
- ),
13487
- ];
13488
- } else {
13489
- const existing = attrs[index];
13490
- const value = existing.value;
13491
- let newAttr;
13492
- if (!value) {
13493
- // The NAME is authored (`class`), the value is not.
13494
- newAttr = {
13495
- ...existing,
13496
- value: markSynthesized(inheritOriginLoc(b.literal(hash, JSON.stringify(hash)), existing)),
13497
- };
13498
- } else if (value.type === 'Literal' && typeof value.value === 'string') {
13499
- const merged = `${value.value} ${hash}`;
13500
- newAttr = { ...existing, value: { ...value, value: merged, raw: JSON.stringify(merged) } };
13501
- } else {
13502
- const expression = value.type === 'JSXExpressionContainer' ? value.expression : value;
13503
- // The template-literal bake maps to the authored class value it wraps.
13504
- newAttr = {
13505
- ...existing,
13506
- value: inheritOriginLoc(
13507
- b.jsx_expression_container(
13508
- b.template([b.quasi('', false), b.quasi(` ${hash}`, true)], [expression]),
13509
- ),
13510
- value,
13511
- ),
13512
- };
13513
- }
13514
- newAttrs = attrs.slice();
13515
- newAttrs[index] = newAttr;
13516
- }
13517
- // The core helper mirrors the attribute list onto `element.attributes` in
13518
- // every branch; keep that alias in sync on the rebuilt copy.
13519
- return {
13520
- ...element,
13521
- openingElement: { ...openingElement, attributes: newAttrs },
13522
- attributes: newAttrs,
13523
- };
13524
- }
13525
-
13526
- function annotateRootWithHash(node, hash, classAttrName) {
13527
- if (!node || typeof node !== 'object') return node;
13528
- if (
13529
- (node.type === 'FunctionDeclaration' ||
13530
- node.type === 'FunctionExpression' ||
13531
- node.type === 'ArrowFunctionExpression') &&
13532
- node.metadata?.tsrx_dynamic_wrapper !== true
13533
- ) {
13534
- return node;
13535
- }
13536
- if (node.type === 'JSXElement') {
13537
- let working = node;
13538
- if (!isCompositeJsxTag(node) || node.metadata?.dynamicElement) {
13539
- working = addHashClassToElement(node, hash, classAttrName);
13540
- }
13541
- const children = working.children;
13542
- if (Array.isArray(children)) {
13543
- let newChildren = null;
13544
- for (let i = 0; i < children.length; i++) {
13545
- const mapped = annotateRootWithHash(children[i], hash, classAttrName);
13546
- // Dropped style elements are filtered out (core: .filter(Boolean)).
13547
- if (newChildren === null && (mapped !== children[i] || !mapped)) {
13548
- newChildren = children.slice(0, i);
13549
- }
13550
- if (newChildren !== null && mapped) newChildren.push(mapped);
13551
- }
13552
- if (newChildren !== null) {
13553
- if (working === node) working = { ...node, children: newChildren };
13554
- else working.children = newChildren;
13555
- }
13556
- }
13557
- return working;
13558
- }
13559
- // Scoped-CSS styles leave the render tree here; Float style resources stay —
13560
- // the HeadHoist partition (normalizeChildren) owns removing them from body DOM.
13561
- if (node.type === 'JSXStyleElement') return headResourceKind(node) === 'style' ? node : null;
13562
- let out = null;
13563
- for (const key of Object.keys(node)) {
13564
- if (key === 'loc' || key === 'start' || key === 'end' || key === 'metadata' || key === 'css') {
13565
- continue;
13566
- }
13567
- const value = node[key];
13568
- if (!value || typeof value !== 'object') continue;
13569
- let mapped;
13570
- if (Array.isArray(value)) {
13571
- // Core maps generic arrays WITHOUT filtering — a dropped style element
13572
- // leaves a null slot the downstream child normalization skips.
13573
- let newArray = null;
13574
- for (let i = 0; i < value.length; i++) {
13575
- const item = annotateRootWithHash(value[i], hash, classAttrName);
13576
- if (newArray === null && item !== value[i]) newArray = value.slice(0, i);
13577
- if (newArray !== null) newArray.push(item);
13578
- }
13579
- mapped = newArray ?? value;
13580
- } else {
13581
- mapped = annotateRootWithHash(value, hash, classAttrName);
13582
- }
13583
- if (mapped !== value) {
13584
- if (out === null) out = { ...node };
13585
- out[key] = mapped;
13586
- }
13587
- }
13588
- return out ?? node;
13589
- }
13590
-
13591
- /**
13592
- * Find the JSX render roots owned by a component. `@{}` components expose one
13593
- * `JSXCodeBlock.render`; React-style functions can return JSX from any block in
13594
- * their own body. Nested functions are separate component/value boundaries and
13595
- * must not donate styles to their enclosing component.
13596
- */
13597
- function componentStyleRoots(componentNode) {
13598
- const body = componentNode.body;
13599
- if (!body) return [];
13600
- if (body.type === 'JSXCodeBlock') {
13601
- return body.render ? [{ kind: 'render', node: body.render }] : [];
13602
- }
13603
- if (body.type !== 'BlockStatement') return [];
13604
-
13605
- const roots = [];
13606
- function visit(node) {
13607
- if (!node || typeof node !== 'object') return;
13608
- if (Array.isArray(node)) {
13609
- for (const child of node) visit(child);
13610
- return;
13611
- }
13612
- if (
13613
- node.type === 'FunctionDeclaration' ||
13614
- node.type === 'FunctionExpression' ||
13615
- node.type === 'ArrowFunctionExpression'
13616
- ) {
13617
- return;
13618
- }
13619
- if (node.type === 'ReturnStatement') {
13620
- if (node.argument && isJsxNode(node.argument)) {
13621
- roots.push({ kind: 'return', statement: node, node: node.argument });
13622
- }
13623
- return;
13624
- }
13625
- for (const key of Object.keys(node)) {
13626
- if (
13627
- key === 'loc' ||
13628
- key === 'start' ||
13629
- key === 'end' ||
13630
- key === 'parent' ||
13631
- key === 'metadata' ||
13632
- key === 'css'
13633
- )
13634
- continue;
13635
- visit(node[key]);
13636
- }
13637
- }
13638
- visit(body);
13639
- return roots;
13640
- }
13641
-
13642
- /**
13643
- * Walk a component's owned render roots for `JSXStyleElement` nodes. For each
13644
- * one found:
13645
- * - Pull the pre-parsed `StyleSheet` AST out of its children.
13646
- * - Run `prepareStylesheetForRender` (rewrites `.foo` → `.foo.<hash>`) over a
13647
- * clone of the sheet — the core pipeline mutates what it is given.
13648
- * - Collect into a list rendered via `renderStylesheets` to a CSS string.
13649
- * - Register `{hash, css}` on `ctx.cssInjections` so a module-level
13650
- * `injectStyle(hash, css)` is emitted in the prelude.
13651
- * - Rebuild the render roots via `annotateRootWithHash` (COW) to stamp the
13652
- * hash class on every native JSX element AND remove the JSXStyleElement
13653
- * nodes from the rendered tree (they don't contribute DOM in the new model).
13654
- *
13655
- * Returns `{ cssHash, node }` — the hash (or `null` when no `<style>` blocks
13656
- * are present) and the possibly-rebuilt component node the caller must use.
13657
- *
13658
- * The first `JSXStyleElement` contributes the canonical hash for the whole
13659
- * component. @tsrx/core hashes individual style tags by source position, so
13660
- * multiple blocks are explicitly rebased onto that canonical component hash
13661
- * before selector rendering. Every rendered root receives the same hash class.
13662
- */
13663
- function applyCssScoping(componentNode, ctx) {
13664
- const roots = componentStyleRoots(componentNode);
13665
- if (roots.length === 0) return { cssHash: null, node: componentNode };
13666
- let cssHash = null;
13667
- const styles = [];
13668
- function collect(node) {
13669
- if (!node || typeof node !== 'object') return;
13670
- if (Array.isArray(node)) {
13671
- for (const i of node) collect(i);
13672
- return;
13673
- }
13674
- if (
13675
- node.type === 'FunctionDeclaration' ||
13676
- node.type === 'FunctionExpression' ||
13677
- node.type === 'ArrowFunctionExpression'
13678
- ) {
13679
- return;
13680
- }
13681
- if (node.type === 'JSXStyleElement') {
13682
- // A Float style resource ships its plain CSS by href identity — it
13683
- // neither contributes to the component's scope hash nor gets scoped.
13684
- if (headResourceKind(node) === 'style') return;
13685
- const sheet = (node.children || []).find((c) => c && c.type === 'StyleSheet');
13686
- if (sheet) {
13687
- styles.push({ node, sheet });
13688
- if (!cssHash) cssHash = node.metadata?.styleScopeHash || sheet.hash || null;
13689
- }
13690
- return;
13691
- }
13692
- for (const key of Object.keys(node)) {
13693
- if (
13694
- key === 'loc' ||
13695
- key === 'start' ||
13696
- key === 'end' ||
13697
- key === 'parent' ||
13698
- key === 'metadata' ||
13699
- key === 'css'
13700
- )
13701
- continue;
13702
- const v = node[key];
13703
- if (v && typeof v === 'object') collect(v);
13704
- }
13705
- }
13706
- for (const root of roots) collect(root.node);
13707
- if (!cssHash || styles.length === 0) return { cssHash: null, node: componentNode };
13708
- // A component has one scope even when its CSS is split for readability.
13709
- // Rebase before analyze/render: selector and keyframe rewriting read
13710
- // `sheet.hash`, while DOM annotation below uses `cssHash`. The core
13711
- // analyze/prepare pipeline mutates the sheet it is given, so it runs over a
13712
- // clone of each (bounded) StyleSheet subtree — the parser-owned sheet nodes
13713
- // stay pristine.
13714
- const preparedSheets = styles.map((style) => {
13715
- const sheet = cloneAstNode(style.sheet);
13716
- sheet.hash = cssHash;
13717
- // Mark `:global(...)` selectors before scoping so they render unscoped.
13718
- analyzeCss(sheet);
13719
- prepareStylesheetForRender(sheet);
13720
- return sheet;
13721
- });
13722
- const css = renderStylesheets(preparedSheets);
13723
- ctx.cssInjections.push({
13724
- hash: cssHash,
13725
- css,
13726
- order: styles[0]?.sheet.start ?? styles[0]?.node.start ?? componentNode.start ?? 0,
13727
- // The authored `<style>` element(s) this stylesheet came from.
13728
- // `injectStyle` is emitted at module scope, so without an origin the
13729
- // block maps to the module and neither the tag nor its CSS is reachable.
13730
- origins: styles.map((style) => style.node).filter(Boolean),
13731
- });
13732
- ctx.runtimeNeeded.add('injectStyle');
13733
- // Rebuild every owned render root copy-on-write: add the canonical hash
13734
- // class to native elements and strip JSXStyleElement nodes from DOM output.
13735
- // The rebuilt roots are grafted back through a spine rebuild of the
13736
- // component node; the received component tree is never modified.
13737
- let node = componentNode;
13738
- const returnReplacements = new Map();
13739
- for (const root of roots) {
13740
- // Normalize dynamic class exprs BEFORE the hash is appended (see helper), so
13741
- // clsx array/object values compose correctly alongside the scope hash.
13742
- const wrapped = wrapScopedClassExprs(root.node, ctx);
13743
- const annotated = annotateRootWithHash(wrapped, cssHash, 'class');
13744
- if (annotated === root.node) continue;
13745
- if (root.kind === 'render') {
13746
- node = { ...node, body: { ...node.body, render: annotated } };
13747
- } else {
13748
- returnReplacements.set(root.statement, { ...root.statement, argument: annotated });
13749
- }
13750
- }
13751
- if (returnReplacements.size > 0) {
13752
- node = mapAst(node, (n) => returnReplacements.get(n) ?? null);
13753
- }
13754
- return { cssHash, node };
13755
- }
13756
-
13757
13427
  // Strip comments and plain string literals so prose or route paths cannot
13758
13428
  // register as a component reference during the early-reference scan below.
13759
13429
  // Template literals are deliberately LEFT intact: `${Name}` interpolations are
@@ -13983,14 +13653,15 @@ function compileComponent(node, ctx, options) {
13983
13653
  : null;
13984
13654
  const firstStyle = ctx.cssInjections.length;
13985
13655
 
13986
- // Scoped `<style>` block. New TSRX surfaces each style block as a
13987
- // `JSXStyleElement` child of the rendered tree (parser pre-computes the
13988
- // content hash + parses CSS into a StyleSheet AST). Collect them, run the
13989
- // @tsrx/core scoping pipeline (rewrites `.foo` → `.foo.<hash>` AND stamps
13990
- // the hash class onto every element under this component), emit a single
13991
- // module-level `injectStyle(hash, css)`, and surface `cssHash` so
13992
- // resolveStyleExpr can also prefix any `{style (expr)}` class expressions.
13993
- const scoping = applyCssScoping(node, ctx);
13656
+ // Scoped `<style>` blocks. TSRX surfaces each block as a `JSXStyleElement`
13657
+ // item of the list it is written in (parser pre-computes the content hash
13658
+ // + parses CSS into a StyleSheet AST). The pre-pass gives every list that
13659
+ // holds a block its own hash, rewrites `.foo` → `.foo.<hash>`, stamps the
13660
+ // hash on the list's other items and their descendants (never on the
13661
+ // element that contains the list), emits one `injectStyle(hash, css)` per
13662
+ // scope, and surfaces the first scope's hash as `cssHash` — kept only as a
13663
+ // "this component owns scoped CSS" flag for resolveStyleExpr's fallback.
13664
+ const scoping = applyStyleScopes(node, ctx);
13994
13665
  node = scoping.node;
13995
13666
  let cssHash = scoping.cssHash;
13996
13667
  // Backwards-compat: internal callers (legacy synthetic Component shapes)
@@ -17881,7 +17552,7 @@ function compileReturnJsxFunction(node, ctx, options) {
17881
17552
  const hookMemoOpaqueOwner = ctx.inlineHookMemo && hasInlineMemoOpaqueDirective(node);
17882
17553
  recordProfileComponent(ctx, node, name);
17883
17554
  const beforeCss = ctx.cssInjections.length;
17884
- const scoping = applyCssScoping(node, ctx);
17555
+ const scoping = applyStyleScopes(node, ctx);
17885
17556
  node = scoping.node;
17886
17557
  const cssHash = scoping.cssHash;
17887
17558
  const cssEntries =
@@ -18003,6 +17674,7 @@ function compileReturnJsxFunction(node, ctx, options) {
18003
17674
  compInlinedSubs,
18004
17675
  cssHash,
18005
17676
  cssEntries,
17677
+ scoping.runtimeApplied,
18006
17678
  ),
18007
17679
  };
18008
17680
  }
@@ -18082,6 +17754,10 @@ function compileReturnJsxFunction(node, ctx, options) {
18082
17754
  }),
18083
17755
  );
18084
17756
  }
17757
+ if (ctx.mode === 'server' && !ctx.nativeReads) {
17758
+ // Imported themes inject on read; touch them ahead of this body's own CSS.
17759
+ returnBody.unshift(...serverThemeTouchStatements(scoping.runtimeApplied, ctx, node));
17760
+ }
18085
17761
  const emittedFunction = b.function_declaration(
18086
17762
  node.id,
18087
17763
  node.params,
@@ -18125,7 +17801,15 @@ function compileReturnJsxFunction(node, ctx, options) {
18125
17801
  return { nodes: [fn] };
18126
17802
  }
18127
17803
 
18128
- function nativeReturnedJsxValue(node, ctx, name, inlinedSubs, cssHash, cssEntries) {
17804
+ function nativeReturnedJsxValue(
17805
+ node,
17806
+ ctx,
17807
+ name,
17808
+ inlinedSubs,
17809
+ cssHash,
17810
+ cssEntries,
17811
+ runtimeApplied = [],
17812
+ ) {
18129
17813
  let value;
18130
17814
  if (requiresTemplateNormalization(node, 'html', true, ctx)) {
18131
17815
  // Head resources, directive roots and other compiler-only syntax keep
@@ -18145,7 +17829,8 @@ function nativeReturnedJsxValue(node, ctx, name, inlinedSubs, cssHash, cssEntrie
18145
17829
  }
18146
17830
  // A stored styled value may be constructed outside an SSR render. Its CSS
18147
17831
  // enters the request collector when interpreted, not a global pending queue.
18148
- if (cssHash === null && !ctx.nativeModuleStyles) return value;
17832
+ const themeTouches = serverThemeTouchStatements(runtimeApplied, ctx, node);
17833
+ if (cssHash === null && !ctx.nativeModuleStyles && themeTouches.length === 0) return value;
18149
17834
  const scopedElement = runtimeAliasForContext(ctx, 'nativeCreateScopedElement');
18150
17835
  const scopedValue = runtimeAliasForContext(ctx, 'nativeCreateScopedValue');
18151
17836
  // A static styled fragment needs a reader too, even when it only owns a
@@ -18166,15 +17851,18 @@ function nativeReturnedJsxValue(node, ctx, name, inlinedSubs, cssHash, cssEntrie
18166
17851
  node,
18167
17852
  );
18168
17853
  }
18169
- if (cssEntries.length === 0) return value;
18170
- ctx.runtimeNeeded.add('injectStyle');
18171
- const css = cssEntries.map((entry) => {
18172
- const origin = claimCssOrigins(ctx, entry) ?? node;
18173
- return inheritOriginLoc(
18174
- b.stmt(ssrCall('injectStyle', [b.literal(entry.hash), b.literal(entry.css)], origin)),
18175
- origin,
18176
- );
18177
- });
17854
+ if (cssEntries.length === 0 && themeTouches.length === 0) return value;
17855
+ if (cssEntries.length > 0) ctx.runtimeNeeded.add('injectStyle');
17856
+ const css = [
17857
+ ...themeTouches,
17858
+ ...cssEntries.map((entry) => {
17859
+ const origin = claimCssOrigins(ctx, entry) ?? node;
17860
+ return inheritOriginLoc(
17861
+ b.stmt(ssrCall('injectStyle', [b.literal(entry.hash), b.literal(entry.css)], origin)),
17862
+ origin,
17863
+ );
17864
+ }),
17865
+ ];
18178
17866
  const readerIndex =
18179
17867
  value.callee?.name === scopedValue ? 0 : value.callee?.name === scopedElement ? 2 : -1;
18180
17868
  if (readerIndex === -1) {
@@ -18319,11 +18007,22 @@ function requiresTemplateNormalization(
18319
18007
  t === 'FragmentStart' ||
18320
18008
  t === 'FragmentEnd' ||
18321
18009
  t === 'FoldedDirective' ||
18322
- t === 'HeadHoist' ||
18323
- t === 'JSXCodeBlock'
18010
+ t === 'HeadHoist'
18324
18011
  ) {
18325
18012
  return true;
18326
18013
  }
18014
+ if (t === 'JSXCodeBlock') {
18015
+ // A render-only child block is transparent template grouping (the same
18016
+ // rule normalizeChildren and extractFragment apply): only its render
18017
+ // decides. A setup-bearing or code-only block is a render scope of its
18018
+ // own and needs the compiled-fragment path. A styled static fragment
18019
+ // therefore stays a descriptor when its block only groups the fragment
18020
+ // that holds the `<style>` — the form amendment A1 leaves a value factory
18021
+ // written in plain TSX (raw CSS needs a `@{ … }` container).
18022
+ const body = node.body || [];
18023
+ if (body.length > 0 || !node.render) return true;
18024
+ return requiresTemplateNormalization(node.render, parentNs, allowHeadHoists, ctx);
18025
+ }
18327
18026
  if (t === 'Fragment' || t === 'JSXFragment' || t === 'Tsx' || t === 'Tsrx') {
18328
18027
  return (node.children || []).some((child) =>
18329
18028
  requiresTemplateNormalization(child, parentNs, allowHeadHoists, ctx),
@@ -18626,6 +18325,15 @@ function extractFragment(node, ctx, holeProps, parentNs = 'html') {
18626
18325
  // `{/* … */}` is a `JSXEmptyExpression` container — a JSX comment, which
18627
18326
  // renders to nothing (React drops it). Skip it so it never becomes a hole.
18628
18327
  if (!expr || expr.type === 'JSXEmptyExpression') continue;
18328
+ // String literals (`{" "}`, `{"hello"}`) are static text, same as JSXText.
18329
+ // The .tsrx and server paths fold them via staticTextLiteral; hoisting one
18330
+ // as hN here made .tsx client templates grow an extra text hole while the
18331
+ // server baked the string, so hydration consumed the next sibling (duplicate
18332
+ // elements). Leave the container in place so emitElementHtml bakes it.
18333
+ if (staticTextLiteral(expr) !== null) {
18334
+ newChildren.push(child);
18335
+ continue;
18336
+ }
18629
18337
  const hn = `h${holeProps.length}`;
18630
18338
  if (expr && expr.type === 'TSAsExpression') {
18631
18339
  // Preserve the `as T` cast in the renderer (it marks a dynamic TEXT hole).
@@ -21327,7 +21035,7 @@ function normalizeChildren(
21327
21035
  continue;
21328
21036
  }
21329
21037
  // Drop a `<style>` block at child position — its CSS gets registered
21330
- // via the @tsrx/core scoping pipeline (applyCssScoping / applyStyleMap);
21038
+ // via the style-scope pre-pass (applyStyleScopes, ./style-scopes.js);
21331
21039
  // it contributes no DOM here.
21332
21040
  continue;
21333
21041
  } else if (isWrappedJsxDirective(n)) {
@@ -24116,7 +23824,7 @@ function cleanupsPush(fnNode) {
24116
23824
  }
24117
23825
 
24118
23826
  /**
24119
- * The `[spread, name, value]` rows a commit-phase source collector
23827
+ * The `[spread, name, value, merge?]` rows a commit-phase source collector
24120
23828
  * (`setHostPropSources` / `setFormControlSources`) resolves in authored order.
24121
23829
  * Shared by the mount and update emits, which differ only in how a source's
24122
23830
  * value is read back — a bag local at mount, a bag field on update.
@@ -24126,20 +23834,26 @@ function cleanupsPush(fnNode) {
24126
23834
  * split ssrDirectSource makes on the server); the collector itself serializes
24127
23835
  * every source of the element and stays anchored there. Nothing else in a row
24128
23836
  * reproduces the authored name — the value is the prop's expression — so
24129
- * without this the whole group answered only for the element.
23837
+ * without this the whole group answered only for the element. A trailing `true`
23838
+ * marks a synthesized scope class that must merge with a spread's class.
24130
23839
  */
24131
23840
  function commitSourceRows(sources, valueOf) {
24132
- return b.array(
24133
- sources.map(({ spread, name, nameOrigin, binding }) =>
24134
- spread
24135
- ? b.array([b.literal(true), valueOf(binding, true)])
24136
- : b.array([
24137
- b.literal(false),
24138
- inheritOriginLoc(b.literal(name), nameOrigin ?? null),
24139
- valueOf(binding, false),
24140
- ]),
24141
- ),
24142
- );
23841
+ const rows = [];
23842
+ for (let i = 0; i < sources.length; i++) {
23843
+ const source = sources[i];
23844
+ if (source.spread) {
23845
+ rows.push(b.array([b.literal(true), valueOf(source.binding, true)]));
23846
+ continue;
23847
+ }
23848
+ const cells = [
23849
+ b.literal(false),
23850
+ inheritOriginLoc(b.literal(source.name), source.nameOrigin ?? null),
23851
+ valueOf(source.binding, false),
23852
+ ];
23853
+ if (source.merge) cells.push(b.literal(true));
23854
+ rows.push(b.array(cells));
23855
+ }
23856
+ return b.array(rows);
24143
23857
  }
24144
23858
 
24145
23859
  // Mount for a DEFERRED property-write binding: store the element ref + seed the
@@ -25630,6 +25344,7 @@ function emitElementHtml(
25630
25344
  name: rawAttrName,
25631
25345
  nameOrigin: attr.name,
25632
25346
  binding,
25347
+ merge: isSynthesizedClassAttr(attr),
25633
25348
  });
25634
25349
  continue;
25635
25350
  }
@@ -27221,6 +26936,21 @@ function tagBindingName(node) {
27221
26936
  return name.type === 'Identifier' || name.type === 'JSXIdentifier' ? name.name : null;
27222
26937
  }
27223
26938
 
26939
+ // A marked module binding can be shadowed by a parameter, block, or directive
26940
+ // item. Keep the name-only fast path for all other tags; a candidate needs the
26941
+ // same authored-scope proof used for Activity, shared by client and SSR emits.
26942
+ function isDescriptorChildrenTag(node, ctx) {
26943
+ const name = node.openingElement?.name || node.id;
26944
+ if (
26945
+ (name?.type !== 'Identifier' && name?.type !== 'JSXIdentifier') ||
26946
+ !ctx.descriptorChildrenBindings?.has(name.name)
26947
+ ) {
26948
+ return false;
26949
+ }
26950
+ const binding = activityReferenceBinding(name, node, ctx);
26951
+ return binding?.scope === ctx.activityLexical?.rootScope;
26952
+ }
26953
+
27224
26954
  // React-style render-prop detection: if a component's children are exactly one
27225
26955
  // `{fn}` expression hole whose expression is a function (arrow or function
27226
26956
  // expression) — `<Comp>{(data) => <jsx/>}</Comp>` — return that function node so
@@ -27573,7 +27303,7 @@ function makeCompCall(
27573
27303
  const children = rewriteOpaqueTitles(sourceChildren, ctx, 'opaque');
27574
27304
  const childrenParentNs =
27575
27305
  !activityDescriptor && !isFragmentLongForm(node, ctx) ? 'opaque' : parentNs;
27576
- if (compName !== null && ctx.descriptorChildrenBindings?.has(compName)) {
27306
+ if (compName !== null && isDescriptorChildrenTag(node, ctx)) {
27577
27307
  const kids = children
27578
27308
  .map((child) => lowerInspectableJsxChild(child, ctx))
27579
27309
  .filter((child) => child != null);