jops-core 1.0.14 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +346 -28
  2. package/bin/init.js +214 -21
  3. package/lib/jops-core.d.ts +251 -15
  4. package/lib/jops-core.min.js +1 -1
  5. package/lib/nextjs/JopsApp.d.ts +35 -0
  6. package/lib/nextjs/JopsApp.js +50 -0
  7. package/package.json +33 -5
  8. package/templates/nextjs-jops-spa/README.md +34 -0
  9. package/templates/nextjs-jops-spa/data/quotes.json +62 -0
  10. package/templates/nextjs-jops-spa/gitignore +21 -0
  11. package/templates/nextjs-jops-spa/next.config.ts +15 -0
  12. package/templates/nextjs-jops-spa/package.json +25 -0
  13. package/templates/nextjs-jops-spa/public/jops-spa/App.js +10 -0
  14. package/templates/nextjs-jops-spa/public/jops-spa/Login.js +26 -0
  15. package/templates/nextjs-jops-spa/public/jops-spa/Quotes.js +102 -0
  16. package/templates/nextjs-jops-spa/public/jops-spa/api.js +15 -0
  17. package/templates/nextjs-jops-spa/public/jops-spa/badge.js +8 -0
  18. package/templates/nextjs-jops-spa/public/jops-spa/format.js +5 -0
  19. package/templates/nextjs-jops-spa/public/jops-spa/layout/login.html +34 -0
  20. package/templates/nextjs-jops-spa/public/jops-spa/layout/main.html +17 -0
  21. package/templates/nextjs-jops-spa/public/jops-spa/layout/quotes.html +50 -0
  22. package/templates/nextjs-jops-spa/public/jops-spa/layout/quotes_row.html +8 -0
  23. package/templates/nextjs-jops-spa/public/jops-spa/skeleton.js +7 -0
  24. package/templates/nextjs-jops-spa/src/app/about/page.tsx +59 -0
  25. package/templates/nextjs-jops-spa/src/app/api/quotes/route.ts +11 -0
  26. package/templates/nextjs-jops-spa/src/app/globals.css +6 -0
  27. package/templates/nextjs-jops-spa/src/app/jops-spa/page.tsx +11 -0
  28. package/templates/nextjs-jops-spa/src/app/layout.tsx +27 -0
  29. package/templates/nextjs-jops-spa/src/app/page.tsx +5 -0
  30. package/templates/nextjs-jops-spa/tsconfig.json +21 -0
package/README.md CHANGED
@@ -21,6 +21,8 @@ No Webpack. No Babel. No compiler. Just `<script type="module">` and a browser.
21
21
 
22
22
  **Target audience:** Teams building internal tools, dashboards, embedded WebViews, or native mobile hybrid apps who want a structured, class-based frontend architecture without the npm/webpack/babel ecosystem overhead.
23
23
 
24
+ **Next.js teams:** a JOPS SPA can also run inside a Next.js page, next to React, with Next.js as its backend. See [Next.js Integration](#nextjs-integration).
25
+
24
26
  ---
25
27
 
26
28
  ## JOPS Demos
@@ -74,7 +76,14 @@ npm install jops-core
74
76
  npx jops-core init
75
77
  ```
76
78
 
77
- `npx jops-core init` vendors the library to `/lib/jops-core.min.js`, configures `index.html` with the import map, and injects build and postinstall scripts into `package.json`.
79
+ `npx jops-core init` vendors the library to `/lib/jops-core.min.js`, configures `index.html` with the import map, and injects build and postinstall scripts into `package.json`. It first asks which template to start from:
80
+
81
+ | Choice | Template |
82
+ | --- | --- |
83
+ | 1 | Empty JOPS project: the basic skeleton of a JOPS app |
84
+ | 2 | Hello World JOPS project: an example app using every feature |
85
+ | 3 | Next.js JOPS Embedded SPA: a Next.js project hosting a JOPS SPA (see [Next.js Integration](#nextjs-integration)) |
86
+ | 4 | None: only the library, `index.html` and scripts |
78
87
 
79
88
  ### Updating the library
80
89
 
@@ -277,6 +286,137 @@ A `cordova_plugins.js` "not found" error in the console is expected when the pro
277
286
 
278
287
  ---
279
288
 
289
+ ## Next.js Integration
290
+
291
+ A JOPS SPA can run inside a page of a Next.js (App Router) app. Next.js keeps what it does best (server rendering, data, Route Handlers, its own routing and React ecosystem) and the JOPS app adds class-based views with an Android-style lifecycle on the pages that need them. Both render on the same page:
292
+
293
+ - **Next.js owns the path** (`/quotes`), **JOPS owns the hash** (`/quotes#details?id=q2`).
294
+ - The JOPS app's files live in `public/` and are served as-is: they are never bundled by Turbopack or webpack.
295
+ - The backend is Next.js: the JOPS app calls Route Handlers with `fetch()`.
296
+
297
+ ### Quick start: the Next.js template
298
+
299
+ In an empty folder (or a fresh npm project):
300
+
301
+ ```bash
302
+ npx jops-core init
303
+ ```
304
+
305
+ and pick **3. Next.js JOPS Embedded SPA**. `init` writes the project into the current folder, merges its dependencies and scripts into an existing `package.json` (existing entries are kept), vendors the library and runs `npm install`. Then:
306
+
307
+ ```bash
308
+ npm run dev
309
+ ```
310
+
311
+ | Path | What it is |
312
+ | --- | --- |
313
+ | `src/app/about/page.tsx` | A React-only page (no JOPS) |
314
+ | `src/app/jops-spa/page.tsx` | Renders `<JopsApp app="/jops-spa/App.js" />`, which launches the JOPS SPA |
315
+ | `src/app/api/quotes/route.ts` | `GET /api/quotes[?status=…]`: the SPA's backend |
316
+ | `public/jops-spa/` | The JOPS SPA: a Router with a mock `#login` screen and a `#quotes` list with status filters |
317
+ | `public/lib/jops-core.min.js` | The JOPS library, vendored |
318
+
319
+ `init` refuses to run in a folder that already has a Next.js project (`next.config.*`, `tsconfig.json`, `src/app`) and changes nothing there; to add JOPS to an existing Next.js app, follow the manual setup below.
320
+
321
+ ### Manual setup in an existing Next.js app
322
+
323
+ **1. Install JOPS and vendor the library** into `public/lib/` (served as `/lib/jops-core.min.js`):
324
+
325
+ ```bash
326
+ npm i jops-core
327
+ npx jops-core update
328
+ ```
329
+
330
+ `update` detects a Next.js project (`next.config.*`) and copies the library to `public/lib/jops-core.min.js`. Run it again after every `npm i jops-core@latest`; adding `"postinstall": "jops-core update"` to `package.json` does it automatically.
331
+
332
+ **2. Put the JOPS app in `public/`**, e.g. `public/my-app/App.js`, `public/my-app/layout/main.html` and its views. `App.js` is the same entry module as in a standalone app:
333
+
334
+ ```js
335
+ import { View } from "jops-core";
336
+
337
+ export default class App {
338
+ constructor() {
339
+ const root = new View();
340
+ root.loadLayout("/my-app/layout/main.html").then(() => root.render());
341
+ }
342
+ }
343
+ ```
344
+
345
+ **3. Render `JopsApp` on the page** that hosts the app:
346
+
347
+ ```tsx
348
+ // src/app/my-app/page.tsx
349
+ import JopsApp from "jops-core/nextjs";
350
+
351
+ export default function MyAppPage() {
352
+ return <JopsApp app="/my-app/App.js" id="my-app-root" />;
353
+ }
354
+ ```
355
+
356
+ `JopsApp` is a Client Component (`"use client"` is built in), so it can be used from a Server Component page. It renders an empty `<div>` on the server; in the browser it loads `/lib/jops-core.min.js` once per page, adds the `"jops-core"` import map to `<head>` and mounts the app into its `<div>`. You don't add an import map or script tag to the root layout.
357
+
358
+ | Prop | Description |
359
+ | --- | --- |
360
+ | `app` | **Required.** URL of the app's entry module (`/my-app/App.js`); its default export is instantiated |
361
+ | `id` | `id` of the root `<div>` (CSS, tests) |
362
+ | `className` | `className` of the root `<div>` |
363
+ | `lib` | URL of the jops-core library, default `/lib/jops-core.min.js`. The import map uses the same URL, so the page always has a single jops-core instance |
364
+
365
+ The root `<div>` has `position: relative`, which the Router's slide and fade animations need.
366
+
367
+ ### App lifecycle inside Next.js
368
+
369
+ - **Leaving the page ends the app completely**, like closing a standalone app: a Next.js link to another page, or Back past the entry the app was launched from. The Router and every view are destroyed, the `<div>` is emptied, `Store` and `EventBus` are cleared, and the history methods JOPS wrapped are restored. Nothing of the app is left running.
370
+ - **Coming back launches it again** (a link, or Forward) and restores the screen from the hash.
371
+ - **Links to the app's own page don't relaunch it.** A Next.js `<Link href="/my-app">` while the app runs shows the default (first) route; `<Link href="/my-app#details?id=7">` shows that route. Stacked `navigate()` views are closed in both cases.
372
+ - Next.js history state (`history.state`) is kept in every entry JOPS writes, so Next.js Back/Forward keep working.
373
+
374
+ ### Conventions
375
+
376
+ - **Root-absolute paths** for everything JOPS loads: `src="/my-app/Quotes.js"`, `layout="/my-app/layout/quotes.html"`, `loadLayout("/my-app/…")`. ES module `import` statements inside your `.js` files resolve relative to the file as usual.
377
+ - **Vendored library** (`public/lib/`), not the CDN: with the CDN, JOPS turns `/…` paths into page-relative ones.
378
+ - **Next.js owns the CSS.** `setStyle()` has no effect inside Next.js (a console message says so once). Tailwind CSS v4 also scans the files in `public/`, so Tailwind classes in JOPS layouts and views are generated by the Next.js build. Write class names as complete literal strings (`"bg-sky-50"`, never `` `bg-${color}-50` ``). Put other CSS in `globals.css` or a CSS file imported by the page, scoped under the app's root class (`.my-app table td { … }`), since it applies to the whole site.
379
+ - **Backend calls:** `fetch("/api/…")` to Route Handlers on the same origin; the session cookie is sent automatically. A JOPS app can't call Server Actions.
380
+ - **One JOPS app per page.** `Router`, `Store` and `EventBus` are page-wide singletons.
381
+
382
+ ### Without React: hosted mode
383
+
384
+ `JopsApp` uses hosted mode, which any host page can use directly. Load the library with `data-host` and no `data-app`: nothing starts automatically, and `window.JOPS` exposes `mount()` and `unmount()` (a `jops:ready` event fires when it is available):
385
+
386
+ ```html
387
+ <script type="importmap">{ "imports": { "jops-core": "/lib/jops-core.min.js" } }</script>
388
+ <script type="module" src="/lib/jops-core.min.js" data-host></script>
389
+ <div id="app" style="position: relative"></div>
390
+ <script type="module">
391
+ const start = () => window.JOPS.mount({ app: "/my-app/App.js", root: document.getElementById("app") });
392
+ window.JOPS ? start() : window.addEventListener("jops:ready", start, { once: true });
393
+ </script>
394
+ ```
395
+
396
+ | Function | Description |
397
+ | --- | --- |
398
+ | `mount({ app, root })` | Imports `app` and creates its default export; the root view renders into `root` (an `HTMLElement`) instead of `<body>`. Returns a promise of the App instance |
399
+ | `unmount()` | Ends the app completely (see "App lifecycle" above). Safe to call more than once; `mount()` can start an app again afterwards |
400
+
401
+ Both are also exported by the module (`import { mount, unmount } from "jops-core"`) for modules running on the same page. In hosted mode the Android back button integration is off.
402
+
403
+ A **standalone** app (started by `data-app`) can render into an element instead of `<body>` with `data-root`:
404
+
405
+ ```html
406
+ <div id="app"></div>
407
+ <script type="module" src="/lib/jops-core.min.js" data-app="./src/App.js" data-root="app"></script>
408
+ ```
409
+
410
+ ### Deploying
411
+
412
+ The result is a standard Next.js app: `npm run build` produces `.next/`, and it runs on a Node.js server when it uses server features such as Route Handlers. Deploy it like any Next.js app (see the [Next.js deployment docs](https://nextjs.org/docs/app/getting-started/deploying)). Points specific to JOPS:
413
+
414
+ - **Deploy `public/` with the app**: the whole JOPS app and the library live there. With `output: "standalone"`, copy `public/` (and `.next/static/`) into `.next/standalone/` yourself.
415
+ - **Caching:** files in `public/` are not content-hashed, so a CDN or the browser may keep serving an old `jops-core.min.js` or view after a deploy. Use short cache headers for those paths, or versioned paths.
416
+ - **Library version:** run `npx jops-core update` after updating the package and before `npm run build`.
417
+
418
+ ---
419
+
280
420
  ## Class Reference
281
421
 
282
422
  ### `View` — Base Class
@@ -289,30 +429,106 @@ import { View } from "jops-core";
289
429
  export default class MyView extends View { ... }
290
430
  ```
291
431
 
432
+ #### Constructor
433
+
434
+ | Constructor | Description |
435
+ | --------------------------- | ----------- |
436
+ | `new View(layout?, props?)` | `layout` (optional) is passed to `loadLayout(layout)` when the view is created: a `.js` layout module path, an `.html` file path, or an inline HTML string. An inline string is assigned to `this.layout` immediately. `props` (optional) sets the view's [input props](#tag-props-input-properties) for a view created in code. |
437
+
438
+ Usually the layout comes from the view's `layout=` attribute or from an overridden `loadLayout()`, so subclasses call `super()` with no argument. Pass an inline layout when a view builds its markup from data, for example one row per record:
439
+
440
+ ```js
441
+ import { View, layout } from "jops-core";
442
+
443
+ export default class QuoteRow extends View {
444
+ constructor(quote) {
445
+ super(layout`<tr><td>${quote.number}</td><td>${quote.companyName}</td></tr>`);
446
+ this.quote = quote;
447
+ }
448
+ }
449
+
450
+ // in the parent view:
451
+ await this.addSubView("rows", new QuoteRow(quote));
452
+ ```
453
+
292
454
  #### Lifecycle (called by the framework in order)
293
455
 
294
456
  | Method | When called |
295
457
  | ------------------------- | ------------------------------------------------------------------------------ |
296
- | `async loadLayout(path?)` | Override to load a layout `.js` module or `.html` file |
458
+ | `async loadLayout(path?)` | Override to load a layout `.js` module or `.html` file. When you pass a path, `await` (or `return`) `super.loadLayout(…)` — see [Overriding `loadLayout()`](#overriding-loadlayout). Loads once per view |
297
459
  | `inflate()` | Converts `this.layout` string into `this.domNode` |
298
460
  | `async onLayout()` | DOM is live — add subviews, read initial data |
299
- | `async onResume()` | View becomes visible — called on first display and on every return navigation |
461
+ | `async onResume()` | View comes to the front — on first display, on every return navigation, and when the same route is shown again with new parameters. A Router route is resumed only when it is shown |
300
462
  | `bindEvents()` | Wires `on*` attributes on `[jops-event-bind]` elements to class methods |
301
- | `onBackPressed()` | Called by Router when the user navigates back from this view (browser back, `Router.back()`, Android back button in hybrid apps) — never on forward navigation |
463
+
464
+ Optional hooks, called by the Router:
465
+
466
+ | Method | When called |
467
+ | ----------------- | ----------- |
468
+ | `onPause()` | A different view comes to the front and this one stays alive: another route is shown, a stacked view is opened on top of it with `navigate()`, or `replace()` shows another view. Always runs before the incoming view's `onResume()`. Not called when the view is destroyed instead (Back from a stacked view, a stack closed by a route link, leaving the app) |
469
+ | `onBackPressed()` | The user navigates back from this view (browser back, `Router.back()`, Android back button in hybrid apps) — never on forward navigation. For a route it runs before `onPause()`; for a stacked view it is the last call before the view is destroyed, so put closing logic here |
470
+
471
+ `onPause()` and `onResume()` are not passed on to child views. The root view (the one whose `render()` is called first) gets the full lifecycle too, after its declared children.
302
472
 
303
473
  #### Instance Methods
304
474
 
305
475
  | Method | Description |
306
476
  | ------------------------------- | -------------------------------------------------------------------------------------------------------------- |
307
- | `get(id)` | Returns an `Element` wrapper for the child with the given DOM `id` |
308
- | `setStyle(style)` | Injects a `.css` file link or inline style tag into `<head>`, deduped |
477
+ | `get(id)` | Returns an `Element` wrapper for the first element with the given `id`, searching every top-level element of the layout |
478
+ | `find(id)` | Returns an array of `Element` wrappers for every element with the given `id` across the layout (sub-views' DOM included), in page order |
479
+ | `find(condition)` | Depth-first search across full subview subtree; returns flat array of all views matching the predicate |
480
+ | `props` | The view's read-only [input props](#tag-props-input-properties) |
481
+ | `setStyle(style)` | Injects a `.css` file link or inline style tag into `<head>`, deduped. No effect when the app runs inside Next.js (the host owns the CSS) |
309
482
  | `addSubView(containerId, view)` | Appends `view` into the child element with `id="containerId"`, runs its full lifecycle |
310
483
  | `insertSubView(view, index)` | Inserts `view` at position `index` within `this.domNode`, runs its full lifecycle |
311
484
  | `removeSubView(view)` | Removes `view` from DOM and `this.subViews`, calls `view.destroy()` |
312
- | `find(condition)` | Depth-first search across full subview subtree; returns flat array of all views matching the predicate |
313
- | `show()` | Restores `this.domNode` display (removes inline `display` override) |
314
- | `hide()` | Sets `this.domNode` to `display: none` |
315
- | `destroy()` | Recursively destroys subtree — calls `destroy()` on all subviews, removes `domNode` from DOM, nulls references |
485
+ | `show()` | Shows the view again (removes the inline `display` override) |
486
+ | `hide()` | Hides the view (`display: none`) |
487
+ | `destroy()` | Recursively destroys subtree — calls `destroy()` on all subviews, removes the view's elements from the DOM, removes its owned `EventBus` subscriptions, nulls references |
488
+
489
+ - **`show()` / `hide()` are visual only**: they don't call `onPause()` / `onResume()`. Refresh or pause a view toggled this way yourself.
490
+ - **A removed view is final.** `removeSubView()` destroys it; adding the same instance again logs a warning and does nothing. Keep the data outside the view (the `Store`, the parent's fields, the server) and create a new instance to show it again. `addSubView()` also warns and adds nothing when the container id doesn't exist or the layout failed to load.
491
+ - **The root view's container is borrowed.** The root view uses its container (`<body>`, the `data-root` element, or the element passed to `mount()`) as its `domNode`; destroying the root empties the container instead of removing it.
492
+
493
+ #### Tag props (input properties)
494
+
495
+ Attributes on a declared `jops` tag are passed to its view as **props**, like Android's `AttributeSet` or React's props:
496
+
497
+ ```html
498
+ <Card jops src="/src/Card.js" title="Monthly report" item-id="42" variant="compact"></Card>
499
+ ```
500
+
501
+ ```js
502
+ export default class Card extends View {
503
+ constructor() {
504
+ super();
505
+ // props are available right after super(), in loadLayout() and in onLayout()
506
+ this.layout = layout`<article class="card ${this.props.variant}"><h3>${this.props.title}</h3></article>`;
507
+ this.inflate();
508
+ }
509
+ onLayout() {
510
+ console.log(this.props.itemId); // "42"
511
+ }
512
+ }
513
+ ```
514
+
515
+ - Names are converted to camelCase (`item-id` → `itemId`); values are always strings.
516
+ - `this.props` is read-only (a frozen object), and `{}` when there are none.
517
+ - `jops`, `type="jops"`, `src`, `layout`, `path` and `jops-event-bind` belong to JOPS and are not props. `id`, `class` and `on*` attributes are props only: they are not copied to the view's element, and an `onclick` prop is not bound.
518
+ - The view's constructor needs no parameters for this: props reach declared views whatever their constructor looks like.
519
+ - A view created in code can get props through the constructor: `new View(layout, { title: "Hi" })`, or `super(undefined, props)` in a subclass.
520
+ - Props come from tags in static layouts processed by `render()`: the root layout, the Router's route declarations, and the layouts of declared views. Tags inside the layout of a view added with `addSubView()`, or inside a route's own layout, are not created as views; add those children in code.
521
+
522
+ #### Layouts with several top-level elements
523
+
524
+ A layout may have one or more top-level elements:
525
+
526
+ ```html
527
+ <h2 id="title">Quotes</h2>
528
+ <table><tbody id="rows"></tbody></table>
529
+ ```
530
+
531
+ `this.domNode` is the first one and `this.domNodes` holds all of them. `get()`, `find(id)`, `bindEvents()`, `show()`, `hide()`, `addSubView()`, `insertSubView()` and `destroy()` act on all of them, in order. Top-level text is ignored. The Router's `slide` and `fade` animations need single-root views: a stacked view with several top-level elements switches instantly.
316
532
 
317
533
  #### Declarative Event Binding
318
534
 
@@ -326,22 +542,50 @@ Mark any element with `jops-event-bind` and add `on*` attributes whose values ar
326
542
  onSave(event) { ... }
327
543
  ```
328
544
 
545
+ `bindEvents()` wires the markup of the view's layout when the view is shown. HTML you generate later (with `Element.set()` or `innerHTML`) is not bound — the browser would treat `onclick="onSave"` as an inline script. Wire generated markup yourself, ideally with one listener on its container (event delegation):
546
+
547
+ ```js
548
+ // layout: <tbody id="lines" jops-event-bind onclick="onLineAction"></tbody>
549
+ // generated rows: <button data-action="remove" data-index="3">✕</button>
550
+ onLineAction(event) {
551
+ const button = event.target.closest("[data-action]");
552
+ if (button?.dataset.action === "remove") this.removeLine(Number(button.dataset.index));
553
+ }
554
+ ```
555
+
329
556
  ---
330
557
 
331
558
  ### `Element` — DOM Accessor (returned by `View.get()`)
332
559
 
333
- Returned by `view.get(id)`. Wraps a single DOM element for reading and writing.
560
+ Returned by `view.get(id)` (and, as an array, by `view.find(id)`). Wraps a single DOM element for reading and writing.
334
561
 
335
- | Method | Description |
336
- | ------------ | -------------------------------------------------------------------- |
337
- | `set(value)` | Sets `innerHTML` of the element — accepts plain text or HTML strings |
338
- | `value()` | Returns the current `innerHTML` of the element |
562
+ | Method / property | Description |
563
+ | ------------------- | ----------- |
564
+ | `set(html)` | Sets the `innerHTML` of the element (plain text or HTML). Returns the wrapper, for chaining. **Not escaped**: pass values from users or the network through `layout` |
565
+ | `attr(name, value)` | Sets an attribute; `null` or `undefined` removes it, `""` sets a boolean attribute (`disabled`, `hidden`, `required`). Returns the wrapper, for chaining |
566
+ | `value()` | Returns the current `innerHTML` of the element |
567
+ | `element` | The underlying DOM element, for anything else (`classList`, `textContent`, properties). `null` when no element has that id |
568
+ | `Element.from(element, id)` | Static. Wraps an element you already have |
569
+
570
+ `set()`, `attr()` and `value()` never throw. When no element has the id, they do nothing (`value()` returns `""`) and log a warning with the id — usually a typo. On a destroyed view (for example after an async request finished when the user had already left), they do nothing silently. Code that uses `element` or `this.domNode` directly has no such guard.
571
+
572
+ > `set()` replaces plain markup. Don't use it to clear a container that holds sub-views: call `removeSubView()` for each of them first, otherwise they stay in the parent's `subViews`.
339
573
 
340
574
  ```js
341
575
  this.get("username").set("Alice");
342
576
  const current = this.get("username").value();
577
+
578
+ // chaining, with escaped data from the API
579
+ this.get("quote_id")
580
+ .set(layout`${quote.number}`)
581
+ .attr("href", `#quote?id=${quote.id}`);
582
+
583
+ this.get("save").attr("disabled", ""); // disable
584
+ this.get("save").attr("disabled", null); // enable again
343
585
  ```
344
586
 
587
+ > `attr()` can't inject HTML, but URL attributes (`href`, `src`) still accept `javascript:` URLs. Don't put untrusted values into them unchecked.
588
+
345
589
  ---
346
590
 
347
591
  ### `layout` — Tagged Template Function
@@ -372,6 +616,33 @@ await root.loadLayout("/src/layout/mainlayout.html");
372
616
  root.render();
373
617
  ```
374
618
 
619
+ #### Overriding `loadLayout()`
620
+
621
+ When a view class overrides `loadLayout()` and passes a **path**, it must `await` (or `return`) `super.loadLayout(…)`:
622
+
623
+ ```js
624
+ class QuoteRow extends View {
625
+ loadLayout() {
626
+ return super.loadLayout("/quotes/layout/quote_row.html"); // ✓ returns the promise
627
+ }
628
+ }
629
+
630
+ // Equivalent; handy when the override does more after loading:
631
+ class QuoteRow extends View {
632
+ async loadLayout() {
633
+ await super.loadLayout("/quotes/layout/quote_row.html"); // ✓ waits, then resolves
634
+ }
635
+ }
636
+ ```
637
+
638
+ The framework waits for the promise returned by `loadLayout()` before calling `inflate()`. Loading a path is asynchronous (`fetch()` for `.html`, a dynamic `import()` for `.js`), so without `await` the layout arrives too late and the view renders empty.
639
+
640
+ | Passed to `super.loadLayout()` | Loading | `await` needed |
641
+ | --- | --- | --- |
642
+ | An `.html` path | `fetch()` | **yes** |
643
+ | A `.js` path | dynamic `import()` | **yes** |
644
+ | A string (inline HTML, or a layout module imported at the top of the file) | synchronous | no |
645
+
375
646
  `mainlayout.html` is a plain HTML fragment:
376
647
 
377
648
  ```html
@@ -388,6 +659,8 @@ root.render();
388
659
 
389
660
  > **Note:** `.html` layouts are fetched at runtime and cached in memory — identical dedup behaviour to the browser's module cache for `.js` layouts. Each unique path is fetched once per page load.
390
661
 
662
+ `loadLayout()` loads **once per view**: calls made while a load is in progress (with or without a path) wait for it, and once a layout is loaded, later calls return at once. A failed load (for example a 404) leaves the view without a layout, so it can be retried. This also makes `new View("/path/to/layout.html")` followed by `addSubView()` work: `addSubView()` waits for the layout the constructor started loading.
663
+
391
664
  ---
392
665
 
393
666
  ### `raw(html)` — Function
@@ -427,9 +700,24 @@ Hash-based SPA router. Singleton. Declared in the layout as `<Router jops animat
427
700
  | `navigate(path, ClassDef?)` | Creates a new view instance for `path`, pushes onto the nav stack, animates forward. Updates address bar to `#path?params`. `ClassDef` required for dynamic routes not in the static route map |
428
701
  | `back()` | Navigates back — pops the nav stack with animation if non-empty, otherwise calls `history.back()` |
429
702
  | `canGoBack()` | Returns `true` if there is an earlier JOPS history entry to go back to (like checking the back stack in Android) |
430
- | `replace(path, ClassDef?)` | Clears the nav stack and navigates to `path`. Uses existing flat route instance if available, otherwise creates a new one from `ClassDef` |
703
+ | `replace(path, ClassDef?)` | Clears the nav stack and navigates to `path`. Uses existing flat route instance if available, otherwise creates a new one from `ClassDef`. Replaces the current history entry and keeps its existing `history.state` |
431
704
  | `getParams()` | Returns the current URL query params as a plain object. Works for both static (`href="#path?k=v"`) and dynamic (`navigate("/path?k=v")`) routes |
432
705
 
706
+ #### Navigation and the lifecycle
707
+
708
+ | Navigation | Outgoing view | Incoming view |
709
+ | --- | --- | --- |
710
+ | Route link (`href="#users"`), Forward to a route | `onPause()` | `onResume()` |
711
+ | Back to the previous route | `onBackPressed()`, `onPause()` | `onResume()` |
712
+ | Same route with new params (`#details?id=1` → `#details?id=2`) | — | `onResume()` |
713
+ | `navigate()` (stacked view) | `onPause()` | new instance: `onLayout()` → `onResume()` |
714
+ | Back from a stacked view | `onBackPressed()`, then destroyed | `onResume()` |
715
+ | Route link while stacked views are open | stacked views destroyed (no `onPause()`) | `onResume()` |
716
+
717
+ - The first route declared in `<Router>` is the default: it is shown on startup when the URL has no known route hash, and whenever the hash becomes empty (`href="#"`, a link to the page itself).
718
+ - Each route is resumed only when it is shown; at startup only the selected route gets `onResume()`. Every route gets `onLayout()` at startup.
719
+ - **Screen rule:** put screen views in `<Router>`. A route's own layout is not scanned for declared views, so a screen adds its child views in code with `addSubView()`.
720
+
433
721
  #### Animation
434
722
 
435
723
  Set via the `animation` attribute on the `<Router>` element:
@@ -440,7 +728,7 @@ Set via the `animation` attribute on the `<Router>` element:
440
728
  | `"slide"` | Forward: incoming slides in from right. Back: outgoing slides out to right |
441
729
  | `"fade"` | Crossfade between outgoing and incoming |
442
730
 
443
- Animation only occurs on `navigate()` (forward) and `back()` when the nav stack is non-empty. Flat tab switching is always instant.
731
+ Animation only occurs on `navigate()` (forward) and `back()` when the nav stack is non-empty. Flat tab switching is always instant, and so is a transition involving a view whose layout has several top-level elements.
444
732
 
445
733
  #### Layout Declaration
446
734
 
@@ -498,18 +786,46 @@ Store.singleton().clear();
498
786
 
499
787
  Stateless cross-view event broadcasting. All methods are static — no instance needed.
500
788
 
501
- | Method | Description |
502
- | --------------------------------------- | ---------------------------------------------------------------------- |
503
- | `EventBus.subscribe(event, callback)` | Registers a listener for the named event |
504
- | `EventBus.send(event, data?)` | Fires the named event to all subscribers with an optional data payload |
505
- | `EventBus.unsubscribe(event, callback)` | Removes a specific listener by reference |
789
+ | Method | Description |
790
+ | ---------------------------------------------- | ---------------------------------------------------------------------- |
791
+ | `EventBus.subscribe(owner, event, callback)` | Registers a listener owned by `owner` (usually a view, `this`). It is removed automatically when the owner is destroyed |
792
+ | `EventBus.subscribe(event, callback)` | Registers a global listener. It stays until you unsubscribe it |
793
+ | `EventBus.send(event, data?)` | Fires the named event to all subscribers with an optional data payload |
794
+ | `EventBus.unsubscribe(event, callback)` | Removes a specific listener (owned or global) by reference |
795
+ | `EventBus.unsubscribeAll(owner)` | Removes every subscription of `owner` (called by `View.destroy()`) |
796
+ | `EventBus.clear()` | Removes every listener of every event |
506
797
 
507
798
  ```js
508
- import { EventBus } from "jops-core";
799
+ import { View, EventBus } from "jops-core";
800
+
801
+ class Header extends View {
802
+ onLayout() {
803
+ // owned: removed when this view is destroyed (removeSubView, Router, unmount)
804
+ EventBus.subscribe(this, "user:login", (user) => this.showUser(user));
805
+ }
806
+ }
509
807
 
510
- EventBus.subscribe("user:login", (data) => console.log(data.name));
511
808
  EventBus.send("user:login", { name: "Alice" });
512
- EventBus.unsubscribe("user:login", handler);
809
+
810
+ // global: keep the function reference to be able to unsubscribe it
811
+ const onLogin = (user) => console.log(user.name);
812
+ EventBus.subscribe("user:login", onLogin);
813
+ EventBus.unsubscribe("user:login", onLogin);
814
+ ```
815
+
816
+ Prefer the owned form inside views. A global subscription made by a view keeps that view in memory and keeps calling it after it is destroyed, until you unsubscribe it (in an overridden `destroy()` that calls `super.destroy()`, or in `onPause()` with a new subscription in `onResume()`).
817
+
818
+ ---
819
+
820
+ ### `mount()` / `unmount()` — Functions
821
+
822
+ Start and end an app in a host page (hosted mode). Used by `JopsApp` in Next.js; see [Without React: hosted mode](#without-react-hosted-mode). The page must load jops-core with `data-host` (otherwise it starts `data-app` / `./src/App.js` by itself); `window.JOPS.mount` / `window.JOPS.unmount` are the same functions.
823
+
824
+ ```js
825
+ import { mount, unmount } from "jops-core";
826
+
827
+ await mount({ app: "/my-app/App.js", root: document.getElementById("app") });
828
+ unmount(); // destroys the Router and all views, empties the root, clears Store and EventBus
513
829
  ```
514
830
 
515
831
  ---
@@ -554,17 +870,19 @@ t.terminate();
554
870
 
555
871
  | Command | Description |
556
872
  | ---------------------- | -------------------------------------------------------------------------- |
557
- | `npx jops-core init` | Vendor library, configure `index.html`, inject scripts into `package.json`. Detects Cordova and Capacitor projects (see [Hybrid Mobile Apps](#hybrid-mobile-apps-cordova-ionic--capacitor)) |
558
- | `npx jops-core update` | Copy the latest `jops-core.min.js` from `node_modules` to `lib/` |
873
+ | `npx jops-core init` | Vendor library, configure `index.html`, inject scripts into `package.json`. Detects Cordova and Capacitor projects (see [Hybrid Mobile Apps](#hybrid-mobile-apps-cordova-ionic--capacitor)). Template 3 creates a Next.js project instead (see [Next.js Integration](#nextjs-integration)) |
874
+ | `npx jops-core update` | Copy the latest `jops-core.min.js` from `node_modules` to `lib/` (`public/lib/` in a Next.js project) |
559
875
  | `npx jops-core build` | Generate production-ready `dist/` folder (skipped in Cordova/Capacitor projects) |
560
876
 
561
877
  `init` and `update` accept `--cordova`, `--capacitor` or `--web` to override the detected project type.
562
878
 
879
+ After each command the CLI checks npm for a newer jops-core and prints a notice if one is available (at most 1.5 s, silent when offline). `build` also warns if `lib/jops-core.min.js` doesn't match the installed version. Turn the check off with `--no-update-check` or `JOPS_NO_UPDATE_CHECK=1`; it is skipped automatically when `CI` is set.
880
+
563
881
  ---
564
882
 
565
883
  ## Source & Contributing
566
884
 
567
- JOPS is distributed as a single minified ES module (`lib/jops-core.min.js`) together with full TypeScript declarations (`lib/jops-core.d.ts`). The source repository is private.
885
+ JOPS is distributed as a single minified ES module (`lib/jops-core.min.js`) together with full TypeScript declarations (`lib/jops-core.d.ts`), plus the `JopsApp` React component for Next.js (`lib/nextjs/`, imported as `jops-core/nextjs`). The source repository is private.
568
886
 
569
887
  This is a deliberate choice. JOPS is maintained by one developer at [UIDB](https://ui-db.com), and keeping the codebase in one pair of hands keeps it small, consistent, and fast to change.
570
888