octane 0.2.3 → 0.2.5

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.
Files changed (41) hide show
  1. package/dist/cjs/constants.cjs +9 -0
  2. package/dist/cjs/css.cjs +9 -0
  3. package/dist/cjs/dom-tables.cjs +6 -1
  4. package/dist/cjs/error-codes.client.generated.cjs +2 -0
  5. package/dist/cjs/host-property-diagnostics.cjs +2 -0
  6. package/dist/cjs/index.cjs +2 -0
  7. package/dist/cjs/runtime.cjs +604 -78
  8. package/dist/cjs/runtime.server.cjs +280 -68
  9. package/dist/cjs/server/index.cjs +4 -0
  10. package/dist/cjs/version.cjs +1 -1
  11. package/dist/compiler/bundler.js +14 -1
  12. package/dist/compiler/compile.js +270 -528
  13. package/dist/compiler/hook-methods.js +164 -0
  14. package/dist/compiler/hydrate-boundaries.js +165 -15
  15. package/dist/compiler/parser-output.js +37 -0
  16. package/dist/compiler/parser.browser.js +145 -1
  17. package/dist/compiler/parser.node.js +28 -36
  18. package/dist/compiler/plain-hook-memo.js +37 -0
  19. package/dist/compiler/slot-hooks.js +129 -5
  20. package/dist/compiler/strong-mode.js +836 -3
  21. package/dist/compiler/style-scopes.js +1134 -0
  22. package/dist/compiler/volar.LICENSES.txt +1 -1
  23. package/dist/compiler/volar.js +5781 -3934
  24. package/dist/constants.d.ts +3 -0
  25. package/dist/constants.js +6 -0
  26. package/dist/css.d.ts +7 -0
  27. package/dist/css.js +8 -0
  28. package/dist/dom-tables.js +6 -1
  29. package/dist/error-codes.client.generated.d.ts +1 -0
  30. package/dist/error-codes.client.generated.js +2 -0
  31. package/dist/host-property-diagnostics.js +2 -0
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.js +2 -0
  34. package/dist/runtime.d.ts +35 -15
  35. package/dist/runtime.js +612 -79
  36. package/dist/runtime.server.d.ts +30 -1
  37. package/dist/runtime.server.js +287 -69
  38. package/dist/server/index.d.ts +1 -1
  39. package/dist/server/index.js +4 -0
  40. package/dist/version.js +1 -1
  41. package/package.json +5 -5
@@ -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). */
@@ -7517,6 +7583,18 @@ function isTypeOnlyStatement(node) {
7517
7583
  }
7518
7584
  // `declare const/let/var/function/class/module/namespace …` — ambient, no emit.
7519
7585
  if (node.declare === true) return true;
7586
+ // Component/type declaration merging often uses a namespace containing only
7587
+ // types. Like an interface, it must disappear from the emitted JavaScript.
7588
+ // Keep value namespaces and Octane's `module server` dialect on their own paths.
7589
+ if (node.type === 'TSModuleDeclaration' && node.kind === 'namespace') {
7590
+ if (node.body?.type === 'TSModuleDeclaration') return isTypeOnlyStatement(node.body);
7591
+ if (node.body?.type === 'TSModuleBlock') {
7592
+ return node.body.body.every(
7593
+ (statement) =>
7594
+ isTypeOnlyStatement(statement) || stripTypeOnlySpecifiers(statement) === null,
7595
+ );
7596
+ }
7597
+ }
7520
7598
  // `import type { … } from …`
7521
7599
  if (node.type === 'ImportDeclaration' && node.importKind === 'type') return true;
7522
7600
  // `export type { … }`, `export type X = …`, `export interface I {}`
@@ -9814,16 +9892,11 @@ function compileInternal(
9814
9892
  // use, custom helpers, etc.) — merged into the single prelude import.
9815
9893
  addUserImportSpecifiers(ctx, node);
9816
9894
  } 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
- }
9895
+ // Style blocks anywhere in a non-component statement: assigned blocks
9896
+ // (`const x = <style>…</style>`, exported or nested in a helper) become
9897
+ // class-map objects and their sheets flow through cssInjections; scoped
9898
+ // blocks inside plain functions' templates get their scope hashes.
9899
+ node = applyStyleScopes(node, ctx).node;
9827
9900
  // HOOKS EVERYWHERE: a plain function (a custom hook, a helper) can hold
9828
9901
  // octane hooks too — slot them the same way components do, so their base
9829
9902
  // hooks get a per-call-site symbol (and custom-hook calls inside get one to
@@ -10392,11 +10465,18 @@ function compileServer(
10392
10465
  let newBody = null;
10393
10466
  const statements = ast.body;
10394
10467
  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
- }
10468
+ // Components lower their own scopes inside compileServerComponent so
10469
+ // only the CSS they own is emitted per render; every other statement's
10470
+ // blocks are module-level.
10471
+ const statement = statements[i];
10472
+ const declaration =
10473
+ statement.type === 'ExportDefaultDeclaration' || statement.type === 'ExportNamedDeclaration'
10474
+ ? statement.declaration
10475
+ : statement;
10476
+ const node =
10477
+ isComponentFunction(declaration) || isReturnJsxFunction(declaration)
10478
+ ? statement
10479
+ : applyStyleScopes(statement, ctx).node;
10400
10480
  if (newBody === null && node !== statements[i]) newBody = statements.slice(0, i);
10401
10481
  if (newBody !== null) newBody.push(node);
10402
10482
  }
@@ -10556,11 +10636,11 @@ function compileServerComponent(node, ctx) {
10556
10636
  const isExported = !!(node.export || node.default);
10557
10637
  const isDefault = !!node.default;
10558
10638
 
10559
- // Scoped <style>: applyCssScoping stamps hash classes + registers cssInjections.
10639
+ // Scoped <style>: applyStyleScopes stamps scope classes + registers cssInjections.
10560
10640
  // Capture this component's entries to emit injectStyle INSIDE the body (so the
10561
10641
  // active server render collects CSS only for components it actually renders).
10562
10642
  const beforeCss = ctx.cssInjections.length;
10563
- const scoping = applyCssScoping(node, ctx);
10643
+ const scoping = applyStyleScopes(node, ctx);
10564
10644
  node = scoping.node;
10565
10645
  const cssHash = scoping.cssHash;
10566
10646
  const cssEntries = [...ctx.moduleCssInjections, ...ctx.cssInjections.slice(beforeCss)].sort(
@@ -10588,6 +10668,12 @@ function compileServerComponent(node, ctx) {
10588
10668
  cssEntries,
10589
10669
  'opaque',
10590
10670
  node.body?.type === 'JSXCodeBlock',
10671
+ null,
10672
+ false,
10673
+ false,
10674
+ // Imported themes this component applies are touched at the top of
10675
+ // its body (serverThemeTouchStatements) so their CSS precedes its own.
10676
+ scoping.runtimeApplied,
10591
10677
  );
10592
10678
  } finally {
10593
10679
  ctx.currentComponentLocals = prevLocals;
@@ -10672,6 +10758,7 @@ function ssrCompileBody(
10672
10758
  componentNs = null,
10673
10759
  returnedFragmentTemplate = false,
10674
10760
  returnedFragmentRoot = false,
10761
+ themeTouches = [],
10675
10762
  unboxedHtml = false,
10676
10763
  ) {
10677
10764
  const prevMapTemps = ctx.currentMapTemps;
@@ -10688,6 +10775,7 @@ function ssrCompileBody(
10688
10775
  componentNs,
10689
10776
  returnedFragmentTemplate,
10690
10777
  returnedFragmentRoot,
10778
+ themeTouches,
10691
10779
  unboxedHtml,
10692
10780
  );
10693
10781
  } finally {
@@ -10706,6 +10794,9 @@ function ssrCompileBodyWithMapTemps(
10706
10794
  componentNs,
10707
10795
  returnedFragmentTemplate,
10708
10796
  returnedFragmentRoot,
10797
+ // Imported themes the component's scopes apply — only the component body
10798
+ // itself passes them; nested branch/loop helpers (ssrCompileSub) pass none.
10799
+ themeTouches,
10709
10800
  unboxedHtml,
10710
10801
  ) {
10711
10802
  const returnedOutput = node.body?.type === 'JSXCodeBlock' && hasOwnValueReturn(node);
@@ -10860,6 +10951,7 @@ function ssrCompileBodyWithMapTemps(
10860
10951
  ctx._valueDirectiveLowering = prevValueDirectiveLowering;
10861
10952
 
10862
10953
  const body = [];
10954
+ body.push(...serverThemeTouchStatements(themeTouches ?? [], ctx, node));
10863
10955
  if (cssEntries && cssEntries.length) {
10864
10956
  ctx.runtimeNeeded.add('injectStyle');
10865
10957
  for (const entry of cssEntries) {
@@ -11084,6 +11176,23 @@ function ssrCall(helper, args, origin) {
11084
11176
  );
11085
11177
  }
11086
11178
 
11179
+ /**
11180
+ * `_$touchStyleMap(theme)` for every imported theme a server component's
11181
+ * scopes apply, emitted before the component's own `injectStyle` calls so the
11182
+ * theme's CSS (a lazily injecting `_$styleMap`) lands first in the request
11183
+ * collector and the applying scope's rules win the cascade.
11184
+ */
11185
+ function serverThemeTouchStatements(runtimeApplied, ctx, origin) {
11186
+ if (!runtimeApplied || runtimeApplied.length === 0) return [];
11187
+ ctx.runtimeNeeded.add('touchStyleMap');
11188
+ return runtimeApplied.map((expression) =>
11189
+ inheritOriginLoc(
11190
+ b.stmt(ssrCall('touchStyleMap', [cloneAstNode(expression)], expression)),
11191
+ origin,
11192
+ ),
11193
+ );
11194
+ }
11195
+
11087
11196
  function ssrThunk(expression, origin) {
11088
11197
  return inheritOriginLoc(b.arrow([], expression), origin);
11089
11198
  }
@@ -11097,17 +11206,17 @@ function ssrSourcePair(present, value, origin) {
11097
11206
  }
11098
11207
 
11099
11208
  /**
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.
11209
+ * One `[spread, name, value, merge?]` source row for a grouped attribute helper.
11210
+ * The name literal is the only token in the group that names THIS attribute, so
11211
+ * it is what the authored name is claimed against (see registerAttrLoweringOrigin);
11212
+ * the helper itself stays anchored on the element it serializes. A trailing
11213
+ * `true` marks a synthesized scope class that must merge with a spread's class.
11104
11214
  */
11105
- function ssrDirectSource(ctx, name, value, origin) {
11215
+ function ssrDirectSource(ctx, name, value, origin, merge) {
11106
11216
  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
- );
11217
+ const cells = [b.literal(false, 'false'), b.literal(name, JSON.stringify(name)), value];
11218
+ if (merge) cells.push(b.literal(true, 'true'));
11219
+ return inheritOriginLoc(b.array(cells), origin);
11111
11220
  }
11112
11221
 
11113
11222
  /**
@@ -11866,7 +11975,15 @@ function ssrEmitElement(node, ctx, name, inlinedSubs, parentNs, cssHash, compone
11866
11975
  );
11867
11976
  attrExpr = tsrxExprNode(attrInner, ctx, name, inlinedSubs);
11868
11977
  }
11869
- attrSources.push(ssrDirectSource(ctx, rawAttrName, bindAttributeEvaluation(attrExpr), attr));
11978
+ attrSources.push(
11979
+ ssrDirectSource(
11980
+ ctx,
11981
+ rawAttrName,
11982
+ bindAttributeEvaluation(attrExpr),
11983
+ attr,
11984
+ isSynthesizedClassAttr(attr),
11985
+ ),
11986
+ );
11870
11987
  continue;
11871
11988
  }
11872
11989
 
@@ -12453,8 +12570,7 @@ function ssrEmitComponent(node, ctx, name, inlinedSubs, parentNs, cssHash, compo
12453
12570
  const children = rewriteOpaqueTitles(sourceChildren, ctx, 'opaque');
12454
12571
  const opaqueChildren = !activityDescriptor && !isFragmentLongForm(node, ctx);
12455
12572
  const descriptorChildren =
12456
- (tagBindingName(node) !== null &&
12457
- ctx.descriptorChildrenBindings?.has(tagBindingName(node))) ||
12573
+ isDescriptorChildrenTag(node, ctx) ||
12458
12574
  ctx._tsxValuePos ||
12459
12575
  (returnedFragmentTemplate && !requiresTemplateNormalization(node, parentNs, true, ctx));
12460
12576
  if (descriptorChildren) {
@@ -12674,6 +12790,8 @@ function ssrCompileSub(
12674
12790
  componentNs,
12675
12791
  returnedFragmentTemplate,
12676
12792
  returnedFragmentRoot,
12793
+ // Nested helpers touch no themes; the component body already did.
12794
+ [],
12677
12795
  unboxedHtml,
12678
12796
  );
12679
12797
  } finally {
@@ -13304,147 +13422,6 @@ function hasModuleStyleMaps(body) {
13304
13422
  });
13305
13423
  }
13306
13424
 
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
13425
  // Local copy-on-write mirror of @tsrx/core's annotate_with_hash (scoping.js):
13449
13426
  // stamps the scoped-CSS hash class onto native (and dynamic-tag) JSX elements
13450
13427
  // and drops JSXStyleElement nodes from the rendered tree. The core helper
@@ -13459,301 +13436,6 @@ function isCompositeJsxTag(node) {
13459
13436
  return name.type === 'JSXMemberExpression';
13460
13437
  }
13461
13438
 
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
13439
  // Strip comments and plain string literals so prose or route paths cannot
13758
13440
  // register as a component reference during the early-reference scan below.
13759
13441
  // Template literals are deliberately LEFT intact: `${Name}` interpolations are
@@ -13983,14 +13665,15 @@ function compileComponent(node, ctx, options) {
13983
13665
  : null;
13984
13666
  const firstStyle = ctx.cssInjections.length;
13985
13667
 
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);
13668
+ // Scoped `<style>` blocks. TSRX surfaces each block as a `JSXStyleElement`
13669
+ // item of the list it is written in (parser pre-computes the content hash
13670
+ // + parses CSS into a StyleSheet AST). The pre-pass gives every list that
13671
+ // holds a block its own hash, rewrites `.foo` → `.foo.<hash>`, stamps the
13672
+ // hash on the list's other items and their descendants (never on the
13673
+ // element that contains the list), emits one `injectStyle(hash, css)` per
13674
+ // scope, and surfaces the first scope's hash as `cssHash` — kept only as a
13675
+ // "this component owns scoped CSS" flag for resolveStyleExpr's fallback.
13676
+ const scoping = applyStyleScopes(node, ctx);
13994
13677
  node = scoping.node;
13995
13678
  let cssHash = scoping.cssHash;
13996
13679
  // Backwards-compat: internal callers (legacy synthetic Component shapes)
@@ -17881,7 +17564,7 @@ function compileReturnJsxFunction(node, ctx, options) {
17881
17564
  const hookMemoOpaqueOwner = ctx.inlineHookMemo && hasInlineMemoOpaqueDirective(node);
17882
17565
  recordProfileComponent(ctx, node, name);
17883
17566
  const beforeCss = ctx.cssInjections.length;
17884
- const scoping = applyCssScoping(node, ctx);
17567
+ const scoping = applyStyleScopes(node, ctx);
17885
17568
  node = scoping.node;
17886
17569
  const cssHash = scoping.cssHash;
17887
17570
  const cssEntries =
@@ -18003,6 +17686,7 @@ function compileReturnJsxFunction(node, ctx, options) {
18003
17686
  compInlinedSubs,
18004
17687
  cssHash,
18005
17688
  cssEntries,
17689
+ scoping.runtimeApplied,
18006
17690
  ),
18007
17691
  };
18008
17692
  }
@@ -18082,6 +17766,10 @@ function compileReturnJsxFunction(node, ctx, options) {
18082
17766
  }),
18083
17767
  );
18084
17768
  }
17769
+ if (ctx.mode === 'server' && !ctx.nativeReads) {
17770
+ // Imported themes inject on read; touch them ahead of this body's own CSS.
17771
+ returnBody.unshift(...serverThemeTouchStatements(scoping.runtimeApplied, ctx, node));
17772
+ }
18085
17773
  const emittedFunction = b.function_declaration(
18086
17774
  node.id,
18087
17775
  node.params,
@@ -18125,7 +17813,15 @@ function compileReturnJsxFunction(node, ctx, options) {
18125
17813
  return { nodes: [fn] };
18126
17814
  }
18127
17815
 
18128
- function nativeReturnedJsxValue(node, ctx, name, inlinedSubs, cssHash, cssEntries) {
17816
+ function nativeReturnedJsxValue(
17817
+ node,
17818
+ ctx,
17819
+ name,
17820
+ inlinedSubs,
17821
+ cssHash,
17822
+ cssEntries,
17823
+ runtimeApplied = [],
17824
+ ) {
18129
17825
  let value;
18130
17826
  if (requiresTemplateNormalization(node, 'html', true, ctx)) {
18131
17827
  // Head resources, directive roots and other compiler-only syntax keep
@@ -18145,7 +17841,8 @@ function nativeReturnedJsxValue(node, ctx, name, inlinedSubs, cssHash, cssEntrie
18145
17841
  }
18146
17842
  // A stored styled value may be constructed outside an SSR render. Its CSS
18147
17843
  // enters the request collector when interpreted, not a global pending queue.
18148
- if (cssHash === null && !ctx.nativeModuleStyles) return value;
17844
+ const themeTouches = serverThemeTouchStatements(runtimeApplied, ctx, node);
17845
+ if (cssHash === null && !ctx.nativeModuleStyles && themeTouches.length === 0) return value;
18149
17846
  const scopedElement = runtimeAliasForContext(ctx, 'nativeCreateScopedElement');
18150
17847
  const scopedValue = runtimeAliasForContext(ctx, 'nativeCreateScopedValue');
18151
17848
  // A static styled fragment needs a reader too, even when it only owns a
@@ -18166,15 +17863,18 @@ function nativeReturnedJsxValue(node, ctx, name, inlinedSubs, cssHash, cssEntrie
18166
17863
  node,
18167
17864
  );
18168
17865
  }
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
- });
17866
+ if (cssEntries.length === 0 && themeTouches.length === 0) return value;
17867
+ if (cssEntries.length > 0) ctx.runtimeNeeded.add('injectStyle');
17868
+ const css = [
17869
+ ...themeTouches,
17870
+ ...cssEntries.map((entry) => {
17871
+ const origin = claimCssOrigins(ctx, entry) ?? node;
17872
+ return inheritOriginLoc(
17873
+ b.stmt(ssrCall('injectStyle', [b.literal(entry.hash), b.literal(entry.css)], origin)),
17874
+ origin,
17875
+ );
17876
+ }),
17877
+ ];
18178
17878
  const readerIndex =
18179
17879
  value.callee?.name === scopedValue ? 0 : value.callee?.name === scopedElement ? 2 : -1;
18180
17880
  if (readerIndex === -1) {
@@ -18319,11 +18019,22 @@ function requiresTemplateNormalization(
18319
18019
  t === 'FragmentStart' ||
18320
18020
  t === 'FragmentEnd' ||
18321
18021
  t === 'FoldedDirective' ||
18322
- t === 'HeadHoist' ||
18323
- t === 'JSXCodeBlock'
18022
+ t === 'HeadHoist'
18324
18023
  ) {
18325
18024
  return true;
18326
18025
  }
18026
+ if (t === 'JSXCodeBlock') {
18027
+ // A render-only child block is transparent template grouping (the same
18028
+ // rule normalizeChildren and extractFragment apply): only its render
18029
+ // decides. A setup-bearing or code-only block is a render scope of its
18030
+ // own and needs the compiled-fragment path. A styled static fragment
18031
+ // therefore stays a descriptor when its block only groups the fragment
18032
+ // that holds the `<style>` — the form amendment A1 leaves a value factory
18033
+ // written in plain TSX (raw CSS needs a `@{ … }` container).
18034
+ const body = node.body || [];
18035
+ if (body.length > 0 || !node.render) return true;
18036
+ return requiresTemplateNormalization(node.render, parentNs, allowHeadHoists, ctx);
18037
+ }
18327
18038
  if (t === 'Fragment' || t === 'JSXFragment' || t === 'Tsx' || t === 'Tsrx') {
18328
18039
  return (node.children || []).some((child) =>
18329
18040
  requiresTemplateNormalization(child, parentNs, allowHeadHoists, ctx),
@@ -18626,6 +18337,15 @@ function extractFragment(node, ctx, holeProps, parentNs = 'html') {
18626
18337
  // `{/* … */}` is a `JSXEmptyExpression` container — a JSX comment, which
18627
18338
  // renders to nothing (React drops it). Skip it so it never becomes a hole.
18628
18339
  if (!expr || expr.type === 'JSXEmptyExpression') continue;
18340
+ // String literals (`{" "}`, `{"hello"}`) are static text, same as JSXText.
18341
+ // The .tsrx and server paths fold them via staticTextLiteral; hoisting one
18342
+ // as hN here made .tsx client templates grow an extra text hole while the
18343
+ // server baked the string, so hydration consumed the next sibling (duplicate
18344
+ // elements). Leave the container in place so emitElementHtml bakes it.
18345
+ if (staticTextLiteral(expr) !== null) {
18346
+ newChildren.push(child);
18347
+ continue;
18348
+ }
18629
18349
  const hn = `h${holeProps.length}`;
18630
18350
  if (expr && expr.type === 'TSAsExpression') {
18631
18351
  // Preserve the `as T` cast in the renderer (it marks a dynamic TEXT hole).
@@ -21327,7 +21047,7 @@ function normalizeChildren(
21327
21047
  continue;
21328
21048
  }
21329
21049
  // Drop a `<style>` block at child position — its CSS gets registered
21330
- // via the @tsrx/core scoping pipeline (applyCssScoping / applyStyleMap);
21050
+ // via the style-scope pre-pass (applyStyleScopes, ./style-scopes.js);
21331
21051
  // it contributes no DOM here.
21332
21052
  continue;
21333
21053
  } else if (isWrappedJsxDirective(n)) {
@@ -24116,7 +23836,7 @@ function cleanupsPush(fnNode) {
24116
23836
  }
24117
23837
 
24118
23838
  /**
24119
- * The `[spread, name, value]` rows a commit-phase source collector
23839
+ * The `[spread, name, value, merge?]` rows a commit-phase source collector
24120
23840
  * (`setHostPropSources` / `setFormControlSources`) resolves in authored order.
24121
23841
  * Shared by the mount and update emits, which differ only in how a source's
24122
23842
  * value is read back — a bag local at mount, a bag field on update.
@@ -24126,20 +23846,26 @@ function cleanupsPush(fnNode) {
24126
23846
  * split ssrDirectSource makes on the server); the collector itself serializes
24127
23847
  * every source of the element and stays anchored there. Nothing else in a row
24128
23848
  * reproduces the authored name — the value is the prop's expression — so
24129
- * without this the whole group answered only for the element.
23849
+ * without this the whole group answered only for the element. A trailing `true`
23850
+ * marks a synthesized scope class that must merge with a spread's class.
24130
23851
  */
24131
23852
  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
- );
23853
+ const rows = [];
23854
+ for (let i = 0; i < sources.length; i++) {
23855
+ const source = sources[i];
23856
+ if (source.spread) {
23857
+ rows.push(b.array([b.literal(true), valueOf(source.binding, true)]));
23858
+ continue;
23859
+ }
23860
+ const cells = [
23861
+ b.literal(false),
23862
+ inheritOriginLoc(b.literal(source.name), source.nameOrigin ?? null),
23863
+ valueOf(source.binding, false),
23864
+ ];
23865
+ if (source.merge) cells.push(b.literal(true));
23866
+ rows.push(b.array(cells));
23867
+ }
23868
+ return b.array(rows);
24143
23869
  }
24144
23870
 
24145
23871
  // Mount for a DEFERRED property-write binding: store the element ref + seed the
@@ -25630,6 +25356,7 @@ function emitElementHtml(
25630
25356
  name: rawAttrName,
25631
25357
  nameOrigin: attr.name,
25632
25358
  binding,
25359
+ merge: isSynthesizedClassAttr(attr),
25633
25360
  });
25634
25361
  continue;
25635
25362
  }
@@ -27221,6 +26948,21 @@ function tagBindingName(node) {
27221
26948
  return name.type === 'Identifier' || name.type === 'JSXIdentifier' ? name.name : null;
27222
26949
  }
27223
26950
 
26951
+ // A marked module binding can be shadowed by a parameter, block, or directive
26952
+ // item. Keep the name-only fast path for all other tags; a candidate needs the
26953
+ // same authored-scope proof used for Activity, shared by client and SSR emits.
26954
+ function isDescriptorChildrenTag(node, ctx) {
26955
+ const name = node.openingElement?.name || node.id;
26956
+ if (
26957
+ (name?.type !== 'Identifier' && name?.type !== 'JSXIdentifier') ||
26958
+ !ctx.descriptorChildrenBindings?.has(name.name)
26959
+ ) {
26960
+ return false;
26961
+ }
26962
+ const binding = activityReferenceBinding(name, node, ctx);
26963
+ return binding?.scope === ctx.activityLexical?.rootScope;
26964
+ }
26965
+
27224
26966
  // React-style render-prop detection: if a component's children are exactly one
27225
26967
  // `{fn}` expression hole whose expression is a function (arrow or function
27226
26968
  // expression) — `<Comp>{(data) => <jsx/>}</Comp>` — return that function node so
@@ -27573,7 +27315,7 @@ function makeCompCall(
27573
27315
  const children = rewriteOpaqueTitles(sourceChildren, ctx, 'opaque');
27574
27316
  const childrenParentNs =
27575
27317
  !activityDescriptor && !isFragmentLongForm(node, ctx) ? 'opaque' : parentNs;
27576
- if (compName !== null && ctx.descriptorChildrenBindings?.has(compName)) {
27318
+ if (compName !== null && isDescriptorChildrenTag(node, ctx)) {
27577
27319
  const kids = children
27578
27320
  .map((child) => lowerInspectableJsxChild(child, ctx))
27579
27321
  .filter((child) => child != null);