undici icon indicating copy to clipboard operation
undici copied to clipboard

Caching

Open ronag opened this issue 2 years ago • 8 comments

I started working on a caching dispatcher + handler. Thought I'd post it here in case anyone is interested in working on something like that.

We only needed to cache redirects so I kind of stopped there.

import stream from 'node:stream'
import { LRUCache } from 'lru-cache'
import cacheControlParser from 'cache-control-parser'

class CacheHandler {
  constructor({ key, handler, store }) {
    this.entry = null
    this.key = key
    this.handler = handler
    this.store = store
    this.abort = null
    this.resume = null
  }

  onConnect(abort) {
    this.abort = abort

    return this.handler.onConnect(abort)
  }

  onHeaders(statusCode, rawHeaders, resume, statusMessage) {
    this.resume = resume

    // TODO (fix): Check if content-length fits in cache...

    let cacheControl
    for (let n = 0; n < rawHeaders.length; n += 2) {
      if (
        rawHeaders[n].length === 'cache-control'.length &&
        rawHeaders[n].toString().toLowerCase() === 'cache-control'
      ) {
        cacheControl = cacheControlParser.parse(rawHeaders[n + 1].toString())
        break
      }
    }

    if (
      cacheControl &&
      cacheControl.public &&
      !cacheControl.private &&
      !cacheControl['no-store'] &&
      // TODO (fix): Support all cache control directives...
      // !opts.headers['no-transform'] &&
      !cacheControl['no-cache'] &&
      !cacheControl['must-understand'] &&
      !cacheControl['must-revalidate'] &&
      !cacheControl['proxy-revalidate']
    ) {
      const maxAge = cacheControl['s-max-age'] ?? cacheControl['max-age']
      const ttl = cacheControl.immutable
        ? 31556952 // 1 year
        : Number(maxAge)

      if (ttl > 0) {
        this.entry = this.store.create(this.key, ttl * 1e3)
        this.entry.statusCode = statusCode
        this.entry.statusMessage = statusMessage
        this.entry.rawHeaders = rawHeaders
        this.entry.on('drain', resume)
        this.entry.on('error', this.abort)
      }
    }

    return this.handler.onHeaders(statusCode, rawHeaders, resume, statusMessage)
  }

  onData(chunk) {
    let ret = true

    if (this.entry) {
      this.entry.size += chunk.bodyLength
      if (this.entry.size > this.store.maxEntrySize) {
        this.entry.destroy()
        this.entry = null
      } else {
        ret = this.entry.write(chunk)
      }
    }

    return this.handler.onData(chunk) !== false && ret !== false
  }

  onComplete(rawTrailers) {
    if (this.entry) {
      this.entry.rawTrailers = rawTrailers
      this.entry.end()
    }

    return this.handler.onComplete(rawTrailers)
  }

  onError(err) {
    if (this.entry) {
      this.entry.destroy(err)
    }

    return this.handler.onError(err)
  }
}

// TODO (fix): Filsystem backed cache...
class CacheStore {
  constructor({ maxSize, maxEntrySize }) {
    this.maxSize = maxSize
    this.maxEntrySize = maxEntrySize
    this.cache = new LRUCache({
      maxSize,
      sizeCalculation: (value) => value.body.byteLength,
    })
  }

  create (key, ttl) {
    const entry = Object.assign(new stream.PassThrough(), {
      statusCode: null,
      statusMessage: null,
      rawHeaders: null,
      rawTrailers: null,
      size: 0,
    }).on('finish', () => {
      this.cache.set(key, entry, ttl)
    })
    return entry
  }

  get(key, callback) {
    callback(null, this.cache.get(key))
  }
}

export class CacheDispatcher {
  constructor(dispatcher, { maxSize = 0, maxEntrySize = maxSize / 10 }) {
    this.dispatcher = dispatcher
    this.store = new CacheStore({ maxSize, maxEntrySize })
  }

  dispatch(opts, handler) {
    if (opts.headers?.['cache-control'] || opts.headers?.authorization) {
      // TODO (fix): Support all cache control directives...
      // const cacheControl = cacheControlParser.parse(opts.headers['cache-control'])
      // cacheControl['no-cache']
      // cacheControl['no-store']
      // cacheControl['max-age']
      // cacheControl['max-stale']
      // cacheControl['min-fresh']
      // cacheControl['no-transform']
      // cacheControl['only-if-cached']
      this.dispatcher.dispatch(opts, handler)
      return
    }

    // TODO (fix): Support all methods?
    if (opts.method !== 'GET' && opts.method !== 'HEAD') {
      this.dispatcher.dispatch(opts, handler)
      return
    }

    // TODO (fix): Support body?
    opts.body.resume()

    // TODO (fix): How to generate key?
    const key = `${opts.method}:${opts.path}`

    this.store.get(key, (err, value) => {
      if (err) {
        // TODO (fix): How to handle cache errors?
        this.handler.onError(err)
      } else if (value) {
        const { statusCode, statusMessage, rawHeaders, rawTrailers } = value
        const ac = new AbortController()
        const signal = ac.signal

        let _resume = null
        const resume = () => {
          _resume?.(null)
          _resume = null
        }
        const abort = () => {
          ac.abort()
          resume()
        }

        try {
          handler.onConnect(abort)
          signal.throwIfAborted()
          handler.onHeaders(statusCode, rawHeaders, resume, statusMessage)
          signal.throwIfAborted()

          stream.pipeline(
            value,
            new stream.Writable({
              signal,
              write(chunk, encoding, callback) {
                try {
                  if (handler.onData(chunk) === false) {
                    _resume = callback
                  } else {
                    callback(null)
                  }
                } catch (err) {
                  callback(err)
                }
              },
            }),
            (err) => {
              if (err) {
                handler.onError(err)
              } else {
                handler.onComplete(rawTrailers)
              }
            }
          )
        } catch (err) {
          handler.onError(err)
        }
      } else {
        this.dispatcher.dispatch(opts, new CacheHandler({ handler, store: this.store, key }))
      }
    })
  }
}

ronag avatar Sep 08 '23 14:09 ronag

Hope you find this helpful https://github.com/radix-ui/primitives/issues/1836. It is also in the Dialog docs

mmounirf avatar Nov 15 '23 00:11 mmounirf

Hope you find this helpful https://ui.shadcn.com/docs/components/dialog. It is also in the Dialog docs

the dialog works within the dropdown menu, but keeps the dropdown menu open when the dialoog opens.

Steveb599 avatar Nov 15 '23 00:11 Steveb599

If you checked the previous issue, you would have found one of the comments containing an example that covers exactly what you're looking for. Here's a link for the comment including a sandbox link https://github.com/radix-ui/primitives/issues/1836#issuecomment-1433597185

mmounirf avatar Nov 15 '23 01:11 mmounirf

If nothing works use alert-dialog

tejas-gk avatar Nov 23 '23 11:11 tejas-gk

If you checked the previous issue, you would have found one of the comments containing an example that covers exactly what you're looking for. Here's a link for the comment including a sandbox link radix-ui/primitives#1836 (comment)

I tried this but it doesnt work for me, it does not open modal and after clicking on the modal trigger the dropdown does not open anymore. This is the code:

interface Props {
  userId: string;
  escortId: string;
}

interface DialogItemProps {
  onSelect?: () => void;
  onOpenChange?: (open: boolean) => void;
}

const DropDownMenu = ({ userId, escortId }: Props) => {
  const [dropdownOpen, setDropdownOpen] = useState(false);
  const [hasOpenDialog, setHasOpenDialog] = useState(false);
  const dropdownTriggerRef: RefObject<HTMLButtonElement> = useRef(null);
  const focusRef = useRef<HTMLButtonElement | null>(null);

  const handleDialogItemSelect = () => {
    focusRef.current = dropdownTriggerRef.current;
  };

  const handleDialogItemOpenChange = (open: boolean) => {
    setHasOpenDialog(open);
    if (open === false) {
      setDropdownOpen(false);
    }
  };

  const DialogItem = forwardRef<HTMLDivElement, DialogItemProps>(
    ({ onSelect, onOpenChange, ...itemProps }, forwardedRef) => {
      return (
        <Dialog onOpenChange={onOpenChange}>
          <DialogTrigger asChild>
            <DropdownMenuItem
              {...itemProps}
              ref={forwardedRef}
              className="justify-start cursor-pointer"
              onSelect={(ev: Event) => {
                ev.preventDefault();
                onSelect && onSelect();
              }}
            >
              remove user
            </DropdownMenuItem>
          </DialogTrigger>
          <DialogContent>
            <DialogHeader>
              <DialogTitle>
               confirm remove user
              </DialogTitle>
            </DialogHeader>
            <DialogFooter>
              <Button
                className="rounded"
                type="submit"
                onClick={() => removeUser(userId)}
              >
                Remove User
              </Button>
            </DialogFooter>
          </DialogContent>
        </Dialog>
      );
    }
  );

  return (
    <DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
      <DropdownMenuTrigger asChild>
        <Button
          variant="ghost"
          className="w-8 h-8 p-0"
          ref={dropdownTriggerRef}
        >
          <span className="sr-only">open menu</span>
          <FiMoreHorizontal className="w-4 h-4" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent
        hidden={hasOpenDialog}
        align="center"
        onCloseAutoFocus={(ev) => {
          if (focusRef.current) {
            focusRef?.current?.focus();
            focusRef.current = null;
            ev.preventDefault();
          }
        }}
      >
        <DropdownMenuSeparator />
        <DropdownMenuItem className="justify-start cursor-pointer">
          <Link href={`/user/${userId}`}>
            user profile
          </Link>
        </DropdownMenuItem>
        <DialogItem
          onSelect={handleDialogItemSelect}
          onOpenChange={handleDialogItemOpenChange}
        />
      </DropdownMenuContent>
    </DropdownMenu>
  );
};

export default DropDownMenu;

Steveb599 avatar Nov 26 '23 11:11 Steveb599

I have the same issue. i can open the dialog, but when i close it. it freezes the whole screen. The example on Shadcn doesnt seem to work

sspoth avatar Dec 01 '23 01:12 sspoth

https://github.com/radix-ui/primitives/issues/1836#issuecomment-1674338372 it working

toy-crane avatar Dec 26 '23 13:12 toy-crane

If you checked the previous issue, you would have found one of the comments containing an example that covers exactly what you're looking for. Here's a link for the comment including a sandbox link radix-ui/primitives#1836 (comment)

I tried this but it doesnt work for me, it does not open modal and after clicking on the modal trigger the dropdown does not open anymore. This is the code:

interface Props {
  userId: string;
  escortId: string;
}

interface DialogItemProps {
  onSelect?: () => void;
  onOpenChange?: (open: boolean) => void;
}

const DropDownMenu = ({ userId, escortId }: Props) => {
  const [dropdownOpen, setDropdownOpen] = useState(false);
  const [hasOpenDialog, setHasOpenDialog] = useState(false);
  const dropdownTriggerRef: RefObject<HTMLButtonElement> = useRef(null);
  const focusRef = useRef<HTMLButtonElement | null>(null);

  const handleDialogItemSelect = () => {
    focusRef.current = dropdownTriggerRef.current;
  };

  const handleDialogItemOpenChange = (open: boolean) => {
    setHasOpenDialog(open);
    if (open === false) {
      setDropdownOpen(false);
    }
  };

  const DialogItem = forwardRef<HTMLDivElement, DialogItemProps>(
    ({ onSelect, onOpenChange, ...itemProps }, forwardedRef) => {
      return (
        <Dialog onOpenChange={onOpenChange}>
          <DialogTrigger asChild>
            <DropdownMenuItem
              {...itemProps}
              ref={forwardedRef}
              className="justify-start cursor-pointer"
              onSelect={(ev: Event) => {
                ev.preventDefault();
                onSelect && onSelect();
              }}
            >
              remove user
            </DropdownMenuItem>
          </DialogTrigger>
          <DialogContent>
            <DialogHeader>
              <DialogTitle>
               confirm remove user
              </DialogTitle>
            </DialogHeader>
            <DialogFooter>
              <Button
                className="rounded"
                type="submit"
                onClick={() => removeUser(userId)}
              >
                Remove User
              </Button>
            </DialogFooter>
          </DialogContent>
        </Dialog>
      );
    }
  );

  return (
    <DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
      <DropdownMenuTrigger asChild>
        <Button
          variant="ghost"
          className="w-8 h-8 p-0"
          ref={dropdownTriggerRef}
        >
          <span className="sr-only">open menu</span>
          <FiMoreHorizontal className="w-4 h-4" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent
        hidden={hasOpenDialog}
        align="center"
        onCloseAutoFocus={(ev) => {
          if (focusRef.current) {
            focusRef?.current?.focus();
            focusRef.current = null;
            ev.preventDefault();
          }
        }}
      >
        <DropdownMenuSeparator />
        <DropdownMenuItem className="justify-start cursor-pointer">
          <Link href={`/user/${userId}`}>
            user profile
          </Link>
        </DropdownMenuItem>
        <DialogItem
          onSelect={handleDialogItemSelect}
          onOpenChange={handleDialogItemOpenChange}
        />
      </DropdownMenuContent>
    </DropdownMenu>
  );
};

export default DropDownMenu;

if (open === false) { setDropdownOpen(false); } delete this line, it can achieve open results. But i don`t konw how to hide DropdownMenu componet.

zenoncao avatar Jan 17 '24 04:01 zenoncao

@Steveb599 - did you manage to find a flexible solution in the end?

I even tried setting modal={false} on <DropdownMenu modal={false}> in conjunction with removing the onSelect prop callback from the menu item from another issue as a supposed solution, but that instantly opened and closed the modal, even though it closed the dropdown menu, which is of course the worst case scenario and not a working suggestion (in my case).

Also, the other suggested solutions of placing the <Dialog> component outside of and wrapping the <DropdownMenu> is really not ideal as that would only work for single dialogs within a single dropdown menu, and i would rather not add multiple inelegant states to manage multiple dialogs..

Sayvai avatar Jan 19 '24 17:01 Sayvai

This issue has been automatically closed because it received no activity for a while. If you think it was closed by accident, please leave a comment. Thank you.

shadcn avatar Feb 12 '24 23:02 shadcn