@preact-mvc/core 0.0.0 → 0.1.1
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 +96 -0
- package/dist/context.d.ts +10 -0
- package/dist/context.js +30 -0
- package/dist/controller.d.ts +8 -0
- package/dist/controller.js +21 -0
- package/dist/decorators.d.ts +22 -0
- package/dist/decorators.js +38 -0
- package/{src/index.ts → dist/index.d.ts} +1 -21
- package/dist/index.js +15 -0
- package/dist/injector.d.ts +47 -0
- package/dist/injector.js +202 -0
- package/dist/module.d.ts +7 -0
- package/dist/module.js +69 -0
- package/dist/router/index.js +4 -0
- package/dist/router/link.d.ts +7 -0
- package/dist/router/link.js +16 -0
- package/dist/router/module.d.ts +35 -0
- package/dist/router/module.js +32 -0
- package/dist/router/outlet.d.ts +1 -0
- package/dist/router/outlet.js +44 -0
- package/dist/router/router.d.ts +52 -0
- package/dist/router/router.js +151 -0
- package/dist/signals.d.ts +4 -0
- package/{src/signals.ts → dist/signals.js} +4 -7
- package/dist/view.d.ts +8 -0
- package/dist/view.js +133 -0
- package/package.json +16 -8
- package/src/context.tsx +0 -39
- package/src/controller.ts +0 -30
- package/src/decorators.ts +0 -77
- package/src/injector.ts +0 -228
- package/src/module.ts +0 -76
- package/src/router/link.tsx +0 -25
- package/src/router/module.ts +0 -42
- package/src/router/outlet.tsx +0 -65
- package/src/router/router.ts +0 -165
- package/src/view.ts +0 -153
- /package/{src/router/index.ts → dist/router/index.d.ts} +0 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getModuleMeta } from '../module';
|
|
2
|
+
import { ROUTES, ROUTER_BASE, Router, defaultBasePath } from './router';
|
|
3
|
+
// RouterModule.forRoot(routes) returns a module-shaped object — anything with
|
|
4
|
+
// __module metadata can sit in `imports`, so static config works the same as
|
|
5
|
+
// a decorated module class.
|
|
6
|
+
export class RouterModule {
|
|
7
|
+
static forRoot(routes, opts = {}) {
|
|
8
|
+
return {
|
|
9
|
+
__module: {
|
|
10
|
+
providers: [
|
|
11
|
+
{ provide: ROUTES, useValue: routes },
|
|
12
|
+
{ provide: ROUTER_BASE, useValue: opts.basePath ?? defaultBasePath() },
|
|
13
|
+
{
|
|
14
|
+
provide: Router,
|
|
15
|
+
useFactory: (r, base) => new Router(r, base),
|
|
16
|
+
deps: [ROUTES, ROUTER_BASE],
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// Resolve which view a matched route should render: an explicit component, or
|
|
24
|
+
// the module's declared bootstrap component.
|
|
25
|
+
export function routeComponent(match) {
|
|
26
|
+
const { route } = match;
|
|
27
|
+
if (route.component)
|
|
28
|
+
return route.component;
|
|
29
|
+
if (route.module)
|
|
30
|
+
return getModuleMeta(route.module)?.bootstrap?.[0] ?? null;
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function RouterOutlet(): import("preact").JSX.Element | null;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
|
|
2
|
+
import { createContext } from 'preact';
|
|
3
|
+
import { useContext } from 'preact/hooks';
|
|
4
|
+
import PRRouter from 'preact-router';
|
|
5
|
+
import { useInjector, useService, InjectorContext } from '../context';
|
|
6
|
+
import { getModuleInjector } from '../module';
|
|
7
|
+
import { routeComponent } from './module';
|
|
8
|
+
import { Router } from './router';
|
|
9
|
+
/**
|
|
10
|
+
* Which depth of the matched chain this outlet renders — the root outlet
|
|
11
|
+
* is depth 0, an outlet inside a routed view is depth 1, and so on.
|
|
12
|
+
*/
|
|
13
|
+
const OutletDepth = createContext(0);
|
|
14
|
+
/**
|
|
15
|
+
* The preact-router bridge: a mounted PR <Router> claiming every URL via a
|
|
16
|
+
* `default` sink child. Its existence makes preact-router's machinery live
|
|
17
|
+
* in this app — foreign `route()` calls and the global `<a href>` click
|
|
18
|
+
* interceptor resolve through it — and its onChange feeds our `url`
|
|
19
|
+
* signal, so navigations from ANY preact-routing code drive our router.
|
|
20
|
+
* The sink renders nothing; url/matches props die on the component.
|
|
21
|
+
*/
|
|
22
|
+
const BridgeSink = () => null;
|
|
23
|
+
export function RouterOutlet() {
|
|
24
|
+
const router = useService(Router);
|
|
25
|
+
const parent = useInjector();
|
|
26
|
+
const depth = useContext(OutletDepth);
|
|
27
|
+
const matches = router.matches.value;
|
|
28
|
+
const match = matches?.[depth];
|
|
29
|
+
if (!match) {
|
|
30
|
+
// The outermost outlet keeps the bridge mounted even with no match, so
|
|
31
|
+
// route() interop survives 404s.
|
|
32
|
+
return depth === 0 ? (_jsx(PRRouter, { onChange: (e) => router.syncUrl(e.url), children: _jsx(BridgeSink, { default: true }) })) : null;
|
|
33
|
+
}
|
|
34
|
+
const Component = routeComponent(match);
|
|
35
|
+
if (!Component)
|
|
36
|
+
return null;
|
|
37
|
+
// A route with `module` gets the module's injector — its providers are
|
|
38
|
+
// scoped to that feature. Nesting is automatic: a deeper outlet resolves
|
|
39
|
+
// its module under this level's injector.
|
|
40
|
+
const injector = match.route.module
|
|
41
|
+
? getModuleInjector(match.route.module, parent)
|
|
42
|
+
: parent;
|
|
43
|
+
return (_jsxs(OutletDepth.Provider, { value: depth + 1, children: [depth === 0 && (_jsx(PRRouter, { onChange: (e) => router.syncUrl(e.url), children: _jsx(BridgeSink, { default: true }) })), _jsx(InjectorContext.Provider, { value: injector, children: _jsx(Component, {}) })] }));
|
|
44
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { InjectionToken } from '../injector';
|
|
2
|
+
export interface Route {
|
|
3
|
+
path: string;
|
|
4
|
+
component?: any;
|
|
5
|
+
module?: any;
|
|
6
|
+
children?: Route[];
|
|
7
|
+
}
|
|
8
|
+
export interface RouteMatch {
|
|
9
|
+
route: Route;
|
|
10
|
+
params: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
export declare const ROUTES: InjectionToken<Route[]>;
|
|
13
|
+
export declare const ROUTER_BASE: InjectionToken<string>;
|
|
14
|
+
/**
|
|
15
|
+
* The directory the app's document lives at, e.g. '/demo/' when the page is
|
|
16
|
+
* served from `host/demo/index.html`. Deriving the base from baseURI (rather
|
|
17
|
+
* than baking it at build time) means the same bundle works under any mount
|
|
18
|
+
* path — GitHub Pages project dirs, reverse-proxy prefixes, iframed demos.
|
|
19
|
+
*/
|
|
20
|
+
export declare function defaultBasePath(): string;
|
|
21
|
+
export declare class Router {
|
|
22
|
+
private routes;
|
|
23
|
+
/** Mount prefix, normalized to '' or '/demo' (no trailing slash). */
|
|
24
|
+
readonly base: string;
|
|
25
|
+
url: import("@preact/signals-core").Signal<string>;
|
|
26
|
+
/**
|
|
27
|
+
* The matched chain outermost → innermost — depth d is what the d-th
|
|
28
|
+
* nested <RouterOutlet/> renders.
|
|
29
|
+
*/
|
|
30
|
+
matches: import("@preact/signals-core").ReadonlySignal<RouteMatch[] | null>;
|
|
31
|
+
/** Back-compat: the chain's outermost match. */
|
|
32
|
+
match: import("@preact/signals-core").ReadonlySignal<RouteMatch | null>;
|
|
33
|
+
/** Params merged across the whole chain (inner levels win on collision). */
|
|
34
|
+
params: import("@preact/signals-core").ReadonlySignal<any>;
|
|
35
|
+
constructor(routes: Route[], base?: string);
|
|
36
|
+
/**
|
|
37
|
+
* Static-host deep links: the site's 404 shim redirects
|
|
38
|
+
* `/docs/<app>/<rest>` to `<app>/?__pmvc_path=<rest>` so the app loads;
|
|
39
|
+
* restore the real URL via replaceState before seeding `url` — the
|
|
40
|
+
* browser history never sees the hop. No-op everywhere else.
|
|
41
|
+
*/
|
|
42
|
+
private consumeShimParam;
|
|
43
|
+
private strip;
|
|
44
|
+
/** Absolute href for an in-app path — what <a href> and pushState see. */
|
|
45
|
+
href(to: string): string;
|
|
46
|
+
/** Called by the preact-router bridge on any URL change it sees —
|
|
47
|
+
* foreign route() calls, its global <a> interceptor, popstate. */
|
|
48
|
+
syncUrl(fullUrl: string): void;
|
|
49
|
+
private onPopState;
|
|
50
|
+
navigate(to: string): void;
|
|
51
|
+
isActive(path: string): boolean;
|
|
52
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
import { computed, signal } from '@preact/signals';
|
|
8
|
+
import { route as prRoute } from 'preact-router';
|
|
9
|
+
import { Injectable } from '../decorators';
|
|
10
|
+
import { InjectionToken } from '../injector';
|
|
11
|
+
export const ROUTES = new InjectionToken('ROUTES');
|
|
12
|
+
export const ROUTER_BASE = new InjectionToken('ROUTER_BASE');
|
|
13
|
+
/**
|
|
14
|
+
* The directory the app's document lives at, e.g. '/demo/' when the page is
|
|
15
|
+
* served from `host/demo/index.html`. Deriving the base from baseURI (rather
|
|
16
|
+
* than baking it at build time) means the same bundle works under any mount
|
|
17
|
+
* path — GitHub Pages project dirs, reverse-proxy prefixes, iframed demos.
|
|
18
|
+
*/
|
|
19
|
+
export function defaultBasePath() {
|
|
20
|
+
if (typeof document === 'undefined')
|
|
21
|
+
return '/';
|
|
22
|
+
const p = new URL(document.baseURI).pathname;
|
|
23
|
+
return p.endsWith('/') ? p : p.slice(0, p.lastIndexOf('/') + 1);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Prefix match: consume the pattern's leading segments off `segs`, returning
|
|
27
|
+
* the captured params and the unconsumed tail. `'*'` consumes everything;
|
|
28
|
+
* `''` consumes nothing (an index route — leaf check decides validity).
|
|
29
|
+
*/
|
|
30
|
+
function matchPrefix(pattern, segs) {
|
|
31
|
+
if (pattern === '*')
|
|
32
|
+
return { params: {}, rest: [] };
|
|
33
|
+
const pSegs = pattern.split('/').filter(Boolean);
|
|
34
|
+
if (pSegs.length > segs.length)
|
|
35
|
+
return null;
|
|
36
|
+
const params = {};
|
|
37
|
+
for (let i = 0; i < pSegs.length; i++) {
|
|
38
|
+
const p = pSegs[i];
|
|
39
|
+
const u = decodeURIComponent(segs[i]);
|
|
40
|
+
if (p.startsWith(':'))
|
|
41
|
+
params[p.slice(1)] = u;
|
|
42
|
+
else if (p !== u)
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return { params, rest: segs.slice(pSegs.length) };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Match `segs` against a route list into a chain outermost → innermost.
|
|
49
|
+
* A route with `children` matches by prefix and the tail recurses; a leaf
|
|
50
|
+
* route must consume the whole tail. Parents may match alone when the tail
|
|
51
|
+
* is exhausted (Angular semantics: `/users` renders the parent shell even
|
|
52
|
+
* if it has children).
|
|
53
|
+
*/
|
|
54
|
+
function matchSegments(routes, segs) {
|
|
55
|
+
for (const route of routes) {
|
|
56
|
+
const m = matchPrefix(route.path, segs);
|
|
57
|
+
if (m === null)
|
|
58
|
+
continue;
|
|
59
|
+
if (route.children?.length) {
|
|
60
|
+
const child = matchSegments(route.children, m.rest);
|
|
61
|
+
if (child !== null)
|
|
62
|
+
return [{ route, params: m.params }, ...child];
|
|
63
|
+
if (m.rest.length === 0)
|
|
64
|
+
return [{ route, params: m.params }];
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (m.rest.length === 0)
|
|
68
|
+
return [{ route, params: m.params }];
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
function matchRoutes(routes, url) {
|
|
73
|
+
const path = url.split('?')[0].split('#')[0];
|
|
74
|
+
return matchSegments(routes, path.split('/').filter(Boolean));
|
|
75
|
+
}
|
|
76
|
+
let Router = class Router {
|
|
77
|
+
constructor(routes, base = defaultBasePath()) {
|
|
78
|
+
this.routes = routes;
|
|
79
|
+
this.url = signal(typeof location === 'undefined' ? '/' : '');
|
|
80
|
+
/**
|
|
81
|
+
* The matched chain outermost → innermost — depth d is what the d-th
|
|
82
|
+
* nested <RouterOutlet/> renders.
|
|
83
|
+
*/
|
|
84
|
+
this.matches = computed(() => matchRoutes(this.routes, this.url.value));
|
|
85
|
+
/** Back-compat: the chain's outermost match. */
|
|
86
|
+
this.match = computed(() => this.matches.value?.[0] ?? null);
|
|
87
|
+
/** Params merged across the whole chain (inner levels win on collision). */
|
|
88
|
+
this.params = computed(() => Object.assign({}, ...(this.matches.value ?? []).map((m) => m.params)));
|
|
89
|
+
this.onPopState = () => {
|
|
90
|
+
this.syncUrl(location.pathname + location.search);
|
|
91
|
+
};
|
|
92
|
+
this.base = base === '/' ? '' : base.replace(/\/+$/, '');
|
|
93
|
+
this.consumeShimParam();
|
|
94
|
+
if (typeof location !== 'undefined') {
|
|
95
|
+
this.url.value = this.strip(location.pathname) + location.search;
|
|
96
|
+
}
|
|
97
|
+
if (typeof window !== 'undefined') {
|
|
98
|
+
window.addEventListener('popstate', this.onPopState);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Static-host deep links: the site's 404 shim redirects
|
|
103
|
+
* `/docs/<app>/<rest>` to `<app>/?__pmvc_path=<rest>` so the app loads;
|
|
104
|
+
* restore the real URL via replaceState before seeding `url` — the
|
|
105
|
+
* browser history never sees the hop. No-op everywhere else.
|
|
106
|
+
*/
|
|
107
|
+
consumeShimParam() {
|
|
108
|
+
if (typeof location === 'undefined' || typeof history === 'undefined')
|
|
109
|
+
return;
|
|
110
|
+
const rest = new URL(location.href).searchParams.get('__pmvc_path');
|
|
111
|
+
if (rest === null)
|
|
112
|
+
return;
|
|
113
|
+
history.replaceState(null, '', this.base + '/' + rest);
|
|
114
|
+
}
|
|
115
|
+
strip(path) {
|
|
116
|
+
if (this.base && path.startsWith(this.base)) {
|
|
117
|
+
return path.slice(this.base.length) || '/';
|
|
118
|
+
}
|
|
119
|
+
return path;
|
|
120
|
+
}
|
|
121
|
+
/** Absolute href for an in-app path — what <a href> and pushState see. */
|
|
122
|
+
href(to) {
|
|
123
|
+
return this.base + to;
|
|
124
|
+
}
|
|
125
|
+
/** Called by the preact-router bridge on any URL change it sees —
|
|
126
|
+
* foreign route() calls, its global <a> interceptor, popstate. */
|
|
127
|
+
syncUrl(fullUrl) {
|
|
128
|
+
const [path, search = ''] = fullUrl.split('?');
|
|
129
|
+
const stripped = this.strip(path);
|
|
130
|
+
const next = stripped + (search ? `?${search}` : '');
|
|
131
|
+
if (next !== this.url.value)
|
|
132
|
+
this.url.value = next;
|
|
133
|
+
}
|
|
134
|
+
navigate(to) {
|
|
135
|
+
// Through preact-router: updates every mounted <Router> in the tree —
|
|
136
|
+
// ours and foreign ones — not just our own subscriber. route() is a
|
|
137
|
+
// no-op when no mounted PR Router claims the url, so fall back to a
|
|
138
|
+
// manual push when the bridge isn't up yet.
|
|
139
|
+
if (!prRoute(this.base + to)) {
|
|
140
|
+
history.pushState(null, '', this.base + to);
|
|
141
|
+
this.url.value = to;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
isActive(path) {
|
|
145
|
+
return this.url.value === path;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
Router = __decorate([
|
|
149
|
+
Injectable()
|
|
150
|
+
], Router);
|
|
151
|
+
export { Router };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { effect as preactEffect } from '@preact/signals';
|
|
2
|
+
export { signal, computed, batch, untracked } from '@preact/signals';
|
|
3
|
+
export type { Signal, ReadonlySignal } from '@preact/signals';
|
|
4
|
+
export declare function effect(fn: Parameters<typeof preactEffect>[0]): () => void;
|
|
@@ -3,17 +3,14 @@
|
|
|
3
3
|
// is installed before any view uses it.
|
|
4
4
|
import { effect as preactEffect } from '@preact/signals';
|
|
5
5
|
import { getCurrentInjector } from './injector';
|
|
6
|
-
|
|
7
6
|
export { signal, computed, batch, untracked } from '@preact/signals';
|
|
8
|
-
export type { Signal, ReadonlySignal } from '@preact/signals';
|
|
9
|
-
|
|
10
7
|
// Scope-bound effect: created during a class's construction (field
|
|
11
8
|
// initializers or constructor body), it registers on that scope's injector
|
|
12
9
|
// and is disposed automatically when the scope is deactivated — Angular's
|
|
13
10
|
// DestroyRef semantics. Outside an injection context it's the raw primitive:
|
|
14
11
|
// call the returned disposer yourself.
|
|
15
|
-
export function effect(fn
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
export function effect(fn) {
|
|
13
|
+
const dispose = preactEffect(fn);
|
|
14
|
+
getCurrentInjector()?.registerCleanup(dispose);
|
|
15
|
+
return dispose;
|
|
19
16
|
}
|
package/dist/view.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Component as PreactComponent } from 'preact';
|
|
2
|
+
import type { ComponentChildren } from 'preact';
|
|
3
|
+
export declare abstract class View<P = {}> extends PreactComponent<P> {
|
|
4
|
+
abstract render(): ComponentChildren;
|
|
5
|
+
setState(): never;
|
|
6
|
+
forceUpdate(): never;
|
|
7
|
+
}
|
|
8
|
+
export declare function installViewInterop(): void;
|
package/dist/view.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Component as PreactComponent, h, options } from 'preact';
|
|
2
|
+
import { useLayoutEffect, useRef } from 'preact/hooks';
|
|
3
|
+
import { InjectorContext, useInjector } from './context';
|
|
4
|
+
import { Injector } from './injector';
|
|
5
|
+
import { registerModule } from './module';
|
|
6
|
+
import { ownerOf } from './decorators';
|
|
7
|
+
// --- Class-based views -----------------------------------------------------
|
|
8
|
+
// A View subclass is never instantiated by Preact. The options.vnode hook
|
|
9
|
+
// below swaps any vnode whose type is a View subclass for a host function
|
|
10
|
+
// component, which constructs the class ONCE per mount inside a child
|
|
11
|
+
// injector — so `inject()` in field initializers resolves against that view's
|
|
12
|
+
// scope (component providers -> module providers -> root).
|
|
13
|
+
//
|
|
14
|
+
// render() runs on every render pass with no framework involvement: signals
|
|
15
|
+
// read inside it subscribe the host component automatically.
|
|
16
|
+
export class View extends PreactComponent {
|
|
17
|
+
// Views are signal-driven; setState/forceUpdate would hit Preact internals
|
|
18
|
+
// on an instance Preact never mounted. Fail loudly instead of corrupting.
|
|
19
|
+
setState() {
|
|
20
|
+
throw new Error('View components are signal-driven — assign a signal instead of calling setState()');
|
|
21
|
+
}
|
|
22
|
+
forceUpdate() {
|
|
23
|
+
throw new Error('View components are signal-driven — assign a signal instead of calling forceUpdate()');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function isViewClass(t) {
|
|
27
|
+
return typeof t === 'function' && t.prototype instanceof View;
|
|
28
|
+
}
|
|
29
|
+
// --- Error boundary ----------------------------------------------------------
|
|
30
|
+
// Real Preact class component (not a View — it must be instantiated by Preact).
|
|
31
|
+
// Wraps every view host so mount/render failures stay local instead of
|
|
32
|
+
// aborting the whole render pass.
|
|
33
|
+
class ViewErrorBoundary extends PreactComponent {
|
|
34
|
+
constructor() {
|
|
35
|
+
super(...arguments);
|
|
36
|
+
this.state = { error: null };
|
|
37
|
+
}
|
|
38
|
+
componentDidCatch(error) {
|
|
39
|
+
console.error(`[framework] ${this.props.viewName} failed:`, error);
|
|
40
|
+
this.setState({ error });
|
|
41
|
+
}
|
|
42
|
+
render() {
|
|
43
|
+
const err = this.state.error;
|
|
44
|
+
if (!err)
|
|
45
|
+
return this.props.children;
|
|
46
|
+
return h('div', {
|
|
47
|
+
style: {
|
|
48
|
+
border: '1px solid #e53935',
|
|
49
|
+
background: '#ffebee',
|
|
50
|
+
padding: '0.75rem',
|
|
51
|
+
borderRadius: '4px',
|
|
52
|
+
fontFamily: 'monospace',
|
|
53
|
+
fontSize: '0.85rem',
|
|
54
|
+
},
|
|
55
|
+
}, h('strong', null, `${this.props.viewName} failed to mount`), h('pre', { style: { whiteSpace: 'pre-wrap', margin: '0.5rem 0' } }, String(err?.message ?? err)), h('button', { onClick: () => this.setState({ error: null }) }, 'Retry'));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// --- Host -------------------------------------------------------------------
|
|
59
|
+
const hostCache = new Map();
|
|
60
|
+
function hostFor(cls) {
|
|
61
|
+
let host = hostCache.get(cls);
|
|
62
|
+
if (host)
|
|
63
|
+
return host;
|
|
64
|
+
const meta = (cls.__component ?? {});
|
|
65
|
+
const inner = function ViewHost(props) {
|
|
66
|
+
const parent = useInjector();
|
|
67
|
+
const ref = useRef(null);
|
|
68
|
+
if (!ref.current) {
|
|
69
|
+
// Module ownership: a view declared in a module's components/bootstrap
|
|
70
|
+
// may only mount where that module's injector is an ancestor — Angular's
|
|
71
|
+
// declaration rule enforced at mount. Undeclared views mount freely.
|
|
72
|
+
const owner = ownerOf(cls);
|
|
73
|
+
if (owner) {
|
|
74
|
+
let node = parent;
|
|
75
|
+
while (node && !node.modules.has(owner))
|
|
76
|
+
node = node.parent;
|
|
77
|
+
if (!node) {
|
|
78
|
+
throw new Error(`${cls.name} is declared by ${owner.name ?? 'a module'} but mounted outside its injector scope. ` +
|
|
79
|
+
`Mount it under a route/module that includes ${owner.name ?? 'that module'}.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const inj = new Injector(parent, `view ${cls.name}`);
|
|
83
|
+
// Standalone scope: imported modules contribute providers (and mark
|
|
84
|
+
// themselves visible for ownership checks); view-level providers and
|
|
85
|
+
// declared controllers register on top, closest-scope-wins.
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
for (const imp of meta.imports ?? [])
|
|
88
|
+
registerModule(imp, inj, seen);
|
|
89
|
+
for (const p of meta.providers ?? [])
|
|
90
|
+
inj.provide(p);
|
|
91
|
+
for (const c of meta.controllers ?? [])
|
|
92
|
+
inj.provide(c);
|
|
93
|
+
ref.current = { inj, inst: inj.instantiate(cls, props) };
|
|
94
|
+
}
|
|
95
|
+
const { inj, inst } = ref.current;
|
|
96
|
+
inst.props = props;
|
|
97
|
+
// Mount activates the whole view scope: onInit for the view and every
|
|
98
|
+
// controller/provider its injector constructed; unmount fires onDestroy.
|
|
99
|
+
// useLayoutEffect (synchronous at commit) is deliberate: deferred
|
|
100
|
+
// useEffect callbacks are dropped if the host unmounts before they flush,
|
|
101
|
+
// which would silently lose onInit on same-tick mount+navigate.
|
|
102
|
+
useLayoutEffect(() => {
|
|
103
|
+
inj.activate();
|
|
104
|
+
return () => inj.deactivate();
|
|
105
|
+
}, []);
|
|
106
|
+
// The view's injector becomes the scope for everything it renders, so
|
|
107
|
+
// child components resolve through it — the element-injector hierarchy.
|
|
108
|
+
return h(InjectorContext.Provider, { value: inj }, inst.render());
|
|
109
|
+
};
|
|
110
|
+
inner.displayName = cls.name;
|
|
111
|
+
host = function BoundedView(props) {
|
|
112
|
+
return h(ViewErrorBoundary, { viewName: cls.name }, h(inner, props));
|
|
113
|
+
};
|
|
114
|
+
host.displayName = cls.name;
|
|
115
|
+
hostCache.set(cls, host);
|
|
116
|
+
return host;
|
|
117
|
+
}
|
|
118
|
+
let installed = false;
|
|
119
|
+
// Install the vnode interceptor. Must run before any view class is rendered;
|
|
120
|
+
// called on module load and again defensively from bootstrap().
|
|
121
|
+
export function installViewInterop() {
|
|
122
|
+
if (installed)
|
|
123
|
+
return;
|
|
124
|
+
installed = true;
|
|
125
|
+
const prev = options.vnode;
|
|
126
|
+
options.vnode = (vnode) => {
|
|
127
|
+
const t = vnode.type;
|
|
128
|
+
if (isViewClass(t))
|
|
129
|
+
vnode.type = hostFor(t);
|
|
130
|
+
prev?.(vnode);
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
installViewInterop();
|
package/package.json
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preact-mvc/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Angular-inspired modules/DI/decorators + class-based JSX views rendered by Preact + signal reactivity",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
|
-
".":
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"default": "./dist/index.js"
|
|
10
|
+
},
|
|
8
11
|
"./package.json": "./package.json",
|
|
9
|
-
"./*": "./
|
|
12
|
+
"./*": "./dist/*"
|
|
10
13
|
},
|
|
11
14
|
"files": [
|
|
12
|
-
"
|
|
13
|
-
"!src/**/*.test.ts",
|
|
14
|
-
"!src/**/*.test.tsx"
|
|
15
|
+
"dist"
|
|
15
16
|
],
|
|
16
17
|
"scripts": {
|
|
17
|
-
"typecheck": "tsc --noEmit"
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"build": "tsc -p tsconfig.build.json"
|
|
18
20
|
},
|
|
19
21
|
"dependencies": {
|
|
20
22
|
"@preact/signals": "^1.3.0",
|
|
@@ -25,5 +27,11 @@
|
|
|
25
27
|
"access": "public",
|
|
26
28
|
"registry": "https://registry.npmjs.org"
|
|
27
29
|
},
|
|
28
|
-
"
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/jwhenry3/preact-mvc",
|
|
33
|
+
"directory": "packages/core"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"types": "./dist/index.d.ts"
|
|
29
37
|
}
|
package/src/context.tsx
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { createContext } from 'preact';
|
|
2
|
-
import type { ComponentChildren } from 'preact';
|
|
3
|
-
import { useContext, useRef } from 'preact/hooks';
|
|
4
|
-
import { Injector } from './injector';
|
|
5
|
-
import type { Provider, Token } from './injector';
|
|
6
|
-
|
|
7
|
-
// The current injector travels through the view tree as Preact context.
|
|
8
|
-
// Module and route boundaries swap in child injectors.
|
|
9
|
-
|
|
10
|
-
export const InjectorContext = createContext<Injector>(null as unknown as Injector);
|
|
11
|
-
|
|
12
|
-
export function useInjector(): Injector {
|
|
13
|
-
const injector = useContext(InjectorContext);
|
|
14
|
-
if (!injector) {
|
|
15
|
-
throw new Error('No injector in context — is this view mounted under bootstrap()?');
|
|
16
|
-
}
|
|
17
|
-
return injector;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// Resolve a service/store from the view's injector scope.
|
|
21
|
-
export function useService<T>(token: Token<T>): T {
|
|
22
|
-
return useInjector().get(token);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// View-local provider scope: equivalent to a component-level providers array.
|
|
26
|
-
// Providers created here live and die with this subtree.
|
|
27
|
-
export function Provide(props: { providers: Provider[]; children?: ComponentChildren }) {
|
|
28
|
-
const parent = useInjector();
|
|
29
|
-
const ref = useRef<Injector | null>(null);
|
|
30
|
-
if (!ref.current) {
|
|
31
|
-
ref.current = new Injector(parent, 'view providers');
|
|
32
|
-
for (const p of props.providers) ref.current.provide(p);
|
|
33
|
-
}
|
|
34
|
-
return (
|
|
35
|
-
<InjectorContext.Provider value={ref.current}>
|
|
36
|
-
{props.children}
|
|
37
|
-
</InjectorContext.Provider>
|
|
38
|
-
);
|
|
39
|
-
}
|
package/src/controller.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { useLayoutEffect, useRef } from 'preact/hooks';
|
|
2
|
-
import { useInjector } from './context';
|
|
3
|
-
import { Injector } from './injector';
|
|
4
|
-
import type { Type } from './injector';
|
|
5
|
-
|
|
6
|
-
export interface OnInit {
|
|
7
|
-
onInit(): void;
|
|
8
|
-
}
|
|
9
|
-
export interface OnDestroy {
|
|
10
|
-
onDestroy(): void;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
// Binds a controller class to a view instance. The controller is created once
|
|
14
|
-
// per mounted view inside its own child injector, so `inject()` in its
|
|
15
|
-
// constructor sees every provider from the enclosing module scope upward.
|
|
16
|
-
// Lifecycle maps to mount/unmount: onInit on mount, onDestroy on unmount.
|
|
17
|
-
export function useController<T extends object>(cls: Type<T>): T {
|
|
18
|
-
const parent = useInjector();
|
|
19
|
-
const ref = useRef<{ injector: Injector; ctrl: T } | null>(null);
|
|
20
|
-
if (!ref.current) {
|
|
21
|
-
const injector = new Injector(parent, `controller ${cls.name}`);
|
|
22
|
-
ref.current = { injector, ctrl: injector.instantiate(cls) };
|
|
23
|
-
}
|
|
24
|
-
const { ctrl, injector } = ref.current;
|
|
25
|
-
useLayoutEffect(() => {
|
|
26
|
-
injector.activate();
|
|
27
|
-
return () => injector.deactivate();
|
|
28
|
-
}, []);
|
|
29
|
-
return ctrl;
|
|
30
|
-
}
|
package/src/decorators.ts
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import type { Provider, Type } from './injector';
|
|
2
|
-
|
|
3
|
-
// Decorators attach metadata to the class constructor. They work with
|
|
4
|
-
// experimentalDecorators (esbuild honors the tsconfig flag) and can also be
|
|
5
|
-
// invoked manually: `Module({ ... })(MyModule)`.
|
|
6
|
-
|
|
7
|
-
export interface InjectableMeta {
|
|
8
|
-
providedIn?: 'root';
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function Injectable(meta: InjectableMeta = {}) {
|
|
12
|
-
return function <T extends Type<any>>(target: T): T {
|
|
13
|
-
(target as any).__injectable = meta;
|
|
14
|
-
return target;
|
|
15
|
-
};
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function Controller() {
|
|
19
|
-
return function <T extends Type<any>>(target: T): T {
|
|
20
|
-
(target as any).__controller = true;
|
|
21
|
-
return target;
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface ComponentMeta {
|
|
26
|
-
// Scoped to the component instance — like Angular component-level providers.
|
|
27
|
-
providers?: Provider[];
|
|
28
|
-
// Controllers registered in this view's scope: shared with the entire
|
|
29
|
-
// subtree (a child view injecting the same controller gets this instance).
|
|
30
|
-
// Undeclared controllers stay transient — one per injecting scope.
|
|
31
|
-
controllers?: Type<any>[];
|
|
32
|
-
// Modules whose providers and declared components join this view's scope —
|
|
33
|
-
// Angular's standalone-component `imports`. Enables module-less views that
|
|
34
|
-
// still participate in module-based scoping.
|
|
35
|
-
imports?: any[];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function Component(meta: ComponentMeta = {}) {
|
|
39
|
-
return function <T>(target: T): T {
|
|
40
|
-
(target as any).__component = meta;
|
|
41
|
-
return target;
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface ModuleMeta {
|
|
46
|
-
imports?: any[];
|
|
47
|
-
providers?: Provider[];
|
|
48
|
-
controllers?: Type<any>[];
|
|
49
|
-
components?: any[];
|
|
50
|
-
exports?: any[];
|
|
51
|
-
bootstrap?: any[];
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// View class -> the module that declared it. Built as module classes are
|
|
55
|
-
// decorated; consulted at mount time to enforce scope ownership.
|
|
56
|
-
const componentOwners = new Map<any, any>();
|
|
57
|
-
|
|
58
|
-
export function ownerOf(view: any): any {
|
|
59
|
-
return componentOwners.get(view);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function Module(meta: ModuleMeta) {
|
|
63
|
-
return function <T>(target: T): T {
|
|
64
|
-
(target as any).__module = meta;
|
|
65
|
-
for (const c of [...(meta.components ?? []), ...(meta.bootstrap ?? [])]) {
|
|
66
|
-
const prev = componentOwners.get(c);
|
|
67
|
-
if (prev && prev !== target) {
|
|
68
|
-
console.warn(
|
|
69
|
-
`[framework] ${c.name ?? c} is declared by both ${prev.name} and ${(target as any).name}; keeping ${prev.name}`
|
|
70
|
-
);
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
componentOwners.set(c, target);
|
|
74
|
-
}
|
|
75
|
-
return target;
|
|
76
|
-
};
|
|
77
|
-
}
|