about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorbptato <nincsnevem662@gmail.com>2024-07-22 21:07:54 +0200
committerbptato <nincsnevem662@gmail.com>2024-07-22 21:07:54 +0200
commit98c6b86b3f2d66a67d5fc25ff78d8a5a228f28a6 (patch)
tree2a8de456757cfe238341e7d7c5723d8d7acf1eb6
parent814b7b000af414696da6f5d8613fd21a1e104ef4 (diff)
downloadchawan-98c6b86b3f2d66a67d5fc25ff78d8a5a228f28a6.tar.gz
buffer, dom, event: JS binding for dispatchEvent
* move dispatchEvent to event, add a JS binding
* only reshape if the document was actually invalidated after event
  dispatch/interval call/etc.
-rw-r--r--src/html/catom.nim3
-rw-r--r--src/html/dom.nim144
-rw-r--r--src/html/event.nim52
-rw-r--r--src/html/script.nim17
-rw-r--r--src/html/xmlhttprequest.nim4
-rw-r--r--src/server/buffer.nim48
6 files changed, 152 insertions, 116 deletions
diff --git a/src/html/catom.nim b/src/html/catom.nim
index b6dced60..f22d74ef 100644
--- a/src/html/catom.nim
+++ b/src/html/catom.nim
@@ -197,8 +197,7 @@ func toStaticAtom*(factory: CAtomFactory; atom: CAtom): StaticAtom =
     return StaticAtom(i)
   return atUnknown
 
-var getFactory* {.compileTime.}: proc(ctx: JSContext): CAtomFactory {.nimcall,
-  noSideEffect.}
+var getFactory*: proc(ctx: JSContext): CAtomFactory {.nimcall, noSideEffect.}
 
 proc toAtom*(ctx: JSContext; atom: StaticAtom): CAtom =
   return ctx.getFactory().toAtom(atom)
diff --git a/src/html/dom.nim b/src/html/dom.nim
index 43e078a6..d1b8d249 100644
--- a/src/html/dom.nim
+++ b/src/html/dom.nim
@@ -187,6 +187,7 @@ type
     renderBlockingElements: seq[Element]
 
     invalidCollections: HashSet[pointer] # pointers to Collection objects
+    invalid*: bool # whether the document must be rendered again
 
     cachedAll: HTMLAllCollection
     cachedSheets: seq[CSSStylesheet]
@@ -2437,62 +2438,36 @@ func findAutoFocus*(document: Document): Element =
   return nil
 
 # Forward declaration hack
-isDefaultPassive = func(eventTarget: EventTarget): bool =
-  if eventTarget of Window:
+isDefaultPassive = func(target: EventTarget): bool =
+  if target of Window:
     return true
-  if not (eventTarget of Node):
+  if not (target of Node):
     return false
-  let node = Node(eventTarget)
-  return EventTarget(node.document) == eventTarget or
-    EventTarget(node.document.html) == eventTarget or
-    EventTarget(node.document.body) == eventTarget
+  let node = Node(target)
+  return EventTarget(node.document) == target or
+    EventTarget(node.document.html) == target or
+    EventTarget(node.document.body) == target
+
+getParent = proc(ctx: JSContext; eventTarget: EventTarget; event: Event):
+    EventTarget =
+  if eventTarget of Node:
+    if eventTarget of Document:
+      if event.ctype == ctx.toAtom(satLoad):
+        return nil
+      # if no browsing context, then window will be nil anyway
+      return Document(eventTarget).window
+    return Node(eventTarget).parentNode
+  return nil
 
 getFactory = proc(ctx: JSContext): CAtomFactory =
   return ctx.getGlobal().factory
 
-type DispatchEventResult = tuple
-  called: bool
-  canceled: bool
-
-proc dispatchEvent0(window: Window; event: Event; currentTarget: EventTarget;
-    called, stop, canceled: var bool) =
-  let ctx = window.jsctx
-  event.currentTarget = currentTarget
-  var els = currentTarget.eventListeners # copy intentionally
-  for el in els:
-    if JS_IsUndefined(el.callback):
-      continue # removed, presumably by a previous handler
-    if el.ctype == event.ctype:
-      let e = ctx.invoke(el, event)
-      called = true
-      if JS_IsException(e):
-        window.console.error(ctx.getExceptionMsg())
-      JS_FreeValue(ctx, e)
-      if efStopImmediatePropagation in event.flags:
-        stop = true
-        break
-      if efStopPropagation in event.flags:
-        stop = true
-      if efCanceled in event.flags:
-        canceled = true
-
-proc dispatchEvent*(window: Window; event: Event; target: EventTarget):
-    DispatchEventResult =
-  #TODO this is far from being compliant
-  var called = false
-  var canceled = false
-  var stop = false
-  window.dispatchEvent0(event, target, called, stop, canceled)
-  if not stop and target of Node:
-    for a in Node(target).nodeAncestors:
-      window.dispatchEvent0(event, a, called, stop, canceled)
-      if stop:
-        break
-  return (called, canceled)
+windowConsoleError = proc(ctx: JSContext; ss: varargs[string]) =
+  ctx.getGlobal().console.error(ss)
 
 proc fireEvent*(window: Window; name: StaticAtom; target: EventTarget) =
   let event = newEvent(window.toAtom(name), target)
-  discard window.dispatchEvent(event, target)
+  discard window.jsctx.dispatchEvent(target, event)
 
 proc parseColor(element: Element; s: string): ARGBColor =
   let cval = parseComponentValue(s)
@@ -2841,6 +2816,11 @@ proc invalidateCollections(node: Node) =
   for id in node.liveCollections:
     node.document.invalidCollections.incl(id)
 
+proc setInvalid*(element: Element) =
+  element.invalid = true
+  if element.document != nil:
+    element.document.invalid = true
+
 proc delAttr(element: Element; i: int; keep = false) =
   let map = element.attributesInternal
   let name = element.attrs[i].qualifiedName
@@ -2866,7 +2846,7 @@ proc delAttr(element: Element; i: int; keep = false) =
       map.attrlist.del(j) # ordering does not matter
   element.reflectAttr(name, none(string))
   element.invalidateCollections()
-  element.invalid = true
+  element.setInvalid()
 
 proc newCSSStyleDeclaration(element: Element; value: string):
     CSSStyleDeclaration =
@@ -3084,7 +3064,7 @@ proc loadResource(window: Window; image: HTMLImageElement) =
           cachedURL.bmp = bmp
           for share in cachedURL.shared:
             share.bitmap = bmp
-          image.invalid = true
+          image.setInvalid()
         )
       )
     window.loadingResourcePromises.add(p)
@@ -3224,7 +3204,7 @@ proc attr*(element: Element; name: CAtom; value: string) =
   if i >= 0:
     element.attrs[i].value = value
     element.invalidateCollections()
-    element.invalid = true
+    element.setInvalid()
   else:
     element.attrs.insert(AttrData(
       qualifiedName: name,
@@ -3255,7 +3235,7 @@ proc attrns*(element: Element; localName: CAtom; prefix: NamespacePrefix;
     element.attrs[i].qualifiedName = qualifiedName
     element.attrs[i].value = value
     element.invalidateCollections()
-    element.invalid = true
+    element.setInvalid()
   else:
     element.attrs.insert(AttrData(
       prefix: prefixAtom,
@@ -3399,7 +3379,9 @@ proc remove*(node: Node; suppressObservers: bool) =
     parent.childList[i] = parent.childList[i + 1]
     parent.childList[i].index = i
   parent.childList.setLen(parent.childList.len - 1)
-  node.parentNode.invalidateCollections()
+  parent.invalidateCollections()
+  if parent of Element:
+    Element(parent).setInvalid()
   node.parentNode = nil
   node.index = -1
   if node.document != nil and (node of HTMLStyleElement or
@@ -3438,7 +3420,7 @@ proc resetElement*(element: Element) =
       input.file = nil
     else:
       input.value = input.attr(satValue)
-    input.invalid = true
+    input.setInvalid()
   of TAG_SELECT:
     let select = HTMLSelectElement(element)
     if not select.attrb(satMultiple):
@@ -3464,7 +3446,7 @@ proc resetElement*(element: Element) =
   of TAG_TEXTAREA:
     let textarea = HTMLTextAreaElement(element)
     textarea.value = textarea.childTextContent()
-    textarea.invalid = true
+    textarea.setInvalid()
   else: discard
 
 proc setForm*(element: FormAssociatedElement; form: HTMLFormElement) =
@@ -3632,7 +3614,7 @@ proc insert*(parent, node, before: Node; suppressObservers = false) =
     #TODO live ranges
     discard
   if parent of Element:
-    Element(parent).invalid = true
+    Element(parent).setInvalid()
   for node in nodes:
     insertNode(parent, node, before)
 
@@ -3744,14 +3726,14 @@ proc textContent*(node: Node; data: Option[string]) {.jsfset.} =
 proc reset*(form: HTMLFormElement) =
   for control in form.controls:
     control.resetElement()
-    control.invalid = true
+    control.setInvalid()
 
 proc renderBlocking*(element: Element): bool =
   if "render" in element.attr(satBlocking).split(AsciiWhitespace):
     return true
   if element of HTMLScriptElement:
     let element = HTMLScriptElement(element)
-    if element.ctype == CLASSIC and element.parserDocument != nil and
+    if element.ctype == stClassic and element.parserDocument != nil and
         not element.attrb(satAsync) and not element.attrb(satDefer):
       return true
   return false
@@ -3786,14 +3768,14 @@ proc fetchClassicScript(element: HTMLScriptElement; url: URL;
     onComplete: OnCompleteProc) =
   let window = element.document.window
   if not element.scriptingEnabled:
-    element.onComplete(ScriptResult(t: RESULT_NULL))
+    element.onComplete(ScriptResult(t: srtNull))
     return
   let request = createPotentialCORSRequest(url, rdScript, cors)
   request.client = some(window.settings)
   #TODO make this non-blocking somehow
   let response = window.loader.doRequest(request.request)
   if response.res != 0:
-    element.onComplete(ScriptResult(t: RESULT_NULL))
+    element.onComplete(ScriptResult(t: srtNull))
     return
   window.loader.resume(response.outputId)
   let s = response.body.recvAll()
@@ -3801,7 +3783,7 @@ proc fetchClassicScript(element: HTMLScriptElement; url: URL;
   let source = s.decodeAll(cs)
   response.body.sclose()
   let script = window.jsctx.createClassicScript(source, url, options, false)
-  element.onComplete(ScriptResult(t: RESULT_SCRIPT, script: script))
+  element.onComplete(ScriptResult(t: srtScript, script: script))
 
 #TODO settings object
 proc fetchDescendantsAndLink(element: HTMLScriptElement; script: Script;
@@ -3815,7 +3797,7 @@ proc fetchExternalModuleGraph(element: HTMLScriptElement; url: URL;
     options: ScriptOptions; onComplete: OnCompleteProc) =
   let window = element.document.window
   if not element.scriptingEnabled:
-    element.onComplete(ScriptResult(t: RESULT_NULL))
+    element.onComplete(ScriptResult(t: srtNull))
     return
   window.importMapsAllowed = false
   element.fetchSingleModule(
@@ -3825,7 +3807,7 @@ proc fetchExternalModuleGraph(element: HTMLScriptElement; url: URL;
     parseURL("about:client").get,
     isTopLevel = true,
     onComplete = proc(element: HTMLScriptElement; res: ScriptResult) =
-      if res.t == RESULT_NULL:
+      if res.t == srtNull:
         element.onComplete(res)
       else:
         element.fetchDescendantsAndLink(res.script, rdScript, onComplete)
@@ -3846,7 +3828,7 @@ proc fetchSingleModule(element: HTMLScriptElement; url: URL;
   let settings = element.document.window.settings
   let i = settings.moduleMap.find(url, moduleType)
   if i != -1:
-    if settings.moduleMap[i].value.t == RESULT_FETCHING:
+    if settings.moduleMap[i].value.t == srtFetching:
       #TODO await value
       assert false
     element.onComplete(settings.moduleMap[i].value)
@@ -3878,14 +3860,14 @@ proc execute*(element: HTMLScriptElement) =
   #assert element.scriptResult != nil
   if element.scriptResult == nil:
     return
-  if element.scriptResult.t == RESULT_NULL:
+  if element.scriptResult.t == srtNull:
     #TODO fire error event
     return
-  let needsInc = element.external or element.ctype == MODULE
+  let needsInc = element.external or element.ctype == stModule
   if needsInc:
     inc document.ignoreDestructiveWrites
   case element.ctype
-  of CLASSIC:
+  of stClassic:
     let oldCurrentScript = document.currentScript
     #TODO not if shadow root
     document.currentScript = element
@@ -3929,11 +3911,11 @@ proc prepare*(element: HTMLScriptElement) =
   else:
     "text/javascript"
   if typeString.isJavaScriptType():
-    element.ctype = CLASSIC
+    element.ctype = stClassic
   elif typeString == "module":
-    element.ctype = MODULE
+    element.ctype = stModule
   elif typeString == "importmap":
-    element.ctype = IMPORTMAP
+    element.ctype = stImportMap
   else:
     return
   if parserDocument != nil:
@@ -3946,10 +3928,10 @@ proc prepare*(element: HTMLScriptElement) =
     return
   if not element.scriptingEnabled:
     return
-  if element.attrb(satNomodule) and element.ctype == CLASSIC:
+  if element.attrb(satNomodule) and element.ctype == stClassic:
     return
   #TODO content security policy
-  if element.ctype == CLASSIC and element.attrb(satEvent) and
+  if element.ctype == stClassic and element.attrb(satEvent) and
       element.attrb(satFor):
     let f = element.attr(satFor).strip(chars = AsciiWhitespace)
     let event = element.attr(satEvent).strip(chars = AsciiWhitespace)
@@ -3973,7 +3955,7 @@ proc prepare*(element: HTMLScriptElement) =
   )
   #TODO settings object
   if element.attrb(satSrc):
-    if element.ctype == IMPORTMAP:
+    if element.ctype == stImportMap:
       #TODO fire error event
       return
     let src = element.attr(satSrc)
@@ -3990,22 +3972,22 @@ proc prepare*(element: HTMLScriptElement) =
     element.delayingTheLoadEvent = true
     if element in element.document.renderBlockingElements:
       options.renderBlocking = true
-    if element.ctype == CLASSIC:
+    if element.ctype == stClassic:
       element.fetchClassicScript(url.get, options, classicCORS, encoding,
         markAsReady)
     else:
       element.fetchExternalModuleGraph(url.get, options, markAsReady)
   else:
     let baseURL = element.document.baseURL
-    if element.ctype == CLASSIC:
+    if element.ctype == stClassic:
       let ctx = element.document.window.jsctx
       let script = ctx.createClassicScript(sourceText, baseURL, options)
-      element.markAsReady(ScriptResult(t: RESULT_SCRIPT, script: script))
+      element.markAsReady(ScriptResult(t: srtScript, script: script))
     else:
-      #TODO MODULE, IMPORTMAP
-      element.markAsReady(ScriptResult(t: RESULT_NULL))
-  if element.ctype == CLASSIC and element.attrb(satSrc) or
-      element.ctype == MODULE:
+      #TODO stModule, stImportMap
+      element.markAsReady(ScriptResult(t: srtNull))
+  if element.ctype == stClassic and element.attrb(satSrc) or
+      element.ctype == stModule:
     let prepdoc = element.preparationTimeDocument
     if element.attrb(satAsync):
       prepdoc.scriptsToExecSoon.add(element)
@@ -4026,7 +4008,7 @@ proc prepare*(element: HTMLScriptElement) =
             script.execute()
             prepdoc.scriptsToExecInOrder.shrink(1)
       )
-    elif element.ctype == MODULE or element.attrb(satDefer):
+    elif element.ctype == stModule or element.attrb(satDefer):
       element.parserDocument.scriptsToExecOnLoad.addFirst(element)
       element.onReady = (proc() =
         element.readyForParserExec = true
@@ -4038,7 +4020,7 @@ proc prepare*(element: HTMLScriptElement) =
         element.readyForParserExec = true
       )
   else:
-    #TODO if CLASSIC, parserDocument != nil, parserDocument has a style sheet
+    #TODO if stClassic, parserDocument != nil, parserDocument has a style sheet
     # that is blocking scripts, either the parser is an XML parser or a HTML
     # parser with a script level <= 1
     element.execute()
diff --git a/src/html/event.nim b/src/html/event.nim
index d396f3ba..04b7fb67 100644
--- a/src/html/event.nim
+++ b/src/html/event.nim
@@ -3,6 +3,8 @@ import std/options
 import std/times
 
 import html/catom
+import html/script
+import js/domexception
 import monoucha/fromjs
 import monoucha/javascript
 import monoucha/jserror
@@ -64,7 +66,10 @@ jsDestructor(CustomEvent)
 jsDestructor(EventTarget)
 
 # Forward declaration hack
-var isDefaultPassive*: proc(eventTarget: EventTarget): bool {.nimcall.} = nil
+var isDefaultPassive*: proc(target: EventTarget): bool {.nimcall,
+  noSideEffect.} = nil
+var getParent*: proc(ctx: JSContext; target: EventTarget, event: Event):
+  EventTarget {.nimcall.}
 
 type
   EventInit* = object of JSDict
@@ -192,8 +197,7 @@ proc findEventListener(eventTarget: EventTarget; ctype: CAtom;
   return -1
 
 # EventListener
-proc invoke*(ctx: JSContext; listener: EventListener; event: Event):
-    JSValue =
+proc invoke(ctx: JSContext; listener: EventListener; event: Event): JSValue =
   #TODO make this standards compliant
   if JS_IsNull(listener.callback):
     return JS_UNDEFINED
@@ -290,6 +294,48 @@ proc removeEventListener(ctx: JSContext; eventTarget: EventTarget;
   if i != -1:
     eventTarget.removeAnEventListener(ctx, i)
 
+proc dispatchEvent0(ctx: JSContext; event: Event; currentTarget: EventTarget;
+    stop, canceled: var bool) =
+  event.currentTarget = currentTarget
+  var els = currentTarget.eventListeners # copy intentionally
+  for el in els:
+    if JS_IsUndefined(el.callback):
+      continue # removed, presumably by a previous handler
+    if el.ctype == event.ctype:
+      let e = ctx.invoke(el, event)
+      if JS_IsException(e):
+        ctx.logException()
+      JS_FreeValue(ctx, e)
+      if efStopImmediatePropagation in event.flags:
+        stop = true
+        break
+      if efStopPropagation in event.flags:
+        stop = true
+      if efCanceled in event.flags:
+        canceled = true
+
+proc dispatch*(ctx: JSContext; target: EventTarget; event: Event): bool =
+  #TODO this is far from being compliant
+  var canceled = false
+  var stop = false
+  event.flags.incl(efDispatch)
+  var target = target
+  while target != nil and not stop:
+    ctx.dispatchEvent0(event, target, stop, canceled)
+    target = ctx.getParent(target, event)
+  event.flags.excl(efDispatch)
+  return canceled
+
+proc dispatchEvent*(ctx: JSContext; this: EventTarget; event: Event):
+    DOMResult[bool] {.jsfunc.} =
+  if efDispatch in event.flags:
+    return errDOMException("Event's dispatch flag is already set",
+      "InvalidStateError")
+  if efInitialized notin event.flags:
+    return errDOMException("Event is not initialized", "InvalidStateError")
+  event.isTrusted = false
+  return ok(ctx.dispatch(this, event))
+
 proc addEventModule*(ctx: JSContext) =
   let eventCID = ctx.registerType(Event)
   ctx.registerType(CustomEvent, parent = eventCID)
diff --git a/src/html/script.nim b/src/html/script.nim
index 59ada2f0..ab445516 100644
--- a/src/html/script.nim
+++ b/src/html/script.nim
@@ -7,10 +7,10 @@ type
     pmParserInserted, pmNotParserInserted
 
   ScriptType* = enum
-    NO_SCRIPTTYPE, CLASSIC, MODULE, IMPORTMAP
+    stNone, stClassic, stModule, stImportMap
 
   ScriptResultType* = enum
-    RESULT_NULL, RESULT_SCRIPT, RESULT_IMPORT_MAP_PARSE, RESULT_FETCHING
+    srtNull, srtScript, srtImportMapParse, srtFetching
 
   RequestDestination* = enum
     rdNone = ""
@@ -64,13 +64,11 @@ type
 
   ScriptResult* = ref object
     case t*: ScriptResultType
-    of RESULT_NULL:
+    of srtNull, srtFetching:
       discard
-    of RESULT_SCRIPT:
+    of srtScript:
       script*: Script
-    of RESULT_FETCHING:
-      discard
-    of RESULT_IMPORT_MAP_PARSE:
+    of srtImportMapParse:
       discard #TODO
 
   ModuleMapEntry = object
@@ -93,3 +91,8 @@ func fetchDestinationFromModuleType*(default: RequestDestination;
   if moduleType == "css":
     return rdStyle
   return default
+
+var windowConsoleError*: proc(ctx: JSContext; ss: varargs[string]) {.nimcall.}
+
+proc logException*(ctx: JSContext) =
+  windowConsoleError(ctx, ctx.getExceptionMsg())
diff --git a/src/html/xmlhttprequest.nim b/src/html/xmlhttprequest.nim
index 2ab6b88d..1fc2e8d7 100644
--- a/src/html/xmlhttprequest.nim
+++ b/src/html/xmlhttprequest.nim
@@ -164,10 +164,10 @@ proc fireProgressEvent(window: Window; target: EventTarget; name: StaticAtom;
     total: length,
     lengthComputable: length != 0
   ))
-  discard window.dispatchEvent(event, target)
+  discard window.jsctx.dispatch(target, event)
 
 # Forward declaration hack
-var windowFetch* {.compileTime.}: proc(window: Window; input: JSRequest;
+var windowFetch*: proc(window: Window; input: JSRequest;
   init = none(RequestInit)): JSResult[FetchPromise] {.nimcall.} = nil
 
 proc errorSteps(window: Window; this: XMLHttpRequest; name: StaticAtom) =
diff --git a/src/server/buffer.nim b/src/server/buffer.nim
index 3864d9c6..984ed826 100644
--- a/src/server/buffer.nim
+++ b/src/server/buffer.nim
@@ -719,6 +719,11 @@ proc do_reshape(buffer: Buffer) =
     buffer.images)
   buffer.prevStyled = styledRoot
 
+proc maybeReshape(buffer: Buffer) =
+  if buffer.document != nil and buffer.document.invalid:
+    buffer.do_reshape()
+    buffer.document.invalid = false
+
 proc processData0(buffer: Buffer; data: UnsafeSlice): bool =
   if buffer.ishtml:
     if buffer.htmlParser.parseBuffer(data.toOpenArray()) == PRES_STOP:
@@ -736,7 +741,7 @@ proc processData0(buffer: Buffer; data: UnsafeSlice): bool =
         Text(lastChild).data &= data
       else:
         plaintext.insert(buffer.document.createTextNode($data), nil)
-      plaintext.invalid = true
+      plaintext.setInvalid()
   true
 
 func canSwitch(buffer: Buffer): bool {.inline.} =
@@ -990,16 +995,14 @@ proc clone*(buffer: Buffer; newurl: URL): int {.proxy.} =
 proc dispatchDOMContentLoadedEvent(buffer: Buffer) =
   let window = buffer.window
   let event = newEvent(window.toAtom(satDOMContentLoaded), buffer.document)
-  let (called, _) = window.dispatchEvent(event, buffer.document)
-  if called:
-    buffer.do_reshape()
+  discard window.jsctx.dispatch(buffer.document, event)
+  buffer.maybeReshape()
 
 proc dispatchLoadEvent(buffer: Buffer) =
   let window = buffer.window
   let event = newEvent(window.toAtom(satLoad), window)
-  let (called, _) = window.dispatchEvent(event, window)
-  if called:
-    buffer.do_reshape()
+  discard window.jsctx.dispatch(window, event)
+  buffer.maybeReshape()
 
 proc finishLoad(buffer: Buffer): EmptyPromise =
   if buffer.state != bsLoadingPage:
@@ -1320,20 +1323,20 @@ proc readSuccess*(buffer: Buffer; s: string; hasFd: bool): ReadSuccessResult
       case input.inputType
       of itFile:
         input.file = newWebFile(s, fd)
-        input.invalid = true
+        input.setInvalid()
         buffer.do_reshape()
         res.repaint = true
         res.open = buffer.implicitSubmit(input)
       else:
         input.value = s
-        input.invalid = true
+        input.setInvalid()
         buffer.do_reshape()
         res.repaint = true
         res.open = buffer.implicitSubmit(input)
     of TAG_TEXTAREA:
       let textarea = HTMLTextAreaElement(buffer.document.focus)
       textarea.value = s
-      textarea.invalid = true
+      textarea.setInvalid()
       buffer.do_reshape()
       res.repaint = true
     else: discard
@@ -1473,15 +1476,15 @@ proc click(buffer: Buffer; input: HTMLInputElement): ClickResult =
     )
   of itCheckbox:
     input.setChecked(not input.checked)
-    input.invalid = true
+    input.setInvalid()
     buffer.do_reshape()
     return ClickResult(repaint: true)
   of itRadio:
     for radio in input.radiogroup:
       radio.setChecked(false)
-      radio.invalid = true
+      radio.setInvalid()
     input.setChecked(true)
-    input.invalid = true
+    input.setInvalid()
     buffer.do_reshape()
     return ClickResult(repaint: true)
   of itReset:
@@ -1531,24 +1534,27 @@ proc click(buffer: Buffer; clickable: Element): ClickResult =
     return ClickResult(repaint: buffer.restoreFocus())
 
 proc click*(buffer: Buffer; cursorx, cursory: int): ClickResult {.proxy.} =
-  if buffer.lines.len <= cursory: return
-  var called = false
+  if buffer.lines.len <= cursory: return ClickResult()
+  var repaint = false
   var canceled = false
   let clickable = buffer.getCursorClickable(cursorx, cursory)
   if buffer.config.scripting:
     let element = buffer.getCursorElement(cursorx, cursory)
     if element != nil:
-      let event = newEvent(buffer.window.toAtom(satClick), element)
-      (called, canceled) = buffer.window.dispatchEvent(event, element)
-      if called:
+      let window = buffer.window
+      let event = newEvent(window.toAtom(satClick), element)
+      canceled = window.jsctx.dispatch(element, event)
+      if buffer.document.invalid:
         buffer.do_reshape()
+        buffer.document.invalid = false
+        repaint = true
   if not canceled:
     if clickable != nil:
       var res = buffer.click(clickable)
-      if called: # override repaint
+      if repaint: # override
         res.repaint = true
       return res
-  return ClickResult(repaint: called)
+  return ClickResult(repaint: repaint)
 
 proc select*(buffer: Buffer; selected: seq[int]): ClickResult {.proxy.} =
   if buffer.document.focus != nil and
@@ -1795,7 +1801,7 @@ proc runBuffer(buffer: Buffer) =
         let r = buffer.window.timeouts.runTimeoutFd(event.fd)
         assert r
         buffer.window.runJSJobs()
-        buffer.do_reshape()
+        buffer.maybeReshape()
     buffer.loader.unregistered.setLen(0)
 
 proc cleanup(buffer: Buffer) =