@lark.js/mvc 0.0.4 → 0.0.5

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/README.md CHANGED
@@ -1,97 +1,133 @@
1
1
  ---
2
2
  name: lark-mvc
3
3
  description: >
4
- Lark MVC Framework (@lark.js/mvc) skill for building TypeScript frontend applications.
5
- Use this skill whenever the user is working with the Lark framework, including:
6
- creating Views, defining Stores, configuring Routes, writing templates, using Updater,
7
- integrating with Vite or Webpack, or any question about Lark's API, architecture,
8
- or conventions. Also trigger when the user mentions hash-based routing, Proxy-based
9
- reactive state, VDOM diff rendering, event delegation, or the v-lark attribute pattern,
10
- even if they do not explicitly name "Lark".
4
+ Comprehensive guide to the Lark MVC Framework (@lark.js/mvc) for building
5
+ TypeScript SPAs. Use this skill any time the user works with Lark creating
6
+ Views with View.extend() or defineView(), defining reactive Stores with
7
+ defineStore() / computed() / multi(), wiring State for cross-view data,
8
+ setting up Router (including Router.beforeEach async guards), writing
9
+ HTML templates with {{=}}/{{forOf}}/{{if}}/@event/v-lark syntax, configuring
10
+ the Vite plugin or Webpack loader, registering Views with registerViewClass,
11
+ integrating Module Federation with CrossSite, calling Service for API
12
+ requests with caching/dedup/queue, or anything mentioning Frame trees,
13
+ hash routing, real-DOM diff, capture-phase event delegation, or the v-lark
14
+ attribute. Also trigger on Lark-related debugging (window.__lark_Debug,
15
+ Frame Visualizer Bridge, ldk/lak/lvk attributes) and on questions about
16
+ Lark's three data pipelines (Updater / State / Store) or migration patterns
17
+ between them.
11
18
  ---
12
19
 
13
20
  # Lark MVC Framework
14
21
 
15
- Lark is a TypeScript MVC framework for building single-page applications with hash-based routing, Proxy-based reactive state management, and real DOM diff rendering.
22
+ `@lark.js/mvc` is a TypeScript MVC framework for single-page applications. It pairs a strict Model-View-Controller layout with Proxy-based reactive state, hash-based routing, and a real-DOM diff renderer. The framework treats micro-frontend integration (Module Federation) as a first-class concern.
16
23
 
17
- ## Architecture Overview
24
+ This guide walks through architecture, the full public API, project layout, the three data pipelines, the template language, the build-tool integrations, and the common pitfalls. For exhaustive API signatures and template syntax, follow the pointers in [References](#references) at the end.
18
25
 
19
- Lark follows a strict MVC separation:
26
+ ## When to reach for this skill
20
27
 
21
- - **Model**: `defineStore` for reactive state (recommended), `cell`/`observeCell` for standalone reactive cells, `State` for global singleton data (default when no store is specified), `Service` for API request management
22
- - **View**: `View.extend()` creates view subclasses with template rendering, event delegation, and lifecycle hooks
23
- - **Controller**: `Router` for hash-based navigation, `Updater` for per-view data binding and VDOM diff, `Frame` for view lifecycle management
28
+ Any task that names or clearly implies Lark:
24
29
 
25
- Data flows through three pipelines (use Store when possible, State is the default when no store is specified):
30
+ - Creating, extending, or registering Views; wiring view event handlers; setting up view lifecycle (`init`, `make`, `assign`, `render`, `destroy`).
31
+ - Designing reactive state with `defineStore`, `cell`, `computed`, `multi`, or cross-view sharing through `State`.
32
+ - Routing tasks: hash navigation, route guards (`Router.beforeEach`), two-phase `change`/`changed` events, `Router.to(...)`.
33
+ - Authoring `.html` templates with the `{{=}}` / `{{forOf}}` / `{{if}}` / `@event` / `v-lark` syntax.
34
+ - Configuring the Vite plugin (`larkMvcPlugin`) or Webpack loader (`larkMvcLoader`).
35
+ - Embedding remote views via Module Federation (`CrossSite`, `FrameworkConfig.require`).
36
+ - API request layers using `Service.extend`, `Service.add`, `service.all/one/save`, `cleanKeys`.
37
+ - Debugging Frame trees, working with `window.__lark_Debug`, or the Frame Visualizer Bridge.
26
38
 
27
- 1. **Updater Pipeline** (view-local): `updater.set(data)` -> `updater.digest()` -> template function -> HTML string -> VDOM diff -> DOM patch -> `endUpdate()`
28
- 2. **State Pipeline** (default cross-view): `State.set(data)` -> `State.digest()` -> `changed` event -> views read `State.get()` in `assign()` -> `updater.digest()` -> VDOM diff -> DOM patch. State uses key reference counting: when no view observes a key, the data is automatically deleted on view destroy via `State.clean()`
29
- 3. **Store Pipeline** (cross-view, recommended): `store.key = value` -> Proxy set trap -> `trigger()` -> `GlobalDeps` lookup -> Queue microtask batch -> `store.observe` callback -> `updater.digest()` / `updater.set()` -> VDOM diff -> DOM patch
39
+ ## Architecture
30
40
 
31
- ### Boot Sequence (Critical Order)
41
+ Lark separates code along three orthogonal axes:
32
42
 
33
- `Framework.boot()` executes in a specific order that must not be changed:
43
+ - **Model**: `State` (simple global singleton, recommended for lightweight cross-view values), `defineStore` (Proxy-based reactive store with handlers, `computed`, multi-instance support recommended for complex reactive state), `cell`/`observeCell` (standalone reactive cells), `Service` (API request manager with LFU cache + deduplication + serial queue).
44
+ - **View**: `View.extend()` and the typed `defineView()` factory both produce View subclasses. Views own templates, event handlers, the lifecycle, the per-view `Updater`, and resource bookkeeping.
45
+ - **Controller**: `Router` (hash routing, two-phase change confirmation, `beforeEach` async guards), `Updater` (per-view data binding, change tracking, VDOM diff), `Frame` (the runtime tree of view containers, mount/unmount lifecycle, deferred `invoke` queue).
34
46
 
35
- 1. Merge config -> 2. Set Router config -> 3. Set EventDelegator frame getter -> 4. Bind Router/State events -> 5. **Create rootFrame** -> 6. **Router.\_bind()** -> 7. Mount default view
47
+ ### The three data pipelines
36
48
 
37
- The rootFrame is created BEFORE `Router._bind()` so that the root element has a proper ID. If the order were reversed, rootId would default to `"root"` and views could render into `document.body` unexpectedly.
49
+ Lark exposes three ways to flow data to a view. Pick the simplest one that solves the task.
38
50
 
39
- If a view was already mounted by the router (e.g. after a page reload with `#!/counter`), the default view is NOT mounted to avoid URL/display mismatch (`if (defaultView && !rootFrame.view)`).
51
+ 1. **Updater pipeline** (view-local). Use when only the current view reads and writes the data.
52
+ `updater.set(data)` → `updater.digest()` → compiled template function → HTML string → `vdomGetNode` parses it into a temporary DOM tree → `vdomSetChildNodes` diffs against the live DOM → DOM ops applied → `endUpdate()` notifies child frames.
40
53
 
41
- ### Window Globals
54
+ 2. **State pipeline** (simple cross-view, recommended for lightweight shared values like counters, toggles, page title, session info).
55
+ `State.set(data)` → `State.digest()` → `changed` event fires with `keys: ReadonlySet<string>` → views listening read via `State.get()` in their `assign()` → standard Updater path. State uses key reference counting; pair with `mixins: [State.clean("a,b")]` so keys are removed when the last view unmounts.
42
56
 
43
- After boot, the framework sets these globals for debugging and extension:
57
+ 3. **Store pipeline** (complex cross-view, recommended when you need reactive handlers, derived data, multi-instance isolation, or store-internal reactions).
58
+ `store.key = value` → Proxy `set` trap → `trigger()` → `GlobalDeps` lookup → microtask-batched `Queue` → `store.observe` callbacks → standard Updater path. Supports `computed(deps, fn)` for derived state.
44
59
 
45
- | Global | Value | Purpose |
46
- | ------------------------- | ---------------- | --------------------------------- |
47
- | `window.__lark_Framework` | Framework object | Direct framework access |
48
- | `window.__lark_State` | State object | Direct state access |
49
- | `window.__lark_Router` | Router object | Direct router access |
50
- | `window.__lark_Frame` | Frame class | Direct Frame class access |
51
- | `window.__lark_View` | View class | Direct View class access |
52
- | `window.__lark_Debug` | boolean | Enable safeguard Proxy debug mode |
60
+ ### Boot sequence (order matters)
53
61
 
54
- Setting `window.__lark_Debug = true` before boot enables:
62
+ `Framework.boot(config)` runs these steps in this exact order:
55
63
 
56
- - Safeguard Proxy wrapping on State/Updater data reads (warns on cross-page reads, delays direct mutation warnings by 500ms)
57
- - Safeguard Proxy on Router `diff()` results and Location params
64
+ 1. Merge user config into the shared `config` object.
65
+ 2. Inject the merged config into `Router` via `Router._setConfig`.
66
+ 3. Set the EventDelegator's frame getter so global events can find views.
67
+ 4. Subscribe Router and State `changed` events to the dispatcher.
68
+ 5. Mark Framework / Router / State as booted.
69
+ 6. Install the Frame Visualizer Bridge (`postMessage` listener for DevTools).
70
+ 7. **Create the root Frame with `Frame.createRoot(config.rootId)` BEFORE step 8.**
71
+ 8. **Bind `Router._bind()` so hashchange/popstate/beforeunload fire — and Router.diff() runs once initially.**
72
+ 9. Mount the `defaultView` ONLY if Router didn't already mount one (e.g., after a page reload with `#!/counter`).
58
73
 
59
- ## Project Structure
74
+ The root must exist before `Router._bind()` because the initial `diff()` may immediately fire CHANGED → `dispatcherNotifyChange` → `Frame.getRoot()`. If the root didn't exist yet, Router would default to `"root"` and the view would render into the wrong element.
75
+
76
+ ### Window globals
77
+
78
+ After boot, the framework attaches these to `window` for debugging and HMR:
79
+
80
+ | Global | Value | Purpose |
81
+ | ------------------------------------ | ---------------- | ------------------------------------- |
82
+ | `window.__lark_Framework` | Framework object | Direct framework access |
83
+ | `window.__lark_State` | State object | Direct state access |
84
+ | `window.__lark_Router` | Router object | Direct router access |
85
+ | `window.__lark_Frame` | Frame class | Direct Frame class access |
86
+ | `window.__lark_View` | View class | Direct View class access |
87
+ | `window.__lark_registerViewClass` | function | HMR helper: re-register a View class |
88
+ | `window.__lark_invalidateViewClass` | function | HMR helper: drop a View from registry |
89
+ | `window.__lark_getViewClassRegistry` | function | HMR helper: read the View registry |
90
+ | `window.__lark_Debug` | boolean (opt-in) | Enables Safeguard Proxy debug checks |
91
+
92
+ Set `window.__lark_Debug = true` before boot to enable Safeguard Proxy wrapping on `State.get()` reads, `Router.diff()` results, Location params, and `Updater.get()` — it warns when data set on one page is read from a different page, and when something tries to mutate `State.get()` data directly instead of going through `State.set()` + `State.digest()`.
93
+
94
+ ## Project structure
60
95
 
61
96
  ```
62
97
  project/
63
- index.html # Entry HTML with <script type="module" src="/src/boot.ts">
64
- vite.config.ts # Vite config with larkMvcPlugin()
65
- webpack.config.mjs # Webpack config with larkMvcLoader (alternative bundler)
66
- src/
67
- boot.ts # Bootstrap: registerViewClass + Framework.boot(config)
68
- view.ts # Project-level View base class (View.extend)
69
- styles.css # Global styles
70
- store/
71
- count.ts # defineStore declarations
72
- views/
73
- home.ts # View.extend({ template, init, render, event methods })
74
- home.html # Template file (compiled by larkMvcPlugin or larkMvcLoader)
75
- about.ts
76
- about.html
77
- components/
78
- counter-store.ts # Sub-component views
79
- counter-store.html
80
- ```
81
-
82
- **Important**: `boot.ts` must be placed inside `src/` (not project root). The `index.html` entry references it as `/src/boot.ts`.
83
-
84
- ## Step-by-Step Guide
85
-
86
- ### 1. Install and Configure
98
+ ├─ index.html # entry, references <script type="module" src="/src/boot.ts">
99
+ ├─ vite.config.ts # OR webpack.config.mjs
100
+ └─ src/
101
+ ├─ boot.ts # registerViewClass(...) + Framework.boot(config)
102
+ ├─ view.ts # project-wide base view (re-export of defineView/View.extend)
103
+ ├─ styles.css
104
+ ├─ store/
105
+ │ └─ count.ts # defineStore declarations
106
+ ├─ views/
107
+ │ ├─ home.ts
108
+ │ ├─ home.html # compiled by larkMvcPlugin / larkMvcLoader
109
+ │ ├─ about.ts
110
+ │ └─ about.html
111
+ └─ components/ # sub-views embedded via v-lark
112
+ ├─ counter-store.ts
113
+ └─ counter-store.html
114
+ ```
115
+
116
+ `boot.ts` must live inside `src/` — `index.html` references it as `/src/boot.ts`. Putting it at the project root breaks the import resolution at runtime.
117
+
118
+ ## Quick start
119
+
120
+ ### 1. Install
87
121
 
88
122
  ```bash
89
123
  pnpm add @lark.js/mvc
90
124
  ```
91
125
 
92
- ### 2. Configure Vite Integration
126
+ ### 2. Configure your bundler
93
127
 
94
- ```typescript
128
+ Vite (recommended):
129
+
130
+ ```ts
95
131
  // vite.config.ts
96
132
  import { defineConfig } from "vite";
97
133
  import { resolve } from "path";
@@ -99,122 +135,38 @@ import { larkMvcPlugin } from "@lark.js/mvc/vite";
99
135
 
100
136
  export default defineConfig({
101
137
  plugins: [larkMvcPlugin()],
102
- resolve: {
103
- alias: {
104
- "@": resolve(__dirname, "./src"),
105
- },
106
- },
138
+ resolve: { alias: { "@": resolve(__dirname, "./src") } },
107
139
  });
108
140
  ```
109
141
 
110
- The `larkMvcPlugin` automatically compiles `.html` template imports into JS function modules. Zero configuration required -- just add the plugin and import `.html` files in your views.
111
-
112
- How it works internally:
113
-
114
- 1. **resolveId hook**: Intercepts `.html` import source strings. When a module imports a `.html` file, the plugin resolves the full path and appends the `?lark-template` suffix (internal constant `LARK_TEMPLATE_SUFFIX`). This prevents Vite from treating the file as a static asset.
115
- 2. **load hook**: When Vite requests a module whose ID ends with `?lark-template`, the plugin reads the raw HTML file from disk, auto-extracts global variables via `extractGlobalVars()` AST analysis, and compiles the template through `compileTemplate()`. The compiled output is an ES module exporting a function with signature `(data, selfId, refData) => string`.
116
- 3. **enforce: "pre"**: The plugin is registered as a pre-phase plugin to ensure it runs before other Vite plugins that might also handle `.html` files.
117
-
118
- For debug mode with line tracking and detailed error messages:
119
-
120
- ```typescript
121
- plugins: [larkMvcPlugin({ debug: true })];
122
- ```
142
+ The plugin runs in the `pre` phase. Its `resolveId` hook tags `.html` imports with a `?lark-template` suffix so Vite doesn't treat them as static assets, then its `load` hook compiles the raw HTML through `extractGlobalVars()` + `compileTemplate()` into an ES module exporting `(data, viewId, refData) => string`.
123
143
 
124
- ### 3. Configure Webpack Integration
144
+ For Webpack, mirror the same idea with the loader:
125
145
 
126
- ```javascript
146
+ ```js
127
147
  // webpack.config.mjs
128
- import path from "path";
129
- import { fileURLToPath } from "url";
130
- import HtmlWebpackPlugin from "html-webpack-plugin";
131
148
  import { larkMvcLoader } from "@lark.js/mvc/webpack";
132
149
 
133
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
134
-
135
150
  export default {
136
- entry: "./boot.ts",
137
- output: {
138
- path: path.resolve(__dirname, "dist"),
139
- filename: "js/[name].[contenthash:8].js",
140
- clean: true,
141
- },
142
- resolve: {
143
- extensions: [".ts", ".js"],
144
- alias: {
145
- "@": path.resolve(__dirname, "src"),
146
- },
147
- },
151
+ // ...
148
152
  module: {
149
153
  rules: [
150
- {
151
- test: /\.ts$/,
152
- use: "ts-loader",
153
- exclude: /node_modules/,
154
- },
155
- {
156
- test: /\.css$/,
157
- use: ["style-loader", "css-loader", "postcss-loader"],
158
- },
159
- // Lark template processing - larkMvcLoader compiles .html to JS functions
154
+ { test: /\.ts$/, use: "ts-loader", exclude: /node_modules/ },
160
155
  {
161
156
  test: /\.html$/,
162
157
  use: [{ loader: larkMvcLoader }],
163
- exclude: /index\.html$/, // Exclude the entry HTML page
158
+ exclude: /index\.html$/, // HtmlWebpackPlugin handles the entry HTML
164
159
  },
165
160
  ],
166
161
  },
167
- plugins: [
168
- new HtmlWebpackPlugin({
169
- template: "./index.html",
170
- inject: "body",
171
- minify: false,
172
- }),
173
- ],
174
- devServer: {
175
- port: 3001,
176
- open: true,
177
- hot: true,
178
- },
179
- devtool: "source-map",
180
162
  };
181
163
  ```
182
164
 
183
- The `larkMvcLoader` is a standard Webpack loader that compiles `.html` template files into JS function modules at build time. It works by:
184
-
185
- 1. Receiving the raw HTML source string as input from Webpack.
186
- 2. Auto-extracting global variables via `extractGlobalVars()` AST analysis (the same function used by the Vite plugin).
187
- 3. Compiling the template through `compileTemplate()` to produce an ES module string.
188
- 4. Returning the compiled result to Webpack via `this.callback()`.
189
-
190
- Key configuration points:
191
-
192
- - Import `larkMvcLoader` from `@lark.js/mvc/webpack` (not from a file path).
193
- - Use the loader object directly as `loader: larkMvcLoader` (it is a function, not a string name).
194
- - Exclude `index.html` from the loader rule -- the entry HTML page should be processed by HtmlWebpackPlugin, not by larkMvcLoader.
195
- - Use `HtmlWebpackPlugin` to inject scripts into the entry HTML page.
165
+ Both integrations accept `{ debug: true }` to inject source-position markers into the compiled template, so runtime errors point back to the original `.html` line and expression.
196
166
 
197
- For debug mode, pass loader options:
198
-
199
- ```javascript
200
- {
201
- test: /\.html$/,
202
- use: [
203
- {
204
- loader: larkMvcLoader,
205
- options: { debug: true },
206
- },
207
- ],
208
- exclude: /index\.html$/,
209
- },
210
- ```
211
-
212
- The `debug` option enables line tracking and detailed compile-time/runtime error messages with source mapping.
213
-
214
- ### 4. Create Entry HTML
167
+ ### 3. Entry HTML
215
168
 
216
169
  ```html
217
- <!-- index.html -->
218
170
  <!doctype html>
219
171
  <html lang="en">
220
172
  <head>
@@ -229,18 +181,17 @@ The `debug` option enables line tracking and detailed compile-time/runtime error
229
181
  </html>
230
182
  ```
231
183
 
232
- The `<div id="app">` corresponds to `rootId: "app"` in the boot config.
233
-
234
- For Webpack, the entry HTML uses HtmlWebpackPlugin instead of a `<script>` tag. HtmlWebpackPlugin automatically injects the bundled script.
184
+ The `<div id="app">` matches `rootId: "app"` in the boot config.
235
185
 
236
- ### 5. Create a Project Base View
186
+ ### 4. A project-level base View
237
187
 
238
- ```typescript
188
+ ```ts
239
189
  // src/view.ts
240
- import { View, Router } from "@lark.js/mvc";
190
+ import { defineView, Router } from "@lark.js/mvc";
241
191
 
242
- export default View.extend({
192
+ export default defineView({
243
193
  make() {
194
+ // Called once per instance via the merged ctors[] pipeline.
244
195
  this.updater.set({ appName: "My App" });
245
196
  this.on("destroy", () => {
246
197
  console.log(`View destroyed: ${this.id}`);
@@ -252,100 +203,142 @@ export default View.extend({
252
203
  });
253
204
  ```
254
205
 
255
- **How View.extend() works internally**: It creates an ES6 class that extends the parent View. The `make` function is collected into a `ctors` array (along with mixin `make` functions). In the constructor, extend props (like `template`) are applied as **instance properties** after `super()`, because ES6 class field declarations (e.g., `template;` in View) would shadow prototype properties. **CRITICAL**: `render` is explicitly skipped as an instance property — it is wrapped on the prototype by `View.wrapMethod()` to manage signature checking and resource cleanup. Setting `render` on the instance would bypass signature checking, the `"render"` event, and `destroyAllResources()`.
206
+ `defineView` is a thin, type-safe wrapper around `View.extend`: it threads the literal's own shape into `this` via `ThisType<P & ViewInterface>`, so `this.appName` inside `make` is typed without manual casts. Runtime behavior is identical to `View.extend({...})`.
207
+
208
+ ### 5. Boot
209
+
210
+ ```ts
211
+ // src/boot.ts
212
+ import { Framework, registerViewClass, View } from "@lark.js/mvc";
213
+ import type { FrameworkConfig } from "@lark.js/mvc";
214
+ import HomeView from "./views/home";
215
+ import AboutView from "./views/about";
216
+ import NotFoundView from "./views/404";
217
+
218
+ registerViewClass("home", HomeView as typeof View);
219
+ registerViewClass("about", AboutView as typeof View);
220
+ registerViewClass("404", NotFoundView as typeof View);
221
+
222
+ const config: FrameworkConfig = {
223
+ rootId: "app",
224
+ defaultPath: "/home",
225
+ defaultView: "home",
226
+ routes: {
227
+ "/home": "home",
228
+ "/about": "about",
229
+ },
230
+ unmatchedView: "404",
231
+ error(e: Error) {
232
+ console.error("Lark error:", e);
233
+ },
234
+ };
235
+
236
+ Framework.boot(config);
237
+ ```
238
+
239
+ All view classes must be registered _before_ `Framework.boot()`. The registry lives in `src/view-registry.ts` and is exposed through `registerViewClass` (re-exported from `@lark.js/mvc`).
240
+
241
+ ## Defining Stores
256
242
 
257
- ### 6. Define Stores
243
+ ### Basic store
258
244
 
259
- ```typescript
245
+ ```ts
260
246
  // src/store/count.ts
261
- import { defineStore, cell, observeCell, multi } from "@lark.js/mvc";
247
+ import { defineStore } from "@lark.js/mvc";
262
248
 
263
- export interface CountStore {
249
+ interface CountStore {
264
250
  count: number;
265
251
  step: number;
252
+ doubled: number; // computed
266
253
  history: string[];
267
254
  increment: () => void;
268
255
  decrement: () => void;
269
256
  reset: () => void;
270
- setStep: (val: number) => void;
271
- clearHistory: () => void;
272
- registerObservers: () => void;
273
257
  }
274
258
 
275
259
  const useCountStore = defineStore<CountStore>(
276
260
  "count",
277
- (store, { lazySet, shallowSet }) => {
278
- return {
279
- count: 0,
280
- step: 1,
281
- history: [] as string[],
282
- increment() {
283
- store.count = store.count + store.step;
284
- store.history = [...store.history, `+${store.step} -> ${store.count}`];
285
- },
286
- decrement() {
287
- store.count = store.count - store.step;
288
- store.history = [...store.history, `-${store.step} -> ${store.count}`];
289
- },
290
- reset() {
291
- store.count = 0;
292
- store.history = [...store.history, "Reset -> 0"];
293
- },
294
- setStep(val: number) {
295
- store.step = val;
296
- },
297
- clearHistory() {
298
- store.history = [];
299
- },
300
- registerObservers() {
301
- // Inner observe (no view binding) for store-internal reactions
302
- store.observe(undefined, ["step"], () => {
303
- store.count = 0; // Reset count when step changes
304
- });
305
- },
306
- };
307
- },
261
+ (store, { computed, lazySet, shallowSet }) => ({
262
+ count: 0,
263
+ step: 1,
264
+ doubled: computed(["count"], () => store.count * 2),
265
+ history: [] as string[],
266
+ increment() {
267
+ store.count = store.count + store.step;
268
+ store.history = [...store.history, `+${store.step} -> ${store.count}`];
269
+ },
270
+ decrement() {
271
+ store.count = store.count - store.step;
272
+ store.history = [...store.history, `-${store.step} -> ${store.count}`];
273
+ },
274
+ reset() {
275
+ store.count = 0;
276
+ store.history = [];
277
+ },
278
+ }),
308
279
  );
309
280
 
310
281
  export default useCountStore;
311
282
  ```
312
283
 
313
- **Creator function signature**: `(store, apis) => initialState`
284
+ ### How the creator runs
314
285
 
315
- - `store` InnerStore proxy for reading/writing reactive state. Reads return the raw reactive Proxy; writes through `store.key = value` trigger dependency tracking
316
- - `apis` — `{ lazySet, shallowSet }` utility functions:
317
- - `lazySet(target, data)` — Batch-set properties without triggering observers (used during `createState` initialization)
318
- - `shallowSet(target, key, data)` — Create a shallow reactive sub-state where only top-level property changes trigger observers
286
+ The creator runs once at definition time. Lark walks the return value:
319
287
 
320
- ### Standalone Reactive Cells
288
+ - Function entries become **handlers** on the store proxy (`store.increment()` invokes the original closure).
289
+ - `computed(deps, fn)` markers occupy a reactive state slot. After every other state key is initialized, `computed` fires its `fn()` to produce the initial value, then registers an internal `track` so the dep keys re-run `fn` when they change. Writes to a computed key are silently ignored.
290
+ - Everything else becomes initial state and is registered with the reactive Proxy.
321
291
 
322
- For simple reactive data that doesn't need the full store pattern, use `cell` and `observeCell`:
292
+ ### How `useStore(view)` works
323
293
 
324
- ```typescript
325
- import { cell, observeCell } from "@lark.js/mvc";
294
+ - `useStore()` (no view) — read-only access for module-level utilities.
295
+ - `useStore(view)` — registers the view in the store's `boundViews` set; on view destroy, the view is automatically detached.
296
+ - Reading a state key (e.g. `store.count`) outside the creator returns a **deep-cloned copy** (via the native `structuredClone` when available). Mutating the returned value does NOT trigger reactivity.
297
+ - Writing a state key (`store.count = 5`) goes through the reactive Proxy and triggers observers.
298
+ - **Inside the creator**, `store.count` returns the raw reactive Proxy — direct mutation works and is reactive.
299
+
300
+ ### Subscribing a view
301
+
302
+ `store.observe(view, keys?, defaultCallback?)` subscribes the view to store changes. Variations:
326
303
 
327
- // Create a standalone reactive cell
328
- const count = cell(0);
304
+ ```ts
305
+ // Default: observe every state key in the store (including computeds).
306
+ // Avoids the "two lists to keep in sync" pain point.
307
+ store.observe(this);
329
308
 
330
- // Observe changes
331
- const unobserve = observeCell(count, () => {
332
- console.log("count changed:", count.count);
309
+ // Explicit: only the listed keys trigger updates.
310
+ store.observe(this, ["count", "step"]);
311
+
312
+ // With a custom callback (override the default updater.digest behavior).
313
+ store.observe(this, ["count"], (changedMap) => {
314
+ console.log("count changed", changedMap);
333
315
  });
334
316
 
335
- // Mutate (triggers observer)
336
- count.count = 1;
317
+ // Fine-grained ObservePayload entries.
318
+ store.observe(this, [
319
+ { key: "count", alias: "currentCount", lazy: false },
320
+ {
321
+ key: "items",
322
+ transform: (val) => ({ itemCount: (val as unknown[]).length }),
323
+ },
324
+ ]);
337
325
 
338
- // Clean up
339
- unobserve();
326
+ // Inner observe — no view binding, for store-internal reactions.
327
+ // Keys are required (no useful default for a callback-only observer).
328
+ store.observe(undefined, ["step"], () => {
329
+ store.count = 0; // reset count when step changes
330
+ });
340
331
  ```
341
332
 
342
- `cell(data)` creates a Proxy-based reactive state with `belong: "lark-global"` and a unique `linkKeys`. `observeCell(state, cb, immediate?)` tracks changes and calls `cb` when any property changes. By default `immediate = true` (fires callback immediately).
333
+ `lazy: true` (the default) calls `updater.digest()` to merge data. `lazy: false` calls `updater.set()` first, then `digest()` useful when the callback supplies a transformed value that should land on the data object before the next render.
343
334
 
344
- ### Multi-Instance Stores
335
+ Inner observes are de-duplicated by `key + observeKeys.join("-") + cb.toString()`, so the same inner observe with identical key/callback won't register twice.
345
336
 
346
- When a component is used multiple times on the same page and each instance needs independent state, use `multi`:
337
+ ### Multi-instance stores
347
338
 
348
- ```typescript
339
+ When a component is reused N times and each instance needs its own state:
340
+
341
+ ```ts
349
342
  import { defineStore, multi } from "@lark.js/mvc";
350
343
 
351
344
  const useCounterStore = defineStore("counter", (store) => ({
@@ -355,28 +348,70 @@ const useCounterStore = defineStore("counter", (store) => ({
355
348
  },
356
349
  }));
357
350
 
358
- // multi() returns [useFn, mixinObj]
351
+ // multi() returns [useFn, mixinObj].
359
352
  const [useMultiCounter, counterMixin] = multi(useCounterStore);
360
353
 
361
- // In the view:
362
- export default View.extend({
363
- mixins: [counterMixin], // make() generates per-instance flag
354
+ export default defineView({
355
+ mixins: [counterMixin], // its make() stamps a per-instance flag onto the view
364
356
  template,
365
357
  init() {
366
- const store = useMultiCounter(this); // Each view instance gets its own store clone
358
+ const store = useMultiCounter(this); // each instance gets its own store clone
367
359
  store.observe(this, ["count"]);
368
360
  },
369
361
  });
370
362
  ```
371
363
 
372
- `multi()` works by intercepting `mountFrame` calls to propagate a per-instance flag (`lark-comp-{storeName}`) down the Frame tree. When `useFn(view)` is called, it checks the flag and either returns an existing store clone or creates one via `cloneStore()`.
364
+ `multi()` intercepts the parent frame's `mountFrame` to propagate a `lark-comp-<storeName>` flag down the Frame tree. When `useFn(view)` is called, it looks up the flag and either returns an existing store clone or creates one via `cloneStore()`.
365
+
366
+ ### Standalone reactive cells
367
+
368
+ For one-off reactive values that don't need a full store:
369
+
370
+ ```ts
371
+ import { cell, observeCell } from "@lark.js/mvc";
372
+
373
+ const count = cell({ value: 0 });
374
+ const off = observeCell(count, () => console.log("changed:", count.value));
375
+ count.value = 1; // triggers callback
376
+ off(); // clean up
377
+ ```
378
+
379
+ `cell(data)` creates a Proxy with `belong: "lark-global"` and a unique `linkKeys`. `observeCell(state, cb, immediate?)` registers a tracker (defaults to firing immediately).
380
+
381
+ ## Defining Views
382
+
383
+ ### View template
384
+
385
+ ```html
386
+ <!-- src/views/home.html -->
387
+ <div>
388
+ <h1>{{=title}}</h1>
389
+ <div>Count: {{=count}}</div>
390
+ <button @click="navigateTo({path: '/about'})">About</button>
391
+
392
+ {{if count > 0}}
393
+ <p>Positive</p>
394
+ {{else}}
395
+ <p>Zero or negative</p>
396
+ {{/if}}
397
+
398
+ <ul>
399
+ {{forOf items as item idx}}
400
+ <li id="item-{{=item.id}}">{{=idx}}: {{=item.name}}</li>
401
+ {{/forOf}}
402
+ </ul>
403
+
404
+ <!-- Sub-view embedding -->
405
+ <div v-lark="components/child"></div>
406
+ </div>
407
+ ```
373
408
 
374
- ### 7. Create a View with Template
409
+ ### View class
375
410
 
376
- ```typescript
411
+ ```ts
377
412
  // src/views/home.ts
378
413
  import { Router } from "@lark.js/mvc";
379
- import View from "../view";
414
+ import View from "../view"; // project-level base
380
415
  import template from "./home.html";
381
416
  import useCountStore from "../store/count";
382
417
 
@@ -386,11 +421,13 @@ export default View.extend({
386
421
  init() {
387
422
  this.assign();
388
423
 
389
- // Observe count store -- changes trigger automatic view re-render
390
424
  const store = useCountStore(this);
391
- store.observe(this, ["count", "step"]);
425
+ store.observe(this); // observe every store key (D5 default)
392
426
  },
393
427
 
428
+ // assign() pulls the latest store + State values into this.updater.
429
+ // Always call snapshot() at the top and return altered() at the end
430
+ // so the framework knows whether a re-digest is needed.
394
431
  assign() {
395
432
  this.updater.snapshot();
396
433
 
@@ -399,315 +436,655 @@ export default View.extend({
399
436
  title: "Home",
400
437
  count: store.count,
401
438
  step: store.step,
439
+ items: [
440
+ { id: "a", name: "Alpha" },
441
+ { id: "b", name: "Beta" },
442
+ ],
402
443
  });
403
444
 
404
445
  return this.updater.altered();
405
446
  },
406
447
 
448
+ // render() is wrapped by the framework to manage signature/lifecycle.
449
+ // The default implementation calls this.updater.digest().
407
450
  render() {
408
451
  this.updater.digest();
409
452
  },
410
453
 
454
+ // Event method naming: `name<eventType>`. See "Event methods" below.
411
455
  "navigateTo<click>"(e: Record<string, unknown>) {
412
456
  const params = e["params"] as Record<string, string> | undefined;
413
- const path = params?.path;
414
- if (path) Router.to(path);
457
+ if (params?.path) Router.to(params.path);
415
458
  },
416
459
  });
417
460
  ```
418
461
 
419
- ### 8. Write Templates
462
+ ### Event methods
420
463
 
421
- ```html
422
- <!-- src/views/home.html -->
423
- <div>
424
- <h1>{{=title}}</h1>
425
- <div>Count: {{=count}}</div>
426
- <button @click="navigateTo({path: '/about'})">About</button>
464
+ Lark scans the View prototype once per class (in `View.prepare`) and builds three event maps on the prototype (`$evtObjMap`, `$selMap`, `$globalEvtList`). DOM events are delegated to `document.body` using **capture-phase** listeners with reference counting — the first binding installs the listener, the last unbinding removes it.
427
465
 
428
- {{if count > 0}}
429
- <p>Positive</p>
430
- {{else}}
431
- <p>Zero or negative</p>
432
- {{/if}}
466
+ | Pattern | Meaning |
467
+ | -------------------------- | ------------------------------------------------- |
468
+ | `handler<click>` | Root event on the view element |
469
+ | `$selector<click>` | Delegated event matching CSS selector `.selector` |
470
+ | `$<click>` | Empty selector — frame boundary event only |
471
+ | `$window<resize>` | Global event on `window` |
472
+ | `$document<keydown>` | Global event on `document` |
473
+ | `handler<click,mousedown>` | Multi-event binding |
474
+ | `name<click><ctrl>` | Modifier filter — only fires when Ctrl is pressed |
433
475
 
434
- <ul>
435
- {{forOf items as item idx}}
436
- <li>{{=idx}}: {{=item}}</li>
437
- {{/forOf}}
438
- </ul>
476
+ Each event handler receives an event object with these augmented fields:
439
477
 
440
- <!-- Sub-view embedding -->
441
- <div v-lark="components/child"></div>
442
- </div>
443
- ```
478
+ - `e.eventTarget` — the actual DOM element that was clicked.
479
+ - `e.params` — parsed parameters from `@event` attributes (URL query string format).
480
+ - All standard DOM Event properties (`type`, `target`, etc.).
444
481
 
445
- ### 9. Bootstrap the Application
482
+ When two mixins define the same event method, they're merged into a single function that calls both in sequence via a `handlerList` array.
446
483
 
447
- ```typescript
448
- // src/boot.ts
449
- import { Framework, Router, View, registerViewClass } from "@lark.js/mvc";
450
- import type { FrameworkConfig } from "@lark.js/mvc";
451
- import HomeView from "./views/home";
452
- import AboutView from "./views/about";
453
- import CounterView from "./views/counter";
454
- import NotFoundView from "./views/404";
455
- import CounterStoreComponent from "./components/counter-store";
456
- import CounterUpdaterComponent from "./components/counter-updater";
484
+ ### Resource management
457
485
 
458
- // Register View classes to Frame
459
- registerViewClass("home", HomeView as typeof View);
460
- registerViewClass("about", AboutView as typeof View);
461
- registerViewClass("counter", CounterView as typeof View);
462
- registerViewClass("404", NotFoundView as typeof View);
463
- registerViewClass(
464
- "components/counter-store",
465
- CounterStoreComponent as typeof View,
466
- );
467
- registerViewClass(
468
- "components/counter-updater",
469
- CounterUpdaterComponent as typeof View,
470
- );
486
+ `capture` and `release` manage objects whose lifetime tracks the view (timers, services, observers, etc.):
471
487
 
472
- const config: FrameworkConfig = {
473
- rootId: "app",
474
- defaultPath: "/home",
475
- defaultView: "home",
476
- routes: {
477
- "/home": "home",
478
- "/about": "about",
479
- "/counter": "counter",
480
- },
481
- unmatchedView: "404",
482
- error(e: Error) {
483
- console.error("Lark error:", e);
488
+ ```ts
489
+ const timer = setInterval(() => {
490
+ /* ... */
491
+ }, 1000);
492
+ this.capture(
493
+ "myTimer",
494
+ {
495
+ destroy() {
496
+ clearInterval(timer);
497
+ },
484
498
  },
485
- };
486
-
487
- Framework.boot(config);
499
+ true,
500
+ );
501
+ // destroyOnRender=true: destroyed on next render call
502
+ // destroyOnRender=false: destroyed only on view destroy
488
503
  ```
489
504
 
490
- ## Key Patterns
505
+ `release(key, destroy=true)` removes the entry (and calls `.destroy()` unless `destroy=false`).
491
506
 
492
- ### Event Method Naming
507
+ ### Async safety with `wrapAsync`
493
508
 
494
- Event handlers use the `name<eventType>` pattern. The framework scans the View prototype at class preparation time (in `View.prepare()`) and builds three event maps on the prototype: `$evtObjMap`, `$selMap`, `$globalEvtList`.
495
-
496
- | Pattern | Meaning |
497
- | -------------------------- | ------------------------------------------------- |
498
- | `handler<click>` | Root event on the view element |
499
- | `$selector<click>` | Delegated event matching CSS selector `.selector` |
500
- | `$window<resize>` | Global event on `window` |
501
- | `$document<keydown>` | Global event on `document` |
502
- | `handler<click,mousedown>` | Multi-event binding |
509
+ Async callbacks may resolve after the view has been re-rendered or destroyed. `wrapAsync` captures the current signature so the callback short-circuits if the view has moved on:
503
510
 
504
- All DOM events are delegated to `document.body` using **capture-phase** listeners (`addEventListener(type, handler, true)`). The EventDelegator uses reference counting: the first binding adds the listener, the last unbinding removes it. On event dispatch, the processor walks up the DOM from the target to `document.body`, checking `@<eventType>` attributes and selector matches at each level.
511
+ ```ts
512
+ async loadData() {
513
+ const safeCallback = this.wrapAsync((data) => {
514
+ this.updater.set({ items: data }).digest();
515
+ });
516
+ const data = await fetch("/api/items").then(r => r.json());
517
+ safeCallback(data); // no-op if view re-rendered or destroyed
518
+ }
519
+ ```
505
520
 
506
- The event object passed to handlers contains:
521
+ ### Location observation
507
522
 
508
- - `e.eventTarget` -- the actual DOM element that was clicked
509
- - `e.params` -- parsed parameters from `@event` attributes (URL query string format)
510
- - Standard DOM Event properties (`type`, `target`, etc.)
523
+ ```ts
524
+ // In a view:
525
+ this.observeLocation("page,size", true); // params + observePath
526
+ this.observeLocation(["page", "size"]); // array form
527
+ this.observeLocation({ params: ["page"], path: true }); // object form
528
+ ```
511
529
 
512
- When two mixins define the same event method, they are merged into a single function that calls both in sequence via a `handlerList` array.
530
+ When the listed params or path change, the framework re-runs the view's render automatically.
513
531
 
514
- ### Store Observe Variations
532
+ ### State observation (for the State pipeline)
515
533
 
516
- ```typescript
517
- // Simple: auto-digest on key change
518
- store.observe(this, ["count", "step"]);
534
+ ```ts
535
+ this.observeState("count,step"); // comma-separated
536
+ this.observeState(["count", "step"]); // array
537
+ ```
519
538
 
520
- // With custom callback
521
- store.observe(this, ["count"], (changedMap) => {
522
- console.log("count changed", changedMap);
523
- });
539
+ When State.digest() flips one of these keys, the framework re-renders the view.
524
540
 
525
- // With ObservePayload for fine-grained control
526
- store.observe(this, [
527
- { key: "count", alias: "currentCount", lazy: false },
528
- { key: "items", transform: (val) => ({ itemCount: val.length }) },
529
- ]);
541
+ ### Sub-view embedding
530
542
 
531
- // Inner observe (no view binding, for store-internal reactions)
532
- store.observe(undefined, ["step"], () => {
533
- store.count = 0; // Reset count when step changes
534
- });
543
+ ```html
544
+ <div v-lark="components/child-view"></div>
545
+ ```
535
546
 
536
- // Lazy observe (default: true) -- triggers updater.digest() on change
537
- // Non-lazy observe (lazy: false) -- triggers updater.set() then digest()
538
- // This means lazy: true merges data via digest(), lazy: false sets data first
547
+ At mount time, `Frame.mountZone` runs `querySelectorAll("[v-lark]")` on the view's root, creates a child Frame for each match, and mounts the registered View class. The container's inner content is replaced by the child view's rendered output.
548
+
549
+ For dynamic loading (no upfront `registerViewClass`), `mountView` automatically calls `Framework.use()` to load the View class through the configured `require` hook (see Module Federation below).
550
+
551
+ ## Defining the Framework Boot
552
+
553
+ `Framework.boot(config)` config accepts:
554
+
555
+ ```ts
556
+ interface FrameworkConfig {
557
+ rootId: string; // required — DOM id for the root frame
558
+ defaultView?: string; // default view path
559
+ defaultPath?: string; // path when URL hash is empty (defaults to "/")
560
+ routes?: Record<string, string | RouteViewConfig>; // path → view path mapping
561
+ hashbang?: string; // defaults to "#!"
562
+ unmatchedView?: string; // 404 view path
563
+ rewrite?: (path, params, routes) => string; // dynamic path rewriting
564
+ error?: (e: Error) => void; // global error handler (do not re-throw)
565
+ extensions?: string[]; // extension view paths loaded at startup
566
+ initModule?: string; // init module path
567
+ skipViewRendered?: boolean;
568
+ projectName?: string; // for Module Federation discriminator
569
+ crossConfigs?: CrossSiteConfig[]; // MF remote configs
570
+ require?: (names: string[], params?) => Promise<unknown[]>; // async View loader
571
+ [k: string]: unknown; // custom keys are allowed
572
+ }
539
573
  ```
540
574
 
541
- ### Store Read/Write Behavior
575
+ After boot, prefer `Framework.getConfig(key)` for reads and `Framework.setConfig(patch)` for writes. The older `Framework.config(...)` overload still works but is `@deprecated`.
542
576
 
543
- The `useStore()` function returns a Proxy that separates state reads from handler access:
577
+ ## Router
544
578
 
545
- - **Reading state keys** (e.g., `store.count`) — returns a deep-cloned copy of the value (frozen for React adapter). This prevents external mutation of reactive state
546
- - **Writing state keys** (e.g., `store.count = 5`) — sets the value on the internal reactive state, triggering dependency tracking
547
- - **Accessing handlers** (e.g., `store.increment()`) — calls the function defined in the creator
548
- - **Inside the creator**, `store.count` reads the raw reactive Proxy (no cloning), enabling direct mutation and reactivity
579
+ ### Navigation
549
580
 
550
- The store uses a microtask-based `Queue` scheduler (Promise.resolve().then()) for batching observer callbacks. Multiple state changes in the same synchronous block are batched and processed in a single microtask.
581
+ ```ts
582
+ Router.to("/list", { page: 2 }); // path + params
583
+ Router.to({ page: 3 }); // params-only — keeps current path
584
+ Router.to("/list", { page: 2 }, true); // replace (no history entry)
585
+ Router.to("/list", { page: 2 }, false, true); // silent (no events)
586
+ ```
551
587
 
552
- ### Router Two-Phase Change
588
+ ### Parsing
553
589
 
554
- Route changes go through two phases:
590
+ ```ts
591
+ const loc = Router.parse(); // current location
592
+ const loc = Router.parse("https://x.com/?a=1#!/path?p=v");
593
+ // loc.path, loc.params, loc.hash, loc.query, loc.view, loc.get("key", "default")
594
+ const diff = Router.diff(); // last LocationDiff or undefined
595
+ ```
555
596
 
556
- 1. **`change` event** -- listeners can call `prevent()` or `reject()` to cancel navigation
557
- 2. **`changed` event** -- URL has been updated, views are re-rendered
597
+ ### Two-phase change events (existing API)
558
598
 
559
- ```typescript
599
+ ```ts
560
600
  Router.on("change", (e) => {
561
- if (hasUnsavedChanges) {
562
- e.prevent(); // Cancel navigation
563
- }
601
+ if (hasUnsavedChanges)
602
+ e.prevent(); // pause subsequent processing
603
+ else if (mustReject)
604
+ e.reject(); // revert URL to lastHash
605
+ else e.resolve(); // commit (auto if neither called)
606
+ });
607
+ Router.on("changed", (diff) => {
608
+ // diff is a LocationDiff: { params, path?, view?, force, changed }
564
609
  });
565
610
  ```
566
611
 
567
- ### Resource Management
612
+ ### Async route guards (`Router.beforeEach`)
568
613
 
569
- Use `capture/release` for automatic cleanup:
614
+ ```ts
615
+ const off = Router.beforeEach(async (to, from) => {
616
+ if (to.path === "/admin") {
617
+ const ok = await checkPermission();
618
+ return ok; // false → revert URL, throw → also reverts
619
+ }
620
+ return true; // or undefined — permits navigation
621
+ });
622
+ // later, off() to unsubscribe
623
+ ```
570
624
 
571
- ```typescript
572
- const timer = setInterval(() => {
573
- /* ... */
574
- }, 1000);
575
- this.capture(
576
- "myTimer",
625
+ Guards run in registration order. Any guard that returns/resolves to `false`, throws, or rejects aborts the navigation and reverts the URL. Returning `true`, `undefined`, or any non-`false` value permits it.
626
+
627
+ ## Service (API requests)
628
+
629
+ `Service` is an opinionated layer around `fetch` (or any sync function) with LFU caching, in-flight deduplication, serial task queueing, and lifecycle events.
630
+
631
+ ```ts
632
+ import { Service, type Payload } from "@lark.js/mvc";
633
+
634
+ const AppService = Service.extend(
635
+ (payload, callback) => {
636
+ fetch(payload.get<string>("url"), {
637
+ method: payload.get<string>("method") || "GET",
638
+ headers: { "Content-Type": "application/json" },
639
+ body: payload.get("data")
640
+ ? JSON.stringify(payload.get("data"))
641
+ : undefined,
642
+ })
643
+ .then((r) => r.json())
644
+ .then((data) => {
645
+ payload.set(data);
646
+ callback();
647
+ })
648
+ .catch(() => callback());
649
+ },
650
+ 20, // cacheMax
651
+ 5, // cacheBuffer
652
+ );
653
+
654
+ AppService.add([
655
+ { name: "userList", url: "/api/users", cache: 60_000 },
577
656
  {
578
- destroy() {
579
- clearInterval(timer);
657
+ name: "userDetail",
658
+ url: "/api/users/:id",
659
+ cache: 30_000,
660
+ before(payload) {
661
+ payload.set(
662
+ "url",
663
+ payload.get<string>("url").replace(":id", payload.get<string>("id")),
664
+ );
665
+ },
666
+ after(payload) {
667
+ const data = payload.get("data");
668
+ payload.set({ formatted: formatUser(data) });
580
669
  },
670
+ cleanKeys: "userList", // invalidate userList cache when this completes
581
671
  },
582
- true,
583
- );
584
- // destroyOnRender=true: destroyed on next render call
585
- // destroyOnRender=false: destroyed only on view destroy
672
+ ]);
586
673
  ```
587
674
 
588
- ### Async Safety with wrapAsync
675
+ ### Per-subclass isolation
589
676
 
590
- ```typescript
591
- async loadData() {
592
- const safeCallback = this.wrapAsync((data) => {
593
- this.updater.set({ items: data }).digest();
594
- });
595
- const data = await fetch("/api/items").then(r => r.json());
596
- safeCallback(data); // Only executes if view has not been re-rendered/destroyed
597
- }
677
+ `Service.extend()` produces a subclass with its own `_metaList`, `_payloadCache`, `_pendingCacheKeys`, `_syncFn`, `_staticEmitter`, `_cacheMax`, and `_cacheBuffer` via `static override`. This isolation is intentional — endpoints registered on one subclass never leak into another. Refactoring these `static override` declarations away would break the isolation.
678
+
679
+ ### Using a service in a view
680
+
681
+ ```ts
682
+ export default View.extend({
683
+ template,
684
+ init() {
685
+ const service = new AppService();
686
+ this.capture("userService", service, true); // auto-destroy on render
687
+ this.service = service;
688
+ this.loadData();
689
+ },
690
+ loadData() {
691
+ this.service.all("userList", (errors, userListPayload) => {
692
+ if (!errors[0]) {
693
+ this.updater.set({ users: userListPayload.get("data") }).digest();
694
+ }
695
+ });
696
+ },
697
+ refreshDetail(id: string) {
698
+ // save() bypasses cache, always makes a real request.
699
+ this.service.save({ name: "userDetail", id }, (errors, payload) => {
700
+ if (!errors[0]) {
701
+ this.updater.set({ detail: payload.get("data") }).digest();
702
+ }
703
+ });
704
+ },
705
+ });
598
706
  ```
599
707
 
600
- ### Sub-View Embedding
708
+ ### Service method summary
709
+
710
+ | Method | Behavior |
711
+ | --------------------------- | ----------------------------------------------------------------------------- |
712
+ | `service.all(attrs, done)` | Fetch all endpoints; callback with `(errors, p1, p2, ...)` when ALL complete |
713
+ | `service.one(attrs, done)` | Fetch all endpoints; callback PER endpoint: `(error, payload, isLast, index)` |
714
+ | `service.save(attrs, done)` | Like `all` but always skips cache (force refresh) |
715
+ | `service.enqueue(task)` | Queue a task for serial execution |
716
+ | `service.dequeue(...args)` | Pop and run the next queued task |
717
+ | `service.destroy()` | Destroy instance; cancel further callbacks |
601
718
 
602
- Use the `v-lark` attribute to embed child views:
719
+ ### Caching and dedup
720
+
721
+ The `Cache` class is an LFU cache with single-pass partial-selection eviction (O(n·k)) — see `cache.ts`. The pending-key map (`pendingCacheKeys`) deduplicates concurrent requests for the same `(endpoint, params)`. All pending callbacks are queued and called when the single in-flight request completes.
722
+
723
+ `defaultCacheKey` memoizes `JSON.stringify(meta)` per `ServiceMetaEntry` via `WeakMap` — meta entries are immutable after `Service.add()`.
724
+
725
+ ## Templates
726
+
727
+ ### Operators
728
+
729
+ | Operator | Syntax | Meaning |
730
+ | -------- | --------------- | ------------------------------------------------------------------ |
731
+ | `=` | `{{=variable}}` | HTML-escaped output (`&`, `<`, `>`, `"`, `'`, backtick) |
732
+ | `!` | `{{!variable}}` | Raw output (no escaping). Use with care for user-generated content |
733
+ | `@` | `{{@variable}}` | Reference lookup — stores a JS value in refData, emits a token |
734
+ | `:` | `{{:variable}}` | Two-way binding marker (renders identically to `=`) |
735
+
736
+ ### Control flow
603
737
 
604
738
  ```html
605
- <div v-lark="components/child-view"></div>
739
+ {{if condition}}...{{else if other}}...{{else}}...{{/if}} {{forOf list as item}}
740
+ ... {{/forOf}} {{forOf list as item idx}} {{=idx}}: {{=item.name}} {{/forOf}}
741
+ {{forOf list as {name, age} idx last first}} ... {{/forOf}} {{forIn object as
742
+ value key}} ... {{/forIn}} {{for (let i = 0; i < n; i++)}} ... {{/for}} {{set
743
+ localVar = expr}}
606
744
  ```
607
745
 
608
- The framework automatically creates a child Frame, mounts the registered View class, and manages its lifecycle. The `v-lark` attribute value must match a path registered via `registerViewClass`.
746
+ `forOf` REQUIRES the `as` keyword: `{{forOf list as item}}` is correct; `{{forOf list item}}` is a compile-time error.
609
747
 
610
- ### Updater vs State vs Store Data Flow
748
+ ### Event binding
611
749
 
612
- Three patterns for managing view data. When no store is specified, State is the default cross-view state management:
750
+ ```html
751
+ <button @click="handlerName({key: 'value', other: 123})">Go</button>
752
+ <input @input="onInput()" />
753
+ <form @submit.prevent="onSubmit()">...</form>
754
+ ```
613
755
 
614
- **Updater pattern** (view-local, manual):
756
+ The compiler converts JS object literal params (`{a: 1}`) into URL query format (`a=1`) so they can survive transport through DOM attributes. It also injects the current view's `$viewId` and a SPLITTER separator so the EventDelegator can route correctly across nested frames.
615
757
 
616
- ```typescript
617
- this.updater.set({ count: newCount }).digest();
758
+ ### Sub-view embedding
759
+
760
+ ```html
761
+ <div v-lark="components/child"></div>
762
+ <div v-lark="components/child?title=hello&id=42"></div>
763
+ <div v-lark="remote-app/views/home"></div>
764
+ <!-- Module Federation -->
618
765
  ```
619
766
 
620
- **State pattern** (default cross-view, key-ref counting):
767
+ When `v-lark` carries a query string, the params are translated into the child view's `init` arguments. If the value contains a SPLITTER reference, `translateData` resolves it via the parent view's refData before the child mounts.
621
768
 
622
- ```typescript
623
- // Write: set data and digest to notify
624
- State.set({ count: newCount }).digest();
769
+ ### VDOM optimization hints
625
770
 
626
- // Read: in view's assign(), pull from State
627
- assign() {
628
- this.updater.snapshot();
629
- this.updater.set({ count: State.get("count") });
630
- return this.updater.altered();
631
- }
771
+ | Attribute | Effect |
772
+ | --------- | --------------------------------------------------------------------- |
773
+ | `ldk` | "Diff key" — if old and new have the same `ldk`, skip the entire diff |
774
+ | `lak` | "Attribute key" — skip attribute diff but still diff children |
775
+ | `lvk` | "View key" — assign-optimization marker |
632
776
 
633
- // Observe: listen to State "changed" event to trigger re-render
634
- State.on("changed", (e) => {
635
- if (e.keys.count) this.assign(); // Re-assign if count changed
777
+ Mark large static subtrees with `ldk` to skip rendering work entirely.
778
+
779
+ ## Module Federation (micro-frontend)
780
+
781
+ ### Pattern 1 — Direct async loading
782
+
783
+ Configure `FrameworkConfig.require` to resolve unknown view paths through MF:
784
+
785
+ ```ts
786
+ declare const __webpack_init_sharing__: (name: string) => Promise<void>;
787
+ declare const __webpack_share_scopes__: Record<string, Record<string, unknown>>;
788
+
789
+ Framework.boot({
790
+ rootId: "app",
791
+ projectName: "host-app",
792
+ crossConfigs: [
793
+ {
794
+ projectName: "remote-app",
795
+ source: "remote_app@//cdn.example.com/remote-app/remoteEntry.js",
796
+ },
797
+ ],
798
+ require: async (names: string[]) => {
799
+ await __webpack_init_sharing__("default");
800
+ const container = __webpack_share_scopes__["default"];
801
+ return Promise.all(
802
+ names.map(async (name) => {
803
+ const slash = name.indexOf("/");
804
+ const remote = slash > -1 ? name.substring(0, slash) : name;
805
+ const mod = slash > -1 ? name.substring(slash + 1) : "./index";
806
+ const rc = (window as Record<string, unknown>)[remote] as
807
+ | {
808
+ init: (s: Record<string, unknown>) => Promise<void>;
809
+ get: (m: string) => Promise<() => unknown>;
810
+ }
811
+ | undefined;
812
+ if (!rc) return undefined;
813
+ await rc.init(container);
814
+ const factory = await rc.get(`./${mod}`);
815
+ const raw = factory();
816
+ return raw && (raw as Record<string, unknown>).__esModule
817
+ ? (raw as Record<string, unknown>).default
818
+ : raw;
819
+ }),
820
+ );
821
+ },
636
822
  });
823
+ ```
637
824
 
638
- // Cleanup: use State.clean() in mixins to auto-delete keys on view destroy
639
- export default View.extend({
640
- mixins: [State.clean("count")],
825
+ Then `v-lark="remote-app/views/home"` just works.
826
+
827
+ ### Pattern 2 — CrossSite bridge view (with skeleton + prepare)
828
+
829
+ For richer scenarios that want a loading skeleton and a `prepare` hook on the remote side:
830
+
831
+ ```ts
832
+ import { CrossSite, registerViewClass } from "@lark.js/mvc";
833
+ registerViewClass("cross-site", CrossSite);
834
+ ```
835
+
836
+ ```html
837
+ <div v-lark="cross-site?view=remote-app/views/home&bizCode=mybiz"></div>
838
+ ```
839
+
840
+ CrossSite renders a skeleton container `mf_${viewId}` first, then loads the remote project's `prepare` module via `loadRemoteView()`, then mounts the actual view. Race condition is guarded by `$sign`: if the user navigates away during the async load, the stale mount is aborted. When the remote view path matches the previous one and the existing view supports `assign()`, CrossSite updates in place instead of re-mounting.
841
+
842
+ `CrossSite.callView(name, ...args)` invokes a method on the embedded remote view via `Frame.invoke()`.
843
+
844
+ ### Module Federation Webpack config
845
+
846
+ Host:
847
+
848
+ ```js
849
+ import { ModuleFederationPlugin } from "webpack/container";
850
+
851
+ export default {
852
+ plugins: [
853
+ new ModuleFederationPlugin({
854
+ name: "host_app",
855
+ remotes: {
856
+ "remote-app": "remote_app@//cdn.example.com/remote-app/remoteEntry.js",
857
+ },
858
+ shared: {
859
+ "@lark.js/mvc": { singleton: true, requiredVersion: "^1.0.0" },
860
+ },
861
+ }),
862
+ ],
863
+ };
864
+ ```
865
+
866
+ Remote:
867
+
868
+ ```js
869
+ new ModuleFederationPlugin({
870
+ name: "remote_app",
871
+ filename: "remoteEntry.js",
872
+ exposes: {
873
+ "./views/home": "./src/views/home",
874
+ "./prepare": "./src/prepare",
875
+ },
876
+ shared: {
877
+ "@lark.js/mvc": { singleton: true, requiredVersion: "^1.0.0" },
878
+ },
641
879
  });
642
880
  ```
643
881
 
644
- Key differences from Store:
882
+ `@lark.js/mvc` MUST be shared as `singleton: true` so host and remote use the same View/Frame class instances — `instanceof` checks fail across boundaries otherwise.
645
883
 
646
- - State uses `State.set()` + `State.digest()` (manual notification), Store uses direct property assignment (automatic reactivity via Proxy)
647
- - State keys are auto-deleted when reference count reaches 0 (via `State.clean()` mixin), Store persists until `store.$destroyFn()` is called
648
- - State requires manual event listening (`State.on("changed", ...)`), Store provides `store.observe()` with auto-cleanup
884
+ ### `splitChunks.chunks` must be `"async"` in MF projects
649
885
 
650
- **Store pattern** (cross-view, reactive, recommended):
886
+ | Value | Splits initial chunks | Splits async chunks |
887
+ | ----------- | --------------------- | ------------------- |
888
+ | `"initial"` | yes | no |
889
+ | `"async"` | no | yes |
890
+ | `"all"` | yes | yes |
651
891
 
652
- ```typescript
653
- const store = useCountStore(this);
654
- store.observe(this, ["count"]);
655
- // Later, from any view:
656
- useCountStore().increment(); // Automatically triggers re-render in all observing views
892
+ If `chunks: "all"` extracts `@lark.js/mvc` into a separate vendor chunk, the MF shared scope initialization fails — `remoteEntry.js` needs `@lark.js/mvc` synchronously available in the initial entry chunk. With `chunks: "async"`, shared singletons stay in the entry chunk and `window.<remote_name>` is set correctly.
893
+
894
+ ### `Frame.root()` vs `new Frame()` for MF
895
+
896
+ `Frame.root()` (and the newer `Frame.createRoot()`) is a singleton always returns the first root, ignoring later `id` arguments. For MF containers that need independent rendering contexts, use `new Frame(containerId)` directly so each mount owns its frame tree.
897
+
898
+ Use the new APIs (preferred):
899
+
900
+ - `Frame.getRoot()` — pure getter, returns `undefined` if not yet created.
901
+ - `Frame.createRoot(id)` — idempotent create (Framework.boot calls this).
902
+ - `new Frame(containerId)` — independent root for MF or for an embeddable widget.
903
+ - `Frame.root(id)` — `@deprecated` alias that delegates to `createRoot`.
904
+
905
+ ### Exposed mount function pattern
906
+
907
+ For React/other-host integrations, expose a mount function rather than raw View classes:
908
+
909
+ ```ts
910
+ // src/exposed/counter-view.ts
911
+ import {
912
+ Framework,
913
+ Frame,
914
+ registerViewClass,
915
+ EventDelegator,
916
+ Router,
917
+ State,
918
+ } from "@lark.js/mvc";
919
+ import CounterView from "../views/counter";
920
+ import "../index.css"; // MF remote must explicitly import CSS
921
+
922
+ const MF_COUNTER = "mf/counter";
923
+ registerViewClass(MF_COUNTER, CounterView);
924
+
925
+ export function mountCounter(container: HTMLElement): () => void {
926
+ const containerId = container.id || "mf-counter-root";
927
+ container.id = containerId;
928
+
929
+ Framework.setConfig({ rootId: containerId, error: console.error });
930
+ EventDelegator.setFrameGetter((id: string) => Frame.get(id));
931
+ Reflect.set(Router, "_booted", true);
932
+ Reflect.set(State, "_booted", true);
933
+
934
+ const frame = new Frame(containerId); // NOT Frame.createRoot
935
+ frame.mountView(MF_COUNTER);
936
+
937
+ return () => {
938
+ frame.unmountView();
939
+ Frame.getAll().delete(containerId);
940
+ const el = document.getElementById(containerId);
941
+ if (el) Reflect.set(el, "frameBound", 0);
942
+ };
943
+ }
657
944
  ```
658
945
 
659
- ### Frame Tree Events
946
+ The MF remote MUST explicitly import its CSS (`import "../index.css"`) — Webpack only bundles CSS reachable from the exposed module's import graph.
660
947
 
661
- Frame fires lifecycle events:
948
+ ## Three pipelines side-by-side
662
949
 
663
- - `add` -- new Frame created
664
- - `remove` -- Frame removed
665
- - `created` -- all child Frames rendered
666
- - `alter` -- child Frame content changed
950
+ ```ts
951
+ // Updater (view-local, manual)
952
+ this.updater.set({ count: newCount }).digest();
667
953
 
668
- ```typescript
669
- Frame.on("add", ({ frame }) => {
670
- /* ... */
954
+ // State (simple cross-view)
955
+ State.set({ count: newCount }).digest();
956
+ // to react:
957
+ State.on("changed", (e) => {
958
+ if (e.keys?.has("count")) this.assign();
671
959
  });
960
+ // to clean up on view destroy:
961
+ export default View.extend({ mixins: [State.clean("count")] });
962
+
963
+ // Store (complex cross-view, recommended for non-trivial state)
964
+ const store = useCountStore(this);
965
+ store.observe(this); // default: all keys (D5)
966
+ useCountStore().increment(); // mutate from anywhere → all observers re-render
672
967
  ```
673
968
 
674
- ### Vite vs Webpack Comparison
675
-
676
- | Feature | Vite (larkMvcPlugin) | Webpack (larkMvcLoader) |
677
- | -------------------- | -------------------------------------------------- | -------------------------------------------------- |
678
- | Import path | `@lark.js/mvc/vite` | `@lark.js/mvc/webpack` |
679
- | Type | Vite Plugin (resolveId + load hooks) | Webpack Loader (standard loader interface) |
680
- | Configuration | `plugins: [larkMvcPlugin()]` | `module.rules` with loader rule |
681
- | Debug mode | `larkMvcPlugin({ debug: true })` | `options: { debug: true }` |
682
- | HTML entry exclusion | Not needed (Vite handles index.html separately) | `exclude: /index\.html$/` |
683
- | Dev server | Vite dev server (fast HMR) | webpack-dev-server (standard HMR) |
684
- | Template compilation | Same pipeline: extractGlobalVars + compileTemplate | Same pipeline: extractGlobalVars + compileTemplate |
685
-
686
- ## Common Pitfalls
687
-
688
- 1. **boot.ts must be in `src/`** -- The index.html entry point references `/src/boot.ts`, not `/boot.ts`
689
- 2. **registerViewClass before boot** -- All view classes must be registered before calling `Framework.boot()`
690
- 3. **Template imports require larkMvcPlugin or larkMvcLoader** -- `.html` imports only work because the Vite plugin or Webpack loader compiles them at build time
691
- 4. **Use State.set/digest, not direct mutation** -- Directly modifying State data bypasses change detection. In debug mode (`window.__lark_Debug = true`), safeguard Proxy warns about this with a 500ms delay
692
- 5. **observe requires view binding for auto-cleanup** -- Always pass `this` to `store.observe(this, ...)` so the observation is cleaned up when the view is destroyed. Without view binding, you must manually call the returned unobserve function
693
- 6. **Event method names use `<>` not `()`** -- The pattern is `name<click>`, not `name(click)`
694
- 7. **assign must return altered()** -- The `assign` method should call `this.updater.snapshot()` at the start and return `this.updater.altered()` at the end to enable incremental updates
695
- 8. **Do not modify view.signature** -- It is managed internally; setting it to 0 destroys the view. The wrapped render() increments signature automatically
696
- 9. **v-lark containers are replaced** -- The content inside a `v-lark` element is replaced by the child view's rendered output
697
- 10. **Webpack must exclude index.html** -- The larkMvcLoader rule must exclude `index.html` so HtmlWebpackPlugin can process it instead
698
- 11. **Webpack uses loader object, not string name** -- Import `larkMvcLoader` from `@lark.js/mvc/webpack` and use it directly as `loader: larkMvcLoader`, not as a string like `"larkMvcLoader"`
699
- 12. **Store reads return cloned data** — `useStore(view).count` returns a deep-cloned copy, not the reactive Proxy. Mutating the returned value does NOT trigger reactivity. Only writes through `store.key = value` are reactive
700
- 13. **forOf requires "as" keyword** -- `{{forOf list item}}` is invalid. Must use `{{forOf list as item}}`
701
- 14. **Inner observe deduplication** -- `store.observe(undefined, keys, callback)` uses a deduplication flag based on `key + observeKeys.join("-") + cb.toString()`. The same inner observe with identical key/callback won't register twice
702
- 15. **View.wrapAsync is signature-based** -- The callback only executes if `view.signature` hasn't changed since `wrapAsync` was called. Re-rendering or destroying the view increments signature, invalidating the callback
703
- 16. **Frame object pooling** -- Destroyed Frame objects are cached and reused. Don't hold references to Frame instances after unmountView() as they may be reinitialized for a different view
704
- 17. **Updater re-digest support** -- Calling `updater.digest()` during an ongoing digest is supported via an internal queue. The sentinel pattern (`null` entry) marks the digest boundary, and callbacks are executed after all digest cycles complete
705
- 18. **Store creator runs once** -- The creator function passed to `defineStore` runs once at definition time. Store state persists across view mounts/unmounts unless `store.$destroyFn()` is explicitly called
706
- 19. **State is the default when no store is specified** -- Without `defineStore`, cross-view data flows through `State.set()` / `State.get()` / `State.digest()`. Always use `State.set()` + `State.digest()` to update data (never mutate `State.get()` directly). Use `State.clean()` in mixins to auto-delete keys on view destroy; otherwise, State data persists globally and is never garbage collected
969
+ Key distinctions:
970
+
971
+ | | State | Store |
972
+ | -------------- | ------------------------------------------- | ----------------------------------------------------------- |
973
+ | Write API | `State.set()` + `State.digest()` | direct: `store.key = value` |
974
+ | Reactivity | Manual `digest()` notification | Automatic via Proxy |
975
+ | Subscribe | `State.on("changed", ...)` + manual cleanup | `store.observe(view, ...)` with auto-cleanup |
976
+ | Memory | Auto-deleted via `State.clean()` mixin | Persists until `store.$destroyFn()` |
977
+ | Derived data | not supported | `computed(deps, fn)` |
978
+ | Multi-instance | not supported | `multi(useStore)` |
979
+ | Best for | Counters, toggles, page title, session info | Reactive handlers, derived state, multi-instance components |
980
+
981
+ ## Updater APIs worth knowing
982
+
983
+ - `updater.get(key?)` read data; without key returns the whole data object.
984
+ - `updater.set(data, excludes?)` merge `data` into the view's data, track changed keys.
985
+ - `updater.digest(data?, excludes?, callback?)` render; optional `data` is set first. Supports re-digest during an active digest via an internal queue (the `null` sentinel marks digest boundaries).
986
+ - `updater.snapshot()` record the current monotonic `version`; pair with `altered()` to detect changes cheaply (no JSON.stringify).
987
+ - `updater.altered()` returns `boolean | undefined`. `undefined` if `snapshot` was never called.
988
+ - `updater.translate(value)` resolve a `SPLITTER + digits` ref token to its original value. Non-ref strings are returned as-is. The protocol is strict: only `SPLITTER` followed by ASCII digits qualifies.
989
+ - `updater.parse(expr)` **safe** path resolver. Accepts a dotted property path (`a.b.c`) or a numeric literal (`42`, `-1.5`). Anything else returns `undefined`. Does NOT eval arbitrary JS CSP-safe.
990
+ - `updater.getChangedKeys()` `ReadonlySet<string>` of keys changed since the last digest.
991
+
992
+ ## Frame APIs worth knowing
993
+
994
+ - `Frame.get(id)` look up a Frame by DOM id.
995
+ - `Frame.getAll()` registry as `Map<string, Frame>`.
996
+ - `Frame.getRoot()` current root or `undefined`.
997
+ - `Frame.createRoot(id)` create root (idempotent; ignores `id` after first creation).
998
+ - `Frame.root(id)` `@deprecated` alias to `createRoot`.
999
+ - `frame.invoke(name, args?)` call a method on the frame's view. If the view isn't yet rendered, the call is deferred until render.
1000
+ - `frame.invokeTyped<V, K>(name, args)` type-safe variant; carries the view's method signature through TS.
1001
+ - `frame.children()` array of child Frame ids (order is not stable).
1002
+ - `frame.parent(level?)` — ancestor frame; defaults to parent (level=1).
1003
+ - `frame.mountFrame(id, viewPath, params?)` — explicit child Frame creation.
1004
+ - `frame.unmountFrame(id)` / `frame.mountZone(id?)` / `frame.unmountZone(id?)` — bulk operations.
1005
+ - `Frame.on("add" | "remove", handler)` — lifecycle events.
1006
+ - `frame.on("created" | "alter", handler)` — fires when all children have rendered / when child content changes.
1007
+
1008
+ ## Framework APIs worth knowing
1009
+
1010
+ - `Framework.boot(config)` — start the app.
1011
+ - `Framework.getConfig()` / `Framework.getConfig(key)` — read config.
1012
+ - `Framework.setConfig(patch)` — merge into config; returns the merged result.
1013
+ - `Framework.config(...)` — `@deprecated`; still works.
1014
+ - `Framework.isBooted()` — boolean.
1015
+ - `Framework.use(names, callback?)` — async View loader. Returns `Promise<unknown[]>` when no callback is passed.
1016
+ - `Framework.mark(host, key)` / `Framework.unmark(host)` — async callback validity tracking. Stored in a module-level `WeakMap`, does NOT pollute the host object with magic keys.
1017
+ - `Framework.dispatch(target, type, init?)` — fire a custom DOM event.
1018
+ - `Framework.task(fn, args?, ctx?)` — schedule a function for chunked execution (`scheduler.postTask` → `requestIdleCallback` → `setTimeout(0)`).
1019
+ - `Framework.delay(ms)` — Promise-based setTimeout.
1020
+ - `Framework.waitZoneViewsRendered(viewId, timeout?)` — Promise resolving to `Framework.WAIT_OK` (1) or `Framework.WAIT_TIMEOUT_OR_NOT_FOUND` (0).
1021
+ - `Framework.applyStyle(idOrPairs, css?)` — inject CSS dynamically; returns a cleanup function.
1022
+ - `Framework.guid(prefix?)` / `Framework.toMap(list, key?)` / `Framework.toUrl(...)` / `Framework.parseUrl(url)` / `Framework.mix(target, ...sources)` / `Framework.keys(obj)` / `Framework.inside(a, b)` / `Framework.node(idOrEl)` / `Framework.nodeId(el)` — utility helpers.
1023
+ - `Framework.guard(o)` — Safeguard Proxy wrap (no-op outside debug mode).
1024
+ - `Framework.Base` / `Framework.View` / `Framework.Frame` / `Framework.Cache` / `Framework.State` / `Framework.Router` — class re-exports.
1025
+
1026
+ ## Vite vs Webpack at a glance
1027
+
1028
+ | Feature | Vite (`larkMvcPlugin`) | Webpack (`larkMvcLoader`) |
1029
+ | ------------------- | -------------------------------------------------------- | ------------------------------------------------------------ |
1030
+ | Import path | `@lark.js/mvc/vite` | `@lark.js/mvc/webpack` |
1031
+ | Type | Vite plugin (`resolveId` + `load` hooks, `enforce: pre`) | Standard Webpack loader |
1032
+ | Configuration | `plugins: [larkMvcPlugin()]` | `module.rules` with the loader rule |
1033
+ | Debug mode | `larkMvcPlugin({ debug: true })` | `use: [{ loader: larkMvcLoader, options: { debug: true } }]` |
1034
+ | HTML entry handling | Vite handles `index.html` natively | MUST `exclude: /index\.html$/` so HtmlWebpackPlugin owns it |
1035
+ | Dev server | Vite dev server (fast HMR) | webpack-dev-server |
1036
+ | Template pipeline | Same: `extractGlobalVars` → `compileTemplate` | Same: `extractGlobalVars` → `compileTemplate` |
1037
+
1038
+ Both produce compiled `.html` modules that import their runtime helpers from `@lark.js/mvc/runtime` (a 948-byte module containing `encHtml`, `strSafe`, `encUri`, `encQuote`, `refFn`).
1039
+
1040
+ ## Common pitfalls
1041
+
1042
+ 1. **`boot.ts` must live inside `src/`** — the entry HTML references `/src/boot.ts`, not `/boot.ts`.
1043
+ 2. **`registerViewClass` before `Framework.boot()`** — all view classes (and their sub-components) must be registered before boot, OR you must provide a `FrameworkConfig.require` so unknown paths can be loaded on demand.
1044
+ 3. **`.html` imports require the bundler integration** — they only work because the Vite plugin or Webpack loader compiles them at build time.
1045
+ 4. **Use `State.set` + `State.digest`, not direct mutation** — direct mutation bypasses change detection. Debug mode (`window.__lark_Debug = true`) warns synchronously and dedupes the warning per key.
1046
+ 5. **`observe` requires view binding for auto-cleanup** — `store.observe(this, ...)` tears down when the view destroys. Inner observes (no view) require explicit `keys` and explicit unsubscribe.
1047
+ 6. **Event method names use `<>`, not `()`** — the pattern is `name<click>`, not `name(click)`.
1048
+ 7. **`assign()` must call `snapshot()` and return `altered()`** — otherwise the framework can't tell if data actually changed.
1049
+ 8. **Do not modify `view.signature`** — it's managed internally. Setting it to 0 destroys the view. The wrapped `render()` increments it.
1050
+ 9. **`v-lark` containers are replaced** — content inside a `v-lark` element gets replaced by the child view's rendered output. Don't put authoring text there.
1051
+ 10. **Webpack: exclude `index.html`** — `larkMvcLoader` must not process the entry HTML; HtmlWebpackPlugin owns it.
1052
+ 11. **Webpack: import the loader as a value** — `loader: larkMvcLoader`, not `loader: "larkMvcLoader"`.
1053
+ 12. **Store reads return cloned data** — `useStore(view).count` returns a deep clone (via `structuredClone`). Mutating it does NOT trigger reactivity. Only writes through `store.key = value` are reactive.
1054
+ 13. **`forOf` requires `as`** — `{{forOf list item}}` is invalid; use `{{forOf list as item}}`.
1055
+ 14. **Inner observe deduplication** — `store.observe(undefined, keys, callback)` is deduped on `key + observeKeys.join("-") + cb.toString()`. The same inner observe registers only once.
1056
+ 15. **`wrapAsync` is signature-based** — the callback runs only if `view.signature` hasn't changed since `wrapAsync` was called.
1057
+ 16. **Frame object pooling has a cap** — destroyed Frame objects are pooled up to `MAX_FRAME_POOL = 64`. Don't hold references to Frame instances after `unmountFrame()`.
1058
+ 17. **Updater supports re-entrant digest** — calling `updater.digest()` inside an active digest is supported through `digestingQueue`. The `null` sentinel marks digest boundaries.
1059
+ 18. **Store creator runs once** — at definition time. State persists across view mounts/unmounts. Call `useStore.$destroyFn()` (set via `Object.defineProperties` on the use-fn) to tear it down.
1060
+ 19. **State for simple, Store for complex** — use `State.set` + `State.digest` for lightweight shared values. Reach for `defineStore` when you need reactive handlers, derived data via `computed(deps, fn)`, multi-instance isolation via `multi()`, or store-internal reactions via inner `observe`. Always pair State writes with `State.clean(keys)` mixin on consumers so data doesn't leak globally.
1061
+ 20. **MF view paths use the remote project prefix** — `v-lark="remote-app/views/home"` triggers async loading through `FrameworkConfig.require` if the path isn't yet registered. Ensure `require` is configured AND `ModuleFederationPlugin` shares `@lark.js/mvc` as a singleton.
1062
+ 21. **`CrossSite` is the export name** — register it as `registerViewClass("cross-site", CrossSite)`.
1063
+ 22. **CrossSite uses `view=` not `xview=`** — `v-lark="cross-site?view=remote-app/views/home"`.
1064
+ 23. **`Framework.use()` returns a Promise** — without the optional callback, it resolves to `unknown[]`. Without a configured `require`, it falls back to dynamic `import()`.
1065
+ 24. **`Updater.parse` is path-only, no eval** — it accepts dotted paths and numeric literals. `updater.parse("1 + 2")` returns `undefined`. CSP-safe by design.
1066
+ 25. **`LarkInnerKeys` for VDOM short-circuits** — `ldk` skips the entire diff for static elements; `lak` skips attribute diff but still diffs children; `lvk` is an assign-optimization marker.
1067
+ 26. **MF: `splitChunks.chunks` MUST be `"async"`** — using `"all"` extracts `@lark.js/mvc` into a separate vendor chunk, breaking shared-scope initialization. The error surfaces as `ScriptExternalLoadError: Loading script failed (missing)`.
1068
+ 27. **MF: `new Frame(containerId)` for independent contexts** — `Frame.createRoot()` (and the deprecated `Frame.root()`) is a singleton that ignores later id arguments. Each MF mount needs its own `new Frame()`.
1069
+ 28. **MF: remote must explicitly import CSS** — Webpack bundles only CSS reachable from the exposed module's import graph. Without an `import "../index.css"` in the exposed entry, host pages won't receive utility classes used in the templates.
1070
+ 29. **Sub-component `v-lark` paths must match exactly** — template strings embed the paths at build time; renaming a `registerViewClass` path without updating the template breaks the load.
1071
+ 30. **Dynamic `import()` shape is unknown** — for chunk splitting, use a small `extractDefault()` helper to unwrap the ESM default, then cast with `as typeof View` (NOT `as any`).
1072
+
1073
+ ## Migration notes (recent API changes)
1074
+
1075
+ - `ChangeEvent.keys` is now `ReadonlySet<string>` (was `Record<string, 1>`). Use `keys.has("foo")` instead of `keys.foo`. Affects `State.on("changed")` handlers and `view.observeState` callbacks.
1076
+ - `StateInterface.diff()` returns `ReadonlySet<string>`.
1077
+ - `Framework.toUrl(path, params, keepEmpty?)` — `keepEmpty` is now `Set<string>` (was `Record<string, number>`).
1078
+ - `Updater.set/digest`, `State.set/digest`, and `setData` take `excludes?: ReadonlySet<string>` (was `Set<string>`).
1079
+ - `Frame.root(id)` is `@deprecated`. Use `Frame.getRoot()` for reads, `Frame.createRoot(id)` for the explicit singleton creation, or `new Frame(id)` for independent mounts.
1080
+ - `Framework.config(...)` is `@deprecated`. Use `Framework.getConfig(key?)` and `Framework.setConfig(patch)`.
1081
+ - `Updater.parse` no longer evals — only safe path/literal resolution. Migrate to a small helper function if you needed expression eval.
1082
+ - `mark.ts` no longer writes magic keys onto host objects — it uses a module-level `WeakMap`. Works on frozen objects.
1083
+ - `Cache.del` now splices immediately (was leaving tombstones until the next eviction).
707
1084
 
708
1085
  ## References
709
1086
 
710
- For detailed API signatures and template syntax, consult the reference files:
1087
+ For deeper detail than this guide:
711
1088
 
712
- - `references/api-reference.md` -- Complete API reference for all Lark modules
713
- - `references/template-syntax.md` -- Template syntax, operators, control flow, and event binding
1089
+ - `references/api-reference.md` Complete API signatures for every Lark module.
1090
+ - `references/template-syntax.md` Full template language reference, including compilation pipeline, operators, control flow, encoders, and debug mode.