@volter/editor-react 0.5.210 → 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.
- package/contributions/component-contracts.service.ts +1 -1
- package/dist-node/serving.mjs +14770 -1629
- package/package.json +20 -9
- package/serving/component-prop-worker.ts +3 -19
- package/serving/index.ts +20 -8
- package/serving/source-diagnostics.ts +311 -0
- package/serving/ui-oid-plugin.ts +154 -775
- package/src/source/component-prop-types.ts +6 -62
- package/src/source/drei-detailed-names.ts +24 -0
- package/src/source/oid-transform.ts +166 -376
- package/src/source/plan-csf-story.ts +3 -26
- package/src/source/plan-extract-component.ts +15 -60
- package/src/source/plan-fork-component.ts +25 -92
- package/src/source/plan-named-style.ts +3 -17
- package/src/source/plan-source-edit.ts +9 -40
- package/src/source/r3f-contract-resolver.ts +9 -103
- package/src/source/r3f-physics-binding.ts +28 -186
- package/src/source/r3f-project-contracts.ts +13 -103
- package/src/source/relative-import-specifier.ts +3 -18
- package/src/source/reparent-guard.ts +16 -61
- package/src/source/source-edit-request.ts +2 -20
- package/src/source/ts-ast.ts +5 -95
- package/src/source/utility-class-support.ts +4 -32
- package/src/source/writer.ts +74 -326
- package/src/source/r3f-diagnostic-index.ts +0 -165
- package/src/source/r3f-environment-binding.ts +0 -106
- package/src/source/r3f-joint-binding.ts +0 -228
- package/src/source/r3f-lod-binding.ts +0 -81
- package/src/source/r3f-particle-binding.ts +0 -213
- package/src/source/syntactic-prop-specs.ts +0 -296
|
@@ -1,26 +1,14 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The
|
|
3
|
-
* imports are followed to the component contracts and prop-shape type aliases
|
|
4
|
-
* they bring into scope. Pure TypeScript AST work over a {@link ContractHost}
|
|
5
|
-
* that supplies the three things a file system used to — read a file, test
|
|
6
|
-
* for one, join a relative specifier — so the walk itself is independent of
|
|
7
|
-
* WHERE the sources are read from. The dev server supplies them with
|
|
8
|
-
* `node:fs` and a stamp-checked cache (`r3f-project-contracts.ts`); any other
|
|
9
|
-
* reader supplies its own, and gets the same contracts rather than a second
|
|
10
|
-
* answer ("HeroBox does not expose a scale prop" while the dev server wrote
|
|
11
|
-
* it — one editor, two answers — is what this split exists to prevent).
|
|
12
|
-
*/
|
|
1
|
+
/** Resolve relative project imports to visible and exported R3F contracts and
|
|
2
|
+
* prop-shape aliases. The server supplies file reads and a stamp-checked cache. */
|
|
13
3
|
|
|
14
4
|
import ts from 'typescript';
|
|
15
5
|
import {
|
|
16
6
|
analyzeR3fComponentContracts,
|
|
17
7
|
builtinR3fContractsOfImport,
|
|
18
|
-
type ComponentPropSpec,
|
|
19
8
|
exportedR3fTypeAliases,
|
|
20
9
|
type R3fComponentContract,
|
|
21
10
|
type R3fTypeAlias,
|
|
22
11
|
} from './oid-transform';
|
|
23
|
-
import { localComponentPropSpecs } from './syntactic-prop-specs';
|
|
24
12
|
import { hasModifier, parseAuthoringTsx } from './ts-ast';
|
|
25
13
|
|
|
26
14
|
export interface ModuleAnalysis {
|
|
@@ -28,14 +16,9 @@ export interface ModuleAnalysis {
|
|
|
28
16
|
exported: Map<string, R3fComponentContract>;
|
|
29
17
|
/** This module's own `export type X = …` declarations, for its importers. */
|
|
30
18
|
exportedTypeAliases: Map<string, R3fTypeAlias>;
|
|
31
|
-
/** Syntactic declared-prop specs per exported component
|
|
32
|
-
* (`syntactic-prop-specs.ts`) — the checker-free surface attached to OID
|
|
33
|
-
* entries so override detection and Apply-to-Component gate on facts
|
|
34
|
-
* instead of an absent `ts.Program`. */
|
|
35
|
-
exportedProps: Map<string, ComponentPropSpec[]>;
|
|
36
19
|
}
|
|
37
20
|
|
|
38
|
-
|
|
21
|
+
const SOURCE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js'] as const;
|
|
39
22
|
|
|
40
23
|
/** What a resolver needs from its host. Paths are whatever vocabulary the host
|
|
41
24
|
* speaks (absolute on the server, project-relative in storage); the resolver
|
|
@@ -49,10 +32,8 @@ export interface ContractHost {
|
|
|
49
32
|
extname(file: string): string;
|
|
50
33
|
/** `<base>/<name>` — the `index.*` candidate under a directory specifier. */
|
|
51
34
|
join(base: string, name: string): string;
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
* run it; a host without one analyzes every time within one resolver. */
|
|
55
|
-
analyzeFileCached?(file: string, analyze: () => ModuleAnalysis): ModuleAnalysis;
|
|
35
|
+
/** Shared stamp-checked cache for one file's analysis. */
|
|
36
|
+
analyzeFileCached(file: string, analyze: () => ModuleAnalysis): ModuleAnalysis;
|
|
56
37
|
}
|
|
57
38
|
|
|
58
39
|
function resolveRelativeModule(
|
|
@@ -93,52 +74,6 @@ export class ProjectContractResolver {
|
|
|
93
74
|
|
|
94
75
|
constructor(private readonly host: ContractHost) {}
|
|
95
76
|
|
|
96
|
-
importedForSource(code: string, file: string): Map<string, R3fComponentContract> {
|
|
97
|
-
return this.importedBindings(parseAuthoringTsx(file, code), file);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
typeAliasesForSource(code: string, file: string): Map<string, R3fTypeAlias> {
|
|
101
|
-
return this.importedTypeAliases(parseAuthoringTsx(file, code), file);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Declared-prop specs for every component a file's JSX can name — its own
|
|
106
|
-
* top-level components plus its relative imports (a local declaration
|
|
107
|
-
* shadows an import, the language's own rule). This is what the hosted
|
|
108
|
-
* stamping hook attaches to OID entries in place of the dev server's
|
|
109
|
-
* checker-resolved specs.
|
|
110
|
-
*/
|
|
111
|
-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one bounded import-declaration pass, the same dispatch importedBindings carries.
|
|
112
|
-
propSpecsForSource(code: string, file: string): Map<string, ComponentPropSpec[]> {
|
|
113
|
-
const sf = parseAuthoringTsx(file, code);
|
|
114
|
-
const specs = new Map<string, ComponentPropSpec[]>();
|
|
115
|
-
for (const statement of sf.statements) {
|
|
116
|
-
if (
|
|
117
|
-
!ts.isImportDeclaration(statement) ||
|
|
118
|
-
!ts.isStringLiteral(statement.moduleSpecifier) ||
|
|
119
|
-
!statement.importClause
|
|
120
|
-
) {
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
const target = resolveRelativeModule(this.host, file, statement.moduleSpecifier.text);
|
|
124
|
-
if (!target) continue;
|
|
125
|
-
const exported = this.analyzeFile(target).exportedProps;
|
|
126
|
-
const defaultName = statement.importClause.name?.text;
|
|
127
|
-
const defaultSpecs = exported.get('default');
|
|
128
|
-
if (defaultName && defaultSpecs) specs.set(defaultName, defaultSpecs);
|
|
129
|
-
const bindings = statement.importClause.namedBindings;
|
|
130
|
-
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
131
|
-
for (const element of bindings.elements) {
|
|
132
|
-
const imported = exported.get(element.propertyName?.text ?? element.name.text);
|
|
133
|
-
if (imported) specs.set(element.name.text, imported);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
for (const [name, local] of localComponentPropSpecs(sf, this.importedTypeAliases(sf, file))) {
|
|
137
|
-
specs.set(name, local);
|
|
138
|
-
}
|
|
139
|
-
return specs;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
77
|
analyzeSource(code: string, file: string): ModuleAnalysis {
|
|
143
78
|
const sf = parseAuthoringTsx(file, code);
|
|
144
79
|
const imported = this.importedBindings(sf, file);
|
|
@@ -151,17 +86,10 @@ export class ProjectContractResolver {
|
|
|
151
86
|
imported,
|
|
152
87
|
this.importedTypeAliases(sf, file),
|
|
153
88
|
);
|
|
154
|
-
const props = localComponentPropSpecs(sf, this.importedTypeAliases(sf, file));
|
|
155
89
|
return {
|
|
156
90
|
visible,
|
|
157
91
|
exported: this.exportsOf(sf, file, visible, (target) => this.analyzeFile(target).exported),
|
|
158
92
|
exportedTypeAliases: exportedR3fTypeAliases(sf),
|
|
159
|
-
exportedProps: this.exportsOf(
|
|
160
|
-
sf,
|
|
161
|
-
file,
|
|
162
|
-
props,
|
|
163
|
-
(target) => this.analyzeFile(target).exportedProps,
|
|
164
|
-
),
|
|
165
93
|
};
|
|
166
94
|
}
|
|
167
95
|
|
|
@@ -173,7 +101,6 @@ export class ProjectContractResolver {
|
|
|
173
101
|
visible: new Map(),
|
|
174
102
|
exported: new Map(),
|
|
175
103
|
exportedTypeAliases: new Map(),
|
|
176
|
-
exportedProps: new Map(),
|
|
177
104
|
};
|
|
178
105
|
}
|
|
179
106
|
this.visiting.add(file);
|
|
@@ -185,13 +112,10 @@ export class ProjectContractResolver {
|
|
|
185
112
|
visible: new Map(),
|
|
186
113
|
exported: new Map(),
|
|
187
114
|
exportedTypeAliases: new Map(),
|
|
188
|
-
exportedProps: new Map(),
|
|
189
115
|
}
|
|
190
116
|
: this.analyzeSource(source, file);
|
|
191
117
|
};
|
|
192
|
-
const analysis = this.host.analyzeFileCached
|
|
193
|
-
? this.host.analyzeFileCached(file, analyze)
|
|
194
|
-
: analyze();
|
|
118
|
+
const analysis = this.host.analyzeFileCached(file, analyze);
|
|
195
119
|
this.cache.set(file, analysis);
|
|
196
120
|
return analysis;
|
|
197
121
|
} finally {
|
|
@@ -199,21 +123,8 @@ export class ProjectContractResolver {
|
|
|
199
123
|
}
|
|
200
124
|
}
|
|
201
125
|
|
|
202
|
-
/**
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* The component-contract walk above already follows relative imports; this
|
|
206
|
-
* is the same walk for the OTHER thing a shared module exports. Without it,
|
|
207
|
-
* `typeAllowsStandardRootProps` stopped at the file boundary, so a project
|
|
208
|
-
* that declares its canonical prop shape once — the thing the authoring
|
|
209
|
-
* convention is asking for — read as forwarding nothing, and every prefab
|
|
210
|
-
* annotated with it drew a permanent R3F002 telling it to do what it was
|
|
211
|
-
* already doing.
|
|
212
|
-
*
|
|
213
|
-
* Only relative specifiers resolve. An installed package's `.d.ts` is not
|
|
214
|
-
* source this walk can trust, and the built-in prop-type table is how a
|
|
215
|
-
* package surface earns recognition instead.
|
|
216
|
-
*/
|
|
126
|
+
/** Resolve imported prop-shape aliases under their local names. Only
|
|
127
|
+
* relative project sources are followed; package surfaces use builtin types. */
|
|
217
128
|
private importedTypeAliases(sf: ts.SourceFile, file: string): Map<string, R3fTypeAlias> {
|
|
218
129
|
const aliases = new Map<string, R3fTypeAlias>();
|
|
219
130
|
for (const statement of sf.statements) {
|
|
@@ -301,12 +212,7 @@ export class ProjectContractResolver {
|
|
|
301
212
|
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
|
|
302
213
|
? resolveRelativeModule(this.host, file, statement.moduleSpecifier.text)
|
|
303
214
|
: null;
|
|
304
|
-
// A
|
|
305
|
-
// it dropped the whole contract: racing-game reaches every one of its
|
|
306
|
-
// components through `src/models/index.ts`, so `<Ramp/>`, `<Train/>` and
|
|
307
|
-
// `<Vehicle/>` all read as "definition not in this file" and refused
|
|
308
|
-
// every transform write at their callsites. `export *` skips `default` by
|
|
309
|
-
// the language's own rule.
|
|
215
|
+
// A barrel's `export *` carries every named contract except `default`.
|
|
310
216
|
if (!statement.exportClause) {
|
|
311
217
|
if (!target) continue;
|
|
312
218
|
for (const [name, contract] of moduleExports(target)) {
|
|
@@ -1,144 +1,20 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* ## The measured defect
|
|
7
|
-
*
|
|
8
|
-
* `@react-three/cannon` drives a node by writing its MATRIX:
|
|
9
|
-
* `object.matrixAutoUpdate = false; object.matrix.copy(compose(p, q, ONE))`
|
|
10
|
-
* (`node_modules/@react-three/cannon/dist/index.js`'s `apply`). Two consequences,
|
|
11
|
-
* both of which the editor got wrong before this module existed:
|
|
12
|
-
*
|
|
13
|
-
* - The node's own `position`/`rotation`/`scale` JSX props are DEAD. Writing
|
|
14
|
-
* one produces a file that says the object is somewhere it will never be:
|
|
15
|
-
* the body re-spawns from the hook's argument and overwrites the matrix on
|
|
16
|
-
* the next frame. Measured on the racing-game ingest — a moved chassis wrote
|
|
17
|
-
* its leaf element and the remount re-spawned at the binding's
|
|
18
|
-
* `[-110, 0.75, 220]`, digit for digit.
|
|
19
|
-
* - `scale` goes with them: `apply` composes with a fixed `(1,1,1)`, so a
|
|
20
|
-
* cannon body erases its node's scale as surely as its position.
|
|
21
|
-
*
|
|
22
|
-
* The transform is therefore owned by the BINDING — the `useBox(…)` /
|
|
23
|
-
* `useCompoundBody(…)` call that attached the body to this element's ref — and
|
|
24
|
-
* an honest editor writes where the binding reads, or refuses by name.
|
|
25
|
-
*
|
|
26
|
-
* ## Where the binding reads, measured rather than assumed
|
|
27
|
-
*
|
|
28
|
-
* Over the whole vendored cannon corpus (`vendor/games/racing-game`: `Chassis`,
|
|
29
|
-
* `Wheel`, `Ramp`, `Train`, `Goal`, `Heightmap`, `BoundingBox`) **not one hook
|
|
30
|
-
* argument carries an inline position literal.** Every binding takes its spawn
|
|
31
|
-
* from the component's OWN PROPS — `useBox(() => ({ …props }))`, or the
|
|
32
|
-
* shorthand `useBox(() => ({ args, position, rotation }))` over a destructured
|
|
33
|
-
* prop — so the literal that places the body is the one at the component's
|
|
34
|
-
* CALLSITE (`<Ramp position={[2, -1, 168.55]} />`, `App.tsx:57`). That is the
|
|
35
|
-
* prop literal the write routes to, and the ordinary JSX prop writer already
|
|
36
|
-
* reaches it; what was missing was a contract that admits the forwarding is
|
|
37
|
-
* real, which is `forwarded` below.
|
|
38
|
-
*
|
|
39
|
-
* `literal` is the other shape — a spawn spelled at the hook itself. It is
|
|
40
|
-
* recorded so a refusal can NAME it (`position is owned by useBox at
|
|
41
|
-
* Ramp.tsx:8`) rather than pretending the element's own prop is writable. No
|
|
42
|
-
* writer for it ships: a hook argument is not a JSX element, it has no oid, and
|
|
43
|
-
* building an address for a shape with zero occurrences in the corpus would be
|
|
44
|
-
* a writer with no caller.
|
|
45
|
-
*
|
|
46
|
-
* ## Why this is a SOURCE analysis and not the runtime flag
|
|
47
|
-
*
|
|
48
|
-
* `matrixAutoUpdate === false` is a true runtime tell for cannon, and a useless
|
|
49
|
-
* one for routing: it says a driver owns the node, never WHERE the driver reads
|
|
50
|
-
* its spawn. It is also not general — `@react-three/rapier` writes
|
|
51
|
-
* `object.position.copy(…)`/`object.quaternion.copy(…)` and leaves
|
|
52
|
-
* `matrixAutoUpdate` alone, so the flag misses rapier entirely while the source
|
|
53
|
-
* says plainly that `<RigidBody position={…}>` is the spawn (which is why the
|
|
54
|
-
* built-in rapier contract in `oid-transform.ts` already routes it correctly).
|
|
55
|
-
* The binding is a fact about the file; the write anchor has to be too.
|
|
56
|
-
*
|
|
57
|
-
* Nothing here is a general "physics abstraction": it recognizes ONE package's
|
|
58
|
-
* body hooks by import specifier, the same hand-audited way
|
|
59
|
-
* `BUILTIN_R3F_CONTRACTS` recognizes `<RigidBody>`, because guessing at an
|
|
60
|
-
* unknown library's ownership is how an editor writes props a library ignores.
|
|
61
|
-
*/
|
|
1
|
+
/** Analyze Cannon body hooks that own a JSX element's transform. Forwarded
|
|
2
|
+
* spawn props contribute to the component contract; channels driven through
|
|
3
|
+
* the body API each frame remain simulation owned. Bind refs within their
|
|
4
|
+
* enclosing scopes rather than guessing from runtime matrix flags. */
|
|
62
5
|
|
|
63
6
|
import ts from 'typescript';
|
|
64
7
|
import { enclosingScope, refIdentifier } from './ts-ast';
|
|
65
8
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
* instantiates hands `channel` to a body binding inside itself, so the
|
|
76
|
-
* literal written HERE is the spawn the simulation reads
|
|
77
|
-
* (`oid-transform.ts`'s `bodyForwarded`). */
|
|
78
|
-
readonly r3fAuthoring?: { readonly bodyForwarded?: readonly string[] | undefined } | undefined;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Is this element's `channel` PLACED BY A SIMULATED BODY — the fact that makes
|
|
83
|
-
* an edit here a `physics-binding` write rather than an ordinary `source-prop`
|
|
84
|
-
* one, in `WriteAnchorKind`'s vocabulary (`@volter/project/adapter`'s `authoring.ts`).
|
|
85
|
-
*
|
|
86
|
-
* Two shapes, and both are the same question ("does a body read this value?")
|
|
87
|
-
* asked of the two places a body can be attached, which is why they answer from
|
|
88
|
-
* one function rather than one per lane:
|
|
89
|
-
*
|
|
90
|
-
* - the element carries the binding itself (`<mesh ref={ref}>` whose ref a
|
|
91
|
-
* `useBox` bound) — its own props are dead on arrival, so the write is
|
|
92
|
-
* refused, and the KIND still says which lane owns the value;
|
|
93
|
-
* - the element is a CALLSITE whose component hands the channel to a binding
|
|
94
|
-
* inside it (`<Ramp position={…}/>`, `<CitadelTower position={…}/>`) — the
|
|
95
|
-
* literal a body actually spawns from, and the WRITABLE case. This is the
|
|
96
|
-
* one whose authored value has to survive the re-settle, which is the whole
|
|
97
|
-
* reason `physics-binding` is a kind of its own.
|
|
98
|
-
*
|
|
99
|
-
* Deliberately beside {@link physicsRefusal}: the refusal names who owns the
|
|
100
|
-
* placement and this names the lane, and two derivations of "who owns this
|
|
101
|
-
* placement" living in different files is exactly how a subject came to be
|
|
102
|
-
* classified in one lane while its edit travelled another.
|
|
103
|
-
*/
|
|
104
|
-
export function bodyPlacedChannel(
|
|
105
|
-
element: PhysicsBoundElement | undefined,
|
|
106
|
-
channel: string,
|
|
107
|
-
): boolean {
|
|
108
|
-
if (channel !== 'position' && channel !== 'rotation' && channel !== 'scale') return false;
|
|
109
|
-
if (element?.physicsBinding) return true;
|
|
110
|
-
return element?.r3fAuthoring?.bodyForwarded?.includes(channel) === true;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* WHY a write to this element's own `channel` would be lost — `null` when no
|
|
115
|
-
* body owns it, which is the ordinary case.
|
|
116
|
-
*
|
|
117
|
-
* ONE derivation, read by both writers (the first-party R3F source lane's
|
|
118
|
-
* `transformEditability` and the ingest lane's `oid-source-persistence`), so
|
|
119
|
-
* the two can never disagree about who owns a placement. Each sentence names
|
|
120
|
-
* the binding and where to go instead; "does not forward position" would send
|
|
121
|
-
* an author to add a prop the body would ignore.
|
|
122
|
-
*/
|
|
123
|
-
export function physicsRefusal(
|
|
124
|
-
element: PhysicsBoundElement | undefined,
|
|
125
|
-
channel: string,
|
|
126
|
-
): string | null {
|
|
127
|
-
const binding = element?.physicsBinding;
|
|
128
|
-
if (!binding) return null;
|
|
129
|
-
if (channel !== 'position' && channel !== 'rotation' && channel !== 'scale') return null;
|
|
130
|
-
const at = `${element!.file.split('/').pop() ?? element!.file}:${binding.line}`;
|
|
131
|
-
const owned = `${channel} is owned by ${binding.hook} at ${at}`;
|
|
132
|
-
if ((binding.forwarded as readonly string[]).includes(channel)) {
|
|
133
|
-
return `${owned}, which reads this component's own ${channel} prop — write it where the instance is placed, not on this element`;
|
|
134
|
-
}
|
|
135
|
-
if ((binding.literal as readonly string[]).includes(channel)) {
|
|
136
|
-
return `${owned} — the body spawns from that hook's own argument, and a hook argument has no write anchor`;
|
|
137
|
-
}
|
|
138
|
-
// Including `scale`, always: the body re-composes the node's matrix with a
|
|
139
|
-
// fixed unit scale (`@react-three/cannon`'s `apply`), so a scale written here
|
|
140
|
-
// is erased on the next frame exactly like a position.
|
|
141
|
-
return `${owned} — this node is placed by the simulation, so a ${channel} written here is overwritten on the next frame`;
|
|
9
|
+
type PhysicsChannel = 'position' | 'rotation' | 'scale';
|
|
10
|
+
/** Transform channels owned by an element's Cannon body hook. */
|
|
11
|
+
export interface R3fPhysicsBinding {
|
|
12
|
+
/** Channels whose spawn the hook takes from the component's own props — the
|
|
13
|
+
* write belongs at this component's CALLSITE. */
|
|
14
|
+
forwarded: PhysicsChannel[];
|
|
15
|
+
/** Transform channels written through the body API inside useFrame.
|
|
16
|
+
* Omit the field when no channel is driven. */
|
|
17
|
+
apiDriven?: PhysicsChannel[];
|
|
142
18
|
}
|
|
143
19
|
|
|
144
20
|
/**
|
|
@@ -164,10 +40,9 @@ const CANNON_BODY_HOOKS = new Set([
|
|
|
164
40
|
|
|
165
41
|
const PHYSICS_PACKAGES = new Set(['@react-three/cannon']);
|
|
166
42
|
|
|
167
|
-
/**
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const names = new Map<string, string>();
|
|
43
|
+
/** Local names imported as Cannon body hooks, including aliases. */
|
|
44
|
+
function bodyHookNames(sf: ts.SourceFile): Set<string> {
|
|
45
|
+
const names = new Set<string>();
|
|
171
46
|
for (const statement of sf.statements) {
|
|
172
47
|
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
173
48
|
continue;
|
|
@@ -177,7 +52,7 @@ function bodyHookNames(sf: ts.SourceFile): Map<string, string> {
|
|
|
177
52
|
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
178
53
|
for (const element of bindings.elements) {
|
|
179
54
|
const imported = element.propertyName?.text ?? element.name.text;
|
|
180
|
-
if (CANNON_BODY_HOOKS.has(imported)) names.
|
|
55
|
+
if (CANNON_BODY_HOOKS.has(imported)) names.add(element.name.text);
|
|
181
56
|
}
|
|
182
57
|
}
|
|
183
58
|
return names;
|
|
@@ -238,14 +113,6 @@ function bodyArgumentObject(call: ts.CallExpression): ts.ObjectLiteralExpression
|
|
|
238
113
|
return undefined;
|
|
239
114
|
}
|
|
240
115
|
|
|
241
|
-
function isVec3Literal(expression: ts.Expression): boolean {
|
|
242
|
-
if (!ts.isArrayLiteralExpression(expression) || expression.elements.length !== 3) return false;
|
|
243
|
-
return expression.elements.every((element) => {
|
|
244
|
-
const inner = ts.isPrefixUnaryExpression(element) ? element.operand : element;
|
|
245
|
-
return ts.isNumericLiteral(inner);
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
|
|
249
116
|
/** Does this expression read one of the enclosing component's own props? */
|
|
250
117
|
function readsProp(expression: ts.Expression, scope: PropScope, channel: PhysicsChannel): boolean {
|
|
251
118
|
if (ts.isIdentifier(expression)) return scope.named.has(expression.text);
|
|
@@ -257,14 +124,8 @@ function readsProp(expression: ts.Expression, scope: PropScope, channel: Physics
|
|
|
257
124
|
);
|
|
258
125
|
}
|
|
259
126
|
|
|
260
|
-
/**
|
|
261
|
-
|
|
262
|
-
*
|
|
263
|
-
* LAST WINS, the way an object literal actually evaluates: `{ position: […],
|
|
264
|
-
* ...props }` is placed by `props`, and `{ ...props, position: […] }` by the
|
|
265
|
-
* literal. Getting that backwards would name the wrong owner in a refusal.
|
|
266
|
-
*/
|
|
267
|
-
type ChannelOwner = 'forwarded' | 'literal' | 'expression';
|
|
127
|
+
/** The last assignment to each channel determines whether it forwards props. */
|
|
128
|
+
type ChannelOwner = 'forwarded' | 'expression';
|
|
268
129
|
|
|
269
130
|
/**
|
|
270
131
|
* What ONE property of a hook argument says about placement.
|
|
@@ -296,29 +157,20 @@ function propertyOwnership(
|
|
|
296
157
|
if (!ts.isPropertyAssignment(property) || !ts.isIdentifier(property.name)) return [];
|
|
297
158
|
const name = property.name.text;
|
|
298
159
|
if (!isPhysicsChannel(name)) return [];
|
|
299
|
-
if (isVec3Literal(property.initializer)) return [[name, 'literal']];
|
|
300
160
|
return [[name, readsProp(property.initializer, scope, name) ? 'forwarded' : 'expression']];
|
|
301
161
|
}
|
|
302
162
|
|
|
303
163
|
function channelOwnership(
|
|
304
164
|
literalObject: ts.ObjectLiteralExpression,
|
|
305
165
|
scope: PropScope,
|
|
306
|
-
):
|
|
166
|
+
): PhysicsChannel[] {
|
|
307
167
|
const owner = new Map<PhysicsChannel, ChannelOwner>();
|
|
308
168
|
for (const property of literalObject.properties) {
|
|
309
169
|
for (const [channel, kind] of propertyOwnership(property, scope)) owner.set(channel, kind);
|
|
310
170
|
}
|
|
311
|
-
|
|
312
|
-
[...owner.entries()].filter(([, value]) => value === kind).map(([channel]) => channel);
|
|
313
|
-
return { forwarded: pick('forwarded'), literal: pick('literal') };
|
|
171
|
+
return [...owner.entries()].filter(([, value]) => value === 'forwarded').map(([channel]) => channel);
|
|
314
172
|
}
|
|
315
173
|
|
|
316
|
-
/**
|
|
317
|
-
* The ref identifier a body hook binds — either the ref it was HANDED
|
|
318
|
-
* (`useBox(fn, ref)`, the forwardRef spelling) or the one it HANDS BACK
|
|
319
|
-
* (`const [ref] = useBox(fn)`, the plain spelling). Both occur in the vendored
|
|
320
|
-
* corpus and they are the same binding.
|
|
321
|
-
*/
|
|
322
174
|
/** The API identifier a body hook's tuple hands back (`const [, api] = useBox(…)`),
|
|
323
175
|
* or null when the call does not destructure one. */
|
|
324
176
|
function boundApiName(call: ts.CallExpression): string | null {
|
|
@@ -363,6 +215,7 @@ function apiDrivenChannels(
|
|
|
363
215
|
return [...found];
|
|
364
216
|
}
|
|
365
217
|
|
|
218
|
+
/** Resolve a hook's returned ref or its explicitly supplied ref argument. */
|
|
366
219
|
function boundRefName(call: ts.CallExpression): string | null {
|
|
367
220
|
const declaration = call.parent;
|
|
368
221
|
if (ts.isVariableDeclaration(declaration) && ts.isArrayBindingPattern(declaration.name)) {
|
|
@@ -377,14 +230,7 @@ function boundRefName(call: ts.CallExpression): string | null {
|
|
|
377
230
|
return null;
|
|
378
231
|
}
|
|
379
232
|
|
|
380
|
-
/**
|
|
381
|
-
* Every JSX element in `sf` whose ref a physics body binds, mapped to the
|
|
382
|
-
* binding that owns its transform.
|
|
383
|
-
*
|
|
384
|
-
* Keyed by the OPENING element so both readers join on the same node: the
|
|
385
|
-
* component-contract analysis (which asks about a component's root) and the
|
|
386
|
-
* stamping transform (which asks about each element it stamps).
|
|
387
|
-
*/
|
|
233
|
+
/** Join scoped body-hook refs to JSX elements for component-contract analysis. */
|
|
388
234
|
export function physicsBindingsByElement(
|
|
389
235
|
sf: ts.SourceFile,
|
|
390
236
|
): Map<ts.JsxOpeningLikeElement, R3fPhysicsBinding> {
|
|
@@ -396,22 +242,19 @@ export function physicsBindingsByElement(
|
|
|
396
242
|
* bound it: two components may each call their ref `ref`. */
|
|
397
243
|
const byScope = new Map<ts.Node | null, Map<string, R3fPhysicsBinding>>();
|
|
398
244
|
|
|
399
|
-
const record = (call: ts.CallExpression
|
|
245
|
+
const record = (call: ts.CallExpression): void => {
|
|
400
246
|
const refName = boundRefName(call);
|
|
401
247
|
if (!refName) return;
|
|
402
248
|
const scope = enclosingScope(call);
|
|
403
249
|
const argument = bodyArgumentObject(call);
|
|
404
|
-
const
|
|
250
|
+
const forwarded = argument
|
|
405
251
|
? channelOwnership(argument, propScopeOf(scope?.parameters))
|
|
406
|
-
:
|
|
407
|
-
const { line } = sf.getLineAndCharacterOfPosition(call.getStart(sf));
|
|
252
|
+
: [];
|
|
408
253
|
const api = boundApiName(call);
|
|
409
254
|
const driven = api ? apiDrivenChannels(scope, sf, api) : [];
|
|
410
255
|
const scoped = byScope.get(scope) ?? new Map<string, R3fPhysicsBinding>();
|
|
411
256
|
scoped.set(refName, {
|
|
412
|
-
|
|
413
|
-
line: line + 1,
|
|
414
|
-
...ownership,
|
|
257
|
+
forwarded,
|
|
415
258
|
...(driven.length > 0 ? { apiDriven: driven } : {}),
|
|
416
259
|
});
|
|
417
260
|
byScope.set(scope, scoped);
|
|
@@ -419,8 +262,7 @@ export function physicsBindingsByElement(
|
|
|
419
262
|
|
|
420
263
|
const collect = (node: ts.Node): void => {
|
|
421
264
|
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
|
422
|
-
|
|
423
|
-
if (hook) record(node, hook);
|
|
265
|
+
if (hooks.has(node.expression.text)) record(node);
|
|
424
266
|
}
|
|
425
267
|
ts.forEachChild(node, collect);
|
|
426
268
|
};
|
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
isBuiltinR3fContract,
|
|
16
16
|
type R3fAuthoringDiagnostic,
|
|
17
17
|
type R3fComponentContract,
|
|
18
|
-
type R3fTypeAlias,
|
|
19
18
|
runtimeMotionRefs,
|
|
20
19
|
} from './oid-transform';
|
|
21
20
|
import {
|
|
@@ -25,41 +24,9 @@ import {
|
|
|
25
24
|
} from './r3f-contract-resolver';
|
|
26
25
|
import { parseAuthoringTsx } from './ts-ast';
|
|
27
26
|
|
|
28
|
-
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* every existing caller already uses. */
|
|
32
|
-
export type { R3fAuthoringDiagnostic };
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* THE ON-DISK HALF OF THE WALK, CACHED ACROSS CALLS — stamped by the file's
|
|
36
|
-
* own bytes, so it still "naturally tracks the latest bytes on disk without a
|
|
37
|
-
* cache invalidation protocol" (the property the fresh-resolver-per-call rule
|
|
38
|
-
* below exists to keep), while no longer re-deriving the same unchanged module
|
|
39
|
-
* once per importer.
|
|
40
|
-
*
|
|
41
|
-
* WHY IT HAD TO EXIST. `uiOidPlugin`'s `transform` builds one resolver per
|
|
42
|
-
* MODULE TRANSFORM (`vite-plugin-ui-oid.ts`, `importedR3fContracts(code,
|
|
43
|
-
* clean)`), and each resolver walks that module's whole transitive
|
|
44
|
-
* RELATIVE-import closure with `ts.createSourceFile`. The per-resolver cache
|
|
45
|
-
* therefore only ever helped inside one file's walk and was thrown away
|
|
46
|
-
* immediately after, so a project's shared modules were re-parsed once per
|
|
47
|
-
* importer — quadratic in the shared-component fan-in, and invisible on a
|
|
48
|
-
* small project. Measured on an imported 3D FPS project port (528 source files,
|
|
49
|
-
* ~200 project modules served during one editor boot), V8 CPU profile of the
|
|
50
|
-
* dev server across the whole ready -> page-bound window: **46.9s of 72.7s
|
|
51
|
-
* sampled (64.5%) inside `typescript.js`**, entered from here and from
|
|
52
|
-
* `oid-transform.ts`. The server's event loop was blocked ~80-90% of every
|
|
53
|
-
* 5s bucket for two minutes, which is why a static `editor-mark.svg` took
|
|
54
|
-
* 38s to serve and the browser's own module waterfall could not start.
|
|
55
|
-
*
|
|
56
|
-
* THE STAMP IS TAKEN TWICE, AROUND THE READ. A single stat cannot tell a
|
|
57
|
-
* concurrent save from a quiet file: stat-then-read caches new bytes under an
|
|
58
|
-
* old stamp, read-then-stat caches old bytes under a new one, and either way
|
|
59
|
-
* the entry is stale FOREVER because its stamp looks current. Only a
|
|
60
|
-
* before/after pair that AGREES proves nothing was written across the read;
|
|
61
|
-
* a disagreeing pair is simply not cached (the analysis is still returned).
|
|
62
|
-
*/
|
|
27
|
+
/** Cache disk analysis by file stamp. Matching stats before and after the
|
|
28
|
+
* read establish that the source stayed unchanged; concurrent saves produce
|
|
29
|
+
* uncached analysis. A fresh resolver joins the current import closure. */
|
|
63
30
|
const analysisByFile = new Map<string, { stamp: string; analysis: ModuleAnalysis }>();
|
|
64
31
|
/** Bound: a dev session may open many projects, and nothing else evicts. */
|
|
65
32
|
const MAX_CACHED_ANALYSES = 4096;
|
|
@@ -105,37 +72,11 @@ function newResolver(): ProjectContractResolver {
|
|
|
105
72
|
return new ProjectContractResolver(nodeContractHost);
|
|
106
73
|
}
|
|
107
74
|
|
|
108
|
-
/** Resolve the contracts of component identifiers imported into one source
|
|
109
|
-
* module. A fresh resolver per call makes save/HMR behavior naturally track
|
|
110
|
-
* the latest bytes on disk without a cache invalidation protocol; the walk's
|
|
111
|
-
* on-disk half reuses {@link analysisByFile}, which keeps that property by
|
|
112
|
-
* stamping every entry with the bytes it was derived from. */
|
|
113
|
-
export function importedR3fContracts(
|
|
114
|
-
code: string,
|
|
115
|
-
file: string,
|
|
116
|
-
): Map<string, R3fComponentContract> {
|
|
117
|
-
return newResolver().importedForSource(code, file);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** The prop-shape type aliases one module's relative imports bring into
|
|
121
|
-
* scope, under their local names — the alias half of `importedR3fContracts`,
|
|
122
|
-
* and the other input `transformSource` needs to read a shared prop shape. */
|
|
123
|
-
export function importedR3fTypeAliases(code: string, file: string): Map<string, R3fTypeAlias> {
|
|
124
|
-
return newResolver().typeAliasesForSource(code, file);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** All local and imported component bindings visible to one module. Used by
|
|
128
|
-
* the live index endpoint to refresh metadata after a shared component HMRs. */
|
|
129
|
-
export function visibleR3fContracts(code: string, file: string): Map<string, R3fComponentContract> {
|
|
130
|
-
return newResolver().analyzeSource(code, file).visible;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
75
|
/**
|
|
134
76
|
* The component a `default` export names, when the export is
|
|
135
77
|
* `export default World` or `export default function World() {…}`.
|
|
136
78
|
*
|
|
137
|
-
* A three root's entry module default-exports its component
|
|
138
|
-
* the editor's `roots/r3f-root.tsx`) — the same contract dom roots use. That component IS the
|
|
79
|
+
* A three root's entry module default-exports its component — the same contract dom roots use. That component IS the
|
|
139
80
|
* adapter root, so the single-spatial-root rule must not fire on it: a world
|
|
140
81
|
* legitimately returns a fragment of lights, scenery and players.
|
|
141
82
|
*/
|
|
@@ -190,17 +131,8 @@ function returnedRootComponent(element: ts.JsxElement | ts.JsxSelfClosingElement
|
|
|
190
131
|
return null;
|
|
191
132
|
}
|
|
192
133
|
|
|
193
|
-
/**
|
|
194
|
-
*
|
|
195
|
-
* authored transform channel to it.
|
|
196
|
-
*
|
|
197
|
-
* A simulation-owned root is NOT that (FX-9/PD-11). The component's own source
|
|
198
|
-
* already says who writes the transform — it takes no transform prop and drives
|
|
199
|
-
* its root ref every frame — so asking it to "keep instances movable" asks for
|
|
200
|
-
* an authored placement the next tick overwrites. The warning was permanent and
|
|
201
|
-
* unactionable on exactly the components (prey, projectiles, anything mapped
|
|
202
|
-
* over simulation state) it fired on most.
|
|
203
|
-
*/
|
|
134
|
+
/** Warn when a single native root omits authored transform forwarding.
|
|
135
|
+
* Simulation-owned roots accept no authored transform and are excluded. */
|
|
204
136
|
function needsForwardedTransforms(
|
|
205
137
|
contract: R3fComponentContract | undefined,
|
|
206
138
|
): contract is R3fComponentContract {
|
|
@@ -245,32 +177,10 @@ function isKeyedListItem(
|
|
|
245
177
|
}
|
|
246
178
|
|
|
247
179
|
export interface R3fAuthoringDiagnosticsOptions {
|
|
248
|
-
/**
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
|
|
252
|
-
* import reach. Absent or `false` means the caller resolved something else,
|
|
253
|
-
* or resolved nothing, and this analyzer stays silent.
|
|
254
|
-
*
|
|
255
|
-
* It USED to default to a source-text sniff — does this file's text name
|
|
256
|
-
* `@react-three/fiber`? — and that sniff is the reason a project could not
|
|
257
|
-
* centralize its R3F imports: a terminal whose 138 components take their
|
|
258
|
-
* prop shape from one shared `prefab.ts` and otherwise render nothing but
|
|
259
|
-
* intrinsic `<group>`/`<mesh>` tags had 12 files pass it and 126 SILENTLY
|
|
260
|
-
* SKIPPED, reported as no findings. Silence that cannot be distinguished
|
|
261
|
-
* from a clean bill of health is the worst thing an analyzer can produce,
|
|
262
|
-
* and reach answers all 138 because reach does not care where a project
|
|
263
|
-
* keeps its imports.
|
|
264
|
-
*
|
|
265
|
-
* Refusing to answer without a resolved surface is not a smaller analyzer,
|
|
266
|
-
* it is an honest one: a project's DOM regions run through here too
|
|
267
|
-
* (`project-validation.ts` validates every source file, whatever its
|
|
268
|
-
* adapter), and every rule below would fire on a `<div>`-rooted component —
|
|
269
|
-
* R3F002 would tell it to forward `position`. A caller with no region answer
|
|
270
|
-
* has an AMBIGUOUS file, which `resolveOidSurface` already reports as
|
|
271
|
-
* `OID001`/`OID002` with the declaration that fixes it.
|
|
272
|
-
*/
|
|
273
|
-
knownThreeSurface?: boolean;
|
|
180
|
+
/** Whether the caller resolved this file to a stage region through declared
|
|
181
|
+
* includes, manifest roots and import reach. False suppresses R3F-specific
|
|
182
|
+
* diagnostics for DOM or unresolved regions. */
|
|
183
|
+
knownStageSurface: boolean;
|
|
274
184
|
}
|
|
275
185
|
|
|
276
186
|
/** Convention diagnostics for ordinary R3F source. These are warnings, not a
|
|
@@ -279,16 +189,16 @@ export interface R3fAuthoringDiagnosticsOptions {
|
|
|
279
189
|
export function r3fAuthoringDiagnostics(
|
|
280
190
|
code: string,
|
|
281
191
|
file: string,
|
|
282
|
-
opts
|
|
192
|
+
opts: R3fAuthoringDiagnosticsOptions,
|
|
283
193
|
): R3fAuthoringDiagnostic[] {
|
|
284
|
-
if (!opts
|
|
194
|
+
if (!opts.knownStageSurface) return [];
|
|
285
195
|
const analysis = newResolver().analyzeSource(code, file);
|
|
286
196
|
const sf = parseAuthoringTsx(file, code);
|
|
287
197
|
const diagnostics: R3fAuthoringDiagnostic[] = [];
|
|
288
198
|
const diagnosedDefinitions = new Set<string>();
|
|
289
199
|
const diagnosedMultiRoots = new Set<string>();
|
|
290
200
|
const diagnosedMotionRoots = new Set<string>();
|
|
291
|
-
//
|
|
201
|
+
// The default-exported component is the adapter root.
|
|
292
202
|
const adapterRootComponents = new Set<string>();
|
|
293
203
|
const defaultExport = defaultExportedComponent(sf);
|
|
294
204
|
if (defaultExport) adapterRootComponents.add(defaultExport);
|