@lotics/cli 0.54.0 → 0.55.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/dist/dev/server.js +4 -1
- package/dist/dev/wrapper_page.d.ts +3 -0
- package/dist/dev/wrapper_page.js +67 -0
- package/dist/src/cli.js +203 -52
- package/dist/starter_template.d.ts +6 -4
- package/dist/starter_template.js +143 -52
- package/dist/starter_template.test.js +20 -0
- package/package.json +1 -1
package/dist/dev/server.js
CHANGED
|
@@ -86,7 +86,10 @@ export async function startDevServer(args) {
|
|
|
86
86
|
// ── HTTP server ────────────────────────────────────────────────────────
|
|
87
87
|
const server = http.createServer(async (req, res) => {
|
|
88
88
|
const url = req.url ?? "/";
|
|
89
|
-
|
|
89
|
+
// Route by pathname — `useUrlState` writes a query string onto the wrapper
|
|
90
|
+
// URL, so a refresh / shared link requests `/?…`; the page must still serve.
|
|
91
|
+
const pathname = url.split("?")[0];
|
|
92
|
+
if (req.method === "GET" && (pathname === "/" || pathname === "/index.html")) {
|
|
90
93
|
res.writeHead(200, {
|
|
91
94
|
"Content-Type": "text/html; charset=utf-8",
|
|
92
95
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
* wrapper → iframe: { id, type: "result", data } | { id, type: "error", message }
|
|
25
25
|
* streaming (op "agentRun"): { id, type: "stream-chunk", chunk } * → { id, type: "stream-end" };
|
|
26
26
|
* the iframe aborts with { id, type: "abort" }.
|
|
27
|
+
* urlState (useUrlState + router adapter): the iframe reads/writes the wrapper's
|
|
28
|
+
* address bar via urlState.get/set/go; set push/replace and go drive history,
|
|
29
|
+
* and back/forward broadcast { type: "url-state", params } back to the iframe.
|
|
27
30
|
*/
|
|
28
31
|
export interface WrapperPageArgs {
|
|
29
32
|
app_name: string;
|
package/dist/dev/wrapper_page.js
CHANGED
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
* wrapper → iframe: { id, type: "result", data } | { id, type: "error", message }
|
|
25
25
|
* streaming (op "agentRun"): { id, type: "stream-chunk", chunk } * → { id, type: "stream-end" };
|
|
26
26
|
* the iframe aborts with { id, type: "abort" }.
|
|
27
|
+
* urlState (useUrlState + router adapter): the iframe reads/writes the wrapper's
|
|
28
|
+
* address bar via urlState.get/set/go; set push/replace and go drive history,
|
|
29
|
+
* and back/forward broadcast { type: "url-state", params } back to the iframe.
|
|
27
30
|
*/
|
|
28
31
|
export function buildWrapperPage(args) {
|
|
29
32
|
const { app_name, app_id, workspace_id, vite_url, api_url } = args;
|
|
@@ -165,6 +168,54 @@ export function buildWrapperPage(args) {
|
|
|
165
168
|
return undefined;
|
|
166
169
|
}
|
|
167
170
|
|
|
171
|
+
// urlState.get/set — useUrlState keeps app view-state (filters, search) in
|
|
172
|
+
// THIS wrapper page's address bar so it survives refresh and is shareable,
|
|
173
|
+
// mirroring the production host. Handled locally (the URL is the store; no
|
|
174
|
+
// API hop). The wrapper page is its own top-level page, so its query string
|
|
175
|
+
// is entirely the app's. Repeated keys become arrays. Parse/merge MUST
|
|
176
|
+
// match @lotics/app-sdk parseSearch/serializeMerge and the production host
|
|
177
|
+
// (inline JS here — no module system to share the trivial copy).
|
|
178
|
+
function readUrlParams() {
|
|
179
|
+
const sp = new URLSearchParams(window.location.search);
|
|
180
|
+
const out = {};
|
|
181
|
+
sp.forEach(function (_value, key) {
|
|
182
|
+
if (key in out) return;
|
|
183
|
+
const all = sp.getAll(key);
|
|
184
|
+
out[key] = all.length > 1 ? all : all[0];
|
|
185
|
+
});
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
var lastPushAt = 0;
|
|
189
|
+
function handleUrlStateSet(payload) {
|
|
190
|
+
const params = (payload && payload.params) || {};
|
|
191
|
+
const sp = new URLSearchParams(window.location.search);
|
|
192
|
+
Object.keys(params).forEach(function (key) {
|
|
193
|
+
sp.delete(key);
|
|
194
|
+
const value = params[key];
|
|
195
|
+
if (value === undefined || value === null) return;
|
|
196
|
+
if (Array.isArray(value)) value.forEach(function (item) { sp.append(key, item); });
|
|
197
|
+
else sp.append(key, value);
|
|
198
|
+
});
|
|
199
|
+
const qs = sp.toString();
|
|
200
|
+
const url = window.location.pathname + (qs ? "?" + qs : "") + window.location.hash;
|
|
201
|
+
// push adds a back-able entry (the router adapter); replace for filter
|
|
202
|
+
// churn. Flood guard: coerce sub-100ms pushes to replace. pushState/
|
|
203
|
+
// replaceState don't fire popstate, so no echo. Mirrors the production host.
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
if (payload && payload.push && now - lastPushAt > 100) {
|
|
206
|
+
lastPushAt = now;
|
|
207
|
+
window.history.pushState(null, "", url);
|
|
208
|
+
} else {
|
|
209
|
+
window.history.replaceState(null, "", url);
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
function handleUrlStateGo(payload) {
|
|
214
|
+
const delta = payload && payload.delta;
|
|
215
|
+
if (typeof delta === "number") window.history.go(delta);
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
168
219
|
// Streaming agent runs (op "agentRun"): the response is a stream, so it
|
|
169
220
|
// can't use rpc()'s single JSON round-trip. POST /_agent_run, read the SSE
|
|
170
221
|
// body, and forward chunks to the iframe as the SDK's bridged streaming
|
|
@@ -226,6 +277,12 @@ export function buildWrapperPage(args) {
|
|
|
226
277
|
? await handleUpload(msg.payload)
|
|
227
278
|
: msg.op === "openExternal"
|
|
228
279
|
? handleOpenExternal(msg.payload)
|
|
280
|
+
: msg.op === "urlState.get"
|
|
281
|
+
? readUrlParams()
|
|
282
|
+
: msg.op === "urlState.set"
|
|
283
|
+
? handleUrlStateSet(msg.payload)
|
|
284
|
+
: msg.op === "urlState.go"
|
|
285
|
+
? handleUrlStateGo(msg.payload)
|
|
229
286
|
: await rpc(msg.op, msg.payload);
|
|
230
287
|
const ms = Math.round(performance.now() - startedAt);
|
|
231
288
|
console.debug("[lotics-dev] " + msg.op + " " + ms + "ms", data);
|
|
@@ -242,6 +299,16 @@ export function buildWrapperPage(args) {
|
|
|
242
299
|
);
|
|
243
300
|
}
|
|
244
301
|
});
|
|
302
|
+
|
|
303
|
+
// Browser/adapter back/forward → broadcast the new params so useUrlState and
|
|
304
|
+
// the router adapter re-hydrate (the app's own set writes use push/replace
|
|
305
|
+
// State — no popstate — so there's no echo). Mirrors the production host.
|
|
306
|
+
window.addEventListener("popstate", function () {
|
|
307
|
+
iframe.contentWindow.postMessage(
|
|
308
|
+
{ type: "url-state", params: readUrlParams() },
|
|
309
|
+
VITE_ORIGIN
|
|
310
|
+
);
|
|
311
|
+
});
|
|
245
312
|
})();
|
|
246
313
|
</script>
|
|
247
314
|
</body>
|
package/dist/src/cli.js
CHANGED
|
@@ -30241,8 +30241,8 @@ import { spawn as spawn2 } from "node:child_process";
|
|
|
30241
30241
|
import { tmpdir } from "node:os";
|
|
30242
30242
|
|
|
30243
30243
|
// src/starter_template.ts
|
|
30244
|
-
var STARTER_FALLBACK_UI_VERSION = "
|
|
30245
|
-
var STARTER_FALLBACK_SDK_VERSION = "0.
|
|
30244
|
+
var STARTER_FALLBACK_UI_VERSION = "6.1.0";
|
|
30245
|
+
var STARTER_FALLBACK_SDK_VERSION = "0.40.0";
|
|
30246
30246
|
var STARTER_REACT_NATIVE_VERSION = "0.85.3";
|
|
30247
30247
|
function buildStarterTemplate(args) {
|
|
30248
30248
|
const uiVersion = args.ui_version ?? `^${STARTER_FALLBACK_UI_VERSION}`;
|
|
@@ -30284,7 +30284,10 @@ function buildStarterTemplate(args) {
|
|
|
30284
30284
|
"react-dom": "^19.0.0",
|
|
30285
30285
|
"react-native": STARTER_REACT_NATIVE_VERSION,
|
|
30286
30286
|
"react-native-svg": "^15.0.0",
|
|
30287
|
-
"react-native-web": "^0.21.0"
|
|
30287
|
+
"react-native-web": "^0.21.0",
|
|
30288
|
+
// In-app routing for the scaffolded list→detail example. The app owns
|
|
30289
|
+
// its routing; the SDK's `isEmbedded()` picks memory vs browser history.
|
|
30290
|
+
"react-router-dom": "^7.0.0"
|
|
30288
30291
|
},
|
|
30289
30292
|
devDependencies: {
|
|
30290
30293
|
"@testing-library/react": "^16.1.0",
|
|
@@ -30409,8 +30412,18 @@ export default defineConfig({
|
|
|
30409
30412
|
// otherwise serves them without a synthesized default export ("does not
|
|
30410
30413
|
// provide an export named 'default'"), blanking the iframe. (Production/rollup
|
|
30411
30414
|
// resolves the interop already, so this is dev-only.)
|
|
30415
|
+
//
|
|
30416
|
+
// \`react-markdown\` (@lotics/ui's Markdown renderer, reached by AgentRun and
|
|
30417
|
+
// any markdown surface) pulls a transitive CJS dep, \`style-to-js\`, whose
|
|
30418
|
+
// default export the dev optimizer won't synthesize unbundled ("does not
|
|
30419
|
+
// provide an export named 'default'") \u2014 blanking the iframe the moment a
|
|
30420
|
+
// markdown component mounts. Pre-bundling react-markdown folds the whole
|
|
30421
|
+
// subtree (incl. style-to-js) into one interop'd chunk; remark-gfm (imported
|
|
30422
|
+
// alongside it) is pre-bundled for the same consistency. Both are regular
|
|
30423
|
+
// deps of @lotics/ui (hoisted), so they need no package.json entry here.
|
|
30412
30424
|
include: [
|
|
30413
30425
|
"react-native-svg",
|
|
30426
|
+
"react-markdown", "remark-gfm",
|
|
30414
30427
|
"react-native-web", "@react-native/normalize-colors",
|
|
30415
30428
|
"inline-style-prefixer/lib/createPrefixer",
|
|
30416
30429
|
"inline-style-prefixer/lib/plugins/crossFade",
|
|
@@ -30487,6 +30500,10 @@ export default defineConfig({
|
|
|
30487
30500
|
// the hooks dispatcher the instant a hook (useAgentRun / useQuery)
|
|
30488
30501
|
// runs in a rendered App tree.
|
|
30489
30502
|
"@lotics/app-sdk",
|
|
30503
|
+
// react-router-dom (App's router) also ships compiled dist JS \u2014 pin it
|
|
30504
|
+
// into the shared chunk so its hooks don't split the react instance
|
|
30505
|
+
// ("Invalid hook call") when a test renders the routed App tree.
|
|
30506
|
+
"react-router-dom",
|
|
30490
30507
|
],
|
|
30491
30508
|
esbuildOptions: {
|
|
30492
30509
|
resolveExtensions: [".web.tsx", ".web.ts", ".web.js", ".tsx", ".ts", ".jsx", ".js", ".json"],
|
|
@@ -30533,73 +30550,132 @@ export default defineConfig({
|
|
|
30533
30550
|
content: `import "@lotics/ui/index.css";
|
|
30534
30551
|
import "@lotics/ui/fonts.css";
|
|
30535
30552
|
import { PortalHost } from "@lotics/ui/portal";
|
|
30553
|
+
import { LoticsLocaleProvider, vi } from "@lotics/ui/locale";
|
|
30536
30554
|
import { mount } from "@lotics/app-sdk";
|
|
30537
30555
|
import App from "./App";
|
|
30538
30556
|
|
|
30539
30557
|
// PortalHost is the render target for @lotics/ui overlays (Popover, Tooltip,
|
|
30540
30558
|
// Dialog). Without it, Portal renders nothing \u2014 keep it wrapping the app.
|
|
30559
|
+
//
|
|
30560
|
+
// LoticsLocaleProvider supplies the kit's own strings (Pagination, sort
|
|
30561
|
+
// headers, select-all, the Drawer's record nav\u2026) in one language \u2014 Vietnamese
|
|
30562
|
+
// by default, since Lotics apps are Vietnamese. Swap to \`en\` (or your own
|
|
30563
|
+
// LoticsLocale pack) for another language; remove it to fall back to English.
|
|
30541
30564
|
mount(
|
|
30542
|
-
<
|
|
30543
|
-
<
|
|
30544
|
-
|
|
30565
|
+
<LoticsLocaleProvider locale={vi}>
|
|
30566
|
+
<PortalHost>
|
|
30567
|
+
<App />
|
|
30568
|
+
</PortalHost>
|
|
30569
|
+
</LoticsLocaleProvider>,
|
|
30545
30570
|
);
|
|
30546
30571
|
`
|
|
30547
30572
|
},
|
|
30548
30573
|
{
|
|
30549
30574
|
path: "src/App.tsx",
|
|
30550
|
-
//
|
|
30551
|
-
//
|
|
30552
|
-
// -
|
|
30553
|
-
//
|
|
30554
|
-
//
|
|
30555
|
-
//
|
|
30556
|
-
//
|
|
30557
|
-
//
|
|
30558
|
-
//
|
|
30559
|
-
//
|
|
30560
|
-
//
|
|
30561
|
-
//
|
|
30562
|
-
|
|
30563
|
-
|
|
30564
|
-
|
|
30575
|
+
// Default scaffold = a minimal in-app router example (a list screen and a
|
|
30576
|
+
// detail screen), so a new app starts with the recommended routing shape:
|
|
30577
|
+
// - The app uses react-router via `AppRouter` (from @lotics/app-sdk/router),
|
|
30578
|
+
// which makes screens real addressable URLs — embedded, they live in the
|
|
30579
|
+
// host address bar (?_loc=…) and the browser Back/Forward walk screens;
|
|
30580
|
+
// standalone, real path URLs.
|
|
30581
|
+
// - The full-container layout pattern is preserved in `Screen`: an outer
|
|
30582
|
+
// <View flex:1> claims the iframe height (index.html sets
|
|
30583
|
+
// html/body/#root to 100% + #root is a flex column). Keep that chain
|
|
30584
|
+
// plain — @lotics/ui/stack wraps children in an unstyled <View> that
|
|
30585
|
+
// breaks `flex: 1` propagation to a fill-remaining-space child.
|
|
30586
|
+
// A single-screen app can delete the router and render one Screen directly.
|
|
30587
|
+
content: `import type { ReactNode } from "react";
|
|
30588
|
+
import { View } from "react-native";
|
|
30589
|
+
import { useNavigate, useParams } from "react-router-dom";
|
|
30590
|
+
import { AppRouter } from "@lotics/app-sdk/router";
|
|
30565
30591
|
import { Card } from "@lotics/ui/card";
|
|
30566
30592
|
import { Text } from "@lotics/ui/text";
|
|
30567
30593
|
import { Button } from "@lotics/ui/button";
|
|
30568
30594
|
|
|
30569
|
-
//
|
|
30570
|
-
//
|
|
30571
|
-
//
|
|
30572
|
-
//
|
|
30573
|
-
//
|
|
30574
|
-
|
|
30595
|
+
// AppRouter makes the app's screens real, addressable URLs \u2014 write plain
|
|
30596
|
+
// react-router (useNavigate / useParams / <Link>) and it handles both modes:
|
|
30597
|
+
// - Embedded in the Lotics host: the current screen lives in the host address
|
|
30598
|
+
// bar (?_loc=\u2026) \u2014 shareable + refresh-survivable \u2014 and the browser Back /
|
|
30599
|
+
// Forward buttons walk app screens (then leave the app).
|
|
30600
|
+
// - Standalone at <slug>.lotics.app: a normal browser router with real path URLs.
|
|
30601
|
+
|
|
30602
|
+
const ITEMS = [
|
|
30603
|
+
{ id: "1", name: "First item" },
|
|
30604
|
+
{ id: "2", name: "Second item" },
|
|
30605
|
+
{ id: "3", name: "Third item" },
|
|
30606
|
+
];
|
|
30607
|
+
|
|
30608
|
+
// Outer <View flex:1> claims the full iframe height \u2014 works because index.html
|
|
30609
|
+
// sets html/body/#root to 100% and #root is a flex column. Keep this flex chain
|
|
30610
|
+
// plain (not @lotics/ui/stack) so a fill-remaining-space child can claim height.
|
|
30611
|
+
function Screen({ children }: { children: ReactNode }) {
|
|
30612
|
+
return (
|
|
30613
|
+
<View style={{ flex: 1, padding: 24, alignItems: "center" }}>
|
|
30614
|
+
<View style={{ maxWidth: 640, width: "100%", gap: 16 }}>{children}</View>
|
|
30615
|
+
</View>
|
|
30616
|
+
);
|
|
30617
|
+
}
|
|
30618
|
+
|
|
30619
|
+
function ListScreen() {
|
|
30620
|
+
const navigate = useNavigate();
|
|
30575
30621
|
return (
|
|
30576
|
-
<
|
|
30577
|
-
|
|
30578
|
-
|
|
30579
|
-
|
|
30580
|
-
|
|
30581
|
-
|
|
30582
|
-
|
|
30583
|
-
|
|
30584
|
-
|
|
30585
|
-
|
|
30586
|
-
|
|
30587
|
-
|
|
30588
|
-
|
|
30589
|
-
|
|
30590
|
-
|
|
30591
|
-
|
|
30592
|
-
|
|
30622
|
+
<Screen>
|
|
30623
|
+
<Text size="xl" weight="semibold">${escapeHtml(args.app_name)}</Text>
|
|
30624
|
+
<Text color="muted">
|
|
30625
|
+
Tap an item to open its detail screen \u2014 the app routes itself. Edit{" "}
|
|
30626
|
+
<Text weight="medium">src/App.tsx</Text> and run{" "}
|
|
30627
|
+
<Text weight="medium">lotics app deploy</Text> to publish.
|
|
30628
|
+
</Text>
|
|
30629
|
+
{ITEMS.map((item) => (
|
|
30630
|
+
<Card key={item.id}>
|
|
30631
|
+
<View
|
|
30632
|
+
style={{
|
|
30633
|
+
padding: 16,
|
|
30634
|
+
flexDirection: "row",
|
|
30635
|
+
alignItems: "center",
|
|
30636
|
+
justifyContent: "space-between",
|
|
30637
|
+
gap: 12,
|
|
30638
|
+
}}
|
|
30639
|
+
>
|
|
30640
|
+
<Text weight="medium">{item.name}</Text>
|
|
30641
|
+
<Button title="Open" color="primary" onPress={() => navigate("/item/" + item.id)} />
|
|
30593
30642
|
</View>
|
|
30594
30643
|
</Card>
|
|
30595
|
-
|
|
30596
|
-
|
|
30597
|
-
|
|
30598
|
-
|
|
30644
|
+
))}
|
|
30645
|
+
</Screen>
|
|
30646
|
+
);
|
|
30647
|
+
}
|
|
30648
|
+
|
|
30649
|
+
function ItemDetailScreen() {
|
|
30650
|
+
const { id } = useParams();
|
|
30651
|
+
const navigate = useNavigate();
|
|
30652
|
+
const item = ITEMS.find((i) => i.id === id);
|
|
30653
|
+
return (
|
|
30654
|
+
<Screen>
|
|
30655
|
+
{/* An in-app Back control; navigate(-1) walks the history (the browser Back
|
|
30656
|
+
button walks app screens too). */}
|
|
30657
|
+
<View style={{ alignItems: "flex-start" }}>
|
|
30658
|
+
<Button title="Back" onPress={() => navigate(-1)} />
|
|
30599
30659
|
</View>
|
|
30600
|
-
|
|
30660
|
+
<Text size="xl" weight="semibold">{item ? item.name : "Not found"}</Text>
|
|
30661
|
+
<Card>
|
|
30662
|
+
<View style={{ padding: 16, gap: 8 }}>
|
|
30663
|
+
<Text>Detail for item {id}.</Text>
|
|
30664
|
+
<Text color="muted">Reached via in-app navigation, not a host route.</Text>
|
|
30665
|
+
</View>
|
|
30666
|
+
</Card>
|
|
30667
|
+
</Screen>
|
|
30601
30668
|
);
|
|
30602
30669
|
}
|
|
30670
|
+
|
|
30671
|
+
const routes = [
|
|
30672
|
+
{ path: "/", element: <ListScreen /> },
|
|
30673
|
+
{ path: "/item/:id", element: <ItemDetailScreen /> },
|
|
30674
|
+
];
|
|
30675
|
+
|
|
30676
|
+
export default function App() {
|
|
30677
|
+
return <AppRouter routes={routes} />;
|
|
30678
|
+
}
|
|
30603
30679
|
`
|
|
30604
30680
|
},
|
|
30605
30681
|
{
|
|
@@ -30678,9 +30754,9 @@ import { render } from "@testing-library/react";
|
|
|
30678
30754
|
import App from "./App";
|
|
30679
30755
|
|
|
30680
30756
|
describe("App", () => {
|
|
30681
|
-
test("renders
|
|
30757
|
+
test("renders the default route", () => {
|
|
30682
30758
|
const { container } = render(<App />);
|
|
30683
|
-
expect(container).
|
|
30759
|
+
expect(container.textContent).toContain("First item");
|
|
30684
30760
|
});
|
|
30685
30761
|
});
|
|
30686
30762
|
`
|
|
@@ -30764,6 +30840,16 @@ import { Text } from "@lotics/ui/text";
|
|
|
30764
30840
|
in this Vite app (the alias is preconfigured in \`vite.config.ts\`). See
|
|
30765
30841
|
the full export list at https://www.npmjs.com/package/@lotics/ui.
|
|
30766
30842
|
|
|
30843
|
+
## Routing
|
|
30844
|
+
|
|
30845
|
+
\`src/App.tsx\` ships a minimal in-app router. Write plain react-router and wrap
|
|
30846
|
+
your routes in \`AppRouter\` from \`@lotics/app-sdk/router\` \u2014 it makes screens real,
|
|
30847
|
+
addressable URLs in both modes: embedded in the Lotics host the current screen
|
|
30848
|
+
lives in the host address bar (\`?_loc=\u2026\`, shareable + refresh-survivable) and the
|
|
30849
|
+
browser Back/Forward walk app screens; standalone (\`<slug>.lotics.app\`) it's a
|
|
30850
|
+
normal browser router with real path URLs. A single-screen app can drop the
|
|
30851
|
+
router and render one screen directly.
|
|
30852
|
+
|
|
30767
30853
|
See https://lotics.ai/docs/app-sdk for the SDK reference.
|
|
30768
30854
|
`
|
|
30769
30855
|
}
|
|
@@ -31060,6 +31146,54 @@ function buildWrapperPage(args) {
|
|
|
31060
31146
|
return undefined;
|
|
31061
31147
|
}
|
|
31062
31148
|
|
|
31149
|
+
// urlState.get/set \u2014 useUrlState keeps app view-state (filters, search) in
|
|
31150
|
+
// THIS wrapper page's address bar so it survives refresh and is shareable,
|
|
31151
|
+
// mirroring the production host. Handled locally (the URL is the store; no
|
|
31152
|
+
// API hop). The wrapper page is its own top-level page, so its query string
|
|
31153
|
+
// is entirely the app's. Repeated keys become arrays. Parse/merge MUST
|
|
31154
|
+
// match @lotics/app-sdk parseSearch/serializeMerge and the production host
|
|
31155
|
+
// (inline JS here \u2014 no module system to share the trivial copy).
|
|
31156
|
+
function readUrlParams() {
|
|
31157
|
+
const sp = new URLSearchParams(window.location.search);
|
|
31158
|
+
const out = {};
|
|
31159
|
+
sp.forEach(function (_value, key) {
|
|
31160
|
+
if (key in out) return;
|
|
31161
|
+
const all = sp.getAll(key);
|
|
31162
|
+
out[key] = all.length > 1 ? all : all[0];
|
|
31163
|
+
});
|
|
31164
|
+
return out;
|
|
31165
|
+
}
|
|
31166
|
+
var lastPushAt = 0;
|
|
31167
|
+
function handleUrlStateSet(payload) {
|
|
31168
|
+
const params = (payload && payload.params) || {};
|
|
31169
|
+
const sp = new URLSearchParams(window.location.search);
|
|
31170
|
+
Object.keys(params).forEach(function (key) {
|
|
31171
|
+
sp.delete(key);
|
|
31172
|
+
const value = params[key];
|
|
31173
|
+
if (value === undefined || value === null) return;
|
|
31174
|
+
if (Array.isArray(value)) value.forEach(function (item) { sp.append(key, item); });
|
|
31175
|
+
else sp.append(key, value);
|
|
31176
|
+
});
|
|
31177
|
+
const qs = sp.toString();
|
|
31178
|
+
const url = window.location.pathname + (qs ? "?" + qs : "") + window.location.hash;
|
|
31179
|
+
// push adds a back-able entry (the router adapter); replace for filter
|
|
31180
|
+
// churn. Flood guard: coerce sub-100ms pushes to replace. pushState/
|
|
31181
|
+
// replaceState don't fire popstate, so no echo. Mirrors the production host.
|
|
31182
|
+
const now = Date.now();
|
|
31183
|
+
if (payload && payload.push && now - lastPushAt > 100) {
|
|
31184
|
+
lastPushAt = now;
|
|
31185
|
+
window.history.pushState(null, "", url);
|
|
31186
|
+
} else {
|
|
31187
|
+
window.history.replaceState(null, "", url);
|
|
31188
|
+
}
|
|
31189
|
+
return undefined;
|
|
31190
|
+
}
|
|
31191
|
+
function handleUrlStateGo(payload) {
|
|
31192
|
+
const delta = payload && payload.delta;
|
|
31193
|
+
if (typeof delta === "number") window.history.go(delta);
|
|
31194
|
+
return undefined;
|
|
31195
|
+
}
|
|
31196
|
+
|
|
31063
31197
|
// Streaming agent runs (op "agentRun"): the response is a stream, so it
|
|
31064
31198
|
// can't use rpc()'s single JSON round-trip. POST /_agent_run, read the SSE
|
|
31065
31199
|
// body, and forward chunks to the iframe as the SDK's bridged streaming
|
|
@@ -31121,6 +31255,12 @@ function buildWrapperPage(args) {
|
|
|
31121
31255
|
? await handleUpload(msg.payload)
|
|
31122
31256
|
: msg.op === "openExternal"
|
|
31123
31257
|
? handleOpenExternal(msg.payload)
|
|
31258
|
+
: msg.op === "urlState.get"
|
|
31259
|
+
? readUrlParams()
|
|
31260
|
+
: msg.op === "urlState.set"
|
|
31261
|
+
? handleUrlStateSet(msg.payload)
|
|
31262
|
+
: msg.op === "urlState.go"
|
|
31263
|
+
? handleUrlStateGo(msg.payload)
|
|
31124
31264
|
: await rpc(msg.op, msg.payload);
|
|
31125
31265
|
const ms = Math.round(performance.now() - startedAt);
|
|
31126
31266
|
console.debug("[lotics-dev] " + msg.op + " " + ms + "ms", data);
|
|
@@ -31137,6 +31277,16 @@ function buildWrapperPage(args) {
|
|
|
31137
31277
|
);
|
|
31138
31278
|
}
|
|
31139
31279
|
});
|
|
31280
|
+
|
|
31281
|
+
// Browser/adapter back/forward \u2192 broadcast the new params so useUrlState and
|
|
31282
|
+
// the router adapter re-hydrate (the app's own set writes use push/replace
|
|
31283
|
+
// State \u2014 no popstate \u2014 so there's no echo). Mirrors the production host.
|
|
31284
|
+
window.addEventListener("popstate", function () {
|
|
31285
|
+
iframe.contentWindow.postMessage(
|
|
31286
|
+
{ type: "url-state", params: readUrlParams() },
|
|
31287
|
+
VITE_ORIGIN
|
|
31288
|
+
);
|
|
31289
|
+
});
|
|
31140
31290
|
})();
|
|
31141
31291
|
</script>
|
|
31142
31292
|
</body>
|
|
@@ -31195,7 +31345,8 @@ async function startDevServer(args) {
|
|
|
31195
31345
|
process.once("exit", killViteOnExit);
|
|
31196
31346
|
const server = http.createServer(async (req, res) => {
|
|
31197
31347
|
const url = req.url ?? "/";
|
|
31198
|
-
|
|
31348
|
+
const pathname = url.split("?")[0];
|
|
31349
|
+
if (req.method === "GET" && (pathname === "/" || pathname === "/index.html")) {
|
|
31199
31350
|
res.writeHead(200, {
|
|
31200
31351
|
"Content-Type": "text/html; charset=utf-8",
|
|
31201
31352
|
"Cache-Control": "no-cache, no-store, must-revalidate"
|
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
* Conventions (locked decisions):
|
|
12
12
|
* - Vite + React + TypeScript (strict mode).
|
|
13
13
|
* - Entry: src/App.tsx with `export default`. main.tsx wires
|
|
14
|
-
* `mount(<PortalHost><App/></PortalHost>)` —
|
|
15
|
-
*
|
|
14
|
+
* `mount(<LoticsLocaleProvider locale={vi}><PortalHost><App/></PortalHost></LoticsLocaleProvider>)` —
|
|
15
|
+
* LoticsLocaleProvider sets the kit's strings to Vietnamese (Lotics apps are
|
|
16
|
+
* Vietnamese; swap to `en` for English); PortalHost is the render target
|
|
17
|
+
* @lotics/ui overlays (Popover/Tooltip/Dialog) need — and imports
|
|
16
18
|
* @lotics/ui/index.css (base style reset) + @lotics/ui/fonts.css
|
|
17
19
|
* (path-independent Inter @font-face bundle — Text renders unstyled without it).
|
|
18
20
|
* - Vite default `base: "/"` so emitted asset URLs are absolute; the render
|
|
@@ -39,8 +41,8 @@ export interface StarterFile {
|
|
|
39
41
|
* scaffolds resolve the live version via `fetchLatestNpmVersion` and only
|
|
40
42
|
* fall back here when the lookup fails.
|
|
41
43
|
*/
|
|
42
|
-
export declare const STARTER_FALLBACK_UI_VERSION = "
|
|
43
|
-
export declare const STARTER_FALLBACK_SDK_VERSION = "0.
|
|
44
|
+
export declare const STARTER_FALLBACK_UI_VERSION = "6.1.0";
|
|
45
|
+
export declare const STARTER_FALLBACK_SDK_VERSION = "0.40.0";
|
|
44
46
|
/**
|
|
45
47
|
* react-native pin for scaffolded apps. Matches the monorepo frontend's pin so
|
|
46
48
|
* an app deep-typechecks `@lotics/ui`'s `.tsx` source against the SAME RN types
|
package/dist/starter_template.js
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
* Conventions (locked decisions):
|
|
12
12
|
* - Vite + React + TypeScript (strict mode).
|
|
13
13
|
* - Entry: src/App.tsx with `export default`. main.tsx wires
|
|
14
|
-
* `mount(<PortalHost><App/></PortalHost>)` —
|
|
15
|
-
*
|
|
14
|
+
* `mount(<LoticsLocaleProvider locale={vi}><PortalHost><App/></PortalHost></LoticsLocaleProvider>)` —
|
|
15
|
+
* LoticsLocaleProvider sets the kit's strings to Vietnamese (Lotics apps are
|
|
16
|
+
* Vietnamese; swap to `en` for English); PortalHost is the render target
|
|
17
|
+
* @lotics/ui overlays (Popover/Tooltip/Dialog) need — and imports
|
|
16
18
|
* @lotics/ui/index.css (base style reset) + @lotics/ui/fonts.css
|
|
17
19
|
* (path-independent Inter @font-face bundle — Text renders unstyled without it).
|
|
18
20
|
* - Vite default `base: "/"` so emitted asset URLs are absolute; the render
|
|
@@ -35,8 +37,11 @@
|
|
|
35
37
|
* scaffolds resolve the live version via `fetchLatestNpmVersion` and only
|
|
36
38
|
* fall back here when the lookup fails.
|
|
37
39
|
*/
|
|
38
|
-
export const STARTER_FALLBACK_UI_VERSION = "
|
|
39
|
-
|
|
40
|
+
export const STARTER_FALLBACK_UI_VERSION = "6.1.0";
|
|
41
|
+
// Must be ≥ the release that added `@lotics/app-sdk/router` (`AppRouter`, used by
|
|
42
|
+
// the scaffolded App.tsx) — an older offline pin won't resolve the subpath and
|
|
43
|
+
// the scaffold won't typecheck.
|
|
44
|
+
export const STARTER_FALLBACK_SDK_VERSION = "0.40.0";
|
|
40
45
|
/**
|
|
41
46
|
* react-native pin for scaffolded apps. Matches the monorepo frontend's pin so
|
|
42
47
|
* an app deep-typechecks `@lotics/ui`'s `.tsx` source against the SAME RN types
|
|
@@ -92,6 +97,9 @@ export function buildStarterTemplate(args) {
|
|
|
92
97
|
"react-native": STARTER_REACT_NATIVE_VERSION,
|
|
93
98
|
"react-native-svg": "^15.0.0",
|
|
94
99
|
"react-native-web": "^0.21.0",
|
|
100
|
+
// In-app routing for the scaffolded list→detail example. The app owns
|
|
101
|
+
// its routing; the SDK's `isEmbedded()` picks memory vs browser history.
|
|
102
|
+
"react-router-dom": "^7.0.0",
|
|
95
103
|
},
|
|
96
104
|
devDependencies: {
|
|
97
105
|
"@testing-library/react": "^16.1.0",
|
|
@@ -209,8 +217,18 @@ export default defineConfig({
|
|
|
209
217
|
// otherwise serves them without a synthesized default export ("does not
|
|
210
218
|
// provide an export named 'default'"), blanking the iframe. (Production/rollup
|
|
211
219
|
// resolves the interop already, so this is dev-only.)
|
|
220
|
+
//
|
|
221
|
+
// \`react-markdown\` (@lotics/ui's Markdown renderer, reached by AgentRun and
|
|
222
|
+
// any markdown surface) pulls a transitive CJS dep, \`style-to-js\`, whose
|
|
223
|
+
// default export the dev optimizer won't synthesize unbundled ("does not
|
|
224
|
+
// provide an export named 'default'") — blanking the iframe the moment a
|
|
225
|
+
// markdown component mounts. Pre-bundling react-markdown folds the whole
|
|
226
|
+
// subtree (incl. style-to-js) into one interop'd chunk; remark-gfm (imported
|
|
227
|
+
// alongside it) is pre-bundled for the same consistency. Both are regular
|
|
228
|
+
// deps of @lotics/ui (hoisted), so they need no package.json entry here.
|
|
212
229
|
include: [
|
|
213
230
|
"react-native-svg",
|
|
231
|
+
"react-markdown", "remark-gfm",
|
|
214
232
|
"react-native-web", "@react-native/normalize-colors",
|
|
215
233
|
"inline-style-prefixer/lib/createPrefixer",
|
|
216
234
|
"inline-style-prefixer/lib/plugins/crossFade",
|
|
@@ -287,6 +305,10 @@ export default defineConfig({
|
|
|
287
305
|
// the hooks dispatcher the instant a hook (useAgentRun / useQuery)
|
|
288
306
|
// runs in a rendered App tree.
|
|
289
307
|
"@lotics/app-sdk",
|
|
308
|
+
// react-router-dom (App's router) also ships compiled dist JS — pin it
|
|
309
|
+
// into the shared chunk so its hooks don't split the react instance
|
|
310
|
+
// ("Invalid hook call") when a test renders the routed App tree.
|
|
311
|
+
"react-router-dom",
|
|
290
312
|
],
|
|
291
313
|
esbuildOptions: {
|
|
292
314
|
resolveExtensions: [".web.tsx", ".web.ts", ".web.js", ".tsx", ".ts", ".jsx", ".js", ".json"],
|
|
@@ -333,73 +355,132 @@ export default defineConfig({
|
|
|
333
355
|
content: `import "@lotics/ui/index.css";
|
|
334
356
|
import "@lotics/ui/fonts.css";
|
|
335
357
|
import { PortalHost } from "@lotics/ui/portal";
|
|
358
|
+
import { LoticsLocaleProvider, vi } from "@lotics/ui/locale";
|
|
336
359
|
import { mount } from "@lotics/app-sdk";
|
|
337
360
|
import App from "./App";
|
|
338
361
|
|
|
339
362
|
// PortalHost is the render target for @lotics/ui overlays (Popover, Tooltip,
|
|
340
363
|
// Dialog). Without it, Portal renders nothing — keep it wrapping the app.
|
|
364
|
+
//
|
|
365
|
+
// LoticsLocaleProvider supplies the kit's own strings (Pagination, sort
|
|
366
|
+
// headers, select-all, the Drawer's record nav…) in one language — Vietnamese
|
|
367
|
+
// by default, since Lotics apps are Vietnamese. Swap to \`en\` (or your own
|
|
368
|
+
// LoticsLocale pack) for another language; remove it to fall back to English.
|
|
341
369
|
mount(
|
|
342
|
-
<
|
|
343
|
-
<
|
|
344
|
-
|
|
370
|
+
<LoticsLocaleProvider locale={vi}>
|
|
371
|
+
<PortalHost>
|
|
372
|
+
<App />
|
|
373
|
+
</PortalHost>
|
|
374
|
+
</LoticsLocaleProvider>,
|
|
345
375
|
);
|
|
346
376
|
`,
|
|
347
377
|
},
|
|
348
378
|
{
|
|
349
379
|
path: "src/App.tsx",
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
// -
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
380
|
+
// Default scaffold = a minimal in-app router example (a list screen and a
|
|
381
|
+
// detail screen), so a new app starts with the recommended routing shape:
|
|
382
|
+
// - The app uses react-router via `AppRouter` (from @lotics/app-sdk/router),
|
|
383
|
+
// which makes screens real addressable URLs — embedded, they live in the
|
|
384
|
+
// host address bar (?_loc=…) and the browser Back/Forward walk screens;
|
|
385
|
+
// standalone, real path URLs.
|
|
386
|
+
// - The full-container layout pattern is preserved in `Screen`: an outer
|
|
387
|
+
// <View flex:1> claims the iframe height (index.html sets
|
|
388
|
+
// html/body/#root to 100% + #root is a flex column). Keep that chain
|
|
389
|
+
// plain — @lotics/ui/stack wraps children in an unstyled <View> that
|
|
390
|
+
// breaks `flex: 1` propagation to a fill-remaining-space child.
|
|
391
|
+
// A single-screen app can delete the router and render one Screen directly.
|
|
392
|
+
content: `import type { ReactNode } from "react";
|
|
393
|
+
import { View } from "react-native";
|
|
394
|
+
import { useNavigate, useParams } from "react-router-dom";
|
|
395
|
+
import { AppRouter } from "@lotics/app-sdk/router";
|
|
365
396
|
import { Card } from "@lotics/ui/card";
|
|
366
397
|
import { Text } from "@lotics/ui/text";
|
|
367
398
|
import { Button } from "@lotics/ui/button";
|
|
368
399
|
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
400
|
+
// AppRouter makes the app's screens real, addressable URLs — write plain
|
|
401
|
+
// react-router (useNavigate / useParams / <Link>) and it handles both modes:
|
|
402
|
+
// - Embedded in the Lotics host: the current screen lives in the host address
|
|
403
|
+
// bar (?_loc=…) — shareable + refresh-survivable — and the browser Back /
|
|
404
|
+
// Forward buttons walk app screens (then leave the app).
|
|
405
|
+
// - Standalone at <slug>.lotics.app: a normal browser router with real path URLs.
|
|
406
|
+
|
|
407
|
+
const ITEMS = [
|
|
408
|
+
{ id: "1", name: "First item" },
|
|
409
|
+
{ id: "2", name: "Second item" },
|
|
410
|
+
{ id: "3", name: "Third item" },
|
|
411
|
+
];
|
|
412
|
+
|
|
413
|
+
// Outer <View flex:1> claims the full iframe height — works because index.html
|
|
414
|
+
// sets html/body/#root to 100% and #root is a flex column. Keep this flex chain
|
|
415
|
+
// plain (not @lotics/ui/stack) so a fill-remaining-space child can claim height.
|
|
416
|
+
function Screen({ children }: { children: ReactNode }) {
|
|
375
417
|
return (
|
|
376
|
-
<View
|
|
377
|
-
style={{
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
418
|
+
<View style={{ flex: 1, padding: 24, alignItems: "center" }}>
|
|
419
|
+
<View style={{ maxWidth: 640, width: "100%", gap: 16 }}>{children}</View>
|
|
420
|
+
</View>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function ListScreen() {
|
|
425
|
+
const navigate = useNavigate();
|
|
426
|
+
return (
|
|
427
|
+
<Screen>
|
|
428
|
+
<Text size="xl" weight="semibold">${escapeHtml(args.app_name)}</Text>
|
|
429
|
+
<Text color="muted">
|
|
430
|
+
Tap an item to open its detail screen — the app routes itself. Edit{" "}
|
|
431
|
+
<Text weight="medium">src/App.tsx</Text> and run{" "}
|
|
432
|
+
<Text weight="medium">lotics app deploy</Text> to publish.
|
|
433
|
+
</Text>
|
|
434
|
+
{ITEMS.map((item) => (
|
|
435
|
+
<Card key={item.id}>
|
|
436
|
+
<View
|
|
437
|
+
style={{
|
|
438
|
+
padding: 16,
|
|
439
|
+
flexDirection: "row",
|
|
440
|
+
alignItems: "center",
|
|
441
|
+
justifyContent: "space-between",
|
|
442
|
+
gap: 12,
|
|
443
|
+
}}
|
|
444
|
+
>
|
|
445
|
+
<Text weight="medium">{item.name}</Text>
|
|
446
|
+
<Button title="Open" color="primary" onPress={() => navigate("/item/" + item.id)} />
|
|
393
447
|
</View>
|
|
394
448
|
</Card>
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
449
|
+
))}
|
|
450
|
+
</Screen>
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function ItemDetailScreen() {
|
|
455
|
+
const { id } = useParams();
|
|
456
|
+
const navigate = useNavigate();
|
|
457
|
+
const item = ITEMS.find((i) => i.id === id);
|
|
458
|
+
return (
|
|
459
|
+
<Screen>
|
|
460
|
+
{/* An in-app Back control; navigate(-1) walks the history (the browser Back
|
|
461
|
+
button walks app screens too). */}
|
|
462
|
+
<View style={{ alignItems: "flex-start" }}>
|
|
463
|
+
<Button title="Back" onPress={() => navigate(-1)} />
|
|
399
464
|
</View>
|
|
400
|
-
|
|
465
|
+
<Text size="xl" weight="semibold">{item ? item.name : "Not found"}</Text>
|
|
466
|
+
<Card>
|
|
467
|
+
<View style={{ padding: 16, gap: 8 }}>
|
|
468
|
+
<Text>Detail for item {id}.</Text>
|
|
469
|
+
<Text color="muted">Reached via in-app navigation, not a host route.</Text>
|
|
470
|
+
</View>
|
|
471
|
+
</Card>
|
|
472
|
+
</Screen>
|
|
401
473
|
);
|
|
402
474
|
}
|
|
475
|
+
|
|
476
|
+
const routes = [
|
|
477
|
+
{ path: "/", element: <ListScreen /> },
|
|
478
|
+
{ path: "/item/:id", element: <ItemDetailScreen /> },
|
|
479
|
+
];
|
|
480
|
+
|
|
481
|
+
export default function App() {
|
|
482
|
+
return <AppRouter routes={routes} />;
|
|
483
|
+
}
|
|
403
484
|
`,
|
|
404
485
|
},
|
|
405
486
|
{
|
|
@@ -478,9 +559,9 @@ import { render } from "@testing-library/react";
|
|
|
478
559
|
import App from "./App";
|
|
479
560
|
|
|
480
561
|
describe("App", () => {
|
|
481
|
-
test("renders
|
|
562
|
+
test("renders the default route", () => {
|
|
482
563
|
const { container } = render(<App />);
|
|
483
|
-
expect(container).
|
|
564
|
+
expect(container.textContent).toContain("First item");
|
|
484
565
|
});
|
|
485
566
|
});
|
|
486
567
|
`,
|
|
@@ -564,6 +645,16 @@ import { Text } from "@lotics/ui/text";
|
|
|
564
645
|
in this Vite app (the alias is preconfigured in \`vite.config.ts\`). See
|
|
565
646
|
the full export list at https://www.npmjs.com/package/@lotics/ui.
|
|
566
647
|
|
|
648
|
+
## Routing
|
|
649
|
+
|
|
650
|
+
\`src/App.tsx\` ships a minimal in-app router. Write plain react-router and wrap
|
|
651
|
+
your routes in \`AppRouter\` from \`@lotics/app-sdk/router\` — it makes screens real,
|
|
652
|
+
addressable URLs in both modes: embedded in the Lotics host the current screen
|
|
653
|
+
lives in the host address bar (\`?_loc=…\`, shareable + refresh-survivable) and the
|
|
654
|
+
browser Back/Forward walk app screens; standalone (\`<slug>.lotics.app\`) it's a
|
|
655
|
+
normal browser router with real path URLs. A single-screen app can drop the
|
|
656
|
+
router and render one screen directly.
|
|
657
|
+
|
|
567
658
|
See https://lotics.ai/docs/app-sdk for the SDK reference.
|
|
568
659
|
`,
|
|
569
660
|
},
|
|
@@ -77,4 +77,24 @@ describe("buildStarterTemplate", () => {
|
|
|
77
77
|
expect(pkg.dependencies["@lotics/ui"]).toBe(`^${STARTER_FALLBACK_UI_VERSION}`);
|
|
78
78
|
expect(pkg.dependencies["@lotics/app-sdk"]).toBe(`^${STARTER_FALLBACK_SDK_VERSION}`);
|
|
79
79
|
});
|
|
80
|
+
test("package.json includes react-router-dom for the in-app router example", () => {
|
|
81
|
+
const pkg = JSON.parse(fileNamed(buildStarterTemplate(baseArgs), "package.json"));
|
|
82
|
+
expect(pkg.dependencies["react-router-dom"]).toBeDefined();
|
|
83
|
+
});
|
|
84
|
+
test("App.tsx routes via the SDK's AppRouter (real addressable URLs, both modes)", () => {
|
|
85
|
+
// The scaffold demonstrates the recommended shape: the app uses react-router
|
|
86
|
+
// through `AppRouter`, which makes screens addressable URLs in both modes.
|
|
87
|
+
// Guards against the wiring being dropped back to a single screen.
|
|
88
|
+
const app = fileNamed(buildStarterTemplate(baseArgs), "src/App.tsx");
|
|
89
|
+
expect(app).toContain('from "react-router-dom"');
|
|
90
|
+
expect(app).toContain('from "@lotics/app-sdk/router"');
|
|
91
|
+
expect(app).toContain("<AppRouter routes={routes}");
|
|
92
|
+
});
|
|
93
|
+
test("vite.config.ts pins react-router-dom into the vitest web optimizer (else the routed App test splits the react instance)", () => {
|
|
94
|
+
// react-router-dom ships compiled dist JS; un-pinned, its hooks load a second
|
|
95
|
+
// react instance under vitest and the scaffold's App.test fails with "Invalid
|
|
96
|
+
// hook call" the moment it renders RouterProvider.
|
|
97
|
+
const config = fileNamed(buildStarterTemplate(baseArgs), "vite.config.ts");
|
|
98
|
+
expect(config).toMatch(/include:\s*\[[\s\S]*"react-router-dom"/);
|
|
99
|
+
});
|
|
80
100
|
});
|