Skip to content

Commit f3757aa

Browse files
committed
fix(core): share component registry across module instances
When the cli is installed globally (or hoisted at a different level than the user's app), Node resolves `@webjskit/core` from each importer's location and may load TWO instances of the package — one for `@webjskit/server` (server-side) and one for the user's app modules. Each instance had its own private `registry` Map, so: user-app: ThemeToggle.register('theme-toggle') → instance-A registry server: lookup('theme-toggle') → instance-B registry (empty) SSR's `injectDSD` then skipped the tag and emitted bare `<theme-toggle></theme-toggle>` even though the user registered it correctly. The component appeared empty in the SSR'd HTML and only "showed up" after browser hydration. Fix: key the registry Map and class→tag WeakMap off `globalThis` with `Symbol.for('webjs:registry')` / `Symbol.for('webjs:classToTag')`. Every instance of the registry module now finds the same maps regardless of how many copies of `@webjskit/core` are loaded. Adds a regression test (`test/registry.test.js`) that loads the registry module twice from distinct file URLs (simulating the dual- install scenario) and asserts a registration in instance A is visible to instance B's `allTags()`, `lookup()`, and `tagOf()`. Versions: - `@webjskit/core`: 0.1.0 → 0.2.0 - `@webjskit/server`: 0.2.0 → 0.2.1 (core pin updated) - `@webjskit/cli`: 0.2.0 → 0.2.1 (server pin updated)
1 parent 77f9e8e commit f3757aa

6 files changed

Lines changed: 96 additions & 12 deletions

File tree

package-lock.json

Lines changed: 23 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@webjskit/cli",
3-
"version": "0.2.0",
3+
"version": "0.2.1",
44
"type": "module",
55
"description": "webjs CLI — dev, start, create, db",
66
"bin": {
@@ -13,7 +13,7 @@
1313
"README.md"
1414
],
1515
"dependencies": {
16-
"@webjskit/server": "0.2.0"
16+
"@webjskit/server": "0.2.1"
1717
},
1818
"publishConfig": {
1919
"access": "public"

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@webjskit/core",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"type": "module",
55
"description": "webjs core runtime — html/css tags, WebComponent base, isomorphic renderers",
66
"types": "./index.d.ts",

packages/core/src/registry.js

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,38 @@
1717
* @typedef {{ cls: typeof import('./component.js').WebComponent, moduleUrl: string | null, lazy: boolean, tag: string }} RegistryEntry
1818
*/
1919

20+
/* ------------------------------------------------------------------
21+
* Shared registry on globalThis.
22+
*
23+
* In a typical install, the dev server (`@webjskit/server`) and the
24+
* user's app modules each import `@webjskit/core`. When the cli is
25+
* installed globally (or hoisted at a different level than the user's
26+
* app), Node resolves the bare specifier from each importer's location
27+
* and may end up loading TWO instances of `@webjskit/core` — one for
28+
* the server, one for the user's app. Each instance would otherwise
29+
* have its own private `registry` Map, so:
30+
*
31+
* user-app: ThemeToggle.register('theme-toggle') → instance-A registry
32+
* server: lookup('theme-toggle') → instance-B registry (empty)
33+
*
34+
* SSR's `injectDSD` would then skip the tag and emit bare
35+
* `<theme-toggle></theme-toggle>` even though the user registered it
36+
* correctly. Symptom: components render with no children server-side
37+
* and only "appear" after the browser hydrates.
38+
*
39+
* We side-step this by keying both maps off `globalThis` with a stable
40+
* `Symbol.for(...)`. Every loaded instance of this module finds the
41+
* same maps regardless of which copy of `@webjskit/core` it lives in.
42+
* @ts-ignore */
43+
const REGISTRY_KEY = Symbol.for('webjs:registry');
44+
/** @ts-ignore */
45+
const CLASS_TO_TAG_KEY = Symbol.for('webjs:classToTag');
46+
47+
const _g = /** @type {any} */ (globalThis);
2048
/** @type {Map<string, RegistryEntry>} */
21-
const registry = new Map();
49+
const registry = _g[REGISTRY_KEY] || (_g[REGISTRY_KEY] = new Map());
2250
/** @type {WeakMap<Function, string>} */
23-
const classToTag = new WeakMap();
51+
const classToTag = _g[CLASS_TO_TAG_KEY] || (_g[CLASS_TO_TAG_KEY] = new WeakMap());
2452

2553
const isBrowser =
2654
typeof window !== 'undefined' && typeof customElements !== 'undefined';

packages/server/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@webjskit/server",
3-
"version": "0.2.0",
3+
"version": "0.2.1",
44
"type": "module",
55
"description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
66
"main": "index.js",
@@ -14,7 +14,7 @@
1414
"README.md"
1515
],
1616
"dependencies": {
17-
"@webjskit/core": "0.1.0",
17+
"@webjskit/core": "0.2.0",
1818
"chokidar": "^3.6.0",
1919
"esbuild": "^0.28.0",
2020
"superjson": "^2.2.6",

test/registry.test.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,41 @@ test('primeModuleUrl: creates an entry with no cls when called before register()
9999
// lookupModuleUrl via lookup+allTags shouldn't crash.
100100
assert.ok(allTags().includes('rx-prime'));
101101
});
102+
103+
test('registry is shared across module instances via globalThis (dual-instance bug)', async () => {
104+
// Two different file URLs for the same registry source. When `@webjskit/core`
105+
// is installed twice (e.g. globally + locally), Node loads it as two distinct
106+
// module instances. Each instance must still see the same registry, otherwise
107+
// a component registered in one instance is invisible to lookups in the other
108+
// (the bug that caused SSR-bare custom elements when the cli was global).
109+
const { mkdtempSync, rmSync, copyFileSync } = await import('node:fs');
110+
const { tmpdir } = await import('node:os');
111+
const { join } = await import('node:path');
112+
const { pathToFileURL } = await import('node:url');
113+
114+
const dir = mkdtempSync(join(tmpdir(), 'webjs-registry-dual-'));
115+
try {
116+
const src = new URL('../packages/core/src/registry.js', import.meta.url);
117+
const copyA = join(dir, 'registry-a.js');
118+
const copyB = join(dir, 'registry-b.js');
119+
copyFileSync(src, copyA);
120+
copyFileSync(src, copyB);
121+
122+
const a = await import(pathToFileURL(copyA).href);
123+
const b = await import(pathToFileURL(copyB).href);
124+
125+
assert.notEqual(a, b, 'two distinct module instances expected');
126+
127+
class Shared {}
128+
a.register('rx-shared-tag', Shared);
129+
130+
// Both instances must agree the tag is registered.
131+
assert.ok(a.allTags().includes('rx-shared-tag'));
132+
assert.ok(b.allTags().includes('rx-shared-tag'),
133+
'instance B must see registrations made via instance A — registry is shared');
134+
assert.equal(b.lookup('rx-shared-tag'), Shared);
135+
assert.equal(b.tagOf(Shared), 'rx-shared-tag');
136+
} finally {
137+
rmSync(dir, { recursive: true, force: true });
138+
}
139+
});

0 commit comments

Comments
 (0)