embedded-react 0.1.0
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/LICENSE +201 -0
- package/NOTICE +58 -0
- package/README.md +224 -0
- package/aot/compile.mjs +3066 -0
- package/aot/screenshot-smoke.mjs +110 -0
- package/aot/style-map.mjs +248 -0
- package/assets/bake-font.mjs +190 -0
- package/assets/bake-image.mjs +50 -0
- package/assets/build-builtin-font.mjs +51 -0
- package/assets/emit-c.mjs +187 -0
- package/assets/emit-container.mjs +121 -0
- package/assets/emit-pack.mjs +128 -0
- package/assets/index.mjs +72 -0
- package/assets/rasterize.mjs +169 -0
- package/build.mjs +136 -0
- package/pack-container.mjs +161 -0
- package/package.json +79 -0
- package/persist-transform.mjs +106 -0
- package/src/embedded-react/Animated.js +352 -0
- package/src/embedded-react/AppRegistry.js +49 -0
- package/src/embedded-react/Easing.js +39 -0
- package/src/embedded-react/LayoutAnimation.js +45 -0
- package/src/embedded-react/Platform.js +26 -0
- package/src/embedded-react/StyleSheet.js +36 -0
- package/src/embedded-react/components.js +44 -0
- package/src/embedded-react/imperative.js +68 -0
- package/src/embedded-react/index.js +52 -0
- package/src/embedded-react/layout-anim-config.js +91 -0
- package/src/embedded-react/split-style.js +58 -0
- package/src/embedded-react/svg-ops.js +564 -0
- package/src/embedded-react/usePersistentState.js +69 -0
- package/src/host-config.js +196 -0
- package/src/native-ui.js +24 -0
- package/src/props.js +183 -0
- package/src/renderer.js +57 -0
package/build.mjs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Cory Lamming
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Bundles a demo (React app + reconciler + host config) into a single classic (IIFE) script that
|
|
18
|
+
// QuickJS can run with a plain JS_Eval, and bakes the images/fonts the demo imports into a generated
|
|
19
|
+
// C translation unit (dist/assets.generated.c) exposing er_register_assets(). Globals the bundle
|
|
20
|
+
// expects at runtime (NativeUI, screen, console, timer shims) are provided by the C host (e.g.,
|
|
21
|
+
// examples/linux/main_js.c); the example firmware compiles the generated assets and calls
|
|
22
|
+
// er_register_assets() at boot.
|
|
23
|
+
//
|
|
24
|
+
// Demos live in the top-level demos/ folder, one folder per demo. Pick one with:
|
|
25
|
+
// npm run build # default demo (thermostat)
|
|
26
|
+
// npm run build -- marine-dash # a specific demo by folder name
|
|
27
|
+
// Outputs are always dist/app.bundle.js + dist/assets.generated.{c,h} — the single "active" app the
|
|
28
|
+
// example hosts pick up.
|
|
29
|
+
import { build } from 'esbuild';
|
|
30
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
|
+
import { dirname, resolve, basename } from 'node:path';
|
|
32
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
33
|
+
import { bakeAssets } from './assets/index.mjs';
|
|
34
|
+
|
|
35
|
+
const here = dirname(fileURLToPath(import.meta.url)); // bridges/quickjs/js
|
|
36
|
+
const repoRoot = resolve(here, '../../..');
|
|
37
|
+
const demosDir = resolve(repoRoot, 'demos');
|
|
38
|
+
const libEntry = resolve(here, 'src/embedded-react/index.js');
|
|
39
|
+
const nodeModules = resolve(here, 'node_modules');
|
|
40
|
+
const distDir = resolve(here, 'dist');
|
|
41
|
+
|
|
42
|
+
const DEFAULT_DEMO = 'thermostat';
|
|
43
|
+
const demo = process.argv[2] || process.env.DEMO || DEFAULT_DEMO;
|
|
44
|
+
const demoDir = resolve(demosDir, demo);
|
|
45
|
+
const entry = resolve(demoDir, 'index.jsx');
|
|
46
|
+
|
|
47
|
+
if (!existsSync(entry)) {
|
|
48
|
+
const available = existsSync(demosDir)
|
|
49
|
+
? readdirSync(demosDir, { withFileTypes: true })
|
|
50
|
+
.filter((d) => d.isDirectory())
|
|
51
|
+
.map((d) => d.name)
|
|
52
|
+
: [];
|
|
53
|
+
console.error(`Demo "${demo}" not found (expected ${entry}).`);
|
|
54
|
+
console.error(`Available demos: ${available.join(', ') || '(none)'}`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Asset discovery is import-driven: an `import x from './x.png'` (image) or `import F from './F.ttf'`
|
|
59
|
+
// (font) is intercepted here. The import resolves to the asset's NAME (its file basename) — the
|
|
60
|
+
// string an <Image source>/imageName looks up, or the family a fontFamily uses — and the path is
|
|
61
|
+
// recorded, so it gets baked below. Only what the app imports is baked.
|
|
62
|
+
const images = new Map(); // name -> path
|
|
63
|
+
const fonts = new Map(); // family -> path
|
|
64
|
+
const assetPlugin = {
|
|
65
|
+
name: 'embedded-react-assets',
|
|
66
|
+
setup(build) {
|
|
67
|
+
build.onLoad({ filter: /\.(png|jpe?g|webp|gif|bmp)$/i }, (args) => {
|
|
68
|
+
const name = basename(args.path).replace(/\.[^.]+$/, '');
|
|
69
|
+
images.set(name, args.path);
|
|
70
|
+
return { contents: `module.exports = ${JSON.stringify(name)};`, loader: 'js' };
|
|
71
|
+
});
|
|
72
|
+
build.onLoad({ filter: /\.(ttf|otf)$/i }, (args) => {
|
|
73
|
+
const family = basename(args.path).replace(/\.[^.]+$/, '');
|
|
74
|
+
fonts.set(family, args.path);
|
|
75
|
+
return { contents: `module.exports = ${JSON.stringify(family)};`, loader: 'js' };
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const bundlePath = resolve(distDir, 'app.bundle.js');
|
|
81
|
+
await build({
|
|
82
|
+
entryPoints: [entry],
|
|
83
|
+
bundle: true,
|
|
84
|
+
format: 'iife',
|
|
85
|
+
outfile: bundlePath,
|
|
86
|
+
platform: 'neutral',
|
|
87
|
+
target: 'es2020',
|
|
88
|
+
jsx: 'automatic',
|
|
89
|
+
// Demos live outside the embedded-react package, so the package self-reference doesn't resolve for
|
|
90
|
+
// them: map the bare `embedded-react` import to the library source and let the demo's bare deps
|
|
91
|
+
// (react, react-reconciler) resolve from this package's node_modules. (The library's own internal
|
|
92
|
+
// imports still resolve relatively / from node_modules as before.)
|
|
93
|
+
alias: { 'embedded-react': libEntry },
|
|
94
|
+
nodePaths: [nodeModules],
|
|
95
|
+
plugins: [assetPlugin],
|
|
96
|
+
// Production React: smaller and avoids dev-only warning machinery that needs more shims.
|
|
97
|
+
define: { 'process.env.NODE_ENV': '"production"' },
|
|
98
|
+
legalComments: 'none',
|
|
99
|
+
logLevel: 'info',
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
console.log(`Bundled demo "${demo}" -> dist/app.bundle.js`);
|
|
103
|
+
|
|
104
|
+
// --- Bake imported assets ---------------------------------------------------------------------
|
|
105
|
+
// Fonts are pre-rasterized at fixed sizes (the engine has no runtime rasterizer), so bake exactly
|
|
106
|
+
// the literal fontSize values the bundle uses. Computed/dynamic sizes can't be discovered statically
|
|
107
|
+
// and will snap to the nearest baked size at runtime; pin them via assets.config.js if needed.
|
|
108
|
+
const bundleSrc = readFileSync(bundlePath, 'utf8');
|
|
109
|
+
const discoveredSizes = [
|
|
110
|
+
...new Set([...bundleSrc.matchAll(/\bfontSize\s*:\s*(\d+(?:\.\d+)?)/g)].map((m) => Math.round(Number(m[1])))),
|
|
111
|
+
].sort((a, b) => a - b);
|
|
112
|
+
|
|
113
|
+
// Optional per-demo overrides: demos/<demo>/assets.config.js
|
|
114
|
+
// export default { fonts: { 'Family': { sizes: [..], bpp: 4, glyphs: 'ascii'|'common'|[cps] } } }
|
|
115
|
+
let config = {};
|
|
116
|
+
const configPath = resolve(demoDir, 'assets.config.js');
|
|
117
|
+
if (existsSync(configPath)) {
|
|
118
|
+
config = (await import(pathToFileURL(configPath).href)).default || {};
|
|
119
|
+
}
|
|
120
|
+
const fontConfig = config.fonts || {};
|
|
121
|
+
|
|
122
|
+
const fontJobs = [...fonts.entries()].map(([family, path]) => {
|
|
123
|
+
const fc = fontConfig[family] || {};
|
|
124
|
+
const sizes = fc.sizes && fc.sizes.length ? fc.sizes : discoveredSizes.length ? discoveredSizes : [16];
|
|
125
|
+
return { path, family, sizes, bpp: fc.bpp ?? 4, glyphs: fc.glyphs ?? 'ascii' };
|
|
126
|
+
});
|
|
127
|
+
const imageJobs = [...images.entries()].map(([name, path]) => ({ path, name }));
|
|
128
|
+
|
|
129
|
+
const summary = bakeAssets({ images: imageJobs, fonts: fontJobs, outDir: distDir });
|
|
130
|
+
const fontDesc = fontJobs.length
|
|
131
|
+
? fontJobs.map((f) => `${f.family}@[${f.sizes.join(',')}]x${f.bpp}bpp`).join(', ')
|
|
132
|
+
: 'none';
|
|
133
|
+
console.log(
|
|
134
|
+
`Baked ${summary.images} image(s), ${summary.fonts} font size(s) -> dist/assets.generated.c\n` +
|
|
135
|
+
` fonts: ${fontDesc}`,
|
|
136
|
+
);
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Cory Lamming
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// `npm run pack [demo]` — builds a deployable ERCF config container (dist/app.erpkg).
|
|
18
|
+
//
|
|
19
|
+
// The full release path for Flow A: bundle the app (esbuild → IIFE), precompile it to QuickJS
|
|
20
|
+
// bytecode (so the device never ships the parser or source text), bake the imported images/fonts into
|
|
21
|
+
// an ERPK pack, and wrap both — with a QuickJS version stamp and an integrity CRC — into a single
|
|
22
|
+
// .erpkg. That one file is "the config": load it with er_runtime_load_container() on the desktop, or
|
|
23
|
+
// upload it to a device's config region. See bridges/quickjs/js/assets/emit-container.mjs for the
|
|
24
|
+
// format, and er_runtime.h for the loader.
|
|
25
|
+
//
|
|
26
|
+
// npm run pack # default demo (thermostat)
|
|
27
|
+
// npm run pack -- marine-dash # a specific demo by folder name
|
|
28
|
+
//
|
|
29
|
+
// Prereq: the bytecode precompiler (er-bridge-quickjs-compile) must be built. This script searches the
|
|
30
|
+
// usual build dirs and tells you how to build it if it's missing (override with ER_COMPILE_BIN).
|
|
31
|
+
import { build } from 'esbuild';
|
|
32
|
+
import { spawnSync } from 'node:child_process';
|
|
33
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
34
|
+
import { dirname, resolve, basename } from 'node:path';
|
|
35
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
36
|
+
import { bakeImage } from './assets/bake-image.mjs';
|
|
37
|
+
import { bakeFont } from './assets/bake-font.mjs';
|
|
38
|
+
import { emitAssetPack } from './assets/emit-pack.mjs';
|
|
39
|
+
import { emitContainer } from './assets/emit-container.mjs';
|
|
40
|
+
|
|
41
|
+
// QuickJS release the bytecode targets — MUST match the FetchContent pin in
|
|
42
|
+
// bridges/quickjs/CMakeLists.txt and ER_QUICKJS_TAG in er_runtime.c. The loader rejects a mismatch.
|
|
43
|
+
const QJS_TAG = 'v0.15.0';
|
|
44
|
+
|
|
45
|
+
const here = dirname(fileURLToPath(import.meta.url)); // bridges/quickjs/js
|
|
46
|
+
const repoRoot = resolve(here, '../../..');
|
|
47
|
+
const demosDir = resolve(repoRoot, 'demos');
|
|
48
|
+
const libEntry = resolve(here, 'src/embedded-react/index.js');
|
|
49
|
+
const nodeModules = resolve(here, 'node_modules');
|
|
50
|
+
const distDir = resolve(here, 'dist');
|
|
51
|
+
|
|
52
|
+
const demo = process.argv[2] || process.env.DEMO || 'thermostat';
|
|
53
|
+
const demoDir = resolve(demosDir, demo);
|
|
54
|
+
const entry = resolve(demoDir, 'index.jsx');
|
|
55
|
+
if (!existsSync(entry)) {
|
|
56
|
+
const available = existsSync(demosDir)
|
|
57
|
+
? readdirSync(demosDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
|
|
58
|
+
: [];
|
|
59
|
+
console.error(`Demo "${demo}" not found (expected ${entry}).`);
|
|
60
|
+
console.error(`Available demos: ${available.join(', ') || '(none)'}`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- Locate the bytecode precompiler -----------------------------------------------------------
|
|
65
|
+
const exe = process.platform === 'win32' ? '.exe' : '';
|
|
66
|
+
const compileBin =
|
|
67
|
+
process.env.ER_COMPILE_BIN ||
|
|
68
|
+
[
|
|
69
|
+
resolve(repoRoot, 'bridges/quickjs/build', `er-bridge-quickjs-compile${exe}`),
|
|
70
|
+
resolve(repoRoot, 'examples/linux/build/bridges/quickjs', `er-bridge-quickjs-compile${exe}`),
|
|
71
|
+
resolve(repoRoot, 'tools/simulator/build/bridges/quickjs', `er-bridge-quickjs-compile${exe}`),
|
|
72
|
+
].find(existsSync);
|
|
73
|
+
if (!compileBin) {
|
|
74
|
+
console.error('Bytecode precompiler (er-bridge-quickjs-compile) not found. Build it once:');
|
|
75
|
+
console.error(' cmake -S bridges/quickjs -B bridges/quickjs/build -G "MinGW Makefiles"');
|
|
76
|
+
console.error(' cmake --build bridges/quickjs/build --target er-bridge-quickjs-compile');
|
|
77
|
+
console.error('(or set ER_COMPILE_BIN to the binary path)');
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- Bundle the app (same import-driven asset discovery as build.mjs) ---------------------------
|
|
82
|
+
const images = new Map(); // name -> path
|
|
83
|
+
const fonts = new Map(); // family -> path
|
|
84
|
+
const assetPlugin = {
|
|
85
|
+
name: 'embedded-react-assets',
|
|
86
|
+
setup(b) {
|
|
87
|
+
b.onLoad({ filter: /\.(png|jpe?g|webp|gif|bmp)$/i }, (args) => {
|
|
88
|
+
const name = basename(args.path).replace(/\.[^.]+$/, '');
|
|
89
|
+
images.set(name, args.path);
|
|
90
|
+
return { contents: `module.exports = ${JSON.stringify(name)};`, loader: 'js' };
|
|
91
|
+
});
|
|
92
|
+
b.onLoad({ filter: /\.(ttf|otf)$/i }, (args) => {
|
|
93
|
+
const family = basename(args.path).replace(/\.[^.]+$/, '');
|
|
94
|
+
fonts.set(family, args.path);
|
|
95
|
+
return { contents: `module.exports = ${JSON.stringify(family)};`, loader: 'js' };
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
mkdirSync(distDir, { recursive: true });
|
|
101
|
+
const bundlePath = resolve(distDir, 'app.bundle.js');
|
|
102
|
+
await build({
|
|
103
|
+
entryPoints: [entry],
|
|
104
|
+
bundle: true,
|
|
105
|
+
format: 'iife',
|
|
106
|
+
outfile: bundlePath,
|
|
107
|
+
platform: 'neutral',
|
|
108
|
+
target: 'es2020',
|
|
109
|
+
jsx: 'automatic',
|
|
110
|
+
alias: { 'embedded-react': libEntry },
|
|
111
|
+
nodePaths: [nodeModules],
|
|
112
|
+
plugins: [assetPlugin],
|
|
113
|
+
define: { 'process.env.NODE_ENV': '"production"' },
|
|
114
|
+
legalComments: 'none',
|
|
115
|
+
logLevel: 'info',
|
|
116
|
+
});
|
|
117
|
+
console.log(`Bundled demo "${demo}" -> dist/app.bundle.js`);
|
|
118
|
+
|
|
119
|
+
// --- Precompile to QuickJS bytecode -------------------------------------------------------------
|
|
120
|
+
const qbcPath = resolve(distDir, 'app.bundle.qbc');
|
|
121
|
+
const cc = spawnSync(compileBin, [bundlePath, qbcPath], { stdio: 'inherit' });
|
|
122
|
+
if (cc.status !== 0) {
|
|
123
|
+
console.error('Bytecode compile failed.');
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
const bytecode = readFileSync(qbcPath);
|
|
127
|
+
|
|
128
|
+
// --- Bake imported assets into an ERPK pack (same sizing rules as build.mjs / sim.mjs) ----------
|
|
129
|
+
const bundleSrc = readFileSync(bundlePath, 'utf8');
|
|
130
|
+
const discoveredSizes = [
|
|
131
|
+
...new Set([...bundleSrc.matchAll(/\bfontSize\s*:\s*(\d+(?:\.\d+)?)/g)].map((m) => Math.round(Number(m[1])))),
|
|
132
|
+
].sort((a, b) => a - b);
|
|
133
|
+
|
|
134
|
+
let config = {};
|
|
135
|
+
const configPath = resolve(demoDir, 'assets.config.js');
|
|
136
|
+
if (existsSync(configPath)) config = (await import(pathToFileURL(configPath).href)).default || {};
|
|
137
|
+
const fontConfig = config.fonts || {};
|
|
138
|
+
|
|
139
|
+
const fontJobs = [...fonts.entries()].map(([family, path]) => {
|
|
140
|
+
const fc = fontConfig[family] || {};
|
|
141
|
+
const sizes = fc.sizes && fc.sizes.length ? fc.sizes : discoveredSizes.length ? discoveredSizes : [16];
|
|
142
|
+
return { path, family, sizes, bpp: fc.bpp ?? 4, glyphs: fc.glyphs ?? 'ascii' };
|
|
143
|
+
});
|
|
144
|
+
const imageJobs = [...images.entries()].map(([name, path]) => ({ path, name }));
|
|
145
|
+
|
|
146
|
+
const bakedImages = imageJobs.map((i) => bakeImage(i));
|
|
147
|
+
const bakedFonts = fontJobs.map((f) => bakeFont(f));
|
|
148
|
+
const assetPack = bakedImages.length || bakedFonts.length ? emitAssetPack({ images: bakedImages, fonts: bakedFonts }) : null;
|
|
149
|
+
const fontSizeCount = bakedFonts.reduce((n, f) => n + f.sizes.length, 0);
|
|
150
|
+
|
|
151
|
+
// --- Wrap into the ERCF container ---------------------------------------------------------------
|
|
152
|
+
const container = emitContainer({ bytecode, assetPack, qjsTag: QJS_TAG });
|
|
153
|
+
const outPath = resolve(distDir, 'app.erpkg');
|
|
154
|
+
writeFileSync(outPath, container);
|
|
155
|
+
|
|
156
|
+
const kb = (n) => `${(n / 1024).toFixed(1)} KB`;
|
|
157
|
+
console.log(
|
|
158
|
+
`Packed config -> dist/app.erpkg (${kb(container.length)})\n` +
|
|
159
|
+
` qjs ${QJS_TAG} · bytecode ${kb(bytecode.length)}` +
|
|
160
|
+
(assetPack ? ` · assets ${kb(assetPack.length)} (${bakedImages.length} image(s), ${fontSizeCount} font size(s))` : ' · no assets'),
|
|
161
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "embedded-react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "React Native-style component package + reconciler that drives the embedded-react C engine through the QuickJS NativeUI bridge (Flow A).",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"author": "Cory Lamming",
|
|
8
|
+
"homepage": "https://github.com/TheMasterCoder007/embedded-react#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/TheMasterCoder007/embedded-react.git",
|
|
12
|
+
"directory": "bridges/quickjs/js"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/TheMasterCoder007/embedded-react/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"react",
|
|
19
|
+
"react-native",
|
|
20
|
+
"embedded",
|
|
21
|
+
"microcontroller",
|
|
22
|
+
"esp32",
|
|
23
|
+
"stm32",
|
|
24
|
+
"mcu",
|
|
25
|
+
"jsx",
|
|
26
|
+
"gui",
|
|
27
|
+
"ui",
|
|
28
|
+
"quickjs",
|
|
29
|
+
"aot"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"exports": {
|
|
35
|
+
".": "./src/embedded-react/index.js"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src/",
|
|
39
|
+
"aot/",
|
|
40
|
+
"assets/",
|
|
41
|
+
"build.mjs",
|
|
42
|
+
"pack-container.mjs",
|
|
43
|
+
"persist-transform.mjs",
|
|
44
|
+
"README.md",
|
|
45
|
+
"LICENSE",
|
|
46
|
+
"NOTICE",
|
|
47
|
+
"!**/__tests__/**",
|
|
48
|
+
"!**/*.test.js"
|
|
49
|
+
],
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "node build.mjs",
|
|
52
|
+
"pack": "node pack-container.mjs",
|
|
53
|
+
"aot": "node aot/compile.mjs",
|
|
54
|
+
"aot:smoke": "node aot/screenshot-smoke.mjs",
|
|
55
|
+
"parity": "node parity.mjs",
|
|
56
|
+
"version:sync": "node ../../../tools/sync-version.mjs",
|
|
57
|
+
"version:check": "node ../../../tools/sync-version.mjs --check",
|
|
58
|
+
"release": "node ../../../tools/release.mjs",
|
|
59
|
+
"build:builtin-font": "node assets/build-builtin-font.mjs",
|
|
60
|
+
"sim": "node sim.mjs",
|
|
61
|
+
"create": "node create-embedded-react.mjs",
|
|
62
|
+
"test": "vitest run",
|
|
63
|
+
"test:watch": "vitest",
|
|
64
|
+
"test:runtime": "node test/runtime/run.mjs",
|
|
65
|
+
"test:bytecode": "node test/runtime/run.mjs --bytecode"
|
|
66
|
+
},
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"react": "18.3.1",
|
|
69
|
+
"react-reconciler": "0.29.2"
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@babel/core": "^7.29.7",
|
|
73
|
+
"@babel/plugin-syntax-jsx": "^7.29.7",
|
|
74
|
+
"esbuild": "^0.24.0",
|
|
75
|
+
"opentype.js": "^2.0.0",
|
|
76
|
+
"pngjs": "^7.0.0",
|
|
77
|
+
"vitest": "^2.1.0"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Cory Lamming
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Simulator-only Babel transform: rewrites `useState(init)` in app code to a persisting helper so
|
|
18
|
+
// state transparently survives a hot reload (see /SIMULATOR.md, Phase 3 — transparent variant). Plain
|
|
19
|
+
// `useState` from 'react' just persists in the simulator; on a device (no transform, no __erPersist)
|
|
20
|
+
// it's exactly useState. Only the app's own files are transformed — never the library or React (so
|
|
21
|
+
// the helper itself isn't rewritten).
|
|
22
|
+
//
|
|
23
|
+
// Each call is keyed by `module::component#index` (component name + its useState order), which is
|
|
24
|
+
// stable across the edits you make most often (JSX/logic). Adding/removing a useState in a component,
|
|
25
|
+
// or renaming it, shifts that component's keys and resets its state — press R in the sim for a clean
|
|
26
|
+
// reset any time.
|
|
27
|
+
import { transformSync } from '@babel/core';
|
|
28
|
+
import syntaxJsx from '@babel/plugin-syntax-jsx';
|
|
29
|
+
|
|
30
|
+
/** Best-effort name of the function/component enclosing a path (for a stable, readable key). */
|
|
31
|
+
function enclosingName(path) {
|
|
32
|
+
const fn = path.getFunctionParent();
|
|
33
|
+
if (!fn) return '_';
|
|
34
|
+
if (fn.node.id && fn.node.id.name) return fn.node.id.name; // function Foo() {}
|
|
35
|
+
const parent = fn.parentPath && fn.parentPath.node;
|
|
36
|
+
if (parent) {
|
|
37
|
+
if (parent.type === 'VariableDeclarator' && parent.id && parent.id.name) return parent.id.name; // const Foo = () =>
|
|
38
|
+
if (parent.key && parent.key.name) return parent.key.name; // method / property
|
|
39
|
+
if (parent.type === 'AssignmentExpression' && parent.left && parent.left.name) return parent.left.name;
|
|
40
|
+
}
|
|
41
|
+
return '_';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Babel plugin: useState(init) → __erPersistState("key", init), importing the helper when used. */
|
|
45
|
+
function persistPlugin({ types: t }) {
|
|
46
|
+
return {
|
|
47
|
+
name: 'er-persist-usestate',
|
|
48
|
+
visitor: {
|
|
49
|
+
Program: {
|
|
50
|
+
enter(_path, state) {
|
|
51
|
+
state.erCounters = new Map();
|
|
52
|
+
state.erUsed = false;
|
|
53
|
+
},
|
|
54
|
+
exit(path, state) {
|
|
55
|
+
if (!state.erUsed) return;
|
|
56
|
+
path.unshiftContainer(
|
|
57
|
+
'body',
|
|
58
|
+
t.importDeclaration(
|
|
59
|
+
[t.importSpecifier(t.identifier('__erPersistState'), t.identifier('usePersistentState'))],
|
|
60
|
+
t.stringLiteral('embedded-react'),
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
CallExpression(path, state) {
|
|
66
|
+
if (!t.isIdentifier(path.node.callee, { name: 'useState' })) return;
|
|
67
|
+
// Only rewrite the real useState (an imported binding from react / embedded-react).
|
|
68
|
+
const binding = path.scope.getBinding('useState');
|
|
69
|
+
if (!binding || binding.kind !== 'module') return;
|
|
70
|
+
const decl = binding.path.parentPath && binding.path.parentPath.node;
|
|
71
|
+
const src = decl && decl.source && decl.source.value;
|
|
72
|
+
if (src !== 'react' && src !== 'embedded-react') return;
|
|
73
|
+
|
|
74
|
+
const fnName = enclosingName(path);
|
|
75
|
+
const idx = state.erCounters.get(fnName) || 0;
|
|
76
|
+
state.erCounters.set(fnName, idx + 1);
|
|
77
|
+
const key = `${state.opts.moduleId}::${fnName}#${idx}`;
|
|
78
|
+
|
|
79
|
+
path.replaceWith(
|
|
80
|
+
t.callExpression(t.identifier('__erPersistState'), [t.stringLiteral(key), ...path.node.arguments]),
|
|
81
|
+
);
|
|
82
|
+
state.erUsed = true;
|
|
83
|
+
path.skip();
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Applies the persist transform to a module's source.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} code Module source (JSX allowed).
|
|
93
|
+
* @param {string} moduleId Stable module identifier (used in keys; e.g. a path relative to the app).
|
|
94
|
+
* @returns {string} Transformed source (JSX preserved for esbuild to handle).
|
|
95
|
+
*/
|
|
96
|
+
export function transformPersist(code, moduleId) {
|
|
97
|
+
const out = transformSync(code, {
|
|
98
|
+
filename: moduleId,
|
|
99
|
+
babelrc: false,
|
|
100
|
+
configFile: false,
|
|
101
|
+
sourceType: 'module',
|
|
102
|
+
parserOpts: { plugins: ['jsx'] },
|
|
103
|
+
plugins: [syntaxJsx, [persistPlugin, { moduleId }]],
|
|
104
|
+
});
|
|
105
|
+
return out.code;
|
|
106
|
+
}
|