{"_id":"@3sln/cerp","name":"@3sln/cerp","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"@3sln/cerp","version":"0.1.0","description":"Hot patchable custom element definitions.","main":"index.js","module":"index.js","type":"module","sideEffects":false,"author":{"name":"Ray Stubbs"},"repository":{"type":"git","url":"git+https://github.com/3sln/cerp.git"},"license":"MIT","keywords":["custom elements","customElements","web components","hot reload","hmr","hot module replacement"],"exports":{".":"./index.js","./package.json":"./package.json"},"devDependencies":{"@types/bun":"latest","@web/test-runner":"^1.0.0","@web/test-runner-playwright":"^1.0.0","chai":"^6.2.2","playwright":"^1.62.1"},"publishConfig":{"access":"public"},"scripts":{"test":"web-test-runner","test:watch":"web-test-runner --watch","test:coverage":"web-test-runner --coverage"},"_id":"@3sln/cerp@0.1.0","gitHead":"3ebc3d0f8a29df65bfbe9d013e9d66e6e862952c","bugs":{"url":"https://github.com/3sln/cerp/issues"},"homepage":"https://github.com/3sln/cerp#readme","_nodeVersion":"22.23.1","_npmVersion":"10.9.8","dist":{"integrity":"sha512-ZbZdpM54pcJ9hw/1DowhuPp43zgLPJ/kSoVx3upBjVPJcFfRGZc1kgv2LEp0+G35MQJgotZwPqKkLzlRhxqFfw==","shasum":"962f57518ea92d4702433cc3d65dbe6715e88a15","tarball":"https://registry.npmjs.org/@3sln/cerp/-/cerp-0.1.0.tgz","fileCount":4,"unpackedSize":28400,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@3sln%2fcerp@0.1.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIBEHRtnxIi+MVIA8tAvMHvZtLBTORw6kdKUvguzA4BcgAiAPoeBDJ4qeR5D7A2WlpTHQtksSEHlp1+KV6stxp9rckw=="}]},"_npmUser":{"name":"ray.3sln","email":"contact+npm@3sln.com"},"directories":{},"maintainers":[{"name":"ray.3sln","email":"contact+npm@3sln.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/cerp_0.1.0_1785874622525_0.1296753326444875"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-04T20:17:02.346Z","0.1.0":"2026-08-04T20:17:02.669Z","modified":"2026-08-04T20:17:03.234Z"},"maintainers":[{"name":"ray.3sln","email":"contact+npm@3sln.com"}],"description":"Hot patchable custom element definitions.","homepage":"https://github.com/3sln/cerp#readme","keywords":["custom elements","customElements","web components","hot reload","hmr","hot module replacement"],"repository":{"type":"git","url":"git+https://github.com/3sln/cerp.git"},"author":{"name":"Ray Stubbs"},"bugs":{"url":"https://github.com/3sln/cerp/issues"},"license":"MIT","readme":"# cerp\n\n> Custom Element Reloadable Prototypes\n\nA small library for defining custom elements from a plain descriptor instead of\na class, so a definition can be patched in place while the page is running —\nconstructor included.\n\n```javascript\n// x-counter.js\nimport cerp from '@3sln/cerp';\n\nconst reg = cerp({hotReload: import.meta.env.DEV});\n\nexport const definition = {\n  attrs: ['count'],\n  shadow: {mode: 'open'},\n\n  proto: {\n    get count() {\n      return Number(this.getAttribute('count') ?? 0);\n    },\n    render() {\n      this.shadowRoot.textContent = `count: ${this.count}`;\n    },\n  },\n\n  init() {\n    this.render();\n  },\n\n  connected(signal) {\n    this.addEventListener('click', () => this.setAttribute('count', this.count + 1), {signal});\n  },\n\n  attr() {\n    this.render();\n  },\n\n  reload() {\n    this.render();\n  },\n};\n\nconst counter = reg.define('x-counter', definition);\n\nif (import.meta.hot) {\n  import.meta.hot.accept(module => counter.update(module.definition));\n}\n```\n\nEdit `render` and save: every `<x-counter>` on the page redraws, keeping its\nshadow root, its count and its click listener. Edit `init` and the next one\nconstructed runs the new version.\n\n### Why a descriptor and not a class\n\nThe browser reads a custom element's constructor, `observedAttributes` and\n`formAssociated` once, when the name is defined, and never looks at them again.\nA name can be defined once per registry and never taken back.\n\nSo a library that hands the browser *your* class can never replace your\nconstructor. It can swap a prototype underneath the instances that already\nexist, but every instance created after that still runs the constructor from\nwhichever version of the module happened to load first — a shadow root built\nthere, fields initialised there, a subscription opened there, all frozen at the\nfirst version for the life of the page.\n\ncerp hands the browser a class of its own, once, and keeps your implementation\non a prototype it can rewrite. The constructor the browser holds is a fixed\nshim that calls into the current descriptor, so `init` is genuinely replaceable.\n\nIt also means cerp never touches `window.customElements`. It registers through\nit like any other caller, so `whenDefined`, `upgrade`, `getName` and everything\nelse keep working.\n\n### The registry\n\n```javascript\nconst reg = cerp({\n  // Patch definitions in place when a name is defined twice. Off by default:\n  // it costs an instance registry and a MutationObserver per element, and\n  // moves attribute callbacks off the browser's synchronous reaction queue.\n  hotReload: false,\n\n  // Hold `disconnected` back by a task so a reordering move does not read as\n  // a disconnection. On by default.\n  delayDisconnect: true,\n\n  // The realm to define in. Defaults to the current global, and to that\n  // realm's own registry — pass either to define into an iframe.\n  window: globalThis,\n  registry: undefined,\n\n  warn: message => console.warn(`cerp: ${message}`),\n});\n```\n\n`reg.define(name, descriptor)` returns a handle: `{name, Element, update}`.\n`update(descriptor)` re-patches the definition and reads better from an HMR\ncallback than a second `define` does. Both do the same thing. With `hotReload`\noff, redefining a name throws rather than silently doing nothing.\n\n`reg.get(name)` returns the descriptor currently in force, or `undefined` if\nthis registry did not define the name.\n\n### The descriptor\n\n| field                                   | when it applies                                         |\n| --------------------------------------- | ------------------------------------------------------- |\n| `proto`                                 | reconciled on every define                              |\n| `attrs`                                 | reconciled on every define (fixed without `hotReload`)  |\n| `init(signal)`                          | once per instance, in the constructor                   |\n| `reload(previous, signal)`              | on every live instance when the definition is patched   |\n| `connected(signal)`                     | on connection, and again after a reload                 |\n| `disconnected()`                        | on disconnection                                        |\n| `moved()`                               | on a move, native or emulated                           |\n| `adopted()`                             | on adoption into another document                       |\n| `attr(name, oldValue, value, namespace)`| on a change to an observed attribute                    |\n| `extends`                               | fixed at first define                                   |\n| `shadow`                                | fixed at first define                                   |\n| `internals`                             | fixed at first define                                   |\n| `formAssociated`                        | fixed at first define                                   |\n\n`this` is the element in every hook and every `proto` member.\n\n`extends` names a built-in tag, for a customized built-in — `{extends: 'button'}`\nis created as `document.createElement('button', {is: 'x-name'})`. Safari does\nnot implement customized built-ins at all.\n\n`shadow` takes the options `attachShadow` takes; the root is attached before\n`init` runs, so `this.shadowRoot` is already there. `internals: true` attaches\n`ElementInternals`, reachable as `element[internals]` using the exported symbol.\nBoth throw on a second call, which is why cerp owns them and neither can be\nreloaded.\n\n`proto` holds getters, setters and methods, and is merged by property\ndescriptor — an accessor stays an accessor and is not invoked while copying. A\nmember dropped from the descriptor is deleted from the prototype; a member that\ndid not change keeps its identity. Names the browser reserves for reactions\n(`connectedCallback` and friends) are refused with a warning, since cerp\ndefines those itself and yours would never be called.\n\nOne caution: do not build a descriptor by spreading a previous one. Spread\n*reads* the source, so a `proto` getter would run against a plain object and\neither throw or land as a frozen value. Hand `update` a whole descriptor — which\nis what a reloaded module gives you anyway.\n\n### Hot reloading\n\n`init` runs **once per instance**, in the constructor, and is never re-run.\nWhen a definition is patched, live instances get `reload(previous, signal)`\ninstead — that is where a new version reconciles whatever the old one left\nbehind. New instances created afterwards run the new `init` normally.\n\nTeardown is by `AbortSignal`, not by a cleanup hook. Every hook that can run\nmore than once is handed a signal that is aborted before its replacement runs,\nso anything registered against it unwinds by itself:\n\n```javascript\nconnected(signal) {\n  window.addEventListener('resize', () => this.render(), {signal});\n\n  const observer = new ResizeObserver(() => this.render());\n  observer.observe(this);\n  signal.addEventListener('abort', () => observer.disconnect());\n}\n```\n\nThere are two scopes, each also reachable from `proto` members as a\nsymbol-keyed property:\n\n- **`signal`** — the connection. Aborted when the element disconnects, and\n  replaced on reload just before `connected` runs again. `element[signal]`.\n- **`instanceSignal`** — the instance. Aborted and replaced when the definition\n  reloads, just before `reload` runs. `element[instanceSignal]`.\n\n```javascript\nimport cerp, {signal, instanceSignal, internals} from '@3sln/cerp';\n\n// inside a proto method\nthis[signal].addEventListener('abort', () => subscription.close());\n```\n\nA reload re-runs `connected` for anything currently connected. That is not\nceremony: a listener registered as `this.onClick.bind(this)` captured the old\nmethod, and no amount of prototype reconciliation reaches inside a bound\nfunction.\n\nBecause `delayDisconnect` treats a reordering move as no disconnection at all,\na moved element keeps its connection signal — listeners survive reordering\nrather than being torn down and rebuilt on every move.\n\n### What cannot be hot reloaded\n\n`extends`, `shadow`, `internals` and `formAssociated` are fixed when the\nbrowser first defines the name. Changing one in a later descriptor warns and\ndoes nothing until the page is reloaded — the alternative is a library that\nreports `formAssociated: true` while `attachInternals().form` throws.\n\n`attrs` is the exception: with `hotReload` on it can be widened or narrowed\nfreely, and an attribute that becomes observed is backfilled with the value it\nalready holds, the way the browser does at upgrade. That works because in\nhot reload mode attribute changes come from a `MutationObserver` rather than\nfrom the browser's reaction queue, which makes them **asynchronous**. With\n`hotReload` off they come from the reaction queue and are synchronous, and\n`attrs` is fixed at define. Both paths report the same four arguments.\n\n### Development\n\n```\nbun install\nbun run test\n```\n\nThe suite runs in headless Chromium through\n[`@web/test-runner`](https://modern-web.dev/docs/test-runner/overview/), not\nagainst a DOM emulation. cerp is built entirely out of one specific piece of\nbrowser machinery and almost nothing it does means anything away from it:\nwhether a definition may be replaced, when the browser snapshots\n`observedAttributes` and `formAssociated`, whether `attributeChangedCallback`\nfires synchronously and with how many arguments, and what order the custom\nelement reaction queue drains a move in are the behaviours under test. An\nemulator supplies its own answers to those.\n\nEach test takes a fresh realm from `createRealm()` in `test-helpers.js` — an\niframe, and so an empty `CustomElementRegistry`, since a custom element name can\nbe defined once per registry and never taken back.\n\n`bun run test:watch` reruns on change, `bun run test:coverage` reports coverage.\n","readmeFilename":"README.md","_rev":"1-e3957877de6d1008a369b41a709f0943"}