@arcote.tech/platform 0.7.16 → 0.7.18

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/platform",
3
3
  "type": "module",
4
- "version": "0.7.16",
4
+ "version": "0.7.18",
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.16",
18
- "@arcote.tech/arc-react": "^0.7.16"
17
+ "@arcote.tech/arc-ds": "^0.7.18",
18
+ "@arcote.tech/arc-react": "^0.7.18"
19
19
  },
20
20
  "peerDependencies": {
21
- "@arcote.tech/arc": "^0.7.16",
22
- "@arcote.tech/arc-otel": "^0.7.16",
21
+ "@arcote.tech/arc": "^0.7.18",
22
+ "@arcote.tech/arc-otel": "^0.7.18",
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: { elements: readonly ArcContextElementAny[] } | undefined;
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 { elements: readonly ArcContextElementAny[] }, S extends string>(
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: allFragments,
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
- registerModule(mod as unknown as ArcModule);
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 (!match && pages.length > 0 && route !== "/") {
108
- const firstNav = pages.find((p) => p.icon && p.label && !p.path.includes("/:"));
109
- navigate(firstNav?.path ?? "/");
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) {
@@ -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 { useCallback, useEffect, useRef, useState } from "react";
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
- // Listen for token changes sync (not full reload)
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
  }, []);