Skip to content

Commit c4964ef

Browse files
committed
WIP all previous component tests passing
1 parent 2586f53 commit c4964ef

File tree

16 files changed

+4155
-20
lines changed

16 files changed

+4155
-20
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ reference/
44
.tmp/
55

66
.env
7+
8+
/reference
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './lib/jsx.ts'
2+
export { Fragment } from './lib/component.ts'
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import type { EventListeners } from '@remix-run/interaction'
2+
3+
export type Task = (signal: AbortSignal) => void
4+
5+
export interface Handle<C = Record<string, never>> {
6+
/**
7+
* Stable identifier per component instance. Useful for HTML APIs like
8+
* htmlFor, aria-owns, etc. so consumers don't have to supply an id.
9+
*/
10+
id: string
11+
12+
/**
13+
* Set and get values in an element tree for indirect ancestor/descendant
14+
* communication.
15+
*/
16+
context: Context<C>
17+
18+
/**
19+
* Schedules an update for the component to render again.
20+
*
21+
* @param task A render task to run after the update completes
22+
*/
23+
update(task?: Task): void
24+
25+
/**
26+
* Schedules a task to run after the next update.
27+
*
28+
* @param task
29+
*/
30+
queueTask(task: Task): void
31+
32+
/**
33+
* Raises an error the closest Catch boundary. Useful when running outside
34+
* of a framework-controlled scope (ie outside of rendering or events).
35+
*
36+
* @param error The raised error
37+
*
38+
* @example
39+
* ```tsx
40+
* this.raise(new Error("Oops"))
41+
* ```
42+
*/
43+
raise(error: unknown): void
44+
45+
/**
46+
* The component's closest frame
47+
*/
48+
frame: FrameHandle
49+
50+
/**
51+
* A signal indicating the connected status of the component. When the
52+
* component is disconnected from the tree the signal will be aborted.
53+
* Useful for setup scope cleanup.
54+
*
55+
* @example Clear a timer
56+
* ```ts
57+
* function Clock() {
58+
* let interval = setInterval(() => {
59+
* if (this.signal.aborted) {
60+
* clearInterval(interval)
61+
* return
62+
* }
63+
* this.render()
64+
* }, 1000)
65+
* return () => <span>{new Date().toString()}</span>
66+
* }
67+
* ```
68+
*
69+
* Because signals are event targets, you can also add an event instead.
70+
* ```ts
71+
* function Clock() {
72+
* let interval = setInterval(this.render)
73+
* this.signal.addEventListener("abort", () => clearInterval(interval))
74+
* return () => <span>{new Date().toString()}</span>
75+
* }
76+
* ```
77+
*
78+
* @discussion
79+
* You don't need to check both this.signal and a render/event signal as
80+
* render/event signals are aborted when the component disconnects
81+
*/
82+
signal: AbortSignal
83+
}
84+
85+
/**
86+
* Default Handle context so types must be declared explicitly.
87+
*/
88+
export type NoContext = Record<string, never>
89+
90+
export type Component<
91+
Context = NoContext,
92+
SetupProps = Remix.ElementProps,
93+
RenderProps = Remix.ElementProps,
94+
> = (this: Handle<Context>, props: SetupProps) => Remix.Node | ((props: RenderProps) => Remix.Node)
95+
96+
export type ContextFrom<ComponentType> =
97+
ComponentType extends Component<infer Provided, any, any>
98+
? Provided
99+
: ComponentType extends (this: Handle<infer Provided>, ...args: any[]) => any
100+
? Provided
101+
: never
102+
103+
export interface Context<C> {
104+
set(values: C): void
105+
get<ComponentType>(component: ComponentType): ContextFrom<ComponentType>
106+
get(component: Remix.ElementType | symbol): unknown | undefined
107+
}
108+
109+
// export type FrameContent = RemixElement | Element | DocumentFragment | ReadableStream | string
110+
export type FrameContent = DocumentFragment | string
111+
112+
export type FrameHandle = EventTarget & {
113+
reload(): Promise<void>
114+
replace(content: FrameContent): Promise<void>
115+
}
116+
117+
export interface FrameProps {
118+
name?: string
119+
src: string
120+
fallback?: Remix.Renderable
121+
on?: EventListeners
122+
}
123+
124+
export type ComponentProps<T> = T extends {
125+
(props: infer Setup): infer R
126+
}
127+
? R extends (props: infer Render) => any
128+
? Setup & Render
129+
: Setup
130+
: never
131+
132+
export interface CatchProps {
133+
children?: Remix.Node
134+
fallback?: Remix.Node | ((error: Error) => Remix.Node)
135+
}
136+
137+
export interface FragmentProps {
138+
children?: Remix.Node
139+
}
140+
141+
export interface BuiltinElements {
142+
Catch: CatchProps
143+
Fragment: FragmentProps
144+
Frame: FrameProps
145+
}
146+
147+
export type Key = string | number | bigint
148+
149+
type ComponentConfig = {
150+
id: string
151+
type: Function
152+
frame: FrameHandle
153+
raise: (error: unknown) => void
154+
getContext: (type: Component) => unknown
155+
}
156+
157+
export type ComponentHandle = ReturnType<typeof createComponent>
158+
159+
export function createComponent<C = NoContext>(config: ComponentConfig) {
160+
let taskQueue: Task[] = []
161+
let renderCtrl = new AbortController()
162+
let connectedCtrl = new AbortController()
163+
let contextValue: C | undefined = undefined
164+
165+
let getContent: null | ((props: Remix.ElementProps) => Remix.Node) = null
166+
let scheduleUpdate: (task?: Task) => void = () => {
167+
throw new Error('scheduleUpdate not implemented')
168+
}
169+
170+
let context: Context<C> = {
171+
set: (value: C) => {
172+
contextValue = value
173+
},
174+
get: (type: Component) => {
175+
return config.getContext(type)
176+
},
177+
}
178+
179+
let handle: Handle<C> = {
180+
id: config.id,
181+
update: (task?: Task) => {
182+
if (task) taskQueue.push(task)
183+
scheduleUpdate()
184+
},
185+
queueTask: (task: Task) => {
186+
taskQueue.push(task)
187+
},
188+
raise: config.raise,
189+
frame: config.frame,
190+
context: context,
191+
signal: connectedCtrl.signal,
192+
}
193+
194+
function dequeueTasks() {
195+
return taskQueue.splice(0, taskQueue.length).map((task) => task.bind(handle, renderCtrl.signal))
196+
}
197+
198+
function render(props: Remix.ElementProps): [Remix.Node, Array<() => void>] {
199+
if (connectedCtrl.signal.aborted) {
200+
console.warn('render called after component was removed, potential application memory leak')
201+
return [null, []]
202+
}
203+
204+
renderCtrl.abort()
205+
renderCtrl = new AbortController()
206+
207+
if (!getContent) {
208+
let result = config.type.call(handle, props)
209+
if (typeof result === 'function') {
210+
getContent = (props) => result.call(handle, props, renderCtrl.signal)
211+
} else {
212+
getContent = (props) => config.type.call(handle, props)
213+
}
214+
}
215+
216+
let node = getContent(props)
217+
return [node, dequeueTasks()]
218+
}
219+
220+
function remove(): (() => void)[] {
221+
connectedCtrl.abort()
222+
return dequeueTasks()
223+
}
224+
225+
function setScheduleUpdate(_scheduleUpdate: (task?: Task) => void) {
226+
scheduleUpdate = _scheduleUpdate
227+
}
228+
229+
function getContextValue(): C | undefined {
230+
return contextValue
231+
}
232+
233+
return { render, remove, setScheduleUpdate, frame: config.frame, getContextValue }
234+
}
235+
236+
export function Frame(this: Handle<FrameHandle>, _: FrameProps) {
237+
return null // reconciler renders
238+
}
239+
240+
export function Fragment(_: FragmentProps) {
241+
return null // reconciler renders
242+
}
243+
244+
export function Catch(_: CatchProps) {
245+
return null // reconciler renders
246+
}
247+
248+
export function createFrameHandle(
249+
def?: Partial<{
250+
src: string
251+
replace: FrameHandle['replace']
252+
reload: FrameHandle['reload']
253+
}>,
254+
): FrameHandle {
255+
return Object.assign(
256+
new EventTarget(),
257+
{
258+
src: '/',
259+
replace: notImplemented('replace not implemented'),
260+
reload: notImplemented('reload not implemented'),
261+
},
262+
def,
263+
)
264+
}
265+
266+
function notImplemented(msg: string) {
267+
return (): never => {
268+
throw new Error(msg)
269+
}
270+
}

packages/component/src/lib/dom.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { StyleProps } from './style/lib/style'
2+
13
/**
24
* Adapted from Preact:
35
* - Source: https://github.com/preactjs/preact/blob/eee0c6ef834534498e433f0f7a3ef679efd24380/src/dom.d.ts
@@ -8,19 +10,6 @@ type Booleanish = boolean | 'true' | 'false'
810

911
export type Trackable<T> = T
1012

11-
export type DOMCSSProperties = {
12-
[key in keyof Omit<
13-
CSSStyleDeclaration,
14-
'item' | 'setProperty' | 'removeProperty' | 'getPropertyValue' | 'getPropertyPriority'
15-
>]?: string | number | null | undefined
16-
}
17-
export type AllCSSProperties = {
18-
[key: string]: string | number | null | undefined
19-
}
20-
export interface CSSProperties extends AllCSSProperties, DOMCSSProperties {
21-
cssText?: string | null
22-
}
23-
2413
export interface SVGProps<eventTarget extends EventTarget = SVGElement>
2514
extends HTMLProps<eventTarget> {
2615
accentHeight?: Trackable<number | string | undefined>
@@ -966,7 +955,7 @@ export interface AllHTMLProps<eventTarget extends EventTarget = EventTarget>
966955
srcObject?: Trackable<MediaStream | MediaSource | Blob | File | null>
967956
start?: Trackable<number | undefined>
968957
step?: Trackable<number | string | undefined>
969-
style?: Trackable<string | CSSProperties | undefined>
958+
style?: Trackable<string | StyleProps | undefined>
970959
summary?: Trackable<string | undefined>
971960
tabIndex?: Trackable<number | undefined>
972961
tabindex?: Trackable<number | undefined>
@@ -1053,7 +1042,7 @@ export interface HTMLProps<eventTarget extends EventTarget = EventTarget>
10531042
popover?: Trackable<'auto' | 'hint' | 'manual' | boolean | undefined>
10541043
slot?: Trackable<string | undefined>
10551044
spellcheck?: Trackable<boolean | undefined>
1056-
style?: Trackable<string | CSSProperties | undefined>
1045+
style?: Trackable<string | StyleProps | undefined>
10571046
tabindex?: Trackable<number | undefined>
10581047
tabIndex?: Trackable<number | undefined>
10591048
title?: Trackable<string | undefined>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Use this when framework code is incorrect, indicating an internal bug rather
3+
* than application code error.
4+
*/
5+
export function invariant(assertion: any, message?: string): asserts assertion {
6+
let prefix = 'Framework invariant'
7+
if (assertion) return
8+
throw new Error(message ? `${prefix}: ${message}` : prefix)
9+
}
10+
11+
/**
12+
* Use this when application logic is incorrect, indicating a developer error.
13+
*
14+
* Using ID-based warnings with external documentation links allows us to:
15+
* - Update warning messages without releasing new versions
16+
* - Avoid bloating the library with warning messages or complicating builds
17+
* with prod/dev build/export shenanigans
18+
* - Provide detailed troubleshooting guides and examples
19+
*
20+
* `id` is first so we can easily grep the codebase for ensure calls
21+
*/
22+
export function ensure(id: number, assertion: boolean): asserts assertion {
23+
if (assertion) return
24+
throw new Error(`REMIX_${id}: https://rmx.as/w/${id}`)
25+
}

packages/component/src/lib/remix-types.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
import type { EventListeners } from '@remix-run/interaction'
2+
import type { CSSProps } from './style/lib/style'
3+
import type {
4+
VirtualRoot as VirtualRoot_,
5+
VirtualRootOptions as VirtualRootOptions_,
6+
} from './vdom.ts'
27

38
declare global {
49
namespace Remix {
@@ -45,7 +50,7 @@ declare global {
4550
export interface HostProps<eventTarget extends EventTarget> {
4651
children?: Node
4752
on?: EventListeners<eventTarget> | undefined
48-
css?: 'TODO: support css properties' | undefined
53+
css?: CSSProps
4954
connect?: 'TODO: support connect' | undefined
5055
}
5156

@@ -58,11 +63,14 @@ declare global {
5863
* }
5964
*
6065
* @example
61-
* function Button({ on = [], ...rest }: Props<"button">) {
62-
* // on is always EventDescriptor[] here
63-
* return <button {...rest} on={[...on, dom.click(handler)]} />
66+
* function Button({ on, ...rest }: Props<"button">) {
67+
* return <button {...rest} on={{ ...on, click: handler }} />
6468
* }
6569
*/
6670
export type Props<T extends keyof JSX.IntrinsicElements> = JSX.IntrinsicElements[T]
71+
72+
export type VirtualRoot = VirtualRoot_
73+
74+
export type VirtualRootOptions = VirtualRootOptions_
6775
}
6876
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export type {
2+
CSSProps as EnhancedStyleProperties,
3+
DOMStyleProperties as StyleProperties,
4+
} from './lib/style.ts'
5+
export { processStyle } from './lib/style.ts'
6+
export { createStyleManager } from './lib/stylesheet.ts'

0 commit comments

Comments
 (0)