@arcote.tech/platform 0.7.16 → 0.7.17
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/package.json +5 -5
- package/src/arc.ts +85 -29
- package/src/index.ts +1 -1
- package/src/layout/page-router.tsx +36 -4
- package/src/module-loader.ts +60 -7
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcote.tech/platform",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.17",
|
|
5
5
|
"private": false,
|
|
6
6
|
"author": "Przemysław Krasiński [arcote.tech]",
|
|
7
7
|
"description": "Arc Platform — module system, router, layout, theme, i18n, platform app shell",
|
|
@@ -14,12 +14,12 @@
|
|
|
14
14
|
"type-check": "tsc --noEmit"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@arcote.tech/arc-ds": "^0.7.
|
|
18
|
-
"@arcote.tech/arc-react": "^0.7.
|
|
17
|
+
"@arcote.tech/arc-ds": "^0.7.17",
|
|
18
|
+
"@arcote.tech/arc-react": "^0.7.17"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
|
-
"@arcote.tech/arc": "^0.7.
|
|
22
|
-
"@arcote.tech/arc-otel": "^0.7.
|
|
21
|
+
"@arcote.tech/arc": "^0.7.17",
|
|
22
|
+
"@arcote.tech/arc-otel": "^0.7.17",
|
|
23
23
|
"@lingui/core": "^5.0.0",
|
|
24
24
|
"@lingui/react": "^5.0.0",
|
|
25
25
|
"framer-motion": "^12.0.0",
|
package/src/arc.ts
CHANGED
|
@@ -141,6 +141,8 @@ export function contextFragments(
|
|
|
141
141
|
// module() — primary API for creating Arc modules
|
|
142
142
|
// ---------------------------------------------------------------------------
|
|
143
143
|
|
|
144
|
+
type ModuleContext = { elements: readonly ArcContextElementAny[] };
|
|
145
|
+
|
|
144
146
|
class ModuleBuilder<
|
|
145
147
|
TPages = {},
|
|
146
148
|
TCtx = undefined,
|
|
@@ -149,7 +151,7 @@ class ModuleBuilder<
|
|
|
149
151
|
private _public: ArcFragment[] = [];
|
|
150
152
|
private _private: ArcFragment[] = [];
|
|
151
153
|
private _pages: Record<string, string> | undefined;
|
|
152
|
-
private _ctx:
|
|
154
|
+
private _ctx: ModuleContext | (() => ModuleContext) | undefined;
|
|
153
155
|
private _scope: string | undefined;
|
|
154
156
|
private _protectedBy: ModuleAccessRule[] = [];
|
|
155
157
|
|
|
@@ -170,9 +172,15 @@ class ModuleBuilder<
|
|
|
170
172
|
/**
|
|
171
173
|
* Register the module's ArcContext and scope name.
|
|
172
174
|
* Context element fragments are auto-injected in build().
|
|
175
|
+
*
|
|
176
|
+
* Accepts the context directly or as a thunk (`() => ctx`). The thunk form
|
|
177
|
+
* is resilient to circular imports between module packages: at build()
|
|
178
|
+
* time a cycle can leave the context binding still `undefined` (ESM `var`
|
|
179
|
+
* hoisting); the thunk re-reads the live binding after the whole import
|
|
180
|
+
* graph has evaluated.
|
|
173
181
|
*/
|
|
174
|
-
context<C extends
|
|
175
|
-
ctx: C,
|
|
182
|
+
context<C extends ModuleContext, S extends string>(
|
|
183
|
+
ctx: C | (() => C),
|
|
176
184
|
scope: S,
|
|
177
185
|
): ModuleBuilder<TPages, C, S> {
|
|
178
186
|
this._ctx = ctx;
|
|
@@ -211,47 +219,95 @@ class ModuleBuilder<
|
|
|
211
219
|
return this;
|
|
212
220
|
}
|
|
213
221
|
|
|
222
|
+
/** Resolve the declared context — unwraps the thunk form. Returns
|
|
223
|
+
* undefined when the binding has not evaluated yet (import cycle). */
|
|
224
|
+
private resolveCtx(): ModuleContext | undefined {
|
|
225
|
+
if (typeof this._ctx === "function") {
|
|
226
|
+
try {
|
|
227
|
+
return (this._ctx as () => ModuleContext)();
|
|
228
|
+
} catch {
|
|
229
|
+
// TDZ / not yet evaluated — caller retries after the graph settles
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return this._ctx;
|
|
234
|
+
}
|
|
235
|
+
|
|
214
236
|
build(): BuiltModule<TPages, TCtx, TScope>;
|
|
215
237
|
build<D extends Record<string, unknown>>(domain: D): BuiltModule<TPages, TCtx, TScope> & Readonly<D>;
|
|
216
238
|
build(domain?: Record<string, unknown>) {
|
|
217
239
|
const moduleId = nextModuleId();
|
|
218
240
|
|
|
219
|
-
// Auto-inject context element fragments when .context() was called
|
|
220
|
-
if (this._ctx) {
|
|
221
|
-
const ctxFrags = contextFragments(this._ctx);
|
|
222
|
-
this._private = [...ctxFrags, ...this._private];
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const allFragments = [...this._public, ...this._private];
|
|
226
|
-
|
|
227
|
-
// Assign moduleId to all fragments
|
|
228
|
-
for (const f of allFragments) {
|
|
229
|
-
(f as { moduleId: string }).moduleId = moduleId;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Auto-extract and register context from context-element fragments
|
|
233
|
-
const contextElements = allFragments
|
|
234
|
-
.filter((f): f is ContextElementFragment => f.is("context-element"))
|
|
235
|
-
.map((f) => f.element);
|
|
236
|
-
|
|
237
|
-
if (contextElements.length > 0) {
|
|
238
|
-
const ctx = createContext(contextElements);
|
|
239
|
-
setContext(ctx);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
241
|
const mod: Record<string, unknown> = {
|
|
243
242
|
id: moduleId,
|
|
244
243
|
name: this.name,
|
|
245
|
-
fragments:
|
|
244
|
+
fragments: [] as ArcFragment[],
|
|
246
245
|
publicFragments: this._public,
|
|
247
246
|
pages: Object.freeze(this._pages ?? {}),
|
|
248
247
|
};
|
|
249
|
-
if (this._ctx !== undefined) mod.context = this._ctx;
|
|
250
248
|
if (this._scope !== undefined) mod.scope = this._scope;
|
|
251
249
|
if (this._protectedBy.length > 0) mod.access = { rules: this._protectedBy };
|
|
252
250
|
if (domain) Object.assign(mod, domain);
|
|
253
251
|
|
|
254
|
-
|
|
252
|
+
const finalize = (resolvedCtx: ModuleContext | undefined) => {
|
|
253
|
+
// Auto-inject context element fragments when .context() was called
|
|
254
|
+
let privateFragments = this._private;
|
|
255
|
+
if (resolvedCtx) {
|
|
256
|
+
privateFragments = [
|
|
257
|
+
...contextFragments(resolvedCtx),
|
|
258
|
+
...this._private,
|
|
259
|
+
];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const allFragments = [...this._public, ...privateFragments];
|
|
263
|
+
|
|
264
|
+
// Assign moduleId to all fragments
|
|
265
|
+
for (const f of allFragments) {
|
|
266
|
+
(f as { moduleId: string }).moduleId = moduleId;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Auto-extract and register context from context-element fragments
|
|
270
|
+
const contextElements = allFragments
|
|
271
|
+
.filter((f): f is ContextElementFragment => f.is("context-element"))
|
|
272
|
+
.map((f) => f.element);
|
|
273
|
+
|
|
274
|
+
if (contextElements.length > 0) {
|
|
275
|
+
const ctx = createContext(contextElements);
|
|
276
|
+
setContext(ctx);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
mod.fragments = allFragments;
|
|
280
|
+
if (resolvedCtx !== undefined) mod.context = resolvedCtx;
|
|
281
|
+
|
|
282
|
+
registerModule(mod as unknown as ArcModule);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const resolved = this.resolveCtx();
|
|
286
|
+
if (this._ctx !== undefined && !resolved) {
|
|
287
|
+
// `.context()` was declared but the binding has not evaluated yet —
|
|
288
|
+
// we are inside an import cycle between module packages (bundled ESM
|
|
289
|
+
// `var` hoisting yields `undefined` instead of a TDZ error). Defer the
|
|
290
|
+
// WHOLE registration to a microtask: it runs after the entire import
|
|
291
|
+
// graph has evaluated, so the module appears atomically WITH its
|
|
292
|
+
// context elements — no window where its wrappers/pages render
|
|
293
|
+
// against a context that is missing this module's elements.
|
|
294
|
+
queueMicrotask(() => {
|
|
295
|
+
const late = this.resolveCtx();
|
|
296
|
+
if (!late) {
|
|
297
|
+
console.error(
|
|
298
|
+
`[arc] module "${this.name}": context is still undefined after ` +
|
|
299
|
+
`the import graph evaluated — circular import between module ` +
|
|
300
|
+
`packages. Pass a thunk to survive the cycle: ` +
|
|
301
|
+
`module("${this.name}").context(() => ctx, scope). ` +
|
|
302
|
+
`Registering the module WITHOUT its context elements.`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
finalize(late);
|
|
306
|
+
});
|
|
307
|
+
return mod;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
finalize(resolved);
|
|
255
311
|
return mod;
|
|
256
312
|
}
|
|
257
313
|
}
|
package/src/index.ts
CHANGED
|
@@ -93,7 +93,7 @@ export type { LocaleProviderProps } from "./locale";
|
|
|
93
93
|
export { useTitle } from "./hooks/use-title";
|
|
94
94
|
|
|
95
95
|
// Platform
|
|
96
|
-
export { loadModules, reloadModules, syncModules, useModuleLoader } from "./module-loader";
|
|
96
|
+
export { loadModules, reloadModules, syncModules, useModuleLoader, useModulesReady } from "./module-loader";
|
|
97
97
|
export type { ModuleLoaderState, ModuleManifest } from "./module-loader";
|
|
98
98
|
export { PlatformApp } from "./platform-app";
|
|
99
99
|
export type { PlatformAppProps } from "./platform-app";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AnimatePresence, motion } from "framer-motion";
|
|
2
|
-
import { Suspense, useEffect } from "react";
|
|
2
|
+
import { Suspense, useEffect, useRef } from "react";
|
|
3
3
|
import { getDefaultLayout } from "../registry";
|
|
4
4
|
import { usePageFragments } from "./use-page-fragments";
|
|
5
5
|
import { useArcNavigate, useArcRoute, matchRoutePath, hasRouteParams } from "../router";
|
|
@@ -81,6 +81,13 @@ function matchPage(path: string, pages: PageFragment[]): MatchResult | undefined
|
|
|
81
81
|
* Renders the current page fragment inside its layout.
|
|
82
82
|
* Supports parent pages with children — renders shell with tabs prop.
|
|
83
83
|
*/
|
|
84
|
+
// A route that never matches must not be redirected away from more than this
|
|
85
|
+
// many times. Beyond it, an app-level guard is almost certainly bouncing the
|
|
86
|
+
// user straight back (e.g. a provider redirects to a page whose chunk is not
|
|
87
|
+
// loaded for the current token) — redirecting again just spins, remounting the
|
|
88
|
+
// whole tree until React throws #185. Stop, render blank, log a diagnosable error.
|
|
89
|
+
const MAX_REDIRECTS_PER_ROUTE = 3;
|
|
90
|
+
|
|
84
91
|
export function PageRouter() {
|
|
85
92
|
const route = useArcRoute();
|
|
86
93
|
const navigate = useArcNavigate();
|
|
@@ -89,6 +96,11 @@ export function PageRouter() {
|
|
|
89
96
|
const match = matchPage(route, pages);
|
|
90
97
|
const DefaultLayout = getDefaultLayout();
|
|
91
98
|
|
|
99
|
+
// Per-route count of fallback redirects we've issued from an unmatched path.
|
|
100
|
+
// Persists across the redirect ping-pong (cleared per route once it matches).
|
|
101
|
+
const redirectCounts = useRef<Map<string, number>>(new Map());
|
|
102
|
+
const loopedRoutes = useRef<Set<string>>(new Set());
|
|
103
|
+
|
|
92
104
|
// Sync matched params to router context
|
|
93
105
|
useEffect(() => {
|
|
94
106
|
const newParams = match?.params ?? {};
|
|
@@ -104,10 +116,30 @@ export function PageRouter() {
|
|
|
104
116
|
|
|
105
117
|
// Redirect to first available page when current route is no longer registered
|
|
106
118
|
useEffect(() => {
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
119
|
+
if (match) {
|
|
120
|
+
// A route resolving clears its redirect budget so a later genuine
|
|
121
|
+
// visit (e.g. once a token-gated chunk loads) starts fresh.
|
|
122
|
+
redirectCounts.current.delete(route);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (pages.length === 0 || route === "/") return;
|
|
126
|
+
if (loopedRoutes.current.has(route)) return; // already gave up on this route
|
|
127
|
+
|
|
128
|
+
const count = redirectCounts.current.get(route) ?? 0;
|
|
129
|
+
if (count >= MAX_REDIRECTS_PER_ROUTE) {
|
|
130
|
+
loopedRoutes.current.add(route);
|
|
131
|
+
console.error(
|
|
132
|
+
`[arc] PageRouter: route "${route}" has no registered page and the ` +
|
|
133
|
+
`fallback redirect is looping — an app guard keeps sending the user ` +
|
|
134
|
+
`back here. Stopping on a blank screen. Likely cause: the page for ` +
|
|
135
|
+
`this route lives in a token-gated chunk not loaded for the current ` +
|
|
136
|
+
`token (check protectedBy / module chunk grouping).`,
|
|
137
|
+
);
|
|
138
|
+
return;
|
|
110
139
|
}
|
|
140
|
+
redirectCounts.current.set(route, count + 1);
|
|
141
|
+
const firstNav = pages.find((p) => p.icon && p.label && !p.path.includes("/:"));
|
|
142
|
+
navigate(firstNav?.path ?? "/");
|
|
111
143
|
}, [match, pages, route, navigate]);
|
|
112
144
|
|
|
113
145
|
if (!match) {
|
package/src/module-loader.ts
CHANGED
|
@@ -1,12 +1,50 @@
|
|
|
1
|
+
import { registerModuleSyncProvider } from "@arcote.tech/arc";
|
|
1
2
|
import { clearModules, getAllRegisteredModules, getContext, setActiveModules } from "./registry";
|
|
2
3
|
import type { BuildManifest, BuildManifestGroup } from "./types";
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
useSyncExternalStore,
|
|
10
|
+
} from "react";
|
|
4
11
|
|
|
5
12
|
/** @deprecated Use BuildManifest from "./types" */
|
|
6
13
|
export type ModuleManifest = BuildManifest;
|
|
7
14
|
|
|
8
15
|
export type ModuleLoaderState = "loading" | "ready" | "error";
|
|
9
16
|
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// modulesReady store — module-level so `useModulesReady()` is callable from
|
|
19
|
+
// anywhere in the tree without prop-drilling. False while a sync/load is in
|
|
20
|
+
// flight, true otherwise (including on error — no longer "in flight").
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
let modulesReady = true;
|
|
24
|
+
const readyListeners = new Set<() => void>();
|
|
25
|
+
|
|
26
|
+
function setModulesReady(value: boolean): void {
|
|
27
|
+
if (modulesReady === value) return;
|
|
28
|
+
modulesReady = value;
|
|
29
|
+
for (const listener of readyListeners) listener();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Reactive flag: `false` while module chunks are being (re)loaded after a
|
|
34
|
+
* token change / initial load / dev reload, `true` otherwise. For declarative
|
|
35
|
+
* "loading workspace…" UI. SSR snapshot is `true` (no chunks to wait for).
|
|
36
|
+
*/
|
|
37
|
+
export function useModulesReady(): boolean {
|
|
38
|
+
return useSyncExternalStore(
|
|
39
|
+
(cb) => {
|
|
40
|
+
readyListeners.add(cb);
|
|
41
|
+
return () => readyListeners.delete(cb);
|
|
42
|
+
},
|
|
43
|
+
() => modulesReady,
|
|
44
|
+
() => true,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
10
48
|
/**
|
|
11
49
|
* URL for a token-group bundle. Server-filtered manifests carry a signed
|
|
12
50
|
* `group.url`; we use that directly. The signed URL is intentionally
|
|
@@ -234,37 +272,52 @@ export function useModuleLoader(
|
|
|
234
272
|
|
|
235
273
|
// Full reload — for dev-mode file changes (re-import all JS)
|
|
236
274
|
const fullReload = useCallback(async () => {
|
|
275
|
+
setModulesReady(false);
|
|
237
276
|
try {
|
|
238
277
|
await reloadModules(baseUrl);
|
|
239
278
|
setState("ready");
|
|
240
279
|
} catch (e) {
|
|
241
280
|
setError(e instanceof Error ? e : new Error(String(e)));
|
|
242
281
|
setState("error");
|
|
282
|
+
} finally {
|
|
283
|
+
setModulesReady(true);
|
|
243
284
|
}
|
|
244
285
|
}, [baseUrl]);
|
|
245
286
|
|
|
246
287
|
// Sync — for token changes (only fetch manifest, import new, remove stale)
|
|
247
288
|
const sync = useCallback(async () => {
|
|
289
|
+
setModulesReady(false);
|
|
248
290
|
try {
|
|
249
291
|
await syncModules(baseUrl);
|
|
250
292
|
setState("ready");
|
|
251
293
|
} catch (e) {
|
|
252
294
|
setError(e instanceof Error ? e : new Error(String(e)));
|
|
253
295
|
setState("error");
|
|
296
|
+
} finally {
|
|
297
|
+
setModulesReady(true);
|
|
254
298
|
}
|
|
255
299
|
}, [baseUrl]);
|
|
256
300
|
|
|
301
|
+
// Register the module-sync provider so `scope.setToken()` can await chunk
|
|
302
|
+
// loading through the core coordinator (no platform→core back-dependency).
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
if (options.skip) return;
|
|
305
|
+
return registerModuleSyncProvider(() => sync());
|
|
306
|
+
}, [sync, options.skip]);
|
|
307
|
+
|
|
257
308
|
// Initial load
|
|
258
309
|
useEffect(() => {
|
|
259
310
|
if (options.skip || loaded.current) return;
|
|
260
311
|
loaded.current = true;
|
|
261
312
|
|
|
313
|
+
setModulesReady(false);
|
|
262
314
|
loadModules(baseUrl)
|
|
263
315
|
.then(() => setState("ready"))
|
|
264
316
|
.catch((e) => {
|
|
265
317
|
setError(e instanceof Error ? e : new Error(String(e)));
|
|
266
318
|
setState("error");
|
|
267
|
-
})
|
|
319
|
+
})
|
|
320
|
+
.finally(() => setModulesReady(true));
|
|
268
321
|
|
|
269
322
|
// Listen for SSE reload events from CLI (code changed on disk → full reload)
|
|
270
323
|
const evtSource = new EventSource(`${baseUrl}/api/reload-stream`);
|
|
@@ -280,13 +333,14 @@ export function useModuleLoader(
|
|
|
280
333
|
return () => evtSource.close();
|
|
281
334
|
}, [baseUrl, fullReload, options.skip]);
|
|
282
335
|
|
|
283
|
-
//
|
|
336
|
+
// Cross-tab token changes (another tab logged in/out → localStorage write)
|
|
337
|
+
// → sync. Same-tab changes are driven directly through the module-sync
|
|
338
|
+
// coordinator (scope.setToken → triggerModuleSync → registered provider),
|
|
339
|
+
// so we deliberately do NOT listen for the same-tab "arc:token-change"
|
|
340
|
+
// CustomEvent here — that would double-sync.
|
|
284
341
|
useEffect(() => {
|
|
285
342
|
if (typeof window === "undefined") return;
|
|
286
343
|
|
|
287
|
-
const onTokenChange = () => setTokenVersion((v) => v + 1);
|
|
288
|
-
window.addEventListener("arc:token-change", onTokenChange);
|
|
289
|
-
|
|
290
344
|
const onStorage = (e: StorageEvent) => {
|
|
291
345
|
if (e.key?.startsWith("arc:token:")) {
|
|
292
346
|
setTokenVersion((v) => v + 1);
|
|
@@ -295,7 +349,6 @@ export function useModuleLoader(
|
|
|
295
349
|
window.addEventListener("storage", onStorage);
|
|
296
350
|
|
|
297
351
|
return () => {
|
|
298
|
-
window.removeEventListener("arc:token-change", onTokenChange);
|
|
299
352
|
window.removeEventListener("storage", onStorage);
|
|
300
353
|
};
|
|
301
354
|
}, []);
|