From c120f29cc92e056f6e1a20627e944263b06a662c Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 29 Apr 2024 15:03:13 +0200 Subject: [PATCH] feat(react): implement plugin infrastructure and move react support to a plugin --- oranda.json | 3 +- package.json | 11 +- packages/actions/package.json | 37 +--- packages/actions/src/actions.ts | 54 ++---- packages/actions/src/index.ts | 1 + packages/actions/src/morph.ts | 27 +-- packages/actions/src/plugin.ts | 8 + packages/actions/src/schema.ts | 4 - packages/actions/vite.config.ts | 2 +- packages/react/README.md | 25 +++ packages/react/package.json | 22 +-- .../src/actions-react-plugin.test.tsx} | 87 +++++---- packages/react/src/container.test.tsx | 62 ------ packages/react/src/index.ts | 5 +- packages/react/src/plugin.ts | 55 ++++++ .../react/src/react-tree-builder.test.tsx | 76 ++++++-- packages/react/src/react-tree-builder.ts | 122 ++++++------ packages/react/src/root.test.tsx | 58 ++++++ packages/react/src/{container.ts => root.ts} | 159 ++++++++-------- pnpm-lock.yaml | 180 +++++++++--------- 20 files changed, 538 insertions(+), 460 deletions(-) create mode 100644 packages/actions/src/plugin.ts create mode 100644 packages/react/README.md rename packages/{actions/src/actions-container.test.tsx => react/src/actions-react-plugin.test.tsx} (78%) delete mode 100644 packages/react/src/container.test.tsx create mode 100644 packages/react/src/plugin.ts create mode 100644 packages/react/src/root.test.tsx rename packages/react/src/{container.ts => root.ts} (61%) diff --git a/oranda.json b/oranda.json index ad072f2..80ea515 100644 --- a/oranda.json +++ b/oranda.json @@ -5,7 +5,8 @@ "build": { "path_prefix": "coldwired", "additional_pages": { - "Actions": "./packages/actions/README.md" + "Actions": "./packages/actions/README.md", + "React": "./packages/react/README.md" } }, "components": { diff --git a/package.json b/package.json index d757bcd..93be592 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,10 @@ "description": "An implementation of @hotwired/turbo based on remix and morphdom", "license": "MIT", "repository": "tchak/coldwired", - "keywords": ["turbo", "stimulus"], + "keywords": [ + "turbo", + "stimulus" + ], "scripts": { "turbo": "turbo run test lint build", "build": "turbo run build --force", @@ -19,7 +22,7 @@ "devDependencies": { "@axodotdev/oranda": "^0.6.1", "@changesets/cli": "^2.27.1", - "@testing-library/dom": "^10.0.0", + "@testing-library/dom": "^10.1.0", "@typescript-eslint/eslint-plugin": "^7.7.1", "@typescript-eslint/parser": "^7.7.1", "@vitest/browser": "^1.5.2", @@ -31,8 +34,8 @@ "npm-run-all": "^4.1.5", "playwright": "^1.43.1", "prettier": "^3.2.5", - "rollup": "^4.16.4", - "turbo": "^1.13.2", + "rollup": "^4.17.1", + "turbo": "^1.13.3", "typescript": "^5.4.5", "vite": "^5.2.10", "vitest": "^1.5.2" diff --git a/packages/actions/package.json b/packages/actions/package.json index 5b34bb3..9c61403 100644 --- a/packages/actions/package.json +++ b/packages/actions/package.json @@ -2,9 +2,7 @@ "name": "@coldwired/actions", "description": "DOM manipulation actions based on morphdom", "license": "MIT", - "files": [ - "dist" - ], + "files": ["dist"], "main": "./dist/index.cjs.js", "module": "./dist/index.es.js", "types": "./dist/types/index.d.ts", @@ -16,9 +14,7 @@ }, "type": "module", "version": "0.12.2", - "keywords": [ - "turbo" - ], + "keywords": ["turbo"], "scripts": { "build": "run-s clean build:*", "build:vite": "vite build", @@ -38,6 +34,9 @@ "@coldwired/utils": "^0.12.0", "morphdom": "^2.7.2" }, + "devDependencies": { + "tiny-invariant": "^1.3.2" + }, "engines": { "node": ">=16" }, @@ -49,24 +48,15 @@ "eslintConfig": { "root": true, "parser": "@typescript-eslint/parser", - "plugins": [ - "@typescript-eslint" - ], - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "prettier" - ], + "plugins": ["@typescript-eslint"], + "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], "rules": { "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-redeclare": "off" }, "overrides": [ { - "files": [ - "vite.config.js", - "vitest.config.ts" - ], + "files": ["vite.config.js", "vitest.config.ts"], "env": { "node": true } @@ -84,16 +74,5 @@ "github": { "release": true } - }, - "peerDependencies": { - "@coldwired/react": "^0.12.2" - }, - "devDependencies": { - "@coldwired/react": "^0.12.2", - "@types/react": "^18.2.45", - "@types/react-dom": "^18.2.18", - "react": "^18.0.0", - "react-dom": "^18.0.0", - "tiny-invariant": "^1.3.2" } } diff --git a/packages/actions/src/actions.ts b/packages/actions/src/actions.ts index 6fd9d7f..91f6bd8 100644 --- a/packages/actions/src/actions.ts +++ b/packages/actions/src/actions.ts @@ -12,13 +12,13 @@ import { focusNextElement, parseHTMLFragment, } from '@coldwired/utils'; -import type { Container, ContainerOptions } from '@coldwired/react'; import { ClassListObserver, ClassListObserverDelegate } from './class-list-observer'; import { AttributeObserver, AttributeObserverDelegate } from './attribute-observer'; import { Metadata } from './metadata'; import { morph } from './morph'; import { Schema, defaultSchema } from './schema'; +import type { Plugin } from './plugin'; const voidActionNames = ['remove', 'focus', 'enable', 'disable', 'hide', 'show'] as const; const fragmentActionNames = ['after', 'before', 'append', 'prepend', 'replace', 'update'] as const; @@ -55,9 +55,9 @@ type PinnedAction = export type ActionsOptions = { element?: Element; - schema?: Schema; + schema?: Partial; debug?: boolean; - container?: Omit; + plugins?: Plugin[]; }; export class Actions { @@ -69,8 +69,7 @@ export class Actions { #metadata = new Metadata(); #controller = new AbortController(); - #containerOptions?: Omit; - #container?: Container; + #plugins: Plugin[] = []; #pending = new Set>(); #pinned = new Map(); @@ -81,6 +80,7 @@ export class Actions { this.#element = options?.element ?? document.documentElement; this.#schema = { ...defaultSchema, ...options?.schema }; this.#debug = options?.debug ?? false; + this.#plugins = options?.plugins ?? []; this.#delegate = { handleEvent: this.handleEvent.bind(this), classListChanged: this.classListChanged.bind(this), @@ -88,43 +88,28 @@ export class Actions { }; this.#classListObserver = new ClassListObserver(this.#element, this.#delegate); this.#attributeObserver = new AttributeObserver(this.#element, this.#delegate); - this.#containerOptions = options?.container; } async ready() { - await Promise.all(this.#pending); + const pendingPlugins = this.#plugins.map((plugin) => plugin.ready()); + await Promise.all([...this.#pending, ...pendingPlugins]); } get element() { return this.#element; } + get plugins() { + return this.#plugins; + } + observe() { this.#classListObserver.observe(); this.#attributeObserver.observe(); this.#element.addEventListener('input', this.#delegate); this.#element.addEventListener('change', this.#delegate); this.#element.addEventListener(ACTIONS_EVENT_TYPE, this.#delegate); - } - - async mount(root: Element, Layout?: Parameters[1]) { - if (!this.#containerOptions) { - throw new Error('No container provided'); - } - const mod = await import('@coldwired/react'); - const container = mod.createContainer({ - fragmentTagName: this.#schema.fragmentTagName, - loadingClassName: this.#schema.loadingClassName, - ...this.#containerOptions, - }); - await this.ready(); - await container.mount(root, Layout); - await container.render(this.#element); - this.#container = container; - } - - get container() { - return this.#container; + this.#plugins.forEach((plugin) => plugin.init(this.#element)); } disconnect() { @@ -142,8 +127,6 @@ export class Actions { this.#pending.clear(); this.#pinned.clear(); this.#metadata.clear(); - this.#container?.destroy(); - this.#container = undefined; } applyActions(actions: (Action | MaterializedAction)[]) { @@ -289,15 +272,14 @@ export class Actions { private _applyActions(actions: MaterializedAction[]) { this._debugApplyActions(actions); for (const action of actions) { - if ( - this.#container && - action.targets.some((element) => this.#container?.isInsideFragment(element)) - ) { - throw new Error('Cannot apply action inside fragment'); - } if (isFragmentAction(action)) { this[`_${action.action}`](action); } else { + if (action.action != 'focus') { + action.targets.forEach((element) => { + this.#plugins.forEach((plugin) => plugin.validate?.(element)); + }); + } this[`_${action.action}`](action); } } @@ -354,7 +336,7 @@ export class Actions { ) { morph(from, to, { metadata: this.#metadata, - container: this.#container, + plugins: this.#plugins, ...this.#schema, ...options, }); diff --git a/packages/actions/src/index.ts b/packages/actions/src/index.ts index fb05fc7..ed07253 100644 --- a/packages/actions/src/index.ts +++ b/packages/actions/src/index.ts @@ -1,5 +1,6 @@ export * from './actions'; export * from './schema'; +export * from './plugin'; import { dispatchAction } from './actions'; diff --git a/packages/actions/src/morph.ts b/packages/actions/src/morph.ts index ced37f7..0168b98 100644 --- a/packages/actions/src/morph.ts +++ b/packages/actions/src/morph.ts @@ -13,15 +13,15 @@ import { focusNextElement, type FocusNextOptions, } from '@coldwired/utils'; -import type { Container } from '@coldwired/react'; import { Metadata } from './metadata'; +import { Plugin } from './plugin'; type MorphOptions = FocusNextOptions & { childrenOnly?: boolean; forceAttribute?: string; metadata?: Metadata; - container?: Container; + plugins?: Plugin[]; }; export function morph( @@ -29,6 +29,8 @@ export function morph( toElementOrDocument: string | Element | Document | DocumentFragment, options?: MorphOptions, ) { + options?.plugins?.forEach((plugin) => plugin.validate?.(fromElementOrDocument)); + if (fromElementOrDocument instanceof Document) { invariant(toElementOrDocument instanceof Document, 'Cannot morph document to element'); morphDocument(fromElementOrDocument, toElementOrDocument, options); @@ -56,9 +58,10 @@ function morphToDocumentFragment( toDocumentFragment.normalize(); if (options?.childrenOnly) { - if (options.container?.isFragment(fromElement)) { - options.container.render(fromElement, toDocumentFragment); - } else { + const pluginRendered = options?.plugins?.some((plugin) => + plugin.onBeforeUpdateElement?.(fromElement, toDocumentFragment), + ); + if (!pluginRendered) { const wrapper = toDocumentFragment.ownerDocument.createElement('div'); wrapper.append(toDocumentFragment); morphToElement(fromElement, wrapper, options); @@ -89,15 +92,13 @@ function morphToDocumentFragment( function morphToElement(fromElement: Element, toElement: Element, options?: MorphOptions): void { const forceAttribute = options?.forceAttribute; - if (options?.container?.isInsideFragment(fromElement)) { - throw new Error('Cannot morph element inside fragment'); - } - morphdom(fromElement, toElement, { childrenOnly: options?.childrenOnly, onBeforeElUpdated(fromElement, toElement) { - if (options?.container?.isFragment(fromElement)) { - options.container.render(fromElement, toElement); + const pluginRendered = options?.plugins?.some((plugin) => + plugin.onBeforeUpdateElement?.(fromElement, toElement), + ); + if (pluginRendered) { return false; } @@ -155,14 +156,14 @@ function morphToElement(fromElement: Element, toElement: Element, options?: Morp }, onBeforeNodeDiscarded(node) { if (isElement(node)) { + options?.plugins?.forEach((plugin) => plugin.onBeforeDestroyElement?.(node)); focusNextElement(node, options); - options?.container?.remove(node); } return true; }, onNodeAdded(node) { if (isElement(node)) { - options?.container?.render(node); + options?.plugins?.forEach((plugin) => plugin.onCreateElement?.(node)); } return node; }, diff --git a/packages/actions/src/plugin.ts b/packages/actions/src/plugin.ts new file mode 100644 index 0000000..ef3641c --- /dev/null +++ b/packages/actions/src/plugin.ts @@ -0,0 +1,8 @@ +export interface Plugin { + ready(): Promise; + init(element: Element): void; + validate?(element: Element | Document): void; + onCreateElement?(element: Element): boolean; + onBeforeUpdateElement?(element: Element, toElement: Element | DocumentFragment): boolean; + onBeforeDestroyElement?(element: Element): boolean; +} diff --git a/packages/actions/src/schema.ts b/packages/actions/src/schema.ts index 62b114d..36718d0 100644 --- a/packages/actions/src/schema.ts +++ b/packages/actions/src/schema.ts @@ -1,17 +1,13 @@ export type Schema = { - fragmentTagName: string; forceAttribute: string; focusGroupAttribute: string; focusDirectionAttribute: string; hiddenClassName: string; - loadingClassName: string; }; export const defaultSchema: Schema = { - fragmentTagName: 'turbo-fragment', forceAttribute: 'data-turbo-force', focusGroupAttribute: 'data-turbo-focus-group', focusDirectionAttribute: 'data-turbo-focus-direction', hiddenClassName: 'hidden', - loadingClassName: 'loading', }; diff --git a/packages/actions/vite.config.ts b/packages/actions/vite.config.ts index c22caa5..f0b64fe 100644 --- a/packages/actions/vite.config.ts +++ b/packages/actions/vite.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ formats: ['cjs', 'es'], }, rollupOptions: { - external: ['@coldwired/utils', '@coldwired/react', 'morphdom'], + external: ['@coldwired/utils', 'morphdom'], }, }, }); diff --git a/packages/react/README.md b/packages/react/README.md new file mode 100644 index 0000000..85eeecb --- /dev/null +++ b/packages/react/README.md @@ -0,0 +1,25 @@ +# @coldwired/react [![npm package][npm-badge]][npm] + +[npm-badge]: https://img.shields.io/npm/v/@coldwired/react.svg +[npm]: https://www.npmjs.com/package/@coldwired/react + +## Why? + +## Usage + +### Setup + +You need to create a react root and a plugin that you will register with your `Actions` instance. + +```ts +import { Actions } from '@coldwired/actions'; +import { createRoot, createReactPlugin } from '@coldwired/react'; + +const root = createRoot(document.getElementById('react-root'), { + loader: (name) => import(`./${name}.js`).default, +}); +const plugin = createReactPlugin({ root }); +const actions = new Actions({ element: document.body, plugins: [plugin] }); +actions.observe(); +await actions.ready(); +``` diff --git a/packages/react/package.json b/packages/react/package.json index 471aab0..21f8100 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,10 +1,8 @@ { "name": "@coldwired/react", - "description": "React container for use with @coldwired", + "description": "React support for @coldwired", "license": "MIT", - "files": [ - "dist" - ], + "files": ["dist"], "main": "./dist/index.cjs.js", "module": "./dist/index.es.js", "types": "./dist/types/index.d.ts", @@ -34,6 +32,7 @@ "@coldwired/utils": "^0.12.0" }, "devDependencies": { + "@coldwired/actions": "*", "@types/react": "^18.2.45", "@types/react-dom": "^18.2.18", "html-entities": "^2.4.0", @@ -54,24 +53,15 @@ "eslintConfig": { "root": true, "parser": "@typescript-eslint/parser", - "plugins": [ - "@typescript-eslint" - ], - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "prettier" - ], + "plugins": ["@typescript-eslint"], + "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], "rules": { "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-redeclare": "off" }, "overrides": [ { - "files": [ - "vite.config.js", - "vitest.config.ts" - ], + "files": ["vite.config.js", "vitest.config.ts"], "env": { "node": true } diff --git a/packages/actions/src/actions-container.test.tsx b/packages/react/src/actions-react-plugin.test.tsx similarity index 78% rename from packages/actions/src/actions-container.test.tsx rename to packages/react/src/actions-react-plugin.test.tsx index 60580cc..ccdc09a 100644 --- a/packages/actions/src/actions-container.test.tsx +++ b/packages/react/src/actions-react-plugin.test.tsx @@ -1,58 +1,65 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { getByText, fireEvent, waitFor } from '@testing-library/dom'; -import { StrictMode, useState } from 'react'; - -import { - NAME_ATTRIBUTE, - PROPS_ATTRIBUTE, - REACT_COMPONENT_TAG, - Manifest, - encodeProps, -} from '@coldwired/react'; - -import { Actions } from '.'; - -describe('@coldwired/actions', () => { +import { useState } from 'react'; +import { Actions } from '@coldwired/actions'; +import { encode as htmlEncode } from 'html-entities'; + +import { createRoot, createReactPlugin, defaultSchema, type Manifest, type Root } from '.'; +import type { ReactComponent } from './react-tree-builder'; + +const NAME_ATTRIBUTE = defaultSchema.nameAttribute; +const PROPS_ATTRIBUTE = defaultSchema.propsAttribute; +const REACT_COMPONENT_TAG = defaultSchema.componentTagName; +const DEFAULT_TAG_NAME = defaultSchema.fragmentTagName; + +function encodeProps(props: ReactComponent['props']): string { + return htmlEncode(JSON.stringify(props)); +} + +const Counter = ({ label }: { label?: string }) => { + const [count, setCount] = useState(0); + return ( +
+

+ {label ?? 'Count'}: {count} +

+ +
+ ); +}; +const manifest: Manifest = { Counter }; + +describe('@coldwired/react', () => { let actions: Actions; - - const DEFAULT_TAG_NAME = 'turbo-fragment'; - const Counter = ({ label }: { label?: string }) => { - const [count, setCount] = useState(0); - return ( -
-

- {label ?? 'Count'}: {count} -

- -
- ); - }; - const manifest: Manifest = { Counter }; + let root: Root; beforeEach(async () => { actions?.disconnect(); + document.body.innerHTML = `
<${DEFAULT_TAG_NAME} id="frag-1" class="loading">
Hello
`; + root = createRoot(document.getElementById('root')!, { + loader: (name) => Promise.resolve(manifest[name]), + }); + const plugin = createReactPlugin(root); actions = new Actions({ element: document.documentElement, - container: { loader: (name) => Promise.resolve(manifest[name]) }, + plugins: [plugin], }); - document.body.innerHTML = `
<${DEFAULT_TAG_NAME} id="frag-1" class="loading">
Hello
`; actions.observe(); await actions.ready(); - await actions.mount(document.getElementById('root')!, StrictMode); }); function layout(content: string) { return `
${content}
`; } - it('render with container', async () => { + it('actions plugin', async () => { expect(document.body.innerHTML).toEqual( layout( `<${DEFAULT_TAG_NAME} id="frag-1">
Hello
`, ), ); - expect(actions.container?.getCache().size).toEqual(1); + expect(root.getCache().size).toEqual(1); actions.update({ targets: '#main', @@ -109,7 +116,7 @@ describe('@coldwired/actions', () => { fragment: `<${DEFAULT_TAG_NAME} id="frag-1"><${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Counter"><${DEFAULT_TAG_NAME} id="frag-2">Test`, }); await actions.ready(); - expect(actions.container?.getCache().size).toEqual(2); + expect(root.getCache().size).toEqual(2); await waitFor(() => { expect(document.body.innerHTML).toEqual( layout( @@ -123,7 +130,7 @@ describe('@coldwired/actions', () => { fragment: `<${DEFAULT_TAG_NAME} id="frag-1"><${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Counter"><${DEFAULT_TAG_NAME} id="frag-2">Test 23`, }); await actions.ready(); - expect(actions.container?.getCache().size).toEqual(2); + expect(root.getCache().size).toEqual(2); await waitFor(() => { expect(document.body.innerHTML).toEqual( layout( @@ -143,7 +150,7 @@ describe('@coldwired/actions', () => { `<${DEFAULT_TAG_NAME} id="frag-1">

Count: 2

`, ), ); - expect(actions.container?.getCache().size).toEqual(1); + expect(root.getCache().size).toEqual(1); }); actions.update({ @@ -157,7 +164,7 @@ describe('@coldwired/actions', () => { `<${DEFAULT_TAG_NAME} id="frag-1">

Count: 2

`, ), ); - expect(actions.container?.getCache().size).toEqual(1); + expect(root.getCache().size).toEqual(1); }); actions.update({ @@ -171,12 +178,12 @@ describe('@coldwired/actions', () => { `
<${DEFAULT_TAG_NAME} id="frag-1">

Count: 2

`, ), ); - expect(actions.container?.getCache().size).toEqual(1); + expect(root.getCache().size).toEqual(1); }); actions.update({ targets: '#main', - fragment: `
<${DEFAULT_TAG_NAME} id="frag-1"><${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Counter" label="My Count">
`, + fragment: `
<${DEFAULT_TAG_NAME} id="frag-1"><${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Counter" ${PROPS_ATTRIBUTE}="${encodeProps({ label: 'My Count' })}">
`, }); await actions.ready(); await waitFor(() => { @@ -185,7 +192,7 @@ describe('@coldwired/actions', () => { `
<${DEFAULT_TAG_NAME} id="frag-1">

My Count: 2

`, ), ); - expect(actions.container?.getCache().size).toEqual(1); + expect(root.getCache().size).toEqual(1); }); fireEvent.click(getByText(document.body, 'Increment')); @@ -208,7 +215,7 @@ describe('@coldwired/actions', () => { `
<${DEFAULT_TAG_NAME} id="frag-1">

My New Count: 3

`, ), ); - expect(actions.container?.getCache().size).toEqual(1); + expect(root.getCache().size).toEqual(1); }); }); }); diff --git a/packages/react/src/container.test.tsx b/packages/react/src/container.test.tsx deleted file mode 100644 index ed6ea95..0000000 --- a/packages/react/src/container.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { useState, StrictMode } from 'react'; - -import { createContainer, NAME_ATTRIBUTE, REACT_COMPONENT_TAG, type Manifest } from '.'; - -const DEFAULT_TAG_NAME = 'turbo-fragment'; - -describe('@coldwired/react', () => { - describe('container', () => { - const Counter = () => { - const [count, setCount] = useState(0); - return ( -
-

Count: {count}

- -
- ); - }; - const manifest: Manifest = { Counter }; - - it('render simple fragment', async () => { - const container = createContainer({ - loader: (name) => Promise.resolve(manifest[name]), - fragmentTagName: DEFAULT_TAG_NAME, - loadingClassName: 'loading', - }); - document.body.innerHTML = `<${DEFAULT_TAG_NAME}>
Hello
`; - await container.mount(document.getElementById('root')!, StrictMode); - await container.render(document.body); - - expect(document.body.innerHTML).toEqual( - `<${DEFAULT_TAG_NAME}>
Hello
`, - ); - - await container.render( - document.body.firstElementChild!, - `
Hello World!
`, - ); - expect(document.body.innerHTML).toEqual( - `<${DEFAULT_TAG_NAME}>
Hello World!
`, - ); - expect(container.getCache().size).toEqual(1); - container.destroy(); - }); - - it('render fragment with component', async () => { - const container = createContainer({ - loader: (name) => Promise.resolve(manifest[name]), - fragmentTagName: DEFAULT_TAG_NAME, - loadingClassName: 'loading', - }); - document.body.innerHTML = `<${DEFAULT_TAG_NAME}><${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Counter">
`; - await container.mount(document.getElementById('root')!, StrictMode); - await container.render(document.body); - - expect(document.body.innerHTML).toEqual( - `<${DEFAULT_TAG_NAME}>

Count: 0

`, - ); - container.destroy(); - }); - }); -}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 0e78e55..b1668da 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,2 +1,3 @@ -export * from './container'; -export * from './react-tree-builder'; +export * from './root'; +export * from './plugin'; +export { hydrate, preload, createReactTree } from './react-tree-builder'; diff --git a/packages/react/src/plugin.ts b/packages/react/src/plugin.ts new file mode 100644 index 0000000..a31387d --- /dev/null +++ b/packages/react/src/plugin.ts @@ -0,0 +1,55 @@ +import type { Plugin } from '@coldwired/actions'; +import type { Root } from './root'; + +export function createReactPlugin(root: Root): Plugin { + const pending: Set> = new Set(); + return { + init(element) { + let onReady: () => void; + const ready = new Promise((resolve) => { + onReady = resolve; + }); + pending.add(ready); + const mountAndRender = async () => { + await root.mount(); + const batch = root.render(element); + if (batch.count != 0) { + await batch.done; + } + }; + mountAndRender().then(() => { + pending.delete(ready); + onReady(); + }); + }, + async ready() { + await Promise.all([...pending]); + }, + validate(element) { + if (root.contains('body' in element ? element.body : element)) { + throw new Error('Cannot apply actions inside fragment'); + } + }, + onCreateElement(element) { + const batch = root.render(element); + if (batch.count == 0) { + return false; + } + pending.add(batch.done); + batch.done.then(() => pending.delete(batch.done)); + return true; + }, + onBeforeUpdateElement(element, toElement) { + const batch = root.render(element, toElement); + if (batch.count == 0) { + return false; + } + pending.add(batch.done); + batch.done.then(() => pending.delete(batch.done)); + return true; + }, + onBeforeDestroyElement(element) { + return root.remove(element); + }, + }; +} diff --git a/packages/react/src/react-tree-builder.test.tsx b/packages/react/src/react-tree-builder.test.tsx index 3dc7b31..3465f4c 100644 --- a/packages/react/src/react-tree-builder.test.tsx +++ b/packages/react/src/react-tree-builder.test.tsx @@ -3,15 +3,25 @@ import { renderToStaticMarkup } from 'react-dom/server'; import type { ReactNode } from 'react'; import { parseHTMLFragment } from '@coldwired/utils'; import { z } from 'zod'; +import { encode as htmlEncode } from 'html-entities'; import { createReactTree, hydrate, - NAME_ATTRIBUTE, - REACT_COMPONENT_TAG, - REACT_SLOT_TAG, + preload, + defaultSchema, + type ReactComponent, } from './react-tree-builder'; +const NAME_ATTRIBUTE = defaultSchema.nameAttribute; +const PROPS_ATTRIBUTE = defaultSchema.propsAttribute; +const REACT_COMPONENT_TAG = defaultSchema.componentTagName; +const REACT_SLOT_TAG = defaultSchema.slotTagName; + +function encodeProps(props: ReactComponent['props']): string { + return htmlEncode(JSON.stringify(props)); +} + function Greeting({ firstName, lastName }: { firstName: string; lastName: string }) { return (

@@ -37,6 +47,14 @@ function FieldSet({ ); } +function Button({ children }: { children: ReactNode }) { + return ; +} + +function Box({ children }: { children: ReactNode }) { + return

{children}
; +} + describe('@coldwired/react', () => { describe('createReactTree', () => { it('render simple tree', () => { @@ -133,26 +151,30 @@ describe('@coldwired/react', () => { }); it('render component', () => { - const greeting1 = `<${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Greeting" first_name="John" last_name="Doe">`; - const greeting2 = `<${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Greeting" first-name="Greer" last-name="Pilkington">`; - const fieldset1 = `<${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="FieldSet" lang="fr" legend="Test">Hello World`; - const fieldset2 = `<${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="FieldSet" lang="en"><${REACT_SLOT_TAG} ${NAME_ATTRIBUTE}="legend">Test${greeting2}`; + const greeting1 = `<${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Greeting" ${PROPS_ATTRIBUTE}="${encodeProps({ first_name: 'John', last_name: 'Doe' })}">`; + const greeting2 = `<${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Greeting" ${PROPS_ATTRIBUTE}="${encodeProps({ ['first-name']: 'Greer', ['last-name']: 'Pilkington' })}">`; + const fieldset1 = `<${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="FieldSet" ${PROPS_ATTRIBUTE}="${encodeProps({ lang: 'fr', legend: 'Test' })}">Hello World`; + const fieldset2 = `<${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="FieldSet" ${PROPS_ATTRIBUTE}="${encodeProps({ lang: 'en' })}"><${REACT_SLOT_TAG} ${NAME_ATTRIBUTE}="legend">Test${greeting2}`; + const button = `<${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Button">Click me`; + const box = `<${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Box">${button}`; const tree = hydrate( - parseHTMLFragment(`${greeting1}
${fieldset1}${fieldset2}
`, document), + parseHTMLFragment(`${greeting1}
${fieldset1}${fieldset2}
${box}`, document), { Greeting, FieldSet, + Button, + Box, }, ); const html = renderToStaticMarkup(tree); expect(html).toBe( - '

Bonjour John Doe !

TestHello World
Test

Bonjour Greer Pilkington !

', + '

Bonjour John Doe !

TestHello World
Test

Bonjour Greer Pilkington !

', ); }); it('deserialize values', () => { - const html = `<${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Greeting" string="$$toto" int="$i42" float="$f42.1" boolt="$btrue" boolf="$bfalse" date="$D${new Date().toISOString()}" >`; + const html = `<${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Greeting" ${PROPS_ATTRIBUTE}="${encodeProps({ string: '$$toto', date: `$D${new Date().toISOString()}`, bigInt: '$n389474656382938746542635' })}" >`; const tree = hydrate(parseHTMLFragment(html, document), { Greeting }); const result = z @@ -162,11 +184,8 @@ describe('@coldwired/react', () => { .object({ props: z.object({ string: z.string(), - int: z.number(), - float: z.number(), - boolt: z.boolean(), - boolf: z.boolean(), date: z.date(), + bigInt: z.bigint(), }), }) .array(), @@ -175,12 +194,33 @@ describe('@coldwired/react', () => { .safeParse(tree); expect(result.data?.props.children[0].props).toEqual({ string: '$toto', - int: 42, - float: 42.1, - boolt: true, - boolf: false, date: expect.any(Date), + bigInt: BigInt('389474656382938746542635'), }); }); }); + + describe('preload', () => { + it('preload components', async () => { + const greeting1 = `<${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Greeting" ${PROPS_ATTRIBUTE}=${encodeProps({ first_name: 'John', last_name: 'Doe' })}>`; + const greeting2 = `<${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Greeting" ${PROPS_ATTRIBUTE}=${encodeProps({ ['first-name']: 'Greer', ['last-name']: 'Pilkington' })}>`; + const fieldset1 = `<${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="FieldSet" ${PROPS_ATTRIBUTE}=${encodeProps({ lang: 'fr', legend: 'Test' })}>Hello World`; + const fieldset2 = `<${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="FieldSet" ${PROPS_ATTRIBUTE}=${encodeProps({ lang: 'en' })}><${REACT_SLOT_TAG} ${NAME_ATTRIBUTE}="legend">Test${greeting2}`; + const button = `<${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Button">Click me`; + const box = `<${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Box">${button}`; + + const components: Record = { + Greeting, + FieldSet, + Button, + Box, + }; + + const manifest = await preload( + parseHTMLFragment(`${greeting1}
${fieldset1}${fieldset2}
${box}`, document), + async (names) => Object.fromEntries(names.map((name) => [name, components[name]])), + ); + expect(manifest).toStrictEqual(components); + }); + }); }); diff --git a/packages/react/src/react-tree-builder.ts b/packages/react/src/react-tree-builder.ts index 12dd0f0..2bf3a88 100644 --- a/packages/react/src/react-tree-builder.ts +++ b/packages/react/src/react-tree-builder.ts @@ -1,8 +1,8 @@ import type { ComponentType, ReactNode, Key } from 'react'; import { createElement, Fragment } from 'react'; -import { decode as htmlDecode, encode as htmlEncode } from 'html-entities'; +import { decode as htmlDecode } from 'html-entities'; -type Child = string | Element | Component; +type Child = string | ReactElement | ReactComponent; type PrimitiveValue = string | number | boolean | null | undefined; type JSONValue = PrimitiveValue | Array | { [key: string]: JSONValue }; type ReactValue = @@ -11,63 +11,69 @@ type ReactValue = | bigint | Array | { [key: string]: ReactValue }; -type Element = { +export type ReactElement = { tagName: string; attributes: Record; children?: Child | Child[]; }; -type Component = { +export type ReactComponent = { name: string; - props: Record; + props: Record; children?: Child | Child[]; }; export type Manifest = Record>; -export const NAME_ATTRIBUTE = '@name'; -export const PROPS_ATTRIBUTE = '@props'; -export const REACT_COMPONENT_TAG = 'react-component'; -export const REACT_SLOT_TAG = 'react-slot'; - -function isElement(node: JSONValue | Element | Component): node is Element { +function isReactElement(node: JSONValue | ReactElement | ReactComponent): node is ReactElement { return !!(node && typeof node == 'object' && 'tagName' in node && 'attributes' in node); } -function isComponent(node: JSONValue | Element | Component): node is Component { - return !!(node && typeof node == 'object' && NAME_ATTRIBUTE in node && PROPS_ATTRIBUTE in node); +function isReactComponent(node: JSONValue | ReactElement | ReactComponent): node is ReactComponent { + return !!(node && typeof node == 'object' && 'name' in node && 'props' in node); } -export type DocumentFragmentLike = DocumentFragment | { childNodes: NodeListOf }; +export type DocumentFragmentLike = { + childNodes: DocumentFragment['childNodes']; + querySelectorAll: DocumentFragment['querySelectorAll']; +}; + +export interface Schema { + componentTagName: string; + slotTagName: string; + nameAttribute: string; + propsAttribute: string; +} + +export const defaultSchema: Schema = { + componentTagName: 'turbo-component', + slotTagName: 'turbo-slot', + nameAttribute: 'name', + propsAttribute: 'props', +}; export function hydrate( documentOrFragment: Document | DocumentFragmentLike, manifest: Manifest, + schema?: Partial, ): ReactNode { + const schema_ = Object.assign({}, defaultSchema, schema); const childNodes = getChildNodes(documentOrFragment); - const { children } = hydrateChildNodes(childNodes); + const { children } = hydrateChildNodes(childNodes, schema_); return createReactTree(children, manifest); } export function preload( documentOrFragment: Document | DocumentFragmentLike, loader: (names: string[]) => Promise, + schema?: Partial, ): Promise { - const childNodes = getChildNodes(documentOrFragment); - const componentNames = [ - ...new Set( - Array.from(childNodes).flatMap((childNode) => { - if (isElementNode(childNode)) { - if (childNode.tagName.toLowerCase() == REACT_COMPONENT_TAG) { - return childNode.getAttribute(NAME_ATTRIBUTE)!; - } - return Array.from(childNode.querySelectorAll(REACT_COMPONENT_TAG)).map( - (element) => element.getAttribute(NAME_ATTRIBUTE)!, - ); - } - return []; - }), + const { componentTagName, nameAttribute } = Object.assign({}, defaultSchema, schema); + const components = documentOrFragment.querySelectorAll(componentTagName); + const componentNames = new Set( + Array.from(components).map( + (component) => (component as HTMLElement).getAttribute(nameAttribute)!, ), - ]; - return loader(componentNames); + ); + return loader([...componentNames]); } export function createReactTree(tree: Child | Child[], manifest: Manifest): ReactNode { @@ -94,34 +100,38 @@ type HydrateResult = { props: Record; }; -function hydrateChildNodes(childNodes: NodeListOf): HydrateResult { +function hydrateChildNodes(childNodes: NodeListOf, schema: Schema): HydrateResult { const result: HydrateResult = { children: [], props: {} }; childNodes.forEach((childNode) => { if (isTextNode(childNode) && childNode.textContent) { result.children.push(childNode.textContent); } else if (isElementNode(childNode)) { const tagName = childNode.tagName.toLowerCase(); - const { children, props } = hydrateChildNodes(childNode.childNodes); - if (tagName == REACT_COMPONENT_TAG) { - const name = childNode.getAttribute(NAME_ATTRIBUTE); + const { children, props } = hydrateChildNodes(childNode.childNodes, schema); + if (tagName == schema.componentTagName) { + const name = childNode.getAttribute(schema.nameAttribute); if (!name) { - throw new Error(`Missing "${NAME_ATTRIBUTE}" attribute on <${REACT_COMPONENT_TAG}>`); + throw new Error( + `Missing "${schema.nameAttribute}" attribute on <${schema.componentTagName}>`, + ); } result.children.push({ name, - props: { ...hydrateProps(childNode), ...props }, + props: { ...hydrateProps(childNode, schema.propsAttribute), ...props }, children: children.length > 0 ? children : undefined, }); } else { if (Object.keys(props).length > 0) { throw new Error( - `<${REACT_SLOT_TAG}> only allowed as direct child of <${REACT_COMPONENT_TAG}>`, + `<${schema.slotTagName}> only allowed as direct child of <${schema.componentTagName}>`, ); } - if (tagName == REACT_SLOT_TAG) { - const name = childNode.getAttribute(NAME_ATTRIBUTE); + if (tagName == schema.slotTagName) { + const name = childNode.getAttribute(schema.nameAttribute); if (!name) { - throw new Error(`Missing "${NAME_ATTRIBUTE}" attribute on <${REACT_SLOT_TAG}>`); + throw new Error( + `Missing "${schema.nameAttribute}" attribute on <${schema.slotTagName}>`, + ); } if (children.length == 1) { const child = children[0]; @@ -157,26 +167,17 @@ function hydrateChildNodes(childNodes: NodeListOf): HydrateResult { return result; } -export function encodeProps(props: Component['props']): string { - return htmlEncode(JSON.stringify(props)); -} - -export function decodeProps(props: string): Component['props'] { +function decodeProps(props: string): ReactComponent['props'] { return JSON.parse(htmlDecode(props)); } -function hydrateProps(childNode: HTMLElement): Component['props'] { - const serializedProps = childNode.getAttribute(PROPS_ATTRIBUTE); - const props = Object.fromEntries( - Array.from(childNode.attributes) - .filter((attr) => attr.name != NAME_ATTRIBUTE && attr.name != PROPS_ATTRIBUTE) - .map((attr) => [attr.name, attr.value]), - ); - return Object.assign(props, serializedProps ? decodeProps(serializedProps) : {}); +function hydrateProps(childNode: HTMLElement, propsAttribute: string): ReactComponent['props'] { + const serializedProps = childNode.getAttribute(propsAttribute); + return serializedProps ? decodeProps(serializedProps) : {}; } function createElementOrComponent( - child: Element | Component, + child: ReactElement | ReactComponent, manifest: Manifest, key?: Key, ): ReactNode { @@ -204,7 +205,7 @@ function createElementOrComponent( } const props: { [key: string]: ReactValue } = Object.fromEntries( Object.entries(child.props).map(([key, value]) => { - if (isElement(value) || isComponent(value)) { + if (isReactElement(value) || isReactComponent(value)) { return [transformPropName(key), createElementOrComponent(value, manifest)]; } return [transformPropName(key), transformPropValue(value)]; @@ -283,15 +284,6 @@ function transformStringValue(value: string): ReactValue { case 'n': // BigInt return BigInt(value.slice(2)); - case 'b': - // Boolean - return value[2] == 't'; - case 'i': - // Integer - return parseInt(value.slice(2)); - case 'f': - // Float - return parseFloat(value.slice(2)); } } return value; diff --git a/packages/react/src/root.test.tsx b/packages/react/src/root.test.tsx new file mode 100644 index 0000000..a195a70 --- /dev/null +++ b/packages/react/src/root.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { useState } from 'react'; + +import { createRoot, defaultSchema, type Manifest } from '.'; + +const NAME_ATTRIBUTE = defaultSchema.nameAttribute; +const REACT_COMPONENT_TAG = defaultSchema.componentTagName; +const DEFAULT_TAG_NAME = defaultSchema.fragmentTagName; + +const Counter = () => { + const [count, setCount] = useState(0); + return ( +
+

Count: {count}

+ +
+ ); +}; +const manifest: Manifest = { Counter }; + +describe('@coldwired/react', () => { + describe('root', () => { + it('render simple fragment', async () => { + document.body.innerHTML = `<${DEFAULT_TAG_NAME}>
Hello
`; + const root = createRoot(document.getElementById('root')!, { + loader: (name) => Promise.resolve(manifest[name]), + }); + await root.mount(); + await root.render(document.body).done; + + expect(document.body.innerHTML).toEqual( + `<${DEFAULT_TAG_NAME}>
Hello
`, + ); + + await root.render(document.body.firstElementChild!, `
Hello World!
`) + .done; + expect(document.body.innerHTML).toEqual( + `<${DEFAULT_TAG_NAME}>
Hello World!
`, + ); + expect(root.getCache().size).toEqual(1); + root.destroy(); + }); + + it('render fragment with component', async () => { + document.body.innerHTML = `<${DEFAULT_TAG_NAME}><${REACT_COMPONENT_TAG} ${NAME_ATTRIBUTE}="Counter">
`; + const root = createRoot(document.getElementById('root')!, { + loader: (name) => Promise.resolve(manifest[name]), + }); + await root.mount(); + await root.render(document.body).done; + + expect(document.body.innerHTML).toEqual( + `<${DEFAULT_TAG_NAME}>

Count: 0

`, + ); + root.destroy(); + }); + }); +}); diff --git a/packages/react/src/container.ts b/packages/react/src/root.ts similarity index 61% rename from packages/react/src/container.ts rename to packages/react/src/root.ts index 9e03ac6..35f931a 100644 --- a/packages/react/src/container.ts +++ b/packages/react/src/root.ts @@ -1,50 +1,70 @@ import { createPortal } from 'react-dom'; -import { createRoot } from 'react-dom/client'; +import { createRoot as createReactRoot, type Root as ReactRoot } from 'react-dom/client'; import { useSyncExternalStore, useEffect, createElement, Fragment, + StrictMode, type ReactNode, type ComponentType, } from 'react'; import { parseHTMLFragment, isElement } from '@coldwired/utils'; -import { hydrate, preload, type Manifest } from './react-tree-builder'; +import { + hydrate, + preload, + defaultSchema as defaultTreeBuilderSchema, + type Manifest, + type Schema as TreeBuilderSchema, + type DocumentFragmentLike, +} from './react-tree-builder'; export type Loader = (name: string) => Promise; +export type { Manifest }; + +export interface RenderBatch { + count: number; + done: Promise; +} -export interface Container { - mount(rootElement: Element, Layout?: ComponentType<{ children: ReactNode }>): Promise; - render(element: Element, fragment?: Element | DocumentFragment | string): Promise; - remove(element: Element): void; - isFragment(element: Element): boolean; - isInsideFragment(element: Element): boolean; +export interface Root { + mount(): Promise; + unmount(): void; + render(element: Element, fragment?: DocumentFragmentLike | string): RenderBatch; + remove(element: Element): boolean; + contains(element: Element): boolean; destroy(): void; getCache(): Map; } -export interface ContainerOptions { +export interface RootOptions { loader: Loader; + Layout?: ComponentType<{ children: ReactNode }>; + manifest?: Manifest; + schema?: Partial; +} + +export interface Schema extends TreeBuilderSchema { fragmentTagName: string; loadingClassName: string; - manifest?: Manifest; } -export function createContainer({ - loader, - manifest: preloadedManifest, - fragmentTagName, - loadingClassName, -}: ContainerOptions): Container { +export const defaultSchema: Schema = { + ...defaultTreeBuilderSchema, + fragmentTagName: 'turbo-fragment', + loadingClassName: 'loading', +}; + +export function createRoot(container: Element, options: RootOptions): Root { + const { loader, manifest: preloadedManifest } = options; let isDestroyed = false; let cache = new Map(); - const mounted = new Map< - Element, - (fragment: Element | DocumentFragment | string) => Promise - >(); + const mounted = new Map Promise>(); const subscriptions = new Set<() => void>(); const manifest: Manifest = Object.assign({}, preloadedManifest); + const schema = Object.assign({}, defaultSchema, options.schema); + const Layout = options.Layout ?? StrictMode; const notify = () => { if (!isDestroyed) { @@ -55,14 +75,14 @@ export function createContainer({ const render = async ( element: Element, - fragmentOrHTML: Element | DocumentFragment | string, + fragmentOrHTML: DocumentFragmentLike | string, reset: boolean, ) => { const fragment = typeof fragmentOrHTML == 'string' ? parseHTMLFragment(fragmentOrHTML, element.ownerDocument) : fragmentOrHTML; - if (isElement(fragment) && fragment.tagName.toLowerCase() != fragmentTagName) { + if (isElement(fragment) && fragment.tagName.toLowerCase() != schema.fragmentTagName) { throw new Error('Cannot rerender with a non-fragment element'); } await preload(fragment, (names) => manifestLoader(names, loader, manifest)); @@ -72,7 +92,7 @@ export function createContainer({ } cache.set(element, tree); notify(); - element.classList.remove(loadingClassName); + element.classList.remove(schema.loadingClassName); if (element.classList.length == 0) { element.removeAttribute('class'); } @@ -109,70 +129,83 @@ export function createContainer({ subscriptions.delete(callback); }; }; - let unmount: (() => void) | undefined; + let root: ReactRoot | undefined; return { - async mount(providerRootElement, Layout = ({ children }) => children) { - if (unmount) { - throw new Error('Container is already mounted'); + async mount() { + if (root) { + throw new Error('Root is already mounted'); } if (isDestroyed) { - throw new Error('Container is destroyed'); + throw new Error('Root is destroyed'); } let onMounted: () => void = () => {}; const ready = new Promise((resolve) => { onMounted = resolve; }); - unmount = createProvider({ - providerRootElement, - subscribe, - getSnapshot, - onMounted, - Layout, - }); + root = createReactRoot(container); + root.render( + createElement( + Layout, + null, + createElement(RootProvider, { subscribe, getSnapshot, onMounted }), + ), + ); await ready; }, - async render(element, fragmentOrHTML) { + unmount() { + if (root) { + root.unmount(); + root = undefined; + } + }, + render(element, fragmentOrHTML) { if (fragmentOrHTML) { - await mounted.get(element)?.(fragmentOrHTML); + const update = mounted.get(element); + if (update) { + return { count: 1, done: update(fragmentOrHTML) }; + } } else { - if (element.tagName.toLowerCase() == fragmentTagName) { - await create(element); + if (element.tagName.toLowerCase() == schema.fragmentTagName) { + return { count: 1, done: create(element) }; } else { - const elements = Array.from(element.querySelectorAll(fragmentTagName)); + const elements = Array.from(element.querySelectorAll(schema.fragmentTagName)); if (elements.length) { - await Promise.all(elements.map(create)); + return { + count: elements.length, + done: Promise.all(elements.map(create)).then(() => undefined), + }; } } } + return { count: 0, done: Promise.resolve() }; }, remove(element) { if (mounted.has(element)) { mounted.delete(element); cache.delete(element); notify(); + return true; } + return false; }, - isFragment(element) { - return mounted.has(element); - }, - isInsideFragment(element) { - return !!element.closest(fragmentTagName) && element.tagName.toLowerCase() != fragmentTagName; + contains(element) { + return ( + element.tagName.toLowerCase() != schema.fragmentTagName && + !!element.closest(schema.fragmentTagName) + ); }, destroy() { isDestroyed = true; - for (const element of cache.keys()) { - element.remove(); - } cache.clear(); notify(); subscriptions.clear(); + root?.unmount?.(); for (const name of Object.keys(manifest)) { delete manifest[name]; } mounted.clear(); - unmount?.(); }, getCache() { return cache; @@ -180,31 +213,7 @@ export function createContainer({ }; } -function createProvider({ - providerRootElement, - subscribe, - getSnapshot, - Layout, - onMounted, -}: { - providerRootElement: Element; - subscribe(callback: () => void): () => void; - getSnapshot(): Map; - Layout: ComponentType<{ children: ReactNode }>; - onMounted: () => void; -}) { - const root = createRoot(providerRootElement); - root.render( - createElement( - Layout, - null, - createElement(ContainerProvider, { subscribe, getSnapshot, onMounted }), - ), - ); - return () => root.unmount(); -} - -function ContainerProvider({ +function RootProvider({ subscribe, getSnapshot, onMounted, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4237f43..fdd073a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^2.27.1 version: 2.27.1 '@testing-library/dom': - specifier: ^10.0.0 - version: 10.0.0 + specifier: ^10.1.0 + version: 10.1.0 '@typescript-eslint/eslint-plugin': specifier: ^7.7.1 version: 7.7.1(@typescript-eslint/parser@7.7.1)(eslint@8.57.0)(typescript@5.4.5) @@ -51,11 +51,11 @@ importers: specifier: ^3.2.5 version: 3.2.5 rollup: - specifier: ^4.16.4 - version: 4.16.4 + specifier: ^4.17.1 + version: 4.17.1 turbo: - specifier: ^1.13.2 - version: 1.13.2 + specifier: ^1.13.3 + version: 1.13.3 typescript: specifier: ^5.4.5 version: 5.4.5 @@ -75,21 +75,6 @@ importers: specifier: ^2.7.2 version: 2.7.2 devDependencies: - '@coldwired/react': - specifier: ^0.12.2 - version: link:../react - '@types/react': - specifier: ^18.2.45 - version: 18.2.79 - '@types/react-dom': - specifier: ^18.2.18 - version: 18.2.25 - react: - specifier: ^18.0.0 - version: 18.2.0 - react-dom: - specifier: ^18.0.0 - version: 18.2.0(react@18.2.0) tiny-invariant: specifier: ^1.3.2 version: 1.3.2 @@ -106,6 +91,9 @@ importers: specifier: ^18.0.0 version: 18.2.0(react@18.2.0) devDependencies: + '@coldwired/actions': + specifier: '*' + version: link:../actions '@types/react': specifier: ^18.2.45 version: 18.2.79 @@ -743,128 +731,128 @@ packages: resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==} dev: true - /@rollup/rollup-android-arm-eabi@4.16.4: - resolution: {integrity: sha512-GkhjAaQ8oUTOKE4g4gsZ0u8K/IHU1+2WQSgS1TwTcYvL+sjbaQjNHFXbOJ6kgqGHIO1DfUhI/Sphi9GkRT9K+Q==} + /@rollup/rollup-android-arm-eabi@4.17.1: + resolution: {integrity: sha512-P6Wg856Ou/DLpR+O0ZLneNmrv7QpqBg+hK4wE05ijbC/t349BRfMfx+UFj5Ha3fCFopIa6iSZlpdaB4agkWp2Q==} cpu: [arm] os: [android] requiresBuild: true dev: true optional: true - /@rollup/rollup-android-arm64@4.16.4: - resolution: {integrity: sha512-Bvm6D+NPbGMQOcxvS1zUl8H7DWlywSXsphAeOnVeiZLQ+0J6Is8T7SrjGTH29KtYkiY9vld8ZnpV3G2EPbom+w==} + /@rollup/rollup-android-arm64@4.17.1: + resolution: {integrity: sha512-piwZDjuW2WiHr05djVdUkrG5JbjnGbtx8BXQchYCMfib/nhjzWoiScelZ+s5IJI7lecrwSxHCzW026MWBL+oJQ==} cpu: [arm64] os: [android] requiresBuild: true dev: true optional: true - /@rollup/rollup-darwin-arm64@4.16.4: - resolution: {integrity: sha512-i5d64MlnYBO9EkCOGe5vPR/EeDwjnKOGGdd7zKFhU5y8haKhQZTN2DgVtpODDMxUr4t2K90wTUJg7ilgND6bXw==} + /@rollup/rollup-darwin-arm64@4.17.1: + resolution: {integrity: sha512-LsZXXIsN5Q460cKDT4Y+bzoPDhBmO5DTr7wP80d+2EnYlxSgkwdPfE3hbE+Fk8dtya+8092N9srjBTJ0di8RIA==} cpu: [arm64] os: [darwin] requiresBuild: true dev: true optional: true - /@rollup/rollup-darwin-x64@4.16.4: - resolution: {integrity: sha512-WZupV1+CdUYehaZqjaFTClJI72fjJEgTXdf4NbW69I9XyvdmztUExBtcI2yIIU6hJtYvtwS6pkTkHJz+k08mAQ==} + /@rollup/rollup-darwin-x64@4.17.1: + resolution: {integrity: sha512-S7TYNQpWXB9APkxu/SLmYHezWwCoZRA9QLgrDeml+SR2A1LLPD2DBUdUlvmCF7FUpRMKvbeeWky+iizQj65Etw==} cpu: [x64] os: [darwin] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-arm-gnueabihf@4.16.4: - resolution: {integrity: sha512-ADm/xt86JUnmAfA9mBqFcRp//RVRt1ohGOYF6yL+IFCYqOBNwy5lbEK05xTsEoJq+/tJzg8ICUtS82WinJRuIw==} + /@rollup/rollup-linux-arm-gnueabihf@4.17.1: + resolution: {integrity: sha512-Lq2JR5a5jsA5um2ZoLiXXEaOagnVyCpCW7xvlcqHC7y46tLwTEgUSTM3a2TfmmTMmdqv+jknUioWXlmxYxE9Yw==} cpu: [arm] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-arm-musleabihf@4.16.4: - resolution: {integrity: sha512-tJfJaXPiFAG+Jn3cutp7mCs1ePltuAgRqdDZrzb1aeE3TktWWJ+g7xK9SNlaSUFw6IU4QgOxAY4rA+wZUT5Wfg==} + /@rollup/rollup-linux-arm-musleabihf@4.17.1: + resolution: {integrity: sha512-9BfzwyPNV0IizQoR+5HTNBGkh1KXE8BqU0DBkqMngmyFW7BfuIZyMjQ0s6igJEiPSBvT3ZcnIFohZ19OqjhDPg==} cpu: [arm] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-arm64-gnu@4.16.4: - resolution: {integrity: sha512-7dy1BzQkgYlUTapDTvK997cgi0Orh5Iu7JlZVBy1MBURk7/HSbHkzRnXZa19ozy+wwD8/SlpJnOOckuNZtJR9w==} + /@rollup/rollup-linux-arm64-gnu@4.17.1: + resolution: {integrity: sha512-e2uWaoxo/rtzA52OifrTSXTvJhAXb0XeRkz4CdHBK2KtxrFmuU/uNd544Ogkpu938BzEfvmWs8NZ8Axhw33FDw==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-arm64-musl@4.16.4: - resolution: {integrity: sha512-zsFwdUw5XLD1gQe0aoU2HVceI6NEW7q7m05wA46eUAyrkeNYExObfRFQcvA6zw8lfRc5BHtan3tBpo+kqEOxmg==} + /@rollup/rollup-linux-arm64-musl@4.17.1: + resolution: {integrity: sha512-ekggix/Bc/d/60H1Mi4YeYb/7dbal1kEDZ6sIFVAE8pUSx7PiWeEh+NWbL7bGu0X68BBIkgF3ibRJe1oFTksQQ==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-powerpc64le-gnu@4.16.4: - resolution: {integrity: sha512-p8C3NnxXooRdNrdv6dBmRTddEapfESEUflpICDNKXpHvTjRRq1J82CbU5G3XfebIZyI3B0s074JHMWD36qOW6w==} + /@rollup/rollup-linux-powerpc64le-gnu@4.17.1: + resolution: {integrity: sha512-UGV0dUo/xCv4pkr/C8KY7XLFwBNnvladt8q+VmdKrw/3RUd3rD0TptwjisvE2TTnnlENtuY4/PZuoOYRiGp8Gw==} cpu: [ppc64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-riscv64-gnu@4.16.4: - resolution: {integrity: sha512-Lh/8ckoar4s4Id2foY7jNgitTOUQczwMWNYi+Mjt0eQ9LKhr6sK477REqQkmy8YHY3Ca3A2JJVdXnfb3Rrwkng==} + /@rollup/rollup-linux-riscv64-gnu@4.17.1: + resolution: {integrity: sha512-gEYmYYHaehdvX46mwXrU49vD6Euf1Bxhq9pPb82cbUU9UT2NV+RSckQ5tKWOnNXZixKsy8/cPGtiUWqzPuAcXQ==} cpu: [riscv64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-s390x-gnu@4.16.4: - resolution: {integrity: sha512-1xwwn9ZCQYuqGmulGsTZoKrrn0z2fAur2ujE60QgyDpHmBbXbxLaQiEvzJWDrscRq43c8DnuHx3QorhMTZgisQ==} + /@rollup/rollup-linux-s390x-gnu@4.17.1: + resolution: {integrity: sha512-xeae5pMAxHFp6yX5vajInG2toST5lsCTrckSRUFwNgzYqnUjNBcQyqk1bXUxX5yhjWFl2Mnz3F8vQjl+2FRIcw==} cpu: [s390x] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-x64-gnu@4.16.4: - resolution: {integrity: sha512-LuOGGKAJ7dfRtxVnO1i3qWc6N9sh0Em/8aZ3CezixSTM+E9Oq3OvTsvC4sm6wWjzpsIlOCnZjdluINKESflJLA==} + /@rollup/rollup-linux-x64-gnu@4.17.1: + resolution: {integrity: sha512-AsdnINQoDWfKpBzCPqQWxSPdAWzSgnYbrJYtn6W0H2E9It5bZss99PiLA8CgmDRfvKygt20UpZ3xkhFlIfX9zQ==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-x64-musl@4.16.4: - resolution: {integrity: sha512-ch86i7KkJKkLybDP2AtySFTRi5fM3KXp0PnHocHuJMdZwu7BuyIKi35BE9guMlmTpwwBTB3ljHj9IQXnTCD0vA==} + /@rollup/rollup-linux-x64-musl@4.17.1: + resolution: {integrity: sha512-KoB4fyKXTR+wYENkIG3fFF+5G6N4GFvzYx8Jax8BR4vmddtuqSb5oQmYu2Uu067vT/Fod7gxeQYKupm8gAcMSQ==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-win32-arm64-msvc@4.16.4: - resolution: {integrity: sha512-Ma4PwyLfOWZWayfEsNQzTDBVW8PZ6TUUN1uFTBQbF2Chv/+sjenE86lpiEwj2FiviSmSZ4Ap4MaAfl1ciF4aSA==} + /@rollup/rollup-win32-arm64-msvc@4.17.1: + resolution: {integrity: sha512-J0d3NVNf7wBL9t4blCNat+d0PYqAx8wOoY+/9Q5cujnafbX7BmtYk3XvzkqLmFECaWvXGLuHmKj/wrILUinmQg==} cpu: [arm64] os: [win32] requiresBuild: true dev: true optional: true - /@rollup/rollup-win32-ia32-msvc@4.16.4: - resolution: {integrity: sha512-9m/ZDrQsdo/c06uOlP3W9G2ENRVzgzbSXmXHT4hwVaDQhYcRpi9bgBT0FTG9OhESxwK0WjQxYOSfv40cU+T69w==} + /@rollup/rollup-win32-ia32-msvc@4.17.1: + resolution: {integrity: sha512-xjgkWUwlq7IbgJSIxvl516FJ2iuC/7ttjsAxSPpC9kkI5iQQFHKyEN5BjbhvJ/IXIZ3yIBcW5QDlWAyrA+TFag==} cpu: [ia32] os: [win32] requiresBuild: true dev: true optional: true - /@rollup/rollup-win32-x64-msvc@4.16.4: - resolution: {integrity: sha512-YunpoOAyGLDseanENHmbFvQSfVL5BxW3k7hhy0eN4rb3gS/ct75dVD0EXOWIqFT/nE8XYW6LP6vz6ctKRi0k9A==} + /@rollup/rollup-win32-x64-msvc@4.17.1: + resolution: {integrity: sha512-0QbCkfk6cnnVKWqqlC0cUrrUMDMfu5ffvYMTUHf+qMN2uAb3MKP31LPcwiMXBNsvoFGs/kYdFOsuLmvppCopXA==} cpu: [x64] os: [win32] requiresBuild: true @@ -875,8 +863,8 @@ packages: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} dev: true - /@testing-library/dom@10.0.0: - resolution: {integrity: sha512-PmJPnogldqoVFf+EwbHvbBJ98MmqASV8kLrBYgsDNxQcFMeIS7JFL48sfyXvuMtgmWO/wMhh25odr+8VhDmn4g==} + /@testing-library/dom@10.1.0: + resolution: {integrity: sha512-wdsYKy5zupPyLCW2Je5DLHSxSfbIp6h80WoHOQc+RPtmPGA52O9x5MJEkv92Sjonpq+poOAtUKhh1kBGAXBrNA==} engines: {node: '>=18'} dependencies: '@babel/code-frame': 7.24.2 @@ -2912,6 +2900,7 @@ packages: hasBin: true dependencies: js-tokens: 4.0.0 + dev: false /loupe@2.3.7: resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} @@ -3505,6 +3494,7 @@ packages: loose-envify: 1.4.0 react: 18.2.0 scheduler: 0.23.0 + dev: false /react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -3519,6 +3509,7 @@ packages: engines: {node: '>=0.10.0'} dependencies: loose-envify: 1.4.0 + dev: false /read-pkg-up@7.0.1: resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} @@ -3656,29 +3647,29 @@ packages: glob: 7.2.3 dev: true - /rollup@4.16.4: - resolution: {integrity: sha512-kuaTJSUbz+Wsb2ATGvEknkI12XV40vIiHmLuFlejoo7HtDok/O5eDDD0UpCVY5bBX5U5RYo8wWP83H7ZsqVEnA==} + /rollup@4.17.1: + resolution: {integrity: sha512-0gG94inrUtg25sB2V/pApwiv1lUb0bQ25FPNuzO89Baa+B+c0ccaaBKM5zkZV/12pUUdH+lWCSm9wmHqyocuVQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true dependencies: '@types/estree': 1.0.5 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.16.4 - '@rollup/rollup-android-arm64': 4.16.4 - '@rollup/rollup-darwin-arm64': 4.16.4 - '@rollup/rollup-darwin-x64': 4.16.4 - '@rollup/rollup-linux-arm-gnueabihf': 4.16.4 - '@rollup/rollup-linux-arm-musleabihf': 4.16.4 - '@rollup/rollup-linux-arm64-gnu': 4.16.4 - '@rollup/rollup-linux-arm64-musl': 4.16.4 - '@rollup/rollup-linux-powerpc64le-gnu': 4.16.4 - '@rollup/rollup-linux-riscv64-gnu': 4.16.4 - '@rollup/rollup-linux-s390x-gnu': 4.16.4 - '@rollup/rollup-linux-x64-gnu': 4.16.4 - '@rollup/rollup-linux-x64-musl': 4.16.4 - '@rollup/rollup-win32-arm64-msvc': 4.16.4 - '@rollup/rollup-win32-ia32-msvc': 4.16.4 - '@rollup/rollup-win32-x64-msvc': 4.16.4 + '@rollup/rollup-android-arm-eabi': 4.17.1 + '@rollup/rollup-android-arm64': 4.17.1 + '@rollup/rollup-darwin-arm64': 4.17.1 + '@rollup/rollup-darwin-x64': 4.17.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.17.1 + '@rollup/rollup-linux-arm-musleabihf': 4.17.1 + '@rollup/rollup-linux-arm64-gnu': 4.17.1 + '@rollup/rollup-linux-arm64-musl': 4.17.1 + '@rollup/rollup-linux-powerpc64le-gnu': 4.17.1 + '@rollup/rollup-linux-riscv64-gnu': 4.17.1 + '@rollup/rollup-linux-s390x-gnu': 4.17.1 + '@rollup/rollup-linux-x64-gnu': 4.17.1 + '@rollup/rollup-linux-x64-musl': 4.17.1 + '@rollup/rollup-win32-arm64-msvc': 4.17.1 + '@rollup/rollup-win32-ia32-msvc': 4.17.1 + '@rollup/rollup-win32-x64-msvc': 4.17.1 fsevents: 2.3.3 dev: true @@ -3715,6 +3706,7 @@ packages: resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} dependencies: loose-envify: 1.4.0 + dev: false /semver@5.7.1: resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==} @@ -4130,64 +4122,64 @@ packages: engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} dev: true - /turbo-darwin-64@1.13.2: - resolution: {integrity: sha512-CCSuD8CfmtncpohCuIgq7eAzUas0IwSbHfI8/Q3vKObTdXyN8vAo01gwqXjDGpzG9bTEVedD0GmLbD23dR0MLA==} + /turbo-darwin-64@1.13.3: + resolution: {integrity: sha512-glup8Qx1qEFB5jerAnXbS8WrL92OKyMmg5Hnd4PleLljAeYmx+cmmnsmLT7tpaVZIN58EAAwu8wHC6kIIqhbWA==} cpu: [x64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-darwin-arm64@1.13.2: - resolution: {integrity: sha512-0HySm06/D2N91rJJ89FbiI/AodmY8B3WDSFTVEpu2+8spUw7hOJ8okWOT0e5iGlyayUP9gr31eOeL3VFZkpfCw==} + /turbo-darwin-arm64@1.13.3: + resolution: {integrity: sha512-/np2xD+f/+9qY8BVtuOQXRq5f9LehCFxamiQnwdqWm5iZmdjygC5T3uVSYuagVFsZKMvX3ycySwh8dylGTl6lg==} cpu: [arm64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-linux-64@1.13.2: - resolution: {integrity: sha512-7HnibgbqZrjn4lcfIouzlPu8ZHSBtURG4c7Bedu7WJUDeZo+RE1crlrQm8wuwO54S0siYqUqo7GNHxu4IXbioQ==} + /turbo-linux-64@1.13.3: + resolution: {integrity: sha512-G+HGrau54iAnbXLfl+N/PynqpDwi/uDzb6iM9hXEDG+yJnSJxaHMShhOkXYJPk9offm9prH33Khx2scXrYVW1g==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-linux-arm64@1.13.2: - resolution: {integrity: sha512-sUq4dbpk6SNKg/Hkwn256Vj2AEYSQdG96repio894h5/LEfauIK2QYiC/xxAeW3WBMc6BngmvNyURIg7ltrePg==} + /turbo-linux-arm64@1.13.3: + resolution: {integrity: sha512-qWwEl5VR02NqRyl68/3pwp3c/olZuSp+vwlwrunuoNTm6JXGLG5pTeme4zoHNnk0qn4cCX7DFrOboArlYxv0wQ==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-windows-64@1.13.2: - resolution: {integrity: sha512-DqzhcrciWq3dpzllJR2VVIyOhSlXYCo4mNEWl98DJ3FZ08PEzcI3ceudlH6F0t/nIcfSItK1bDP39cs7YoZHEA==} + /turbo-windows-64@1.13.3: + resolution: {integrity: sha512-Nudr4bRChfJzBPzEmpVV85VwUYRCGKecwkBFpbp2a4NtrJ3+UP1VZES653ckqCu2FRyRuS0n03v9euMbAvzH+Q==} cpu: [x64] os: [win32] requiresBuild: true dev: true optional: true - /turbo-windows-arm64@1.13.2: - resolution: {integrity: sha512-WnPMrwfCXxK69CdDfS1/j2DlzcKxSmycgDAqV0XCYpK/812KB0KlvsVAt5PjEbZGXkY88pCJ1BLZHAjF5FcbqA==} + /turbo-windows-arm64@1.13.3: + resolution: {integrity: sha512-ouJCgsVLd3icjRLmRvHQDDZnmGzT64GBupM1Y+TjtYn2LVaEBoV6hicFy8x5DUpnqdLy+YpCzRMkWlwhmkX7sQ==} cpu: [arm64] os: [win32] requiresBuild: true dev: true optional: true - /turbo@1.13.2: - resolution: {integrity: sha512-rX/d9f4MgRT3yK6cERPAkfavIxbpBZowDQpgvkYwGMGDQ0Nvw1nc0NVjruE76GrzXQqoxR1UpnmEP54vBARFHQ==} + /turbo@1.13.3: + resolution: {integrity: sha512-n17HJv4F4CpsYTvKzUJhLbyewbXjq1oLCi90i5tW1TiWDz16ML1eDG7wi5dHaKxzh5efIM56SITnuVbMq5dk4g==} hasBin: true optionalDependencies: - turbo-darwin-64: 1.13.2 - turbo-darwin-arm64: 1.13.2 - turbo-linux-64: 1.13.2 - turbo-linux-arm64: 1.13.2 - turbo-windows-64: 1.13.2 - turbo-windows-arm64: 1.13.2 + turbo-darwin-64: 1.13.3 + turbo-darwin-arm64: 1.13.3 + turbo-linux-64: 1.13.3 + turbo-linux-arm64: 1.13.3 + turbo-windows-64: 1.13.3 + turbo-windows-arm64: 1.13.3 dev: true /type-check@0.4.0: @@ -4368,7 +4360,7 @@ packages: dependencies: esbuild: 0.20.2 postcss: 8.4.38 - rollup: 4.16.4 + rollup: 4.17.1 optionalDependencies: fsevents: 2.3.3 dev: true