@preact-mvc/core 0.0.0-stage → 0.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.
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "@preact-mvc/core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Angular-inspired modules/DI/decorators + class-based JSX views rendered by Preact + signal reactivity",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./package.json": "./package.json",
9
+ "./*": "./src/*"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "!src/**/*.test.ts",
14
+ "!src/**/*.test.tsx"
15
+ ],
16
+ "scripts": {
17
+ "typecheck": "tsc --noEmit"
18
+ },
19
+ "dependencies": {
20
+ "@preact/signals": "^1.3.0",
21
+ "preact": "^10.24.0",
22
+ "preact-router": "^4.1.2"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "registry": "https://registry.npmjs.org"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/jwhenry3/preact-mvc",
31
+ "directory": "packages/core"
32
+ },
33
+ "license": "MIT"
34
+ }
@@ -0,0 +1,39 @@
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
+ }
@@ -0,0 +1,30 @@
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
+ }
@@ -0,0 +1,77 @@
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
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ // Signals — the reactivity primitive shared by models, controllers, and views.
2
+ export { signal, computed, effect, batch, untracked } from './signals';
3
+ export type { Signal, ReadonlySignal } from './signals';
4
+
5
+ // Dependency injection
6
+ export {
7
+ Injector,
8
+ InjectionToken,
9
+ inject,
10
+ runInInjectionContext,
11
+ getCurrentInjector,
12
+ DestroyRef,
13
+ } from './injector';
14
+ export type { Provider, Token, Type } from './injector';
15
+
16
+ // Metadata
17
+ export { Injectable, Controller, Module, Component } from './decorators';
18
+ export type { InjectableMeta, ModuleMeta, ComponentMeta } from './decorators';
19
+
20
+ // Class-based views
21
+ export { View, installViewInterop } from './view';
22
+
23
+ // Modules & bootstrap
24
+ export { bootstrap, createModuleInjector, getModuleInjector, getModuleMeta } from './module';
25
+
26
+ // View integration
27
+ export { InjectorContext, useInjector, useService, Provide } from './context';
28
+ export { useController } from './controller';
29
+ export type { OnInit, OnDestroy } from './controller';
30
+
31
+ // Router
32
+ export { Router, ROUTES, ROUTER_BASE, RouterModule, RouterOutlet, Link, defaultBasePath } from './router';
33
+ export type { Route, RouteMatch, RouterOptions } from './router';
@@ -0,0 +1,228 @@
1
+ export type Type<T> = new (...args: any[]) => T;
2
+
3
+ export class InjectionToken<T> {
4
+ constructor(protected readonly _desc: string) {}
5
+ toString() {
6
+ return `InjectionToken ${this._desc}`;
7
+ }
8
+ }
9
+
10
+ export type Token<T> = Type<T> | InjectionToken<T> | (abstract new (...args: any[]) => T);
11
+
12
+ export type Provider =
13
+ | Type<any>
14
+ | { provide: Token<any>; useClass: Type<any> }
15
+ | { provide: Token<any>; useValue: any }
16
+ | { provide: Token<any>; useFactory: (...deps: any[]) => any; deps?: Token<any>[] };
17
+
18
+ interface Record {
19
+ factory: () => any;
20
+ resolving: boolean;
21
+ resolved: boolean;
22
+ value: any;
23
+ }
24
+
25
+ // --- Injection context -----------------------------------------------------
26
+ // While an Injector constructs a class (service, controller, factory), it
27
+ // becomes the "current" injector so `inject()` calls inside the constructor
28
+ // or field initializers resolve against it — the same model as Angular's
29
+ // injection context.
30
+
31
+ let currentInjector: Injector | null = null;
32
+
33
+ export function runInInjectionContext<T>(injector: Injector, fn: () => T): T {
34
+ const prev = currentInjector;
35
+ currentInjector = injector;
36
+ try {
37
+ return fn();
38
+ } finally {
39
+ currentInjector = prev;
40
+ }
41
+ }
42
+
43
+ export function inject<T>(token: Token<T>): T {
44
+ if (!currentInjector) {
45
+ throw new Error(
46
+ 'inject() called outside an injection context. It can only be used in the ' +
47
+ 'constructor/field initializers of a class instantiated by the framework.'
48
+ );
49
+ }
50
+ return currentInjector.get(token);
51
+ }
52
+
53
+ export function getCurrentInjector(): Injector | null {
54
+ return currentInjector;
55
+ }
56
+
57
+ // --- Injector ---------------------------------------------------------------
58
+
59
+ function tokenName(token: Token<any>): string {
60
+ return typeof token === 'function' ? token.name : token.toString();
61
+ }
62
+
63
+ // Injectable handle to the enclosing scope's destruction — inject(DestroyRef)
64
+ // anywhere to register cleanup that runs when the scope is deactivated.
65
+ export class DestroyRef {
66
+ constructor(private readonly injector: Injector) {}
67
+ onDestroy(fn: () => void): void {
68
+ this.injector.registerCleanup(fn);
69
+ }
70
+ }
71
+
72
+ export class Injector {
73
+ private records = new Map<Token<any>, Record>();
74
+
75
+ // Every module whose providers AND declared components are visible in this
76
+ // scope: the module this injector was created for plus its transitive
77
+ // imports. Populated by registerModule(); read by view-mount enforcement.
78
+ readonly modules = new Set<any>();
79
+
80
+ constructor(
81
+ readonly parent: Injector | null = null,
82
+ readonly name = 'injector'
83
+ ) {
84
+ this.records.set(DestroyRef, {
85
+ factory: () => null,
86
+ resolving: false,
87
+ resolved: true,
88
+ value: new DestroyRef(this),
89
+ });
90
+ }
91
+
92
+ get root(): Injector {
93
+ let node: Injector = this;
94
+ while (node.parent) node = node.parent;
95
+ return node;
96
+ }
97
+
98
+ provide(provider: Provider): void {
99
+ if (typeof provider === 'function') {
100
+ const cls = provider;
101
+ this.records.set(cls, {
102
+ factory: () => this.instantiate(cls),
103
+ resolving: false,
104
+ resolved: false,
105
+ value: undefined,
106
+ });
107
+ } else if ('useValue' in provider) {
108
+ this.records.set(provider.provide, {
109
+ factory: () => provider.useValue,
110
+ resolving: false,
111
+ resolved: false,
112
+ value: undefined,
113
+ });
114
+ } else if ('useClass' in provider) {
115
+ const cls = provider.useClass;
116
+ this.records.set(provider.provide, {
117
+ factory: () => this.instantiate(cls),
118
+ resolving: false,
119
+ resolved: false,
120
+ value: undefined,
121
+ });
122
+ } else {
123
+ const { useFactory, deps = [] } = provider;
124
+ this.records.set(provider.provide, {
125
+ factory: () => useFactory(...deps.map((d) => this.get(d))),
126
+ resolving: false,
127
+ resolved: false,
128
+ value: undefined,
129
+ });
130
+ }
131
+ }
132
+
133
+ has(token: Token<any>): boolean {
134
+ if (this.records.has(token)) return true;
135
+ if ((token as any).__controller) return true;
136
+ if (this.parent) return this.parent.has(token);
137
+ return this.isRootProvided(token);
138
+ }
139
+
140
+ get<T>(token: Token<T>): T {
141
+ // Nearest explicit registration wins, walking up the scope chain. This is
142
+ // what makes declaring a controller/provider in a scope meaningful: every
143
+ // descendant resolves that shared instance.
144
+ let node: Injector | null = this;
145
+ while (node) {
146
+ const rec = node.records.get(token);
147
+ if (rec) return node.resolveRecord(token, rec);
148
+ node = node.parent;
149
+ }
150
+ // Controllers are transient unless explicitly provided: one instance per
151
+ // requesting scope, so a view always gets its own controller.
152
+ if ((token as any).__controller) {
153
+ this.provide(token as Type<any>);
154
+ return this.resolveRecord(token, this.records.get(token)!);
155
+ }
156
+ // providedIn: 'root' lazily registers at the top — local providers still
157
+ // shadow it because the chain walk runs first.
158
+ const root = this.root;
159
+ if (root.isRootProvided(token)) {
160
+ root.provide({ provide: token, useClass: token as Type<T> });
161
+ return root.get(token);
162
+ }
163
+ throw new Error(`No provider for ${tokenName(token)} (looked up from "${this.name}")`);
164
+ }
165
+
166
+ private resolveRecord<T>(token: Token<T>, rec: Record): T {
167
+ if (rec.resolving) {
168
+ throw new Error(`Circular dependency detected while resolving ${tokenName(token)}`);
169
+ }
170
+ if (!rec.resolved) {
171
+ rec.resolving = true;
172
+ try {
173
+ rec.value = rec.factory();
174
+ rec.resolved = true;
175
+ } finally {
176
+ rec.resolving = false;
177
+ }
178
+ }
179
+ return rec.value;
180
+ }
181
+
182
+ private isRootProvided(token: Token<any>): boolean {
183
+ const meta = (token as any).__injectable as { providedIn?: 'root' } | undefined;
184
+ return meta?.providedIn === 'root';
185
+ }
186
+
187
+ instantiate<T>(cls: Type<T>, ...args: any[]): T {
188
+ const inst = runInInjectionContext(this, () => new cls(...args));
189
+ this.trackLifecycle(inst);
190
+ return inst;
191
+ }
192
+
193
+ // --- Scope lifecycle -----------------------------------------------------
194
+ // Every class this injector constructs (view, injected controllers,
195
+ // view-scoped providers) is managed together: activate() fires onInit on
196
+ // all of them at mount, deactivate() fires onDestroy at unmount — the same
197
+ // way Angular ties provider lifecycles to their injector's scope.
198
+
199
+ private managed: any[] = [];
200
+ private active = false;
201
+
202
+ private trackLifecycle(inst: any): void {
203
+ if (typeof inst?.onInit !== 'function' && typeof inst?.onDestroy !== 'function') return;
204
+ this.managed.push(inst);
205
+ if (this.active) inst.onInit?.();
206
+ }
207
+
208
+ activate(): void {
209
+ this.active = true;
210
+ for (const inst of this.managed) inst.onInit?.();
211
+ }
212
+
213
+ deactivate(): void {
214
+ for (const inst of this.managed) inst.onDestroy?.();
215
+ for (const cleanup of this.cleanups) cleanup();
216
+ this.managed.length = 0;
217
+ this.cleanups.length = 0;
218
+ this.active = false;
219
+ }
220
+
221
+ private cleanups: Array<() => void> = [];
222
+
223
+ // Register teardown (effect disposers, subscriptions, timers) that runs
224
+ // when this scope is deactivated. Exposed to classes via DestroyRef.
225
+ registerCleanup(fn: () => void): void {
226
+ this.cleanups.push(fn);
227
+ }
228
+ }
package/src/module.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { h, render } from 'preact';
2
+ import { Injector } from './injector';
3
+ import { InjectorContext } from './context';
4
+ import { installViewInterop } from './view';
5
+ import type { ModuleMeta } from './decorators';
6
+
7
+ // Modules are the unit of provider scoping. A module's providers (plus those
8
+ // of its imports, transitively) register into a single child Injector. The
9
+ // injector tree mirrors the view tree: root injector -> AppModule injector ->
10
+ // feature/lazy module injectors -> per-view injectors.
11
+
12
+ export function getModuleMeta(mod: any): ModuleMeta | undefined {
13
+ return mod?.__module;
14
+ }
15
+
16
+ // Applies a module to an injector: registers all transitively imported
17
+ // modules (and their providers/controllers) into it and marks them visible
18
+ // on injector.modules. Used for module injectors and standalone-component
19
+ // `imports` alike.
20
+ export function registerModule(mod: any, injector: Injector, seen: Set<any>): void {
21
+ if (seen.has(mod)) return;
22
+ seen.add(mod);
23
+ const meta = getModuleMeta(mod);
24
+ if (!meta) {
25
+ throw new Error(`${mod?.name ?? mod} is not a module — missing @Module() metadata`);
26
+ }
27
+ for (const imp of meta.imports ?? []) registerModule(imp, injector, seen);
28
+ injector.modules.add(mod);
29
+ for (const provider of meta.providers ?? []) injector.provide(provider);
30
+ // Module-declared controllers register at module scope: one shared
31
+ // instance for everything injected under that module.
32
+ for (const c of meta.controllers ?? []) injector.provide(c);
33
+ }
34
+
35
+ export function createModuleInjector(mod: any, parent: Injector): Injector {
36
+ const meta = getModuleMeta(mod);
37
+ if (!meta) {
38
+ throw new Error(`${mod?.name ?? mod} is not a module — missing @Module() metadata`);
39
+ }
40
+ const injector = new Injector(parent, `module ${mod.name ?? '(anonymous)'}`);
41
+ registerModule(mod, injector, new Set());
42
+ return injector;
43
+ }
44
+
45
+ // Lazy/feature modules get one injector per parent injector, cached so a
46
+ // route-mounted module's providers stay alive across navigations.
47
+ const moduleInjectorCache = new WeakMap<Injector, Map<any, Injector>>();
48
+
49
+ export function getModuleInjector(mod: any, parent: Injector): Injector {
50
+ let byModule = moduleInjectorCache.get(parent);
51
+ if (!byModule) {
52
+ byModule = new Map();
53
+ moduleInjectorCache.set(parent, byModule);
54
+ }
55
+ let injector = byModule.get(mod);
56
+ if (!injector) {
57
+ injector = createModuleInjector(mod, parent);
58
+ byModule.set(mod, injector);
59
+ }
60
+ return injector;
61
+ }
62
+
63
+ export function bootstrap(RootModule: any, el: HTMLElement): void {
64
+ installViewInterop();
65
+ const root = new Injector(null, 'root');
66
+ const appInjector = createModuleInjector(RootModule, root);
67
+ const meta = getModuleMeta(RootModule)!;
68
+ const App = meta.bootstrap?.[0];
69
+ if (!App) {
70
+ throw new Error('Root module must declare a bootstrap component: @Module({ bootstrap: [AppView] })');
71
+ }
72
+ render(
73
+ h(InjectorContext.Provider, { value: appInjector }, h(App, {})),
74
+ el
75
+ );
76
+ }
@@ -0,0 +1,6 @@
1
+ export { Router, ROUTES, ROUTER_BASE, defaultBasePath } from './router';
2
+ export type { Route, RouteMatch } from './router';
3
+ export { RouterModule, routeComponent } from './module';
4
+ export type { RouterOptions } from './module';
5
+ export { RouterOutlet } from './outlet';
6
+ export { Link } from './link';
@@ -0,0 +1,25 @@
1
+ import type { ComponentChildren } from 'preact';
2
+ import type { JSX } from 'preact';
3
+ import { useService } from '../context';
4
+ import { Router } from './router';
5
+
6
+ export function Link(props: {
7
+ to: string;
8
+ class?: string;
9
+ children?: ComponentChildren;
10
+ }) {
11
+ const router = useService(Router);
12
+ const onClick = (e: JSX.TargetedMouseEvent<HTMLAnchorElement>) => {
13
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
14
+ e.preventDefault();
15
+ // preact-router's document-level <a> interceptor routes any plain
16
+ // anchor click it sees — stop propagation so ours doesn't double-nav.
17
+ e.stopPropagation();
18
+ router.navigate(props.to);
19
+ };
20
+ return (
21
+ <a href={router.href(props.to)} class={props.class} onClick={onClick}>
22
+ {props.children}
23
+ </a>
24
+ );
25
+ }
@@ -0,0 +1,42 @@
1
+ import { getModuleMeta } from '../module';
2
+ import { ROUTES, ROUTER_BASE, Router, defaultBasePath } from './router';
3
+ import type { Route } from './router';
4
+
5
+ export interface RouterOptions {
6
+ /**
7
+ * Mount prefix the app is served under ('/demo/'), Angular's APP_BASE_HREF.
8
+ * Defaults to the document's own directory (document.baseURI), so apps
9
+ * hosted under a subpath need no config; '/' disables prefixing.
10
+ */
11
+ basePath?: string;
12
+ }
13
+
14
+ // RouterModule.forRoot(routes) returns a module-shaped object — anything with
15
+ // __module metadata can sit in `imports`, so static config works the same as
16
+ // a decorated module class.
17
+ export class RouterModule {
18
+ static forRoot(routes: Route[], opts: RouterOptions = {}) {
19
+ return {
20
+ __module: {
21
+ providers: [
22
+ { provide: ROUTES, useValue: routes },
23
+ { provide: ROUTER_BASE, useValue: opts.basePath ?? defaultBasePath() },
24
+ {
25
+ provide: Router,
26
+ useFactory: (r: Route[], base: string) => new Router(r, base),
27
+ deps: [ROUTES, ROUTER_BASE],
28
+ },
29
+ ],
30
+ },
31
+ };
32
+ }
33
+ }
34
+
35
+ // Resolve which view a matched route should render: an explicit component, or
36
+ // the module's declared bootstrap component.
37
+ export function routeComponent(match: { route: Route }): any {
38
+ const { route } = match;
39
+ if (route.component) return route.component;
40
+ if (route.module) return getModuleMeta(route.module)?.bootstrap?.[0] ?? null;
41
+ return null;
42
+ }
@@ -0,0 +1,65 @@
1
+ import { createContext } from 'preact';
2
+ import type { FunctionComponent } from 'preact';
3
+ import { useContext } from 'preact/hooks';
4
+ import PRRouter from 'preact-router';
5
+ import type { RoutableProps } from 'preact-router';
6
+ import { useInjector, useService, InjectorContext } from '../context';
7
+ import { getModuleInjector } from '../module';
8
+ import { routeComponent } from './module';
9
+ import { Router } from './router';
10
+
11
+ /**
12
+ * Which depth of the matched chain this outlet renders — the root outlet
13
+ * is depth 0, an outlet inside a routed view is depth 1, and so on.
14
+ */
15
+ const OutletDepth = createContext(0);
16
+
17
+ /**
18
+ * The preact-router bridge: a mounted PR <Router> claiming every URL via a
19
+ * `default` sink child. Its existence makes preact-router's machinery live
20
+ * in this app — foreign `route()` calls and the global `<a href>` click
21
+ * interceptor resolve through it — and its onChange feeds our `url`
22
+ * signal, so navigations from ANY preact-routing code drive our router.
23
+ * The sink renders nothing; url/matches props die on the component.
24
+ */
25
+ const BridgeSink: FunctionComponent<RoutableProps> = () => null;
26
+
27
+ export function RouterOutlet() {
28
+ const router = useService(Router);
29
+ const parent = useInjector();
30
+ const depth = useContext(OutletDepth);
31
+ const matches = router.matches.value;
32
+ const match = matches?.[depth];
33
+ if (!match) {
34
+ // The outermost outlet keeps the bridge mounted even with no match, so
35
+ // route() interop survives 404s.
36
+ return depth === 0 ? (
37
+ <PRRouter onChange={(e) => router.syncUrl(e.url)}>
38
+ <BridgeSink default />
39
+ </PRRouter>
40
+ ) : null;
41
+ }
42
+
43
+ const Component = routeComponent(match);
44
+ if (!Component) return null;
45
+
46
+ // A route with `module` gets the module's injector — its providers are
47
+ // scoped to that feature. Nesting is automatic: a deeper outlet resolves
48
+ // its module under this level's injector.
49
+ const injector = match.route.module
50
+ ? getModuleInjector(match.route.module, parent)
51
+ : parent;
52
+
53
+ return (
54
+ <OutletDepth.Provider value={depth + 1}>
55
+ {depth === 0 && (
56
+ <PRRouter onChange={(e) => router.syncUrl(e.url)}>
57
+ <BridgeSink default />
58
+ </PRRouter>
59
+ )}
60
+ <InjectorContext.Provider value={injector}>
61
+ <Component />
62
+ </InjectorContext.Provider>
63
+ </OutletDepth.Provider>
64
+ );
65
+ }
@@ -0,0 +1,165 @@
1
+ import { computed, signal } from '@preact/signals';
2
+ import { route as prRoute } from 'preact-router';
3
+ import { Injectable } from '../decorators';
4
+ import { InjectionToken } from '../injector';
5
+
6
+ export interface Route {
7
+ path: string;
8
+ component?: any; // view function
9
+ module?: any; // module class — its bootstrap component renders inside its injector
10
+ children?: Route[];
11
+ }
12
+
13
+ export interface RouteMatch {
14
+ route: Route;
15
+ params: Record<string, string>;
16
+ }
17
+
18
+ export const ROUTES = new InjectionToken<Route[]>('ROUTES');
19
+ export const ROUTER_BASE = new InjectionToken<string>('ROUTER_BASE');
20
+
21
+ /**
22
+ * The directory the app's document lives at, e.g. '/demo/' when the page is
23
+ * served from `host/demo/index.html`. Deriving the base from baseURI (rather
24
+ * than baking it at build time) means the same bundle works under any mount
25
+ * path — GitHub Pages project dirs, reverse-proxy prefixes, iframed demos.
26
+ */
27
+ export function defaultBasePath(): string {
28
+ if (typeof document === 'undefined') return '/';
29
+ const p = new URL(document.baseURI).pathname;
30
+ return p.endsWith('/') ? p : p.slice(0, p.lastIndexOf('/') + 1);
31
+ }
32
+
33
+ /**
34
+ * Prefix match: consume the pattern's leading segments off `segs`, returning
35
+ * the captured params and the unconsumed tail. `'*'` consumes everything;
36
+ * `''` consumes nothing (an index route — leaf check decides validity).
37
+ */
38
+ function matchPrefix(
39
+ pattern: string,
40
+ segs: string[],
41
+ ): { params: Record<string, string>; rest: string[] } | null {
42
+ if (pattern === '*') return { params: {}, rest: [] };
43
+ const pSegs = pattern.split('/').filter(Boolean);
44
+ if (pSegs.length > segs.length) return null;
45
+ const params: Record<string, string> = {};
46
+ for (let i = 0; i < pSegs.length; i++) {
47
+ const p = pSegs[i];
48
+ const u = decodeURIComponent(segs[i]);
49
+ if (p.startsWith(':')) params[p.slice(1)] = u;
50
+ else if (p !== u) return null;
51
+ }
52
+ return { params, rest: segs.slice(pSegs.length) };
53
+ }
54
+
55
+ /**
56
+ * Match `segs` against a route list into a chain outermost → innermost.
57
+ * A route with `children` matches by prefix and the tail recurses; a leaf
58
+ * route must consume the whole tail. Parents may match alone when the tail
59
+ * is exhausted (Angular semantics: `/users` renders the parent shell even
60
+ * if it has children).
61
+ */
62
+ function matchSegments(routes: Route[], segs: string[]): RouteMatch[] | null {
63
+ for (const route of routes) {
64
+ const m = matchPrefix(route.path, segs);
65
+ if (m === null) continue;
66
+ if (route.children?.length) {
67
+ const child = matchSegments(route.children, m.rest);
68
+ if (child !== null) return [{ route, params: m.params }, ...child];
69
+ if (m.rest.length === 0) return [{ route, params: m.params }];
70
+ continue;
71
+ }
72
+ if (m.rest.length === 0) return [{ route, params: m.params }];
73
+ }
74
+ return null;
75
+ }
76
+
77
+ function matchRoutes(routes: Route[], url: string): RouteMatch[] | null {
78
+ const path = url.split('?')[0].split('#')[0];
79
+ return matchSegments(routes, path.split('/').filter(Boolean));
80
+ }
81
+
82
+ @Injectable()
83
+ export class Router {
84
+ /** Mount prefix, normalized to '' or '/demo' (no trailing slash). */
85
+ readonly base: string;
86
+ url = signal(typeof location === 'undefined' ? '/' : '');
87
+ /**
88
+ * The matched chain outermost → innermost — depth d is what the d-th
89
+ * nested <RouterOutlet/> renders.
90
+ */
91
+ matches = computed(() => matchRoutes(this.routes, this.url.value));
92
+ /** Back-compat: the chain's outermost match. */
93
+ match = computed(() => this.matches.value?.[0] ?? null);
94
+ /** Params merged across the whole chain (inner levels win on collision). */
95
+ params = computed(() =>
96
+ Object.assign({}, ...(this.matches.value ?? []).map((m) => m.params)),
97
+ );
98
+
99
+ constructor(
100
+ private routes: Route[],
101
+ base = defaultBasePath(),
102
+ ) {
103
+ this.base = base === '/' ? '' : base.replace(/\/+$/, '');
104
+ this.consumeShimParam();
105
+ if (typeof location !== 'undefined') {
106
+ this.url.value = this.strip(location.pathname) + location.search;
107
+ }
108
+ if (typeof window !== 'undefined') {
109
+ window.addEventListener('popstate', this.onPopState);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Static-host deep links: the site's 404 shim redirects
115
+ * `/docs/<app>/<rest>` to `<app>/?__pmvc_path=<rest>` so the app loads;
116
+ * restore the real URL via replaceState before seeding `url` — the
117
+ * browser history never sees the hop. No-op everywhere else.
118
+ */
119
+ private consumeShimParam(): void {
120
+ if (typeof location === 'undefined' || typeof history === 'undefined') return;
121
+ const rest = new URL(location.href).searchParams.get('__pmvc_path');
122
+ if (rest === null) return;
123
+ history.replaceState(null, '', this.base + '/' + rest);
124
+ }
125
+
126
+ private strip(path: string): string {
127
+ if (this.base && path.startsWith(this.base)) {
128
+ return path.slice(this.base.length) || '/';
129
+ }
130
+ return path;
131
+ }
132
+
133
+ /** Absolute href for an in-app path — what <a href> and pushState see. */
134
+ href(to: string): string {
135
+ return this.base + to;
136
+ }
137
+
138
+ /** Called by the preact-router bridge on any URL change it sees —
139
+ * foreign route() calls, its global <a> interceptor, popstate. */
140
+ syncUrl(fullUrl: string): void {
141
+ const [path, search = ''] = fullUrl.split('?');
142
+ const stripped = this.strip(path);
143
+ const next = stripped + (search ? `?${search}` : '');
144
+ if (next !== this.url.value) this.url.value = next;
145
+ }
146
+
147
+ private onPopState = () => {
148
+ this.syncUrl(location.pathname + location.search);
149
+ };
150
+
151
+ navigate(to: string): void {
152
+ // Through preact-router: updates every mounted <Router> in the tree —
153
+ // ours and foreign ones — not just our own subscriber. route() is a
154
+ // no-op when no mounted PR Router claims the url, so fall back to a
155
+ // manual push when the bridge isn't up yet.
156
+ if (!prRoute(this.base + to)) {
157
+ history.pushState(null, '', this.base + to);
158
+ this.url.value = to;
159
+ }
160
+ }
161
+
162
+ isActive(path: string): boolean {
163
+ return this.url.value === path;
164
+ }
165
+ }
package/src/signals.ts ADDED
@@ -0,0 +1,19 @@
1
+ // Re-export the signal primitives. Importing through here guarantees the
2
+ // @preact/signals integration (auto re-render on .value read during render)
3
+ // is installed before any view uses it.
4
+ import { effect as preactEffect } from '@preact/signals';
5
+ import { getCurrentInjector } from './injector';
6
+
7
+ export { signal, computed, batch, untracked } from '@preact/signals';
8
+ export type { Signal, ReadonlySignal } from '@preact/signals';
9
+
10
+ // Scope-bound effect: created during a class's construction (field
11
+ // initializers or constructor body), it registers on that scope's injector
12
+ // and is disposed automatically when the scope is deactivated — Angular's
13
+ // DestroyRef semantics. Outside an injection context it's the raw primitive:
14
+ // call the returned disposer yourself.
15
+ export function effect(fn: Parameters<typeof preactEffect>[0]): () => void {
16
+ const dispose = preactEffect(fn);
17
+ getCurrentInjector()?.registerCleanup(dispose);
18
+ return dispose;
19
+ }
package/src/view.ts ADDED
@@ -0,0 +1,153 @@
1
+ import { Component as PreactComponent, h, options } from 'preact';
2
+ import type { ComponentChildren, VNode } from 'preact';
3
+ import { useLayoutEffect, useRef } from 'preact/hooks';
4
+ import { InjectorContext, useInjector } from './context';
5
+ import { Injector } from './injector';
6
+ import { registerModule } from './module';
7
+ import { ownerOf } from './decorators';
8
+ import type { ComponentMeta } from './decorators';
9
+
10
+ // --- Class-based views -----------------------------------------------------
11
+ // A View subclass is never instantiated by Preact. The options.vnode hook
12
+ // below swaps any vnode whose type is a View subclass for a host function
13
+ // component, which constructs the class ONCE per mount inside a child
14
+ // injector — so `inject()` in field initializers resolves against that view's
15
+ // scope (component providers -> module providers -> root).
16
+ //
17
+ // render() runs on every render pass with no framework involvement: signals
18
+ // read inside it subscribe the host component automatically.
19
+
20
+ export abstract class View<P = {}> extends PreactComponent<P> {
21
+ abstract render(): ComponentChildren;
22
+
23
+ // Views are signal-driven; setState/forceUpdate would hit Preact internals
24
+ // on an instance Preact never mounted. Fail loudly instead of corrupting.
25
+ override setState(): never {
26
+ throw new Error('View components are signal-driven — assign a signal instead of calling setState()');
27
+ }
28
+ override forceUpdate(): never {
29
+ throw new Error('View components are signal-driven — assign a signal instead of calling forceUpdate()');
30
+ }
31
+ }
32
+
33
+ function isViewClass(t: any): boolean {
34
+ return typeof t === 'function' && t.prototype instanceof View;
35
+ }
36
+
37
+ // --- Error boundary ----------------------------------------------------------
38
+ // Real Preact class component (not a View — it must be instantiated by Preact).
39
+ // Wraps every view host so mount/render failures stay local instead of
40
+ // aborting the whole render pass.
41
+
42
+ class ViewErrorBoundary extends PreactComponent<
43
+ { viewName: string; children?: ComponentChildren },
44
+ { error: any }
45
+ > {
46
+ override state = { error: null as any };
47
+
48
+ override componentDidCatch(error: any) {
49
+ console.error(`[framework] ${this.props.viewName} failed:`, error);
50
+ this.setState({ error });
51
+ }
52
+
53
+ override render() {
54
+ const err = this.state.error;
55
+ if (!err) return this.props.children;
56
+ return h(
57
+ 'div',
58
+ {
59
+ style: {
60
+ border: '1px solid #e53935',
61
+ background: '#ffebee',
62
+ padding: '0.75rem',
63
+ borderRadius: '4px',
64
+ fontFamily: 'monospace',
65
+ fontSize: '0.85rem',
66
+ },
67
+ },
68
+ h('strong', null, `${this.props.viewName} failed to mount`),
69
+ h('pre', { style: { whiteSpace: 'pre-wrap', margin: '0.5rem 0' } }, String(err?.message ?? err)),
70
+ h('button', { onClick: () => this.setState({ error: null }) }, 'Retry')
71
+ );
72
+ }
73
+ }
74
+
75
+ // --- Host -------------------------------------------------------------------
76
+
77
+ const hostCache = new Map<any, any>();
78
+
79
+ function hostFor(cls: any): any {
80
+ let host = hostCache.get(cls);
81
+ if (host) return host;
82
+
83
+ const meta = (cls.__component ?? {}) as ComponentMeta;
84
+
85
+ const inner = function ViewHost(props: any) {
86
+ const parent = useInjector();
87
+ const ref = useRef<{ inj: Injector; inst: any } | null>(null);
88
+ if (!ref.current) {
89
+ // Module ownership: a view declared in a module's components/bootstrap
90
+ // may only mount where that module's injector is an ancestor — Angular's
91
+ // declaration rule enforced at mount. Undeclared views mount freely.
92
+ const owner = ownerOf(cls);
93
+ if (owner) {
94
+ let node: Injector | null = parent;
95
+ while (node && !node.modules.has(owner)) node = node.parent;
96
+ if (!node) {
97
+ throw new Error(
98
+ `${cls.name} is declared by ${owner.name ?? 'a module'} but mounted outside its injector scope. ` +
99
+ `Mount it under a route/module that includes ${owner.name ?? 'that module'}.`
100
+ );
101
+ }
102
+ }
103
+ const inj = new Injector(parent, `view ${cls.name}`);
104
+ // Standalone scope: imported modules contribute providers (and mark
105
+ // themselves visible for ownership checks); view-level providers and
106
+ // declared controllers register on top, closest-scope-wins.
107
+ const seen = new Set<any>();
108
+ for (const imp of meta.imports ?? []) registerModule(imp, inj, seen);
109
+ for (const p of meta.providers ?? []) inj.provide(p);
110
+ for (const c of meta.controllers ?? []) inj.provide(c);
111
+ ref.current = { inj, inst: inj.instantiate(cls, props) };
112
+ }
113
+ const { inj, inst } = ref.current;
114
+ inst.props = props;
115
+ // Mount activates the whole view scope: onInit for the view and every
116
+ // controller/provider its injector constructed; unmount fires onDestroy.
117
+ // useLayoutEffect (synchronous at commit) is deliberate: deferred
118
+ // useEffect callbacks are dropped if the host unmounts before they flush,
119
+ // which would silently lose onInit on same-tick mount+navigate.
120
+ useLayoutEffect(() => {
121
+ inj.activate();
122
+ return () => inj.deactivate();
123
+ }, []);
124
+ // The view's injector becomes the scope for everything it renders, so
125
+ // child components resolve through it — the element-injector hierarchy.
126
+ return h(InjectorContext.Provider, { value: inj }, inst.render());
127
+ };
128
+ (inner as any).displayName = cls.name;
129
+
130
+ host = function BoundedView(props: any) {
131
+ return h(ViewErrorBoundary, { viewName: cls.name }, h(inner, props));
132
+ };
133
+ (host as any).displayName = cls.name;
134
+ hostCache.set(cls, host);
135
+ return host;
136
+ }
137
+
138
+ let installed = false;
139
+
140
+ // Install the vnode interceptor. Must run before any view class is rendered;
141
+ // called on module load and again defensively from bootstrap().
142
+ export function installViewInterop(): void {
143
+ if (installed) return;
144
+ installed = true;
145
+ const prev = (options as any).vnode as ((vnode: VNode) => void) | undefined;
146
+ (options as any).vnode = (vnode: VNode) => {
147
+ const t = vnode.type;
148
+ if (isViewClass(t)) vnode.type = hostFor(t);
149
+ prev?.(vnode);
150
+ };
151
+ }
152
+
153
+ installViewInterop();
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.