@volter/editor-react 0.5.211 → 0.5.212

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.
@@ -1,49 +1,71 @@
1
- /**
2
- * OID instrumentation (C1/C2) — the visual-edit "stamp every JSX element with a
3
- * stable source id" trick, ported to volter using the TypeScript compiler API
4
- * (volter has `typescript`; it does NOT have @babel/*). Hand-authored React UI
5
- * component source is transformed at build/dev time to carry a `data-oid` on every
6
- * JSX element, and an OID -> {file,line,col,component,tag} index is built so the
7
- * editor can map a clicked element back to its exact source location.
8
- *
9
- * Stability (C1): unlike visual-edit's line:col key (which shifts every id below an
10
- * edit), we key by a CONTENT signature `component:tag:nthOccurrence`, so an edit on
11
- * an unrelated line keeps existing ids stable. The `data-oid` lives ONLY in the
12
- * transformed output, never on disk (the writer edits the original source instead).
13
- */
1
+ /** Instrument authored JSX with source IDs using the TypeScript compiler API.
2
+ * IDs key file, component, tag, and occurrence, so unrelated line edits keep
3
+ * their identities. Stamps exist only in transform output; source writes use
4
+ * the original files and the recorded index locations. */
14
5
  import { isMemberLiteral, memberKey } from './prop-member-writer';
15
6
  import ts from 'typescript';
16
- import {
17
- environmentBindingsByElement,
18
- type R3fEnvironmentBinding,
19
- } from './r3f-environment-binding';
20
- import { jointBindingsByElement, type R3fJointBinding } from './r3f-joint-binding';
21
- import { lodBindingsByElement, type R3fLodBinding } from './r3f-lod-binding';
22
- import { particleBindingsByElement, type R3fParticleBinding } from './r3f-particle-binding';
7
+ import { dreiDetailedLocalNames } from './drei-detailed-names';
23
8
  import { physicsBindingsByElement, type R3fPhysicsBinding } from './r3f-physics-binding';
24
9
  import { parseAuthoringTsx, refIdentifier } from './ts-ast';
25
10
 
26
- import type { DeclaredRootSurface, OidEntry, R3fComponentContract, R3fTransformProp, SourceDialectEvidence } from '@volter/sdk/source-authoring';
27
- export type { ComponentPropSpec, DeclaredRootSurface, OidEntry, R3fAuthoringDiagnostic, R3fComponentContract, R3fTransformProp, SourceDialectEvidence } from '@volter/sdk/source-authoring';
28
-
29
- export type {
30
- R3fEnvironmentBinding,
31
- R3fEnvironmentNumberBinding,
32
- R3fEnvironmentStringBinding,
33
- } from './r3f-environment-binding';
34
- export type {
35
- R3fJointBinding,
36
- R3fJointHook,
37
- R3fJointLiteral,
38
- R3fJointLiteralRange,
39
- } from './r3f-joint-binding';
40
- export type { R3fLodBinding, R3fLodNumberBinding } from './r3f-lod-binding';
41
- export type {
42
- QuarksEmitterShape,
43
- R3fParticleBinding,
44
- R3fParticleNumberBinding,
45
- } from './r3f-particle-binding';
46
- export type { PhysicsChannel, R3fPhysicsBinding } from './r3f-physics-binding';
11
+ import type { ComponentContract, DeclaredRootSurface, OidEntry as SourceOidEntry } from '@volter/sdk/source-authoring';
12
+ export type { ComponentPropSpec, DeclaredRootSurface } from '@volter/sdk/source-authoring';
13
+
14
+ export type { OidEntry } from '@volter/sdk/source-authoring';
15
+ type OidEntry = SourceOidEntry;
16
+
17
+ /** R3F authoring-convention warning returned by the source diagnostics service. */
18
+ export interface R3fAuthoringDiagnostic {
19
+ code: 'R3F002' | 'R3F003' | 'R3F004' | 'R3F005';
20
+ severity: 'warning';
21
+ file: string;
22
+ /** 1-based source position computed from node.getStart(). */
23
+ line: number;
24
+ col: number;
25
+ /** The component the warning is ABOUT: the definition it names (R3F002/3/5)
26
+ * or the tag at the offending callsite (R3F004). */
27
+ component: string;
28
+ message: string;
29
+ }
30
+ type R3fTransformProp = 'position' | 'rotation' | 'scale';
31
+ /** The R3F authoring contract of a component definition: the kit's `ComponentContract` and what R3F reads
32
+ * beside it. */
33
+ export interface R3fComponentContract extends ComponentContract {
34
+ root: 'single' | 'multiple' | 'non-spatial' | 'unknown';
35
+ rootTag?: string;
36
+ transformProps: R3fTransformProp[];
37
+ /** The callsite's native `visible` prop is proven to reach the single
38
+ * Object3D root. Omitted is deliberately false/unknown, so an editor never
39
+ * writes a decorative prop that the component silently ignores. */
40
+ visibleProp?: true;
41
+ /** The component drives its root transform through a ref in useFrame and
42
+ * accepts no authored transform. Suppress forwarding and motion-conflict
43
+ * diagnostics for that simulation-owned transform. */
44
+ simulationOwnedTransform?: boolean;
45
+
46
+ }
47
+ /**
48
+ * What ONE file's own bytes say about which reconciler its JSX targets.
49
+ *
50
+ * DIAGNOSTIC EVIDENCE ONLY — it decides no attribute anywhere. What a file
51
+ * renders is REPORTED (`serving/source-diagnostics.ts`'s `OID003`/`OID004`);
52
+ * which region it belongs to is a declaration or a reach, never a property of
53
+ * its bytes.
54
+ *
55
+ * Deliberately a cheap regex scan, not an AST walk, because it runs in a
56
+ * `transform` hook on every project `.tsx`. COMMENTS are
57
+ * stripped first (see {@link stripCommentsForScan}) so prose that merely
58
+ * NAMES a tag cannot count as rendering it; a mention inside a string still
59
+ * matches, which is deliberate — import specifiers ARE strings.
60
+ */
61
+ export interface SourceDialectEvidence {
62
+ /** Scanned source includes the R3F package specifier. */
63
+ reconcilerImport: boolean;
64
+ /** Distinct tags observed by the R3F-only JSX scan. */
65
+ r3fOnlyTags: string[];
66
+ /** Distinct tags observed by the DOM-only JSX scan. */
67
+ domOnlyTags: string[];
68
+ }
47
69
 
48
70
  /** A number as the writer reads one: `3`, `-3`, `+0.5` (the writer's `NUMBER_LITERAL_SOURCE`). */
49
71
  function signedNumericLiteral(expression: ts.Expression): boolean {
@@ -202,7 +224,7 @@ const BUILTIN_R3F_CONTRACTS: Readonly<
202
224
  * Package prop types that are themselves a proved native-root prop surface.
203
225
  * Kept beside the component contract because both facts come from the same
204
226
  * hand-audited public API. This lets ordinary wrappers preserve the package's
205
- * own type (`Omit<RigidBodyProps, ...>`) without pretending every imported
227
+ * own type (`Omit<RigidBodyProps,...>`) without pretending every imported
206
228
  * type is spatial.
207
229
  */
208
230
  const BUILTIN_R3F_ROOT_PROP_TYPES: Readonly<Record<string, Readonly<Record<string, string>>>> = {
@@ -299,8 +321,8 @@ function unwrapExpression(expression: ts.Expression): ts.Expression {
299
321
  function returnedExpression(body: ts.ConciseBody): ts.Expression | undefined {
300
322
  if (!ts.isBlock(body)) return unwrapExpression(body);
301
323
  const returns = body.statements.filter(ts.isReturnStatement);
302
- if (returns.length !== 1 || !returns[0]?.expression) return undefined;
303
- return unwrapExpression(returns[0].expression);
324
+ if (returns.length !== 1 || !returns[0]!.expression) return undefined;
325
+ return unwrapExpression(returns[0]!.expression);
304
326
  }
305
327
 
306
328
  /**
@@ -385,10 +407,10 @@ function typeAllowsStandardRootProps(
385
407
  return false;
386
408
  }
387
409
 
388
- function spatialRootOpening(expression: ts.Expression): ts.JsxOpeningLikeElement | undefined {
389
- if (ts.isJsxSelfClosingElement(expression)) return expression;
390
- if (!ts.isJsxElement(expression)) return undefined;
391
- return expression.openingElement;
410
+ function spatialRootOpening(
411
+ expression: ts.JsxElement | ts.JsxSelfClosingElement,
412
+ ): ts.JsxOpeningLikeElement {
413
+ return ts.isJsxSelfClosingElement(expression) ? expression : expression.openingElement;
392
414
  }
393
415
 
394
416
  type FragmentRootCardinality = 0 | 1 | 'multiple' | 'unknown';
@@ -473,8 +495,7 @@ function jsxChildRootCardinality(
473
495
  if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
474
496
  return jsxRootCardinality(child, sf, knownContracts);
475
497
  }
476
- if (ts.isJsxFragment(child)) return fragmentRootCardinality(child, sf, knownContracts);
477
- return 'unknown';
498
+ return fragmentRootCardinality(child, sf, knownContracts);
478
499
  }
479
500
 
480
501
  function fragmentRootCardinality(
@@ -527,9 +548,8 @@ function fragmentComponentContract(
527
548
  * prevent elsewhere in these rules.
528
549
  *
529
550
  * Recognized by suffix because three's naming is regular here and the set is
530
- * open — every release adds materials and geometries, and an exhaustive list
531
- * would silently rot into false positives again. The three exact names are the
532
- * scene-level attachables that carry no suffix.
551
+ * open. The three exact names are the scene-level attachables that carry no
552
+ * suffix.
533
553
  */
534
554
  function isNonSpatialNativeTag(tag: string): boolean {
535
555
  if (/^[A-Z]/.test(tag)) return false;
@@ -678,7 +698,7 @@ function forwardedObjectProps(
678
698
  *
679
699
  * Optional chaining is part of the pattern, not an edge case: `useRef<
680
700
  * THREE.Group>(null)` types `.current` as nullable, so `body.current?.position
681
- * .copy(…)` is the ordinary spelling and `body.current.position.copy(…)` the
701
+ *.copy(…)` is the ordinary spelling and `body.current.position.copy(…)` the
682
702
  * non-null-asserted one. Both must read the same.
683
703
  */
684
704
  const RUNTIME_MOTION_MUTATION_RE =
@@ -743,7 +763,6 @@ function componentContract(
743
763
  }
744
764
 
745
765
  const opening = spatialRootOpening(expression);
746
- if (!opening) return { root: 'unknown', transformProps: [] };
747
766
  const rootTag = opening.tagName.getText(sf);
748
767
  // A material/geometry/attribute root is not a spatial instance at all — see
749
768
  // `isNonSpatialNativeTag`. Classified here rather than suppressed at each
@@ -753,38 +772,15 @@ function componentContract(
753
772
  if (/^[A-Z]/.test(rootTag) && !delegated) return { root: 'unknown', transformProps: [] };
754
773
  if (delegated && delegated.root !== 'single') return delegated;
755
774
  const nativeRootTag = delegated?.rootTag ?? rootTag;
756
- // A channel this component hands to the PHYSICS BINDING that places its root
757
- // is forwarded exactly as truly as one written onto the root's own tag — the
758
- // body is what puts the object in the world, so the callsite literal the hook
759
- // reads IS this instance's placement (`r3f-physics-binding.ts`). Without this
760
- // the contract said `Ramp` "does not forward position to its native root"
761
- // and refused `<Ramp position={[2, -1, 168.55]} />`, the one literal in the
762
- // whole chain that actually places the ramp.
775
+ // Physics spawn channels forwarded from component props contribute to its contract.
763
776
  const rootBinding = physicsBindings.get(opening);
764
- // A channel the component's OWN `useFrame` drives through the body's api is
765
- // NOT forwarded in any sense a write can rely on: the hook may read the
766
- // callsite literal at spawn, but the per-frame driver has the last word and
767
- // the authored value is dead on the next tick (racing-game's `Train` — a
768
- // Kinematic body re-posed from its animated group every frame; the doctor's
769
- // settle leg measured the authored spawn discarded, digit for digit). Those
770
- // channels leave the forwarded set here, and the component is classified
771
- // simulation-owned below, so a callsite write refuses with the sentence
772
- // that names the real owner instead of persisting a literal that lies.
777
+ // Exclude channels driven each frame through the body API from writable forwarding.
773
778
  const apiDriven = (rootBinding?.apiDriven ?? []) as readonly string[];
774
779
  const toBinding = ((rootBinding?.forwarded ?? []) as readonly string[]).filter(
775
780
  (channel) => !apiDriven.includes(channel),
776
781
  );
777
782
  const declared = propForwarding(parameters[0], nativeRootTag, sf, typeAliases, builtinPropTypes);
778
- // A GUARD WRAPPER spells its props AS the component it delegates to —
779
- // `props: Parameters<typeof WeaponModelBody>[0]` — and spreads them whole.
780
- // `typeAllowsStandardRootProps` reads type TEXT and aliases, so that spelling
781
- // reads as "accepts no root props" and the wrapper was reported as forwarding
782
- // nothing: first-person's `WeaponModel` (the hook-free discriminator check in
783
- // front of `WeaponModelBody` that its own header documents, bought by runhuman
784
- // pass 97) drew a permanent R3F002 telling the author to forward
785
- // position/rotation/scale it already forwards, all three, through `{...props}`.
786
- // The delegate's contract is resolved just above, so a wrapper whose props are
787
- // SPELLED as the delegate's accepts exactly what the delegate accepts.
783
+ // A wrapper typed from its delegate accepts the delegate's root props.
788
784
  const forwarding =
789
785
  !declared.standardRootProps &&
790
786
  delegated &&
@@ -801,25 +797,16 @@ function componentContract(
801
797
  const forwardsVisible =
802
798
  toTag.includes('visible') && (!delegated || delegated.visibleProp === true);
803
799
 
804
- // A built-in package contract may only ADD authorability. A component that
805
- // returns an external component while forwarding no transform channel to it
806
- // is exactly as opaque as it was before the table existed — reading the
807
- // package's root as its own would claim a native root the component never
808
- // routes anything to, and turn a silent unknown into a "does not forward"
809
- // warning about source the author cannot act on.
800
+ // An external root contributes authorability only through forwarded channels.
801
+ // Keep wrappers opaque when they expose none of the package transform props.
810
802
  if (isBuiltinR3fContract(delegated) && transformProps.length === 0) {
811
803
  return { root: 'unknown', transformProps: [] };
812
804
  }
813
805
 
814
- // WHICH of the forwarded channels a BODY reads, kept because the answer
815
- // changes what an authored write has to survive (see `bodyForwarded`).
816
- const bodyForwarded = transformProps.filter((channel) => toBinding.includes(channel));
817
-
818
806
  return {
819
807
  root: 'single',
820
808
  rootTag: nativeRootTag,
821
809
  transformProps,
822
- ...(bodyForwarded.length > 0 ? { bodyForwarded } : {}),
823
810
  ...(forwardsVisible ? { visibleProp: true as const } : {}),
824
811
  ...simulationOwnership(opening, transformProps, motionRefs),
825
812
  // The api-driven case (see `apiDriven` above): the component drives its
@@ -864,8 +851,8 @@ function componentDefinitions(statement: ts.Statement): R3fComponentDefinition[]
864
851
 
865
852
  function collectR3fComponentContracts(
866
853
  sf: ts.SourceFile,
867
- importedContracts: ReadonlyMap<string, R3fComponentContract> = new Map(),
868
- importedTypeAliases: ReadonlyMap<string, R3fTypeAlias> = new Map(),
854
+ importedContracts: ReadonlyMap<string, R3fComponentContract>,
855
+ importedTypeAliases: ReadonlyMap<string, R3fTypeAlias>,
869
856
  ): Map<string, R3fComponentContract> {
870
857
  // Imported first, so a local declaration of the same name shadows it exactly
871
858
  // as JavaScript lexical scope does — the same rule `importedContracts`
@@ -931,7 +918,7 @@ export function analyzeR3fComponentContracts(
931
918
  * author states or the host EXECUTES, while a tag list is a guess about a file
932
919
  * whose imports it cannot see. What the scan is still for is telling an author
933
920
  * what the editor OBSERVED: the `OID003`/`OID004` messages in
934
- * `server/project-root-surface.ts` name the tags a file renders, and `OID004` —
921
+ * `serving/source-diagnostics.ts` name the tags a file renders, and `OID004` —
935
922
  * a file rendering both dialects' intrinsics, which one attribute cannot serve
936
923
  * — is nothing BUT this observation.
937
924
  *
@@ -939,7 +926,7 @@ export function analyzeR3fComponentContracts(
939
926
  * element sets, so nothing here can be a DOM tag. `line`, `points`, `sprite`,
940
927
  * `audio`, `color` and `fog` are all real R3F intrinsics but are EXCLUDED from
941
928
  * this list where SVG/HTML also spells them (`<line>`, `<audio>`) — the list is
942
- * evidence, not a catalogue, and a wrong entry would flip a genuine DOM file.
929
+ * evidence, not a catalogue; a wrong entry would falsely report R3F evidence in DOM source.
943
930
  * `[a-z][A-Za-z]*(Geometry|Material)` covers the whole geometry/material family
944
931
  * (`boxGeometry`, `meshStandardMaterial`, `shaderMaterial`, …) without naming
945
932
  * each one; the leading lowercase class is what keeps it from matching a
@@ -954,7 +941,7 @@ const R3F_ONLY_INTRINSIC_RE =
954
941
  *
955
942
  * DIAGNOSTIC EVIDENCE ONLY, on the same terms as the R3F list above: it
956
943
  * classifies no file. What a file renders is reported (`OID003`/`OID004` in
957
- * `server/project-root-surface.ts`); which region a file BELONGS to is a
944
+ * `serving/source-diagnostics.ts`); which region a file BELONGS to is a
958
945
  * declaration or a reach, never a property of its bytes. Excludes every name R3F also spells as an intrinsic
959
946
  * (`line`, `points`, `sprite`, `audio`, `color`, `fog`, `path`, `circle`), so
960
947
  * a hit here is unambiguous.
@@ -964,24 +951,13 @@ const DOM_ONLY_INTRINSIC_RE =
964
951
 
965
952
  function distinctTags(code: string, pattern: RegExp): string[] {
966
953
  const found = new Set<string>();
967
- for (const match of code.matchAll(pattern)) if (match[1]) found.add(match[1]);
954
+ for (const match of code.matchAll(pattern)) found.add(match[1]!);
968
955
  return [...found];
969
956
  }
970
957
 
971
- /**
972
- * Drop line and block comments before the dialect scan, copying string
973
- * literals through untouched. The shipped case that bought this: the starter
974
- * dev-tools JSDoc names its command shape `cheat.<group>.<label>` — `group`
975
- * is an R3F-only intrinsic and `label` a DOM-only one, so ONE comment line
976
- * made a fresh scaffold boot with a dialect-mixing diagnostic about a file
977
- * with no JSX in it. A diagnostic channel agents are told to trust cannot
978
- * cry wolf over prose.
979
- *
980
- * Single pass, no AST — the transform-hook perf bar stands. Known residual
981
- * imprecision, accepted: a regex literal containing `//` or `/*` is misread
982
- * as a comment and drops the rest of itself — which degrades toward "no
983
- * evidence" (the safe default), never toward a fabricated tag.
984
- */
958
+ /** Strip comments before dialect scanning and preserve quoted string literals.
959
+ * This bounded scan treats comment markers inside regex literals as comments,
960
+ * which can discard evidence but cannot fabricate a rendered tag. */
985
961
  function stripCommentsForScan(code: string): string {
986
962
  const kept: string[] = [];
987
963
  let i = 0;
@@ -1041,60 +1017,18 @@ export function sourceDialectEvidence(code: string): SourceDialectEvidence {
1041
1017
  };
1042
1018
  }
1043
1019
 
1044
- /** True when a file's own bytes prove it authors for the R3F reconciler. */
1020
+ /** Whether the scan finds R3F import or intrinsic-tag evidence in this file. */
1045
1021
  export function sourceProvesR3f(evidence: SourceDialectEvidence): boolean {
1046
1022
  return evidence.reconcilerImport || evidence.r3fOnlyTags.length > 0;
1047
1023
  }
1048
1024
 
1049
- /**
1050
- * THE SOURCE-ID ATTRIBUTE for one file, decided by its REGION and nothing else.
1051
- *
1052
- * 1. **The region (authoritative).** `surface` is the answer
1053
- * `file-region-resolver.ts` gave for THIS file — a declaration the game
1054
- * made (a region `include` glob, a region `mounts` entry, or the manifest
1055
- * root whose `entry` this file is) or the region whose import closure
1056
- * reaches it. `'three'` stamps `userData-oid`; `'canvas'`/`'dom'` stamp
1057
- * `data-oid`. It is resolved from OUTSIDE the file, exactly where the fact
1058
- * lives, which is what makes it right for a world whose entry
1059
- * default-exports a component and does not import `@react-three/fiber`
1060
- * (legal: R3F's global JSX-intrinsics augmentation,
1061
- * or an entry composed entirely of already-typed child components, needs no
1062
- * import in THIS file). Every call site threads it from a context that
1063
- * resolved it per file: `binding-resolver.ts`'s per-surface resolvers, the
1064
- * browser bundle's own per-file resolution
1065
- * (`browser-transpile.ts`), and the dev server's
1066
- * `server/project-root-surface.ts` — shared with `classifyProjectHotUpdate`,
1067
- * the HMR half of this same decision, so the two cannot drift apart by hand.
1068
- *
1069
- * 2. **The DEFAULT.** When `surface` is `undefined` — no region placed the
1070
- * file (an ad-hoc file with no `volter.project.json` above it, or a module no
1071
- * region's import closure reaches and none declares) — the answer is
1072
- * `data-oid`, the documented default, and the CALLER says so out loud
1073
- * (`server/project-root-surface.ts`'s `OID001`/`OID002`). There is
1074
- * deliberately no source-text rung: a probe over a file's own bytes answered
1075
- * for a file it could not place, silently, and being wrong that way is
1076
- * indistinguishable from being right. Zero inference (ARCHITECTURE-CORE
1077
- * §The editor protocol) makes the fix a declaration — an `include` glob or
1078
- * a `mounts` entry in `editor/volter.adapter.ts` — not a better heuristic.
1079
- *
1080
- * Nothing about a file's own bytes outranks the region — not even a file that
1081
- * renders THREE host elements while its region says `dom`. That disagreement is
1082
- * FATAL rather than noisy (`data-oid` on a THREE object pierces inside fiber,
1083
- * writes `object.data`, and throws on the next apply), which is exactly why it
1084
- * is reported at full volume as declared-vs-measured DRIFT
1085
- * (`server/project-root-surface.ts`'s `OID003`) and repaired by fixing the
1086
- * declaration. A stamp derived from the bytes instead would make the
1087
- * declaration untestable, which is how a wrong one stays invisible.
1088
- *
1089
- * Lives HERE, beside the transform it parameterizes, rather than in
1090
- * `vite-plugin-ui-oid.ts` (which re-exports it for its existing callers):
1091
- * a caller in a page reaches the same `transformSource`, and that path cannot
1092
- * import a `node:fs`-bearing module.
1093
- */
1025
+ /** Choose the source-ID attribute from the owning region: stage uses
1026
+ * userData-oid; DOM, canvas, and unplaced files use data-oid. Source dialect
1027
+ * diagnostics report disagreement without overriding the region declaration. */
1094
1028
  export function oidAttributeForSurface(
1095
1029
  declaredSurface?: DeclaredRootSurface,
1096
1030
  ): 'data-oid' | 'userData-oid' {
1097
- return declaredSurface === 'three' ? 'userData-oid' : 'data-oid';
1031
+ return declaredSurface === 'stage' ? 'userData-oid' : 'data-oid';
1098
1032
  }
1099
1033
 
1100
1034
  /**
@@ -1142,49 +1076,18 @@ const SEEDED_USER_DATA_TAGS = new Set<string>([
1142
1076
 
1143
1077
  /** Options for {@link transformSource}. */
1144
1078
  export interface TransformSourceOptions {
1145
- /**
1146
- * The attribute name stamped on every JSX element (default `data-oid`).
1147
- * R3F-dialect files (spike W1) pass `userData-oid` instead:
1148
- * react-three-fiber's dashed-prop piercing writes `object.userData.oid`
1149
- * on the constructed Object3D at reconcile time — zero runtime cost, and
1150
- * the editor's R3F authoring adapter reads it back off the live scene
1151
- * graph. The OID→{file,line,col} index is attribute-agnostic (keyed by
1152
- * oid only), so every `/__ui-source/*` endpoint works unchanged for
1153
- * either dialect.
1154
- */
1079
+ /** Attribute stamped on JSX elements. R3F uses userData-oid to place the
1080
+ * identity on the constructed Three object; DOM uses data-oid. */
1155
1081
  attribute?: string;
1156
- /** Contracts for ordinary project-local imported component bindings. */
1157
- importedR3fContracts?: ReadonlyMap<string, R3fComponentContract>;
1158
- /** The prop-shape type aliases those same imports bring into scope. A
1159
- * project that writes its canonical prop shape once and imports it is
1160
- * otherwise read as forwarding nothing. */
1161
- importedR3fTypeAliases?: ReadonlyMap<string, R3fTypeAlias>;
1162
1082
  /** The file is mounted by @pixi/react. Local component callsites propagate
1163
1083
  * their OID onto the component's first native Pixi host, exactly as R3F
1164
1084
  * does with userData, but through ordinary data-* constructor fields. */
1165
1085
  canvasComponents?: boolean;
1166
1086
  }
1167
1087
 
1168
- /**
1169
- * The local names bound to drei's `<Html>` in this file — the ONE seam where a
1170
- * three-surface file renders through **react-dom** instead of the R3F
1171
- * reconciler. `<Html>` creates a real `<div>`, mounts a `createRoot` on it and
1172
- * renders its children there, so every host element inside it is a DOM element
1173
- * and the R3F-pierced identity props this transform stamps on host elements
1174
- * (`userData-oid`, `userData-authoringInstance`, `userData-authoringLabel`,
1175
- * and the `userData={{}}` seed) have nowhere to land.
1176
- *
1177
- * Measured: the starter template's own `BotHud` — screen-pinned dev chrome
1178
- * built on `<Html>`, the documented way to put DOM in a Three world — logged
1179
- * FOUR `React does not recognize the \`userData-…\` prop on a DOM element`
1180
- * errors on every bot run, one per stamped prop. Inside an `<Html>` subtree we
1181
- * therefore stamp `data-oid`, byte-for-byte what a `dom` root gets.
1182
- *
1183
- * Lexical by design: this reads the subtree written in THIS file, which is the
1184
- * whole extent the transform can see. A component defined elsewhere and
1185
- * rendered inside `<Html>` is transformed by its own pass against its own
1186
- * declared surface.
1187
- */
1088
+ /** Local JSX names imported as Drei Html. Its descendants render through
1089
+ * react-dom and receive data-oid rather than R3F identity props. The decision
1090
+ * is lexical to this file; imported components receive their own transform. */
1188
1091
  function dreiHtmlLocalNames(sf: ts.SourceFile): Set<string> {
1189
1092
  const names = new Set<string>();
1190
1093
  for (const statement of sf.statements) {
@@ -1203,36 +1106,10 @@ function dreiHtmlLocalNames(sf: ts.SourceFile): Set<string> {
1203
1106
  return names;
1204
1107
  }
1205
1108
 
1206
- /**
1207
- * Local JSX names bound by an import from an INSTALLED PACKAGE — every
1208
- * specifier that is not relative (`./x`, `../x`), which is exactly the line
1209
- * `r3f-project-contracts.ts`'s `resolveRelativeModule` already draws between
1210
- * "source this editor walks" and "a dependency it never guesses about".
1211
- *
1212
- * Why the callsite stamp must skip them: `__volterOid`/`__volterLabel` are a
1213
- * CONTRACT BETWEEN TWO HALVES OF THIS TRANSFORM. The callsite half writes
1214
- * them; the only thing that ever reads them is the callee half —
1215
- * `componentInstanceExpressions` destructuring them out of the component's own
1216
- * parameters and re-emitting them as `userData-authoringInstance` on that
1217
- * component's host elements. Nothing anywhere reads them off a React instance
1218
- * or a DOM node. A package module is never served through this transform, so
1219
- * the callee half never runs for it: the props have no reader by construction,
1220
- * and the only thing they can still do is escape.
1221
- *
1222
- * And they do. Measured on the vendored `racing-game` ingest: `App.tsx` is a
1223
- * MIXED file (an R3F `Scene` and a DOM `Hud` in one module, so the file's one
1224
- * dialect is `userData-oid`), and its `<Canvas>` — `@react-three/fiber`'s own,
1225
- * which spreads its rest props onto the `<div>` it wraps the canvas in — put
1226
- * `__volterOid` and `__volterLabel` straight into the DOM, for two permanent
1227
- * `React does not recognize the \`__volterOid\` prop on a DOM element` errors on
1228
- * every mount of a real game. Nothing was lost by not stamping it, because
1229
- * nothing was reading it.
1230
- *
1231
- * The one package component that DOES carry editor identity earns it through
1232
- * the hand-audited contract table instead (`BUILTIN_R3F_CONTRACTS`), which
1233
- * describes the native root the package forwards to — a claim about a specific
1234
- * package's public API, never a guess made at a callsite.
1235
- */
1109
+ /** Local names imported from installed packages. Those modules do not run
1110
+ * the callee half of this transform, so private component-instance props
1111
+ * would have no reader and could escape through rest-prop spreads. Known
1112
+ * native Three wrappers receive host OID stamps at their own handling site. */
1236
1113
  function packageImportedLocalNames(sf: ts.SourceFile): Set<string> {
1237
1114
  const names = new Set<string>();
1238
1115
  for (const statement of sf.statements) {
@@ -1280,35 +1157,10 @@ function dreiCameraLocalNames(sf: ts.SourceFile): Set<string> {
1280
1157
  return names;
1281
1158
  }
1282
1159
 
1283
- /**
1284
- * Component-shaped JSX names bound by a function PARAMETER — the second way a
1285
- * tag's callee is unknowable at transform time, and the same hazard as
1286
- * {@link packageImportedLocalNames} for exactly the same reason.
1287
- *
1288
- * `__volterOid`/`__volterLabel` are a contract between two halves of this
1289
- * transform: the callsite half writes them, the callee half destructures them
1290
- * out of the component's own parameters. A tag bound to a parameter names
1291
- * WHATEVER the caller passed, so this module cannot know whether the callee
1292
- * half ever ran for it — and where it did not, the props' only remaining
1293
- * behaviour is to escape into the DOM through any rest-prop spread.
1294
- *
1295
- * MEASURED on the vendored `racing-game` ingest, against the SERVED modules:
1296
- * `useToggle.tsx`'s `<ToggledComponent {...props} />` (its `ToggledComponent`
1297
- * is a parameter — drei's `Stats`/`OrbitControls`, cannon's `Debug`, or a
1298
- * project component, depending on the call) and every R3F `*.stories.tsx`
1299
- * decorator's `<Story />`. Two standing
1300
- * `React does not recognize the \`__volterOid\` prop on a DOM element` errors on
1301
- * every run of the acceptance portfolio.
1302
- *
1303
- * Nothing is lost by not stamping, and identity actually IMPROVES: a forwarder
1304
- * spreads the caller's own props first, so the oid the REAL callsite wrote
1305
- * (`<ToggledMap/>` in `App.tsx`) now reaches the wrapped component instead of
1306
- * being overwritten by a literal naming the forwarder's own line.
1307
- *
1308
- * Scope is the whole file rather than lexical scope on purpose: a name that is
1309
- * a parameter ANYWHERE in a module is not a stable component identity to stamp
1310
- * against, and a per-scope walk would buy precision this decision does not use.
1311
- */
1160
+ /** Component-shaped names bound by function parameters. Their callees are
1161
+ * unknown to this transform, so preserve caller identity rather than adding
1162
+ * private instance props that may escape to DOM hosts. Names are collected
1163
+ * across the whole file, without resolving individual lexical scopes. */
1312
1164
  function parameterBoundLocalNames(sf: ts.SourceFile): Set<string> {
1313
1165
  const names = new Set<string>();
1314
1166
  const addBinding = (name: ts.BindingName): void => {
@@ -1359,16 +1211,6 @@ function reactFragmentNames(sf: ts.SourceFile): Set<string> {
1359
1211
  return names;
1360
1212
  }
1361
1213
 
1362
- /**
1363
- * Transform source: insert `data-oid="…"` (or `opts.attribute="…"`) on every
1364
- * JSX element and register the index. In the R3F dialect, a custom component
1365
- * callsite receives editor-only props whose values are propagated onto the
1366
- * component's existing host Object3Ds. Every transformed project component,
1367
- * on every surface, consumes those props at its parameter boundary so a
1368
- * cross-surface component or ordinary `{...props}` spread cannot forward them
1369
- * into game DOM. That preserves per-instance source identity without inserting
1370
- * a wrapper into the native tree. Returns the transformed code + index entries.
1371
- */
1372
1214
  /** Whether any JSX (an element, a self-closing element or a fragment) appears under `node`. */
1373
1215
  function containsJsx(node: ts.Node): boolean {
1374
1216
  let found = false;
@@ -1384,6 +1226,16 @@ function containsJsx(node: ts.Node): boolean {
1384
1226
  return found;
1385
1227
  }
1386
1228
 
1229
+ /**
1230
+ * Transform source: insert `data-oid="…"` (or `opts.attribute="…"`) on every
1231
+ * JSX element and register the index. In the R3F dialect, a custom component
1232
+ * callsite receives editor-only props whose values are propagated onto the
1233
+ * component's existing host Object3Ds. Object- and identifier-bound props
1234
+ * consume those fields at the parameter boundary, so ordinary `{...props}`
1235
+ * spreads do not forward them into project DOM. That preserves per-instance
1236
+ * source identity without inserting a wrapper into the native tree. Returns
1237
+ * the transformed code + index entries.
1238
+ */
1387
1239
  export function transformSource(
1388
1240
  code: string,
1389
1241
  file: string,
@@ -1397,30 +1249,7 @@ export function transformSource(
1397
1249
  attribute === 'userData-oid' || opts?.canvasComponents === true;
1398
1250
  const sourceEdits: SourceEdit[] = [];
1399
1251
  const entries: OidEntry[] = [];
1400
- const r3fComponentContracts =
1401
- attribute === 'userData-oid'
1402
- ? collectR3fComponentContracts(sf, opts?.importedR3fContracts, opts?.importedR3fTypeAliases)
1403
- : new Map();
1404
- const physicsBindings =
1405
- attribute === 'userData-oid'
1406
- ? physicsBindingsByElement(sf)
1407
- : new Map<ts.JsxOpeningLikeElement, R3fPhysicsBinding>();
1408
- const jointBindings =
1409
- attribute === 'userData-oid'
1410
- ? jointBindingsByElement(sf)
1411
- : new Map<ts.JsxOpeningLikeElement, readonly R3fJointBinding[]>();
1412
- const particleBindings =
1413
- attribute === 'userData-oid'
1414
- ? particleBindingsByElement(sf)
1415
- : new Map<ts.JsxOpeningLikeElement, R3fParticleBinding>();
1416
- const lodBindings =
1417
- attribute === 'userData-oid'
1418
- ? lodBindingsByElement(sf)
1419
- : new Map<ts.JsxOpeningLikeElement, R3fLodBinding>();
1420
- const environmentBindings =
1421
- attribute === 'userData-oid'
1422
- ? environmentBindingsByElement(sf)
1423
- : new Map<ts.JsxOpeningLikeElement, R3fEnvironmentBinding>();
1252
+ const detailedNames = attribute === 'userData-oid' ? dreiDetailedLocalNames(sf) : new Set<string>();
1424
1253
  const componentStack: Array<{
1425
1254
  name: string;
1426
1255
  instanceExpression: string;
@@ -1447,9 +1276,7 @@ export function transformSource(
1447
1276
  text: '__volterOid, __volterLabel, ',
1448
1277
  });
1449
1278
  // After the WHOLE parameter, including a type annotation:
1450
- // `({ name, ...props }: T)` must become `({ __volterOid, …, name, ...props }: T = {})`.
1451
- // Inserting at the binding's end produced `} = {}: T`, which is not
1452
- // TypeScript and broke every typed scene export the look verb imports.
1279
+ // `({ name,...props }: T)` must become `({ __volterOid, …, name,...props }: T = {})`.
1453
1280
  if (!first.initializer) {
1454
1281
  sourceEdits.push({ pos: first.getEnd(), text: ' = {}' });
1455
1282
  }
@@ -1459,7 +1286,7 @@ export function transformSource(
1459
1286
  // Preserve the authored identifier as the REST binding. Code below the
1460
1287
  // parameter continues to read `props`, but it now sees only game props:
1461
1288
  // `function Card(props: Props)` becomes
1462
- // `function Card({ __volterOid, __volterLabel, ...props }: Props)`.
1289
+ // `function Card({ __volterOid, __volterLabel,...props }: Props)`.
1463
1290
  // Remove an optional marker while replacing the identifier; a binding
1464
1291
  // pattern cannot itself be optional, so its equivalent is a `{}` default.
1465
1292
  sourceEdits.push({
@@ -1476,21 +1303,17 @@ export function transformSource(
1476
1303
  };
1477
1304
 
1478
1305
  const pushComponent = (
1479
- name: string | undefined,
1480
- parameters: ts.NodeArray<ts.ParameterDeclaration> | undefined,
1306
+ name: string,
1307
+ parameters: ts.NodeArray<ts.ParameterDeclaration>,
1481
1308
  body: ts.Node,
1482
1309
  ): boolean => {
1483
- if (!name || !/^[A-Z]/.test(name) || !parameters) return false;
1484
- // A capitalised function that renders no JSX is not a component, whatever its name: a table
1485
- // helper like `const PATCH = (key) => ({ key, … })` had its parameter rewritten into props
1486
- // destructuring and received an object of its string's characters. Nothing in it is stamped,
1487
- // and it consumes no transport props, so it is left as written.
1310
+ if (!/^[A-Z]/.test(name)) return false;
1311
+ // Only functions containing JSX consume the component transport props.
1488
1312
  if (!containsJsx(body)) return false;
1489
1313
  // Component membership participates in the stable OID signature on every
1490
1314
  // JSX dialect. Three/Canvas callsites inject the transport props, while
1491
- // every project component consumes them: a component declared on a DOM
1492
- // mount can therefore be called safely from a Three-region module without
1493
- // leaking editor metadata through its own rest props.
1315
+ // supported props bindings consume them before spreading ordinary props
1316
+ // into a DOM host.
1494
1317
  componentStack.push({ name, ...componentInstanceExpressions(parameters) });
1495
1318
  return true;
1496
1319
  };
@@ -1539,7 +1362,6 @@ export function transformSource(
1539
1362
  const tag = node.tagName.getText(sf);
1540
1363
  if (fragmentNames.has(tag)) {
1541
1364
  ts.forEachChild(node, visit);
1542
- if (pushed) componentStack.pop();
1543
1365
  return;
1544
1366
  }
1545
1367
  const componentContext = componentStack[componentStack.length - 1];
@@ -1553,10 +1375,6 @@ export function transformSource(
1553
1375
  // `dreiHtmlLocalNames`. Everything below reads this, not `attribute`.
1554
1376
  const elementAttribute = domSubtreeDepth > 0 ? 'data-oid' : attribute;
1555
1377
  const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
1556
- const localContract =
1557
- elementAttribute === 'userData-oid' && /^[A-Z]/.test(tag)
1558
- ? r3fComponentContracts.get(tag)
1559
- : undefined;
1560
1378
  const entry: OidEntry = {
1561
1379
  file,
1562
1380
  line: line + 1,
@@ -1564,14 +1382,6 @@ export function transformSource(
1564
1382
  component,
1565
1383
  tag,
1566
1384
  authoredProps: authoredPropsOf(node, sf),
1567
- ...(localContract ? { r3fAuthoring: localContract } : {}),
1568
- ...(physicsBindings.has(node) ? { physicsBinding: physicsBindings.get(node)! } : {}),
1569
- ...(jointBindings.has(node) ? { jointBindings: jointBindings.get(node)! } : {}),
1570
- ...(particleBindings.has(node) ? { particleBinding: particleBindings.get(node)! } : {}),
1571
- ...(lodBindings.has(node) ? { lodBinding: lodBindings.get(node)! } : {}),
1572
- ...(environmentBindings.has(node)
1573
- ? { environmentBinding: environmentBindings.get(node)! }
1574
- : {}),
1575
1385
  ...(jsxStack.length > 0 ? { parentOid: jsxStack[jsxStack.length - 1]! } : {}),
1576
1386
  };
1577
1387
  store.index.set(oid, entry);
@@ -1581,51 +1391,47 @@ export function transformSource(
1581
1391
  (elementAttribute === 'userData-oid' || opts?.canvasComponents === true) &&
1582
1392
  /^[A-Z]/.test(tag);
1583
1393
  if (isProjectCustomComponent) {
1584
- const element = ts.isJsxOpeningElement(node) ? node.parent : node;
1585
- if (ts.isJsxElement(element) || ts.isJsxSelfClosingElement(element)) {
1586
- // An installed package's module never passes through this transform,
1587
- // so the callee half of the `__volterOid` contract never runs for it and
1588
- // the props can only escape (into the DOM, via any package component
1589
- // that spreads its rest props onto a host element). The callsite is
1590
- // still INDEXED above — the source entry, its parent and its contract
1591
- // are unchanged; only the unreadable runtime prop is withheld. See
1592
- // {@link packageImportedLocalNames}.
1593
- if (packageNames.has(tag.split('.')[0] ?? tag)) {
1594
- // Detailed and the two Drei cameras spread their remaining props
1595
- // onto a native Three host, where the ordinary R3F OID belongs.
1596
- if (lodBindings.has(node) || dreiCameraNames.has(tag)) {
1597
- sourceEdits.push({ pos: node.attributes.end, text: ` userData-oid="${oid}"` });
1598
- }
1599
- ts.forEachChild(node, visit);
1600
- if (pushed) componentStack.pop();
1601
- return;
1602
- }
1603
- // A tag bound to a PARAMETER names whatever the caller passed, so the
1604
- // callee half may never have run for it either — same withholding,
1605
- // same reason, and the callsite stays indexed. See
1606
- // {@link parameterBoundLocalNames}.
1607
- if (parameterNames.has(tag.split('.')[0] ?? tag)) {
1608
- ts.forEachChild(node, visit);
1609
- if (pushed) componentStack.pop();
1610
- return;
1394
+ const tagRoot = tag.split('.')[0]!;
1395
+ // An installed package's module never passes through this transform,
1396
+ // so the callee half of the `__volterOid` contract never runs for it and
1397
+ // the props can only escape (into the DOM, via any package component
1398
+ // that spreads its rest props onto a host element). The callsite is
1399
+ // still INDEXED above — the source entry, its parent and its contract
1400
+ // are unchanged; only the unreadable runtime prop is withheld. See
1401
+ // {@link packageImportedLocalNames}.
1402
+ if (packageNames.has(tagRoot)) {
1403
+ // Detailed and the two Drei cameras spread their remaining props
1404
+ // onto a native Three host, where the ordinary R3F OID belongs.
1405
+ if (detailedNames.has(tag) || dreiCameraNames.has(tag)) {
1406
+ sourceEdits.push({ pos: node.attributes.end, text: ` userData-oid="${oid}"` });
1611
1407
  }
1612
- const authoredName = node.attributes.properties.find(
1613
- (property): property is ts.JsxAttribute =>
1614
- ts.isJsxAttribute(property) && property.name.getText(sf) === 'name',
1615
- );
1616
- const label =
1617
- authoredName?.initializer && ts.isStringLiteral(authoredName.initializer)
1618
- ? authoredName.initializer.text
1619
- : tag;
1620
- const labelAttribute =
1621
- authoredName?.initializer && ts.isJsxExpression(authoredName.initializer)
1622
- ? `__volterLabel={${authoredName.initializer.expression?.getText(sf) ?? JSON.stringify(tag)}}`
1623
- : `__volterLabel=${JSON.stringify(label)}`;
1624
- sourceEdits.push({
1625
- pos: node.attributes.end,
1626
- text: ` __volterOid="${oid}" ${labelAttribute}`,
1627
- });
1408
+ ts.forEachChild(node, visit);
1409
+ return;
1410
+ }
1411
+ // A tag bound to a PARAMETER names whatever the caller passed, so the
1412
+ // callee half may never have run for it either — same withholding,
1413
+ // same reason, and the callsite stays indexed. See
1414
+ // {@link parameterBoundLocalNames}.
1415
+ if (parameterNames.has(tagRoot)) {
1416
+ ts.forEachChild(node, visit);
1417
+ return;
1628
1418
  }
1419
+ const authoredName = node.attributes.properties.find(
1420
+ (property): property is ts.JsxAttribute =>
1421
+ ts.isJsxAttribute(property) && property.name.getText(sf) === 'name',
1422
+ );
1423
+ const label =
1424
+ authoredName?.initializer && ts.isStringLiteral(authoredName.initializer)
1425
+ ? authoredName.initializer.text
1426
+ : tag;
1427
+ const labelAttribute =
1428
+ authoredName?.initializer && ts.isJsxExpression(authoredName.initializer)
1429
+ ? `__volterLabel={${authoredName.initializer.expression?.getText(sf) ?? JSON.stringify(tag)}}`
1430
+ : `__volterLabel=${JSON.stringify(label)}`;
1431
+ sourceEdits.push({
1432
+ pos: node.attributes.end,
1433
+ text: ` __volterOid="${oid}" ${labelAttribute}`,
1434
+ });
1629
1435
  } else {
1630
1436
  // Host elements carry their identity directly.
1631
1437
  // Append after authored attributes. In R3F, an authored `userData={{…}}`
@@ -1651,24 +1457,9 @@ export function transformSource(
1651
1457
  }
1652
1458
  if (
1653
1459
  elementAttribute === 'userData-oid' &&
1654
- // NEVER on `<primitive>`. Its object is the GAME's, constructed before
1655
- // React ever sees it, and `userData={{}}` REPLACES that object's own
1656
- // `userData` wholesale — measured: a root carrying
1657
- // `markComponentRoot(node, 'Stage')` from its constructor came out of
1658
- // mount with nothing but the three editor keys, so every hierarchy
1659
- // mark a class-owned root sets was destroyed by the editor's own
1660
- // serve-time transform. An adopted object always HAS a `userData`
1661
- // (three sets one on every Object3D/geometry/material), which is
1662
- // precisely why the initializer below is unnecessary here and
1663
- // destructive. That initializer's real subjects are the resources R3F
1664
- // itself constructs.
1460
+ // Primitive objects already own userData. Do not replace it when adopting an object.
1665
1461
  tag !== 'primitive' &&
1666
- // ONLY where the constructed object has no `userData` of its own. The
1667
- // seed is a new `{}` on every render, and Fiber REPLACES `userData`
1668
- // whenever that prop changes: seeded on an Object3D, a material or a
1669
- // custom host, it wiped what the game kept there on each rerender
1670
- // (measured: a mesh's `userData.probe`, set once on mount, gone after
1671
- // 13 renders, leaving only the editor's keys).
1462
+ // Seed userData only on host types that do not construct their own object.
1672
1463
  SEEDED_USER_DATA_TAGS.has(tag) &&
1673
1464
  !node.attributes.properties.some(
1674
1465
  (property) => ts.isJsxAttribute(property) && property.name.getText(sf) === 'userData',
@@ -1699,7 +1490,6 @@ export function transformSource(
1699
1490
  if (opensDomSubtree) domSubtreeDepth--;
1700
1491
  if (enclosingOid !== undefined) jsxStack.pop();
1701
1492
  visit(node.closingElement);
1702
- if (pushed) componentStack.pop();
1703
1493
  return;
1704
1494
  }
1705
1495