@omerdlw/base-framework 1.0.0 → 1.0.2
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 +1 -1
- package/README.md +121 -52
- package/dist/atoms/index.d.ts +0 -1
- package/dist/atoms/index.d.ts.map +1 -1
- package/dist/atoms/index.js +1 -64
- package/dist/atoms/index.js.map +1 -1
- package/dist/error/index.js +22 -25
- package/dist/error/index.js.map +1 -1
- package/dist/hooks/index.d.ts +2 -9
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +97 -540
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/types.d.ts +3 -45
- package/dist/hooks/types.d.ts.map +1 -1
- package/dist/hooks/use-controllable-state.d.ts +1 -1
- package/dist/hooks/use-controllable-state.d.ts.map +1 -1
- package/dist/hooks/use-global-event.d.ts +0 -2
- package/dist/hooks/use-global-event.d.ts.map +1 -1
- package/dist/hooks/use-hotkey.d.ts +1 -1
- package/dist/hooks/use-hotkey.d.ts.map +1 -1
- package/dist/index.js +11283 -11772
- package/dist/index.js.map +1 -1
- package/dist/kernel/adapters.d.ts.map +1 -1
- package/dist/kernel/index.d.ts +1 -0
- package/dist/kernel/index.d.ts.map +1 -1
- package/dist/kernel/index.js +10 -81
- package/dist/kernel/index.js.map +1 -1
- package/dist/kernel/installed.d.ts.map +1 -1
- package/dist/kernel/page-controller.d.ts.map +1 -1
- package/dist/kernel/provider.d.ts.map +1 -1
- package/dist/modules/ambient/constants.d.ts +1 -1
- package/dist/modules/ambient/constants.d.ts.map +1 -1
- package/dist/modules/ambient/index.js +180 -326
- package/dist/modules/ambient/index.js.map +1 -1
- package/dist/modules/ambient/module.d.ts.map +1 -1
- package/dist/modules/background/constants.d.ts +4 -2
- package/dist/modules/background/constants.d.ts.map +1 -1
- package/dist/modules/background/context.d.ts +3 -0
- package/dist/modules/background/context.d.ts.map +1 -1
- package/dist/modules/background/hooks.d.ts +1 -1
- package/dist/modules/background/index.d.ts +2 -2
- package/dist/modules/background/index.d.ts.map +1 -1
- package/dist/modules/background/index.js +1172 -1270
- package/dist/modules/background/index.js.map +1 -1
- package/dist/modules/background/overlay.d.ts +1 -1
- package/dist/modules/background/overlay.d.ts.map +1 -1
- package/dist/modules/background/types.d.ts +1 -1
- package/dist/modules/background/types.d.ts.map +1 -1
- package/dist/modules/context-menu/constants.d.ts +1 -1
- package/dist/modules/context-menu/constants.d.ts.map +1 -1
- package/dist/modules/context-menu/context.d.ts.map +1 -1
- package/dist/modules/context-menu/hooks.d.ts.map +1 -1
- package/dist/modules/context-menu/index.js +133 -238
- package/dist/modules/context-menu/index.js.map +1 -1
- package/dist/modules/controls/constants.d.ts +1 -1
- package/dist/modules/controls/constants.d.ts.map +1 -1
- package/dist/modules/controls/hooks.d.ts +1 -1
- package/dist/modules/controls/index.js +28 -162
- package/dist/modules/controls/index.js.map +1 -1
- package/dist/modules/controls/types.d.ts +1 -1
- package/dist/modules/controls/types.d.ts.map +1 -1
- package/dist/modules/dock/cards.d.ts +2 -2
- package/dist/modules/dock/constants.d.ts +1 -1
- package/dist/modules/dock/constants.d.ts.map +1 -1
- package/dist/modules/dock/context.d.ts +10 -6
- package/dist/modules/dock/context.d.ts.map +1 -1
- package/dist/modules/dock/hooks.d.ts +5 -5
- package/dist/modules/dock/hooks.d.ts.map +1 -1
- package/dist/modules/dock/index.d.ts +1 -1
- package/dist/modules/dock/index.d.ts.map +1 -1
- package/dist/modules/dock/index.js +2060 -2080
- package/dist/modules/dock/index.js.map +1 -1
- package/dist/modules/dock/runtime/dock.d.ts.map +1 -1
- package/dist/modules/dock/types.d.ts +1 -1
- package/dist/modules/dock/types.d.ts.map +1 -1
- package/dist/modules/index.js +10789 -11027
- package/dist/modules/index.js.map +1 -1
- package/dist/modules/loading/constants.d.ts +4 -2
- package/dist/modules/loading/constants.d.ts.map +1 -1
- package/dist/modules/loading/context.d.ts +3 -0
- package/dist/modules/loading/context.d.ts.map +1 -1
- package/dist/modules/loading/hooks.d.ts +1 -1
- package/dist/modules/loading/hooks.d.ts.map +1 -1
- package/dist/modules/loading/index.d.ts +2 -2
- package/dist/modules/loading/index.d.ts.map +1 -1
- package/dist/modules/loading/index.js +118 -222
- package/dist/modules/loading/index.js.map +1 -1
- package/dist/modules/loading/types.d.ts +1 -1
- package/dist/modules/loading/types.d.ts.map +1 -1
- package/dist/modules/media/constants.d.ts +2 -1
- package/dist/modules/media/constants.d.ts.map +1 -1
- package/dist/modules/media/context.d.ts +3 -0
- package/dist/modules/media/context.d.ts.map +1 -1
- package/dist/modules/media/index.d.ts +2 -2
- package/dist/modules/media/index.d.ts.map +1 -1
- package/dist/modules/media/index.js +61 -218
- package/dist/modules/media/index.js.map +1 -1
- package/dist/modules/modal/constants.d.ts +1 -1
- package/dist/modules/modal/constants.d.ts.map +1 -1
- package/dist/modules/modal/context.d.ts.map +1 -1
- package/dist/modules/modal/hooks.d.ts +2 -2
- package/dist/modules/modal/index.js +20 -366
- package/dist/modules/modal/index.js.map +1 -1
- package/dist/modules/notification/constants.d.ts +4 -2
- package/dist/modules/notification/constants.d.ts.map +1 -1
- package/dist/modules/notification/context.d.ts +4 -0
- package/dist/modules/notification/context.d.ts.map +1 -1
- package/dist/modules/notification/hooks.d.ts +1 -1
- package/dist/modules/notification/index.d.ts +2 -3
- package/dist/modules/notification/index.d.ts.map +1 -1
- package/dist/modules/notification/index.js +70 -154
- package/dist/modules/notification/index.js.map +1 -1
- package/dist/modules/notification/module.d.ts.map +1 -1
- package/dist/provider.js +255 -257
- package/dist/provider.js.map +1 -1
- package/dist/theme.d.ts +1 -0
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +2 -2
- package/dist/theme.js.map +1 -1
- package/dist/utils/index.d.ts +5 -6
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -57
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/number.d.ts +0 -1
- package/dist/utils/number.d.ts.map +1 -1
- package/dist/utils/object.d.ts +0 -2
- package/dist/utils/object.d.ts.map +1 -1
- package/dist/utils/string.d.ts +0 -3
- package/dist/utils/string.d.ts.map +1 -1
- package/dist/utils/timing.d.ts +0 -1
- package/dist/utils/timing.d.ts.map +1 -1
- package/docs/README.md +124 -0
- package/docs/architecture-and-rules.md +129 -0
- package/docs/core-engine.md +196 -0
- package/docs/custom-modules.md +267 -0
- package/docs/getting-started.md +199 -0
- package/docs/modules/README.md +176 -0
- package/docs/modules/ambient.md +150 -0
- package/docs/modules/background.md +195 -0
- package/docs/modules/context-menu.md +248 -0
- package/docs/modules/controls.md +159 -0
- package/docs/modules/dock.md +734 -0
- package/docs/modules/error-boundary.md +160 -0
- package/docs/modules/kernel.md +266 -0
- package/docs/modules/loading.md +157 -0
- package/docs/modules/media.md +196 -0
- package/docs/modules/modal.md +229 -0
- package/docs/modules/notification.md +161 -0
- package/docs/modules/result.md +135 -0
- package/docs/testing.md +160 -0
- package/docs/theming.md +185 -0
- package/package.json +50 -2
- package/templates/AGENTS.md +165 -0
- package/dist/hooks/use-async-action.d.ts +0 -3
- package/dist/hooks/use-async-action.d.ts.map +0 -1
- package/dist/hooks/use-debounce.d.ts +0 -2
- package/dist/hooks/use-debounce.d.ts.map +0 -1
- package/dist/hooks/use-intersection-observer.d.ts +0 -4
- package/dist/hooks/use-intersection-observer.d.ts.map +0 -1
- package/dist/hooks/use-media-query.d.ts +0 -2
- package/dist/hooks/use-media-query.d.ts.map +0 -1
- package/dist/hooks/use-mounted.d.ts +0 -2
- package/dist/hooks/use-mounted.d.ts.map +0 -1
- package/dist/hooks/use-server-action.d.ts +0 -4
- package/dist/hooks/use-server-action.d.ts.map +0 -1
- package/dist/hooks/use-storage-state.d.ts +0 -5
- package/dist/hooks/use-storage-state.d.ts.map +0 -1
- package/dist/modules/theme.d.ts +0 -3
- package/dist/modules/theme.d.ts.map +0 -1
- package/dist/utils/json.d.ts +0 -3
- package/dist/utils/json.d.ts.map +0 -1
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Error Boundary — `@omerdlw/base-framework/error`
|
|
2
|
+
|
|
3
|
+
> **Read this first if you need to:** stop one crash from taking down the app, wrap a risky widget, send errors to Sentry (or another sink), or understand how a runtime error becomes the dock's error card.
|
|
4
|
+
|
|
5
|
+
## 1. At a glance
|
|
6
|
+
|
|
7
|
+
| | |
|
|
8
|
+
| :--------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
9
|
+
| **Import** | `import { ComponentError, getErrorReporter, … } from "@omerdlw/base-framework/error"` |
|
|
10
|
+
| **Layer** | `src/core` (frozen upstream). Client-only (`"use client"`). |
|
|
11
|
+
| **Installed by** | `CoreProvider`: `GlobalError` wraps everything, `ModuleError` is the boundary around every module backdrop/overlay, `GlobalErrorListener` is mounted once |
|
|
12
|
+
| **Emits** | `EVENT_TYPES.APP_ERROR` (`{ error, errorInfo?, message, resetError? }`) |
|
|
13
|
+
| **Theme** | `errorTheme` slots `screen`, `icon`, `title`, `retryButton` (`src/config/error.core.theme.ts`) |
|
|
14
|
+
| **Tests** | `tests/core/error.test.ts` |
|
|
15
|
+
|
|
16
|
+
## 2. Mental model
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
render error ──▶ nearest boundary (Component < Module < Global)
|
|
20
|
+
├─ onError(error, info, context) (your hook)
|
|
21
|
+
├─ globalEvents.emit(APP_ERROR, …) unless silent → dock shows its error card
|
|
22
|
+
├─ ErrorReporter.captureError(error, context) → handlers (console / Sentry / yours)
|
|
23
|
+
└─ renders the fallback (default card with "Try again")
|
|
24
|
+
|
|
25
|
+
window "error" / "unhandledrejection" ──▶ GlobalErrorListener (filtered, throttled)
|
|
26
|
+
├─ ErrorReporter.captureError
|
|
27
|
+
└─ globalEvents.emit(APP_ERROR)
|
|
28
|
+
|
|
29
|
+
report(scope, error) anywhere ──▶ the reporter sink installed by GlobalErrorListener
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Boundaries are **class components** (React requires it) configured through props. Raw `Error.message` values never reach the UI: the displayed text is a fixed copy or `toUserMessage(error)`.
|
|
33
|
+
|
|
34
|
+
## 3. Quick start
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
// Inline: a failing widget degrades to a small card with "Try again"
|
|
38
|
+
<ComponentError message="Chart failed to load">
|
|
39
|
+
<RevenueChart />
|
|
40
|
+
</ComponentError>
|
|
41
|
+
|
|
42
|
+
// Custom fallback (render prop)
|
|
43
|
+
<ComponentError fallback={({ error, resetError }) => <MyEmpty onRetry={resetError} />}>…</ComponentError>
|
|
44
|
+
|
|
45
|
+
// Reset when a key changes (e.g. the selected id) and observe errors
|
|
46
|
+
<ErrorBoundaryCore resetKey={id} variant="inline" onError={(e, info, ctx) => track(ctx.route)}>…</ErrorBoundaryCore>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// Wire a reporting sink once, e.g. in an app-level client component
|
|
51
|
+
import * as Sentry from "@sentry/nextjs";
|
|
52
|
+
getErrorReporter({ sampleRate: 0.5 })
|
|
53
|
+
.addHandler(createSentryHandler(Sentry))
|
|
54
|
+
.setContext("release", process.env.NEXT_PUBLIC_RELEASE)
|
|
55
|
+
.setTag("app", "web");
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 4. Boundaries
|
|
59
|
+
|
|
60
|
+
| Component | Variant | Fallback copy | Resets when | Notes |
|
|
61
|
+
| :------------------ | :------- | :--------------------------------------------------------------- | :-------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
62
|
+
| `GlobalError` | `full` | "We ran into an unexpected problem. Please try again" | the **pathname** changes (and on "Try again") | Outermost; also emits `APP_ERROR`. |
|
|
63
|
+
| `ModuleError` | `module` | "This part of the app isn't working right now. Please try again" | "Try again" | Used by `ModuleHost` around every module's backdrop and overlay and by `ModuleBoundary`; `name` identifies the module in the report context. |
|
|
64
|
+
| `ComponentError` | `inline` | `message` or "This section couldn't be loaded. Please try again" | "Try again" | For widgets. |
|
|
65
|
+
| `ErrorBoundaryCore` | any | `message` / `toUserMessage(error)` | "Try again" or when `resetKey` changes | The shared implementation. |
|
|
66
|
+
|
|
67
|
+
`ErrorBoundaryCore` props:
|
|
68
|
+
|
|
69
|
+
| Prop | Meaning |
|
|
70
|
+
| :------------------------------ | :----------------------------------------------------------------------------- |
|
|
71
|
+
| `fallback` | A node, or `({ error, resetError }) => node`. |
|
|
72
|
+
| `message`, `title` | Default-card text; `title` and `name` are also recorded in the report context. |
|
|
73
|
+
| `name`, `variant` | Reported as context (`name`, `variant`). |
|
|
74
|
+
| `resetKey` | When it changes the boundary clears its error state. |
|
|
75
|
+
| `onError(error, info, context)` | Called first; exceptions inside it are caught and logged as warnings. |
|
|
76
|
+
| `onReset` | Called after "Try again" / `resetError()`. |
|
|
77
|
+
| `silent` | Do **not** emit `APP_ERROR` (no dock card); the reporter is still called. |
|
|
78
|
+
|
|
79
|
+
The `context` passed to `onError` and the reporter: `{ componentStack, route, userAgent, timestamp, name, variant, source: "ErrorBoundary" }`.
|
|
80
|
+
|
|
81
|
+
## 5. `GlobalErrorListener`
|
|
82
|
+
|
|
83
|
+
Mounted once by `CoreProvider`. It listens to `window` `error` and `unhandledrejection`, then:
|
|
84
|
+
|
|
85
|
+
1. **Ignores** noise (`shouldIgnoreError`): empty errors, Next.js not-found errors, and messages matching `ResizeObserver loop`, `Network request failed`, `Loading chunk`, `Unexpected end of input`, `Failed to fetch`, `Script error`, `HTTP 404`.
|
|
86
|
+
2. **Throttles:** at most **10 errors** per page lifetime, at least **2 s** apart, and each distinct message **once**.
|
|
87
|
+
3. Sends the error to the reporter (`source: "window.onerror" | "unhandledrejection"`) and emits `APP_ERROR` with `message: toUserMessage(error)`.
|
|
88
|
+
|
|
89
|
+
It also installs the reporter as the **sink** of `report()` (below).
|
|
90
|
+
|
|
91
|
+
## 6. `ErrorReporter`
|
|
92
|
+
|
|
93
|
+
`getErrorReporter(options?)` returns a **singleton** (options apply on the first call only).
|
|
94
|
+
|
|
95
|
+
| Option | Default | Meaning |
|
|
96
|
+
| :------------------- | :-------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
97
|
+
| `enabled` | `true` | Master switch. |
|
|
98
|
+
| `sampleRate` | `1` | 0–1 probability per capture. |
|
|
99
|
+
| `deduplicateWindow` | 60 000 ms | A report with the same **fingerprint** (`componentStack top :: message[0..100] :: name :: route`) is dropped inside the window; at most 100 fingerprints are tracked. |
|
|
100
|
+
| `beforeSend(report)` | — | Return a changed report, or `null` to drop it. |
|
|
101
|
+
|
|
102
|
+
Methods: `addHandler({ name, handle })`, `removeHandler(name)`, `setContext(key, value)` (max 10 keys), `setTag(key, value)`, `captureError(error, extraContext?)` → `ErrorReport | undefined`, `captureMessage(message, level?, context?)`.
|
|
103
|
+
|
|
104
|
+
Default handlers are added lazily in the browser **only if you have not added any**: a console handler (silent in production) and a Sentry handler when `window.Sentry` exists. `createSentryHandler(Sentry)` falls back to the console handler when the object lacks `captureException` / `withScope`; it sets fingerprint, user, tags, `environment` and `custom` contexts and the component stack. Handler exceptions are swallowed.
|
|
105
|
+
|
|
106
|
+
`ErrorReport`: `{ error: { name, message, stack }, fingerprint, timestamp, environment (route, url, userAgent, platform, language, online), componentStack, context, tags, user? }`.
|
|
107
|
+
|
|
108
|
+
## 7. User-facing messages and `report()`
|
|
109
|
+
|
|
110
|
+
Raw `Error.message` values (database, SDK, vendor wording) never reach the UI. `toUserMessage(error, { fallback?, codes? })` from `@omerdlw/base-framework/utils` is the single gate: only a `UserError`, a string `Result` error, or a 4xx payload from our own API is shown as written; everything else maps to a fixed sentence in `USER_MESSAGES` (`generic`, `network`, `timeout`, `unauthorized`, `forbidden`, `notFound`, `rateLimited`, `server`). Server code throws `UserError` for validation/permission text it wants shown; route handlers answer with `apiErrorResponse` from `@/infrastructure/http/api-error`.
|
|
111
|
+
|
|
112
|
+
`report(scope, error, level?)` from `@omerdlw/base-framework/utils` replaces `console.*` everywhere. On the client, `GlobalErrorListener` installs the reporter as the sink; without a sink, browsers stay silent in production and servers always log. Only the reporter's dev-only console handler touches `console`.
|
|
113
|
+
|
|
114
|
+
## 8. Public API
|
|
115
|
+
|
|
116
|
+
| Export | Kind | Description |
|
|
117
|
+
| :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
118
|
+
| `GlobalError`, `ModuleError`, `ComponentError` | components | The three boundaries (§4). |
|
|
119
|
+
| `ErrorBoundaryCore` | class component | Shared implementation. |
|
|
120
|
+
| `GlobalErrorListener` | component | Window error/rejection listener (§5). |
|
|
121
|
+
| `getErrorReporter(options?)` | factory | Singleton reporter (§6). |
|
|
122
|
+
| `createConsoleHandler()`, `createSentryHandler(Sentry)` | factories | Sinks. |
|
|
123
|
+
| `createReport`, `createErrorContext`, `fingerprint`, `getErrorMessage`, `shouldIgnoreError`, `getBrowserEnvironment`, `normalizeSampleRate`, `normalizeDedupeWindow`, `getRuntimePath`, `getUserAgent` | utils | Report construction and filtering. |
|
|
124
|
+
| `ERROR_MESSAGES`, `ERROR_LISTENER_CONFIG`, `DEFAULT_DEDUPE_WINDOW` (60 000), `MAX_CONTEXT` (10), `MAX_FINGERPRINTS` (100), `errorTheme` | constants | Copy, limits, theme spec. |
|
|
125
|
+
| Types | | `ErrorReport`, `ErrorReporterHandler`, `ErrorReporterOptions`, `ErrorBoundaryCoreProps`, `ErrorFallbackRender`, `ErrorContextData`, `BrowserEnvironment`, `ErrorThemeSlot`, … |
|
|
126
|
+
|
|
127
|
+
## 9. Recipes and gotchas
|
|
128
|
+
|
|
129
|
+
| I want to… | Do this |
|
|
130
|
+
| :----------------------------------------- | :---------------------------------------------------------------------- |
|
|
131
|
+
| Isolate a risky widget | `<ComponentError>`. |
|
|
132
|
+
| Handle an error myself without a dock card | `<ErrorBoundaryCore silent onError={…}>`. |
|
|
133
|
+
| Send errors to Sentry | `getErrorReporter().addHandler(createSentryHandler(Sentry))`. |
|
|
134
|
+
| Report a handled error | `report("Checkout", error)` (and keep the UI message user-friendly). |
|
|
135
|
+
| Drop PII before sending | `getErrorReporter({ beforeSend: (r) => ({ ...r, user: undefined }) })`. |
|
|
136
|
+
|
|
137
|
+
- Error boundaries catch **render/lifecycle** errors, not errors in event handlers or async code; those reach the `GlobalErrorListener` (or call `report`).
|
|
138
|
+
- `getErrorReporter` ignores options after the first call; configure it once, early.
|
|
139
|
+
- The default fallback does not render `title`; it shows `message` (or the mapped user message).
|
|
140
|
+
- A `GlobalError` reset on navigation means a crash on one route does not stick to the next.
|
|
141
|
+
|
|
142
|
+
## 10. File map
|
|
143
|
+
|
|
144
|
+
| File | Responsibility |
|
|
145
|
+
| :------------- | :-------------------------------------------------------------------------------------------------------- |
|
|
146
|
+
| `index.ts` | Public barrel. |
|
|
147
|
+
| `types.ts` | Report, context, environment, reporter options, boundary props, Sentry scope. |
|
|
148
|
+
| `constants.ts` | Messages, listener config (limits, ignore patterns), dedupe limits. |
|
|
149
|
+
| `utils.ts` | Fingerprinting, report creation, environment capture, error normalization and ignore rules. |
|
|
150
|
+
| `boundary.tsx` | `ErrorBoundaryCore`, default fallback, `GlobalError`, `ModuleError`, `ComponentError`: the module's view. |
|
|
151
|
+
| `listener.ts` | `GlobalErrorListener`. |
|
|
152
|
+
| `reporter.ts` | `ErrorReporter` (sampling, dedupe, handlers) and sink factories. |
|
|
153
|
+
| `theme.ts` | `errorTheme` spec. |
|
|
154
|
+
|
|
155
|
+
`builder.ts`, `provider.tsx`, `view.tsx` and `motion.ts` are intentionally absent ([README](./README.md#42-why-some-optional-slots-are-absent)).
|
|
156
|
+
|
|
157
|
+
## 11. Dependencies
|
|
158
|
+
|
|
159
|
+
- **Uses:** `@omerdlw/base-framework/events` (`APP_ERROR`), `@omerdlw/base-framework/atoms` (`Button`, `Icon` for the fallback), `@omerdlw/base-framework/theme`, `@omerdlw/base-framework/utils` (`report`, `setReportSink`, `toUserMessage`), `next/navigation` (`usePathname`).
|
|
160
|
+
- **Used by:** `src/core/provider.tsx`; the dock (turns `APP_ERROR` into its error status) and the notification module (toasts `APP_ERROR` with `notify: true`) react to the event without importing this module.
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# Kernel — `@omerdlw/base-framework/kernel`
|
|
2
|
+
|
|
3
|
+
> **Read this first if you need to:** register something for the current page (`usePage`), understand how modules install and find each other, write your own module, or debug why a registration did or did not win.
|
|
4
|
+
|
|
5
|
+
## 1. At a glance
|
|
6
|
+
|
|
7
|
+
| | |
|
|
8
|
+
| :------------------- | :-------------------------------------------------------------------------------------------------------------------- |
|
|
9
|
+
| **Import** | `import { usePage, defineModule, definePeer, useModule, … } from "@omerdlw/base-framework/kernel"` |
|
|
10
|
+
| **Layer** | `src/core` (frozen upstream). Knows **no module by name**; never imports `@omerdlw/base-framework/modules/**`, `@/features/**`, `@/app/**`. |
|
|
11
|
+
| **Mounted by** | `CoreProvider` (`src/core/provider.tsx`) → `ModuleHost` |
|
|
12
|
+
| **State** | A scoped, subscribable **registry** (`createRegistryStore`) and a **page controller stack** |
|
|
13
|
+
| **Extension points** | `defineModule({...})`, module augmentation of `CoreModules`, `PageConfig`, `RegistrySchema` |
|
|
14
|
+
| **Tests** | `tests/core/kernel.test.ts` |
|
|
15
|
+
|
|
16
|
+
## 2. Mental model
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
CoreProvider
|
|
20
|
+
└─ GlobalError ─ Tooltip ─ ModuleHost ───────────────────────────────────────────────┐
|
|
21
|
+
│ 1. sortModules(modules) ← dependency order from `uses`, validates ids │
|
|
22
|
+
│ 2. RegistryProvider ← one registry; types come from module `registry` definitions
|
|
23
|
+
│ 3. ModuleHost context ← id → module map, boundary component
|
|
24
|
+
│ 4. PageControllerProvider ← the stack of mounted usePage() controllers
|
|
25
|
+
│ 5. providers nested (module N inside the modules it `uses`)
|
|
26
|
+
│ 6. <Backdrop/> per module → {children} → <Overlay/> per module (each in an error boundary)
|
|
27
|
+
└────────────────────────────────────────────────────────────────────────────────┘
|
|
28
|
+
|
|
29
|
+
usePage(config)
|
|
30
|
+
for every installed module: page.select(config) → slice
|
|
31
|
+
page.entries(slice) → registry entries (applied in ONE batch)
|
|
32
|
+
page.use(slice, {set}) → page.modules[id] (the module's page API)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Three pieces:
|
|
36
|
+
|
|
37
|
+
1. **Modules** — self-describing units (`defineModule`). They reach each other only through `useModule` / `useModuleState` / `definePeer`, never by importing.
|
|
38
|
+
2. **The registry** — a key/value store per module type. Several _sources_ can register the same key; the kernel picks (or merges) the winner and cleans up per lifecycle.
|
|
39
|
+
3. **Pages** — `usePage(config)` turns one object into every module's registrations and page API. The kernel itself only owns `title`, `registry`, `set` and `reset`.
|
|
40
|
+
|
|
41
|
+
## 3. Quick start
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
"use client";
|
|
45
|
+
import { usePage } from "@omerdlw/base-framework/kernel";
|
|
46
|
+
|
|
47
|
+
export function DashboardClient() {
|
|
48
|
+
const page = usePage({
|
|
49
|
+
title: "Dashboard", // kernel: also the dock title fallback
|
|
50
|
+
dock: { description: "Overview", icon: "solar:chart-2-bold" },
|
|
51
|
+
background: { image: "/images/dashboard.jpg", overlay: true },
|
|
52
|
+
loading: isFetching,
|
|
53
|
+
contextMenu: {
|
|
54
|
+
items: [{ key: "refresh", label: "Refresh", onSelect: refetch }],
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Button onClick={() => page.modules.notification?.toast("Saved")}>
|
|
60
|
+
Save
|
|
61
|
+
</Button>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
// Static route metadata: src/app/registry.tsx (registry type = module id)
|
|
68
|
+
export const APP_REGISTRY_ENTRIES = [
|
|
69
|
+
{
|
|
70
|
+
type: "dock",
|
|
71
|
+
items: { "/": { title: "Home", icon: "solar:home-2-bold", path: "/" } },
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 4. `usePage`
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
const page = usePage(config?: PageConfig, options?: PageOptions): PageController
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
| `PageController` member | Meaning |
|
|
83
|
+
| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
84
|
+
| `config` | The effective config (your object merged with runtime `set` overrides). |
|
|
85
|
+
| `modules` | Each module's **page API** (`page.modules.dock`, `.background`, `.notification` …). Present only when the module is installed **and** defines `page.use`. Always use optional chaining: `page.modules.notification?.toast(...)`. |
|
|
86
|
+
| `set(partial)` | Shallow-merge runtime overrides into the config (objects one level deep). Modules expose typed wrappers (`page.modules.dock.set({...})`). |
|
|
87
|
+
| `reset()` | Drop all runtime overrides. |
|
|
88
|
+
| `Provider` | A component that scopes this controller to a subtree (`usePageController()` inside it returns _this_ controller). |
|
|
89
|
+
|
|
90
|
+
`PageConfig` always has `title?` and `registry?`; every module **augments** it (`dock?`, `background?`, `loading?`, `modal?`, `controls?`, `contextMenu?`, `ambient?`, `media?`, `notification?`).
|
|
91
|
+
|
|
92
|
+
`PageOptions` (second argument): registry metadata applied to **all** the page's registrations (`priority`, `source`, `lifecycle`, `scope`, `instanceId`, …). A module slice can override it per item with its own `registry` key.
|
|
93
|
+
|
|
94
|
+
### 4.1 What happens on each render
|
|
95
|
+
|
|
96
|
+
1. `config` + runtime overrides → `effectiveConfig`.
|
|
97
|
+
2. Every module's `page.select` computes its slice; `createModulePayload` keeps modules that have `page.entries` and a non-null slice.
|
|
98
|
+
3. **Stabilization.** The payload is walked and (a) object/array identity is reused when nothing changed, (b) function values (arrow functions, handlers) are replaced by **stable proxies** that always call the latest function, and (c) React elements are cloned with stabilized props. Result: inline `onClick`/`items: () => …` do **not** cause re-registration on every render. Functions whose name starts with a capital letter (components) and direct component values of `valueKind: "component"` types are passed through by identity.
|
|
99
|
+
4. `applyRegistryConfig` validates (`definition.validate`), then registers every entry in **one batch**; the cleanup runs when the payload changes or the component unmounts.
|
|
100
|
+
5. Each module's `page.use(slice, { set })` runs (it is a hook, so it must not be conditional) and the results become `page.modules`.
|
|
101
|
+
|
|
102
|
+
### 4.2 Rules
|
|
103
|
+
|
|
104
|
+
- Call `usePage` once per page component (or layout) at the top level. It is a hook; do not call it conditionally.
|
|
105
|
+
- `usePageController()` is the read-only variant: it returns the active controller without registering anything (used by chrome that needs the current page).
|
|
106
|
+
- Server components cannot call it: put it in a `"use client"` component.
|
|
107
|
+
|
|
108
|
+
## 5. Registry
|
|
109
|
+
|
|
110
|
+
### 5.1 Type definitions (`RegistryDefinition`, on `defineModule({ registry })`)
|
|
111
|
+
|
|
112
|
+
| Field | Meaning |
|
|
113
|
+
| :----------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
114
|
+
| `keyPolicy` | `"singleton"` (one fixed `singletonKey`), `"named"` (any non-empty string), `"path"` (must start with `/`), `"route"` (a `/…` path **or** one of `reservedKeys`). |
|
|
115
|
+
| `lifecycle` | Default lifecycle (below). |
|
|
116
|
+
| `cleanupDelayMs` | Default delay for delayed cleanup. |
|
|
117
|
+
| `valueKind` | `"object"` (default: values must be plain objects) or `"component"` (functions or component objects). |
|
|
118
|
+
| `merge(valuesLowToHigh)` | Combine several sources instead of picking one (dock merges card fields and styles). |
|
|
119
|
+
| `validate(value)` | Return `{ valid, issues }`; invalid values are skipped with a warning (`strict` mode also validates metadata). |
|
|
120
|
+
| `reservedKeys`, `singletonKey` | See key policies. |
|
|
121
|
+
|
|
122
|
+
### 5.2 Who wins
|
|
123
|
+
|
|
124
|
+
For one `(type, key)` every registration is a **record** with `source`, `priority`, and a sequence number. Records are ordered by:
|
|
125
|
+
|
|
126
|
+
1. `priority` — explicit number, otherwise derived from the source (`static` 100, `dynamic` 200, `user` 300);
|
|
127
|
+
2. source rank (`static` < `dynamic` < `user`);
|
|
128
|
+
3. recency (the later registration wins ties).
|
|
129
|
+
|
|
130
|
+
The highest record is the value, **or** `definition.merge` receives all values from lowest to highest (only when all are plain objects).
|
|
131
|
+
|
|
132
|
+
| Source | Typical use |
|
|
133
|
+
| :-------- | :--------------------------------------------------- |
|
|
134
|
+
| `static` | `src/app/registry.tsx` initial entries. |
|
|
135
|
+
| `dynamic` | **Default** for `usePage` / `useModuleRegistration`. |
|
|
136
|
+
| `user` | User-driven overrides. Pass `{ source: "user" }`. |
|
|
137
|
+
|
|
138
|
+
`REGISTRY_SOURCES` exposes the vocabulary.
|
|
139
|
+
|
|
140
|
+
### 5.3 Lifecycles
|
|
141
|
+
|
|
142
|
+
| Lifecycle | Cleanup when the registering page/component goes away |
|
|
143
|
+
| :------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
144
|
+
| `immediate` | Entry removed in the same batch (forces a delay of 0 even if the type declares one). |
|
|
145
|
+
| `graceful`, `route` | Removed after `cleanupDelayMs` (from options or the type definition) — so route transitions do not flicker. With no delay they behave like `immediate`. |
|
|
146
|
+
| `persistent` | Never removed by the page; use `unregister`. |
|
|
147
|
+
|
|
148
|
+
A pending cleanup is **cancelled** if the same entry is re-registered before the timer fires.
|
|
149
|
+
|
|
150
|
+
### 5.4 Metadata keys (`RegistryMetadata`)
|
|
151
|
+
|
|
152
|
+
`lifecycle` (or `cleanup`), `cleanupDelayMs`, `priority`, `source`, `scope`, `instanceId`, `validation` (`"warn"` default or `"strict"`). Anything else is reported by `validateRegistryMetadata`. `scope` partitions entries (default `"app"`); `instanceId` is stamped by the kernel so a component only unregisters its own entries.
|
|
153
|
+
|
|
154
|
+
### 5.5 Reading the registry
|
|
155
|
+
|
|
156
|
+
| Hook | Returns |
|
|
157
|
+
| :---------------------------- | :--------------------------------------------------------------- |
|
|
158
|
+
| `useRegistryValue(type, key)` | The resolved value of one key (re-renders only when it changes). |
|
|
159
|
+
| `useRegistryEntries(type)` | `Record<key, value>` of every key of a type. |
|
|
160
|
+
|
|
161
|
+
The store also supports `register` / `unregister` / `batch` / `transaction` (atomic multi-entry change with a trace id) through `createRegistryStore`; most code never calls them directly (`usePage` and `useModuleRegistration` do).
|
|
162
|
+
|
|
163
|
+
## 6. Modules
|
|
164
|
+
|
|
165
|
+
### 6.1 `defineModule`
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
export const myModule = defineModule({
|
|
169
|
+
id: "announcement", // camelCase: /^[a-z][a-zA-Z0-9]*$/ ("contextMenu", not "context-menu")
|
|
170
|
+
context: MyContext, // React context carrying { actions, store } (ModuleRuntime)
|
|
171
|
+
Provider: MyProvider, // wraps the app (nested after the modules it `uses`)
|
|
172
|
+
Backdrop: MyBackdrop, // rendered BEFORE children (behind the page)
|
|
173
|
+
Overlay: MyOverlay, // rendered AFTER children (above the page)
|
|
174
|
+
uses: ["background"], // optional peers (ordering + dev warning if missing)
|
|
175
|
+
registry: { keyPolicy: "singleton", singletonKey: "current", lifecycle: "route" },
|
|
176
|
+
page: {
|
|
177
|
+
select: (config) => config.announcement, // default: config[id]
|
|
178
|
+
entries: (slice, { pathname }) => [{ key: "current", value: slice }],
|
|
179
|
+
use: (slice, { set }) => ({ show: () => set({ announcement: {...} }) }), // page.modules.announcement
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
declare module "@omerdlw/base-framework/kernel" {
|
|
184
|
+
interface CoreModules { announcement: typeof myModule }
|
|
185
|
+
interface PageConfig { announcement?: AnnouncementConfig }
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`defineModule` freezes and returns the definition. `ModuleHost` throws on **invalid ids**, **duplicate ids**, and **dependency cycles**; in development it warns when a `uses` peer is not installed. Every field except `id` is optional: a module can be only an overlay, only a registry type, only a provider.
|
|
190
|
+
|
|
191
|
+
### 6.2 Talking to other modules
|
|
192
|
+
|
|
193
|
+
| API | Use |
|
|
194
|
+
| :------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
195
|
+
| `useModule(id)` | The module's runtime `{ actions, store }`, or `null` when not installed. |
|
|
196
|
+
| `useModuleState(id, selector, fallback, isEqual?)` | A slice of another module's state; returns `fallback` when it is not installed. Only re-renders when the slice changes. |
|
|
197
|
+
| `definePeer(id, inert)` | A typed reader `{ useRuntime, useActions, useState }` that falls back to the `inert` stand-in when the module is missing. **List the id in `uses`.** This is the pattern the dock, background and ambient use. |
|
|
198
|
+
| `useIsModuleInstalled(id)` | Boolean. |
|
|
199
|
+
| `ModuleBoundary` | Wrap module UI in the host's error boundary (`name` is shown in the error UI). |
|
|
200
|
+
|
|
201
|
+
Pass the module id to `useModule`, `useModuleState` and `definePeer` as a **string literal** (`definePeer("media", …)`) and list it in the module's `uses`: `npm run check:architecture` greps for both.
|
|
202
|
+
|
|
203
|
+
### 6.3 Install order and rendering
|
|
204
|
+
|
|
205
|
+
`sortModules` orders modules so that every module comes **after** the modules it `uses`; `ModuleHost` nests providers in that order (a later module's provider is _inside_ earlier ones, so it can read them). All **Backdrops** render first, then `children`, then all **Overlays**, each wrapped in the boundary passed to the host (`ModuleError` from `@omerdlw/base-framework/error` in `CoreProvider`).
|
|
206
|
+
|
|
207
|
+
### 6.4 Registering without `usePage`
|
|
208
|
+
|
|
209
|
+
`useModuleRegistration(id, value, { enabled?, ...metadata })` registers one module's slice (each module re-exports a named wrapper: `useDockRegistration`, `useBackgroundRegistration`, `useLoadingRegistration`, …). `enabled: false` or a `null` value removes it.
|
|
210
|
+
|
|
211
|
+
## 7. Public API
|
|
212
|
+
|
|
213
|
+
| Export | Kind | Description |
|
|
214
|
+
| :-------------------------------------------------------------------------------------------------------------------------------- | :------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
215
|
+
| `defineModule(definition)` | factory | Declare a module (§6.1). |
|
|
216
|
+
| `ModuleHost`, `ModuleBoundary` | components | Install modules; wrap UI in the module error boundary. |
|
|
217
|
+
| `useModule`, `useModuleState`, `useIsModuleInstalled`, `definePeer` | hooks / factory | Module-to-module access (§6.2). |
|
|
218
|
+
| `useModuleRegistration` | hook | Register one slice outside `usePage`. |
|
|
219
|
+
| `usePage`, `usePageController`, `PageControllerProvider` | hooks / provider | Page registration and read access (§4). |
|
|
220
|
+
| `RegistryProvider`, `useRegistryValue`, `useRegistryEntries`, `createRegistryStore` | provider / hooks / factory | Registry access (§5). |
|
|
221
|
+
| `createRegistryOperations`, `createModuleRegistryDefinitions`, `sortModules`, `applyRegistryConfig`, `createRegistryApplyContext` | functions | Lower-level pieces (used by the host and by tests). |
|
|
222
|
+
| `Compose`, `ProviderEntry` | component / type | Nest providers from a list (`[Component, props]` tuples allowed). |
|
|
223
|
+
| `REGISTRY_SOURCES`, `hasOwnProperty` | constants / util | |
|
|
224
|
+
| Types (`export * from "./types"`) | | `CoreModule`, `CoreModules`, `ModuleId`, `ModuleRuntime`, `ModuleStateOf`, `PageConfig`, `PageController`, `PageModules`, `PageOptions`, `RegistryDefinition`, `RegistryMetadata`, `RegistrySchema`, `AppRegistryEntry`, `ValidationResult`, … |
|
|
225
|
+
|
|
226
|
+
## 8. Recipes and gotchas
|
|
227
|
+
|
|
228
|
+
| I want to… | Do this |
|
|
229
|
+
| :--------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------- |
|
|
230
|
+
| Static page metadata for every route | Add to `APP_REGISTRY_ENTRIES` in `src/app/registry.tsx`. |
|
|
231
|
+
| Let a layout set a default that pages override | Register in the layout with `{ source: "static" }` (or a lower `priority`); pages use the default `dynamic`. |
|
|
232
|
+
| Install only some modules | `<CoreProvider modules={[dockModule, notificationModule]}>`. Modules that `use` a missing peer fall back to inert stand-ins. |
|
|
233
|
+
| Add my own module | Follow §6.1; export it, append it to `modules`, augment the kernel types. No edits to `src/core`. |
|
|
234
|
+
| Read a module's state without coupling | `useModuleState("dock", s => s.expanded, false)`. |
|
|
235
|
+
|
|
236
|
+
- `page.modules.x` is `undefined` for modules that are not installed or have no `page.use`: use `?.`.
|
|
237
|
+
- `ModuleHost` reads the `modules` list **once** (at mount). Changing the array later has no effect; decide the module set before rendering `CoreProvider`.
|
|
238
|
+
- A `useRegistryValue` read happens **after** the registering effect: the first render of the registering component sees `undefined`.
|
|
239
|
+
- `usePage` registrations are page-scoped: they disappear (per lifecycle) when the component unmounts, so call it in the component that owns the page.
|
|
240
|
+
- A module id is an identifier everywhere (registry type, `page.modules[id]`, `uses`): keep it camelCase and stable.
|
|
241
|
+
|
|
242
|
+
## 9. File map
|
|
243
|
+
|
|
244
|
+
| File | Responsibility |
|
|
245
|
+
| :-------------------- | :------------------------------------------------------------------------------------------------------------------- |
|
|
246
|
+
| `index.ts` | Public barrel. |
|
|
247
|
+
| `types.ts` | Registry, module, page-config and controller contracts. |
|
|
248
|
+
| `constants.ts` | Registry sources, source priority/rank, lifecycles, metadata keys, default scope. |
|
|
249
|
+
| `utils.ts` | Input and scope resolution, config merging (`mergeModuleConfigs`), small guards. |
|
|
250
|
+
| `schema.ts` | Key/value/metadata validation, metadata normalization (lifecycle → cleanup delay). |
|
|
251
|
+
| `operations.ts` | Pure registry state machine (`createRegistryOperations`): records, ordering, merge, resolution cache. |
|
|
252
|
+
| `store.ts` | `createRegistryStore`: subscriptions, snapshots, handles, `batch`, `transaction`. |
|
|
253
|
+
| `runtime.ts` | Transactions. |
|
|
254
|
+
| `provider.tsx` | `RegistryProvider`, `useRegistryValue`, `useRegistryEntries`, `useRegistryActions`. |
|
|
255
|
+
| `handlers.ts` | `applyRegistryConfig`: slices → entries in one batch, cleanup per lifecycle. |
|
|
256
|
+
| `hooks.ts` | `useRegistry` (select → stabilize → apply), `createRegistryApplyContext`, `useShallowStable`, config stabilization. |
|
|
257
|
+
| `adapters.tsx` | `useModuleRegistration`. |
|
|
258
|
+
| `installed.ts` | Host context, `sortModules`, payload creation, registry definitions from modules. |
|
|
259
|
+
| `module.tsx` | `defineModule`, `ModuleHost`, `useModule`, `useModuleState`, `definePeer`, `ModuleBoundary`, `useIsModuleInstalled`. |
|
|
260
|
+
| `page-controller.tsx` | `usePage` (producer), `usePageController` (consumer), controller stack and scopes. |
|
|
261
|
+
| `compose.ts` | `Compose` and provider-entry helpers. |
|
|
262
|
+
|
|
263
|
+
## 10. Dependencies
|
|
264
|
+
|
|
265
|
+
- **Uses:** `@omerdlw/base-framework/utils`, `@omerdlw/base-framework/hooks` only. It names no module and imports no upper layer.
|
|
266
|
+
- **Used by:** every module (`defineModule`, registry hooks, peers), `src/core/provider.tsx` (mounts `ModuleHost`), `src/app/registry.tsx` (initial entries), pages and features (`usePage`, `useModuleState`).
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Loading — `@omerdlw/base-framework/modules/loading`
|
|
2
|
+
|
|
3
|
+
> **Read this first if you need to:** show a full-screen loading state for a page or an async action, keep a spinner up long enough not to flicker, supply your own skeleton, or read "is the app busy" from another module.
|
|
4
|
+
|
|
5
|
+
## 1. At a glance
|
|
6
|
+
|
|
7
|
+
| | |
|
|
8
|
+
| :----------------- | :-------------------------------------------------------------------------------------------------------- |
|
|
9
|
+
| **Import** | `import { useLoadingActions, useLoading, defineLoading, … } from "@omerdlw/base-framework/modules/loading"` |
|
|
10
|
+
| **Module id** | `loading` (`loadingModule`, in `defaultModules`) |
|
|
11
|
+
| **Renders** | `LoadingOverlay`: a fixed full-screen `<div role="status" aria-busy>` with a `Spinner` (or your skeleton) |
|
|
12
|
+
| **Registry type** | `loading` — singleton key `page-loading`, lifecycle `graceful`, cleanup delay 600 ms |
|
|
13
|
+
| **Page slice** | `usePage({ loading })` → `page.modules.loading` (all actions + `set`) |
|
|
14
|
+
| **Peers (`uses`)** | none (the dock reads _this_ module through `definePeer("loading")`) |
|
|
15
|
+
| **Theme** | `loadingTheme` slot `overlay` in `src/config/loading.module.theme.ts` |
|
|
16
|
+
| **z-index** | `Z_INDEX.LOADING` (set in the theme `styles`) |
|
|
17
|
+
| **Tests** | `tests/modules/loading.test.ts` |
|
|
18
|
+
|
|
19
|
+
## 2. Mental model
|
|
20
|
+
|
|
21
|
+
There are two independent sources of "loading", merged into one state:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
page-registered (usePage({ loading }), useLoading(config)) ← declarative, tied to the route
|
|
25
|
+
manual (startLoading / stopLoading / withLoading) ← imperative, tied to a task
|
|
26
|
+
└────────────── LoadingStateWithPage ──────────────┘
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- **Page-registered wins.** If the registered config has `isLoading: true`, its `message`, `skeleton`, `showOverlay` (and the fact that it is loading) are used and the manual state is ignored until it turns `false`.
|
|
30
|
+
- Otherwise the **manual** state is used.
|
|
31
|
+
- The overlay appears when `isLoading && showOverlay` **and** no `FullscreenState` primitive is active (a full-screen empty/error state always takes precedence).
|
|
32
|
+
- Separately from the overlay, the **dock** reacts to `isLoading` (it shows its loading card; see [dock.md §7](./dock.md#7-attention-who-owns-the-top-card)). `showOverlay: false` suppresses only the full-screen overlay, not the dock's loading card.
|
|
33
|
+
|
|
34
|
+
## 3. Quick start
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
// Declarative: loading while a route-level query resolves
|
|
38
|
+
usePage({ loading: isFetching ? "Loading profile…" : false }); // string = message (and loading)
|
|
39
|
+
|
|
40
|
+
// Imperative: the overlay stays up at least `minDuration`
|
|
41
|
+
const { withLoading } = useLoadingActions();
|
|
42
|
+
await withLoading(saveSettings(), { message: "Saving…", minDuration: 400 });
|
|
43
|
+
await withLoading(() => refresh(), "Refreshing…"); // a string is the message
|
|
44
|
+
|
|
45
|
+
// From the page controller
|
|
46
|
+
const page = usePage({ loading: false });
|
|
47
|
+
page.modules.loading?.set("Refreshing…");
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
// Reusable preset
|
|
52
|
+
const CheckoutLoading = defineLoading({
|
|
53
|
+
message: "Processing payment",
|
|
54
|
+
showOverlay: true,
|
|
55
|
+
});
|
|
56
|
+
CheckoutLoading.use(isSubmitting); // boolean | message string | LoadingOptions
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## 4. Public API
|
|
60
|
+
|
|
61
|
+
| Export | Kind | Description |
|
|
62
|
+
| :-------------------------------------------------------------- | :-------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
63
|
+
| `loadingModule` | module | `defineModule` definition. |
|
|
64
|
+
| `LoadingProvider` | component | Holds manual state, timers, merges the page-registered value, publishes the store. Mounted by the host. |
|
|
65
|
+
| `LoadingOverlay` | component | The overlay. Mounted by the host. |
|
|
66
|
+
| `useLoading(config?, options?)` | hook | Registers `config` as the page's loading config **and** returns `{ ...state, ...actions }`. `options`: `RegistryMetadata & { enabled? }`. |
|
|
67
|
+
| `useLoadingState()` | hook | `LoadingStateWithPage` (re-renders on change). Throws outside the provider. |
|
|
68
|
+
| `useLoadingActions()` | hook | Stable actions. |
|
|
69
|
+
| `useLoadingRegistration(config, options?)` | hook | Register only (what `usePage` does). |
|
|
70
|
+
| `defineLoading(def)` | builder | `defineLoading(options \| message).use(overrides?, options?)` → `LoadingStateWithPage & LoadingActions`. |
|
|
71
|
+
| `LoadingContext` | context | For tests and the dock's peer reader. |
|
|
72
|
+
| `DEFAULT_LOADING_STATE`, `LOADING_REGISTRY_KEY`, `loadingTheme` | constants | |
|
|
73
|
+
| `normalizeLoadingOptions(options)`, `selectPageLoading(input)` | utils | Coerce loose input; map `boolean` / `string` / object page input to `LoadingOptions`. |
|
|
74
|
+
| Types | | `LoadingState`, `LoadingStateWithPage`, `LoadingOptions`, `LoadingActions`, `LoadingPageConfig`, `DefinedLoading`, … |
|
|
75
|
+
|
|
76
|
+
### 4.1 `LoadingOptions`
|
|
77
|
+
|
|
78
|
+
| Option | Default | Meaning |
|
|
79
|
+
| :------------ | :------ | :----------------------------------------------------------------------- |
|
|
80
|
+
| `isLoading` | — | Only meaningful for page-registered configs. |
|
|
81
|
+
| `message` | `null` | Text (non-strings are dropped). Available in state for custom skeletons. |
|
|
82
|
+
| `skeleton` | `null` | A node shown instead of the spinner. |
|
|
83
|
+
| `minDuration` | `0` | Minimum ms the **manual** loading stays up. Non-positive / invalid → 0. |
|
|
84
|
+
| `showOverlay` | `true` | `false` keeps state without the full-screen overlay. |
|
|
85
|
+
|
|
86
|
+
### 4.2 `LoadingStateWithPage`
|
|
87
|
+
|
|
88
|
+
`{ isLoading, isPageLoading, message, skeleton, minDuration, showOverlay }`. `isPageLoading` is `true` whenever loading is on (page-registered or manual).
|
|
89
|
+
|
|
90
|
+
### 4.3 Actions
|
|
91
|
+
|
|
92
|
+
| Action | Behaviour |
|
|
93
|
+
| :-------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------- |
|
|
94
|
+
| `startLoading(options?)` | Turn manual loading on (cancels a pending delayed stop) and record the start time. |
|
|
95
|
+
| `stopLoading()` | Turn it off. If `minDuration` has not elapsed yet, the stop is **delayed** by the remaining time. With no `minDuration` it is immediate. |
|
|
96
|
+
| `setLoading(boolean)` | Shorthand for the two above (no options). |
|
|
97
|
+
| `setSkeleton(nodeOrUpdater)` | Replace the manual skeleton; accepts `(current) => next`. No re-render when unchanged. |
|
|
98
|
+
| `withLoading(task, options \| message)` | `startLoading`, await the promise (or call the function and await its result), `stopLoading` in `finally`. Rejections propagate. |
|
|
99
|
+
|
|
100
|
+
`page.modules.loading` = these actions plus `set(loading)` (`page.set({ loading })`).
|
|
101
|
+
|
|
102
|
+
### 4.4 Page input forms
|
|
103
|
+
|
|
104
|
+
| `usePage({ loading })` | Becomes |
|
|
105
|
+
| :--------------------- | :------------------------------------------- |
|
|
106
|
+
| `true` / `false` | `{ isLoading }` |
|
|
107
|
+
| `"Loading…"` | `{ isLoading: true, message: "Loading…" }` |
|
|
108
|
+
| object | used as is (needs `isLoading: true` to show) |
|
|
109
|
+
| `null` / `undefined` | nothing registered |
|
|
110
|
+
|
|
111
|
+
The same value also feeds the dock's `isLoading` card flag (`usePage({ dock })` derives it from `loading`).
|
|
112
|
+
|
|
113
|
+
## 5. Behaviour details
|
|
114
|
+
|
|
115
|
+
- **Graceful cleanup.** The registry entry survives 600 ms after the page unmounts, so a route change does not flash the overlay off and on between pages.
|
|
116
|
+
- **`minDuration` is manual-only.** Page-registered loading ends as soon as the page stops declaring it.
|
|
117
|
+
- **Navigation.** The dock calls `stopLoading()` after a navigation completes or times out (15 s), so a `startLoading()` made before navigating does not stick.
|
|
118
|
+
- **No counter.** Manual loading is a single flag. Two overlapping `withLoading` calls share it: the **first** to finish stops it. Use a page-level `loading` flag derived from your own pending count if you need reference counting.
|
|
119
|
+
- **Fullscreen suppression.** While `useIsFullscreenStateActive()` is true (a full-screen view is active) the overlay is hidden.
|
|
120
|
+
- **Accessibility.** The overlay has `role="status"`, `aria-busy="true"` and `aria-label="Loading"`.
|
|
121
|
+
- **Next.js `loading.tsx`.** `src/app/loading.tsx` (a bare `Spinner`) is the route-segment fallback and is independent of this module.
|
|
122
|
+
|
|
123
|
+
## 6. Theming
|
|
124
|
+
|
|
125
|
+
No classes or inline styles in the module. The `loadingTheme` slot `overlay` (starter: `center fixed inset-0 h-screen w-screen`) and its `styles` (`zIndex: Z_INDEX.LOADING`) come from `src/config/loading.module.theme.ts`. The default content is `<Spinner size={30} />`; pass a `skeleton` to replace it. The overlay has no animation (it mounts and unmounts instantly; [README](./README.md#42-why-some-optional-slots-are-absent)).
|
|
126
|
+
|
|
127
|
+
## 7. Recipes and gotchas
|
|
128
|
+
|
|
129
|
+
| I want to… | Do this |
|
|
130
|
+
| :------------------------------------------ | :------------------------------------------------------------------------------------------- |
|
|
131
|
+
| Block the UI during a mutation | `await withLoading(mutate(), "Saving…")` |
|
|
132
|
+
| Avoid a flash for fast tasks | `minDuration: 300` (only delays the _stop_; it does not delay showing). |
|
|
133
|
+
| Loading state without the overlay | `showOverlay: false` and read `useLoadingState().isLoading` yourself. |
|
|
134
|
+
| Know if the app is busy from another module | `definePeer("loading", inert)` and `useState((s) => s.isLoading)`; list `loading` in `uses`. |
|
|
135
|
+
| A custom loader | `skeleton: <MyLoader />` in the options. |
|
|
136
|
+
|
|
137
|
+
- `useLoading(config)` registers the config for the **page**, not for the component; two components registering different configs fight over the singleton key (higher registry priority wins).
|
|
138
|
+
- `defineLoading(...).use(false)` registers `{ isLoading: false }`, which is a no-op loading state (useful to switch it off).
|
|
139
|
+
- A `message` is stored in state but the default overlay does not print it; render it in your `skeleton`.
|
|
140
|
+
|
|
141
|
+
## 8. File map
|
|
142
|
+
|
|
143
|
+
| File | Responsibility |
|
|
144
|
+
| :------------- | :------------------------------------------------------------------------------------------------ |
|
|
145
|
+
| `index.ts` | Public barrel. |
|
|
146
|
+
| `types.ts` | State, options, actions, definition, page-config and theme contracts. |
|
|
147
|
+
| `constants.ts` | `DEFAULT_LOADING_STATE`, registry key, `loadingTheme`. |
|
|
148
|
+
| `utils.ts` | `normalizeLoadingOptions`, `selectPageLoading`. |
|
|
149
|
+
| `context.tsx` | `LoadingProvider` (manual state, min-duration timer, registry merge, store), `useLoading*` hooks. |
|
|
150
|
+
| `hooks.ts` | `useLoadingOverlayModel` (visibility incl. fullscreen check, theme). |
|
|
151
|
+
| `overlay.tsx` | `LoadingOverlay`. |
|
|
152
|
+
| `module.tsx` | `loadingModule`, page API, `defineLoading`, kernel type augmentation. |
|
|
153
|
+
|
|
154
|
+
## 9. Dependencies
|
|
155
|
+
|
|
156
|
+
- **Uses:** `@omerdlw/base-framework/kernel` (`defineModule`, `useModuleRegistration`, `useRegistryValue`), `@omerdlw/base-framework/atoms` (`Spinner`), `@omerdlw/base-framework/hooks` (`useIsFullscreenStateActive`), `@omerdlw/base-framework/utils` (`createStore`), `@omerdlw/base-framework/theme`.
|
|
157
|
+
- **Used by:** `src/core/provider.tsx` (installs it); the **dock**, which reads `isLoading` and calls `stopLoading` through `definePeer("loading")` (inert when not installed); pages and features through `useLoading*` / `page.modules.loading`.
|