octane 0.1.19 → 0.1.21

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.
@@ -31,6 +31,7 @@ import { findLeadingJsxImportSourcePragma } from './pragma.js';
31
31
  import { normalizeUniversalRuntime } from './universal-runtime.js';
32
32
  import { formatCompileDiagnostic } from './native-change-diagnostics.js';
33
33
  import { findVoidComponentImports, findVoidRootImports, slotHooks } from './slot-hooks.js';
34
+ import { assertStrongMode } from './strong-mode.js';
34
35
  import {
35
36
  assertNoLiveClientOnlyImports,
36
37
  createClientOnlyServerStub,
@@ -306,6 +307,9 @@ export function findVoidComponentExports(source, id) {
306
307
 
307
308
  class OctaneBundlerCompiler {
308
309
  constructor(options) {
310
+ if (options.strong !== undefined && typeof options.strong !== 'boolean') {
311
+ throw new TypeError('Octane compiler `strong` must be a boolean when provided.');
312
+ }
309
313
  this.root = nodePath.resolve(options.root ?? process.cwd());
310
314
  try {
311
315
  this.realRoot = nodeFs.realpathSync(this.root);
@@ -318,6 +322,7 @@ class OctaneBundlerCompiler {
318
322
  hmr: normalizeHmrDialect(options.hmr),
319
323
  dev: options.dev,
320
324
  profile: options.profile === true,
325
+ strong: options.strong === true,
321
326
  universalRuntime: normalizeUniversalRuntime(options.universalRuntime),
322
327
  };
323
328
  this.renderers = normalizeRendererConfig(options.renderers);
@@ -449,6 +454,22 @@ class OctaneBundlerCompiler {
449
454
  return isPathInside(this.root, absoluteFile) || isPathInside(this.realRoot, absoluteFile);
450
455
  }
451
456
 
457
+ _hasApplicationStrongPolicy(file, collected) {
458
+ if (!this._isProjectOwnedSource(file)) return false;
459
+ const absoluteFile = nodePath.isAbsolute(file)
460
+ ? nodePath.resolve(file)
461
+ : nodePath.resolve(this.root, file);
462
+ const application = this._nearestOctanePackageRule(this.root);
463
+ const source = this._nearestOctanePackageRule(nodePath.dirname(absoluteFile));
464
+ addMetadata(collected, application);
465
+ addMetadata(collected, source);
466
+ return (
467
+ application.rule === null ||
468
+ source.rule === null ||
469
+ application.rule.root === source.rule.root
470
+ );
471
+ }
472
+
452
473
  _isInstalledOctaneSource(file, collected) {
453
474
  // Project-owned TS/JS/TSX is always eligible. A linked package is commonly
454
475
  // handed to bundlers as its real path, without a node_modules segment, so
@@ -816,6 +837,13 @@ class OctaneBundlerCompiler {
816
837
  // of both HMR and dev hydration diagnostics. Server transforms stay byte-for-
817
838
  // byte identical even when a shared client/server bundler configuration opts in.
818
839
  const profile = environment === 'client' && (options.profile ?? this.defaults.profile) === true;
840
+ // An application's global policy never leaks into installed or linked
841
+ // compatibility packages, including workspace packages nested inside the
842
+ // project root. Modules may still opt themselves in with their own
843
+ // directive, which the authored-source compiler resolves separately.
844
+ const strong =
845
+ (options.strong ?? this.defaults.strong) === true &&
846
+ this._hasApplicationStrongPolicy(file, collected);
819
847
  const universalRuntime = normalizeUniversalRuntime(
820
848
  options.universalRuntime ?? this.defaults.universalRuntime,
821
849
  );
@@ -892,6 +920,7 @@ class OctaneBundlerCompiler {
892
920
  dev,
893
921
  profile,
894
922
  profileFilename,
923
+ ...(strong ? { strong: true } : null),
895
924
  ...(universalRuntime === undefined ? null : { universalRuntime }),
896
925
  // Keep the established DOM compiler call byte-for-byte equivalent. A
897
926
  // renderer descriptor is an orthogonal compiler input only for the
@@ -971,6 +1000,14 @@ class OctaneBundlerCompiler {
971
1000
  return this._passThrough(code, collected);
972
1001
  }
973
1002
  if (this._hasManualHookSlots(file, collected)) {
1003
+ // Hand-slotted bindings still own their authored policy. Opting one
1004
+ // module in must not require changing its established slot ABI.
1005
+ if (strong || code.includes('use strong')) {
1006
+ const authoredSource = code;
1007
+ assertStrongMode(parseModule(authoredSource, filename), authoredSource, filename, {
1008
+ strong,
1009
+ });
1010
+ }
974
1011
  return this._passThrough(code, collected);
975
1012
  }
976
1013
  const profileFilename = profile ? this._profileModuleId(file, collected) : undefined;
@@ -981,6 +1018,7 @@ class OctaneBundlerCompiler {
981
1018
  hmr: !!hmr,
982
1019
  profile,
983
1020
  profileFilename,
1021
+ ...(strong ? { strong: true } : null),
984
1022
  ...(specializeVoidRoot
985
1023
  ? {
986
1024
  isVoidComponentImport: options.isVoidComponentImport,
@@ -3141,7 +3141,17 @@ function hmrHandoffStatements(state, hot, origin) {
3141
3141
  for (const component of state.hmrComponents) {
3142
3142
  const componentOrigin = component.origin ?? origin;
3143
3143
  const existing = b.member(hmrComponentStore(hot, componentOrigin), component.name);
3144
- const test = b.logical('&&', hmrComponentStore(hot, componentOrigin), existing);
3144
+ // Webpack/rspack leave `hot.data` undefined until a previous instance of
3145
+ // the module has disposed, so first evaluation must guard the bag itself.
3146
+ const test = b.logical(
3147
+ '&&',
3148
+ b.logical(
3149
+ '&&',
3150
+ memberPath(hot, ['data'], componentOrigin),
3151
+ hmrComponentStore(hot, componentOrigin),
3152
+ ),
3153
+ existing,
3154
+ );
3145
3155
  const update = b.stmt(
3146
3156
  b.call(
3147
3157
  b.member(b.member(existing, b.id(state.helpers.hmrSymbol), true), 'update'),
@@ -56,6 +56,7 @@ import {
56
56
  import { assertNoLiveClientOnlyImports } from './client-only-server.js';
57
57
  import { nsForChildren, nsForSelf } from './jsx-namespace.js';
58
58
  import { analyzeNativeChangeDiagnostics } from './native-change-diagnostics.js';
59
+ import { assertStrongMode } from './strong-mode.js';
59
60
  import { assertUniversalRuntimeTarget, normalizeUniversalRuntime } from './universal-runtime.js';
60
61
 
61
62
  // DOM truth tables shared with the client/server runtimes (via constants.ts) —
@@ -3994,11 +3995,22 @@ function isConditionalJsx(node) {
3994
3995
  );
3995
3996
  }
3996
3997
 
3997
- /** Wrap an expression as a BlockStatement body, so makeIfCall can consume it. */
3998
+ /** Wrap a ternary arm as a BlockStatement body, so makeIfCall can consume it. */
3998
3999
  function wrapAsBlockStmt(node) {
3999
4000
  if (!node) return null;
4000
4001
  // null / Literal(null) / Literal(false) → no branch
4001
4002
  if (node.type === 'Literal' && (node.value === null || node.value === false)) return null;
4003
+ if (!isJsxLike(node)) {
4004
+ // A non-JSX arm (`{cond ? xs.map(…) : <Jsx/>}`, a string, a variable
4005
+ // holding an element, a nested ternary) is the branch's render VALUE. As
4006
+ // a bare statement the branch body would evaluate-and-discard it, so wrap
4007
+ // it as the authored-equivalent `<>{expr}</>` — the branch renders the
4008
+ // value through the fragment's child hole. The hole sits at the
4009
+ // fragment's ROOT, a value position on both compilers: a nested ternary
4010
+ // stays a value hole here (it is NOT re-claimed into a nested ifBlock),
4011
+ // which is why the server claim requires host-child position too.
4012
+ node = inheritOriginLoc(b.jsx_fragment([b.jsx_expression_container(node, node)]), node);
4013
+ }
4002
4014
  return b.block([node]);
4003
4015
  }
4004
4016
 
@@ -5653,7 +5665,7 @@ function instrumentProfileComponents(ast, ctx) {
5653
5665
  * Compile a .tsrx source string into JS targeting `octane`.
5654
5666
  * @param {string} source
5655
5667
  * @param {string} filename
5656
- * @param {{ hmr?: boolean | 'vite' | 'webpack', mode?: 'client' | 'server', dev?: boolean, profile?: boolean, profileFilename?: string, autoMemo?: boolean, inlineHookMemo?: boolean, dataCallbackHooks?: readonly string[], renderer?: { id: string, module: string, target: 'dom' | 'universal', server?: string }, rendererBoundaries?: Readonly<Record<string, Readonly<Record<string, { ownerRenderer: string, childRenderer: string, prop: string, server?: string }>>>>, rendererRegistry?: Readonly<Record<string, { module: string, target: 'dom' | 'universal', server?: string }>>, clientOnlyImports?: readonly unknown[], __hydratePrepared?: boolean, __hydrateBoundaryModule?: boolean, __nativeChangeDiagnostics?: readonly unknown[], __nativeChangeAnalysis?: { diagnostics: readonly unknown[], classifications: Map<number, string> } }} [options] —
5668
+ * @param {{ hmr?: boolean | 'vite' | 'webpack', mode?: 'client' | 'server', dev?: boolean, strong?: boolean, profile?: boolean, profileFilename?: string, autoMemo?: boolean, inlineHookMemo?: boolean, dataCallbackHooks?: readonly string[], renderer?: { id: string, module: string, target: 'dom' | 'universal', server?: string }, rendererBoundaries?: Readonly<Record<string, Readonly<Record<string, { ownerRenderer: string, childRenderer: string, prop: string, server?: string }>>>>, rendererRegistry?: Readonly<Record<string, { module: string, target: 'dom' | 'universal', server?: string }>>, clientOnlyImports?: readonly unknown[], __hydratePrepared?: boolean, __hydrateBoundaryModule?: boolean, __nativeChangeDiagnostics?: readonly unknown[], __nativeChangeAnalysis?: { diagnostics: readonly unknown[], classifications: Map<number, string> } }} [options] —
5657
5669
  * `dev: true` emits client hydration source-location metadata (per-component
5658
5670
  * `__s.locs`/`__s.locFile`) and, in server mode, source-located native-element
5659
5671
  * scopes for invalid HTML nesting diagnostics. Both are strictly gated so
@@ -5705,6 +5717,7 @@ function compileAuthored(source, filename, options, bundlerMetadata) {
5705
5717
  const analyzedAst = parseModule(source, cleanFilename);
5706
5718
  analyzeTsrx(analyzedAst, cleanFilename);
5707
5719
  adoptParserAst(analyzedAst);
5720
+ assertStrongMode(analyzedAst, source, cleanFilename, options);
5708
5721
  if (bundlerMetadata !== null) bundlerMetadata.hydrateAst = analyzedAst;
5709
5722
  return compileInternal(source, filename, options, analyzedAst, mode, bundlerMetadata);
5710
5723
  }
@@ -7480,7 +7493,15 @@ function ssrCompileBodyWithMapTemps(
7480
7493
  ((!!(node.body && node.body.type === 'JSXCodeBlock') && !returnedOutput) ||
7481
7494
  returnedFragmentRoot) &&
7482
7495
  inheritSoleCompRoot(bodyNodes, ctx);
7496
+ // Body/sub ROOTS (including fragment-root children) are not host-element
7497
+ // children: the client routes rich holes there through the de-opt value
7498
+ // path (a portal has no host to stamp — see the emitNodeHtml root branch),
7499
+ // so the ternary claim must stay off until an element's own children walk
7500
+ // (ssrEmitElement) turns the flag on.
7501
+ const prevHostChildPos = ctx._ssrHostChildPos;
7502
+ ctx._ssrHostChildPos = false;
7483
7503
  const htmlExpr = ssrEmitNodes(bodyNodes, ctx, name, inlinedSubs, parentNs, cssHash, componentNs);
7504
+ ctx._ssrHostChildPos = prevHostChildPos;
7484
7505
  ctx._ssrInheritRoot = prevInheritRoot;
7485
7506
  ctx._returnedFragmentTemplate = prevReturnedFragmentTemplate;
7486
7507
  ctx._tsxValuePos = prevValuePos;
@@ -8585,6 +8606,11 @@ function ssrEmitElement(node, ctx, name, inlinedSubs, parentNs, cssHash, compone
8585
8606
  // pre/textarea/listing: the parser eats a '\n' right after the opening tag —
8586
8607
  // the first text part must protect a leading newline (see ssrEmitNodes).
8587
8608
  const nlGuardFirst = tag === 'pre' || tag === 'textarea' || tag === 'listing';
8609
+ // These children sit directly under a host element — the one position
8610
+ // where the client claims `{cond ? A : B}` holes (emitElementHtml). Root
8611
+ // positions reset this in ssrCompileBody.
8612
+ const prevHostChildPos = ctx._ssrHostChildPos;
8613
+ ctx._ssrHostChildPos = true;
8588
8614
  childrenExpr = ssrEmitNodes(
8589
8615
  normChildren,
8590
8616
  ctx,
@@ -8595,6 +8621,7 @@ function ssrEmitElement(node, ctx, name, inlinedSubs, parentNs, cssHash, compone
8595
8621
  childComponentNs,
8596
8622
  nlGuardFirst,
8597
8623
  );
8624
+ ctx._ssrHostChildPos = prevHostChildPos;
8598
8625
  }
8599
8626
  // `children=` and spread-held children are content props, not attributes.
8600
8627
  // With no nested JSX children, the last present writer renders as the host's
@@ -8923,18 +8950,35 @@ function ssrCompileSub(
8923
8950
  ) {
8924
8951
  const fnName = `${baseName}$${ctx.nextHelperId++}`;
8925
8952
  const synth = { params: paramNodes || [], body: bodyStmts };
8926
- const fn = ssrCompileBody(
8927
- synth,
8928
- ctx,
8929
- fnName,
8930
- cssHash,
8931
- [],
8932
- parentNs || 'html',
8933
- false,
8934
- componentNs,
8935
- returnedFragmentTemplate,
8936
- returnedFragmentRoot,
8937
- );
8953
+ // Returned-tree mirror subs (`__sfragment`): extractFragment folds EVERY
8954
+ // expression hole in the mirrored tree into a descriptor value hole on the
8955
+ // client (`props.hN` + childTextHole), even holes sitting inside host
8956
+ // elements. Mark them so ssrEmitTsrxExpression keeps `{cond ? A : B}` on
8957
+ // ssrChild throughout the mirror, matching that fold. Directive-arm subs
8958
+ // reset the flag through this same save/restore, so an @if INSIDE a mirror
8959
+ // claims again, exactly like the client. (Component `__schildren` subs are
8960
+ // NOT folded wholesale — only their ROOT holes are the children-as-props
8961
+ // value, which `_ssrHostChildPos` already leaves unclaimed — while a hole
8962
+ // inside a host element within children claims like any template child.)
8963
+ const prevFoldedExprHoles = ctx._ssrFoldedExprHoles;
8964
+ ctx._ssrFoldedExprHoles = baseName === '__sfragment';
8965
+ let fn;
8966
+ try {
8967
+ fn = ssrCompileBody(
8968
+ synth,
8969
+ ctx,
8970
+ fnName,
8971
+ cssHash,
8972
+ [],
8973
+ parentNs || 'html',
8974
+ false,
8975
+ componentNs,
8976
+ returnedFragmentTemplate,
8977
+ returnedFragmentRoot,
8978
+ );
8979
+ } finally {
8980
+ ctx._ssrFoldedExprHoles = prevFoldedExprHoles;
8981
+ }
8938
8982
  return { fnName, fn };
8939
8983
  }
8940
8984
 
@@ -8942,10 +8986,19 @@ function ssrEmitIf(node, ctx, name, inlinedSubs, parentNs, cssHash, componentNs)
8942
8986
  // rewriteHookCalls: key any `use(thenable)` in the @if test (it bypasses the
8943
8987
  // setup rewrite, so without a stable key it collides with sibling/body use()).
8944
8988
  const testExpr = rewriteHookCalls(node.test, ctx, name);
8945
- const thenStmts =
8946
- node.consequent.type === 'BlockStatement' ? node.consequent.body : [node.consequent];
8947
- const thenSub = ssrCompileSub(thenStmts, ctx, '__sif', [], cssHash, parentNs, componentNs);
8948
- inlinedSubs.push(thenSub.fn);
8989
+ // A null consequent (`{cond ? null : <Jsx/>}` lowered by wrapAsBlockStmt)
8990
+ // means "no then branch". It must emit like a MISSING @else — plain '' with
8991
+ // no inner arm range — because the hydrating client renders that branch with
8992
+ // a null body and adopts an empty slot range.
8993
+ const thenStmts = node.consequent
8994
+ ? node.consequent.type === 'BlockStatement'
8995
+ ? node.consequent.body
8996
+ : [node.consequent]
8997
+ : null;
8998
+ const thenSub = thenStmts
8999
+ ? ssrCompileSub(thenStmts, ctx, '__sif', [], cssHash, parentNs, componentNs)
9000
+ : null;
9001
+ if (thenSub) inlinedSubs.push(thenSub.fn);
8949
9002
  let elseCall = ssrHtmlTemplate([], node, ctx);
8950
9003
  let elseFnName = null;
8951
9004
  if (node.alternate) {
@@ -8966,26 +9019,33 @@ function ssrEmitIf(node, ctx, name, inlinedSubs, parentNs, cssHash, componentNs)
8966
9019
  ctx.runtimeNeeded.add('ssrBlock');
8967
9020
  ctx.runtimeNeeded.add('ssrControl');
8968
9021
  ctx.runtimeNeeded.add('ssrArm');
8969
- registerDirectiveOrigin(ctx, node, ['_$ssrControl', '_$ssrArm', thenSub.fnName, elseFnName]);
9022
+ registerDirectiveOrigin(ctx, node, [
9023
+ '_$ssrControl',
9024
+ '_$ssrArm',
9025
+ thenSub ? thenSub.fnName : null,
9026
+ elseFnName,
9027
+ ]);
8970
9028
  // Nested ranges: the OUTER ssrBlock is the if-slot; the INNER one wraps the
8971
9029
  // taken branch's content. The client adopts BOTH on hydration (slot = outer,
8972
9030
  // branch = inner) so no comment markers are inserted — byte-for-byte, exactly
8973
9031
  // like @for. The not-taken arm emits no inner range (just `''`).
8974
- const thenInner = ssrCall(
8975
- 'ssrArm',
8976
- [
8977
- b.literal('then', '"then"'),
8978
- ssrThunk(
8979
- ssrCall(
8980
- 'ssrBlock',
8981
- [ssrSubCall(thenSub.fnName, [b.id('undefined')], node.consequent)],
8982
- node.consequent,
8983
- ),
9032
+ const thenInner = thenSub
9033
+ ? ssrCall(
9034
+ 'ssrArm',
9035
+ [
9036
+ b.literal('then', '"then"'),
9037
+ ssrThunk(
9038
+ ssrCall(
9039
+ 'ssrBlock',
9040
+ [ssrSubCall(thenSub.fnName, [b.id('undefined')], node.consequent)],
9041
+ node.consequent,
9042
+ ),
9043
+ node.consequent,
9044
+ ),
9045
+ ],
8984
9046
  node.consequent,
8985
- ),
8986
- ],
8987
- node.consequent,
8988
- );
9047
+ )
9048
+ : ssrHtmlTemplate([], node, ctx);
8989
9049
  const elseInner = node.alternate
8990
9050
  ? ssrCall(
8991
9051
  'ssrArm',
@@ -9439,6 +9499,30 @@ function ssrEmitTsrxExpression(node, ctx, name, inlinedSubs, parentNs, cssHash,
9439
9499
  ctx.runtimeNeeded.add('ssrPortal');
9440
9500
  return ssrCall('ssrPortal', [], node);
9441
9501
  }
9502
+ if (
9503
+ node.returnedJsxValue !== true &&
9504
+ ctx._tsxValuePos !== true &&
9505
+ ctx._ssrFoldedExprHoles !== true &&
9506
+ ctx._ssrHostChildPos === true &&
9507
+ isConditionalJsx(expr)
9508
+ ) {
9509
+ // Mirror the client's claim EXACTLY: only emitElementHtml's children
9510
+ // walk lowers `{cond ? A : B}` with a JSX arm to an @if, so both sides
9511
+ // compile each arm as an ordinary arm body and the hydration shapes
9512
+ // (control key, arm ranges, a keyed `.map` claimed inside a value arm)
9513
+ // agree by construction. Everywhere else the hole is a VALUE on the
9514
+ // client and must stay on ssrChild here: returned `.tsx` trees
9515
+ // (`_tsxValuePos`), returned-tree mirrors whose holes extractFragment
9516
+ // folds to `props.hN` (`_ssrFoldedExprHoles`), and every non-host-child
9517
+ // position — body/arm/fragment roots and component-children roots,
9518
+ // where a rich hole rides the de-opt value path because a portal there
9519
+ // has no host to stamp (`_ssrHostChildPos`).
9520
+ const asIf = {
9521
+ ...b.if(expr.test, wrapAsBlockStmt(expr.consequent), wrapAsBlockStmt(expr.alternate)),
9522
+ loc: expr.loc, // same devLoc/control-key position as the client's claim
9523
+ };
9524
+ return ssrEmitIf(asIf, ctx, name, inlinedSubs, parentNs, cssHash, componentNs);
9525
+ }
9442
9526
  ctx.runtimeNeeded.add('ssrChild');
9443
9527
  // rewriteHookCalls first (key any `use(thenable)` in the hole — it bypasses the
9444
9528
  // setup rewrite), then rewriteJsxValues (lower nested JSX to createElement).
@@ -20167,10 +20251,16 @@ function hoistBodyHelper(
20167
20251
  // ===========================================================================
20168
20252
 
20169
20253
  function makeIfCall(node, ctx, inlinedSubs, parentNs = 'html', cssHash = null) {
20170
- // node.test, node.consequent (BlockStatement | Element), node.alternate (BlockStatement | IfStatement | null)
20171
-
20172
- const thenStmts =
20173
- node.consequent.type === 'BlockStatement' ? node.consequent.body : [node.consequent];
20254
+ // node.test, node.consequent (BlockStatement | Element | null), node.alternate (BlockStatement | IfStatement | null)
20255
+ // A null consequent (`{cond ? null : <Jsx/>}` lowered by wrapAsBlockStmt)
20256
+ // means "no then branch" — same contract as the null alternate: the helper
20257
+ // slot compiles to `null` and the runtime renders an empty branch.
20258
+
20259
+ const thenStmts = node.consequent
20260
+ ? node.consequent.type === 'BlockStatement'
20261
+ ? node.consequent.body
20262
+ : [node.consequent]
20263
+ : null;
20174
20264
  const elseStmts = node.alternate
20175
20265
  ? node.alternate.type === 'BlockStatement'
20176
20266
  ? node.alternate.body
@@ -20178,20 +20268,22 @@ function makeIfCall(node, ctx, inlinedSubs, parentNs = 'html', cssHash = null) {
20178
20268
  : null;
20179
20269
  // Phase 2: one shared env tuple for both branches (see unionEnv).
20180
20270
  const envNames = unionEnv(ctx, [
20181
- { stmts: thenStmts, params: [] },
20271
+ thenStmts && { stmts: thenStmts, params: [] },
20182
20272
  elseStmts && { stmts: elseStmts, params: [] },
20183
20273
  ]);
20184
- const thenHelperName = hoistBodyHelper(
20185
- ctx,
20186
- inlinedSubs,
20187
- '__then',
20188
- thenStmts,
20189
- [],
20190
- parentNs,
20191
- cssHash,
20192
- envNames,
20193
- directiveKeywordOrigin(ctx, node),
20194
- );
20274
+ const thenHelperName = thenStmts
20275
+ ? hoistBodyHelper(
20276
+ ctx,
20277
+ inlinedSubs,
20278
+ '__then',
20279
+ thenStmts,
20280
+ [],
20281
+ parentNs,
20282
+ cssHash,
20283
+ envNames,
20284
+ directiveKeywordOrigin(ctx, node),
20285
+ )
20286
+ : null;
20195
20287
 
20196
20288
  let elseHelperName = null;
20197
20289
  if (elseStmts) {
@@ -17,6 +17,7 @@
17
17
  import { parseModule } from '@tsrx/core';
18
18
  import { HOOK_NAMES, hookSlotHash } from './compile.js';
19
19
  import { annotateHookCalls } from './hook-deps.js';
20
+ import { assertStrongMode } from './strong-mode.js';
20
21
 
21
22
  // Build a cheap import-presence gate. Precise call identity is annotated by the
22
23
  // lexical scope analysis in analyzeHookDependencies below; this gate only avoids
@@ -990,7 +991,7 @@ function walk(node, owner, st) {
990
991
  *
991
992
  * @param {string} source raw module text
992
993
  * @param {string} id module id (embedded in the stable Symbol.for key)
993
- * @param {{ environment?: 'client' | 'server', hmr?: boolean, profile?: boolean, profileFilename?: string, isVoidComponentImport?: (request: string, imported: string) => boolean }} [options] `hmr: true` (dev serve) emits
994
+ * @param {{ environment?: 'client' | 'server', strong?: boolean, hmr?: boolean, profile?: boolean, profileFilename?: string, isVoidComponentImport?: (request: string, imported: string) => boolean }} [options] `hmr: true` (dev serve) emits
994
995
  * `Symbol.for(stableKey)` so a re-imported module resolves the same hook
995
996
  * slots (state survives HMR); off (ordinary prod builds and SSR) emits
996
997
  * runtime-ranged Symbols. Profiling retains short described Symbols because
@@ -1010,6 +1011,7 @@ export function slotHooks(source, id, options) {
1010
1011
  } catch {
1011
1012
  return null; // let the normal pipeline surface the parse error
1012
1013
  }
1014
+ assertStrongMode(ast, source, id, options);
1013
1015
  const importInfo = octaneHookLocals(ast);
1014
1016
  const canSpecializeRoot =
1015
1017
  !options?.hmr &&